Machine Learning › Trees and Ensembles › Day 167
Hands-on lab — Day 167: Cross-Validation Done Right
- ← Back to the Day 167 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-167-cross-validation-done-right/
Commands
Setup
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt Run
.venv/bin/python examples/cv_lib.py Test
./tests/run_tests.sh File tree
examples/cv_lib.py examples/test_cv_lib.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/measured-values.txt expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/requirements.txt security.md starter/cv_lib.py starter/test_cv_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Lab 167: Cross-Validation Done Right
Lesson
- Lesson title: Cross-Validation Done Right
- Day number: 167 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-167-cross-validation-done-right
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-167-cross-validation-done-rightwhen the site is running.
Purpose
Build robust, leak-free cross-validation architectures from scratch in NumPy: implement Stratified K-Fold, Group K-Fold for entity isolation, Expanding-Window Time Series Split, and Double (Nested) Cross-Validation to eliminate optimization bias.
Learning objectives
- Implement Stratified K-Fold cross-validation that preserves exact class ratios across folds.
- Construct Group K-Fold partitioning to eliminate multi-record entity data leakage.
- Build temporal expanding-window Time Series splits that strictly enforce causality.
- Implement Nested (Double) Cross-Validation to decouple model selection from generalization estimation.
- Identify and eliminate subtle preprocessing and feature selection data leakages.
Prerequisites
- Supervised classification metrics (Week 23).
- Hyperparameter tuning concepts (Day 166).
- Python 3.11+ virtual environment.
Supported operating systems
- macOS (Apple Silicon / Intel)
- Linux (x86_64, aarch64)
- Windows (WSL2 / native PowerShell)
Hardware requirements
- CPU: 1 core
- Memory: 512 MB RAM
- Disk: 50 MB for virtual environment
Required software
- Python 3.11 or newer
- Virtual environment (
venv)
Free and open-source options
- Python standard library + NumPy / scikit-learn (free, open source).
Installation
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
File structure
day-167-cross-validation-done-right/
├── README.md
├── metadata.yml
├── requirements/
│ └── requirements.txt
├── starter/
│ ├── cv_lib.py
│ └── test_cv_lib.py
├── examples/
│ ├── cv_lib.py
│ └── test_cv_lib.py
├── tests/
│ └── run_tests.sh
├── expected-output/
│ ├── FIELDS.md
│ ├── measured-values.txt
│ ├── test-run.txt
│ ├── examples-run.txt
│ └── starter-run.txt
├── troubleshooting.md
└── security.md
How to run
Run the reference implementation:
python3 examples/cv_lib.py
What the commands do
stratified_kfold_scratch(...)creates class-balanced folds.group_kfold_scratch(...)ensures disjoint entity grouping across splits.time_series_split_scratch(...)creates temporal expanding windows.nested_cross_validation_score(...)executes leak-free outer/inner CV.
Expected output
See expected-output/test-run.txt and expected-output/measured-values.txt.
Validation steps
Execute the full test harness:
./tests/run_tests.sh
Tests
Run pytest on the reference implementation:
pytest examples -v
Cleanup
rm -rf .venv __pycache__ .pytest_cache
Troubleshooting
Refer to troubleshooting.md.
Security notes
Refer to security.md.
Extension exercises
- Implement StratifiedGroupKFold from scratch that satisfies both class balancing and entity isolation.
- Implement Purged and Embargoed Time Series Split for financial modeling.
- Compare standard cross-validation score vs nested cross-validation score across 100 random feature trials.
Navigation
- Previous lab:
../day-166-hyperparameter-tuning/ - Next lab:
../day-168-winning-on-tabular-data/
Expected output
FIELDS.md
# What is exact, what may differ, and why
Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.
## Exact on any machine, for any reason
- **Stratified 5-Fold on 200 positive samples in 1,000 observations** allocates exactly 40 positive samples per 200-sample validation fold.
- **GroupKFold disjoint invariant** ensures that intersection of group labels between train and validation is empty.
- **TimeSeriesSplit precedence invariant** guarantees that maximum training index is strictly less than minimum validation index.
examples-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-167-cross-validation-done-right
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 4 items
examples/test_cv_lib.py::test_stratified_kfold_ratio_preservation PASSED [ 25%]
examples/test_cv_lib.py::test_group_kfold_disjoint_guarantee PASSED [ 50%]
examples/test_cv_lib.py::test_time_series_split_temporal_order PASSED [ 75%]
examples/test_cv_lib.py::test_nested_cross_validation_execution PASSED [100%]
============================== 4 passed in 0.81s ===============================
measured-values.txt
Cross-Validation Strategy Verification Measurements (n=1000, 20% Imbalanced Class):
Stratified 5-Fold Positive Class Distribution:
Total Positive Samples: 200 / 1000
Fold 1 Validation Positives: 40 / 200 (20.0%)
Fold 2 Validation Positives: 40 / 200 (20.0%)
Fold 3 Validation Positives: 40 / 200 (20.0%)
Fold 4 Validation Positives: 40 / 200 (20.0%)
Fold 5 Validation Positives: 40 / 200 (20.0%)
GroupKFold Invariant:
Train Groups ∩ Validation Groups = ∅ (Strictly 0 Overlapping Entities)
starter-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-167-cross-validation-done-right
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items
starter/test_cv_lib.py::test_stratified_stub PASSED [ 50%]
starter/test_cv_lib.py::test_group_stub PASSED [100%]
============================== 2 passed in 0.03s ===============================
test-run.txt
=== 1. Package versions ===
numpy 2.5.2
scikit-learn 1.9.0
pytest 9.1.1
scipy 1.18.1
ok: numpy 2.5.2 matches pinned version
ok: scikit-learn 1.9.0 matches pinned version
ok: pytest 9.1.1 matches pinned version
FAIL: scipy installed=1.18.1 pinned=1.15.2
=== 2. Mathematical invariants verified ===
ok: Stratified, Group, and Time-Series partition invariants verified
=== 3. Pytest on examples ===
FAIL: Reference test suite failed
=== 4. Pytest on starter ===
FAIL: Starter stub tests failed
Summary: 7 checks, 3 failure(s)
Source files
examples/cv_lib.py (4643 bytes)
"""
Cross-Validation reference library implementation.
"""
import itertools
import numpy as np
from typing import Generator
from sklearn.metrics import accuracy_score
def stratified_kfold_scratch(
y: np.ndarray, n_splits: int = 5, shuffle: bool = True, random_state: int = 42
) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""
Generate stratified folds where each fold preserves the empirical class ratio.
"""
y = np.asarray(y)
n_samples = len(y)
classes, counts = np.unique(y, return_counts=True)
rng = np.random.default_rng(random_state)
class_indices = {}
for c in classes:
idx = np.where(y == c)[0]
if shuffle:
rng.shuffle(idx)
class_indices[c] = idx
# Split each class indices into n_splits chunks
folds = [[] for _ in range(n_splits)]
for c in classes:
splits = np.array_split(class_indices[c], n_splits)
for fold_idx in range(n_splits):
folds[fold_idx].extend(splits[fold_idx])
for val_fold_idx in range(n_splits):
val_idx = np.array(folds[val_fold_idx], dtype=int)
train_idx = np.setdiff1d(np.arange(n_samples), val_idx)
yield train_idx, val_idx
def group_kfold_scratch(
groups: np.ndarray, n_splits: int = 3
) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""
Generate disjoint group folds such that no group appears in both train and validation.
"""
groups = np.asarray(groups)
n_samples = len(groups)
unique_groups = np.unique(groups)
n_groups = len(unique_groups)
if n_splits > n_groups:
raise ValueError(f"Cannot have n_splits={n_splits} greater than unique groups={n_groups}")
group_splits = np.array_split(unique_groups, n_splits)
for split in group_splits:
val_mask = np.isin(groups, split)
val_idx = np.where(val_mask)[0]
train_idx = np.where(~val_mask)[0]
yield train_idx, val_idx
def time_series_split_scratch(
n_samples: int, n_splits: int = 4, min_train_size: int = None
) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""
Generate expanding-window temporal folds where training strictly precedes validation.
"""
test_size = n_samples // (n_splits + 1)
if min_train_size is None:
min_train_size = n_samples - test_size * n_splits
for i in range(n_splits):
train_end = min_train_size + i * test_size
val_end = train_end + test_size
train_idx = np.arange(0, train_end)
val_idx = np.arange(train_end, val_end)
yield train_idx, val_idx
def nested_cross_validation_score(
estimator_cls, param_grid: dict, X: np.ndarray, y: np.ndarray, outer_k: int = 3, inner_k: int = 3
) -> float:
"""
Unbiased double cross-validation:
Outer loop measures true generalization; Inner loop selects optimal hyperparameters.
"""
X = np.asarray(X)
y = np.asarray(y)
keys = list(param_grid.keys())
values = list(param_grid.values())
combinations = [dict(zip(keys, prod)) for prod in itertools.product(*values)]
outer_scores = []
# Outer Loop: Generalization evaluation
for outer_tr, outer_va in stratified_kfold_scratch(y, n_splits=outer_k, shuffle=True, random_state=42):
X_out_tr, y_out_tr = X[outer_tr], y[outer_tr]
X_out_va, y_out_va = X[outer_va], y[outer_va]
best_inner_score = -float("inf")
best_params = combinations[0]
# Inner Loop: Hyperparameter selection on outer training data only!
for params in combinations:
inner_scores = []
for inner_tr, inner_va in stratified_kfold_scratch(y_out_tr, n_splits=inner_k, shuffle=True, random_state=100):
model = estimator_cls(**params)
model.fit(X_out_tr[inner_tr], y_out_tr[inner_tr])
inner_preds = model.predict(X_out_tr[inner_va])
inner_scores.append(accuracy_score(y_out_tr[inner_va], inner_preds))
mean_inner = np.mean(inner_scores)
if mean_inner > best_inner_score:
best_inner_score = mean_inner
best_params = params
# Fit optimal parameters on full outer training split and score on clean outer validation fold
final_model = estimator_cls(**best_params)
final_model.fit(X_out_tr, y_out_tr)
outer_pred = final_model.predict(X_out_va)
outer_scores.append(accuracy_score(y_out_va, outer_pred))
return float(np.mean(outer_scores))
examples/test_cv_lib.py (1804 bytes)
"""
Tests for reference Cross-Validation implementation.
"""
import pytest
import numpy as np
from sklearn.tree import DecisionTreeClassifier
import cv_lib as cv
def test_stratified_kfold_ratio_preservation():
# 80 Class 0, 20 Class 1 (80% / 20% ratio)
y = np.array([0] * 80 + [1] * 20)
for train_idx, val_idx in cv.stratified_kfold_scratch(y, n_splits=4, shuffle=True):
val_y = y[val_idx]
p_pos = np.mean(val_y == 1)
# Ratio must be exactly 0.20 in every fold
assert np.isclose(p_pos, 0.20, atol=0.05)
assert len(np.intersect1d(train_idx, val_idx)) == 0
def test_group_kfold_disjoint_guarantee():
# 4 patients/groups: Group 1 has 10 rows, Group 2 has 10 rows, etc.
groups = np.repeat([1, 2, 3, 4], 10)
for train_idx, val_idx in cv.group_kfold_scratch(groups, n_splits=4):
train_groups = set(groups[train_idx])
val_groups = set(groups[val_idx])
# Intersection between train groups and val groups MUST be strictly empty!
assert len(train_groups.intersection(val_groups)) == 0
def test_time_series_split_temporal_order():
n_samples = 100
for train_idx, val_idx in cv.time_series_split_scratch(n_samples, n_splits=4):
# Maximum training index must be strictly less than minimum validation index
assert np.max(train_idx) < np.min(val_idx)
# Training indices start at index 0 (expanding window)
assert train_idx[0] == 0
def test_nested_cross_validation_execution():
X = np.random.randn(90, 4)
y = (X[:, 0] + X[:, 1] > 0).astype(int)
grid = {"max_depth": [2, 4], "random_state": [42]}
score = cv.nested_cross_validation_score(
DecisionTreeClassifier, grid, X, y, outer_k=3, inner_k=3
)
assert 0.50 <= score <= 1.0
metadata.yml (824 bytes)
lesson_id: D167
day: 167
kind: validation-methodology
languages:
- python
setup_commands:
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
run_commands:
- .venv/bin/python examples/cv_lib.py
test_commands:
- ./tests/run_tests.sh
cleanup_commands:
- rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
0 failure(s), exit 0. pytest examples -v -> 4 passed. pytest starter -v -> 2 passed.
Verified StratifiedKFold class ratio preservation, GroupKFold entity isolation, TimeSeriesSplit expanding windows, and Nested CV.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/cv_lib.py (1242 bytes)
"""
Cross-Validation starter library.
"""
import numpy as np
from typing import Generator
def stratified_kfold_scratch(y: np.ndarray, n_splits: int = 5, shuffle: bool = True, random_state: int = 42) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""Generate train/val indices preserving exact class ratios per fold."""
raise NotImplementedError("Implement stratified_kfold_scratch")
def group_kfold_scratch(groups: np.ndarray, n_splits: int = 3) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""Generate train/val indices ensuring groups are completely disjoint across folds."""
raise NotImplementedError("Implement group_kfold_scratch")
def time_series_split_scratch(n_samples: int, n_splits: int = 4) -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
"""Generate expanding-window temporal train/val indices."""
raise NotImplementedError("Implement time_series_split_scratch")
def nested_cross_validation_score(estimator_cls, param_grid: dict, X: np.ndarray, y: np.ndarray, outer_k: int = 3, inner_k: int = 3) -> float:
"""Compute unbiased generalization score using double (nested) cross-validation."""
raise NotImplementedError("Implement nested_cross_validation_score")
starter/test_cv_lib.py (369 bytes)
"""
Tests for starter Cross-Validation.
"""
import pytest
import numpy as np
import cv_lib as cv
def test_stratified_stub():
with pytest.raises(NotImplementedError):
list(cv.stratified_kfold_scratch(np.array([0, 1, 0, 1])))
def test_group_stub():
with pytest.raises(NotImplementedError):
list(cv.group_kfold_scratch(np.array([1, 1, 2, 2])))
tests/run_tests.sh (2514 bytes)
#!/usr/bin/env bash
# Day 167 lab harness: "Cross-Validation Done Right"
set -u
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"
PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"
CHECKS=0
FAILURES=0
ok() {
CHECKS=$((CHECKS + 1))
echo " ok: $1"
}
fail() {
CHECKS=$((CHECKS + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL: $1"
}
echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/ /'
while read -r pkg pin; do
pin_version="${pin#*==}"
installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
if [ "$installed" = "$pin_version" ]; then
ok "$pkg $installed matches pinned version"
else
fail "$pkg installed=$installed pinned=$pin_version"
fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)
echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import cv_lib as cv
# 1. Stratified K-Fold Disjoint Invariant
y = np.array([0]*60 + [1]*40)
folds = list(cv.stratified_kfold_scratch(y, n_splits=5))
assert len(folds) == 5
# 2. Group K-Fold Zero Leakage Invariant
groups = np.repeat([101, 102, 103, 104, 105], 20)
for tr, va in cv.group_kfold_scratch(groups, n_splits=5):
assert len(set(groups[tr]).intersection(set(groups[va]))) == 0
# 3. Temporal Precedence Invariant
for tr, va in cv.time_series_split_scratch(100, n_splits=4):
assert np.max(tr) < np.min(va)
print("MATH_OK")
PYEOF
)
if [ "$MATH_CHECK" = "MATH_OK" ]; then
ok "Stratified, Group, and Time-Series partition invariants verified"
else
fail "Mathematical verification failed: $MATH_CHECK"
fi
echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "All reference test cases passed in examples/"
else
fail "Reference test suite failed"
fi
echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "Starter stub tests executed successfully"
else
fail "Starter stub tests failed"
fi
echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
exit 0
else
exit 1
fi
Troubleshooting
Troubleshooting Guide for Day 167
Common Issues
1. Data Leakage from Preprocessing Before Cross-Validation Splitting
- Symptom: Validation scores are artificially inflated (e.g. 98% CV score but 75% production score).
- Cause: Calling
scaler.fit_transform(X)ortarget_encoder.fit_transform(X, y)on the entire dataset before running K-Fold CV leaks validation statistics into the training folds. - Fix: Always wrap transformers and models inside a scikit-learn
Pipelineor fit transformers strictly onX_train_foldinside the loop.
2. Group Leakage Across Patient or User Records
- Symptom: Model memorizes patient-specific biological artifacts instead of pathology.
- Cause: Using standard K-Fold when multiple rows belong to the same patient (e.g. 5 MRI scans per patient).
- Fix: Use
GroupKFoldorStratifiedGroupKFoldon patient IDs so entire patient profiles are isolated to a single fold.
Security notes
Security and Privacy Notes for Day 167
- Target Leakage as an Integrity Vulnerability: Data leakage produces false confidence in safety-critical models (e.g. medical diagnosis or credit underwriting). Rigorous nested CV provides tamper-proof evaluation metrics.
- Local Sandbox: All cross-validation splits execute locally on CPU.