Python Cross-Validation and Evaluation

Estimate model performance reliably with k-fold cross-validation, the right splitter for your data, meaningful metrics and leak-free pipelines.

Lesson 97 of 107Advanced12 min read

Cross-validation estimates how well a model will perform on new data by training and testing it several times on different splits. In k-fold cross-validation the training data is divided into k parts (folds); each fold takes a turn as the validation set while the model trains on the other k − 1, giving k scores instead of one:

Python
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_score
scores = cross_val_score(LinearRegression(), X, y, cv=5, scoring="neg_mean_absolute_error")
print(-scores.mean(), scores.std())

The average shows expected performance and the spread shows how stable it is. This lesson covers the fold procedure, choosing a splitter that matches your data, choosing a metric that matches the decision, and wrapping preprocessing in a pipeline so each fold stays leak-free. The examples predict attendance at community sports sessions. It builds on training and testing datasets.

Fold procedure

With cv=5, scikit-learn:

  1. splits the data into five folds;
  2. for each fold, fits a fresh copy of the model on the other four and scores it on that fold;
  3. returns five scores.
Python
from sklearn.model_selection import cross_validate
results = cross_validate(model, X, y, cv=5, scoring="neg_mean_absolute_error",
                         return_train_score=True)
print(results["test_score"], results["train_score"])

Look at every fold score, not just the mean or the best one. Widely varying scores mean performance depends heavily on which rows the model sees, often a sign of too little data or a few unusual records. A large gap between training and validation scores indicates overfitting.

Cross-validation is for comparing and tuning models using training data. It doesn't replace a final test set: if you use cross-validation scores to choose among many models, the winning score is slightly optimistic, so confirm the final choice once on held-out test data.

Appropriate splitters

The splitter decides which rows end up together, and must match how the data was produced:

SplitterUse when
KFold(shuffle=True)rows are independent (default for regression is KFold without shuffling)
StratifiedKFoldclassification; keeps class proportions in each fold (default for classifiers)
GroupKFoldrows come in groups (same venue, patient, user) that must not be split
TimeSeriesSplitrows are ordered in time; each fold trains on the past and validates on the future
Python
from sklearn.model_selection import TimeSeriesSplit
for train_idx, val_idx in TimeSeriesSplit(n_splits=3).split(X):
    print(train_idx.min(), train_idx.max(), "->", val_idx.min(), val_idx.max())

Using plain k-fold on time-ordered data lets models train on future sessions and validate on past ones, producing scores that won't hold in real forecasting. Using it on grouped data lets near-identical rows from the same venue appear in both training and validation. Pass the splitter object as cv=, and for grouped splitters pass groups= too.

Metrics

The metric should match the decision the model supports:

TaskMetricscoring=
regression, errors in real unitsmean absolute error"neg_mean_absolute_error"
regression, large errors especially badroot mean squared error"neg_root_mean_squared_error"
regression, share of variance explainedR²"r2"
classification, balanced classesaccuracy"accuracy"
classification, rare positive classF1, recall, precision"f1", "recall"
ranking quality of probabilitiesROC AUC"roc_auc"

scikit-learn always maximises scores, so error metrics are returned negated: a score of −3.2 means an MAE of 3.2. Flip the sign when reporting.

A single overall number can hide failure on an important subgroup: a model may predict weekday sessions well and weekend sessions badly. Check errors per subgroup that matters, and always compare with a baseline such as DummyRegressor evaluated with the same folds.

Pipelines

Preprocessing steps that learn from data must be refitted inside each fold. A pipeline does that automatically:

Python
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge

pipeline = make_pipeline(StandardScaler(), Ridge(alpha=1.0))
scores = cross_val_score(pipeline, X, y, cv=5, scoring="neg_mean_absolute_error")

In each fold, the scaler is fitted on that fold's training rows only, then applied to its validation rows. Scaling the whole dataset first, then cross-validating, lets every validation fold influence the scaling, which leaks information. The same applies to imputing missing values, selecting features and encoding categories.

