Lesson 9 of 30

Chapter 9 — Splitting the Dataset Correctly

Chapter overview

A model that performs perfectly on the observations it has already seen may still fail on new cases. Dataset splitting creates a controlled simulation of deployment: the model learns from one portion of the data, development decisions are made on another, and the final result is measured on observations that were protected from the entire modeling process.

This chapter develops a practical decision framework for four common splitting strategies: random, stratified, group-based, and time-based splitting. It also treats leakage as a design problem rather than a last-minute debugging problem. Every example uses Python and scikit-learn, and the practical lab asks students to justify the split that matches the way predictions will be used in reality.

Learning objectives

  • Explain the difference between training performance and generalization performance.
  • Assign clear roles to training, validation, and test sets.
  • Use train_test_split reproducibly and interpret its main parameters.
  • Preserve class proportions with stratification when appropriate.
  • Keep related observations together with group-aware splitting.
  • Respect chronology and prediction timing with time-based splitting.
  • Recognize preprocessing, feature, target, temporal, group, and duplicate leakage.
  • Audit a split with quantitative checks before training a model.
CORE PRINCIPLE  The split should reproduce the boundary that the deployed model will face: new rows, new entities, or future observations. The most realistic boundary is more important than the most convenient code.

 

Chapter map

Table 9.1. Questions answered in this chapter

Section

Central question

Main tool or check

9.1

Why not evaluate on training data?

Generalization gap

9.2

What is each subset for?Train / validation / test roles

9.3

How do we make a basic split?

train_test_split

9.4

How do we preserve class balance?

stratify=y

9.5

How do we protect entity boundaries?

GroupShuffleSplit

9.6

How do we protect chronology?Cutoff / TimeSeriesSplit

9.7

How do we prevent hidden information flow?

Leakage audit

 

Prerequisites

Students should be comfortable with pandas DataFrames, selecting columns and rows, and the ideas of features X and target y. Familiarity with a simple classifier is helpful but not required; the chapter emphasizes evaluation design rather than algorithm complexity.

9.1 Why data splitting is necessary

Data splitting separates learning from evaluation. Without that separation, a model can appear successful simply because it is being asked to reproduce patterns, noise, or individual cases that influenced its training.

Training performance versus generalization

Training performance answers a limited question: how well does the fitted model reproduce the examples used to estimate its parameters? Generalization performance asks the question that matters after deployment: how well will the model perform on relevant observations that were not used during learning?

The difference between the two is the generalization gap. A small gap is not automatically good: both scores could be poor because the model underfits. A large gap is a warning that the model may have memorized idiosyncrasies of the training set, exploited leakage, or become too complex for the available data.

Table 9.2. Reading training and unseen-data performance together

Training result

Unseen-data result

Likely interpretation

High

High

Useful generalization, subject to a valid split

High

Low

Overfitting, leakage, or distribution mismatch

Low

Low

Underfitting, weak features, or a hard problem

Low

High

Usually a measurement, sampling, or implementation anomaly

 

Unseen data as a simulation of deployment

A holdout set is not valuable merely because its rows have a different index. It is valuable when it represents the type of novelty expected in production. For a one-row-per-customer churn dataset, random unseen customers may be appropriate. For repeated hospital visits, the meaningful novelty may be an entirely unseen patient. For forecasting, it is future time. For image recognition, it may be a new person, device, site, or acquisition session.

ASK BEFORE SPLITTING  When this model is used, what exactly will be new: the row, the person or organization, the time period, the location, the device, or the data source?

 

Honest model evaluation

An evaluation is honest when the test observations do not influence model design, preprocessing choices, feature selection, threshold selection, or hyperparameter tuning. The test set is therefore not a convenient dataset for repeated experimentation. It is a final examination whose questions remain sealed until the development process is complete.

  • Use training data to fit model parameters and preprocessing statistics.
  • Use validation data or cross-validation to compare models and tune decisions.
  • Use the test set once, after the entire modeling recipe has been fixed.
  • Report the test result even when it is less favorable than the validation result.

Why evaluating on training data is optimistic

The learning algorithm explicitly searches for a model that reduces error on the training observations. Reusing those observations for evaluation rewards both useful structure and accidental fit. The resulting score is an in-sample score, not an estimate of performance on new cases.

PYTHON  •  A deliberately flexible tree: training score versus test score

from sklearn.datasets import make_classification

from sklearn.model_selection import train_test_split

from sklearn.tree import DecisionTreeClassifier

 

X, y = make_classification(

    n_samples=1_000,

    n_features=20,

    n_informative=5,

    flip_y=0.08,

    random_state=42,

)

 

X_train, X_test, y_train, y_test = train_test_split(

    X, y, test_size=0.25, random_state=42, stratify=y

)

 

model = DecisionTreeClassifier(random_state=42)

model.fit(X_train, y_train)

 

print("Training accuracy:", model.score(X_train, y_train))

 

PYTHON  •  A deliberately flexible tree: training score versus test score — continued
print("Test accuracy:    ", model.score(X_test, y_test))

 

The exact scores depend on the data and software version, but the unrestricted tree commonly obtains a much higher training score than test score. The purpose of the example is not to condemn decision trees; it is to show why an unseen set is necessary to reveal the gap.

Checkpoint

A model obtains 99% accuracy on its training data and 73% on a correctly protected test set. Which number estimates deployment performance more directly? What additional evidence would you request before trusting it? Record a two-sentence answer.

