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/__init__.py +238 -0
- eval_toolkit/__main__.py +156 -0
- eval_toolkit/_version.py +5 -0
- eval_toolkit/analysis.py +196 -0
- eval_toolkit/artifacts.py +376 -0
- eval_toolkit/bootstrap.py +1344 -0
- eval_toolkit/calibration.py +1143 -0
- eval_toolkit/claims.py +670 -0
- eval_toolkit/config.py +112 -0
- eval_toolkit/docs.py +305 -0
- eval_toolkit/evidence.py +90 -0
- eval_toolkit/harness.py +1193 -0
- eval_toolkit/leakage.py +1052 -0
- eval_toolkit/loaders.py +424 -0
- eval_toolkit/manifest.py +622 -0
- eval_toolkit/metrics.py +1720 -0
- eval_toolkit/operating_points.py +192 -0
- eval_toolkit/paths.py +125 -0
- eval_toolkit/plotting.py +991 -0
- eval_toolkit/protocols.py +98 -0
- eval_toolkit/provenance.py +255 -0
- eval_toolkit/py.typed +0 -0
- eval_toolkit/schemas/manifest.v1.json +155 -0
- eval_toolkit/schemas/manifest.v2.json +186 -0
- eval_toolkit/schemas/manifest.v3.json +186 -0
- eval_toolkit/schemas/results.v1.json +87 -0
- eval_toolkit/schemas/results_full.v1.json +83 -0
- eval_toolkit/seeds.py +119 -0
- eval_toolkit/splits.py +520 -0
- eval_toolkit/text_dedup.py +1403 -0
- eval_toolkit/thresholds.py +819 -0
- eval_toolkit-0.27.1.dist-info/METADATA +314 -0
- eval_toolkit-0.27.1.dist-info/RECORD +36 -0
- eval_toolkit-0.27.1.dist-info/WHEEL +4 -0
- eval_toolkit-0.27.1.dist-info/entry_points.txt +2 -0
- eval_toolkit-0.27.1.dist-info/licenses/LICENSE +21 -0
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)
|