> ## Documentation Index
> Fetch the complete documentation index at: https://offthepace.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Off the Pace-Tyre Degradation Predictors (v12)

> Five XGBoost models predicting cumulative 5-lap tyre-degradation pace loss (quantile trio p10/p50/p90), laps-until-cliff class, and remaining stint life, from per-lap thermal, dirty-air, powertrain, weather and compound-prior features.

Five XGBoost models predicting cumulative 5-lap tyre-degradation pace loss (quantile trio p10/p50/p90), laps-until-cliff class, and remaining stint life, from per-lap thermal, dirty-air, powertrain, weather and compound-prior features. Trained on 2018–2024 F1 laps; every model beats a strong per-cohort baseline.

> Conceptual foundation: [Methodology](/decomposition/methodology) · feature source: [`fct_cliff_prediction_features`](/reference/models/fct/fct_cliff_prediction_features).

## Intended use

Race-strategy analysis and the Off the Pace web app (application layer): surfacing when a stint is about to fall off the degradation cliff and how much pace a driver is leaving on track. Not a betting or safety system.

## Models & headline performance

Every model is evaluated against a strong per-cohort baseline; all five beat it. Validation: season-grouped TimeSeriesSplit (expanding window, n\_splits=5); whole seasons move together, the final fold validates on 2024.

| Model                       | Kind           | Metric                        | CV     | Eval   | Baseline | Beats | Of attainable           | Interval               |
| --------------------------- | -------------- | ----------------------------- | ------ | ------ | -------- | ----- | ----------------------- | ---------------------- |
| `degradation_regressor_p10` | quantile       | pinball (↓ lower better)      | 0.5466 | 0.4765 | 0.7337   | ✅     | **28.6× stint-level** ⚑ | ✅ p=0.0043 (5/5 folds) |
| `degradation_regressor_p50` | quantile       | pinball (↓ lower better)      | 1.0941 | 0.9824 | 1.2629   | ✅     | **15.5× stint-level** ⚑ | ✅ p=0.0012 (5/5 folds) |
| `degradation_regressor_p90` | quantile       | pinball (↓ lower better)      | 0.6027 | 0.5129 | 0.6427   | ✅     | **14.7× stint-level** ⚑ | ✅ p=0.0005 (5/5 folds) |
| `cliff_classifier`          | classification | macro\_f1 (↑ higher better)   | 0.3537 | 0.3525 | 0.2067   | ✅     | 65.6%                   | ✅ p=0.0001 (5/5 folds) |
| `stint_life_regressor`      | survival       | aft\_nloglik (↓ lower better) | 2.1553 | 1.9913 | 2.1887   | ✅     | 61.5% of absolute       | ✅ p=0.0152 (5/5 folds) |

*Eval headline on the cv\_final\_fold (season 2024); CV is the season-grouped TimeSeriesSplit mean.*

***Of attainable** is the column to read.* A pinball of 0.20 or a macro-F1 of 0.40 says nothing on its own, because neither is a ratio against anything reachable — 1.0 is not a target any model of this data could hit. The column divides each headline's improvement over an uninformed floor by the improvement a predictor with perfect stint-level knowledge could achieve.

A ⚑ marks a model that scores **past** that ceiling. It is not an error and not a broken metric: laps inside a stint share a compound, a car, a circuit, a fuel load and a driver, so a stint-level ceiling bounds only predictors that are constant within a stint. Clearing it is proof that the model is using within-stint variation — and that no ceiling has yet been established for it.

## Per-model detail

### `degradation_regressor_p10`

* **Family / kind:** degradation\_regressor / quantile
* **Objective:** `reg:quantileerror` (α=0.1)
* **Train rows:** 81,619 · **fit:** 18.34s
* **Artefacts:** `ml/models/degradation_regressor_p10_v12.bst` + parity-tested `ml/models/degradation_regressor_p10_v12.onnx`

<details>
  <summary>Hyperparameters</summary>

  ```json theme={null}
  {
    "colsample_bytree": 0.8054380728542049,
    "gamma": 0.2622291627294689,
    "learning_rate": 0.02634959910109639,
    "max_depth": 7,
    "min_child_weight": 19,
    "n_estimators": 400,
    "reg_alpha": 0.022200081003767785,
    "reg_lambda": 0.90897974836996,
    "subsample": 0.7145777126911342
  }
  ```
