Abstract — Seven heterogeneous learners (XGBoost, XGBoost-DART, LightGBM, LightGBM-GOSS, CatBoost, CatBoost-Bayesian, and Ranger Random Forest) are tuned across a 4-Round Coarse-to-Fine Bayesian Optimization pipeline using Random Forest and Gaussian Process surrogates. Model diversity is unified through a 11-method meta-ensemble, where a Full Ensemble Meta-Stacker achieves a peak Kaggle Private Leaderboard AUC-ROC of 0.94589.
RealMLP
emerged as a top-scoring tabular architecture on the competitive
leaderboard, it was omitted from this 11-method meta-ensemble strictly
due to VRAM/compute constraints on my local rig.
Binary Classification Gradient Boosted Trees Meta-Ensembling SHAP Interpretability Coarse-to-Fine Tuning Platt Calibration Feature Engineering AUC-ROC Optimization
Note on Architecture & Portability: While many
production ML workflows run on Python, I built this pipeline in R to
take full advantage of data.table and mlr3 for
high-performance tabular ETL and experiment tracking. The core
methodology demonstrated here—Bayesian optimization, SHAP
interpretability, and leak-free meta-ensembling—is entirely
framework-agnostic and translates directly to Python ecosystems (e.g.,
scikit-learn, Optuna, SHAP).
In motorsport strategy, pit stop timing depends on tire wear, track
position, weather, and what the competition is doing. Getting it wrong
by even one lap can cost 20+ seconds — the difference between a podium
and going home empty. While this project doesn’t model the pit-stop
timing penalty directly, that real-world urgency is why I
focused so heavily on degradation-aware features
(TyreLife_sq, tyre_deg_acceleration,
tyre_window_progress) that capture the narrow window where
a stop becomes strategically optimal.
To tackle this, I built a prediction system that takes per-lap telemetry — tire compound, tire age, lap times, degradation curves, positional dynamics, and race progress — and outputs a calibrated probability of whether a driver will pit on the next lap.
Transferability & Empirical Approach: Treating machine learning as an empirical science—where physics-based domain constraints guide the feature space rather than black-box data feeding—is essential for reliable systems. The underlying methodology here (multi-model ensembling, SHAP-driven interpretability, and strict holdout validation) transfers directly to high-stakes domains where algorithmic robustness is critical.
Here’s what the rest of this case study covers:
| Section | Title | Content |
|---|---|---|
| 2 | Computational Environment | Software stack, hardware configuration, reproducibility setup |
| 3 | Data Ingestion & Initial Profiling | Loading four CSV sources, dimension checks, NA audit |
| 4 | Exploratory Data Analysis | Target distribution, compound analysis, descriptive statistics |
| 5 | Diagnostic Analysis | Missing values, outliers, skewness, correlation matrix |
| 6 | Feature Density Analysis | KS-statistic overlays for Pit vs No-Pit distributions |
| 7 | Linearity & Model Class Justification | GLM vs XGBoost controlled experiment |
| 8 | Inferential Analysis | Cohen’s d effect sizes, Chi-squared associations |
| 9 | Feature Engineering Pipeline | Physics-informed feature categories, lag features, MICE imputation |
| 10 | Winsorization A/B Test | Controlled before/after clipping experiment |
| 11 | Coarse-to-Fine Hyperparameter Optimization | Four-round tuning architecture with verified results |
| 12 | SHAP Analysis & Feature Selection | SHAP convergence, consensus ranking, interaction A/B test, RF concordance |
| 13 | Out-of-Fold Predictions & Variance Tracking | 5-fold OOF performance per model |
| 14 | 11-Method Meta-Ensemble | Ensemble architecture, Platt scaling, AUC gating |
| 15 | Generalization & Final Validation | Holdout validation, Kaggle leaderboard benchmarks |
| 16 | Submission Pipeline | TTA pipeline, submission preview table |
| 17 | Final Thoughts | What worked, what I’d change, key lessons |
Thread Allocation: data.table
operations are capped at 8 threads via setDTthreads(8) to
avoid L1/L2 cache thrashing on AMD Ryzen architecture, while tree
learners consume 16 threads independently. ETL is memory-bound so more
threads hurt; training is compute-bound so it benefits from all 16.
| Component | Details |
|---|---|
| Language | R 4.6.1 |
| ML Framework | mlr3 ecosystem (mlr3verse, mlr3tuning, mlr3mbo, mlr3extralearners) |
| Bayesian Optimization | rBayesianOptimization (GP surrogate, Round 2) + mlr3mbo |
| Tree Learners | XGBoost, XGBoost-DART, LightGBM, LightGBM-GOSS, CatBoost, CatBoost-Bayesian, Ranger |
| Feature Importance | TreeSHAP (shapviz) + Impurity Importance proxy (Ranger) |
| Imputation | MICE (Predictive Mean Matching) — preserves conditional distribution structure |
| Parallelism | data.table: 8 threads | Tree learners: 16 threads | future::plan(multisession) |
| Evaluation Metric | AUC-ROC (probability ranking, not calibration) |
| CV Strategy | 5-Fold Stratified Group K-Fold (grouped by Race) |
| Seed (Reproducibility) | 2026 (fixed across all stages) |
| GPU Acceleration | CUDA (XGBoost tree_method=‘hist’, device=‘cuda’) | CatBoost task_type=‘GPU’ |
| Dataset | Rows | Columns | Description |
|---|---|---|---|
| train.csv | 439,140 | 16 | Synthetic Grand Prix per-lap telemetry with ground truth |
| test.csv | 188,165 | 15 | Unlabeled evaluation set for competitive submission |
| f1_strategy_dataset_v4.csv | 101,371 | 16 | Historical Grand Prix benchmark priors (Race × Compound) |
| sample_submission.csv | 188,165 | 2 | Official target submission template |
Column Overlap Check: 15 columns are shared between
train and test. The single train-only column is PitNextLap
— the binary target. No structural mismatches between train and
test.
Before engineering complex physics features, we must establish the baseline telemetry boundaries and structural imbalances in the raw data.
eda_data <- copy(train_raw)
n_pit <- as.integer(table(eda_data[[TARGET]])["1"])
n_nopit <- as.integer(table(eda_data[[TARGET]])["0"])
target_tbl <- prop.table(table(eda_data[[TARGET]]))
p_target <- ggplot(
data.frame(
Class = c("No Pit (0)", "Pit (1)"),
Count = c(n_nopit, n_pit),
Pct = as.numeric(target_tbl) * 100
),
aes(x = Class, y = Count, fill = Class)
) +
geom_col(width = 0.52, show.legend = FALSE) +
geom_text(
aes(label = sprintf("%s\n(%.1f%%)", format(Count, big.mark = ","), Pct)),
vjust = -0.45, size = 3.6, fontface = "bold", color = "#1a1a2e"
) +
scale_fill_manual(values = c("No Pit (0)" = "#4361ee", "Pit (1)" = "#f77f00")) +
scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.20))) +
labs(title = "Target Class Distribution",
subtitle = sprintf("Total: %s rows | Class 0: %.1f%% | Class 1: %.1f%%",
format(nrow(eda_data), big.mark = ","),
as.numeric(target_tbl["0"]) * 100,
as.numeric(target_tbl["1"]) * 100),
x = NULL, y = "Count", caption = DA_CAPTION) +
theme_f1()
print(p_target)Class Imbalance: The target is 80.1% No-Pit
/ 19.9% Pit — roughly 4:1. This shaped several design choices:
(1) AUC-ROC as the primary metric (doesn’t depend on threshold
selection), (2) LightGBM’s is_unbalance=TRUE flag, (3)
XGBoost’s scale_pos_weight = 4.026, (4) CatBoost’s
auto_class_weights = "Balanced", and (5) Platt Scaling for
post-hoc probability calibration.
key_cols <- c("TyreLife", "LapTime (s)", "RaceProgress", "Position",
"LapNumber", "Stint", "Cumulative_Degradation", "LapTime_Delta")
key_cols <- intersect(key_cols, names(eda_data))
desc_stats <- rbindlist(lapply(key_cols, function(col) {
x <- eda_data[[col]]
data.table(
Feature = col,
Mean = round(mean(x, na.rm = TRUE), 3),
Median = round(median(x, na.rm = TRUE), 3),
SD = round(sd(x, na.rm = TRUE), 3),
Min = round(min(x, na.rm = TRUE), 3),
Max = round(max(x, na.rm = TRUE), 3),
Skewness = round(moments::skewness(x, na.rm = TRUE), 3),
Kurtosis = round(moments::kurtosis(x, na.rm = TRUE), 3)
)
}))
knitr::kable(desc_stats, format = "html", escape = FALSE,
caption = "Descriptive Statistics of Key Telemetry Features") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 13) |>
kableExtra::column_spec(1, width = "14em", bold = TRUE)| Feature | Mean | Median | SD | Min | Max | Skewness | Kurtosis |
|---|---|---|---|---|---|---|---|
| TyreLife | 14.158 | 12.000 | 9.801 | 1.000 | 77.000 | 1.032 | 4.437 |
| LapTime (s) | 90.949 | 90.521 | 19.773 | 67.694 | 2507.607 | 80.334 | 9682.240 |
| RaceProgress | 0.338 | 0.269 | 0.253 | 0.013 | 1.000 | 0.700 | 2.485 |
| Position | 9.630 | 10.000 | 5.279 | 1.000 | 20.000 | 0.070 | 1.901 |
| LapNumber | 23.106 | 19.000 | 16.958 | 1.000 | 78.000 | 0.646 | 2.433 |
| Stint | 1.789 | 2.000 | 0.950 | 1.000 | 8.000 | 1.185 | 4.270 |
| Cumulative_Degradation | -25.722 | -20.994 | 54.767 | -274.564 | 2412.026 | 3.191 | 159.351 |
| LapTime_Delta | -3.770 | -0.295 | 43.946 | -2403.895 | 2423.932 | -40.855 | 2677.589 |
Key Observations:
LapTime (s) shows extreme skewness
(80.3) and kurtosis (9682) — safety car laps, red flag restarts, and
pit-in laps create massive right-tail outliers reaching 2507sTyreLife has moderate right-skew
(1.03) — most laps happen on fresh tires (stint 1), with a long tail for
extended stintsPosition is roughly symmetric (skew
0.07) — a balanced grid representation across the dataseteda_data[, Stint_ID := paste(Race, Driver, Stint, sep = "_")]
stint_compound <- eda_data[,
.(ended_in_pit = max(as.numeric(as.character(get(TARGET)))),
Compound = unique(Compound)),
by = Stint_ID
]
pit_compound <- stint_compound[,
.(pit_rate = mean(ended_in_pit == 1), n = .N),
by = Compound
][order(-pit_rate)]
p_compound <- ggplot(pit_compound, aes(x = reorder(Compound, pit_rate), y = pit_rate, fill = pit_rate)) +
geom_col(width = 0.62, show.legend = FALSE) +
geom_text(aes(label = sprintf("%.1f%% (n=%s)", pit_rate * 100, format(n, big.mark = ","))),
hjust = 1.1,
color = ifelse(pit_compound$pit_rate > 0.5, "#ffffff", "#1a1a2e"),
size = 3.2, fontface = "bold") +
coord_flip() +
scale_y_continuous(labels = scales::percent, expand = expansion(mult = c(0, 0.15))) +
scale_fill_gradient(low = "#90e0ef", high = "#0077b6") +
labs(title = "Strategic Pit Rate by Tyre Compound",
subtitle = "Percentage of unique tyre stints that terminated with a pit stop",
x = NULL, y = "Stint Termination Rate", caption = DA_CAPTION) +
theme_f1()
print(p_compound)Key Observations:
Compound_TyreLife interaction feature in Section 9, which
captures the non-linear relationship between compound softness and tire
age at the point of pit entrystint_dist <- eda_data[, .N, by = Stint][order(Stint)]
p_stint <- ggplot(stint_dist, aes(x = factor(Stint), y = N, fill = factor(Stint))) +
geom_col(width = 0.62, show.legend = FALSE) +
geom_text(aes(label = format(N, big.mark = ",")), vjust = -0.45, size = 3.2,
fontface = "bold", color = "#1a1a2e") +
scale_fill_viridis_d(option = "mako", begin = 0.3, end = 0.8, direction = -1) +
scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.18))) +
labs(title = "Stint Data Log Distribution",
subtitle = "Stint 1 contains the baseline population; sequential stints decay sharply",
x = "Stint Number", y = "Row Count", caption = DA_CAPTION) +
theme_f1()
print(p_stint)Key Observations:
Stint_TyreLife_ratio feature: normalizing tire age relative
to stint number captures whether a driver is running an atypically long
stint (high ratio = overdue for a stop)Clean Dataset Confirmed: The raw training data contains 0 columns with missing values. Missing values arise only after feature engineering (lag features on Lap 1), where MICE (Predictive Mean Matching) is applied.
outlier_cols <- intersect(c("LapTime (s)", "LapTime_Delta", "Cumulative_Degradation"), names(eda_data))
if (length(outlier_cols) >= 2) {
p_box_list <- lapply(outlier_cols, function(col) {
q99 <- quantile(eda_data[[col]], 0.99, na.rm = TRUE)
q01 <- quantile(eda_data[[col]], 0.01, na.rm = TRUE)
n_out <- sum(eda_data[[col]] > q99 | eda_data[[col]] < q01, na.rm = TRUE)
ggplot(eda_data, aes(x = get(TARGET), y = get(col), fill = get(TARGET))) +
geom_boxplot(outlier.size = 0.35, outlier.alpha = 0.12, width = 0.50,
show.legend = FALSE, linewidth = 0.4) +
scale_fill_manual(values = c("0" = "#4361ee", "1" = "#f77f00")) +
annotate("label", x = 1.5, y = quantile(eda_data[[col]], 0.95, na.rm = TRUE),
label = sprintf("Outliers beyond P1/P99: %s", format(n_out, big.mark = ",")),
size = 2.7, color = "#c1121f", fontface = "bold.italic",
fill = "#fafafa", label.padding = unit(0.35, "lines"), hjust = 0.5) +
coord_cartesian(ylim = c(quantile(eda_data[[col]], 0.01, na.rm = TRUE),
quantile(eda_data[[col]], 0.99, na.rm = TRUE))) +
labs(title = col, x = "PitNextLap", y = NULL) +
theme_f1(base_size = 9)
})
p_outlier <- wrap_plots(p_box_list, ncol = length(outlier_cols)) +
plot_annotation(
title = "Outlier Diagnostics: Heavy-Tail Features vs Target",
subtitle = "Extreme values confirmed — clipping boundaries applied during Winsorization",
caption = DA_CAPTION,
theme = theme(
plot.title = element_text(face = "bold", size = 14, color = "#111122"),
plot.subtitle = element_text(size = 11, color = "#4a4a66"),
plot.caption = element_text(size = 8, color = "#9999aa", hjust = 1)
)
)
print(p_outlier)
}Key Observations:
LapTime (s) shows the widest
interquartile range for the Pit class — laps immediately preceding a pit
stop tend to be slower (degraded tires), but also include outlier
safety-car laps that inflate the upper tailLapTime_Delta has symmetric outliers
in both classes, but the Pit class shows a slight positive shift —
drivers on worn tires are more likely to lose time lap-over-lap,
producing positive deltasCumulative_Degradation separates
cleanly: the Pit class has a visibly higher median, confirming that
accumulated performance loss is a strong pit-stop indicator# Created a temporary diagnostic copy and cast Compound to a factor.
diag_data <- copy(eda_data)
diag_data[, Compound := as.factor(Compound)]
var_test_feats <- c("LapTime (s)", "Cumulative_Degradation", "TyreLife")
if (requireNamespace("car", quietly = TRUE)) {
levene_results <- rbindlist(lapply(var_test_feats, function(feat) {
formula_str <- as.formula(paste0("`", feat, "` ~ Compound"))
lev_test <- car::leveneTest(formula_str, data = diag_data, center = median)
lev_p <- lev_test[1, "Pr(>F)"]
bart_test <- bartlett.test(formula_str, data = diag_data)
bart_p <- bart_test$p.value
data.table(
Feature = feat,
Levenes_p = signif(lev_p, 4),
Bartletts_p = signif(bart_p, 4),
Verdict = ifelse(lev_p < 0.05, "Heteroscedastic", "Homoscedastic")
)
}))
knitr::kable(levene_results, format = "html", escape = FALSE,
caption = "Homogeneity of Variance Tests (Levene's & Bartlett's)") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE, font_size = 14)
}| Feature | Levenes_p | Bartletts_p | Verdict |
|---|---|---|---|
| LapTime (s) | 0 | 0 | Heteroscedastic |
| Cumulative_Degradation | 0 | 0 | Heteroscedastic |
| TyreLife | 0 | 0 | Heteroscedastic |
Key Observations:
Compound types.cor_cols <- intersect(c("TyreLife", "LapTime (s)", "RaceProgress", "Position",
"LapNumber", "Stint", "Cumulative_Degradation", "Position_Change"), names(eda_data))
cor_mat <- cor(eda_data[, ..cor_cols], use = "complete.obs")
cor_long <- as.data.table(as.data.frame(as.table(cor_mat)))
names(cor_long) <- c("Var1", "Var2", "Correlation")
p_cor <- ggplot(cor_long, aes(x = Var1, y = Var2, fill = Correlation)) +
geom_tile(color = "white", linewidth = 0.5) +
geom_text(aes(label = round(Correlation, 2), color = abs(Correlation) > 0.6),
size = 2.7, fontface = "bold") +
scale_color_manual(values = c("FALSE" = "#333344", "TRUE" = "white"), guide = "none") +
scale_fill_gradient2(low = "#023e8a", mid = "white", high = "#c1121f",
midpoint = 0, limits = c(-1, 1), name = "Pearson r") +
scale_x_discrete(expand = expansion(0)) +
scale_y_discrete(expand = expansion(0)) +
labs(title = "Feature Correlation Matrix", x = NULL, y = NULL, caption = DA_CAPTION) +
theme_f1(base_size = 10) +
theme(axis.text.x = element_text(angle = 40, hjust = 1, size = 8.5))
print(p_cor)Multi-Collinearity Note: Several correlated feature clusters are visible in the matrix:
LapNumber / TotalLaps)This doesn’t warrant removal: tree-based models split on thresholds,
not coefficient estimates, so multi-collinearity is a non-issue. The
correlated features encode complementary positional information —
TyreLife captures within-stint age while
LapNumber captures within-race position. This
further backs up the model-class choice in Section 7.
# Selected core numeric features for VIF (avoiding engineered interactions to prevent singularity)
vif_features <- c("TyreLife", "LapTime (s)", "LapTime_Delta", "Cumulative_Degradation",
"RaceProgress", "Position_Change", "laptime_lag1", "tyre_age_rate",
"deg_rolling3", "stint_duration")
vif_features <- intersect(vif_features, names(eda_data))
if (length(vif_features) >= 3 && requireNamespace("car", quietly = TRUE)) {
vif_data <- eda_data[, ..vif_features]
vif_feat <- paste0("`", vif_features[-1], "`")
vif_formula <- as.formula(paste("TyreLife ~", paste(vif_feat, collapse = " + ")))
vif_model <- lm(vif_formula, data = vif_data)
vif_scores <- car::vif(vif_model)
vif_dt <- data.table(
Feature = names(vif_scores),
VIF = as.numeric(vif_scores),
Status = ifelse(vif_scores > 5, "⚠ High (>5)",
ifelse(vif_scores > 3, "△ Moderate (>3)", "✓ Low"))
)
vif_dt <- vif_dt[order(-VIF)]
knitr::kable(vif_dt, format = "html", escape = FALSE, digits = 3,
caption = "Variance Inflation Factor (VIF) — Multicollinearity Diagnostic") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 13) |>
kableExtra::column_spec(1, width = "14em", bold = TRUE)
}| Feature | VIF | Status |
|---|---|---|
LapTime (s)
|
1.068 | ✓ Low |
| Cumulative_Degradation | 1.061 | ✓ Low |
| RaceProgress | 1.027 | ✓ Low |
| LapTime_Delta | 1.023 | ✓ Low |
| Position_Change | 1.000 | ✓ Low |
VIF Interpretation:
eda_cols <- intersect(c("TyreLife", "RaceProgress", "LapTime (s)", "Position",
"LapNumber", "Stint", "Cumulative_Degradation"), names(eda_data))
p_eda_list <- lapply(eda_cols, function(col) {
dt_sub <- data.table(x = eda_data[[col]], target = eda_data[[TARGET]])
x_lo <- quantile(dt_sub$x, 0.01, na.rm = TRUE)
x_hi <- quantile(dt_sub$x, 0.99, na.rm = TRUE)
dt_sub <- dt_sub[x >= x_lo & x <= x_hi]
ks_val <- suppressWarnings(ks.test(dt_sub[target == "0"]$x, dt_sub[target == "1"]$x)$statistic)
ggplot(dt_sub, aes(x = x, fill = target)) +
geom_density(alpha = 0.55, adjust = 1.2) +
scale_fill_manual(values = c("0" = "#4361ee", "1" = "#f77f00"),
labels = c("0" = "No Pit", "1" = "Pit")) +
annotate("label", x = Inf, y = Inf, label = sprintf("KS=%.2f", ks_val),
hjust = 1.15, vjust = 1.5, size = 2.9, color = "#c1121f", fontface = "bold",
fill = alpha("#fafafa", 0.85), label.padding = unit(0.25, "lines")) +
labs(title = col, x = NULL, y = "Density", fill = NULL) +
theme_f1(base_size = 9) +
theme(legend.position = "bottom", legend.key.size = unit(0.35, "cm"),
legend.text = element_text(size = 7))
})
p_eda <- wrap_plots(p_eda_list, ncol = 4) +
plot_annotation(
title = "Feature Distributions: Pit vs No-Pit",
subtitle = "KS statistic shown per feature — higher KS = stronger discriminative signal",
caption = DA_CAPTION,
theme = theme(
plot.title = element_text(face = "bold", size = 14, color = "#111122"),
plot.subtitle = element_text(size = 11, color = "#4a4a66"),
plot.caption = element_text(size = 8, color = "#9999aa", hjust = 1)
)
)
print(p_eda)KS Statistic Interpretation: The Kolmogorov-Smirnov statistic measures the max distance between Pit and No-Pit CDFs. Features with KS > 0.20 (TyreLife, Stint, RaceProgress) have strong discriminative signal — these are going to be high-value predictors.
Key Observations:
Before jumping into tree models, it’s worth checking: can a simple linear model handle this, or do we actually need non-linear learners?
To answer that, I ran a quick head-to-head using only the raw features:
| Model | AUC | Type |
|---|---|---|
| Logistic Regression (GLM) | 0.7161 | Linear |
| XGBoost (Tree Ensemble) | 0.8846 | Non-Linear |
Verdict: Complex Non-Linearities Detected
cohen_d_fn <- function(x, y) {
n1 <- length(x); n2 <- length(y)
s <- sqrt(((n1 - 1) * var(x, na.rm = TRUE) + (n2 - 1) * var(y, na.rm = TRUE)) / (n1 + n2 - 2))
(mean(x, na.rm = TRUE) - mean(y, na.rm = TRUE)) / s
}
inf_cols <- intersect(c("TyreLife", "RaceProgress", "LapTime (s)", "Position",
"LapNumber", "Stint", "Cumulative_Degradation"), names(eda_data))
inf_results <- rbindlist(lapply(inf_cols, function(col) {
x0 <- eda_data[get(TARGET) == "0"][[col]]
x1 <- eda_data[get(TARGET) == "1"][[col]]
tt <- t.test(x0, x1, var.equal = FALSE)
cd <- cohen_d_fn(x0, x1)
data.table(
Feature = col,
Mean_NoPit = round(mean(x0, na.rm = TRUE), 3),
Mean_Pit = round(mean(x1, na.rm = TRUE), 3),
p_value = signif(tt$p.value, 4),
CohenD = round(cd, 3),
EffectSize = fcase(abs(cd) >= 0.8, "Large", abs(cd) >= 0.5, "Medium",
abs(cd) >= 0.2, "Small", default = "Negligible")
)
}))
p_effect <- ggplot(inf_results, aes(x = reorder(Feature, abs(CohenD)), y = CohenD, fill = EffectSize)) +
geom_col(width = 0.65) +
geom_hline(yintercept = c(-0.8, -0.5, -0.2, 0.2, 0.5, 0.8),
linetype = "dotted", color = "gray60", linewidth = 0.4) +
geom_text(aes(label = sprintf("d=%.2f", CohenD),
hjust = ifelse(CohenD < 0, -0.1, 1.1)),
size = 2.9, color = "#1a1a2e", fontface = "bold") +
coord_flip(ylim = c(-1.0, 1.0), clip = "off") +
scale_fill_manual(values = c("Large" = "#c1121f", "Medium" = "#f77f00",
"Small" = "#4361ee", "Negligible" = "#adb5bd")) +
labs(title = "Cohen's d: Feature Discrimination Power",
subtitle = "Measures standardized mean difference between Pit and No-Pit distributions",
x = NULL, y = "Cohen's d", fill = "Effect Size", caption = DA_CAPTION) +
theme_f1()
print(p_effect)knitr::kable(inf_results, format = "html", escape = FALSE,
caption = "Inferential Analysis — Statistical Significance & Effect Sizes") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 12) |>
kableExtra::column_spec(1, width = "14em", bold = TRUE)| Feature | Mean_NoPit | Mean_Pit | p_value | CohenD | EffectSize |
|---|---|---|---|---|---|
| TyreLife | 12.822 | 19.537 | 0 | -0.712 | Medium |
| RaceProgress | 0.314 | 0.432 | 0 | -0.473 | Small |
| LapTime (s) | 91.285 | 89.596 | 0 | 0.085 | Negligible |
| Position | 9.574 | 9.856 | 0 | -0.053 | Negligible |
| LapNumber | 20.849 | 32.192 | 0 | -0.694 | Medium |
| Stint | 1.695 | 2.167 | 0 | -0.506 | Medium |
| Cumulative_Degradation | -21.152 | -44.116 | 0 | 0.425 | Small |
chi_cols <- intersect(c("Compound", "Race"), names(eda_data))
chi_results <- rbindlist(lapply(chi_cols, function(col) {
tbl <- table(eda_data[[col]], eda_data[[TARGET]])
ct <- chisq.test(tbl, simulate.p.value = (nrow(tbl) > 5))
cv <- sqrt(ct$statistic / (nrow(eda_data) * (min(dim(tbl)) - 1)))
data.table(
Feature = col,
Chi2 = round(ct$statistic, 2),
p_value = signif(ct$p.value, 4),
Cramers_V = round(cv, 4),
Association = fcase(cv >= 0.5, "Strong", cv >= 0.3, "Moderate",
cv >= 0.1, "Weak", default = "Negligible")
)
}))
knitr::kable(chi_results, format = "html", escape = FALSE,
caption = "Chi-Squared Test — Categorical Feature Associations") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE, font_size = 14) |>
kableExtra::column_spec(1, width = "10em", bold = TRUE)| Feature | Chi2 | p_value | Cramers_V | Association |
|---|---|---|---|---|
| Compound | 30866.32 | 0.0000000 | 0.2651 | Weak |
| Race | 15545.76 | 0.0004998 | 0.1882 | Weak |
if (!exists("diag_data") || !"Compound" %in% names(diag_data) || !is.factor(diag_data$Compound)) {
diag_data <- copy(eda_data)
diag_data[, Compound := as.factor(Compound)]
}
anova_features <- c("TyreLife", "LapTime (s)", "Cumulative_Degradation")
anova_results <- rbindlist(lapply(anova_features, function(feat) {
formula_str <- as.formula(paste0("`", feat, "` ~ Compound"))
aov_model <- aov(formula_str, data = diag_data)
aov_summary <- summary(aov_model)
p_val <- aov_summary[[1]][["Pr(>F)"]][1]
data.table(
Feature = feat,
ANOVA_p = signif(p_val, 4),
Significant = ifelse(p_val < 0.05, "YES ***", "NO")
)
}))
knitr::kable(anova_results, format = "html", escape = FALSE,
caption = "One-Way ANOVA — Testing if Features Differ Across Compounds") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE, font_size = 14)| Feature | ANOVA_p | Significant |
|---|---|---|
| TyreLife | 0 | YES *** |
| LapTime (s) | 0 | YES *** |
| Cumulative_Degradation | 0 | YES *** |
tukey_result <- TukeyHSD(aov(`TyreLife` ~ Compound, data = diag_data), "Compound")
tukey_dt <- as.data.table(tukey_result$Compound, keep.rownames = TRUE)
setnames(tukey_dt, "rn", "Comparison")
tukey_dt[, Significant := ifelse(`p adj` < 0.05, "YES ***", "NO")]
knitr::kable(head(tukey_dt[, .(Comparison, diff, `p adj`, Significant)], 10),
format = "html", escape = FALSE, digits = 6,
caption = "Tukey HSD Post-Hoc — TyreLife Pairwise Comparisons") |>
kableExtra::kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 12)| Comparison | diff | p adj | Significant |
|---|---|---|---|
| INTERMEDIATE-HARD | -4.620217 | 0 | YES *** |
| MEDIUM-HARD | -6.018148 | 0 | YES *** |
| SOFT-HARD | -6.667562 | 0 | YES *** |
| WET-HARD | -8.269105 | 0 | YES *** |
| MEDIUM-INTERMEDIATE | -1.397931 | 0 | YES *** |
| SOFT-INTERMEDIATE | -2.047345 | 0 | YES *** |
| WET-INTERMEDIATE | -3.648889 | 0 | YES *** |
| SOFT-MEDIUM | -0.649414 | 0 | YES *** |
| WET-MEDIUM | -2.250958 | 0 | YES *** |
| WET-SOFT | -1.601544 | 0 | YES *** |
Key Findings:
Compound type has a statistically significant effect on all
three continuous features.Compound comparison is significant (p < 0.05).
Compounds by
hardness (SOFT - MEDIUM - HARD) is statistically justified.Compounds degrading at drastically
different rates.Compound
as a primary predictor given its strong effect size.Standard ML algorithms don’t understand kinematics. To capture actual vehicle dynamics, fuel burn compensation, and tire degradation curvature, I translated F1 domain knowledge directly into R functions. Here is the core logic:
# ══════════════════════════════════════════════════════════════════════════════
# HERO CODE CHUNK 1: PHYSICS-INFORMED FEATURE TRANSFORMATIONS
# ══════════════════════════════════════════════════════════════════════════════
apply_physics_features <- function(dt, global_fuel_slope = -0.0390) {
setorder(dt, Race, Year, LapNumber, Driver)
# 1. Fuel-burn pace correction: Delta relative to expected fuel mass loss
dt[, lt_fuel_adj := `LapTime (s)` - (global_fuel_slope * LapNumber)]
# 2. Strategic Undercut / Overcut pressure: Joint pace & track position loss
dt[, tmp_pos_delta := Position - shift(Position, 1L, type = "lag"), by = .(Race, Year, Driver)]
dt[, tmp_lt_delta := `LapTime (s)` - shift(`LapTime (s)`, 1L, type = "lag"), by = .(Race, Year, Driver)]
dt[, position_loss_pace_loss := as.integer(tmp_pos_delta > 0 & tmp_lt_delta > 0)]
dt[, tyre_age_x_pos_loss := TyreLife * pmax(tmp_pos_delta, 0)]
dt[, c("tmp_pos_delta", "tmp_lt_delta") := NULL]
# 3. Degradation Cliff Acceleration: 2nd derivative of rolling degradation
dt[, deg_rolling3 := frollmean(Cumulative_Degradation, 3L, align = "right", na.rm = TRUE), by = .(Race, Year, Driver)]
dt[, tyre_deg_acceleration := deg_rolling3 - shift(deg_rolling3, 1L, type = "lag"), by = .(Race, Year, Driver)]
# 4. Field Undercut Threat: Count of surrounding cars pitting within +/-3 positions
dt[, fresh_tire_threat := {
p <- Position; tl <- TyreLife
vapply(seq_len(.N), function(i) {
sum(abs(p - p[i]) <= 3L & tl <= 2L) - as.integer(tl[i] <= 2L)
}, integer(1))
}, by = .(Race, Year, LapNumber)]
return(invisible(dt))
}# To avoid spurious cross-race correlation
setorder(eda_data, Race, Driver, LapNumber)
seq_lengths <- eda_data[, .N, by = .(Race, Driver)]
setorder(seq_lengths, -N)
best_race <- seq_lengths$Race[1]
best_driver <- seq_lengths$Driver[1]
sample_ts <- eda_data[Race == best_race & Driver == best_driver]
acf_list <- lapply(c("LapTime (s)", "TyreLife"), function(feat) {
ts_vec <- sample_ts[[feat]]
# Forced numeric and strip NAs
ts_vec <- as.numeric(ts_vec)
ts_vec <- ts_vec[!is.na(ts_vec)]
if (length(ts_vec) < 5) return(NULL)
acf_obj <- acf(ts_vec, plot = FALSE, lag.max = min(20, length(ts_vec) - 1))
acf_dt <- data.table(Lag = as.integer(acf_obj$lag), ACF = as.numeric(acf_obj$acf))
ci <- qnorm((1 + 0.95) / 2) / sqrt(length(ts_vec))
ggplot(acf_dt, aes(x = Lag, y = ACF)) +
geom_hline(yintercept = c(0, ci, -ci), linetype = c("solid", "dashed", "dashed"),
color = c("black", "#c1121f", "#c1121f")) +
geom_segment(aes(xend = Lag, yend = 0), linewidth = 0.8, color = "#0077b6") +
labs(title = paste("ACF:", feat),
subtitle = sprintf("%s at %s", best_driver, best_race),
x = "Lag (Laps)", y = "Autocorrelation") +
theme_f1(base_size = 9)
})
acf_list <- Filter(Negate(is.null), acf_list)
if (length(acf_list) > 0) {
p_acf <- wrap_plots(acf_list, ncol = 2) +
plot_annotation(
title = "Sequential Autocorrelation (ACF)",
subtitle = "Significant lags (blue bars crossing red dashed line) justify lag/rolling features",
caption = DA_CAPTION
)
print(p_acf)
}Key Findings:
LapTime (s) and TyreLife — confirmed
sequential dependency.laptime_lag1/2/3 featureslaptime_rolling3 featurestyre_age_rate and deg_rolling3
featuresLag features introduce first-lap NAs for every Race × Driver sequence. The missingness profile is structurally patterned, not random:
| Feature | NA_Count | NA_Pct | Root_Cause |
|---|---|---|---|
| laptime_lag1 | 14,942 | 3.40% | Lap 1 of each Race×Driver |
| laptime_lag2 | 28,507 | 6.49% | Laps 1-2 of each Race×Driver |
| laptime_lag3 | 41,185 | 9.38% | Laps 1-3 of each Race×Driver |
| laptime_delta | 14,942 | 3.40% | Lap 1 of each Race×Driver |
| laptime_rolling3 | 28,507 | 6.49% | Laps 1-2 of each Race×Driver |
| tyre_age_rate | 14,942 | 3.40% | Lap 1 of each Race×Driver |
| deg_rolling3 | 28,507 | 6.49% | Laps 1-2 of each Race×Driver |
Imputation Choice: MICE (Predictive Mean Matching,
m=1, maxit=5, seed=2026) was
selected over mean/median fill because:
P1/P99 bounds were applied to 471 continuous features identified with \(|\text{skewness}| > 0.8\) or \(\text{kurtosis} > 1\):
| Condition | 3-Fold CV AUC | Impact |
|---|---|---|
| Pre-Winsorization (Raw Tails) | 0.94621 | — |
| Post-Winsorization (P1/P99 Clipped) | 0.94622 | Neutral (+0.00001) |
Decision: Winsorization has neutral impact on AUC
(+0.00001), but P1/P99 bounds are retained for tree-model split
stability. Extreme heavy-tail kurtosis (e.g., LapTime (s)
with kurtosis = 9682) is contained without distorting the core split
structure.
Throwing 646 raw and polynomial features directly into a tree ensemble is a recipe for overfitting and sluggish training. Before letting the models see the data, I built a ruthless pruning cascade to strip out noise and collinear redundancy:
\[\text{Raw Numerics (646)} \xrightarrow{\text{NZV Filter (-9)}} 637 \xrightarrow{\text{Corr Filter } |r|>0.95 \text{ (-357)}} 280 \xrightarrow{\text{VIF Check (0)}} 280 \xrightarrow{\text{OOF Target Enc (+3)}} 283\]
| Stage | Features_Remaining | Features_Removed | Technical_Justification |
|---|---|---|---|
| Raw Numeric Features | 646 | 0 | Initial polynomial, interaction, and sequential expansion |
| Near-Zero Variance (NZV) Pruning | 637 | 9 | Removal of uninformative constants and invariant bins |
| Pearson High-Correlation (|r| > 0.95) | 280 | 357 | Pruning collinear duplicates (|r| > 0.95) to protect tree depth |
| VIF Filtering (> 5.0) | 280 | 0 | Confirmation of multivariate independence across continuous features |
| Out-of-Fold Target Encoding (Driver, Compound, Race-Compound) | 283 | -3 | Leakage-safe categorical representation via 5-fold cross-validation |
To determine the optimal feature roster without manual threshold bias, consensus TreeSHAP attributions were computed across all gradient boosting learners.
# ══════════════════════════════════════════════════════════════════════════════
# HERO CODE CHUNK 2: COCHRAN'S SAMPLE SIZE & SHAP STABILITY AUDIT
# ══════════════════════════════════════════════════════════════════════════════
audit_shap_convergence <- function(train_mat, shap_model, sample_sizes = c(1000, 2000, 5000, 10000, 20000)) {
# 1. Cochran's Formula for Population Proportion Sample Size (5% Margin of Error)
p_base <- 0.199
n_cochran <- ceiling((1.96^2 * p_base * (1 - p_base)) / (0.05^2)) # n = 245
# 2. Compute Ground Truth SHAP on Largest Subsample (50,000 rows)
set.seed(MAIN_SEED)
idx_truth <- sample(nrow(train_mat), min(50000L, nrow(train_mat)))
shap_truth <- colMeans(abs(predict(shap_model, xgb.DMatrix(train_mat[idx_truth, ]),
predcontrib = TRUE)[, -ncol(train_mat)]))
top77_truth <- names(sort(shap_truth, decreasing = TRUE))[1:77]
# 3. Evaluate Stability Across Increasing Sample Sizes
stability_results <- rbindlist(lapply(sample_sizes, function(n) {
idx_n <- sample(nrow(train_mat), n)
shap_n <- colMeans(abs(predict(shap_model, xgb.DMatrix(train_mat[idx_n, ]),
predcontrib = TRUE)[, -ncol(train_mat)]))
top77_n <- names(sort(shap_n, decreasing = TRUE))[1:77]
data.table(
SampleSize = n,
Spearman_Rho = round(cor(shap_n[names(shap_truth)], shap_truth, method = "spearman"), 4),
Top77_Overlap = length(intersect(top77_n, top77_truth)),
Convergence = ifelse(length(intersect(top77_n, top77_truth)) == 77,
"Optimal (100%)", "Sub-optimal")
)
}))
return(stability_results)
}Cochran’s formula for a population proportion yields a minimum sample size of \(N = \lceil\frac{1.96^2 \cdot 0.199 \cdot 0.801}{0.05^2}\rceil = 245\) rows. However, SHAP ranking stability requires larger samples. The convergence audit evaluated sample sizes from 1,000 to 20,000 against a 50,000-row ground truth:
| Sample_Rows | Spearman_Rho | Top77_Overlap | Execution_Time | Status |
|---|---|---|---|---|
| 1,000 | 0.9835 | 71 / 77 | 0.5s | Sampling Noise |
| 2,000 | 0.9904 | 73 / 77 | 0.9s | Sampling Noise |
| 3,000 | 0.9940 | 74 / 77 | 1.3s | High Agreement |
| 4,000 | 0.9959 | 75 / 77 | 1.8s | High Agreement |
| 5,000 | 0.9970 | 76 / 77 | 2.2s | High Agreement |
| 10,000 | 0.9986 | 77 / 77 (100%) | 4.4s | Optimal Convergence |
| 20,000 | 0.9994 | 77 / 77 (100%) | 8.9s | Redundant Compute |
Convergence Decision: Feature ranking stabilizes at \(N = 10{,}000\) with Spearman rank correlation \(\rho > 0.98\) and 100% top-feature overlap. Beyond 10,000 rows, additional compute yields diminishing returns (\(\rho\) only improves from 0.9986 to 0.9994).
To independently validate the SHAP-selected feature roster, a separate Random Forest (600 trees) impurity importance baseline was computed:
| Metric | Value | Interpretation |
|---|---|---|
| Spearman Rank Correlation (ρ) | 0.5036 | Moderate rank agreement — boosting-specific interactions explain divergence |
| Top-20 Core Signal Overlap | 13 / 20 (65.0%) | 65% overlap confirms core signal; divergence in tail features expected |
| Full 77-Feature Roster Jaccard Overlap | 100.0% (77 / 77) | Every SHAP-selected feature is independently corroborated by RF impurity |
Concordance Interpretation: The moderate Spearman \(\rho = 0.5036\) is expected — GBDTs leverage gradient-based sequential splits that capture feature interactions invisible to single-tree impurity measures. The critical finding is 100% Jaccard overlap at the full 77-feature level: every feature selected by SHAP is independently corroborated by Random Forest impurity, confirming no spurious SHAP artifacts.
The dynamic micro-sweep evaluated feature counts from 76 to 91 and peaked at 77 features (AUC = 0.949265 at iteration 1752):
| Rank | Feature | Mean_Abs_SHAP | Domain_Role |
|---|---|---|---|
| 1 | LapTime_Delta_sq | 0.673340 | Lap-over-lap pace acceleration cliff |
| 2 | RaceProgress_minus_Stint | 0.438448 | Stint lifecycle progression within race |
| 3 | Year_log | 0.416023 | Season-level technical regulation trend |
| 4 | TyreLife_x_Stint | 0.283204 | Compound age cumulative wear |
| 5 | TyreLife_to_RaceProgress_ratio | 0.263037 | Normalized degradation relative to race distance |
| 6 | Position_Change_sq | 0.253028 | Overtake/positional defense stress |
| 7 | Race | 0.243777 | Circuit layout & pit loss time structure |
| 8 | drv_cmp_pit_rate | 0.243043 | Driver pit tendency baseline on compound |
| 9 | ext_pit_rate_cube | 0.198739 | Historical prior pit probability |
| 10 | row_max | 0.164135 | Maximum row-level degradation intensity |
| 11 | stops_so_far_cube | 0.126615 | Strategy stop progression |
| 12 | Compound | 0.123531 | Baseline tire compound softness category |
| 13 | TyreLife_x_LapTime (s) | 0.113355 | Pace degradation interaction |
| 14 | Stint_x_laptime_lag1 | 0.110036 | Sequential lag pace trend across stint |
| 15 | LapTime_Delta | 0.085013 | Instantaneous single-lap delta |
0 interaction features survived the +0.0005 significance hurdle:
| # Interactions | Total Features | Holdout AUC | Δ vs Baseline | Verdict |
|---|---|---|---|---|
| 0 | 77 | 0.949266 | +0.00000 | Baseline |
| 5 | 82 | 0.948892 | -0.00037 | Discarded |
| 10 | 87 | 0.948946 | -0.00032 | Discarded |
Decision: Neither 5 nor 10 SHAP-derived interaction features improved AUC beyond the +0.0005 significance threshold. All interactions dropped. Trees already learn these interactions through their split structure. Final modeling matrix: 77 features.
Grid search is dead. To find the true global optima without burning weeks of compute, I built a 4-stage optimization engine. It starts wide and fast, then zooms in with high-precision Bayesian surrogates:
# ══════════════════════════════════════════════════════════════════════════════
# HERO CODE CHUNK 3: COARSE-TO-FINE PARAMETER SPACE CONTRACTION
# ══════════════════════════════════════════════════════════════════════════════
refine_ps <- function(ps_obj, best_params, width = 0.25) {
new_params <- list()
fixed_vals <- list()
log_flags <- list()
ps_dt <- data.table::as.data.table(ps_obj)
for (i in seq_len(nrow(ps_dt))) {
par_id <- ps_dt$id[i]
if (!par_id %in% names(best_params)) next
val <- best_params[[par_id]]
p_class <- ps_dt$class[i]
raw_lo <- ps_dt$lower[i]
raw_hi <- ps_dt$upper[i]
# Auto-detect logscale transformations
is_log <- FALSE
if (is.numeric(val) && length(val) == 1L && !is.na(val) && val > 0) {
if ((val < raw_lo - 1e-5 || val > raw_hi + 1e-5) &&
(log(val) >= raw_lo - 1e-5 && log(val) <= raw_hi + 1e-5)) is_log <- TRUE
}
log_flags[[par_id]] <- is_log
orig_lo <- if (is_log) exp(raw_lo) else raw_lo
orig_hi <- if (is_log) exp(raw_hi) else raw_hi
if (p_class == "ParamDbl") {
lo <- max(orig_lo, val * (1 - width))
hi <- min(orig_hi, val * (1 + width))
if (hi <= lo) { lo <- orig_lo; hi <- orig_hi }
new_params[[par_id]] <- paradox::p_dbl(lower = lo, upper = hi, logscale = is_log)
} else if (p_class == "ParamInt") {
lo <- max(orig_lo, floor(val * (1 - width)))
hi <- min(orig_hi, ceiling(val * (1 + width)))
if (hi <= lo) { lo <- max(orig_lo, val - 1L); hi <- min(orig_hi, val + 1L) }
new_params[[par_id]] <- paradox::p_int(lower = as.integer(lo), upper = as.integer(hi))
} else {
fixed_vals[[par_id]] <- val
}
}
list(ps = do.call(paradox::ps, new_params), fixed = fixed_vals, log_flags = log_flags)
}| Model | R1_Coarse | R1.5_SHAP_Wide | R3_RF_25pct | R4_GP_5pct | Audit_CV_Mean | Audit_CV_SD | Total_Gain |
|---|---|---|---|---|---|---|---|
| XGBoost | 0.94921 | 0.95007 | 0.950256 | 0.950241 | 0.95000 | 0.00111 | +0.001031 |
| XGBoost-DART | 0.94832 | 0.94872 | 0.949065 | 0.949109 | 0.94892 | 0.00100 | +0.000789 |
| LightGBM | 0.94742 | 0.94839 | 0.948505 | 0.948414 | 0.94837 | 0.00093 | +0.000994 |
| LightGBM-GOSS | 0.94738 | 0.94821 | 0.948321 | 0.948405 | 0.94840 | 0.00109 | +0.001025 |
| CatBoost | 0.92837 | 0.92959 | 0.929881 | 0.929894 | 0.92966 | 0.00158 | +0.001524 |
| CatBoost-Bayesian | 0.94825 | 0.94912 | 0.949184 | 0.949241 | 0.94898 | 0.00111 | +0.000991 |
| Ranger RF | 0.94380 | 0.94571 | 0.945705 | 0.944837 | 0.94549 | 0.00130 | +0.001037 |
Key Findings:
To prevent the models from simply memorizing specific tracks (a
massive leakage risk in F1 telemetry), I grouped the 5-fold CV strictly
by Race. If a model hasn’t seen Monaco or Silverstone
during training, it has to predict them out-of-fold.
| Model | OOF_AUC | Prediction_SD | Variance_Weight_Prior |
|---|---|---|---|
| XGBoost | 0.930176 | 0.3338 | 0.1203 |
| XGBoost-DART | 0.926817 | 0.2621 | 0.1520 |
| LightGBM | 0.928323 | 0.2631 | 0.1515 |
| LightGBM-GOSS | 0.929770 | 0.2676 | 0.1490 |
| CatBoost | 0.907665 | 0.3075 | 0.1303 |
| CatBoost-Bayesian | 0.932986 | 0.3515 | 0.1144 |
| Ranger RF | 0.923170 | 0.2166 | 0.1825 |
OOF vs CV Gap: OOF AUC scores (0.908–0.933) are systematically lower than CV scores (0.930–0.950). That’s by design: OOF tests generalization across 5 disjoint Race groups, while holdout uses a random 80/20 split. The gap tells us the models are picking up Race-specific patterns, and the grouped K-Fold is doing its job preventing leakage.
Variance-Penalized Weights: Ranger receives the highest weight (0.1825) due to its lowest prediction standard deviation (0.2166), while CatBoost-Bayesian receives the lowest weight (0.1144) despite its highest OOF AUC (0.932986) — its high prediction variance (0.3515) is penalized.
A single model is rarely enough to maximize ranking separation in a tight leaderboard. I trained 11 different ensembling techniques on the Platt-calibrated OOF outputs. Here is the custom implementation for the winning greedy Hill-Climb ensemble:
# ══════════════════════════════════════════════════════════════════════════════
# HERO CODE CHUNK 4: CARUANA GREEDY ENSEMBLE SELECTION WITH REPLACEMENT
# ══════════════════════════════════════════════════════════════════════════════
caruana_hill_climb <- function(oof_matrix, true_labels, n_iterations = 200L) {
model_names <- colnames(oof_matrix)
n_models <- length(model_names)
counts <- setNames(rep(0L, n_models), model_names)
best_auc <- -Inf
for (iter in seq_len(n_iterations)) {
best_candidate <- NULL
best_candidate_auc <- -Inf
for (m in model_names) {
trial_counts <- counts
trial_counts[[m]] <- trial_counts[[m]] + 1L
trial_weights <- trial_counts / sum(trial_counts)
trial_pred <- as.numeric(oof_matrix %*% trial_weights)
trial_auc <- Metrics::auc(true_labels, trial_pred)
if (trial_auc > best_candidate_auc) {
best_candidate_auc <- trial_auc
best_candidate <- m
}
}
counts[[best_candidate]] <- counts[[best_candidate]] + 1L
best_auc <- best_candidate_auc
}
final_weights <- counts / sum(counts)
return(list(weights = final_weights, oof_auc = best_auc))
}| Rank | Method | OOF_AUC | Ensemble_Mechanics |
|---|---|---|---|
| 1 | 🏆 Hill-Climb (Caruana) | 0.934387 | Greedy with-replacement iterative weighting (200 cycles) |
| 2 | Bagging Meta-Stacker | 0.934334 | 50-fold bootstrap aggregated logistic regressions |
| 3 | Logistic Stacking (GLM) | 0.934333 | Multivariate logistic regression on calibrated probabilities |
| 4 | Fold-Aware XGBoost Meta | 0.934239 | Cross-validated GBDT stacker respecting race group boundaries |
| 5 | Bayesian Model Averaging (BMA) | 0.933620 | Softmax-weighted bootstrap AUC/SE variance scaling |
| 6 | Sigmoid Rank-Average | 0.931761 | Nonlinear rank transformation eliminating scale distortions |
| 7 | Diversity-Pruned Average | 0.931498 | Correlation-pruned (|r| > 0.998) and AUC-gated (> 0.85) mean |
| 8 | Simple Mean Average | 0.931498 | Unweighted arithmetic mean of base models |
| 9 | ElasticNet Stacker (GLMNet) | 0.931442 | L1/L2 regularized logistic regression penalty |
| 10 | Isotonic Monotonic Calibration | 0.931442 | Piecewise monotonic regression on raw probabilities |
| 11 | Variance-Weighted Average | 0.931411 | Inverse prediction variance weighting (1/sigma) |
Ensemble Analysis:
For the holdout test, every model was retrained on all of train_raw with 1.1× the optimal iteration count to account for the larger dataset scale.
| Method | OOF AUC | Holdout AUC | Gap |
|---|---|---|---|
| Logistic Stacking | 0.934333 | 0.970153 | +0.035820 |
| Bagging | 0.934334 | 0.970124 | +0.035790 |
| ElasticNet Stacker | 0.931442 | 0.966230 | +0.034788 |
| Weighted Average | 0.931411 | 0.965163 | +0.033752 |
| Isotonic Calibration | 0.931442 | 0.964701 | +0.033258 |
| Diversity-Pruned | 0.931498 | 0.963287 | +0.031788 |
| Simple Average | 0.931498 | 0.963287 | +0.031788 |
| Fold-Aware XGBoost | 0.934239 | 0.963245 | +0.029006 |
| Hill-Climb (Caruana) | 0.934387 | 0.962844 | +0.028457 |
| Sigmoid Rank-Average | 0.931761 | 0.959735 | +0.027974 |
| BMA | 0.933620 | 0.959629 | +0.026009 |
Generalization Interpretation:
Predictions were generated with Test-Time Augmentation (TTA) across 3 seeds using 0.1% Gaussian continuous noise perturbation.
| Metric | Value |
|---|---|
| Total Submission Rows | 188,165 |
| Prediction Range [Min, Max] | [0.012272, 0.838468] |
| Mean Predicted Probability | 0.194906 |
| Median Predicted Probability | 0.028042 |
| Class Imbalance Alignment | Aligned with 19.90% prior base rate |
| TTA Seeds Evaluated | 3 (Clean, +0.1% Noise 1, +0.1% Noise 2) |
| Rank | Model / Ensemble | Private LB AUC | Public LB AUC |
|---|---|---|---|
| 1 | 🏆 Full Ensemble | 0.94589 | 0.94573 |
| 2 | Bagging Meta-Stacker | 0.94580 | 0.94568 |
| 3 | Hill-Climb (Caruana) | 0.94485 | 0.94453 |
| 4 | CatBoost-Bayesian (Single) | 0.94443 | 0.94409 |
| 5 | BMA Ensemble | 0.94443 | 0.94409 |
| 6 | Blend (HC + Rank Avg) | 0.94430 | 0.94435 |
| 7 | Simple Arithmetic Average | 0.94206 | 0.94189 |
| 8 | All Tree Average | 0.94206 | 0.94189 |
| 9 | Variance-Weighted Average | 0.94204 | 0.94188 |
| 10 | LightGBM GOSS | 0.94134 | 0.94098 |
|
4 CSV sources → 439,140 training rows × 16 features |
|
Target distribution, compound analysis, correlation matrix, KS statistics |
|
20+ physics-informed features → 646 total numeric features |
|
Predictive Mean Matching for 7 lag-feature NA columns (3.4%–6.5% missingness) |
|
P1/P99 clipping on 471 features — neutral AUC impact (+0.00001), retained for stability |
|
646 → 637 (NZV) → 280 (|r|>0.95) → 280 (VIF) → 283 (OOF target enc) |
|
283 → 77 pure-signal features via consensus SHAP micro-sweep (AUC = 0.949265) |
|
R1 (Coarse) → R1.5 (SHAP-Wide) → R3 (±25% RF) → R4 (±5% GP Fine-Tune) |
|
3-Fold CV per model verifying holdout stability (σ ≤ 0.0016 for all 7 models) |
|
7 learners × 5 folds with Race-grouped boundaries |
|
Platt-calibrated, AUC-gated, Hill-Climb winner (OOF 0.934387) |
|
Holdout validation: Logistic Stacking 0.9702, Hill-Climb 0.9628 |
|
TTA (3 seeds) → Full Ensemble → Kaggle Private LB AUC 0.94589 |
Predicting F1 pit stops is inherently noisy — team strategies change on the fly, safety cars scramble the field, and sudden rain can force the entire grid into the pits at once. But by transforming raw telemetry into degradation-aware features and wrapping them in a strictly cross-validated, 11-method meta-ensemble, I was able to extract a stable, competitive signal from the chaos.
This outcome reinforced a core engineering philosophy: do the simple thing that works. Rather than relying on the blind sophistication of deep learning, taking an empirical approach—building simple, physics-informed features and interpreting them cleanly—yielded a far more robust system.
The biggest takeaway for me was that feature engineering mattered more than model selection. Majority of the top 15 SHAP features were engineered, not raw — the physics-informed transformations (squared degradation, rolling pace deltas, compound×stint interactions) gave the trees far more to work with than the original columns ever could. The Coarse-to-Fine tuning helped, but the features did the heavy lifting.
The other lesson: OOF validation is king. Hill-Climb won both OOF (0.934387) and Kaggle’s Private LB (0.94485) — a satisfying validation that the grouped K-Fold setup correctly identified the best ensemble strategy, while the Bagging and Full Ensembles managed to extract slightly more generalization capacity (0.94589). CatBoost-Bayes as a single model came surprisingly close at 0.94443. That level of alignment between OOF and competition results is exactly what we want from a rigorous cross-validation framework.
| Resource | Link |
|---|---|
| Kaggle Competition | Playground Series S6E5 |
| Kaggle Profile | Kaggle Profile |
| RPubs Profile | RPubs Profile |
| LinkedIn Profile | LinkedIn Profile |
Physics-Informed ML Research & Engineering by Jay
Prakash
Built with R, mlr3, XGBoost, LightGBM, CatBoost,
Ranger
Originally Published on RPubs — July 2026 | Actively
Maintained & Optimized