eval-toolkit 0.27.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eval_toolkit/splits.py ADDED
@@ -0,0 +1,520 @@
1
+ """Data splitting: pluggable :class:`Splitter` Protocol + reference impls.
2
+
3
+ A :class:`Splitter` takes a single :class:`~eval_toolkit.harness.EvalSlice`
4
+ and yields fold-dicts ready for :func:`eval_toolkit.harness.evaluate`. K=1
5
+ splitters (:class:`HoldoutSplitter`) yield one item; K=5 splitters yield five
6
+ fold-dicts each shaped like ``{"train": EvalSlice, "test": EvalSlice}``.
7
+
8
+ Reference impls wrap sklearn splitters except for
9
+ :class:`SourceDisjointKFoldSplitter`, which generalizes the source-disjoint
10
+ pattern that ``prompt-injection-sdd`` hand-rolls today.
11
+
12
+ Composes naturally with :class:`~eval_toolkit.loaders.DatasetLoader`:
13
+ loaders return a single ``{"all": slice}`` dict for un-split data; pipe
14
+ ``splits["all"]`` into the Splitter for cross-validated evaluation. Splits
15
+ returned by the loader (e.g. HF ``DatasetDict``-style ``{"train", "test"}``)
16
+ can be fed into the harness directly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterator, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ from sklearn.model_selection import (
28
+ GroupKFold,
29
+ StratifiedKFold,
30
+ TimeSeriesSplit,
31
+ train_test_split,
32
+ )
33
+
34
+ from eval_toolkit.harness import EvalSlice
35
+
36
+ __all__ = [
37
+ "GroupKFoldSplitter",
38
+ "HoldoutSplitter",
39
+ "PoolBuilder",
40
+ "SourceDisjointKFoldSplitter",
41
+ "Splitter",
42
+ "StratifiedKFoldSplitter",
43
+ "TimeSeriesSplitter",
44
+ "iter_folds_with_pool",
45
+ ]
46
+
47
+
48
+ @runtime_checkable
49
+ class Splitter(Protocol):
50
+ """Iterates folds, each as a named-splits dict ready for ``evaluate(...)``.
51
+
52
+ :meth:`iter_folds` yields ``Iterator[dict[str, EvalSlice]]``. K=1 yields a
53
+ single holdout-shaped item; K=5 yields five fold dicts. The caller composes
54
+ with a seed loop for multi-seed × CV.
55
+
56
+ All reference impls preserve the parent slice's feature_col / label_col /
57
+ strata_col so each fold's :class:`EvalSlice` is interchangeable with the
58
+ parent — pass straight to :func:`eval_toolkit.harness.evaluate` without
59
+ further wiring.
60
+ """
61
+
62
+ def iter_folds( # pragma: no cover
63
+ self,
64
+ slice_: EvalSlice,
65
+ *,
66
+ groups: np.ndarray | None = None,
67
+ ) -> Iterator[dict[str, EvalSlice]]:
68
+ """Yield one fold-dict per iteration."""
69
+ ...
70
+
71
+ def get_n_splits(self, slice_: EvalSlice) -> int: # pragma: no cover
72
+ """Return the number of folds this splitter will produce on ``slice_``."""
73
+ ...
74
+
75
+
76
+ def _slice_subset(parent: EvalSlice, mask_or_idx: np.ndarray, name: str) -> EvalSlice:
77
+ """Build a child :class:`EvalSlice` carrying the parent's column metadata."""
78
+ sub_df = parent.df.iloc[mask_or_idx].reset_index(drop=True)
79
+ return EvalSlice(
80
+ name=name,
81
+ df=sub_df,
82
+ description=f"{parent.description} [{name}]" if parent.description else name,
83
+ feature_col=parent.feature_col,
84
+ label_col=parent.label_col,
85
+ strata_col=parent.strata_col,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class HoldoutSplitter:
91
+ """Single-iteration (k=1) holdout split via sklearn ``train_test_split``.
92
+
93
+ Unifies holdout into the same iterator shape as K-fold so callers can treat
94
+ holdout and CV identically (one ``for fold in splitter.iter_folds(slice_)``
95
+ loop covers both).
96
+
97
+ Parameters
98
+ ----------
99
+ test_size : float, optional
100
+ Fraction in (0, 1). Default 0.2.
101
+ stratify : bool, optional
102
+ If True, stratify on labels. Default True (binary class imbalance is
103
+ the dominant case for this toolkit).
104
+ seed : int, optional
105
+ RNG seed. Default 42.
106
+ """
107
+
108
+ test_size: float = 0.2
109
+ stratify: bool = True
110
+ seed: int = 42
111
+
112
+ def __post_init__(self) -> None:
113
+ """Validate the holdout fraction."""
114
+ if not 0.0 < self.test_size < 1.0:
115
+ raise ValueError(f"test_size must be in (0, 1), got {self.test_size}")
116
+
117
+ def iter_folds(
118
+ self,
119
+ slice_: EvalSlice,
120
+ *,
121
+ groups: np.ndarray | None = None,
122
+ ) -> Iterator[dict[str, EvalSlice]]:
123
+ """Yield exactly one ``{"train", "test"}`` fold dict."""
124
+ n = len(slice_.df)
125
+ idx = np.arange(n)
126
+ stratify_arr = slice_.y_true if self.stratify else None
127
+ train_idx, test_idx = train_test_split(
128
+ idx,
129
+ test_size=self.test_size,
130
+ stratify=stratify_arr,
131
+ random_state=self.seed,
132
+ )
133
+ yield {
134
+ "train": _slice_subset(slice_, train_idx, "train"),
135
+ "test": _slice_subset(slice_, test_idx, "test"),
136
+ }
137
+
138
+ def get_n_splits(self, slice_: EvalSlice) -> int:
139
+ """Always 1 — holdout is k=1."""
140
+ return 1
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class StratifiedKFoldSplitter:
145
+ """K-fold cross-validation with class-stratification.
146
+
147
+ Wraps :class:`sklearn.model_selection.StratifiedKFold`. Default for
148
+ binary class imbalance — keeps positive/negative ratios stable across
149
+ folds.
150
+
151
+ Parameters
152
+ ----------
153
+ k : int, optional
154
+ Number of folds. Default 5.
155
+ shuffle : bool, optional
156
+ Shuffle indices before splitting. Default True.
157
+ seed : int, optional
158
+ RNG seed. Default 42 (only used when ``shuffle=True``).
159
+ """
160
+
161
+ k: int = 5
162
+ shuffle: bool = True
163
+ seed: int = 42
164
+
165
+ def __post_init__(self) -> None:
166
+ """Validate k."""
167
+ if self.k < 2:
168
+ raise ValueError(f"k must be >= 2, got {self.k}")
169
+
170
+ def iter_folds(
171
+ self,
172
+ slice_: EvalSlice,
173
+ *,
174
+ groups: np.ndarray | None = None,
175
+ ) -> Iterator[dict[str, EvalSlice]]:
176
+ """Yield k fold dicts, each ``{"train", "test"}``."""
177
+ skf = StratifiedKFold(
178
+ n_splits=self.k,
179
+ shuffle=self.shuffle,
180
+ random_state=self.seed if self.shuffle else None,
181
+ )
182
+ y = slice_.y_true
183
+ x_dummy = np.arange(len(y)).reshape(-1, 1)
184
+ for train_idx, test_idx in skf.split(x_dummy, y):
185
+ yield {
186
+ "train": _slice_subset(slice_, train_idx, "train"),
187
+ "test": _slice_subset(slice_, test_idx, "test"),
188
+ }
189
+
190
+ def get_n_splits(self, slice_: EvalSlice) -> int:
191
+ """Return ``self.k``."""
192
+ return self.k
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class GroupKFoldSplitter:
197
+ """K-fold CV with group-disjoint test partitions.
198
+
199
+ Wraps :class:`sklearn.model_selection.GroupKFold`. Required when rows
200
+ cluster by user / patient / source / document and within-cluster
201
+ correlation would inflate eval metrics if any group spans train↔test.
202
+
203
+ Parameters
204
+ ----------
205
+ k : int, optional
206
+ Number of folds. Default 5.
207
+ group_col : str, optional
208
+ Column name in the parent slice's dataframe carrying group ids.
209
+ ``None`` means callers must pass ``groups`` explicitly to ``iter_folds``.
210
+ Default ``None``.
211
+ """
212
+
213
+ k: int = 5
214
+ group_col: str | None = None
215
+
216
+ def __post_init__(self) -> None:
217
+ """Validate k."""
218
+ if self.k < 2:
219
+ raise ValueError(f"k must be >= 2, got {self.k}")
220
+
221
+ def _resolve_groups(self, slice_: EvalSlice, groups: np.ndarray | None) -> np.ndarray:
222
+ """Pick the groups array from explicit param or column, with diagnostics."""
223
+ if groups is not None:
224
+ return np.asarray(groups)
225
+ if self.group_col is None:
226
+ raise ValueError(
227
+ "GroupKFoldSplitter needs either `group_col` set or `groups=...` "
228
+ "passed to iter_folds; neither was provided."
229
+ )
230
+ if self.group_col not in slice_.df.columns:
231
+ raise KeyError(
232
+ f"group_col {self.group_col!r} not in slice columns {list(slice_.df.columns)}"
233
+ )
234
+ arr: np.ndarray = slice_.df[self.group_col].to_numpy()
235
+ return arr
236
+
237
+ def iter_folds(
238
+ self,
239
+ slice_: EvalSlice,
240
+ *,
241
+ groups: np.ndarray | None = None,
242
+ ) -> Iterator[dict[str, EvalSlice]]:
243
+ """Yield k fold dicts with group-disjoint test partitions."""
244
+ gkf = GroupKFold(n_splits=self.k)
245
+ g = self._resolve_groups(slice_, groups)
246
+ x_dummy = np.arange(len(g)).reshape(-1, 1)
247
+ for train_idx, test_idx in gkf.split(x_dummy, slice_.y_true, groups=g):
248
+ yield {
249
+ "train": _slice_subset(slice_, train_idx, "train"),
250
+ "test": _slice_subset(slice_, test_idx, "test"),
251
+ }
252
+
253
+ def get_n_splits(self, slice_: EvalSlice) -> int:
254
+ """Return ``self.k``."""
255
+ return self.k
256
+
257
+
258
+ @dataclass(frozen=True, slots=True)
259
+ class SourceDisjointKFoldSplitter:
260
+ """K-fold CV partitioning sources into disjoint groups (round-robin).
261
+
262
+ Generalizes the source-disjoint split pattern from ``prompt-injection-sdd``:
263
+ given a ``source_col`` of categorical values, sort distinct sources by a
264
+ deterministic key and round-robin assign to k folds. Fold ``i``'s test set
265
+ = rows whose source is in bucket ``i``.
266
+
267
+ Stronger than :class:`GroupKFoldSplitter` for the OOD-claim case: the test
268
+ fold's sources never appear anywhere in the model's training set across
269
+ the whole CV procedure (whereas GroupKFold only enforces train↔test
270
+ disjointness within each fold).
271
+
272
+ Parameters
273
+ ----------
274
+ k : int, optional
275
+ Number of folds. Default 3 (matches prompt-injection-sdd convention).
276
+ source_col : str
277
+ Column in the parent slice's dataframe carrying source labels.
278
+ seed : int, optional
279
+ RNG seed for source-order shuffling. Default 42.
280
+ """
281
+
282
+ source_col: str
283
+ k: int = 3
284
+ seed: int = 42
285
+
286
+ def __post_init__(self) -> None:
287
+ """Validate k."""
288
+ if self.k < 2:
289
+ raise ValueError(f"k must be >= 2, got {self.k}")
290
+
291
+ def iter_folds(
292
+ self,
293
+ slice_: EvalSlice,
294
+ *,
295
+ groups: np.ndarray | None = None,
296
+ ) -> Iterator[dict[str, EvalSlice]]:
297
+ """Yield k fold dicts; each fold's test sources are disjoint from train sources.
298
+
299
+ Raises
300
+ ------
301
+ KeyError
302
+ If ``self.source_col`` is not a column in ``slice_.df``.
303
+ """
304
+ if self.source_col not in slice_.df.columns:
305
+ raise KeyError(
306
+ f"source_col {self.source_col!r} not in slice columns " f"{list(slice_.df.columns)}"
307
+ )
308
+ sources = slice_.df[self.source_col].to_numpy()
309
+ unique_sources = np.array(sorted(set(sources.tolist())))
310
+ rng = np.random.default_rng(self.seed)
311
+ rng.shuffle(unique_sources)
312
+ # Round-robin: bucket i = sources at positions [i, i+k, i+2k, ...].
313
+ for fold_idx in range(self.k):
314
+ test_sources = set(unique_sources[fold_idx :: self.k].tolist())
315
+ test_mask = np.array([s in test_sources for s in sources])
316
+ train_idx = np.where(~test_mask)[0]
317
+ test_idx = np.where(test_mask)[0]
318
+ yield {
319
+ "train": _slice_subset(slice_, train_idx, "train"),
320
+ "test": _slice_subset(slice_, test_idx, "test"),
321
+ }
322
+
323
+ def get_n_splits(self, slice_: EvalSlice) -> int:
324
+ """Return ``self.k`` (capped at the number of distinct sources)."""
325
+ if self.source_col not in slice_.df.columns:
326
+ return self.k # caller will hit the KeyError on iter_folds
327
+ n_sources = int(slice_.df[self.source_col].nunique())
328
+ return min(self.k, n_sources)
329
+
330
+
331
+ @dataclass(frozen=True, slots=True)
332
+ class TimeSeriesSplitter:
333
+ """Time-aware K-fold via :class:`sklearn.model_selection.TimeSeriesSplit`.
334
+
335
+ Each fold's train set is everything ≤ a moving boundary; the test set is
336
+ the next chunk after the boundary. Required for honest time-series eval —
337
+ use with :class:`~eval_toolkit.leakage.TemporalLeakageCheck` to verify the
338
+ invariant.
339
+
340
+ Parameters
341
+ ----------
342
+ k : int, optional
343
+ Number of folds. Default 5.
344
+ time_col : str, optional
345
+ Column name carrying a sortable timestamp. If set, the parent slice
346
+ is sorted by this column before splitting. ``None`` assumes the
347
+ slice is already in temporal order.
348
+ """
349
+
350
+ k: int = 5
351
+ time_col: str | None = None
352
+
353
+ def __post_init__(self) -> None:
354
+ """Validate k."""
355
+ if self.k < 2:
356
+ raise ValueError(f"k must be >= 2, got {self.k}")
357
+
358
+ def iter_folds(
359
+ self,
360
+ slice_: EvalSlice,
361
+ *,
362
+ groups: np.ndarray | None = None,
363
+ ) -> Iterator[dict[str, EvalSlice]]:
364
+ """Yield k fold dicts respecting the temporal ordering.
365
+
366
+ Raises
367
+ ------
368
+ KeyError
369
+ If ``self.time_col`` is set but not present in ``slice_.df``.
370
+ """
371
+ if self.time_col is not None:
372
+ if self.time_col not in slice_.df.columns:
373
+ raise KeyError(
374
+ f"time_col {self.time_col!r} not in slice columns " f"{list(slice_.df.columns)}"
375
+ )
376
+ sorted_df = slice_.df.sort_values(self.time_col).reset_index(drop=True)
377
+ sorted_slice = EvalSlice(
378
+ name=slice_.name,
379
+ df=sorted_df,
380
+ description=slice_.description,
381
+ feature_col=slice_.feature_col,
382
+ label_col=slice_.label_col,
383
+ strata_col=slice_.strata_col,
384
+ )
385
+ else:
386
+ sorted_slice = slice_
387
+ tss = TimeSeriesSplit(n_splits=self.k)
388
+ x_dummy = np.arange(len(sorted_slice.df)).reshape(-1, 1)
389
+ for train_idx, test_idx in tss.split(x_dummy):
390
+ yield {
391
+ "train": _slice_subset(sorted_slice, train_idx, "train"),
392
+ "test": _slice_subset(sorted_slice, test_idx, "test"),
393
+ }
394
+
395
+ def get_n_splits(self, slice_: EvalSlice) -> int:
396
+ """Return ``self.k``."""
397
+ return self.k
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # PoolBuilder Protocol + iter_folds_with_pool composition (v0.19.0)
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ @runtime_checkable
406
+ class PoolBuilder(Protocol):
407
+ """Augment a fold's train slice with an external pool and split off val.
408
+
409
+ Composes with :class:`Splitter`: the Splitter produces a fold's
410
+ ``{"train", "test"}``; the PoolBuilder enriches ``train`` (typically
411
+ by injecting an external negative pool or a domain-specific corpus)
412
+ and carves a ``val`` slice off the augmented training set.
413
+
414
+ Implementations carry their pool state in instance attributes so the
415
+ composition helper :func:`iter_folds_with_pool` can be configured
416
+ once outside the fold loop.
417
+ """
418
+
419
+ def build( # pragma: no cover
420
+ self,
421
+ train: EvalSlice,
422
+ *,
423
+ fold_idx: int,
424
+ ) -> dict[str, EvalSlice]:
425
+ """Return at least ``{"train": ..., "val": ...}`` for this fold.
426
+
427
+ The original ``test`` slice from the Splitter is reattached by
428
+ :func:`iter_folds_with_pool`. Implementations may return additional
429
+ keys (e.g. ``"calibration"``) which the helper forwards verbatim.
430
+ """
431
+ ...
432
+
433
+
434
+ def iter_folds_with_pool(
435
+ splitter: Splitter,
436
+ slice_: EvalSlice,
437
+ *,
438
+ pool_builder: PoolBuilder,
439
+ groups: np.ndarray | None = None,
440
+ ) -> Iterator[dict[str, EvalSlice]]:
441
+ """Compose a :class:`Splitter` with a :class:`PoolBuilder`.
442
+
443
+ Each yielded fold-dict contains the PoolBuilder's ``train``/``val``
444
+ plus the Splitter's original ``test``. Additional keys returned by
445
+ the PoolBuilder are forwarded unchanged.
446
+
447
+ Parameters
448
+ ----------
449
+ splitter : Splitter
450
+ Yields per-fold ``{"train", "test"}`` (or richer) dicts.
451
+ slice_ : EvalSlice
452
+ The parent slice whose rows the splitter partitions.
453
+ pool_builder : PoolBuilder
454
+ Carved-train/val builder; sees one fold's ``train`` at a time.
455
+ groups : np.ndarray or None, optional
456
+ Forwarded to ``splitter.iter_folds`` (e.g. for
457
+ :class:`GroupKFoldSplitter`).
458
+
459
+ Yields
460
+ ------
461
+ dict[str, EvalSlice]
462
+ Per-fold dict with at minimum ``train``, ``val``, ``test``.
463
+
464
+ Raises
465
+ ------
466
+ ValueError
467
+ If the ``pool_builder.build(...)`` return dict does not contain
468
+ both ``"train"`` and ``"val"`` keys.
469
+
470
+ Examples
471
+ --------
472
+ >>> import pandas as pd
473
+ >>> from eval_toolkit.harness import EvalSlice
474
+ >>> from eval_toolkit.splits import (
475
+ ... StratifiedKFoldSplitter,
476
+ ... PoolBuilder,
477
+ ... iter_folds_with_pool,
478
+ ... )
479
+ >>> df = pd.DataFrame({
480
+ ... "text": [f"t{i}" for i in range(20)],
481
+ ... "label": [i % 2 for i in range(20)],
482
+ ... })
483
+ >>> parent = EvalSlice(name="all", df=df)
484
+ >>> class TrivialPool:
485
+ ... def build(self, train, *, fold_idx):
486
+ ... # Identity pool builder: train passes through; val carved 50/50.
487
+ ... n = len(train.df)
488
+ ... half = n // 2
489
+ ... val_df = train.df.iloc[:half].copy()
490
+ ... tr_df = train.df.iloc[half:].copy()
491
+ ... return {
492
+ ... "train": EvalSlice(name=f"fold{fold_idx}_train", df=tr_df),
493
+ ... "val": EvalSlice(name=f"fold{fold_idx}_val", df=val_df),
494
+ ... }
495
+ >>> folds = list(iter_folds_with_pool(
496
+ ... StratifiedKFoldSplitter(k=2, seed=0),
497
+ ... parent,
498
+ ... pool_builder=TrivialPool(),
499
+ ... ))
500
+ >>> len(folds)
501
+ 2
502
+ >>> sorted(folds[0].keys())
503
+ ['test', 'train', 'val']
504
+ """
505
+ for fold_idx, fold in enumerate(splitter.iter_folds(slice_, groups=groups)):
506
+ train = fold["train"]
507
+ test = fold["test"]
508
+ built = pool_builder.build(train, fold_idx=fold_idx)
509
+ if "train" not in built or "val" not in built:
510
+ raise ValueError(
511
+ "PoolBuilder.build must return at least {'train', 'val'}; "
512
+ f"got keys {sorted(built.keys())}"
513
+ )
514
+ # PoolBuilder's keys (train, val, possibly more) take precedence;
515
+ # test is reattached from the Splitter.
516
+ yield {**built, "test": test}
517
+
518
+
519
+ # Suppress unused-import warnings: pd / Sequence are referenced indirectly.
520
+ _ = (pd, Sequence)