9.2 Basic dataset split

A three-way split assigns a distinct job to each subset. The separation prevents development decisions from quietly adapting to the same data used for the final claim.

Figure 9.1. The three-way holdout design

TRAINING
70%

VALIDATION
15%

TEST
15%

 

The training set

The training set is the only subset used to estimate model parameters. The model learns coefficients, tree rules, neighbor structures, or other internal representations from it. Data-dependent preprocessing steps must also be fitted on the training set: imputation values, scaling statistics, encoding categories, selected features, and resampling decisions.

The validation set

The validation set supports development choices. It is used to compare candidate algorithms, tune hyperparameters, select features, choose probability thresholds, and diagnose underfitting or overfitting. When cross-validation is used, several temporary validation folds are created within the training portion, and a fixed standalone validation set may be unnecessary.

The test set

The test set estimates final performance after the full modeling procedure is frozen. It should not influence model selection, preprocessing decisions, or metric choice. If the test result triggers another round of tuning, the test set has become validation data; a new independent test set is then needed for an unbiased final estimate.

Table 9.3. The role of each subset

Subset

Permitted uses

Must not be used for

Training

Fit preprocessing and model parametersFinal performance claim

Validation

Model selection, tuning, threshold choiceFitting final parameters as if unseen

Test

One final evaluation of the frozen processIterative tuning or feature exploration

 

Typical proportions

There is no universally correct percentage. The relevant quantities are the absolute number of training observations, the number of independent evaluation units, the rarity of important outcomes, and the precision required from the final metric.

Table 9.4. Starting points for split proportions

Dataset situation

Possible starting point

Reasoning

Large dataset

80/10/10 or 90/5/5

Even a small percentage may provide many evaluation cases

Medium dataset

70/15/15 or 80/10/10Balances learning capacity and stable evaluation

Small dataset

Hold out test; cross-validate trainingAvoids wasting a large fixed validation portion
Rare positive classChoose counts, not only percentagesEach subset needs enough positives for meaningful metrics

Grouped dataset

Allocate groups, not rowsRow percentages can hide small numbers of independent entities
Time-ordered datasetUse meaningful time windowsBusiness cycles and drift matter more than percentages

 

The effect of dataset size

A test set of 20% means 200 observations in a dataset of 1,000 but 200,000 observations in a dataset of one million. The second test set may be unnecessarily large for many metrics, while the first may contain too few cases from a rare class. Always inspect counts after splitting.

For classification, count each class in every subset. For grouped data, count unique groups as well as rows. For time-based data, report the date range and duration. For regression, compare the distribution and range of the target, especially the number of extreme or operationally important values.

PYTHON  •   Translate proportions into absolute counts

import math

 

n_rows = 2_400

positive_rate = 0.03

test_fraction = 0.20

 

expected_test_rows = round(n_rows * test_fraction)

expected_test_positives = n_rows * positive_rate * test_fraction

 

print("Expected test rows:", expected_test_rows)

print("Expected positive cases:", expected_test_positives)

print("Rounded-up minimum estimate:", math.ceil(expected_test_positives))

 

IMPORTANT  A larger test set reduces uncertainty in the final metric but leaves fewer observations for learning. A valid split is a resource-allocation decision, not a ritual percentage.

 

9.3 Using train_test_split

The scikit-learn train_test_split function is a convenient way to create a random holdout. It accepts aligned arrays or DataFrames and returns training and test subsets in matching order.

A basic split

PYTHON  •   Create an 80/20 holdout

from sklearn.model_selection import train_test_split

 

# X may be a pandas DataFrame; y may be a Series.

X_train, X_test, y_train, y_test = train_test_split(

    X,

    y,

    test_size=0.20,

    random_state=42,

    shuffle=True,

)

 

print("Training rows:"len(X_train))

print("Test rows:     "len(X_test))

 

Main parameters

Table 9.5. Important train_test_split parameters

Parameter

Meaning

Practical guidance

test_size

Fraction or number of test samplesSpecify explicitly; interpret in absolute counts

train_size

Fraction or number of training samplesUsually omit when test_size is given

random_state

Seed controlling the random splitUse a documented integer for reproducibility

shuffle

Whether samples are shuffled before splittingKeep True for suitable i.i.d. data; False for chronology

stratify

Labels used to preserve proportionsFor classification, often pass y

 

Creating training, validation, and test sets

train_test_split creates two subsets per call. A three-way split is commonly produced in two stages. First, protect the test set. Second, divide the remaining development data into training and validation sets. The second proportion must be calculated relative to the remainder.

PYTHON  •  A reproducible 70/15/15 split

from sklearn.model_selection import train_test_split

 

# Stage 1: reserve 15% for the final test.

X_dev, X_test, y_dev, y_test = train_test_split(

    X, y,

    test_size=0.15,

    random_state=42,

    stratify=y,

)

 

# Stage 2: 15 / 85 of the remainder gives 15% of the full dataset.

validation_fraction_of_dev = 0.150.85

X_train, X_valid, y_train, y_valid = train_test_split(

    X_dev, y_dev,

    test_size=validation_fraction_of_dev,

    random_state=42,

    stratify=y_dev,

)

 

print(len(X_train) / len(X))   # approximately 0.70

 

PYTHON  •  A reproducible 70/15/15 split — continued

print(len(X_valid) / len(X))   # approximately 0.15

