Machine learningChapter 108 of 114
Overfitting and Underfitting
Memorising the training data, or not learning it at all.
The two failures
- Underfitting — the model is too simple to capture the pattern. It does
badly on training data and on new data.
- Overfitting — the model is complex enough to memorise the training data,
noise included. It does brilliantly on it and badly on anything new.
You diagnose both by comparing the two scores:
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
# 15% of the labels are deliberately wrong, as real data always is
X, y = make_classification(
n_samples=400, n_features=20, n_informative=5, flip_y=0.15, random_state=0
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=0, stratify=y
)
print("depth train test")
for depth in [1, 3, 8, None]:
model = DecisionTreeClassifier(max_depth=depth, random_state=0)
model.fit(X_train, y_train)
label = str(depth) if depth else "full"
print(f"{label:6} {model.score(X_train, y_train):.2f} {model.score(X_test, y_test):.2f}")Output
depth train test 1 0.81 0.82 3 0.89 0.84 8 0.99 0.78 full 1.00 0.77
Read the two columns together. At depth 1 the model is too simple to have learned much, and the two scores sit close together. Depth 3 is the sweet spot.
After that the training score climbs to a perfect 1.00 while the test score falls, from 0.84 to 0.77. Those extra branches went on memorising the 15% of labels that are deliberately wrong, and noise does not generalise.
That widening gap between the columns is what overfitting looks like, and it is why you never judge a model on the data it trained on.
One test score is noisy
A single split is one sample. Change the seed and the number moves:
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
X, y = load_iris(return_X_y=True)
scores = []
for seed in range(5):
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=seed, stratify=y
)
model = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
scores.append(round(model.score(X_test, y_test), 2))
print(scores)
print("spread:", min(scores), "to", max(scores))Output
[0.97, 0.97, 0.97, 0.9, 0.97] spread: 0.9 to 0.97
Seven points of spread from nothing but the split. Reporting whichever number you happened to get is how models come to be oversold.
Cross-validation
Split the data into k parts, train on k-1 and test on the one left out, k times. Every example gets used for testing exactly once:
from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier
X, y = load_iris(return_X_y=True)
model = DecisionTreeClassifier(random_state=0)
scores = cross_val_score(model, X, y, cv=5)
print(scores.round(2))
print("mean:", scores.mean().round(3), "std:", scores.std().round(3))Output
[0.97 0.97 0.9 0.97 1. ] mean: 0.96 std: 0.033
Five numbers instead of one, and a standard deviation telling you how much to trust the mean. This is the right way to report a model's performance when data is limited.
Comparing models honestly
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
X, y = load_breast_cancer(return_X_y=True)
for name, model in [("tree ", DecisionTreeClassifier(random_state=0)),
("forest", RandomForestClassifier(n_estimators=50, random_state=0))]:
scores = cross_val_score(model, X, y, cv=5)
print(f"{name} {scores.mean():.3f} +/- {scores.std():.3f}")Output
tree 0.917 +/- 0.016 forest 0.963 +/- 0.017
The forest is genuinely better here: the gap between the means is larger than the spread of either. When two means differ by less than their standard deviations, you have not shown anything.
Fixing each one
Underfitting — the model cannot represent the pattern:
- use a more flexible model, or let it grow deeper
- add features that actually carry information
- train longer, if it is the kind of model that iterates
Overfitting — it has learned the noise:
- more data is the best fix, and usually the hardest
- simplify: shallower trees, fewer features, stronger regularisation
- for a random forest, more trees rather than deeper ones
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
X, y = load_breast_cancer(return_X_y=True)
for strength in [0.01, 1.0, 100.0]:
model = make_pipeline(StandardScaler(), LogisticRegression(C=strength, max_iter=5000))
scores = cross_val_score(model, X, y, cv=5)
print(f"C={strength:<7} {scores.mean():.3f}")Output
C=0.01 0.949 C=1.0 0.981 C=100.0 0.963
C is the inverse of the regularisation strength: small C means a heavily constrained, simpler model. Sweeping a setting like this and reading the cross-validated mean is the everyday shape of model tuning.
Test yourself
3 questionsHow do you recognise overfitting?
Show the answer
The training score keeps rising while the test score falls — Both scores being low is underfitting. It is the gap between them that tells you which you have.
What does cross-validation give you that one train/test split does not?
Show the answer
Several scores, so you can see how much the number varies — A single split is one sample. Reporting whichever number you happened to get is how models get oversold.
What is the best fix for overfitting when you can get it?
Show the answer
More data — Failing that: simplify the model, use fewer features, or regularise harder.
Preprocessing
Scaling numbers, encoding categories and filling gaps.