Tutorials¶
Six short tutorials, from the most common mistake to the specialised cases. Every code block on this page runs as written with the current trustcv release and scikit-learn (each is executed on every documentation build), and finishes in a few seconds.
| Tutorial | Level | You will learn |
|---|---|---|
| Understanding data leakage | Beginner | How row-level folds flatter a model when patients repeat |
| Choosing a split | Beginner | Which splitter matches your data |
| Patient-level grouping | Intermediate | What to pass as groups= |
| Temporal cross-validation | Intermediate | Forward-only splits, gaps and purging |
| Nested cross-validation | Advanced | Tuning without touching the test folds |
| Spatial cross-validation | Advanced | Blocks and buffers for geographic data |
Understanding data leakage¶
Leakage is when the test folds tell the model something it would not know in practice. The most common case in medical data is repeated patients: if visits of one patient are on both sides of the split, the model can recognise the patient instead of learning the condition.
The example builds 100 patients with 5 visits each. One feature carries a weak real signal; eight others are a patient "fingerprint" that says nothing about the outcome. Row-level K-fold and patient-grouped K-fold then score the same model.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from trustcv import GroupKFoldMedical, KFoldMedical
rng = np.random.default_rng(0)
n_patients, n_visits = 100, 5
patient_ids = np.repeat(np.arange(n_patients), n_visits)
signal = rng.normal(size=n_patients) # weak real signal
y = np.repeat((signal + rng.normal(0, 1.5, n_patients) > 0).astype(int), n_visits)
fingerprint = rng.normal(size=(n_patients, 8)) # identifies the patient, not the outcome
X = np.column_stack([
signal[patient_ids] + rng.normal(0, 0.5, len(y)),
fingerprint[patient_ids] + rng.normal(0, 0.1, (len(y), 8)),
])
model = RandomForestClassifier(n_estimators=100, random_state=0)
row_level = cross_val_score(
model, X, y, cv=KFoldMedical(n_splits=5, shuffle=True, random_state=0), scoring="roc_auc"
)
grouped = cross_val_score(
model, X, y, cv=GroupKFoldMedical(n_splits=5, random_state=0), groups=patient_ids, scoring="roc_auc"
)
print(f"Row-level K-fold AUC: {row_level.mean():.2f}") # close to 1: the model recognises patients
print(f"Patient-grouped AUC: {grouped.mean():.2f}") # about 0.7: what new patients would see
scoring="roc_auc" matters: cross_val_score reports accuracy by default, not AUC.
To put a gap like this in context: if row-level K-fold reports an AUC of 0.92 and the patient-grouped estimate is 0.73, the row-level figure is too high by 0.19 AUC, about 26% in relative terms. The model you would deploy is the 0.73 one. How large the gap is depends on the data; here the fingerprint features make it extreme.
trustcv catches this case when you give it the patient IDs: TrustCV(...).validate(..., groups=patient_ids) fails the group-leakage check if a patient is on both sides. Without IDs it looks for tightly clustered rows and reports NEEDS_REVIEW. See Reading results.
Choosing a split¶
Start from how the data were collected, not from the model.
| Your data | Use | Why |
|---|---|---|
| Independent rows, one per patient | StratifiedKFoldMedical |
Keeps the class balance in every fold |
| Several rows per patient or site | StratifiedGroupKFold, with groups= |
No patient on both sides |
| Transfer to new sites | LeaveOneGroupOut with groups=site |
Each site is the test set once |
| Time-ordered, predicting the future | TimeSeriesSplit or PurgedGroupTimeSeriesSplit |
Never trains on later rows |
| Geographic or spatial | SpatialBlockCV or BufferedSpatialCV |
Test areas are not surrounded by training neighbours |
recommend_cv applies these rules from what you tell it about the data:
import numpy as np
from trustcv import recommend_cv
rng = np.random.default_rng(1)
patients = np.repeat(np.arange(40), 3)
X = rng.normal(size=(120, 4))
y = np.repeat(rng.integers(0, 2, 40), 3)
plan = recommend_cv(X, y, groups=patients)
print(plan.method) # a grouped method, because groups were given
print(plan.splitter)
When in doubt, prefer the stricter split. An estimate that is a little pessimistic is safer than one that is optimistic. The guide to choosing a split covers the harder cases.
Patient-level grouping¶
Here is a small table with three patients and nine visits:
import pandas as pd
data = pd.DataFrame({
"patient_id": [1, 1, 1, 2, 2, 3, 3, 3, 3],
"visit_date": ["2023-01", "2023-02", "2023-03", "2023-01", "2023-02",
"2023-01", "2023-02", "2023-03", "2023-04"],
"blood_pressure": [120, 125, 130, 110, 115, 140, 145, 150, 155],
"diagnosis": [0, 0, 1, 0, 0, 1, 1, 1, 1],
})
print(data.head())
Which column should you pass as groups=?
Show the answer
patient_id. All rows of a patient must be on the same side of every split. visit_date is a time order, not a group, and diagnosis is the label.
from trustcv import GroupKFoldMedical
cv = GroupKFoldMedical(n_splits=3, random_state=0)
X = data[["blood_pressure"]].to_numpy()
for train, test in cv.split(X, data["diagnosis"], groups=data["patient_id"]):
print("test patients:", sorted(set(data["patient_id"].iloc[test])))
The group is whatever makes rows dependent. It is usually the patient, but it can be the hospital, the scanner, the family or the sequencing batch. With more than one level, HierarchicalGroupKFold(hierarchy_level="hospital") holds out whole hospitals; pass the hierarchy to split(hierarchy=...).
Temporal cross-validation¶
Random K-fold on time-ordered data lets the model train on tomorrow to predict today. Forward-only splits avoid that: every training row comes before every test row.
import numpy as np
from trustcv import PurgedKFoldCV, TimeSeriesSplit
X = np.zeros((24, 1))
for train, test in TimeSeriesSplit(n_splits=3, gap=2).split(X):
print(f"train 0-{train.max()}, test {test.min()}-{test.max()}") # always forward
for train, test in PurgedKFoldCV(n_splits=3, purge_gap=2).split(X):
print(f"test {test.min()}-{test.max()}, rows after test in training: {(train > test.max()).sum()}")
gap and purge_gap count rows, not days. If you have one row per hour, gap=24 leaves one day.
Purged K-fold does not make a split forward-only. It removes purge_gap rows on each side of the test block, which helps when labels overlap in time, but its training set still includes later blocks (the second loop above prints how many rows). For an estimate of how a model will do on future patients, use a forward-only splitter: TimeSeriesSplit, ExpandingWindowCV, RollingWindowCV, or PurgedGroupTimeSeriesSplit when patients repeat. The split diagrams show the difference.
Nested cross-validation¶
If you tune hyperparameters on the same folds you report, the reported score is optimistic: you picked the setting that happened to do best on those test rows. Nested cross-validation tunes inside each outer training set and scores on an outer test fold that tuning never saw.
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from trustcv import GroupKFoldMedical, NestedGroupedCV
rng = np.random.default_rng(2)
groups = np.repeat(np.arange(40), 3)
X = rng.normal(size=(120, 5))
y = (X[:, 0] + rng.normal(0, 1, 120) > 0).astype(int)
param_grid = {"C": [0.1, 1.0, 10.0]}
# Option 1: by hand, keeping patients whole at both levels
outer_cv, inner_cv = GroupKFoldMedical(n_splits=4, random_state=0), GroupKFoldMedical(n_splits=3, random_state=0)
scores = []
for train, test in outer_cv.split(X, y, groups):
inner = list(inner_cv.split(X[train], y[train], groups[train]))
search = GridSearchCV(LogisticRegression(), param_grid, cv=inner)
search.fit(X[train], y[train])
scores.append(search.score(X[test], y[test]))
print(f"outer accuracy: {np.mean(scores):.2f}")
# Option 2: NestedGroupedCV does the same loop
nested = NestedGroupedCV(outer_cv=GroupKFoldMedical(n_splits=4, random_state=0),
inner_cv=GroupKFoldMedical(n_splits=3, random_state=0),
n_jobs=1) # inner grid search in this process
outer_scores, best_params = nested.fit_predict(LogisticRegression(), X, y, groups, param_grid)
print(f"outer accuracy: {np.mean(outer_scores):.2f}", best_params[0])
NestedGroupedCV.fit_predict scores accuracy. Feature selection and resampling are tuning too: keep them inside the inner loop, in a Pipeline.
Spatial cross-validation¶
Nearby locations share exposures and outcomes, so a random split puts near-duplicates of each test point into training. Spatial blocks hold out whole areas; a buffer also drops training points close to the test block.
import numpy as np
from trustcv import BufferedSpatialCV, SpatialBlockCV
rng = np.random.default_rng(3)
coords = rng.uniform(0, 100, size=(200, 2)) # x, y in kilometres
X = rng.normal(size=(200, 3))
for train, test in SpatialBlockCV(n_splits=4, random_state=0).split(X, coordinates=coords):
print("block:", len(test), "test,", len(train), "train")
for train, test in BufferedSpatialCV(n_splits=4, buffer_size=10, random_state=0).split(X, coordinates=coords):
print("buffered:", len(test), "test,", len(train), "train") # fewer training points
Coordinates go to split(coordinates=...). buffer_size is in the units of the coordinates (kilometres here); a value below 1 is read as a fraction of the largest distance.
Test your knowledge¶
For twenty more questions, take the quiz.