print(len(X_test) / len(X))    # approximately 0.15

 

Random state and reproducibility

Passing the same integer random_state to the same splitting code and same ordered input data makes the random partition repeatable across calls. This is essential for fair experiments: two candidate models should not appear different merely because they were evaluated on different random rows.

A seed does not make the whole project reproducible by itself. The dataset version, row order, preprocessing code, software versions, model randomness, and evaluation settings must also be recorded. The seed is part of an experiment specification, not a guarantee that all environments will behave identically forever.

Shuffling

Shuffling is useful when the row order is arbitrary or organized by class, source file, or collection batch. It helps prevent one contiguous region from becoming the entire test set. Shuffling is inappropriate when order carries information that deployment must respect, particularly forecasting and other future-prediction problems.

API CONSTRAINT  When shuffle=False in train_test_split, stratify must be None. For chronological data, sort explicitly by time and slice by a cutoff instead of trying to combine non-shuffling with stratification.

 

Audit the output, not only the code

PYTHON  •   Verify sizes, index separation, and target balance

def audit_basic_split(X_train, X_test, y_train, y_test):

    print("Train shape:", X_train.shape)

    print("Test shape: ", X_test.shape)

 

    overlap = set(X_train.index).intersection(X_test.index)

    print("Overlapping row indices:"len(overlap))

 

    print("\nTrain target proportions")

    print(y_train.value_counts(normalize=True).sort_index())

 

    print("\nTest target proportions")

    print(y_test.value_counts(normalize=True).sort_index())

 

audit_basic_split(X_train, X_test, y_train, y_test)

 

9.4 Stratified splitting

A stratified split preserves the target-class proportions approximately across subsets. It is often the safest basic strategy for classification when observations are independent and no group or time boundary is required.

Why class proportions matter

Suppose 5% of observations are fraudulent. A purely random split can, by chance, place a noticeably different percentage in training and testing, especially when the dataset or minority class is small. This affects both learning and evaluation. Stratification reduces that accidental imbalance by allocating samples according to their class labels.

PYTHON  •   Compare random and stratified target distributions

import pandas as pd

from sklearn.model_selection import train_test_split

 

y = pd.Series([0] * 950 + [1] * 50, name="fraud")

X = pd.DataFrame({"row_id"range(len(y))})

 

_, X_test_random, _, y_test_random = train_test_split(

    X, y, test_size=0.20, random_state=7

)

 

_, X_test_strat, _, y_test_strat = train_test_split(

    X, y, test_size=0.20, random_state=7, stratify=y

)

 

print("Full data:", y.mean())

print("Random test:", y_test_random.mean())

print("Stratified test:", y_test_strat.mean())

 

Stratification is approximate

Integer sample counts constrain the result. If a class contains seven observations, it is impossible to reproduce its overall proportion exactly in every subset. Scikit-learn aims to preserve relative frequencies as closely as the requested split and class counts allow.

Limitations for very rare classes

  • A class represented by one observation cannot appear in both training and testing.
  • A tiny test count produces an unstable recall estimate: one additional error can change the metric dramatically.
  • Rare-class examples may be correlated by source, patient, device, or event, so stratifying rows can still leak groups.
  • If the minority class appears only in recent time, class balance and chronological realism may conflict.
  • Collecting more labeled cases or redesigning the evaluation may be more responsible than forcing a split.
QUICK CALCULATION  Expected minority cases in a subset are approximately n_minority × subset_fraction. If this number is only a handful, report confidence intervals or repeated validation and interpret the score cautiously.

 

When stratification is not enough

Standard stratification protects a single class-label distribution; it does not automatically protect group membership, time order, geographic independence, or multiple labels attached to each observation. If several constraints matter, prioritize the deployment boundary. StratifiedGroupKFold can help during cross-validation when both class balance and group separation are needed, but perfect balance may be impossible when groups have different sizes or class mixtures.

A class-count audit

PYTHON  •   Report class counts and rates in every subset

def class_report(name, y_subset):

    counts = y_subset.value_counts(dropna=False).sort_index()

    rates = y_subset.value_counts(normalize=True, dropna=False).sort_index()

    report = pd.DataFrame({"count": counts, "proportion": rates})

    print(f"\n{name}")

    print(report)

 

class_report("TRAIN", y_train)

class_report("VALIDATION", y_valid)

class_report("TEST", y_test)

 

Decision rule

Use a stratified row split when the target is categorical, each row is an approximately independent unit, chronology is not part of the prediction problem, and each class contains enough examples. If any of these conditions fails, move to a more appropriate splitting design.

9.5 Group-based splitting

Repeated observations from the same entity are usually correlated. A random row split may therefore place near-duplicates or entity-specific signatures on both sides of the evaluation boundary.

What is a group?

A group identifier marks observations that must remain together. Common examples include patient_id, customer_id, person_id, machine_id, household_id, school_id, store_id, acquisition_session, or source_document. The identifier is used by the splitter even when it should not be used as a predictive feature.

Table 9.6. Examples of dependent observations

Dataset

Repeated unit

What a random row split may learn

Medical visits

Patient

Patient-specific baseline, history, or recording pattern

Transactions

Customer

Customer identity or stable purchasing signature

Face images

Person

The same face rather than general facial characteristics

Sensor windows

Machine

Machine-specific calibration or wear pattern
Student assessments

Student or school

