Machine Learning·Lesson 3·14 min·0/3 exercises

Train and test

Hold rows back, look at them once — and know how much luck is in the number

train_test_splitstratifyrandom_statetest_sizeTimeSeriesSplitGroupKFold

Watch it happen

Play it through, or step back and forth yourself.

model.fit(X, y)
model.score(X, y)     # 1.00 !
You asked the model to answer questions it had already been given the answers to. And the ranking inverts: the unlimited tree scores a perfect 1.000 on the rows it trained on and the worst 0.733 on rows it hasn't seen.
The worse the model, the better it looks
traintest
logistic regression0.8180.796
tree, depth 40.8280.791
tree, unlimited1.0000.733memorised

Score a model on the rows it trained on and you learn nothing about whether it works. You've asked it to recite. The number will be flattering and it will mean nothing at all.

The idea

Lesson 1 ended on generalisation: the goal is working on rows nobody has seen. This lesson is about the one thing you must do to measure that at all — and it is the single easiest thing in machine learning to get subtly, invisibly wrong.

Grading your own homework

model.fit(X, y)
model.score(X, y)     # 0.94 — and it means nothing

You've asked the model to answer questions it was already given the answers to. Worse, the number rewards exactly the wrong thing: a plain logistic regression scores about 0.81 here, a depth-4 tree 0.85, an unlimited tree 1.00. The model that scores best on the training set is the one that has memorised most, which is to say the one most likely to fail.

Hold some back

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.25,
    stratify=y,
    random_state=0,
)

675 rows to learn from, 225 held back. Note the return order — both X's, then both y's. Getting it wrong doesn't raise; it trains on nonsense and gives you a bad score you can't explain.

The golden rule

The test set is looked at once, at the end.

Not to choose between two models. Not to pick max_depth. Not to decide which features to drop. Every one of those is a decision, and a decision made by looking at the test set is a decision fitted to those 225 rows. Do it twenty times and your test score is as optimistic as a training score — with none of the warning signs, because it's still called a test score.

This is the mistake that ships broken models. It doesn't look like cheating; it looks like diligence. You try things, you check, you keep what works. The fix isn't discipline, it's structure: make those decisions with cross-validation on the training set (lesson 8), and the test set stays clean for the one measurement that matters.

For serious work you'll see a three-way split — train / validation / test. Train fits, validation chooses, test reports. Cross-validation is the same idea with the validation set rotated so you use all your data.

stratify

A plain random split doesn't preserve the class balance, and with an uneven target that matters. Ours is 22.2% late. A random 25% test set comes out at 19.1%:

train_test_split(X, y, test_size=0.25, random_state=0)
# test set is 19.1% late — easier than reality

train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
# test set is 22.2% late — exactly like reality

Three points sounds small. It means you'd be measuring on a test set with fewer late deliveries than the world has, and reporting the result as though it described the world. And it gets worse as the class gets rarer: at 2% positive, an unstratified fold can contain almost no positive rows — or none, at which point precision and recall stop being defined at all.

Stratify on the target for classification. It costs nothing. (For regression there's no direct equivalent — bin the target first if you need it.)

random_state, and how much luck is in your number

The split is random, so without random_state you get a different split — and a different score — every run. Setting it makes your work reproducible. It is not for hunting a seed that flatters you.

Here is why that matters more than it sounds. Same model, same data, eight seeds:

[0.796, 0.796, 0.809, 0.809, 0.836, 0.827, 0.831, 0.827]

Four accuracy points of pure luck, from nothing but which rows landed where. So "our new model is 2% better", measured on one split, is not a finding — it's a statement about the seed. Any single train/test number carries this uncertainty, and reporting it without acknowledging that is the most common overclaim in applied ML.

The fix isn't a better seed. It's taking several measurements and looking at the spread, which is cross-validation, and which is why lesson 8 exists.

How much to hold out

20–25% is the usual answer, and the trade-off is real in both directions. Too small and the score is noisy — 50 test rows means one row is two accuracy points. Too large and you've starved the model in order to measure it.

Scale with what you have: with a million rows, 1% is a fine test set and more would be waste. With 900, 25% gives 225 rows and a score you should already distrust to about ±0.04 — which the seed experiment above just demonstrated. With a few hundred rows, the answer isn't a bigger test set, it's cross-validation.

When a random split is the wrong split

train_test_split assumes your rows are independent. When they aren't, it quietly hands the model a look at the answer:

  • Ordered in time. A random split trains on March to predict February. Your score reflects a situation that will never occur. Use TimeSeriesSplit, which always trains on the past and tests on the future.
  • Grouped rows. The same customer, patient, or device in both halves. The model can recognise the group rather than learn the pattern. Use GroupKFold or GroupShuffleSplit and split by group.
  • Near-duplicates. The same delivery logged twice, landing on both sides. Deduplicate before splitting, not after.

All three are the same failure wearing different clothes: information about a test row was available at training time. That's leakage, and lesson 13 shows the version of it that catches nearly everyone.

See it run

The lesson's code, ready to run and to fiddle with.

Putting the kettle on…

Starting up…

Worked example

not graded

Already written and ready to go — press Run to see what it does, then change a number, a column name, anything, and run it again.

tryraising the range to 20 seeds and seeing whether the spread narrows. It does not.

Press Run — the output appears here.

Your turn

3 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.

Split X and y with test_size=0.25, stratify=y and random_state=0. Return [list(X_train.shape), list(X_test.shape)].

your answer

Show what stratify buys you. Split twice at random_state=0 — once without it, once with — and return the two test-set late rates as a list, each rounded to 4 places.

your answer

Measure the luck. Train the same pipeline across random_state 0 to 7 and return the spread — max minus min accuracy — rounded to 4 places.

your answer