Problem
Generalization is the goal, and held-out data is how you measure it. The catch is that every decision you make by looking at held-out scores, such as which model, which settings, when to stop, quietly trains on those scores. Do enough of that and the held-out number is as optimistic as the training one. You need data that has had no influence on any decision.
The second catch is that "held out" only means something if the held-out rows are really independent of the training rows. Several records from the same patient, or tomorrow's data shuffled in with yesterday's, make the test too easy, and the score you report is not the one you will get in use.
Intuition
Think of three kinds of practice. Training data is the homework you study from. Validation data is a set of practice exams you take repeatedly to decide how to study: which book, how many hours. Test data is the real exam, sealed in an envelope until the end. Once you have peeked at a practice exam to adjust your study plan, it no longer tells you how you will do on something fresh, which is why the envelope must stay shut.
And the exam must resemble reality. If the real exam will cover next year's material, practising on a shuffled mix of this year's and next year's is cheating, even if the shuffle was random.
The 70/15/15 proportions are a common default, not a rule; the section on sizes below says what really matters.
Mechanics
Why three splits
- Train fits the model's parameters.
- Validation (also called dev) compares models and settings: architecture, regularization strength, learning rate, early stopping point. You look at it many times, so it becomes slightly fitted to.
- Test is used once, after all choices are final, to report an estimate of performance on new data.
With only train and test, any tuning you do uses the test set, and the final score is biased upward. When data is scarce, cross-validation replaces the single validation set by rotating many of them, but the test set stays untouched either way.
Split sizes
The right sizes depend on how precisely you need to know the score, not on a ratio. An accuracy measured on m independent test examples has standard error √(p(1 − p) / m), so for p = 0.8 it is 0.040 at m = 100, 0.013 at m = 1,000 and 0.004 at m = 10,000. Quadrupling the test set only halves the uncertainty. With a million examples, 1% held out is already ample; with a hundred, a split of any ratio leaves a noisy estimate and cross-validation is the better tool. The training part should be as large as you can spare, since more data lowers the generalization gap.
Stratified splits
A random split keeps class proportions only on average. With a rare class, a particular split can be badly off, even leaving
a class out of one part. A stratified split fixes the proportion in each part by splitting each class
separately and then combining. In scikit-learn, passing stratify=y to train_test_split routes through
StratifiedShuffleSplit.
Grouped splits
Often rows are not independent: several visits by one patient, several photos of one object, several sentences from one document.
If those rows are split at random, the model sees a patient in training and is tested on that same patient, and it can score well by
recognizing the patient instead of the disease. The fix is to split by group: all rows of a group go to one side.
GroupShuffleSplit and GroupKFold do this. train_test_split itself has no group argument.
Time-ordered splits
When the task is to predict the future, test data must come after training data. A random split lets the model train on the days
surrounding every test day, which no real forecaster ever has. Use a forward split: train on the past, test on the following period.
train_test_split(..., shuffle=False) does this once (first rows train, last rows test; the data must already be sorted by time), and
TimeSeriesSplit repeats it with a growing training window.
When a random split lies
A random split is honest only if the rows are independent and the future looks like the sample. It misleads when classes are rare, rows come in groups, order matters, near-duplicates exist (the same image resized, the same text reposted), or the deployed data comes from a different place than the collected data. In every case the score looks good and then falls apart in use.
Same data, three ways to cut it. Only the grouped cut keeps patients apart; only the time-ordered cut respects the arrow of time.
train_test_split
train_test_split(*arrays, test_size=None, train_size=None, random_state=None, shuffle=True, stratify=None)
returns each input array cut into a train part and a test part, interleaved: X_train, X_test, y_train, y_test.
If neither size is given, test_size defaults to 0.25. Sizes are fractions when floats and counts when ints.
With shuffle=True it takes one split from ShuffleSplit (or StratifiedShuffleSplit when
stratify is given); with shuffle=False it takes the first rows as train and the rest as test, and
combining that with stratify raises an error. Set random_state to an integer to make the split reproducible.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
train_test_split |
test_size=None (→ 0.25), train_size, random_state, shuffle=True, stratify=None |
One split of any number of equal-length arrays; the entry point most code uses. | _split.py |
shuffle=False branch |
first n_train rows, then n_test rows |
A forward split for ordered data; refuses stratify. |
_split.py |
StratifiedShuffleSplit |
n_splits, test_size, train_size |
Random splits that keep each class's share in train and test. | _split.py |
GroupShuffleSplit |
n_splits, test_size; groups passed to split |
Random splits where whole groups go to one side. | _split.py |
GroupKFold |
n_splits=5; groups passed to split |
K folds in which no group appears in two folds. | _split.py |
StratifiedKFold |
n_splits=5, shuffle=False |
K folds that each keep the class proportions. | _split.py |
TimeSeriesSplit |
n_splits=5, max_train_size, test_size, gap=0 |
Forward-chaining folds: each test fold follows its training fold. | _split.py |
Worked example
A rare class and a 25% test set
A dataset of 20 rows has 4 positives and 16 negatives. A 25% test set is 5 rows. A plain random split can pick rows 0, 1, 2, 4 and 5 (a draw that happens to hold no positives): the test set then has 5 negatives and 0 positives, so recall on the rare class is undefined and any accuracy it reports is blind to the thing you care about. This is not a freak event: the chance that a random 5-row test set contains no positive is C(16,5) / C(20,5) = 0.282, a little more than one split in four.
A stratified split allocates each class its share: 4 negatives and 1 positive in test, always. The test set is guaranteed to hold the rare class, though with a single positive it remains a very noisy test of it.
Six patients, three visits each
Eighteen rows come from six patients (ids 0 to 5). A random 6-row test set, rows 0, 4, 7, 10, 13, 16, puts 6 of its 6 rows next to training rows from the same patient: every test row has a sibling in training. A grouped split sends patients 4 and 5 to test, so all six test rows are from people the model never saw, and the number of siblings in training is 0. The grouped test is smaller in effective sample size (two patients, not six independent rows) but it measures what you want: performance on a new patient.
Twelve months of a rising series
Monthly values rise by about 2 each month: 10.4, 12.1, 13.8, 16.2, 18.1, 19.7, 22.3, 23.8, 26.2, 28.1, 29.7, 32.2. The predictor is deliberately simple: for a test month, use the value of the nearest training month. With a random split holding out months 1, 4, 6, 9, every test month sits between training months, and the mean squared error is 4.2. With a forward split that trains on months 0 to 8 and tests on months 9, 10 and 11, every prediction is the stale month-8 value, and the error is 17.3, 4.1 times larger. The two test sets are different months, but the point stands: the random split reports a forecast skill that does not exist, because it let the model look at the future on both sides of each test point.
Implementation
from sklearn.model_selection import (
train_test_split, GroupShuffleSplit, TimeSeriesSplit,
)
# 1) hold out a test set first, then carve validation out of what remains
X_rest, X_test, y_rest, y_test = train_test_split(
X, y, test_size=0.15, stratify=y, random_state=0
)
X_train, X_val, y_train, y_val = train_test_split(
X_rest, y_rest, test_size=0.15 / 0.85, stratify=y_rest, random_state=0
)
# 2) grouped: no patient on both sides
gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=0)
train_idx, test_idx = next(gss.split(X, y, groups=patient_id))
# 3) time-ordered: sorted by time, train on the past
X_train, X_test = X[:-k], X[-k:] # same as shuffle=False
for train_idx, test_idx in TimeSeriesSplit(n_splits=4).split(X):
... # each test fold follows its train fold
Do any preprocessing that learns from data (scaling, imputing, encoding) after splitting and fit it on the training part only; fitting on all rows first lets test information into training.
Tradeoffs
- Data for training vs. data for measuring. Every row held out is a row the model does not learn from. The cost is large for small datasets and negligible for huge ones.
- The validation set wears out. Compare many models on it and the best score is partly luck. A fresh test set, used once, is the only unbiased report.
- Stratifying fixes proportions, not the noise. With very few positives, any split leaves a shaky estimate of the rare class; more data or cross-validation helps, stratification alone does not.
- Grouped and forward splits cost sample size. They give smaller effective test sets and noisier estimates, but those estimates are honest.
- Reproducibility. Without a fixed
random_statethe split, and so the score, changes from run to run, which can look like a model change. Fix it while developing; vary it to see how much the score moves. - Never reuse the test set to decide. If you do, relabel it as validation and collect a new test set.
Check yourself
You tried 40 models on the validation set and report the best one's validation score. What is wrong?
The maximum over 40 noisy estimates is biased upward. Report performance on the untouched test set, which the chosen model has not been selected against.
Why can't you pass stratify to train_test_split together with shuffle=False?
Stratification needs a random draw within each class, while shuffle=False takes fixed positions in the existing order. scikit-learn raises an error rather than guess.
Test accuracy is 99% but live accuracy is 70%. Name two split-related causes.
Rows from the same group (or near-duplicates) on both sides of the split, and a random split of time-ordered data so the test period was not in the future. A shift between collected and deployed data would also do it.
Connections
Read first
- Generalization intro
Read next
- Data leakage core
- Cross-validation core
- Classification metrics intro
References
- Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, 2nd ed., section 7.2 and 7.10. Springer, 2009.
- Kaufman, Rosset and Perlich. Leakage in Data Mining: Formulation, Detection, and Avoidance. ACM TKDD, 2012.
- Roberts et al. Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. Ecography, 2017.
- scikit-learn 1.9.1:
train_test_split,GroupShuffleSplit,TimeSeriesSplit.