Index Ai Academy
Phase 3 · Machine learning · Foundations

Train, validation and test sets

Hold data back so you can trust your results.

About 25 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

A model can learn its training data by heart. Then it scores perfectly on that data and is useless on new customers. The only honest way to judge a model is on data it has never seen. So you hold some data back and keep it out of training. This is the same idea as the eval set for prompts in Phase 1. The same mistake ruins both: tuning on the data you use for the final score.

What you'll be able to do

  • Explain why a model must be scored on data it never trained on
  • Give train, validation and test sets their separate jobs
  • Split randomly with a seed, stratify by label, or split by time
  • Recognize the ways test data leaks into training

What it covers

  1. Scoring on training data lies
  2. Three sets, three jobs
  3. How to split
  4. Worked example: 20 customers, 4 churners
  5. How test data leaks into training

Scoring on training data lies

Picture a student who gets a copy of the exam answers the night before. They memorize every answer and score 100%. Does that prove they understand the subject? No. Give them a fresh exam with new questions and you'll find out what they really learned.

A model can do the same thing. It can memorize the examples it trained on, quirks and all. So a score on those same examples tells you how good its memory is. It doesn't tell you how well it will predict.

The fix is to keep some data back. You train on one part, then score on the part the model never saw. That kept-back part is called held-out data.

The code below loads a table of customers. Each row says whether that customer churned, which means they stopped buying. It trains a decision tree, a model that learns a flowchart of yes/no questions. This tree is allowed to grow as deep as it likes.

The code then prints three numbers. Each one is an accuracy: the share of customers the model labels correctly. The first is on the customers the tree learned from. The second is on customers it didn't see. The third is a baseline, a dumb rule to compare against: predict "stays" for everyone.

import pandas as pd
import requests
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order", "support_tickets", "trail_club"]
X_train, X_test, y_train, y_test = train_test_split(df[features], df["churned"], test_size=0.25, random_state=0, stratify=df["churned"])

tree = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
print("accuracy on training data:", round(tree.score(X_train, y_train), 3))
print("accuracy on held-out data:", round(tree.score(X_test, y_test), 3))
print("always predicting 'stays': ", round(1 - y_test.mean(), 3))
It runs right here in your browser, with nothing to install.

The tree is perfect on its training data. On new customers it does worse than the baseline. Saying "stays" for everyone beats it. The tree memorized noise, meaning random quirks of those particular customers that don't carry over to anyone else. Without the held-out data, you'd have believed the perfect score and shipped it.

Quick check
A model scores 100% accuracy on its training data and 70% on held-out data. Predicting "stays" for everyone scores 81%. Which number tells you how the model will do on new customers?
  1. 70%, the held-out score, and it's worse than the 81% baseline
  2. 81%, because that's the highest held-out number
  3. The average of 100% and 70%
  4. 100%, because the model got every training example right
Show the answer

70%, the held-out score, and it's worse than the 81% baseline

Only the held-out score measures prediction on unseen customers. Here it falls below the simple baseline, so the model memorized instead of learning.

Three sets, three jobs

Think of a school course. You learn from the textbook and homework. You check your progress on practice tests, and you change how you study based on them. At the end there's one final exam that you've never seen.

Data gets split the same way, into three parts:

SetTypical shareUsed for
Training60–80%fitting the model's parameters
Validation10–20%choosing between models and settings (tree depth, features, learning rate)
Test10–20%one final, honest estimate, used once at the end
  • The training set is the homework. The model learns its parameters from it. Parameters are the numbers inside the model that training adjusts.
  • The validation set is the practice tests. You use it to compare choices you make yourself. Examples are how deep the tree may grow or which columns to use. These choices are called settings (or hyperparameters).
  • The test set is the final exam. You use it once, at the very end, for the score you report.

Why you need a validation set

Why not compare settings on the test set? Say you try some settings, score them on a set of data, and keep the winner. Each time you do that, you fit a little to that data. Part of the winner's score is luck. It happened to suit those particular rows.

Do that fifty times on the test set and its score is no longer honest. It has become a practice test. So you tune on validation and touch the test set once.

When you have little data, holding back a fixed validation set wastes too much of it. Cross-validation takes its place. It gets its own lesson later.

Go deeper Why the winner's score is too high

Say 40 settings all have the same true accuracy, 80%. On a test set of a few hundred rows, each one's score wobbles a little by chance. Some land at 78%, some at 82%. If you pick the highest, you pick the setting that got the luckiest rows. Its score overstates what it will do on new data. The more settings you try, the bigger that luck can be. A fresh test set, untouched during the choosing, has no such bias.

Try it now

Each set answers one question. Mixing their jobs is how inflated scores happen.

Quick check
You tried 40 model settings and picked the one with the best test-set score. What's wrong with reporting that score?
  1. The test set was used for choosing, so its score is optimistic; tune on validation, test once
  2. 40 settings is too few
  3. Test scores are always lower than validation scores
  4. Nothing, it's the best model
Show the answer

The test set was used for choosing, so its score is optimistic; tune on validation, test once

