Group-aware cross-validation: never split a group across train and test.
Reach for these splitters whenever rows are not independent because several of them come from the same source -- multiple readings per patient, several sessions per user, many sentences from one document, repeated measurements of one subject. With an ordinary KFold, sibling rows land on both sides of the split and the model can score well simply by recognising the source, which silently inflates the estimate. Grouped splitters keep every group whole inside a single fold, so the test fold always measures generalisation to unseen groups.
Two variants are provided:
GroupKFold -- assigns groups to folds round-robin; ignores y. StratifiedGroupKFold -- also tries to balance the class distribution across folds; requires y.
Both require the groups argument to split() and need at least n_splits distinct groups.
Classes
K-fold over whole groups: a group never spans train and test.
Folding happens at the level of the distinct values in groups, not at the level of rows. Each unique group is assigned to exactly one fold, so when that fold is the test set every row of the group is held out together, and when it is not, every row is in training. As a result no group ever appears on both sides of a split -- the property that makes this splitter the right choice for clustered data.
Groups are assigned round-robin in sorted order (i % n_splits over np.unique(groups)), so folds hold a similar number of groups but not necessarily a similar number of rows: unequal group sizes give unequal fold sizes. Labels are ignored; use StratifiedGroupKFold if class balance matters too. groups is required and there must be at least n_splits distinct groups.
__init__( self, n_splits: int = 5, )
Parameters
n_splits
Notes
6 samples in 3 groups, n_splits=3. Rows of group 1 always move together:
sample: 0 1 2 3 4 5
group: 1 1 2 2 3 3
fold 0: T T . . . . test group {1}
fold 1: . . T T . . test group {2}
fold 2: . . . . T T test group {3}
T = test, . = trainSee Also
>>> import numpy as np
>>> from tuiml.evaluation.splitting import GroupKFold
>>> X = np.arange(12).reshape(6, 2)
>>> y = np.array([0, 0, 0, 1, 1, 1])
>>> groups = np.array([1, 1, 2, 2, 3, 3])
>>> cv = GroupKFold(n_splits=3)
>>> print(cv.get_n_splits())
3
>>> for train_idx, test_idx in cv.split(X, y, groups):
... print(test_idx.tolist(),
... sorted(set(groups[test_idx].tolist())),
... sorted(set(groups[train_idx].tolist())))
[0, 1] [1] [2, 3]
[2, 3] [2] [1, 3]
[4, 5] [3] [1, 2]
The test and train group sets are always disjoint.
Methods
split
(self, X: np.ndarray, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]
split
(self, X: np.ndarray, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]
Yield the n_splits train/test index pairs of a grouped K-fold.
Parameters
X
y
groups
Yields
train_index
test_index
Raises
ValueError
groups is None, has a different length than X, or contains fewer distinct values than n_splits.
get_n_splits
(self, X: Optional[np.ndarray]=None, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> int
get_n_splits
(self, X: Optional[np.ndarray]=None, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> int
Return the number of folds, i.e. how many pairs split will yield.
Parameters
X
y
groups
Returns
n_splits
n_splits.
Grouped K-fold that also tries to balance classes across folds.
Combines the two guarantees of GroupKFold and StratifiedKFold, but only the first is exact. A group is still never split across train and test -- that is a hard constraint. Class balance is best effort: groups are sorted by their majority class and then assigned greedily, each group going to whichever fold currently holds the fewest samples of that group's majority class.
Because whole groups are indivisible, exact stratification is generally impossible, and the greedy pass optimises class counts rather than fold sizes. Expect folds that differ noticeably in the number of groups and rows when group sizes or class mixes vary. Both y and groups are required, and there must be at least n_splits distinct groups.
__init__( self, n_splits: int = 5, shuffle: bool = False, random_state: Optional[int] = None, )
Parameters
n_splits
shuffle
random_state
shuffle=True.
Notes
Fold assignment is driven by each group's majority class, so a group whose labels are mixed contributes its minority rows wherever its majority class sends it. Verify the realised balance rather than assuming it:
for train_idx, test_idx in cv.split(X, y, groups):
print(np.bincount(y[test_idx]))See Also
>>> import numpy as np
>>> from tuiml.evaluation.splitting import StratifiedGroupKFold
>>> X = np.arange(24).reshape(12, 2)
>>> y = np.array([0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1])
>>> groups = np.array([1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6])
>>> cv = StratifiedGroupKFold(n_splits=3)
>>> print(cv.get_n_splits())
3
>>> for train_idx, test_idx in cv.split(X, y, groups):
... print(sorted(set(groups[test_idx].tolist())),
... np.bincount(y[test_idx], minlength=2).tolist())
[1, 3, 6] [2, 4]
[2, 4] [2, 2]
[5] [0, 2]
Groups stay intact, but the folds are visibly uneven -- the best-effort
nature of the stratification in action.
Methods
split
(self, X: np.ndarray, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]
split
(self, X: np.ndarray, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]
Yield the n_splits grouped, class-balanced train/test index pairs.
Parameters
X
y
groups
Yields
train_index
test_index
Raises
ValueError
y or groups is None, if groups has a different length than X, or if there are fewer distinct groups than n_splits.
get_n_splits
(self, X: Optional[np.ndarray]=None, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> int
get_n_splits
(self, X: Optional[np.ndarray]=None, y: Optional[np.ndarray]=None, groups: Optional[np.ndarray]=None) -> int
Return the number of folds, i.e. how many pairs split will yield.
Parameters
X
y
groups
Returns
n_splits
n_splits.