</details>

### `degradation_regressor_p50`

* **Family / kind:** degradation\_regressor / quantile
* **Objective:** `reg:quantileerror` (α=0.5)
* **Train rows:** 81,619 · **fit:** 33.37s
* **Artefacts:** `ml/models/degradation_regressor_p50_v12.bst` + parity-tested `ml/models/degradation_regressor_p50_v12.onnx`

<details>
  <summary>Hyperparameters</summary>

  ```json theme={null}
  {
    "colsample_bytree": 0.6623180491757136,
    "gamma": 0.021527555949566145,
    "learning_rate": 0.026514967108554287,
    "max_depth": 8,
    "min_child_weight": 20,
    "n_estimators": 600,
    "reg_alpha": 0.00885101983500777,
    "reg_lambda": 1.1911895677946351,
    "subsample": 0.9587957555910661
  }
  ```
</details>

### `degradation_regressor_p90`

* **Family / kind:** degradation\_regressor / quantile
* **Objective:** `reg:quantileerror` (α=0.9)
* **Train rows:** 81,619 · **fit:** 14.98s
* **Artefacts:** `ml/models/degradation_regressor_p90_v12.bst` + parity-tested `ml/models/degradation_regressor_p90_v12.onnx`

<details>
  <summary>Hyperparameters</summary>

  ```json theme={null}
  {
    "colsample_bytree": 0.6844966183589957,
    "gamma": 0.15557268873435742,
    "learning_rate": 0.03983092242170753,
    "max_depth": 6,
    "min_child_weight": 20,
    "n_estimators": 300,
    "reg_alpha": 0.007678036164169761,
    "reg_lambda": 0.012341043920582167,
    "subsample": 0.9585397528578296
  }
  ```
</details>

### `cliff_classifier`

* **Family / kind:** cliff\_classifier / classification
* **Objective:** `multi:softprob`
* **Train rows:** 113,226 · **fit:** 88.9s
* **Artefacts:** `ml/models/cliff_classifier_v12.bst` + parity-tested `ml/models/cliff_classifier_v12.onnx`

<details>
  <summary>Hyperparameters</summary>

  ```json theme={null}
  {
    "colsample_bytree": 0.9549024450259136,
    "gamma": 1.0541966575833017,
    "learning_rate": 0.02411763832244701,
    "max_depth": 8,
    "min_child_weight": 3,
    "n_estimators": 700,
    "reg_alpha": 1.3579191574010774,
    "reg_lambda": 0.06566006916248184,
    "subsample": 0.6238327095852775
  }
  ```
</details>

### `stint_life_regressor`

* **Family / kind:** stint\_life\_regressor / survival
* **Objective:** `survival:aft`
* **Train rows:** 119,822 · **fit:** 9.74s
* **Artefacts:** `ml/models/stint_life_regressor_v12.bst` + parity-tested `ml/models/stint_life_regressor_v12.onnx`

<details>
  <summary>Hyperparameters</summary>

  ```json theme={null}
  {
    "aft_loss_distribution_scale": 0.8,
    "colsample_bytree": 0.849893616236758,
    "gamma": 0.0013846541929909116,
    "learning_rate": 0.026307490821070707,
    "max_depth": 8,
    "min_child_weight": 5,
    "n_estimators": 200,
    "reg_alpha": 0.009107831834843637,
    "reg_lambda": 0.24251495812504203,
    "subsample": 0.8462988772079394
  }
  ```
</details>

## Features

32 features in 6 ablation groups. Categoricals are ordinal-encoded from the training map (NULL/unseen → −1).