Choosing by a set makes its score biased upward. The winner partly won by luck on those rows.

How to split

There are four common ways to cut your data. Which one you use depends on what the data looks like.

  • Random, with a seed. Shuffle the rows and cut. A seed is a starting number for the shuffle. The same seed always gives the same shuffle. In scikit-learn you set it with random_state, so the split is repeatable.
  • Stratified. Here about 19% of customers churn and 81% stay. A random cut can leave a test set with too few churners. A stratified split keeps the share of each label the same in every part.
  • By time. Say you'll use the model to predict the future. Then train on the past and test on the most recent period. A random split lets the model peek at future patterns. It can't do that once it's in real use.
  • By group. Sometimes one customer has many rows, one per order. Keep all of a customer's rows in the same part. Otherwise the model can recognize the customer instead of learning the pattern.

The code below makes small test sets, 10% of the customers each time, five times over. It prints the churn rate in each test set. The first line uses plain random splits. The second uses stratified splits.

import numpy as np
import pandas as pd
import requests
from sklearn.model_selection import train_test_split

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
y = df["churned"]
for strat in [None, y]:
    rates = []
    for seed in range(5):
        _, _, _, y_test = train_test_split(df, y, test_size=0.1, random_state=seed, stratify=strat)
        rates.append(round(float(y_test.mean()), 3))
    print("stratified" if strat is not None else "random    ", rates)

With small test sets, random splits make the churn rate swing around from seed to seed. Stratified splits hold it steady.

Quick check
What does this print?
rows = list(range(10))
split = int(len(rows) * 0.8)
train, test = rows[:split], rows[split:]
print(len(train), len(test))
  1. 8 2
  2. 2 8
  3. 10 0
  4. 8 10
Show the answer

8 2

An 80/20 split keeps most data for training and holds back some for an honest score.

Quick check
Your data has one row per order. Ana placed 12 orders. A random split puts 9 of her orders in training and 3 in test. What's the problem?
  1. The model can score well on her test rows by recognizing Ana, not by learning a pattern
  2. Nothing, as long as the split used a seed
  3. The test set is too small
  4. Her orders should all go in the validation set
Show the answer

The model can score well on her test rows by recognizing Ana, not by learning a pattern

Rows from the same customer look alike. Keep each customer's rows together in one part, so the test checks the model on people it has never met.

Worked example

Worked example: 20 customers, 4 churners

You have 20 customers. 4 churned and 16 stayed, so 20% churned. You want a 25% test set, which is 5 customers. A fair test set would hold 1 churner, the same 20%. Run 10 random splits and count how many churners land in the test set each time. Then work out the stratified split by hand.

import numpy as np

labels = np.array([1] * 4 + [0] * 16)   # 20 customers: 4 churned (1), 16 stayed (0)

counts = []
for seed in range(10):
    order = np.random.default_rng(seed).permutation(20)  # shuffle the positions 0 to 19
    test = order[:5]                                     # the first 5: a 25% test set
    counts.append(int(labels[test].sum()))               # churners that landed in test
print("random split, churners in test:", counts)

# Stratified: take 25% of each group separately
print("stratified: churners", round(4 * 0.25), "+ stayers", round(16 * 0.25))
In the lesson you can change this and run it.

The random splits give [1, 1, 1, 1, 1, 2, 2, 1, 0, 1]. Most hold 1 churner. Two hold 2, which makes the test set 40% churners. Seed 8 gives 0 churners, so you couldn't measure churn predictions at all. A stratified split takes 25% of each group: 25% of 4 churners is 1, and 25% of 16 stayers is 4. That's 5 customers with exactly 1 churner, every time.

How test data leaks into training

Data leakage means information from the test data sneaks into training. The model then looks better than it really is on new data. Leaks are easy to miss, because nothing crashes. The score looks good, and that's all you see.

Watch for these:

  • Tuning on the test set. Every look that changes a choice leaks a little.
  • A random split on time-ordered data. The model trains on days that come after some of the days it's tested on.
  • The same customer in both parts. The model recognizes the person instead of the pattern.
  • Preparing data before you split. Say you fill missing values with the average of the whole table. That average includes the test rows, so a little of the test set has reached training. Split first. Then work out averages and other fill-in values from the training rows only.

A good habit is to split first, before you explore or clean anything. Then put the test set away until the end.

Quick check
You fill missing ages with the average age of all 1,000 customers, and only then split into train and test. Why is that a leak?
  1. Filling missing values is never allowed
  2. The split should have been stratified by age
  3. The average used the test rows, so information from the test set reached training
  4. It isn't a leak, because averages are harmless
Show the answer

The average used the test rows, so information from the test set reached training

Split first, then compute the average from the training rows and use it to fill both parts.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

What is the validation set for?

  1. Choosing between models and settings without touching the test set
  2. Training the model, alongside the training set, so it has more data to learn from
  3. The final score you report, because it's the data the model has never been tuned on
  4. Storing rows that have missing values, which would otherwise break the training step

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

Before this lesson