Skills101

Python for Data Science & Machine Learning · Lesson 10 of 12 · Updated ·

Better Models — Overfitting, Cross-Validation, Pipelines, Scaling and Hyperparameter Tuning

A model that memorises its training data is like a student who memorised last year's paper: perfect on that, lost on a new one. This lesson is about making models that generalise.

1. Seeing overfitting

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

rng = np.random.default_rng(7)
n = 400
X = pd.DataFrame({
    "cgpa": rng.normal(7.5, 0.9, n).clip(5, 10).round(2),
    "internships": rng.integers(0, 3, n),
    "projects": rng.integers(0, 5, n),
    "aptitude": rng.normal(65, 12, n).clip(20, 100).round(),
})
score = 1.4 * (X["cgpa"] - 7.5) + 0.8 * X["internships"] + 0.35 * X["projects"] + 0.05 * (X["aptitude"] - 65) + rng.normal(0, 0.8, n)
y = (score > 0.3).astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)

depths = range(1, 16)
train_acc, test_acc = [], []
for d in depths:
    tree = DecisionTreeClassifier(max_depth=d, random_state=0).fit(X_train, y_train)
    train_acc.append(tree.score(X_train, y_train))
    test_acc.append(tree.score(X_test, y_test))

fig, ax = plt.subplots(figsize=(7, 3.8))
ax.plot(depths, train_acc, marker="o", label="training accuracy")
ax.plot(depths, test_acc, marker="o", label="test accuracy")
ax.set_xlabel("max_depth of the tree (model complexity)")
ax.set_ylabel("Accuracy")
ax.set_title("Deeper trees memorise the training data — test accuracy stops improving")
ax.legend()
plt.savefig("overfitting-depth.svg")

Line chart of decision-tree accuracy against max depth: training accuracy climbs to 100 percent while test accuracy peaks early and then falls

  • Underfitting (left): too simple, poor on both sets.
  • Overfitting (right): near-perfect on training data, worse on the test set — the model learned the noise.
  • The best complexity is where test accuracy is highest.

2. Cross-validation: a more honest score

One train/test split can be lucky or unlucky. k-fold cross-validation splits the data into k parts, trains k times, each time testing on a different part, and averages:

import numpy as np
import pandas as pd
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier

rng = np.random.default_rng(7)
n = 400
X = pd.DataFrame({
    "cgpa": rng.normal(7.5, 0.9, n).clip(5, 10).round(2),
    "internships": rng.integers(0, 3, n),
    "projects": rng.integers(0, 5, n),
    "aptitude": rng.normal(65, 12, n).clip(20, 100).round(),
})
score = 1.4 * (X["cgpa"] - 7.5) + 0.8 * X["internships"] + 0.35 * X["projects"] + 0.05 * (X["aptitude"] - 65) + rng.normal(0, 0.8, n)
y = (score > 0.3).astype(int)

for depth in (2, 4, 12):
    scores = cross_val_score(DecisionTreeClassifier(max_depth=depth, random_state=0), X, y, cv=5)
    print(f"depth {depth:2d}: folds {np.round(scores, 2)}  mean {scores.mean():.3f}")
depth  2: folds [0.79 0.79 0.74 0.78 0.78]  mean 0.772
depth  4: folds [0.84 0.81 0.75 0.86 0.81]  mean 0.815
depth 12: folds [0.78 0.82 0.78 0.76 0.78]  mean 0.782

Compare the mean across depths, and look at how much the folds vary — a big spread means the score is unstable.

3. Pipelines: preprocessing without leakage

Real data has text categories (branch) and numbers on different scales. A Pipeline chains preprocessing and the model, so the same steps run in training and prediction — and preprocessing learns only from the training folds (no data leakage):

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

rng = np.random.default_rng(7)
n = 400
X = pd.DataFrame({
    "cgpa": rng.normal(7.5, 0.9, n).clip(5, 10).round(2),
    "internships": rng.integers(0, 3, n),
    "projects": rng.integers(0, 5, n),
    "aptitude": rng.normal(65, 12, n).clip(20, 100).round(),
    "branch": rng.choice(["CSE", "IT", "ECE", "ME"], n),
})
bonus = X["branch"].map({"CSE": 0.4, "IT": 0.3, "ECE": 0.0, "ME": -0.3})
score = 1.4 * (X["cgpa"] - 7.5) + 0.8 * X["internships"] + 0.35 * X["projects"] + 0.05 * (X["aptitude"] - 65) + bonus + rng.normal(0, 0.8, n)
y = (score > 0.3).astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)

prepare = ColumnTransformer([
    ("numbers", StandardScaler(), ["cgpa", "internships", "projects", "aptitude"]),   # mean 0, sd 1
    ("branch", OneHotEncoder(handle_unknown="ignore"), ["branch"]),                     # CSE → [1,0,0,0] …
])
pipe = Pipeline([("prepare", prepare), ("model", LogisticRegression(max_iter=1000))])

search = GridSearchCV(pipe, {"model__C": [0.01, 0.1, 1, 10]}, cv=5)   # try 4 settings × 5 folds
search.fit(X_train, y_train)
print("best C:", search.best_params_["model__C"])
print("cross-validated accuracy:", round(search.best_score_, 3))
print("test accuracy:", round(search.score(X_test, y_test), 3))
best C: 0.1
cross-validated accuracy: 0.86
test accuracy: 0.86
  • StandardScaler puts numbers on the same scale — important for many models (logistic regression with regularisation, k-NN, SVM, neural networks); trees don't need it.
  • OneHotEncoder turns categories into 0/1 columns; handle_unknown="ignore" survives a new branch at prediction time.
  • Hyperparameters (like C, max_depth) are settings you choose; GridSearchCV tries them with cross-validation on the training set only. The test set is used once, at the end.

4. Data leakage — the silent killer

Leakage = information from the test set (or the future) sneaking into training. Examples: scaling the whole dataset before splitting; a feature like "offer_letter_received" when predicting placement; random splits on time-series data. Leaky models look brilliant in the notebook and fail in production. Pipelines and honest feature choices prevent most of it.

Common mistakes

  • Tuning on the test set (then it's no longer a fair exam).
  • Preprocessing before the split.
  • Comparing models on one lucky split instead of cross-validation.
  • Chasing 0.5% accuracy while ignoring leakage and data quality.

Quick recap

  • Overfitting: great on training, worse on test; control complexity.
  • Cross-validation averages several splits for an honest estimate.
  • Pipeline = preprocessing + model, fit on training data only; GridSearchCV tunes hyperparameters.
  • Guard against data leakage; touch the test set once.

Practice what you learned

📝 Quiz: Evaluation and Tuning5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →