Module 4 · Evaluation and Generalization

Cross-Validation

Lesson 11 of 24 11 min read Updated

Cross-validation answers a practical question: if this model is trained on similar data tomorrow, how much should we trust its performance estimate? A single train-test split gives one answer from one random partition. Cross-validation repeats the train-and-score process across several held-out portions, then summarizes both the average score and the variation between scores.

In the Evaluation and Generalization section, cross-validation is the bridge between fitting a model and making a defensible claim about generalization. It helps compare model families, tune hyperparameters, detect unstable performance, and choose an evaluation design that matches the way examples are produced.

Purpose and Outcome

The purpose is not to make a weak model stronger. Cross-validation measures how a training procedure behaves when it is repeatedly deprived of different parts of the data. The output is usually a list of fold scores, a mean score, and a spread such as a standard deviation or confidence interval. The spread matters because two models with nearly identical mean accuracy can have very different reliability across folds.

A good cross-validation design answers three questions. Which examples may be trained together? Which examples may be tested together? Which preprocessing steps must be learned only from the training portion of each fold? Those questions are more important than the number of folds.

How the Mechanism Works

In k-fold cross-validation, the dataset is divided into k disjoint folds. For each round, one fold is the validation fold and the remaining k minus one folds are the training folds. The model is freshly fit for each round. Scores are collected on examples that were not used to fit that round’s model.

The internal loop has four moving parts: a splitter, an estimator, a scorer, and an aggregator. The splitter emits train indices and validation indices. The estimator is cloned or recreated so learned state from one fold cannot leak into another. The scorer evaluates predictions against validation targets. The aggregator reports the distribution of scores.

Terminology is precise. A fold is one held-out subset. A split is one pair of train and validation indices. An out-of-fold prediction is a prediction for an example made by a model that did not train on that example. Nested cross-validation uses an inner cross-validation loop for model selection and an outer loop for unbiased performance estimation.

The critical internal rule is that anything learned from data must be learned inside the training portion of each split. Scaling, imputation, feature selection, dimensionality reduction, target encoding, and oversampling all learn information from examples. If they are fit before splitting, validation folds are contaminated and the reported score is often too optimistic.

API Anatomy

Most Python machine learning libraries represent cross-validation as a splitter object or function. The usual inputs are the feature matrix X, target vector y, optional group labels, the number of splits, and an optional random seed when shuffling is used. The output is a sequence of integer index arrays.

For ordinary independent and identically distributed examples, shuffled k-fold is common. For classification with imbalanced labels, stratified k-fold preserves class proportions in each fold. For repeated measurements from the same user, patient, company, or device, group-aware splits keep each group entirely in training or entirely in validation. For time-ordered data, forward-chaining or rolling-origin splits train on the past and validate on the future.

Example 1: Plain K-Fold Indices

This first example shows the shape of k-fold splitting without any model code. Ten row indices are assigned to five validation folds. Each row appears in validation exactly once and in training four times.

def kfold_indices(n_rows, n_splits):
    indices = list(range(n_rows))
    for fold in range(n_splits):
        test = indices[fold::n_splits]
        train = [i for i in indices if i not in test]
        yield train, test

for number, (train, test) in enumerate(kfold_indices(10, 5), start=1):
    print(f"fold {number}: train={train}, test={test}")

The deterministic output is five splits. The first validation fold contains rows 0 and 5, the second contains rows 1 and 6, and so on. A real library usually distributes remainder rows more carefully, but the invariant is the same: validation partitions do not overlap.

Example 2: Stratification for Class Balance

Classification can fail badly when rare classes are not represented in each validation fold. Stratification splits each class separately, then combines class-specific pieces into balanced folds.

def stratified_test_folds(labels, n_splits):
    by_label = {}
    for index, label in enumerate(labels):
        by_label.setdefault(label, []).append(index)

    folds = [[] for _ in range(n_splits)]
    for label_indices in by_label.values():
        for position, index in enumerate(label_indices):
            folds[position % n_splits].append(index)
    return [sorted(fold) for fold in folds]

labels = [0, 0, 0, 0, 1, 1, 1, 1]
for number, test in enumerate(stratified_test_folds(labels, 4), start=1):
    print(f"fold {number}: test={test}, labels={[labels[i] for i in test]}")

Each validation fold receives one example from class 0 and one from class 1. Without stratification, a small validation fold might contain only the majority class, making accuracy look acceptable while recall for the minority class is unknowable.

Example 3: Rolling-Origin Time Splits

Time series and event streams must respect order. Training on future observations to predict past observations creates an impossible evaluation. Rolling-origin splitting grows the training window and validates on the next horizon.

def rolling_origin_splits(n_rows, initial_train_size, horizon):
    stop = initial_train_size
    while stop + horizon <= n_rows:
        train = list(range(stop))
        test = list(range(stop, stop + horizon))
        yield train, test
        stop += horizon

for number, (train, test) in enumerate(rolling_origin_splits(10, 4, 2), start=1):
    print(f"split {number}: train={train}, test={test}")

The validation windows are rows 4 to 5, 6 to 7, and 8 to 9. This design estimates future performance under the same information constraint the deployed model will face.

Example 4: Cross-Validated Model Comparison

The next example compares two simple constant predictors using mean absolute error. Lower is better. The code intentionally refits the predictor inside each fold, because the training mean must be computed from that fold’s training rows only.

def mean(values):
    return sum(values) / len(values)

def mean_absolute_error(actual, predicted):
    return mean([abs(a - p) for a, p in zip(actual, predicted)])

