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 | VALIDATION | TEST |
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 parameters | Final performance claim |
Validation | Model selection, tuning, threshold choice | Fitting final parameters as if unseen |
Test | One final evaluation of the frozen process | Iterative 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/10 | Balances learning capacity and stable evaluation |
Small dataset | Hold out test; cross-validate training | Avoids wasting a large fixed validation portion |
| Rare positive class | Choose counts, not only percentages | Each subset needs enough positives for meaningful metrics |
Grouped dataset | Allocate groups, not rows | Row percentages can hide small numbers of independent entities |
| Time-ordered dataset | Use meaningful time windows | Business 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 samples | Specify explicitly; interpret in absolute counts |
train_size | Fraction or number of training samples | Usually omit when test_size is given |
random_state | Seed controlling the random split | Use a documented integer for reproducibility |
shuffle | Whether samples are shuffled before splitting | Keep True for suitable i.i.d. data; False for chronology |
stratify | Labels used to preserve proportions | For 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.15 / 0.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 groups | One or repeated group holdouts |
GroupKFold | A group never appears in both sides of a fold | Systematic group-aware cross-validation |
| StratifiedGroupKFold | Attempts class balance while separating groups | Imbalanced grouped classification |
LeaveOneGroupOut | One entire group is held out each time | Small 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 window | Final deployment simulation |
Expanding window | Training grows; each fold tests the next period | Learning from all prior history |
Rolling window | Fixed-length training window moves forward | Old data becomes less relevant |
Gap / embargo | Remove observations near the boundary | Delayed 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 dataset | Fit transforms only on training data; use a pipeline |
Feature | A field created after the prediction decision | Document feature availability at prediction time |
Target | Outcome-derived status or near-copy of the label | Trace how every feature is generated |
Temporal | Future transactions included in historical totals | Use point-in-time-correct features and chronological splits |
Group | Same patient appears in training and testing | Split by the relevant entity identifier |
Duplicates | Copied row or near-identical image on both sides | Deduplicate 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(0, 0.8) observed_months = rng.choice(months, size=rng.integers(4, 13), replace=False) for month in sorted(observed_months): tenure = rng.integers(1, 72) 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
- Run all four splitting strategies and save the comparison table.
- Explain why random and stratified splits can have many overlapping customers.
- Explain why the grouped split can have a different row percentage from 20%.
- Measure the number of positive test cases for every strategy.
- For the time split, report the training and test date ranges.
- Choose the split for predicting future months for existing customers.
- Choose the split for predicting a newly acquired portfolio of unseen customers.
- 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.
- Is the prediction about a future period? If yes, begin with a chronological design.
- Do several rows belong to the same entity, site, device, session, or source? If yes, protect the relevant group boundary.
- Is the target categorical and imbalanced? If yes, preserve or at least audit class proportions within the valid boundary.
- Are observations approximately independent and identically distributed? If yes, a shuffled random or stratified split may be appropriate.
- 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 rows | Stratified random split | Class 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 time | Combined group/time design | Both entity and temporal boundaries |
Very small dataset | Final holdout + suitable cross-validation | Stability 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 preprocessing | Test statistics influence training representation | Split first or fit a pipeline only on training |
| Tune repeatedly on test | The test set becomes part of model selection | Use validation/CV; evaluate test once |
| Group by row, not entity | Related records cross the boundary | Pass a meaningful group identifier |
| Shuffle future data | The model learns from later periods | Sort by time and use forward evaluation |
| Trust percentages alone | Rare cases or groups may be too few | Inspect absolute counts |
| Choose the seed with best score | Seed selection optimizes on evaluation noise | Fix 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
- scikit-learn: train_test_split
- scikit-learn: GroupShuffleSplit
- scikit-learn: TimeSeriesSplit
- scikit-learn user guide: cross-validation and splitters
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.