Individual or institutional signatures

 

Why entity overlap is leakage

Imagine a model that predicts whether a machine will fail. Each machine contributes hundreds of sensor windows. If windows from the same machine appear in training and testing, the model may recognize that machine's vibration profile. The test score then estimates performance on more windows from familiar machines, not on a new machine. Whether this is leakage depends on the intended deployment, but it must be an explicit choice.

Using GroupShuffleSplit

PYTHON  •   Create a holdout with disjoint entities

import numpy as np

from sklearn.model_selection import GroupShuffleSplit

 

groups = df["patient_id"]

X = df.drop(columns=["target""patient_id"])

y = df["target"]

 

splitter = GroupShuffleSplit(

    n_splits=1,

    test_size=0.20,

    random_state=42,

)

 

train_idx, test_idx = next(splitter.split(X, y, groups=groups))

X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]

y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]

groups_train = groups.iloc[train_idx]

groups_test = groups.iloc[test_idx]

 

overlap = np.intersect1d(groups_train.unique(), groups_test.unique())

 

PYTHON  •   Create a holdout with disjoint entities — continued

assert len(overlap) == 0

print("Train groups:", groups_train.nunique())

print("Test groups: ", groups_test.nunique())

 

IMPORTANT API DETAIL  For GroupShuffleSplit, test_size and train_size refer to groups, not to rows. Unequal group sizes mean the resulting row percentage may differ from the requested group percentage.

 

Group-aware validation options

Table 9.7. Common group-aware strategies

Splitter

Guarantee

Typical use

GroupShuffleSplit

Randomly holds out groupsOne or repeated group holdouts

GroupKFold

A group never appears in both sides of a foldSystematic group-aware cross-validation
StratifiedGroupKFoldAttempts class balance while separating groupsImbalanced grouped classification

LeaveOneGroupOut

One entire group is held out each timeSmall number of meaningful sites or subjects

 

Audit group separation

PYTHON  •   Reusable entity-overlap check

def group_overlap_report(groups_train, groups_test):

    train_groups = set(groups_train)

    test_groups = set(groups_test)

    overlap = train_groups & test_groups

 

    return {

        "train_unique_groups"len(train_groups),

        "test_unique_groups"len(test_groups),

        "overlapping_groups"len(overlap),

        "example_overlaps"sorted(overlap)[:5],

    }

 

report = group_overlap_report(groups_train, groups_test)

print(report)

assert report["overlapping_groups"] == 0

 

Questions to settle with domain experts

  • Will deployment predict new observations for known entities, entirely new entities, or both?
  • Which identifier captures the strongest dependency: person, household, site, device, or session?
  • Are some groups so large that they dominate the row count or class distribution?
  • Should sites, geographic regions, or acquisition batches be held out to test broader transfer?
  • Can a group appear under multiple identifiers because of data-integration errors?

9.6 Time-based splitting

When predictions are made about the future, evaluation must preserve chronology. Training on later observations and testing on earlier ones gives the model access to information that would not exist at the historical prediction time.

Figure 9.2. A chronological holdout with an optional gap

Older history

Recent training

Gap

Future test period

TRAIN

TRAIN

GAP

TEST

 

Training on the past and testing on the future

A chronological holdout first sorts observations by the relevant event or prediction time, selects a cutoff, trains on observations before the cutoff, and evaluates on observations after it. The test window should represent a realistic deployment horizon such as the next week, quarter, season, or maintenance cycle.

PYTHON  •   Manual chronological holdout

df = df.sort_values("prediction_time").copy()

cutoff = pd.Timestamp("2025-01-01")

 

train_mask = df["prediction_time"] < cutoff

test_mask = df["prediction_time"] >= cutoff

 

train_df = df.loc[train_mask]

test_df = df.loc[test_mask]

 

assert train_df["prediction_time"].max() < test_df["prediction_time"].min()

print(train_df["prediction_time"].min(), train_df["prediction_time"].max())

print(test_df["prediction_time"].min(), test_df["prediction_time"].max())

 

Why random splitting is dangerous for forecasting-like problems

A random split mixes old and new rows. The training data can then include later market conditions, newer equipment behavior, updated policies, or post-event measurements that help predict earlier test cases. Because adjacent observations are often similar, the model may also benefit from autocorrelation: a training row from tomorrow can closely resemble a test row from today.

Temporal leakage

  • Computing a customer's total purchases using transactions that occur after the prediction date.
  • Using a final hospital discharge code to predict an outcome at admission.
  • Filling missing historical values with an average calculated from future observations.
  • Randomly splitting overlapping sensor windows so neighboring windows occur in both subsets.
  • Using a data extract or revised label that was not available at the intended prediction time.

Concept drift

Concept drift occurs when the relationship between inputs and target changes over time. Customer behavior, fraud tactics, equipment wear, clinical practice, economic conditions, and data-collection systems can evolve. A future holdout naturally tests whether the model survives some of this change; a random split can conceal it by averaging different periods together.

Table 9.8. Common temporal evaluation designs

Temporal design

How it works

When useful

Single cutoff

One past training window and one future test windowFinal deployment simulation

Expanding window

Training grows; each fold tests the next periodLearning from all prior history

Rolling window

Fixed-length training window moves forwardOld data becomes less relevant

Gap / embargo

Remove observations near the boundaryDelayed labels or overlapping windows

 

Using TimeSeriesSplit