| Group            | Features                                                                                                                                                                                                            |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stint_position` | `lap_number`, `lap_in_stint`, `age_in_stint`, `fuel_mass_kg`                                                                                                                                                        |
| `compound`       | `compound`, `compound_grip_peak`, `compound_wear_gradient`, `compound_optimal_temp_low`, `compound_optimal_temp_high`, `compound_cliff_onset_laps`, `compound_cliff_severity`                                       |
| `cliff_prior`    | `expected_compound_pace_s`, `expected_degradation_rate_s_per_lap`, `cliff_onset_passed`, `laps_past_cliff`                                                                                                          |
| `thermal`        | `push_residual`, `cumulative_push_load_surface`, `cumulative_push_load_bulk`, `surface_bulk_ratio`                                                                                                                  |
| `dirty_air`      | `dirty_air_share_lap`, `dirty_air_thermal_load_surface`, `dirty_air_thermal_load_bulk`, `air_state_dominant`                                                                                                        |
| `proximity`      | `share_lap_within_1s`, `share_lap_within_2s`, `share_lap_in_train`, `share_lap_behind_within_1s`, `time_within_1s`, `gap_ahead_min_s`, `gap_ahead_median_s`, `ahead_identity_stability`, `n_distinct_cars_ahead_3s` |

### Leakage guards

driver\_id and race\_year are deliberately excluded: an adversarial probe recovers race\_year from the remaining features at 0.997 accuracy (majority baseline 0.168)-constructor identity and compound generation encode the season, so race\_year would be a backdoor. driver\_id would let the trees relearn per-driver skill, the exact signal the mart strips via driver\_skill\_residual\_s.

Excluded (identity / target / leakage): `circuit_key`, `drift_s_per_lap`, `driver_id`, `driver_skill_field_s`, `driver_skill_loro_mean_s`, `driver_skill_loro_s`, `driver_skill_proxy_s`, `driver_skill_residual_proxy_s`, `driver_skill_residual_s`, `is_training_eligible`, `lap_id`, `laps_until_cliff_class`, `next_3_lap_cumulative_jump_s`, `next_5_lap_cumulative_jump_s`, `next_lap_degradation_jump_detrended_s`, `next_lap_degradation_jump_s`, `race_id`, `race_year`, `remaining_stint_life_laps`, `stint_id`, `stint_length_laps`, `survival_weight`

## Validation

### Baselines

| Family                | Baseline                                                                                                                                                                                                          |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| degradation\_p50      | group-mean over (compound, circuit, age-bucket) cells, compound→global fallback                                                                                                                                   |
| degradation\_p10\_p90 | empirical 10th/90th percentile in the same cells                                                                                                                                                                  |
| cliff\_classifier     | majority-class prior (none\_in\_stint)                                                                                                                                                                            |
| stint\_life           | cell group-mean of remaining stint life over (compound, circuit, age-bucket), compound→global fallback (non-leakage); scored under the same censored likelihood as the model, never against uncensored rows alone |

### Attainable ceilings

Skill is reported as a fraction of the ATTAINABLE quantity, not of 1.0. For the quantile trio the denominator is analytic -- under a Gaussian shape a predictor that knew the stint could remove at most 1 - sqrt(1 - ICC) of the expected pinball, where ICC is the between-stint variance share estimated by one-way ANOVA. For the classifier it is a per-stint majority-class oracle; for stint life it is the censored AFT likelihood at a perfect prediction, which is a hard bound. A fraction above 1.0 means the model reaches past stint identity into within-stint variation.

| Model                       | Target column                  | Between-stint share | Naive estimate | Within-stint lag-1 | Of attainable           |
| --------------------------- | ------------------------------ | ------------------- | -------------- | ------------------ | ----------------------- |
| `degradation_regressor_p10` | `next_5_lap_cumulative_jump_s` | 2.0%                | 27.9%          | 0.2852             | **28.6× stint-level** ⚑ |
| `degradation_regressor_p50` | `next_5_lap_cumulative_jump_s` | 2.0%                | 27.9%          | 0.2852             | **15.5× stint-level** ⚑ |
| `degradation_regressor_p90` | `next_5_lap_cumulative_jump_s` | 2.0%                | 27.9%          | 0.2852             | **14.7× stint-level** ⚑ |
| `cliff_classifier`          | `laps_until_cliff_class`       | 23.6%               | 25.8%          | —                  | 65.6%                   |
| `stint_life_regressor`      | `remaining_stint_life_laps`    | 30.1%               | 29.2%          | 0.9994             | 61.5% of absolute       |

*Between-stint share is a one-way random-effects (ANOVA) estimate. The naive column is `var(per-stint means) / var(column)`, which counts within-stint scatter as between-stint signal at \~19 laps per stint; it is shown so the correction stays visible rather than silently applied.*

### Intervals on every claim

Every beats\_baseline claim carries a paired-t interval over the season-grouped CV folds (n=5) plus a percentile bootstrap that resamples whole STINTS on the evaluation fold. Laps inside a stint share a compound, a car, a circuit, a fuel load and a driver, so a lap-grain interval understates the standard error; the ratio between the two is published per model rather than assumed.

| Model                       | Win margin | 95% CI (paired t, folds) | p      | Stint-grain CI    | Widening vs lap grain |
| --------------------------- | ---------- | ------------------------ | ------ | ----------------- | --------------------- |
| `degradation_regressor_p10` | 0.2855     | \[0.1496, 0.4214]        | 0.0043 | \[0.2283, 0.2838] | 1.33×                 |
| `degradation_regressor_p50` | 0.3269     | \[0.2165, 0.4373]        | 0.0012 | \[0.2534, 0.3069] | 1.65×                 |
| `degradation_regressor_p90` | 0.1865     | \[0.1360, 0.2371]        | 0.0005 | \[0.1120, 0.1495] | 1.62×                 |
| `cliff_classifier`          | 0.1517     | \[0.1231, 0.1802]        | 0.0001 | \[0.1315, 0.1591] | 1.86×                 |
| `stint_life_regressor`      | 0.1059     | \[0.0337, 0.1782]        | 0.0152 | \[0.1663, 0.2274] | 3.04×                 |

**Every `beats_baseline` claim clears its own interval.**

*Effective sample: 6,203 stints over 81,619 laps (13.2 laps per stint). 0 stints straddle a season fold boundary, so the CV split was never the problem — every interval computed at lap grain was.*

### Calibration (quantile interval)

Nominal coverage **0.8** → raw \[p10,p90] empirical **0.7851**, split-conformal **0.8009** (mean interval width 5.9543s).

### Adversarial leakage probe

A throwaway model recovers `race_year` from the features at **0.9965** vs a majority baseline of 0.1680-X carries strong residual temporal signal-race\_year recoverable far above chance; race\_year is correctly excluded from features.

### Feature importance (SHAP vs permutation)

| Model                       | SHAP top-5                                                                                                   | Permutation top-5                                                                                                                             |
| --------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `degradation_regressor_p50` | `push_residual`, `lap_number`, `dirty_air_thermal_load_surface`, `compound_cliff_onset_laps`, `fuel_mass_kg` | `push_residual`, `dirty_air_thermal_load_surface`, `dirty_air_thermal_load_bulk`, `cumulative_push_load_surface`, `compound_cliff_onset_laps` |
| `cliff_classifier`          | `fuel_mass_kg`, `age_in_stint`, `lap_number`, `lap_in_stint`                                                 | `push_residual`, `fuel_mass_kg`, `share_lap_behind_within_1s`, `lap_number`, `dirty_air_thermal_load_surface`                                 |
| `stint_life_regressor`      | `lap_number`, `fuel_mass_kg`, `expected_compound_pace_s`, `age_in_stint`, `compound`                         | `lap_number`, `fuel_mass_kg`, `expected_compound_pace_s`, `compound`, `push_residual`                                                         |

### Underperforming cohorts

Surfaced, never dropped. Specific circuit/constructor cells where the model trails its non-leakage cohort-mean baseline; the model wins comfortably overall.

**15** cohort cells lose to baseline (surfaced, never dropped):

| Dimension     | Cohort                  | n    | Model  | Baseline |
| ------------- | ----------------------- | ---- | ------ | -------- |
| compound      | \_other                 | 3    | 0.3460 | 0.0314   |
| compound      | \_other                 | 3    | 0.3647 | 0.0444   |
| circuit\_key  | british\_grand\_prix    | 377  | 1.9153 | 1.7920   |
| compound      | INTERMEDIATE            | 657  | 0.6646 | 0.5807   |
| compound      | \_other                 | 3    | 0.1494 | 0.0039   |
| circuit\_key  | british\_grand\_prix    | 377  | 1.0891 | 0.9407   |
| circuit\_key  | canadian\_grand\_prix   | 485  | 0.6321 | 0.5338   |
| compound      | INTERMEDIATE            | 1041 | 0.1850 | 0.2400   |
| compound      | \_other                 | 9    | 1.0000 | 1.0000   |
| circuit\_key  | canadian\_grand\_prix   | 813  | 0.2051 | 0.2304   |
| is\_rain\_lap | \_other                 | 15   | 0.0417 | 0.0417   |
| compound      | \_other                 | 12   | 2.4434 | 2.3560   |
| circuit\_key  | british\_grand\_prix    | 628  | 2.7374 | 2.5343   |
| circuit\_key  | canadian\_grand\_prix   | 865  | 2.5270 | 2.5216   |
| circuit\_key  | las\_vegas\_grand\_prix | 682  | 2.1501 | 2.0184   |

## Reproducibility

* **Seed:** `20260528` · **dataset fingerprint:** `7852405cfa4abd47…`
* **Libraries:** xgboost 3.3.0, sklearn 1.9.0
* **ONNX parity:** all 5 boosters round-trip to ONNX within atol=1e-5 on a NaN-bearing sample (M2/M4)
* **Reproduce:** `make ml-all`

## Limitations

* NOT COMPARABLE TO v11. Work item 08m changed how `expected_compound_pace_s` is formed (`compound_cliff_severity` was fitted as a \~5.5-lap level shift and was being consumed as a per-lap rate, plus an unfitted 0.002\*age^2 term). That column is subtracted into `driver_skill_residual_s`, so the degradation trio's target `next_5_lap_cumulative_jump_s` and the classifier's label `laps_until_cliff_class` are DIFFERENT QUANTITIES under unchanged column names: the target's mean moved -1.8793 -> -0.3946 s, 10.79% of cliff labels changed class, and the trio's training population moved 82,470 -> 81,619 rows. v11's published headlines (p10 0.53202 / p50 1.04679 / p90 0.57851 pinball, cliff macro-F1 0.37187) were measured against the SUPERSEDED target. A smaller pinball loss here is a smaller target spread, not more skill - the non-leakage baseline moved too (p50 2.17766 -> 1.26289). NO IMPROVEMENT OVER v11 IS CLAIMED OR IMPLIED. The only admissible readings of this card are each model against its own baseline in this same run. The one exception is stint\_life\_regressor, whose target (`remaining_stint_life_laps`) 08m did not touch - only two of its 32 inputs changed meaning - though even there the eligible row set moved 121,193 -> 119,822, so it is a near-fixed rather than fixed comparison.
* The cliff classifier (macro-F1 ≈ 0.35 on 4-class cliff timing) is the weakest model by headline, and the headline is the wrong way to read it: 1.0 is not reachable on a label where laps inside a stint share a compound, a car, a circuit and a driver. Against an oracle handed the stint id and nothing else it reaches 66% of the ceiling. What limits it is not established. The label was the leading candidate and has been tested: laps\_until\_cliff\_class is a first-crossing scan over int\_compound\_cliff\_predicted, which was unbounded until Phase 8 bounded it at source (compound\_wear\_max\_s\_per\_lap). Repairing it moved 4.97% of rows and did not lift macro-F1, and a fresh 50-trial search on the repaired label did not beat the params tuned on the contaminated one. Neither the label nor the hyperparameters explain the number.
* Hyperparameters come from a 50-trial / season-fold Optuna search per target (ml/models/\<target>\_best\_params.json); `make ml-retrain` refits at those params without re-searching. A target whose data has moved should be re-tuned, not just refit.
* No live 2025 holdout yet-headline numbers are time-series CV until 2025 ingests.
* Skill is reported as a fraction of the ATTAINABLE quantity. A fraction above 1.0 means the model is not bounded by stint-level information, so no ceiling has been established for it - not that it is near-perfect.
* Every beats\_baseline margin carries an interval over the season folds and over a stint-resampled bootstrap. The effective sample is \~7,100 stints, not \~137,000 laps.
* Cliff-onset priors are NULL for \~45% of laps (legacy compounds, un-fit circuits); XGBoost native-NaN carries them, documented rather than imputed.

## Holdout policy

2025 is the designated holdout, ingested post-launch; until then the model trains on all ingested seasons (2018–2024) and selection rests on time-series CV-there is no live holdout. The evaluation headline is reported on the final TimeSeriesSplit fold (2024); it switches to a true-holdout reveal the moment 2025 ingests, with no code change.
