Cross-validation
Reuse limited data for more reliable estimates.
Create a free account Try part of it here
Why it matters
Say you have 600 customers and keep 180 back to check your model. Which 180 you happen to pick can move the score by several points. That's enough to crown the wrong model. Cross-validation fixes this. Every customer takes one turn in the checking group, and you average the results. Now a comparison reflects the models, not the luck of the split. The same worry applies to LLM evals. A prompt that "wins" by two points on 40 examples may not have won anything.
What you'll be able to do
- Show how much a single train/validation split's score depends on luck
- Run k-fold cross-validation and report a mean and spread
- Tune settings with cross-validation while keeping a final test set untouched
- Pick the right splitter for grouped or time-ordered data
What it covers
- One split is one roll of the dice
- k-fold cross-validation
- Worked example: five fold scores
- Comparing models with cross-validation
- Tuning with cross-validation
- Choosing the splitter
One split is one roll of the dice
Imagine judging a basketball player on ten shots. On a good day they sink eight. On a bad day they sink five. Same player, same skill. The ten shots you happened to watch decided the verdict.
A train/test split works the same way. The customers that land in the test set are a random handful. Some handfuls are easier to predict than others. So the score depends partly on luck.
The code below shows how much. It trains the same model on ten different random splits. The seed changes each time, so a different 30% of customers lands in the test set. It scores each split with AUC. AUC measures how well a model ranks churners (customers who left) above stayers. An AUC of 0.5 is a coin flip, and 1.0 is perfect.
import numpy as np import pandas as pd import requests from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["email_open_rate"] = df["email_open_rate"].fillna(0) features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order", "discount_share", "email_open_rate", "support_tickets", "trail_club"] X, y = df[features], df["churned"] aucs = [] for seed in range(10): X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=seed, stratify=y) model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_tr, y_tr) aucs.append(roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])) print("test AUC on 10 random splits:", " ".join(f"{a:.3f}" for a in aucs)) print(f"lowest {min(aucs):.3f}, highest {max(aucs):.3f}")
Same model, same data. Yet the score ranges from about 0.78 to 0.86, depending on the split. Now picture comparing two models on one split each. A gap that size could be pure luck.
Show the answer
Different customers land in the test set, and some groups are easier to predict than others
Each split tests on a different random handful of customers. The score reflects that handful as well as the model.
k-fold cross-validation
Here's the fix. Instead of one test group, give every customer a turn.
Think of a pizza cut into five slices. You set one slice aside to taste-test and cook with the other four. Then you rotate, until every slice has had its turn as the taste test. You end up with five verdicts, and their average is far more reliable than any one of them.
In machine learning the slices are called folds. The number of folds is called . Here are the steps:
- Split the data into equal folds.
- Train on all the folds but one. Score the model on the fold left out. That's the validation fold.
- Repeat until every fold has been the validation fold once.
With five folds it looks like this:
| Run | Fold 1 | Fold 2 | Fold 3 | Fold 4 | Fold 5 |
|---|---|---|---|---|---|
| 1 | validate | train | train | train | train |
| 2 | train | validate | train | train | train |
| 3 | train | train | validate | train | train |
| 4 | train | train | train | validate | train |
| 5 | train | train | train | train | validate |
Every customer is used for validation exactly once. Each customer is used for training k − 1 times, so four times here. Five or ten folds are the usual choices.
Show the answer
Once
Each customer is held out for one run, and helps to train in the other four.
You end up with scores, one per fold. Call the score on fold 1 . The score on fold 2 is , and so on. The cross-validated score is their average:
In plain words: add up the score from every fold, then divide by the number of folds.
You also report the spread: how far the fold scores typically sit from that average. This is the standard deviation from Phase 2. People write the pair as "0.81 ± 0.02". Read it as "0.81 on average, give or take about 0.02". A big spread warns you that a single split could have misled you.
For classification, scikit-learn uses stratified folds by default. A stratified fold keeps the churn rate the same in every fold, so no fold ends up with too few churners.
Go deeper The formula for the spread
Call the average . The spread is the standard deviation of the fold scores:
Read it as: find how far each fold's score is from the average, square those gaps, average them, then take the square root.
Squaring stops gaps above and below the average from canceling out. NumPy's np.std computes exactly this. So does the scores.std() call in the model comparison below, because scores there is a NumPy array. (Careful: pandas' .std() divides by k − 1 instead of k, so it gives a slightly bigger number.)
Every row gets to be test data once, so one lucky split can't fool you.
Worked example: five fold scores
You ran 5-fold cross-validation and got these AUC scores: 0.80, 0.84, 0.78, 0.82 and 0.81. Work out the cross-validated score and its spread.
scores = [0.80, 0.84, 0.78, 0.82, 0.81] # AUC on each of the 5 folds k = len(scores) mean = sum(scores) / k spread = (sum((s - mean) ** 2 for s in scores) / k) ** 0.5 print(f"CV score: {mean:.2f} ± {spread:.2f}") print(f"lowest fold {min(scores):.2f}, highest fold {max(scores):.2f}")
The five scores add up to 4.05. Divided by 5, that's an average of 0.81. Now the spread. The gaps from 0.81 are −0.01, +0.03, −0.03, +0.01 and 0. Their squares are 0.0001, 0.0009, 0.0009, 0.0001 and 0, which add up to 0.0020. Divide by 5 to get 0.0004. The square root of 0.0004 is 0.02. So you'd report 0.81 ± 0.02. Notice the single folds range from 0.78 to 0.84. One split alone could have told you either number.
scores = [0.70, 0.74, 0.72, 0.76, 0.78] print(round(sum(scores) / len(scores), 2))
Show the answer
0.74
The scores add up to 3.70. There are 5 of them, and 3.70 / 5 = 0.74, the cross-validated score.
Comparing models with cross-validation
scikit-learn does the rotating for you. cross_val_score takes a model, the data and the number of folds. It returns one score per fold.
The code below compares three models you've met in this phase:
- logistic regression, which turns a weighted sum of the features into a probability;
- a decision tree, depth 4, a flowchart that asks each customer at most four yes/no questions;
- a random forest, 200 trees that vote.
import pandas as pd import requests from sklearn.model_selection import cross_val_score from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.tree import DecisionTreeClassifier df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["email_open_rate"] = df["email_open_rate"].fillna(0) features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order", "discount_share", "email_open_rate", "support_tickets", "trail_club"] X, y = df[features], df["churned"] candidates = { "logistic regression": make_pipeline(StandardScaler(), LogisticRegression()), "tree, depth 4": DecisionTreeClassifier(max_depth=4, random_state=0), "random forest": RandomForestClassifier(n_estimators=200, min_samples_leaf=5, random_state=0), } for name, model in candidates.items(): scores = cross_val_score(model, X, y, cv=5, scoring="roc_auc") print(f"{name:<20} AUC {scores.mean():.3f} ± {scores.std():.3f} folds: {' '.join(f'{s:.2f}' for s in scores)}")
Logistic regression comes out ahead. The fold scores also show how much a single split could have misled you. The depth-4 tree alone ranges from 0.64 to 0.79.
Keep the preparation inside each fold
Look at the logistic regression line. The scaler sits inside the pipeline. A scaler puts every feature on a similar scale, and it learns each feature's average and spread to do it. A pipeline chains the preparation steps and the model into one object.
cross_val_score refits the whole pipeline in every fold. So the scaler only ever learns from that fold's training part. Suppose you scaled all the data first instead. Then the averages would include the validation fold's customers. A little information about them would leak into training, and the score would come out a bit too rosy.
Show the answer
Exactly once
Each customer sits in exactly one fold, and each fold is the validation fold once. The other four runs use that customer for training.
Tuning with cross-validation
Models have settings you choose yourself, such as how deep a tree may grow. GridSearchCV tries every combination of settings you list. It cross-validates each one. Then it refits the best one on all the data you gave it.
Keep a test set outside the search. The code below first sets aside 20% of the customers as the test set, 120 people. The search only sees the other 80%.
The grid lists 6 depths and 3 values of min_samples_leaf, the smallest number of customers allowed at the end of a branch. That's 18 combinations. With 5 folds each, the search trains 90 models, plus one final refit.
import pandas as pd import requests from sklearn.model_selection import GridSearchCV, train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import roc_auc_score df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["email_open_rate"] = df["email_open_rate"].fillna(0) features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order", "discount_share", "email_open_rate", "support_tickets", "trail_club"] X_dev, X_test, y_dev, y_test = train_test_split(df[features], df["churned"], test_size=0.2, random_state=0, stratify=df["churned"]) search = GridSearchCV(DecisionTreeClassifier(random_state=0), {"max_depth": [2, 3, 4, 5, 6, 8], "min_samples_leaf": [1, 5, 20]}, cv=5, scoring="roc_auc").fit(X_dev, y_dev) print("best settings:", search.best_params_) print(f"best cross-validated AUC: {search.best_score_:.3f}") print(f"held-out test AUC: {roc_auc_score(y_test, search.predict_proba(X_test)[:, 1]):.3f}")
Here the test score happens to come out higher than the cross-validated one. With only 120 test customers, either number can be a few points off. The test set's value is that nothing was tuned on it. So it's the honest one to report.
Go deeper Why the best of many scores is a little optimistic
Picking the best of many cross-validated scores is itself a small form of luck. Say several settings are truly equal. Their scores still wobble a little by chance, and the search keeps whichever wobbled highest. So the winning score tends to sit slightly above what that setting will really do. The more combinations you try, the bigger this effect can be. A test set that the search never saw doesn't have this problem.
Show the answer
0.82, the test score, because nothing was tuned on the test set
The search chose its winner using the cross-validated scores, which makes them slightly optimistic. The test set played no part in choosing, so its score is the honest one, even if chance makes it come out higher.
Choosing the splitter
Random folds assume every row is independent of the others. Two common cases break that.
- Grouped data: several rows per customer, store or user. Say one customer's rows land in both training and validation. Then the model is graded partly on people it has already seen. Use
GroupKFold, which keeps each group on one side. - Time-ordered data: for example, forecasting next month from the past. Random folds let the model train on the future.
TimeSeriesSplitalways validates on data that comes after the training data, as it will in real use.
The cost
k-fold trains the model k times. That's nothing for the models in this phase. For large neural networks it's far too slow. That's why deep learning usually relies on one fixed validation set with lots of data.
For LLM evals, the same idea shows up as reporting uncertainty. Score the prompt on several samples. Or bootstrap the eval set: build many new eval sets by drawing examples at random from the original, with repeats allowed, and see how much the score moves. Then be skeptical of any difference smaller than that spread.
Show the answer
GroupKFold, grouped by store
GroupKFold keeps each group, here each store, entirely in training or entirely in validation. The model is then graded on stores it has never seen.
Show the answer
They perform about the same; the difference is well within the spread
A 0.01 gap with fold-to-fold spread of 0.04 is not evidence of a real difference.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In 5-fold cross-validation on 600 rows, how many rows does each model train on?