PYTHON  •   Forward-chaining validation

from sklearn.model_selection import TimeSeriesSplit

 

ordered = df.sort_values("prediction_time")

X_time = ordered[feature_columns]

y_time = ordered["target"]

 

tscv = TimeSeriesSplit(

    n_splits=5,

    test_size=200,

    gap=10,

)

 

for fold, (train_idx, valid_idx) in enumerate(tscv.split(X_time), start=1):

    train_end = ordered.iloc[train_idx]["prediction_time"].max()

    valid_start = ordered.iloc[valid_idx]["prediction_time"].min()

    print(f"Fold {fold}: train ends {train_end}, validation starts {valid_start}")

 

TimeSeriesSplit creates successive training sets that contain earlier observations and validation sets that follow them. When equal-duration metrics are required, observations should be equally spaced or first aggregated to a consistent interval. The gap parameter excludes a number of observations between train and validation boundaries.

DESIGN WARNING  Time order alone is not always sufficient. If several rows belong to the same customer or machine across time, the project may need both entity and temporal constraints. Define which future scenario the evaluation must simulate.

 

9.7 Data leakage

Data leakage occurs when information unavailable to the real prediction process influences training, model selection, or evaluation. Leakage makes validation results look better without improving genuine future performance.

A practical definition

Information has leaked when a model receives a shortcut that crosses the intended prediction boundary. The shortcut may be a feature that directly reveals the answer, a preprocessing statistic computed from test rows, a future observation, a repeated entity, or a duplicate. The essential question is not whether a column is technically present; it is whether that information would be legitimately available when the prediction is made.

Table 9.9. Common leakage mechanisms and controls

Leakage type

Example

Prevention

Preprocessing

Scaler fitted on the complete datasetFit transforms only on training data; use a pipeline

Feature

A field created after the prediction decisionDocument feature availability at prediction time

Target

Outcome-derived status or near-copy of the labelTrace how every feature is generated

Temporal

Future transactions included in historical totalsUse point-in-time-correct features and chronological splits

Group

Same patient appears in training and testingSplit by the relevant entity identifier

Duplicates

Copied row or near-identical image on both sidesDeduplicate or assign duplicate clusters together

 

Preprocessing leakage

Preprocessing can learn from data even when it does not use the target. For example, the mean used for imputation and the mean and standard deviation used for scaling are statistics estimated from the dataset. If they are computed before splitting, the test distribution affects the representation of the training data.

PYTHON  •   Incorrect: fitting the scaler before the split

from sklearn.preprocessing import StandardScaler

from sklearn.model_selection import train_test_split

 

scaler = StandardScaler()

X_scaled = scaler.fit_transform(X)   # Leakage: X includes future test rows.

 

X_train, X_test, y_train, y_test = train_test_split(

    X_scaled, y, test_size=0.20, random_state=42

)

 

PYTHON  •   Correct: split first, then fit on training data

from sklearn.preprocessing import StandardScaler

from sklearn.model_selection import train_test_split

 

X_train, X_test, y_train, y_test = train_test_split(

    X, y, test_size=0.20, random_state=42, stratify=y

)

 

scaler = StandardScaler()

X_train_scaled = scaler.fit_transform(X_train)

X_test_scaled = scaler.transform(X_test)   # Transform only; do not refit.

 

Pipelines protect the boundary

A scikit-learn Pipeline bundles preprocessing and modeling. During fitting, each transformation learns only from the training subset supplied to the pipeline. During prediction, the already-fitted transformations are applied to validation or test data. Pipelines reduce accidental leakage and make the modeling recipe reproducible.

Leakage-safe pipeline example

PYTHON  •   Fit preprocessing and model as one pipeline

from sklearn.impute import SimpleImputer

from sklearn.linear_model import LogisticRegression

from sklearn.pipeline import Pipeline

from sklearn.preprocessing import StandardScaler

 

pipeline = Pipeline([

    ("imputer"SimpleImputer(strategy="median")),

    ("scaler"StandardScaler()),

    ("model"LogisticRegression(max_iter=1_000)),

])

 

pipeline.fit(X_train, y_train)

test_score = pipeline.score(X_test, y_test)

print("Protected test score:", test_score)

 

Feature and target leakage

Feature leakage arises when a feature is unavailable, incomplete, or not yet known at prediction time. Target leakage is a particularly severe form in which a feature directly or indirectly encodes the outcome. A churn prediction dataset might include account_closed_date; a credit-risk dataset might include final_collection_status; a medical diagnosis model might include a treatment code assigned after diagnosis.

Leakage through duplicates

Exact duplicates are easy to detect, but near-duplicates can be more dangerous. Two crops of the same image, overlapping windows from the same signal, repeated exports of the same transaction, or lightly edited text can cross the split. Deduplication should be performed with a rule defined before model evaluation, and duplicate clusters should be assigned as groups when all copies cannot simply be removed.

Duplicate audit in Python

PYTHON  •   Check exact duplicates across two subsets

import pandas as pd

 

feature_cols = ["feature_1""feature_2""feature_3"]

 

train_keys = pd.util.hash_pandas_object(

    X_train[feature_cols], index=False

)

test_keys = pd.util.hash_pandas_object(

    X_test[feature_cols], index=False

)

 

duplicate_hashes = set(train_keys).intersection(test_keys)

print("Exact feature duplicates across split:"len(duplicate_hashes))

 

