Index Ai Academy
Phase 3 · Machine learning · Evaluation

Cross-validation

Reuse limited data for more reliable estimates.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. One split is one roll of the dice
  2. k-fold cross-validation
  3. Worked example: five fold scores
  4. Comparing models with cross-validation
  5. Tuning with cross-validation
  6. 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}")
It runs right here in your browser, with nothing to install.

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.

Quick check
The same model, trained on the same data, scores 0.78 on one random split and 0.86 on another. Why?
  1. AUC can't be trusted on churn data
  2. The second split used more features
  3. Different customers land in the test set, and some groups are easier to predict than others
  4. The model has a bug that makes it random
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 kk. Here are the steps:

  1. Split the data into kk equal folds.
  2. Train on all the folds but one. Score the model on the fold left out. That's the validation fold.
  3. Repeat until every fold has been the validation fold once.

With five folds it looks like this:

RunFold 1Fold 2Fold 3Fold 4Fold 5
1validatetraintraintraintrain
2trainvalidatetraintraintrain
3traintrainvalidatetraintrain
4traintraintrainvalidatetrain
5traintraintraintrainvalidate

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.

Quick check
With five folds, how many times is each customer used for validation?
  1. Never
  2. Once
  3. Five times
  4. Four times
Show the answer

Once

Each customer is held out for one run, and helps to train in the other four.

You end up with kk scores, one per fold. Call the score on fold 1 s1s_1. The score on fold 2 is s2s_2, and so on. The cross-validated score is their average:

CV score=s1+s2+⋯+skk\text{CV score} = \frac{s_1 + s_2 + \cdots + s_k}{k}

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 mm. The spread is the standard deviation of the fold scores:

spread=(s1−m)2+(s2−m)2+⋯+(sk−m)2k\text{spread} = \sqrt{\frac{(s_1 - m)^2 + (s_2 - m)^2 + \cdots + (s_k - m)^2}{k}}

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.)

Try it now

Every row gets to be test data once, so one lucky split can't fool you.

Worked example

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}")
In the lesson you can change this and run it.

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.

Quick check
What does this print?
scores = [0.70, 0.74, 0.72, 0.76, 0.78]
print(round(sum(scores) / len(scores), 2))
  1. 3.7
  2. 0.74
  3. 0.78
  4. 0.7
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.

Quick check
In 5-fold cross-validation, how many times is each customer used to score (validate) the model?
  1. Four times
  2. It depends on the random shuffle
  3. Five times
  4. Exactly once
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.

Quick check
GridSearchCV reports a best cross-validated AUC of 0.80. The untouched test set gives 0.82. Which number should you report as your estimate for new customers?
  1. Neither; you need to rerun the search on the test set
  2. 0.80, because cross-validation is always more accurate
  3. 0.81, the average of the two
  4. 0.82, the test score, because nothing was tuned on the test set
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. TimeSeriesSplit always 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.

Quick check
Your data has one row per store visit, and each store appears many times. Which splitter keeps every store's rows on one side of each split?
  1. GroupKFold, grouped by store
  2. A single random train/test split
  3. Shuffled KFold
  4. TimeSeriesSplit
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.

Quick check
Model A scores 0.81 ± 0.04 in 5-fold CV and model B scores 0.80 ± 0.04. What's the reasonable conclusion?
  1. Cross-validation failed
  2. B is clearly better because it's simpler
  3. A is clearly better
  4. They perform about the same; the difference is well within the spread
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

Create a free account

Try a quiz question

In 5-fold cross-validation on 600 rows, how many rows does each model train on?

  1. 480
  2. 120
  3. 600
  4. 300

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson