Because cross-validation gives us one score per fold, we can:
Compare two models (A and B) on the same folds.
For each fold \(k\), compute the difference: \[d_k = \text{score}_k(A) - \text{score}_k(B).\]
Look at the mean and standard deviation of these differences.
If the differences are consistently positive (and not too noisy),
we have some evidence that A is genuinely better than B,
not just on one lucky split.
In practice, people might run a small 🔗 paired \(t\)-test on the \(d_k\)’s.
We won’t go into the details, but this is why the standard deviation across folds matters.
✏️ Quick check
Suppose we do 5-fold cross-validation on a dataset with 10,000 examples.
How many models do we train in total?
For a given data point:
How many times is it used in training?
How many times is it used in validation?
If we re-shuffle and build new folds, can the CV estimate change?
Why or why not?
Math lens
Generalization risk vs empirical risk
Let \((x_i, y_i)_{i=1}^n\) be IID samples from some unknown distribution \(\mathcal{P}\).
Model \(f_\theta\) (parameters \(\theta\)), loss \(\ell(y, f_\theta(x))\).
Train accuracy: 0.959
Test accuracy: 0.960
Test recall: 0.077
Code
# Plot class balance in train vs testsplit_labels = ["train"] *len(y_train) + ["test"] *len(y_test)y_all = np.concatenate([y_train, y_test])balance_df = pd.DataFrame({"split": split_labels,"is_fraud": y_all,})class_counts = ( balance_df .groupby(["split", "is_fraud"]) .size() .reset_index(name="count"))pivot = class_counts.pivot(index="split", columns="is_fraud", values="count")pivot = pivot.div(pivot.sum(axis=1), axis=0)pivot.plot(kind="bar", stacked=True)plt.ylabel("Proportion")plt.title("Class balance by split")plt.legend(title="is_fraud", loc="best")plt.tight_layout()plt.show()
Fraud team cares more about recall on fraud than raw accuracy.
Our split and metrics must reflect that.
How much does a random split matter?
Data leakage
What is data leakage?
Data leakage: information from outside the training data for that model leaks into the model-fitting process.
Common ways this happens:
Preprocessing on the full dataset before splitting.
Using test labels during feature engineering.
Temporal leakage (using future info to predict the past).
Group leakage (same user or patient in train and test).
Consequences:
Over‑optimistic metrics that fail to replicate.
Misleading model comparisons.
In high‑stakes settings, harm to stakeholders.
❌️ Anti-example — scaling before splitting
Code
from sklearn.preprocessing import StandardScalerscaler = StandardScaler()X_scaled = scaler.fit_transform(X) # ❌ fits on ALL data, including future test rowsX_train_bad, X_test_bad, y_train_bad, y_test_bad = train_test_split( X_scaled, y, test_size=0.2, stratify=y, random_state=42)
Test rows influence the scaling parameters.
Metrics on X_test_bad are too optimistic.
❌️ Bad demo: scaling pipelines
Code
# BAD: scaling on full dataset before splittingfrom sklearn.preprocessing import StandardScalerfrom sklearn.model_selection import train_test_splitscaler = StandardScaler()X_scaled = scaler.fit_transform(X) # ❌ uses all rows (train + future test)X_train_bad, X_test_bad, y_train_bad, y_test_bad = train_test_split( X_scaled, y, test_size=0.2, stratify=y, random_state=0)
❌ StandardScaler sees future test rows.
❌ Any metric on X_test_bad is optimistically biased.
❌ This pattern completely breaks if you plug it into cross-validation.
✅️ Demo: scaling pipelines
Code
# GOOD: scaling inside a pipelinefrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import StandardScalerfrom sklearn.linear_model import LogisticRegressiongood_clf = Pipeline([ ("scaler", StandardScaler()), ("logreg", LogisticRegression(max_iter=1000, random_state=0)),])good_clf.fit(X_train, y_train) # ✔ scaler fit only on training datay_test_pred = good_clf.predict(X_test)
✔ Preprocessing is inside the training loop.
✔ Safe with cross-validation: scaler refit on each training fold.
✔ Test set truly acts as unseen data.
Worked example 2 — cross-validation
Tuning regularization with \(k\)-fold CV
In LogisticRegression, the hyperparameter C controls how strong the L2 regularization is:
Smaller C ⟶ stronger regularization
(weights are shrunk more, decision boundary smoother, less overfitting).
Larger C ⟶ weaker regularization
(weights can grow, boundary can be more wiggly, more risk of overfitting).
We don’t guess C by eye. Instead, we use \(k\)-fold cross-validation on the training set:
Pick a grid of candidate values, e.g. C ∈ {0.01, 0.1, 1, 10, 100}.
For each C, run \(k\)-fold CV and compute the mean validation score (and optionally its std).
Choose the C with the best mean performance (subject to being reasonably stable).
Code
# First: split off a test setX_train_val, X_test, y_train_val, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42,)def make_model(C):return Pipeline([ ("scaler", StandardScaler()), ("logreg", LogisticRegression(C=C, max_iter=1000, random_state=42)), ])skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)Cs = [0.01, 0.1, 1.0, 10.0]rows = []for C in Cs: model = make_model(C) scores = cross_val_score( model, X_train_val, y_train_val, cv=skf, scoring="recall", # emphasize catching fraud n_jobs=None, ) rows.append({"C": C, "mean_recall": scores.mean(), "std_recall": scores.std()})cv_df = pd.DataFrame(rows).sort_values("C")cv_df
# Pick best C by mean CV recallbest_idx = cv_df["mean_recall"].idxmax()best_C = cv_df.loc[best_idx, "C"]best_C
np.float64(10.0)
Code
# Train final model on all train_val data and evaluate once on the test setbest_model = make_model(best_C)best_model.fit(X_train_val, y_train_val)y_test_pred = best_model.predict(X_test)print(classification_report(y_test, y_test_pred, digits=3))
(👀 We’ll see these concepts in greater detail in the next module)
Larger \(K\) → more training data, smaller test folds → lower bias, higher variance.
5–10 folds are a pragmatic compromise.
Variants of splitting & CV
Leave-one-out (LOO) cross-validation
Special case of \(k\)-fold where \(k = n\) (number of examples).
For each example:
Train on all the other examples.
Validate on this one example.
👍️ Pros:
Uses almost all data for training each time → low bias.
No extra randomness from which examples land in which fold.
👎️ Cons:
Computationally expensive: train \(n\) models.
Validation sets are size 1 → estimates for each run are very noisy.
Folds are highly similar → overall estimate can still have high variance.
In practice:
Nice theoretically, and sometimes used on very small datasets.
For larger \(n\), we usually prefer k = 5 or 10 as a compromise.
Leave-one-source-out / grouped CV
Sometimes our examples are not independent:
Multiple rows can come from the same source:
Same patient, same speaker, same document, same user, same hospital…
If we randomly split rows:
The model may see some data from a source in training, and other data from the same source in validation.
This can overestimate performance (data leakage).
Leave-one-source-out CV:
Define a group label (e.g., patient ID, speaker ID).
Each fold leaves out all examples from one group (or a few groups).
Train on the other groups, validate on the held-out group(s).
This asks a different question:
“How well do we do on a new source we have never seen before?”
Beyond random splits
Stratified: maintain label proportions (default for imbalanced classification).
Group-aware: keep all rows from a patient / user / device together.
Time-aware / rolling: train on past, validate on future. Never shuffle across time.
Question to always ask:
“How will this model actually be used?”
Mimic that usage pattern in your split / CV strategy.
Ethics & risks
Leaky or unrealistic evaluation can:
Convince stakeholders to deploy a model that underperforms in production.
Hide systematic failures on minority groups or rare but important cases.
Good practices:
Document your splitting strategy, seeds, and rationale.
Report subgroup metrics on the held-out test set.
When possible, replicate on a truly independent dataset (e.g., next year’s data).
Notes on practice
In Kaggle/tabular competitions, CV strategy is often more important than model choice.
Modern research struggles with:
Validation under distribution shift.
Designing CV schemes for structured data (graphs, text, time-series).
Splits & CV are not “preprocessing boilerplate” — they are part of your core scientific design.
Quick checks
QC1 — roles of splits
You train a model on a training set and tune hyperparameters using a validation set. When should you evaluate on the test set?
A. After every hyperparameter change, to track progress.
B. Only once, after choosing the final model.
C. Never; the validation set is enough.
D. After each epoch during training.
QC2 — leakage or not?
Which of the following definitely introduces data leakage?
A. Scaling features using only the training set mean and variance.
B. Selecting hyperparameters based on cross-validation performance.
C. Computing feature importances on the full dataset (train + test) and then retraining a model using only the top features.
D. Using stratified splits to preserve class proportions.
QC3 — variance of CV
You run 5-fold CV and get recall scores: 0.80, 0.82, 0.79, 0.81, 0.81.
Which is most reasonable?
A. The model generalizes perfectly; deploy without a test set.
B. The CV estimate of recall is around 0.8 with low variance.
C. You must increase \(K\) to 10; 5-fold CV is invalid.
D. The model is certainly overfitting.
QC4 — choosing a split strategy
You have patient-level data where each patient contributes multiple visits. You want performance on new patients.
Which split strategy is best?
A. Randomly split rows into train and test, ignoring patient IDs.
B. Stratify by label only.
C. Group by patient ID so each patient appears in exactly one split.
D. Use leave-one-out CV over rows.