A leakage audit before training

  • Prediction time: write the exact moment at which the prediction is made.
  • Availability: confirm that every feature exists at that moment.
  • Transformation fit: identify which steps learn statistics and fit them only on training data.
  • Entity overlap: test whether relevant groups appear in more than one subset.
  • Time order: confirm that no training timestamp occurs after a protected future test period.
  • Duplicates: search for exact and domain-specific near-duplicates across subsets.
  • Target construction: verify that labels and label-derived fields do not enter X.
  • Human process: ensure the test set was not used to choose features, thresholds, or metrics.
STOP CONDITION  If a split violates the deployment boundary, do not train models to see whether the problem matters. Repair the evaluation design first; otherwise every subsequent score is difficult to interpret.

 

Practical lab — Compare four splitting strategies

In this lab, students work with a synthetic customer-event dataset containing repeated customers, a chronological variable, and an imbalanced binary target. They create random, stratified, grouped, and time-based splits; audit each split; then explain which strategy is valid under different deployment assumptions.

Lab objectives

  • Implement the four splitting strategies with reproducible code.
  • Compare class distributions and sample counts.
  • Measure customer overlap between training and testing.
  • Check whether chronology is respected.
  • Connect each technical split to a concrete deployment scenario.

Scenario

A subscription service records several monthly observations per customer. The target, churn_next_month, indicates whether the customer leaves during the following month. The company is considering two uses: scoring future months for existing customers and scoring customers from a newly acquired portfolio. These uses imply different evaluation boundaries.

Part A — Create the dataset

PYTHON  •   Generate reproducible grouped and time-ordered data

import numpy as np

import pandas as pd

 

rng = np.random.default_rng(42)

n_customers = 300

months = pd.date_range("2023-01-01", periods=18, freq="MS")

 

rows = []

for customer_id inrange(n_customers):

    customer_risk = rng.normal(00.8)

    observed_months = rng.choice(months, size=rng.integers(413), replace=False)

    for month in sorted(observed_months):

        tenure = rng.integers(172)

        usage = rng.gamma(shape=3.0, scale=12.0)

        support_calls = rng.poisson(1.5)

        late_period = int(month >= pd.Timestamp("2024-01-01"))

        logit = (

            -3.2 + customer_risk - 0.025 * tenure

            - 0.018 * usage + 0.35 * support_calls + 0.8 * late_period

        )

 

PYTHON  •   Generate reproducible grouped and time-ordered data — continued

        probability = 1 / (1 + np.exp(-logit))

        target = rng.binomial(1, probability)

        rows.append((customer_id, month, tenure, usage, support_calls, target))

 

df = pd.DataFrame(rows, columns=[

    "customer_id""month""tenure_months",

    "usage_hours""support_calls""churn_next_month"

]).sort_values(["month""customer_id"]).reset_index(drop=True)

 

feature_cols = ["tenure_months""usage_hours""support_calls"]

X = df[feature_cols]

y = df["churn_next_month"]

groups = df["customer_id"]

print(df.shape)

print(y.value_counts(normalize=True))

 

Part B — Define reusable audits

PYTHON  •   Summarize every candidate split

def summarize_split(name, train_idx, test_idx, frame, target, group_col, time_col):

    train = frame.iloc[train_idx]

    test = frame.iloc[test_idx]

 

    train_groups = set(train[group_col])

    test_groups = set(test[group_col])

 

    return {

        "strategy": name,

        "train_rows"len(train),

        "test_rows"len(test),

        "train_positive_rate": target.iloc[train_idx].mean(),

        "test_positive_rate": target.iloc[test_idx].mean(),

        "overlapping_groups"len(train_groups & test_groups),

        "latest_train_time": train[time_col].max(),

        "earliest_test_time": test[time_col].min(),

        "chronology_ok": train[time_col].max() < test[time_col].min(),

    }

 

def index_from_labels(full_index, selected_index):

 

PYTHON  •   Summarize every candidate split — continued

    positions = full_index.get_indexer(selected_index)

    assert (positions >= 0).all()

    return positions

 

Part C — Random and stratified splits

PYTHON  •   Create two row-level holdouts

from sklearn.model_selection import train_test_split

 

all_idx = np.arange(len(df))

 

random_train, random_test = train_test_split(

    all_idx, test_size=0.20, random_state=42

)

 

strat_train, strat_test = train_test_split(

    all_idx, test_size=0.20, random_state=42, stratify=y

)

 

reports = [

    summarize_split("random", random_train, random_test, df, y, "customer_id""month"),

    summarize_split("stratified", strat_train, strat_test, df, y, "customer_id""month"),

]

 

Part D — Group-based split

PYTHON  •   Hold out complete customers

from sklearn.model_selection import GroupShuffleSplit

 

gss = GroupShuffleSplit(n_splits=1, test_size=0.20, random_state=42)

group_train, group_test = next(gss.split(X, y, groups=groups))

 

reports.append(

    summarize_split("grouped", group_train, group_test, df, y, "customer_id""month")

)

 

Part E — Time-based split

PYTHON  •   Hold out the final three months

cutoff = df["month"].max() - pd.DateOffset(months=2)

time_train = np.flatnonzero((df["month"] < cutoff).to_numpy())

time_test = np.flatnonzero((df["month"] >= cutoff).to_numpy())

 

reports.append(

    summarize_split("time-based", time_train, time_test, df, y, "customer_id""month")

)

 

