Ten rows into three folds. Three does not divide ten, and the leftover has to land somewhere.
rows, k = list(range(10)), 3
10 // 3 # -> 3 the base size
10 % 3 # -> 1 one fold has to take an extra row
# Give the extra rows to the earliest folds:
# fold 0: 4 rows fold 1: 3 rows fold 2: 3 rows
#
# Walking a moving start is what keeps them disjoint:
start = 0
for fold in range(k):
size = 10 // 3 + (1 if fold < 10 % 3 else 0)
test = rows[start:start + size]
train = rows[:start] + rows[start + size:] # everything else
start += size
- The remainder is distributed, not dumped
With 10 rows and 3 folds you could give the last fold 4 and the others 3, which is simpler and makes one fold noticeably different from the rest. Spreading the remainder one row at a time across the earliest folds keeps every size within one of every other, so no fold’s score is measured on a visibly different amount of data. `fold < remainder` is the whole rule.
- The training half is the two slices either side
rows[:start] + rows[start + size:] is everything except the test slice — which guarantees the two halves are disjoint and together cover everything, by construction rather than by checking. It is the same complementary-slice reasoning as split-at, with a hole cut out of the middle instead of a single cut.
- No shuffling here, deliberately
train-test-split shuffles because it takes one sample; this function partitions everything, so shuffling would only decide which rows end up together. That is still a real decision — on data ordered by date, unshuffled folds are time-ordered blocks — and it belongs to the caller, who knows whether the order means anything. sklearn’s KFold takes shuffle as a parameter for exactly this reason, and defaults it off.
Work out the base size and the remainder first. Then walk a start position through the rows, taking each fold’s test slice and moving the start on by however many rows that fold actually took.
size = len(rows) // k plus one more for the first len(rows) % k folds — `1 if fold < remainder else 0`. The training half is rows[:start] + rows[start + size:], everything either side of the test slice.
The start must advance by that fold’s own size, not by the base size — otherwise the folds that took an extra row overlap the next one. Validate k against both ends before any of this: below 2 there is nothing to validate against, and above len(rows) some fold gets nothing.
def folds(rows, k): if not 2 <= k <= len(rows): raise ____(f’k must be between 2 and {len(rows)}, got {k}') base, remainder = divmod(len(rows), ____) pairs = [] start = 0 for fold in range(k): size = base + (1 if fold < ____ else 0) test = rows[start:start + size] train = rows[:____] + rows[start + size:] pairs.append((train, test)) start += ____ return pairs
def folds(rows, k):
if not 2 <= k <= len(rows):
raise ValueError(
f"k must be between 2 and {len(rows)}, got {k}"
)
base, remainder = divmod(len(rows), k)
pairs = []
start = 0
for fold in range(k):
# The earliest folds take the leftover rows, one each, so no two
# fold sizes differ by more than one.
size = base + (1 if fold < remainder else 0)
test = rows[start:start + size]
# Everything either side of the hole: disjoint from test, and
# together with it the whole dataset, by construction.
train = rows[:start] + rows[start + size:]
pairs.append((train, test))
start += size
return pairs
The base size and the remainder come from one call, which is the clearest signal that they are two halves of the same question — how many each, and how many are left over. Writing len(rows) // k and len(rows) % k separately is identical and invites the two to be computed from different expressions after somebody edits one of them.
This is the bug the function is really about. With ten rows and three folds the first fold takes four, so the second must start at 4 — stepping by the base size of 3 starts it at 3, and rows[3] ends up in two test sets. Every row tested exactly once is a property of advancing by what was actually taken.
k of 1 gives a fold whose training set is empty — nothing to learn from. k above the row count gives folds with no test rows, and a score computed from no data is not zero, it is meaningless, and it quietly moves the average of the k scores. sklearn raises on the first and warns on the second; refusing both is simpler to reason about.
sklearn.model_selection.KFold does this and yields indices rather than rows, which is what you want when there are several aligned arrays to slice. Use StratifiedKFold whenever the target is imbalanced — plain k-fold can hand a fold no positive examples at all, and then its recall is undefined and the average of the folds is wrong. Use GroupKFold when rows share an identity, for the reason the next exercise is about. And never use any of them on a time series: every fold but the last trains on data from after its test set, which is the most flattering possible way to evaluate a model and tells you nothing. TimeSeriesSplit exists for that.
- Yield index ranges instead of rows, and see how much easier it becomes to split features and labels in step.
- Make it stratified: keep each class’s proportion the same in every fold, and notice that the remainder now has to be distributed per class.
- Score a model across the folds and report the mean and the deviation of the k scores — the deviation is the number that tells you whether a single split would have lied to you.