Predicting at-risk / unhealthy / fit students from 690,088 records β optimized for balanced accuracy on a brutally imbalanced target
A leakage-safe, seed-bagged LightGBM Β· XGBoost Β· CatBoost ensemble with Optuna-tuned hyperparameters, per-fold target encoding, KMeans clustering, a self-selecting stacking meta-learner, probability-threshold optimisation for a brutally imbalanced target, and confidence-gated pseudo-labeling β evaluated entirely on 5-fold stratified CV so the noisy public board never got to lie to me.
690,088 students, 13 features, 3 classes split 85.9% / 8.4% / 5.8% β and the metric is balanced accuracy, not plain accuracy. That imbalance-plus-metric combination is the entire game; every technique below exists because of it.
| Best local CV (balanced accuracy) | 0.9521 |
| Public leaderboard (20% slice) | 0.9500 |
| Base models per round | 5 folds Γ 3 families Γ 3 seeds = 45 fits |
| Train / test rows | 690,088 / 295,753 |
| Total training jobs, start to finish | ~280 |
The five folds hold inside a 0.003 band β that tightness is the result. A model that doesn't collapse on any single held-out fold is a model that survives the private 80% shakeup. The public leaderboard is a blurry 20% mirror; it even reordered my own two submissions.
A model that always guesses at-risk is right 85.9% of the time on plain accuracy and looks brilliant β until the metric asks "how many fit and unhealthy students did you actually catch?" and the answer is zero.
Balanced accuracy is the average of per-class recall:
balanced_accuracy = mean(recall_at-risk, recall_unhealthy, recall_fit)
The naive always-at-risk model scores (1.0 + 0 + 0) / 3 = 0.333 β garbage. So the metric forces the model to fight for the two thin minority classes. Class weights, threshold tuning, the blend threshold β nearly every design choice below traces back to this one fact.
| Class | Share of data |
|---|---|
| at-risk | 85.9% |
| unhealthy | 8.4% |
| fit | 5.8% |
| Model | Role | Tuned CV |
|---|---|---|
| LightGBM | leaf-wise growth, fast, the numeric workhorse | 0.9490 |
| XGBoost | level-wise growth, strong regularisation, tree_method="hist" |
0.9480 |
| CatBoost | native categorical handling, ordered boosting | 0.9475 |
| Ensemble | 9-model blend + stack + threshold tuning | 0.9521 |
Three gradient-boosted families, each seed-bagged three times, then averaged and stacked into the final ensemble. CatBoost earns its seat on the six categorical columns; LightGBM and XGBoost carry the numerics. They're kept together because they're decorrelated β each makes different mistakes, so averaging beats any one alone.
| Step | Technique | Local CV |
|---|---|---|
| Baseline | single LightGBM, argmax |
0.876 |
+Threshold |
Nelder-Mead multipliers on probabilities | 0.949 |
+Ensemble |
LGB + XGB + CatBoost weighted blend | 0.9510 |
+SeedBag |
3 random seeds per family | 0.9515 |
+Pseudo |
retrain on top-2% confident test rows | 0.9521 β |
MegaΒ·Stack |
+ target-encoding + KMeans + LR/LGB meta-learner | 0.9515 |
Every rung was kept only after it beat the previous one on local CV. The last row is the one most write-ups would quietly omit: the fancier mega-pipeline scored below the leaner pseudo-labeled ensemble. More machinery isn't automatically more signal β sometimes it just adds noise that cancels its own gain. Left in on purpose.
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor':'#12303D','primaryTextColor':'#EAF2F1','primaryBorderColor':'#38C6C0',
'lineColor':'#38C6C0','secondaryColor':'#0E2733','tertiaryColor':'#1E4150',
'fontSize':'15px','fontFamily':'Segoe UI, sans-serif'}}}%%
flowchart LR
A[(train Β· test<br/>690k / 296k)] --> B[Feature engineering<br/>ratios Β· squares Β· missingness]
B --> C{per fold}
C --> D[Target encoding<br/>3 cols Γ 6 cats]
C --> E[KMeans cluster<br/>impute β scale β k=3]
D --> F[9 base models<br/>LGBΒ·XGBΒ·CB Γ 3 seeds]
E --> F
F --> G[Seed + family average]
G --> H{meta-learner race}
H -->|higher OOF| I[Stack: LR vs shallow LGB]
I --> J[Threshold optim<br/>Nelder-Mead, multi-start]
J --> K{confident β₯ 0.9997?}
K -->|yes| L[Pseudo-label round 2]
K -->|no| M[submission.csv]
L --> N{round2 OOF > round1?}
N -->|yes| M
N -->|no| M
classDef io fill:#0E2733,stroke:#F2B134,color:#EAF2F1;
classDef proc fill:#12303D,stroke:#38C6C0,color:#EAF2F1;
classDef dec fill:#1E4150,stroke:#E8604C,color:#EAF2F1;
class A,M io;
class B,D,E,F,G,I,J,L proc;
class C,H,K,N dec;
Raw rows get engineered features β every fold builds its own target-encoding and KMeans clusters (so nothing leaks) β nine base models train on that fold β their probabilities average, then feed a meta-learner chosen by measurement, not assumption β a threshold optimiser re-balances the decision for an imbalanced metric β the most confident test rows get pseudo-labelled back in for a second round, kept only if it actually helps.
LightGBM (leaf-wise, fast, the numeric workhorse) Β· XGBoost (level-wise, strongly regularised, tree_method="hist" + class-balanced weights) Β· CatBoost (native categorical handling, ordered boosting, slowest but earns it on the six categorical columns).
Gradient boosting means: grow one shallow tree, look at what it got wrong, grow the next tree to fix specifically that, repeat for hundreds of rounds, sum the votes. For mixed-type tabular data at this size, that's still the sweet spot β neural nets typically lose to trees on tables like this one.
The knobs we set (hyperparameters). Found by Optuna β a Bayesian search that tries configs, scores them on a cheap 3-fold proxy, and intelligently proposes the next one (20 trials per model):
| Model | Key tuned values |
|---|---|
| LightGBM | learning_rate 0.0104 Β· num_leaves 37 Β· min_child_samples 12 Β· subsample 0.74 Β· colsample_bytree 0.52 Β· reg_alpha 0.001 Β· reg_lambda 0.036 |
| XGBoost | learning_rate 0.0329 Β· max_depth 4 Β· min_child_weight 4 Β· subsample 0.67 Β· colsample_bytree 0.85 Β· reg_alpha 1.01 Β· reg_lambda 0.003 |
| CatBoost | learning_rate 0.0585 Β· depth 7 Β· l2_leaf_reg 2.76 |
Plus structural choices fixed by hand: up to 3000 trees with early stopping (patience 150), balanced class weights, 3 seeds, 5 folds, target-encoding alpha=10, KMeans k=3, pseudo top-2%. Roughly 17 tuned + ~10 hand-set knobs.
The neural-net notion of "parameters." Trees don't have a meaningful parameter count the way a neural net does β a net's parameters are millions of learned weights; a tree's are its split thresholds and leaf values. The whole ensemble sits in the low millions of stored split/leaf values, but that number isn't what characterizes the model β the hyperparameter table above is the actual spec.
Leakage-safe per-fold target encoding β For each categorical, the smoothed probability of each of the three classes inside that category is computed only on the training fold, then mapped onto val/test. Six categories Γ three classes = 18 high-signal columns, without letting the validation target leak back in. Smoothing (alpha=10) pulls rare categories toward the global average.
Fold-local KMeans on scaled numerics β Impute (median) β StandardScaler β KMeans(k=3), fit on the train fold only. Gives the trees a "which cluster of students is this?" shortcut.
Self-selecting stacking meta-learner β The 9 models' probabilities become features for a second-level model. Both a Logistic Regression and a shallow LightGBM are trained on the OOF split; whichever scores higher wins β it chose LightGBM both rounds.
Threshold optimisation for an imbalanced metric β Instead of argmax, each class's probability is multiplied by a tunable factor before the max is taken, searched via Nelder-Mead to maximise balanced accuracy. Final multipliers were roughly [1.16, 0.96]. This single trick took the baseline from 0.876 β ~0.949 β the highest-impact-per-line-of-code change in the whole project.
Confidence-gated pseudo-labeling β Only the top-2% most confident test predictions (probability β₯ 0.9997, 5,916 rows) are added as labelled rows and the whole pipeline is retrained. Round 2 is kept only if its OOF strictly beats Round 1 (it did: 0.9515 β 0.9521).
Two data-hygiene bugs that cost real time β CatBoost hard-crashes on a single NaN inside a categorical column; filling with the string "missing" turns the absence into its own learnable category. All numeric columns were downcast to float32, halving RAM with no accuracy cost.
kaggle/
βββ run.py # lean ensemble β 0.9521 OOF (the public-tested file)
βββ run_local.py # mega pipeline (Optuna + stack + TE + KMeans) β 0.9515 OOF
βββ blend.py # probability blend of the two pipelines (weight + threshold search)
βββ train.csv / test.csv
βββ submission_pseudo.csv # 0.9521 local Β· 0.94976 public
βββ submission_final.csv # 0.9515 local Β· 0.95000 public
βββ feature_importance.xls
pip install pandas numpy scikit-learn lightgbm xgboost catboost optuna
# fast re-run (tuning skipped β reuses the params Optuna already found)
# in run_local.py set: SKIP_TUNING = True
python run_local.py # writes submission_final.csv + *.npy probs for blending
python run.py # writes submission_pseudo.csv
# blend the two pipelines' saved probabilities (no retraining)
python blend.py # writes submission_blend.csvTuning from scratch is ~280 full model fits on 690k rows β hours, not minutes.
SKIP_TUNING=Truereuses the discovered hyperparameters and skips the warm-up entirely.
| Stage | Model fits |
|---|---|
| Optuna tuning | 20 trials Γ 3 folds Γ 3 models = 180 |
| Main pipeline, Round 1 | 5 folds Γ 9 models = 45 |
| Stacking meta-learner | ~10 |
| Pseudo-label retrain, Round 2 | 5 folds Γ 9 models = 45 |
| Total | ~280 full trainings on 690k rows |
| Pipeline | Round-1 OOF | Round-2 OOF | Public (20%) | Note |
|---|---|---|---|---|
run.py (lean + pseudo) |
0.9496 | 0.9521 | 0.94976 | best local CV |
run_local.py (mega) |
0.9499 | 0.9515 | 0.95000 | best public of the two |
| Leaderboard #1 | β | β | 0.95316 | top-10 span = 0.00028 |
The local-CV ordering and the public ordering disagree β that's the 20% slice being noisy, not a flaw. The top ten on that board are separated by less than the noise floor, which is exactly the regime where a robust, un-overfit model climbs on the private 80% while the public-board probers fall.
axis=0vsaxis=1on a(samples, models, classes)array isn't a typo β it's the difference between a working ensemble and a shape-mismatch crash on fold 1.- CatBoost will hard-crash on a single
NaNin a categorical column. LightGBM and XGBoost forgive you; CatBoost does not. - KMeans needs imputed + scaled numerics, or the largest-magnitude column silently owns the distance metric.
- Local CV is the only number you can trust. The public leaderboard is a 20% mirror and it will reorder your own models out of pure noise.
- Fancier β better. The mega-stack lost to the lean pseudo ensemble. The discipline of "only keep it if CV improves" is the whole job.
- Blending needs probabilities, not labels. A majority vote of two CSVs can't break ties; the gain lives in averaging the confidence and re-tuning the threshold.
- The metric shapes the model, not the other way around. Balanced accuracy on an 85.9%-majority target is the reason threshold tuning, class weights, and the whole minority-rescue strategy exist in the first place.