comparison = pd.DataFrame(reports)

print(comparison.to_string(index=False))

 

Part F — Interpret the comparison

Complete the interpretation table after running the code. Do not select a winner based only on similar class proportions. Each column reveals a different property of the evaluation design.

Table 9.10. Expected qualitative differences among the four splits

Strategy

Class balance

Group overlap

Chronology

Suitable deployment claim

Random

Inspect

Usually high

No

New independent rows under a stable distribution

Stratified

Best protected

Usually high

No

Independent rows with important class balance

Grouped

May vary

Zero

Usually no

Entirely unseen customers

Time-based

May drift

May overlap

Yes

Future months after a historical training period

 

Student tasks

  1. Run all four splitting strategies and save the comparison table.
  2. Explain why random and stratified splits can have many overlapping customers.
  3. Explain why the grouped split can have a different row percentage from 20%.
  4. Measure the number of positive test cases for every strategy.
  5. For the time split, report the training and test date ranges.
  6. Choose the split for predicting future months for existing customers.
  7. Choose the split for predicting a newly acquired portfolio of unseen customers.
  8. Propose a design when both new customers and future months must be tested.

Part G — Optional model comparison

The following extension trains the same pipeline under each split. The purpose is to demonstrate that a higher score is not necessarily more trustworthy. A random row split may score well because repeated customers occur on both sides; a future split may be harder because the target process changes over time.

PYTHON  •   Evaluate one fixed pipeline under each split

from sklearn.linear_model import LogisticRegression

from sklearn.metrics import balanced_accuracy_score, f1_score

from sklearn.pipeline import Pipeline

from sklearn.preprocessing import StandardScaler

 

model = Pipeline([

    ("scale"StandardScaler()),

    ("classifier"LogisticRegression(max_iter=1_000, class_weight="balanced")),

])

 

split_indices = {

    "random": (random_train, random_test),

    "stratified": (strat_train, strat_test),

    "grouped": (group_train, group_test),

    "time-based": (time_train, time_test),

}

 

scores = []

for name, (train_idx, test_idx) in split_indices.items():

    model.fit(X.iloc[train_idx], y.iloc[train_idx])

 

PYTHON  •   Evaluate one fixed pipeline under each split — continued

    prediction = model.predict(X.iloc[test_idx])

    scores.append({

        "strategy": name,

        "balanced_accuracy"balanced_accuracy_score(y.iloc[test_idx], prediction),

        "f1"f1_score(y.iloc[test_idx], prediction, zero_division=0),

    })

 

print(pd.DataFrame(scores))

 

Expected findings

INTERPRETATION RULE  The score answers the question defined by the split. If the split does not match deployment, a precise metric is a precise answer to the wrong question.

 

  • Stratification should preserve the overall positive rate more closely than an unconstrained random split.
  • Random and stratified row splits should contain many of the same customer IDs in training and testing.
  • The grouped split should have zero customer overlap but may have less stable class balance.
  • Only the time-based split should guarantee that the latest training time precedes the earliest test time.
  • The most optimistic model score may come from the least realistic split.

Challenge extension

Design a combined future-and-new-customer evaluation. One defensible approach is to choose a future cutoff, identify customers first appearing in the future test window, and evaluate only those customers. Another is to create outer time blocks and keep entities separated within each block. State precisely which population the resulting metric represents and how much data remains.

Lab deliverables

Table 9.11. Practical-lab submission checklist

Deliverable

Evidence expected

Notebook

Executable code with fixed random seeds and clear section labels

Comparison table

Counts, rates, group overlap, and time-boundary checks

Recommendation

Chosen split linked to a stated deployment scenario

Leakage audit

At least five checked risks and any corrective actions

Reflection

Why the highest score is not automatically the most credible

 

Split-selection decision guide

Use the following sequence before writing splitting code. The questions are ordered by constraints that usually dominate simple class balancing.

  1. Is the prediction about a future period? If yes, begin with a chronological design.
  2. Do several rows belong to the same entity, site, device, session, or source? If yes, protect the relevant group boundary.
  3. Is the target categorical and imbalanced? If yes, preserve or at least audit class proportions within the valid boundary.
  4. Are observations approximately independent and identically distributed? If yes, a shuffled random or stratified split may be appropriate.
  5. Is the dataset small? Protect a final test set and use cross-validation within training rather than repeatedly consuming the test set.

Table 9.12. Strategy selection at a glance

Data structure

Recommended starting strategy

Essential audit

Independent classification rowsStratified random splitClass counts and duplicate overlap
Independent regression rows

Random split

Target range and distribution

Repeated entities

Group split

Zero entity overlap
Forecasting or future prediction

Time-based split

Train dates strictly precede test dates
Repeated entities over timeCombined group/time designBoth entity and temporal boundaries

Very small dataset

Final holdout + suitable cross-validationStability across folds and enough test cases

 

Minimum split documentation

Every project report should state the following information so another analyst can reconstruct and challenge the evaluation design.

  • Dataset version and number of observations before and after exclusions.
  • Unit of observation and target definition.
  • Training, validation, and test sizes in both rows and percentages.
  • Class counts or target-distribution summary for every subset.
  • Random seed and splitter parameters, when randomness is used.
  • Group identifier and unique-group counts, when groups are used.
  • Date ranges, cutoff, gap, and horizon, when time is used.
  • Duplicate handling and leakage checks.
  • Explicit statement that the test set was not used during development.

