Skip to main content
The ml-features stage (ml/src/features.py) is where a mart row becomes a row of the X matrix. It handles loading, ordinal encoding, season splitting, dataset fingerprinting, and the forward-window SQL audit before any model ever trains.

The three targets

What it measures: the detrended per-lap pace change (in seconds) from this lap to the next. Positive means pace loss; negative means the tyre is still coming into its window.Distribution: legitimately negative roughly 44% of the time a tyre warming up, or recovering from a dirty-air lap, genuinely gains pace. The target is not clipped at zero; predicting recovery is as important as predicting loss.Detrending (Route C): the raw next_lap_degradation_jump_s carried a subtle fuel artefact a downward drift over the stint as fuel burned off. The detrended version removes the within-stint linear slope (≈−0.072\approx−0.072 s/lap) before training, so the model learns the cliff signal rather than the fuel signal.Models that use it: degradation_regressor_p10, degradation_regressor_p50, degradation_regressor_p90.

Ordinal encoding

Categoricals (compound, air_state_dominant) are encoded from the training map a deterministic integer assignment built once from the training set and persisted to ml/models/encoders.json. (constructor_id and anomaly_class were also ordinal-encoded categoricals before Phase 9 (2026-09-05) dropped both, with the rest of the context group, from the feature contract.)
The MISSING_ORDINAL = −1.0 sentinel handles:
  • Null values (a lap with no compound assignment)
  • Values seen at scoring time but absent from the training set (e.g. a new constructor)
XGBoost treats −1 like any other split point; the trees learn whether “unseen” is closer to one category or another based on training examples. Continuous features keep NaN as NaN. XGBoost has native missing-value support it learns the optimal split direction for missing values and never requires imputation. Booleans map True → 1.0, False → 0.0, NULL → NaN.
After encoding, the feature matrix is fingerprinted with SHA-256:
The fingerprint encodes the encoded matrix, the training season list, and the feature column names. It is logged to stdout during ml-features and embedded in the model card. Given the same mart rows, the same features, and the same training seasons, the fingerprint is byte-for-byte identical across machines and runs so any two runs with the same fingerprint produced the same boosters.The card gates on the fingerprint: if the mart changes and the fingerprint changes, the card is out of date and must be regenerated.
The SQL from which the mart is built lives in transform/models/**. Before any model trains, audit_forward_window resolves every feature back to its defining expression with sqlglot and rejects three things: a LEAD or FOLLOWING window function, the two window constructs that can see the future; a forward self-join, where the reach lives in a JOIN ... ON a.lap_in_stint < f.lap_in_stint predicate rather than in a window (this is how the cliff scan is built, and a feature built the same way would once have passed silently); and its own blindness — if any lineage model fails to parse, or any feature resolves to no definition at all, that is reported as a violation rather than as a clean run.A forward reach does not have to be a window, or a join. It can live in the scope of a GROUP BY, which neither of those checks reads, so audit_aggregation_scope runs beside it: every GROUP BY in the mart’s lineage must confine a group to at most one lap, or be declared in that model’s schema.yml with a written reason the checker parses. The declaration names the grouping key set, so editing the SQL out from under one fails the build rather than silently exempting an aggregation nobody looked at. See the leakage spine, guards 5–8.That last check is not decorative. The audit reads compiled_code from the dbt manifest, which is only populated by a command that compiles; against a dbt parse manifest it fell back to un-parseable Jinja and inspected 0 of 21 models and 0 of 42 features while returning []. An audit that resolves nothing returns exactly what a clean audit returns, so coverage is asserted before the result is trusted.
The audit returns an empty list on success; CI runs it via make ml-features. It is one of the 12 leakage-spine guards see The leakage spine for the full set.The reason this lives in ml/src/features.py rather than in the transform tests is that it operates on the compiled SQL (the dbt manifest), not the dbt model source and it is conceptually the feature layer’s responsibility to verify that what it reads is not forward-looking.

Season split

The training/holdout season split is derived from the data never hard-coded:
Today the holdout season is 2025 (no data yet). The final TimeSeriesSplit fold (2024) stands in as the evaluation holdout until 2025 ingests at which point the reveal happens with zero code change.

Measured and rejected

Two obvious expansions of the (then-)42-feature set have been measured and both were rejected. They are recorded here so a later pass does not re-derive them. Both were run on the project’s own harness: expanding-window season CV (train 2018 to season k−1, test season k, six folds), XGBoost at 300 trees / depth 6 / lr 0.08, S.RANDOM_STATE, is_training_eligible rows only. Measured 2026-08-22.
Read against the current contract, not the one these were measured on. Phase 9 (2026-09-05) subsequently dropped weather_air and track themselves (along with powertrain, telemetry_cliff and context) from the live feature set on a noise-floor ablation see Feature contract. The two write-ups below still stand as the record of what was measured at the time, but a claim like “circuit identity already enters the feature set three times over through track_energy_index, circuit_abrasiveness_index, and the per-circuit-per-season compound cliff parameters” now describes a 42-feature set that no longer exists two of those three paths were removed by the prune.
The warehouse stages a number of signals no model reads: track_position, min_gap_s, tow_benefit_lap_s, time_in_dirty_air_s, track_temp_c, humidity_pct, wind_speed_ms, the track-minus-ambient delta, grid_position, the four speed_i1/i2/fl/st_kph columns, laps_remaining_in_race, race_distance_laps, sc_hazard_per_lap_shrunk and any_hazard_per_lap_shrunk. Coverage runs 83 to 100% on all of them except allocated_sets_per_driver, which is 0%.Joined onto the mart and tested as a block:Nothing, in both directions of “nothing”: no gain, and no loss worth calling a loss. This agrees with the project’s own ablation artefacts, which already score weather_air and track as net-negative groups for the cliff classifier (removing track improves macro-F1 by 0.0047). The feature set is not starved. If anything it is slightly over-fed.The one target the block did move is stint life (+3.25%), and that is race-distance information the model already has through fuel mass.
pressure_hpa sits in bronze, 100% non-null across all seven seasons, and stg_weather drops it in a 12-column projection. Staging it would unblock a proper air-density feature: dry-air plus vapour partial pressures via Tetens gives 0.902 to 1.241 kg/m³ across the calendar, a 37.6% spread driven by Mexico City’s altitude. Downforce, drag and thermal load all scale with it, so the physics argument is a good one.It does not survive measurement. Both features added to the 42:Both sit inside the harness noise, and the reason is structural: air density is very nearly a per-circuit constant, and circuit identity already enters the feature set three times over through track_energy_index, circuit_abrasiveness_index, and the per-circuit-per-season compound cliff parameters. The model already knows Mexico is Mexico.Staging pressure_hpa remains cheap and defensible for completeness. It should not be sold as an ML win.

Relationships

Feature contract

The full 33-feature table, physics group descriptions, and the read-only mart contract.

Models

How each target is used objective function, loss, and weighting.

Leakage spine

The 14 CI guards that enforce the exclusions and the forward-window audit every build.

Pipeline

Where this stage fits in the full make ml-all DAG.