def evaluate_constant_mean(targets, n_splits):
    scores = []
    for train_index, test_index in kfold_indices(len(targets), n_splits):
        prediction = mean([targets[i] for i in train_index])
        actual = [targets[i] for i in test_index]
        predicted = [prediction for _ in test_index]
        scores.append(mean_absolute_error(actual, predicted))
    return scores

targets = [10, 12, 13, 12, 100, 102, 98, 101, 99, 103]
scores = evaluate_constant_mean(targets, 5)
print([round(score, 2) for score in scores])
print(round(mean(scores), 2))

The fold scores show that performance depends heavily on which rows are withheld. The average summarizes the procedure, while the individual scores reveal instability caused by the two clusters in the target values.

Design Choices and Trade-Offs

The number of folds controls a bias-variance and cost trade-off. More folds train on more data per round, but require more model fits. Leave-one-out cross-validation uses almost all examples for training in each round, yet it can be expensive and produce highly correlated validation scores. Five or ten folds are common starting points for independent tabular data, but the correct choice depends on sample size, model cost, and the unit of independence.

Shuffling is useful when row order is arbitrary. It is harmful when order encodes time, batches, or acquisition conditions. Random seeds make splits reproducible, but reproducibility is not the same as validity. A perfectly reproducible leaked split is still wrong.

Stratification reduces fold-to-fold label imbalance, especially for classification metrics such as precision, recall, F1, and area under the precision-recall curve. Group splits reduce leakage when multiple rows come from the same entity, but they can make fold scores noisier because whole groups move together. Time-based splits often produce fewer usable folds because validation must remain in the future.

Cross-validation can estimate a training procedure, not a final fitted model in isolation. After selecting the procedure, you usually fit one final model on all available training data and keep a separate untouched test set for the last estimate when the stakes justify it.

Failure Modes and Troubleshooting

Symptom: validation scores are much higher than later test or deployment scores. Cause: preprocessing was fit before splitting, duplicate entities crossed folds, or future data was used. Diagnostic steps: inspect the pipeline order, search for duplicate group identifiers across train and validation indices, and check whether feature timestamps occur after prediction timestamps. Correction: move preprocessing into a pipeline fit inside each fold, use group-aware splitting, or switch to forward-chaining splits.

Symptom: one fold has undefined precision, recall, or ROC AUC. Cause: the validation fold contains only one class or too few positive examples. Diagnostic steps: print per-fold class counts before fitting. Correction: use stratified splitting, reduce the number of folds, collect more minority-class examples, or choose a metric that remains defined for the available data.

Symptom: cross-validation takes too long. Cause: expensive models are being refit many times, hyperparameter search multiplies the cost, or feature computation is repeated unnecessarily. Diagnostic steps: time each fold and separate preprocessing time from estimator fit time. Correction: reduce folds, use randomized search, cache deterministic transformations inside the training-only pipeline, or start with cheaper model families.

Symptom: scores vary wildly between folds. Cause: small data, heterogeneous groups, temporal drift, or outliers. Diagnostic steps: inspect fold membership and score by group, time period, and label. Correction: report uncertainty honestly, use a split design that matches the deployment population, gather more data, or simplify the model.

Reliability and Performance Implications

Cross-validation protects reliability by exposing score variance before release. It can also reveal that a model choice is fragile even when the mean score is attractive. For performance, remember that k folds multiply training cost by k, and nested cross-validation multiplies it again by the number of inner candidates. Parallel execution can reduce wall-clock time, but it increases memory pressure because several model fits may run at once.

Security and privacy concerns appear when folds are exported, logged, or shared. Fold assignments can reveal group membership or rare labels. Treat row identifiers, group identifiers, and target values as sensitive when the underlying dataset is sensitive. Prefer aggregate fold diagnostics unless row-level details are necessary for debugging.

Hands-On Lab

Prerequisites: Python, a local editor, and no external packages. The lab builds a tiny cross-validation runner for a one-dimensional regression problem. The model predicts with the training-fold mean.

  1. Create a scratch Python file.
  2. Paste the functions from Example 1 and Example 4.
  3. Run the file and record the five fold errors and their average.
  4. Change n_splits from 5 to 2 and run it again.
  5. Compare how the average and the fold-to-fold variation change.

Verification: every target row should appear in exactly one validation fold per run. Add this check to confirm the split coverage.

seen = []
for train, test in kfold_indices(10, 5):
    seen.extend(test)
print(sorted(seen) == list(range(10)))

The expected output is True. Cleanup is simply deleting the scratch file. If you used a notebook, restart the kernel before rerunning to ensure no stale variables hide mistakes.

Assessment Exercises

  • You have 50,000 transactions from 4,000 customers, with several transactions per customer. Which splitter should you use, and what leakage would ordinary shuffled k-fold allow?
  • A fraud dataset has 0.5 percent positives. Explain why five ordinary folds might make F1 unstable, and describe a diagnostic printout you would add before fitting.
  • A forecasting model predicts next week’s demand. Design a three-split validation plan that never trains on future data.
  • Your preprocessing selects the 20 features most correlated with the target before cross-validation. Explain why the score is biased and where feature selection must move.
  • Two models have mean cross-validation accuracy of 0.912 and 0.915, but the second has twice the fold standard deviation. What further evidence would you want before choosing?

Summary

Cross-validation estimates how a complete training procedure generalizes by repeatedly holding out different validation data. The mechanism is simple, but the split design is domain-specific. Choose folds that respect class balance, groups, time, and preprocessing boundaries. Report both average performance and variation, then use failures between folds as evidence about model stability rather than noise to ignore.

Progress is saved in this browser.