Pipelines also make hyperparameter search safe: GridSearchCV(pipeline, {"ridge__alpha": [0.1, 1, 10]}, cv=5) tunes the model while keeping preprocessing inside the folds. Parameter names use the step name, two underscores and the parameter.

Independent practice and a worked check

Task: compare attendance predictors with an appropriate splitter, fold scores, a baseline and an untouched final test set.

Python
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X = [[t, w] for t, w in [(12, 0), (14, 1), (9, 0), (16, 1), (18, 0), (11, 1),
                         (20, 0), (15, 1), (22, 0), (13, 1), (19, 0), (24, 1)]]
y = [21, 34, 19, 38, 30, 33, 36, 41, 40, 38, 39, 52]
X_dev, y_dev, X_test, y_test = X[:9], y[:9], X[9:], y[9:]     # last 3 sessions held out

cv = TimeSeriesSplit(n_splits=3)
for name, est in [("baseline", DummyRegressor()), ("ridge", make_pipeline(StandardScaler(), Ridge()))]:
    folds = -cross_val_score(est, X_dev, y_dev, cv=cv, scoring="neg_mean_absolute_error")
    print(name, [round(float(f), 1) for f in folds])

final = make_pipeline(StandardScaler(), Ridge()).fit(X_dev, y_dev)
print("final test MAE:", round(mean_absolute_error(y_test, final.predict(X_test)), 1))

Fold scores guide the choice; the last three sessions are scored once at the end. For the worked check, preprocessing goes inside the cross-validation pipeline to fit it separately on each training fold.

Reference and next practice

scikit-learn's cross-validation guide and scoring parameter reference cover every splitter and metric. Continue with clustering with k-means. Practise with the Python exercises and Python quiz.

Cross-Validation and Evaluation Summary

  • Fold procedure: Cross validation fits on training folds and evaluates on their held out counterparts.
  • Appropriate splitters: Stratified, grouped and temporal splitters express different observation assumptions.
  • Metrics: A metric should match the outcome and operational consequences.
  • Pipelines: A pipeline fits preprocessing inside each training fold before evaluating that fold.

Cross-Validation and Evaluation Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Cross-Validation and Evaluation in a small program

Python
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_score
X = [[n] for n in range(12)]
y = [3 * n + 2 for n in range(12)]
scores = cross_val_score(LinearRegression(), X, y, cv=3, scoring="neg_mean_absolute_error")
print(len(scores))
print(round(float(-scores.mean()), 6))
Output
3
0.0
Line-by-line explanation
  1. Lines 3 to 4Perfectly linear data: y = 3x + 2.
  2. Line 5Three folds, scored with negative mean absolute error.
  3. Lines 6 to 7Three scores, and an average error of 0 because a straight line fits this data exactly. Real data never gives a perfect zero.
Example 2

Comparing a model with a baseline across folds

  • Ridge beats the baseline in every fold under both splitters. The baseline's time-series errors grow in later folds because attendance keeps rising, which an average of earlier sessions can't follow.
Python
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, TimeSeriesSplit, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# features: temperature (°C), weekend (0/1), weeks since launch; target: attendance
X = [[t, w, n] for n, (t, w) in enumerate([(12, 0), (14, 1), (9, 0), (16, 1), (18, 0), (11, 1),
                                            (20, 0), (15, 1), (22, 0), (13, 1), (19, 0), (24, 1)])]
y = [21, 34, 19, 38, 30, 33, 36, 41, 40, 38, 39, 52]

model = make_pipeline(StandardScaler(), Ridge(alpha=1.0))
baseline = DummyRegressor(strategy="mean")

for name, cv in [("shuffled 4-fold", KFold(4, shuffle=True, random_state=0)),
                 ("time series", TimeSeriesSplit(n_splits=4))]:
    for label, est in [("baseline", baseline), ("ridge", model)]:
        scores = -cross_val_score(est, X, y, cv=cv, scoring="neg_mean_absolute_error")
        print(f"{name:<16} {label:<8} MAE per fold {[round(float(s), 1) for s in scores]}  mean {scores.mean():.1f}")
