# Milestone 1: generate the data (X, y, group) with a fixed seed and
# confirm the class imbalance is ~15%.ml-001 · Train/validation/test splitting — Python
Problem statement
Open this practice in GitHub Codespaces runs in your browser; open this folder under practices/ after it starts
Generate a synthetic imbalanced binary-classification dataset (n = 1500, about 15% positive, 4 informative + 6 noise features, plus one high-cardinality categorical that is pure noise), all in-script with a fixed seed. Then:
- Write a stratified 60/20/20 train/validation/test split by hand and verify each split preserves the 15% positive share.
- Show that standardizing before vs after splitting (scaler fit on all rows vs on training rows only) leaves a logistic regression’s validation accuracy essentially unchanged.
- Show that target-encoding the noise categorical with all rows (peeking) makes it look predictive on validation, while a train-only encoding correctly shows no signal. Use AUC alongside accuracy.
- Fit an unconstrained decision tree vs a regularized one, report the train/val/test accuracy of each, then tune
max_depthon validation and evaluate the winner on the test set exactly once.
You know it is correct when the splits partition the rows with preserved class balance, the leaky encoding’s validation AUC is far above 0.5 while the proper one sits near 0.5, and the overfit tree shows a much larger train-val gap than the regularized one.
Learning objectives
After completing this practice you should be able to:
- explain why held-out data exists and what each of the three splits is for
- implement a stratified multi-way split from scratch with numpy
- distinguish harmless preprocessing-before-split (feature scaling) from catastrophic leakage (target encoding on pooled labels), and demonstrate the difference empirically
- read a train/val/test accuracy table and diagnose overfitting from the gap
- run an honest tuning loop: select on validation, touch test once
Concepts and definitions
See concepts for the shared background. Python-specific notes: numpy.random.default_rng(seed) gives an isolated generator so the data and the split can be reproduced independently; sklearn.linear_model.LogisticRegression applies an L2 penalty by default (C=1.0), so feature scaling can move its predictions slightly — unpenalized models are affine-invariant and would not move at all.
Input / output description
| Item | Description |
|---|---|
| Input | Nothing external — make_synthetic_data(seed=42) builds X (1500 x 10 float), y (1500 ints, 15% ones), group (1500 ints in 0..399, independent of y) in memory |
| Output | Printed report: split sizes and positive shares; validation accuracy with scaler fit on all vs train rows; validation accuracy + AUC for proper vs leaky target encoding; train/val/test accuracy for the overfit and regularized trees; tuned depth and the single test-set evaluation. run_experiments() also returns all numbers as a dict |
Before-coding checklist
My attempt
Work here. Run cells interactively; nothing below overwrites this section.
# Milestone 2: stratified 60/20/20 split by hand; check the three index
# sets are disjoint, cover everything, and preserve the positive share.# Milestone 3: standardization experiment — scaler fit on ALL rows vs on
# train only; compare logistic-regression validation accuracy.# Milestone 4: target-encoding experiment — encode `group` with all labels
# vs train labels only; compare validation accuracy AND AUC.# Milestone 5: overfit vs regularized tree (train/val/test table), then
# tune max_depth on validation and touch the test set exactly once.Hint 1
For the stratified split, do not try to split the whole index vector at once. Loop over the two classes: collect that class’s indices with np.flatnonzero(y == cls), shuffle them, and slice off 60% / 20% / the rest. Concatenating the per-class slices automatically preserves the class balance in every split.
Hint 2
Write target_encode(group, y, fit_idx) so it only ever reads y[fit_idx]: compute each group’s mean of y over fit_idx rows and map it onto all rows (unseen groups get the global mean over fit_idx). Then the leak is a one-argument change — fit_idx=train_idx is proper, fit_idx=np.arange(n) is peeking. For the demonstration, evaluate with AUC as well as accuracy: with a 15% positive class, accuracy hides the leak because predicted probabilities rarely cross 0.5.
Complete solution
The official, tested script lives at solutions/solution.py. Run it with:
python practices/ml-001-train-validation-split/solutions/solution.pyCore code (the script adds a full report and returns every number):
def stratified_three_way_split(y, train_frac=0.6, val_frac=0.2, seed=42):
rng = np.random.default_rng(seed)
train_parts, val_parts, test_parts = [], [], []
for cls in np.unique(y):
idx = np.flatnonzero(y == cls)
rng.shuffle(idx)
n_train = int(round(train_frac * idx.size))
n_val = int(round(val_frac * idx.size))
train_parts.append(idx[:n_train])
val_parts.append(idx[n_train:n_train + n_val])
test_parts.append(idx[n_train + n_val:])
return (np.sort(np.concatenate(train_parts)),
np.sort(np.concatenate(val_parts)),
np.sort(np.concatenate(test_parts)))
def target_encode(group, y, fit_idx):
fit_group, fit_y = group[fit_idx], y[fit_idx]
global_mean = float(fit_y.mean())
means = {int(g): float(fit_y[fit_group == g].mean())
for g in np.unique(fit_group)}
return np.array([means.get(int(g), global_mean) for g in group])
# The two encodings differ by ONE argument:
enc_proper = target_encode(group, y, tr) # leak-free
enc_leaky = target_encode(group, y, np.arange(y.size)) # peekingKey output (seed 42): splits 900/300/300 all at 15.0% positive; standardize-before vs -after validation accuracy 0.937 vs 0.937 (difference 0.000); target encoding on the pure-noise categorical gives validation AUC 0.457 proper vs 0.857 leaky; overfit tree train/val/test = 1.000/0.883/0.887 (gap 0.117) vs regularized 0.909/0.893/0.890 (gap 0.016); tuned max_depth=7 scores 0.910 on validation and 0.907 on the once-touched test set.
Line-by-line explanation
make_synthetic_datafixes the imbalance exactly (225 ones, shuffled), then builds informative features by addingnp.outer(y, shifts)— each positive row’s informative features are shifted up by 0.8-1.6 standard deviations.group = rng.integers(0, 400, size=n)is drawn without looking aty, which is what makes any apparent predictive power of its encoding provably fake.stratified_three_way_splitshuffles within class and slices 60/20/20.int(round(...))on per-class counts gives 135/45/45 positives and 765/255/255 negatives — exactly 15% everywhere.standardize(fit_x, *apply_x)computesmu/sdfromfit_xonly. Calling it asstandardize(x, x)reproduces the “fit on everything” mistake;standardize(x[tr], x[tr], x[va])is the correct pattern.target_encodemaps each group to the mean ofyoverfit_idxrows. Themeans.get(..., global_mean)fallback handles groups that appear in validation but not in the fitting rows.auc_scoreis the Mann-Whitney statistic viascipy.stats.rankdata(average ranks, so ties are handled): the probability a random positive outscores a random negative.- In
run_experiments, experiment 2 fits a logistic regression on the encoded feature alone; a leaky feature with no real signal reaching validation AUC 0.857 is the whole lesson in one number. - The tuning loop fits one tree per depth 1..12, keeps the best validation accuracy, and only the final refit model ever sees
te— the single test-set touch.
Design decisions
- Hand-rolled split instead of
train_test_splittwice. The point of the practice is the mechanics: per-class shuffling makes stratification transparent, and rounding per class shows why the counts come out exact. - A pure-noise categorical for the leak demo. Because
groupis independent ofyby construction, any validation lift is proof of leakage — no need to argue about how much signal is real. - AUC alongside accuracy. With 15% positives, a model can leak heavily yet keep accuracy near the 0.85 majority baseline because probabilities rarely cross 0.5. The rank-based AUC exposes the leak (0.457 vs 0.857).
- One
target_encodefunction with afit_idxargument rather than two functions: it makes “the leak is just a wrong argument” literal, and it is exactly how leaks happen in real pipelines. min_samples_leaf=20kept fixed while tuning depth so validation compares a one-dimensional family; tuning two knobs on 300 rows invites selection noise.
Common mistakes
- Splitting after target-encoding “because the encoder is just feature engineering” — the encoder is part of the model and must be fit on train.
- Stratifying only train/test and letting the validation set drift off the base rate; with 300 rows and 15% positives, the share can easily land at 11% or 19% unstratified.
- Reporting the best validation accuracy (0.910) as the result instead of the once-touched test accuracy (0.907).
- Using accuracy alone to check for leakage on imbalanced data — the leaky encoding here moves accuracy by 2 points but AUC by 40 points.
- Re-tuning “just once more” after seeing the test score. That converts the test set into a second validation set, permanently.
- Forgetting a fallback for categories unseen in the training rows, which crashes (or silently NaNs) the proper encoding on validation.
Alternative solution
The scikit-learn idiom: two chained train_test_split calls with stratify=, and a Pipeline so preprocessing is structurally unable to leak:
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
x_tmp, x_te, y_tmp, y_te = train_test_split(
x, y, test_size=0.2, stratify=y, random_state=42)
x_tr, x_va, y_tr, y_va = train_test_split(
x_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=42) # 0.25*0.8=0.2
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))
pipe.fit(x_tr, y_tr) # scaler statistics come from x_tr only
pipe.score(x_va, y_va)Prefer this in real projects: Pipeline.fit guarantees every step’s statistics come from the training rows, and cross-validation composes with it. The hand-rolled version remains better for learning because nothing is hidden. For leak-resistant target encoding, TargetEncoder in sklearn.preprocessing uses internal cross-fitting.
Extensions and harder variants
- Repeat the split with 50 different seeds and plot the distribution of validation accuracy — how much of the train-val gap is split luck?
- Replace the 60/20/20 split with 5-fold cross-validation for tuning and keep the same untouched test set; compare the chosen depth.
- Make the leak subtler: target-encode with additive smoothing (
(sum + m*prior) / (count + m)) and find the smoothingmat which the leaky validation AUC stops being distinguishable from 0.5. - Add a time column and show why a random split leaks when the deployment regime is “train on the past, predict the future”.
- Shrink the positive class to 3% and observe stratification going from nice-to-have to mandatory (unstratified validation folds can lose the positive class almost entirely).
Review questions
Answer out loud before expanding.
Every tuning decision made while looking at a dataset optimizes for that dataset’s quirks. If you tune on the test set, the final score inherits that optimism and you have no clean data left to measure it. The validation set is the designated place to burn optimism; the test set stays clean precisely because it never influenced any choice.
It is technically leakage but practically negligible here: pooled feature means/sds are nearly identical to training-only ones at this sample size, and they carry no label information. It becomes dangerous when the statistic involves the target (target encoding), row identity, or when n is tiny or distributions shift across the split. Fix it for hygiene — a Pipeline makes it free — but do not expect conclusions to change.
From the validation labels themselves. With 400 groups over 1500 rows, each group has ~4 members, so one row’s label moves its group mean by ~0.25. Encoding with all rows bakes each validation row’s own label into its feature value; the model then partially reads the answer out of the feature. On new data (whose labels cannot be in the encoding) that signal does not exist — which the train-only encoding correctly shows (AUC 0.457, i.e., chance).
The 0.117 gap says the unconstrained tree memorized training noise — perfect recall of the fit data, no better generalization. The regularized tree’s 0.016 gap says its training score is an honest preview of held-out performance. Prefer the regularized tree: equal-or-better validation accuracy with far less variance, and its simpler structure is more stable across resamples.
Report 0.85 — that is the honest estimate. Do not go back and pick a different depth, because choosing a model using the test score makes the test set a validation set and the new number optimistic. A large val-test drop is itself information: the validation set was overused or too small, so next time hold out more data or use cross-validation for tuning.
Personal reflection
2026-08-10 — first pass. The AUC-vs-accuracy contrast in the leak demo is the part worth re-deriving from scratch next review: I initially expected the leak to show up in accuracy and it barely did.