In scikit-learn, which CV splitter keeps all of one patient's rows in a single fold?
answer
- rows from one entity are not independent
- score too good, deployment disappointing
- groups travels through fit, not the constructor
- stratified plus grouped variant exists
- one fold per site when sites are few
basics
~20 sGroupKFold, given a groups array of patient IDs, guarantees no group's rows appear in both the training and test side of a split. The groups array is passed at fit time — GroupKFold(n_splits=5).split(X, y, groups) or search.fit(X, y, groups=ids).
solid answer
~50 sUse a group-aware splitter and supply the group labels: `GroupKFold(n_splits=5)` with `cv.split(X, y, groups)`, or with `cross_val_score(est, X, y, groups=ids, cv=GroupKFold(5))`, or `search.fit(X, y, groups=ids)` for `GridSearchCV`. The contract is that a given group is entirely on one side of each split, so a model cannot be scored on a patient it has already learned. Without it, `KFold` and `StratifiedKFold` split row by row, and any per-entity signal — a scanner's noise profile, a user's writing style, a device's calibration — is learned in training and recognized in test. Nothing errors; the score is simply too high, sometimes dramatically. Related splitters cover the variants: `GroupShuffleSplit` for random group-level draws with a chosen test fraction, `StratifiedGroupKFold` when you need disjoint groups *and* balanced classes, and `LeaveOneGroupOut` when the groups are few and meaningful, such as five hospitals.
code
python · 14 linesimport numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GroupKFold, cross_val_score
rng = np.random.RandomState(0)
patients = np.repeat(np.arange(20), 10) # 20 patients, 10 rows each
X = rng.randn(200, 5) + patients[:, None] * 0.1
y = (patients % 2).astype(int)
cv = GroupKFold(n_splits=5)
print(cross_val_score(LogisticRegression(), X, y, groups=patients, cv=cv))
for tr, te in cv.split(X, y, groups=patients):
assert not set(patients[tr]) & set(patients[te])go deeper
Know that repeated rows from the same person or device must not straddle a split, and that GroupKFold with a groups array is the tool for it.
Explain that groups is supplied at split/fit time rather than in the constructor, and name the variants — GroupShuffleSplit, StratifiedGroupKFold, LeaveOneGroupOut — and what each adds.
Diagnose an over-optimistic score as entity leakage, choose the correct grouping unit from the deployment scenario, and defend reporting the lower grouped number.
Own the definition of the evaluation unit across the organization: what counts as an unseen entity for this product, how that is encoded in the dataset contract, and how it is enforced for every hold-out, not just cross-validation.
## The failure being prevented When several rows describe the same underlying entity — 12 slices from one patient's scan, 40 sessions from one user, a dozen photos of one product — those rows are not independent. A row-level split puts some of an entity's rows in training and the rest in test. The model can then identify the entity rather than the phenomenon and score well for the wrong reason. The symptom is a cross-validation number that is excellent and a deployed model that is mediocre, because in production every entity is new. This is one of the most common ways a promising model dies, and it produces no warning at all — the code runs perfectly. ## Group-aware splitters All of them take a `groups` array of length `n_samples`, holding one label per row, and guarantee that no label spans the train/test boundary of a split. - **`GroupKFold(n_splits=5)`** — the K-fold analogue. It partitions the *groups* into folds and tries to balance the number of samples per fold, since groups vary in size. Every group appears in exactly one test fold. - **`GroupShuffleSplit(n_splits=..., test_size=...)`** — repeated random draws at the group level; use it when you want many independent train/test draws rather than a partition, or a specific test fraction. - **`StratifiedGroupKFold(n_splits=5)`** — keeps groups disjoint *and* tries to preserve class proportions across folds. This is what you want when the entity structure and class imbalance both matter, which in medical data is nearly always. The balance is approximate; with strongly class-correlated groups, perfect stratification is not achievable while keeping groups whole. - **`LeaveOneGroupOut()`** — one fold per group. Natural when groups are few and substantively interesting: train on four hospitals, test on the fifth, and read per-hospital generalization directly. - **`LeavePGroupsOut(n_groups=p)`** — the combinatorial generalization; the number of splits explodes quickly. ## How groups reaches the splitter This is the mechanical detail interviews probe, because it is not where people expect. `groups` is not a constructor argument. It travels through the *fit* path: ``` cv = GroupKFold(n_splits=5) for tr, te in cv.split(X, y, groups=patient_ids): ... cross_val_score(model, X, y, groups=patient_ids, cv=cv) search = GridSearchCV(model, grid, cv=cv) search.fit(X, y, groups=patient_ids) ``` Omitting it raises rather than silently degrading — a group splitter refuses to run without group labels — which is the one part of this failure mode the library does protect you from. ## Choosing what the group is The hard part is domain work, not API work. The group is whatever unit will be entirely unseen at prediction time: - Medical imaging → patient, never image or slice. - Recommenders → user, when the model must serve new users; if it only ever serves known users, a temporal split may be the honest one instead. - Documents chunked for retrieval → source document, not chunk. - Sensor fleets → device or installation, since each has its own calibration. - Augmented data → the original sample, so an image and its rotated copy cannot straddle the split. Get the unit wrong and the splitter enforces the wrong invariant with total confidence. ## Consequences to expect A correct group split usually makes the score *drop*, sometimes a lot. That is the point: the earlier number was measuring memorization. Expect higher fold-to-fold variance too, since groups differ from each other more than random rows do — and that variance is itself the useful signal about how the model behaves on an unseen entity. Group sizes are rarely equal, so folds carry different row counts and, with few large groups, different class balances. `StratifiedGroupKFold` mitigates the latter. If a handful of groups dominate the dataset, consider whether per-group weighting or capping rows per group belongs in the training procedure. ## Interaction with the rest of the workflow Group structure has to be respected at *every* holdout boundary, not just in cross-validation: the final test set must also be group-disjoint, or the leak simply moves. And any preprocessing that aggregates across rows must be fitted inside the fold, since a statistic computed over all rows carries information about the held-out groups.
- You switch to GroupKFold and cross-validated AUC drops from 0.94 to 0.78. What do you report?The 0.78. The earlier figure measured the model's ability to recognize entities it had already trained on, which is not a capability it will have in production where every entity is new. Report the grouped number as the estimate, explain the leak that produced the old one, and use the gap as evidence of how much per-entity signal the features carry.
- How do you keep classes balanced across folds while still keeping groups intact?Use `StratifiedGroupKFold`, which assigns whole groups to folds while trying to preserve class proportions. The balance is approximate by necessity — when a group is entirely one class, keeping it whole constrains how evenly the class can spread. Check the realized per-fold class rates rather than assuming they match.
- When is LeaveOneGroupOut the right choice over GroupKFold?When the groups are few, large and substantively meaningful — five hospitals, four factories, three annotators. One fold per group answers the question you actually care about, which is "how does this transfer to a site it has never seen", and the per-fold scores name the sites directly. With hundreds of small groups it is just an expensive GroupKFold.
- Does a group-aware split remove the need for a time-aware one?No — they enforce different invariants and a dataset can need both. Groups stop entity memorization; ordering stops training on the future. When both apply, the usual resolution is to split by time at the top level and verify group disjointness within it, or to build the index pairs yourself and pass them as an iterable to cv.
saying these in an interview costs you the question
- Passes groups to the splitter's constructor instead of to fit/split
- Thinks stratify=y in train_test_split keeps entities together
- Treats the higher ungrouped score as the real one
- Uses the row ID as the group, which changes nothing
- Groups the cross-validation but leaves the final test set ungrouped