Output
shuffled 4-fold  baseline MAE per fold [8.1, 8.0, 3.6, 6.9]  mean 6.6
shuffled 4-fold  ridge    MAE per fold [1.1, 1.7, 1.6, 1.0]  mean 1.4
time series      baseline MAE per fold [3.5, 9.3, 7.5, 12.5]  mean 8.2
time series      ridge    MAE per fold [1.6, 2.6, 1.2, 0.9]  mean 1.6
Line-by-line explanation
  1. Line 12Scaling sits inside the pipeline, so it's refitted within every fold.
  2. Lines 15 to 17The same two splitters are applied to both estimators, so their scores are directly comparable.
  3. Line 18Error scores are negated by scikit-learn; the minus sign turns them back into positive MAE values.

Common Cross-Validation and Evaluation Mistakes

Preprocessing the whole dataset before cross-validation

Don't
X_scaled = StandardScaler().fit_transform(X)
cross_val_score(Ridge(), X_scaled, y, cv=5)     # each fold's validation rows shaped the scaling
Do
cross_val_score(make_pipeline(StandardScaler(), Ridge()), X, y, cv=5)

Reporting only the mean of negated scores

Don't
print(cross_val_score(model, X, y, cv=5, scoring="neg_mean_absolute_error").mean())
# -4.2: looks like a negative error and hides the fold-to-fold spread
Do
mae = -cross_val_score(model, X, y, cv=5, scoring="neg_mean_absolute_error")
print(f"MAE {mae.mean():.1f} ± {mae.std():.1f} attendees over {len(mae)} folds")

Cross-Validation and Evaluation in Real Projects

Cross-validation in practice

Data scientists use cross-validation to compare feature sets, model types and hyperparameters on the same footing, usually through GridSearchCV or RandomizedSearchCV. Forecasting teams use rolling-origin (time series) validation that mirrors how models are retrained and used each week. Healthcare and education projects use grouped folds so evaluation reflects new patients or new schools.

Reports present the mean and spread across folds next to a baseline, then confirm the chosen model once on an untouched test set. That two-stage approach is what makes a reported number trustworthy.

Cross-Validation and Evaluation FAQ

What is k-fold cross-validation?

A method that splits data into k folds, trains on k − 1 of them and validates on the remaining one, repeating so each fold is used for validation once. The k scores are averaged to estimate performance and their spread shows stability.

How many folds should I use?

Five or ten is standard. More folds use more data for training each time but take longer and can give noisier individual scores. For very small datasets, leave-one-out or repeated k-fold can help; for time series use TimeSeriesSplit.

Why are cross_val_score results negative?

scikit-learn treats every score as "higher is better", so error metrics are negated: neg_mean_absolute_error returns −MAE. Multiply by −1 before reporting.

Do I still need a test set if I use cross-validation?

Yes, when you use cross-validation to choose between models or settings. The chosen configuration's cross-validation score is slightly optimistic; a final evaluation on untouched test data gives an unbiased estimate.

What is the difference between cross_val_score and cross_validate?

cross_val_score returns one array of scores for one metric. cross_validate can compute several metrics at once, return training scores and fit times, and optionally return the fitted estimators.

Cross-Validation and Evaluation Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

Why include preprocessing inside the cross validation pipeline?

Multiple choicemedium

Which review concern is valid for cross-validation and evaluation?

Cross-Validation and Evaluation Quiz

2 questions to check what you learned.

  1. Why include preprocessing inside the cross validation pipeline?

    • To inspect the final test target
    • To fit it separately on each training fold
    • Python ignores the distinction
  2. Which statement correctly describes metrics?

    • A metric should match the outcome and operational consequences.
    • Only the example output matters, not the rule
    • Every operation has the same behavior