Common mistakes and corrections

Table 9.13. Frequent splitting errors

Mistake

Why it fails

Correction

Split after preprocessingTest statistics influence training representationSplit first or fit a pipeline only on training
Tune repeatedly on testThe test set becomes part of model selectionUse validation/CV; evaluate test once
Group by row, not entityRelated records cross the boundaryPass a meaningful group identifier
Shuffle future dataThe model learns from later periodsSort by time and use forward evaluation
Trust percentages aloneRare cases or groups may be too fewInspect absolute counts
Choose the seed with best scoreSeed selection optimizes on evaluation noiseFix the seed or report repeated-split variability

 

Knowledge check

Choose one answer for each question. Complete the questions before consulting the answer key.

1. What is the main purpose of a test set?

  • A. Fit model parameters
  • B. Select features repeatedly
  • C. Estimate final performance on protected unseen data
  • D. Increase the training score

2. Which parameter makes a shuffled train_test_split repeatable?

  • A. test_size
  • B. random_state
  • C. stratify
  • D. train_size

3. In classification, stratify=y is mainly used to:

  • A. Scale features
  • B. Preserve class proportions approximately
  • C. Remove duplicates
  • D. Sort observations by time

4. Several visits belong to each patient. Which starting strategy best tests new-patient generalization?

  • A. Random row split
  • B. Stratified row split only
  • C. Group-based split by patient_id
  • D. Sort by target value

5. Why can random splitting be invalid for forecasting?

  • A. It always creates small sets
  • B. It can train on future observations
  • C. It cannot handle numeric targets
  • D. It removes rare classes

6. Which operation is preprocessing leakage?

  • A. Fitting a scaler on training data
  • B. Transforming test data with the fitted scaler
  • C. Fitting a scaler on all data before splitting
  • D. Recording the random seed

7. GroupShuffleSplit test_size primarily refers to:

  • A. Features
  • B. Target classes
  • C. Groups
  • D. Calendar days

8. A model is tuned many times after inspecting test results. What has happened?

  • A. The test set effectively became validation data
  • B. The model became stratified
  • C. The target was standardized
  • D. Group leakage was removed

9. What should be checked for a time-based holdout?

  • A. Training times follow test times
  • B. Training and test times are identical
  • C. The latest training time precedes the earliest test time
  • D. Rows are shuffled after sorting

10. Which statement is most accurate?

  • A. The highest score always identifies the best split
  • B. Every classification dataset should use only stratification
  • C. The split should reproduce the deployment boundary
  • D. A fixed random seed prevents all leakage

Answer key and explanations

Table 9.14. Knowledge-check solutions

Answer

Explanation

1 — C

The test set is reserved for the final assessment of the frozen modeling process.

2 — B

An integer random_state controls the randomized partition for repeatable calls.

3 — B

Stratification approximately preserves class frequencies in each subset.

4 — C

All visits for a patient must remain together to evaluate generalization to unseen patients.

5 — B

A shuffled split can place later observations in training and earlier observations in testing.

6 — C

Fitting on all data allows test observations to influence the learned scaling statistics.

7 — C

GroupShuffleSplit allocates groups; row counts can differ because group sizes vary.

8 — A

Repeated decisions based on test results adapt the process to that test set.

9 — C

A valid future holdout requires training observations to precede the test period.

10 — C

The split is credible when it simulates the novelty expected at deployment.

 

Score interpretation

  • 9–10 correct: ready to justify and audit split strategies.
  • 7–8 correct: solid understanding; revisit the boundaries that caused errors.
  • 5–6 correct: review group, time, and preprocessing leakage before proceeding.
  • 0–4 correct: repeat the chapter examples and practical lab with guided support.

Chapter summary

  • Training performance measures fit to familiar data; generalization requires unseen data.
  • Training, validation, and test sets have different roles and must not be treated interchangeably.
  • train_test_split is appropriate for simple random or stratified row-level holdouts.
  • Stratification protects class proportions but does not protect groups or chronology.
  • Group-based splitting keeps all observations from the same entity on one side of the boundary.
  • Time-based splitting trains on the past and evaluates on the future, revealing drift and preventing future-to-past leakage.
  • Leakage can enter through preprocessing, features, targets, time, groups, and duplicates.
  • A split must be audited with counts, overlaps, date ranges, and feature-availability checks.
  • The most realistic evaluation can produce a lower score, but that score is more useful for decision-making.
ONE SENTENCE TO REMEMBER  Do not ask which splitter is best in general; ask which splitter reproduces the way the model will encounter new data.

 

Key vocabulary

Table 9.15. Essential terminology

Term

Meaning

Generalization

Performance on relevant observations not used to fit the model

Holdout

A subset protected from some or all of the training process

Stratification

Approximate preservation of class-label proportions

Group

A dependency unit whose observations must remain together

Temporal leakage

Use of information from after the prediction time

Concept drift

Change over time in the relationship between features and target

Pipeline

A fitted sequence of preprocessing and modeling steps

Test contamination

Use of test information during development decisions

 

Further reading

What’s next?

The next stage is to fit preprocessing transformations without crossing the boundaries established here. Numerical scaling, imputation, and categorical encoding should be learned from training data and applied consistently to validation, test, and future observations. A reusable scikit-learn pipeline will make that discipline much easier to maintain.