Lesson 03
Training and Test Sets
Hold back rows the model never sees with train_test_split, repeat the split with random_state, keep class balance with stratify, and score honestly.
A model that is scored on the same rows it learned from can look perfect while being useless on new data, like a learner who memorised last year's answers. The fix is to hide some rows before training. The model learns from the training set and is scored on the test set, which stands in for the future.
Why hold rows back
A deep decision tree can memorise every training row. Scored on those same rows it looks excellent, which tells you nothing about a learner it has not met.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.tree import DecisionTreeRegressor
X = learners[["hours", "practice"]]
y = learners["score"]
tree = DecisionTreeRegressor(random_state=0).fit(X, y)
print("score on the rows it learned:", round(tree.score(X, y), 3))Output:
score on the rows it learned: 0.999Split with train_test_split
train_test_split shuffles the rows and returns four pieces in a fixed order: training features, test features, training labels, test labels. test_size=0.2 keeps a fifth of the rows for testing.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
print(X_train.shape, X_test.shape)
print(y_train.shape, y_test.shape)Output:
(160, 2) (40, 2)
(160,) (40,)random_state makes the split repeatable
The shuffle is random, so each run could pick different test rows. A fixed random_state makes it the same every time, which keeps results comparable while you experiment.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
first = train_test_split(X, test_size=0.2, random_state=42)[1]
again = train_test_split(X, test_size=0.2, random_state=42)[1]
other = train_test_split(X, test_size=0.2, random_state=7)[1]
print(first.index[:5].tolist())
print(first.index.equals(again.index), first.index.equals(other.index))Output:
[95, 15, 30, 158, 128]
True Falsestratify keeps the class balance
48 percent of learners passed. A random split can land a test set with noticeably more or fewer passes. stratify=y keeps the same share in both parts, which matters most for small or unbalanced data.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
_, _, _, plain = train_test_split(X, y, test_size=0.2, random_state=3)
_, _, _, even = train_test_split(X, y, test_size=0.2, random_state=3, stratify=y)
print("whole table:", y.mean())
print("plain split test:", plain.mean())
print("stratified test:", even.mean())Output:
whole table: 0.48
plain split test: 0.625
stratified test: 0.475An honest score
Fit on the training rows only, then score both parts. The gap between the two numbers is the part of the training score that was memorisation.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor
X = learners[["hours", "practice"]]
y = learners["score"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
tree = DecisionTreeRegressor(random_state=0).fit(X_train, y_train)
print("train:", round(tree.score(X_train, y_train), 3))
print("test: ", round(tree.score(X_test, y_test), 3))Output:
train: 0.999
test: 0.442Key takeaways
-
Never judge a model on the rows it was trained on
-
train_test_split returns X_train, X_test, y_train, y_test in that order
-
random_state fixes the shuffle so results are repeatable
-
stratify=y keeps the class balance the same in both parts
-
A big train and test gap means the model memorised instead of learning
-
Next: Linear Regression
-
Previous: Features and Labels
Loading playground…
Check your understanding
Training and Test Sets Quiz
Why keep a test set?
1 / 10