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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cross-slice operating-point transfer for binary classification.
|
|
2
|
+
|
|
3
|
+
The core workflow is:
|
|
4
|
+
|
|
5
|
+
1. Fit one or more threshold selectors on a mixed-class slice.
|
|
6
|
+
2. Apply the fitted thresholds to any target slice.
|
|
7
|
+
|
|
8
|
+
Target slices may be mixed-class, all-positive, or all-negative. Mixed-class
|
|
9
|
+
targets report the usual threshold metrics. Single-class targets report the
|
|
10
|
+
quantity that remains meaningful at an externally selected threshold:
|
|
11
|
+
recall for all-positive slices, FPR/specificity for all-negative slices.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from eval_toolkit.metrics import (
|
|
22
|
+
metrics_at_threshold,
|
|
23
|
+
single_class_threshold_metrics,
|
|
24
|
+
)
|
|
25
|
+
from eval_toolkit.thresholds import ThresholdSelector
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FittedOperatingPoint",
|
|
29
|
+
"OperatingPointSpec",
|
|
30
|
+
"apply_operating_points",
|
|
31
|
+
"fit_operating_points",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class FittedOperatingPoint:
|
|
37
|
+
"""A threshold selected on one scorer/slice pair.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
criterion : str
|
|
42
|
+
Selector label, usually ``selector.criterion``.
|
|
43
|
+
threshold : float
|
|
44
|
+
Decision boundary selected on the fit slice.
|
|
45
|
+
fitted_on_slice : str
|
|
46
|
+
Name of the slice used to fit the threshold.
|
|
47
|
+
scorer_name : str
|
|
48
|
+
Scorer whose scores were used to fit the threshold.
|
|
49
|
+
n_fit, n_positive_fit, n_negative_fit : int
|
|
50
|
+
Fit-slice counts recorded for provenance.
|
|
51
|
+
fit_metrics : dict
|
|
52
|
+
``metrics_at_threshold`` on the fit slice at the selected threshold.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
criterion: str
|
|
56
|
+
threshold: float
|
|
57
|
+
fitted_on_slice: str
|
|
58
|
+
scorer_name: str
|
|
59
|
+
n_fit: int
|
|
60
|
+
n_positive_fit: int
|
|
61
|
+
n_negative_fit: int
|
|
62
|
+
fit_metrics: dict[str, float | int]
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, object]:
|
|
65
|
+
"""JSON-serializable representation."""
|
|
66
|
+
return asdict(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class OperatingPointSpec:
|
|
71
|
+
"""Harness instruction for cross-slice operating-point transfer.
|
|
72
|
+
|
|
73
|
+
``evaluate(..., operating_point_specs=[...])`` fits each selector on
|
|
74
|
+
``fit_slice`` and applies the resulting thresholds to every named target
|
|
75
|
+
in ``apply_slices``. If ``scorer_names`` is empty, all evaluated scorers
|
|
76
|
+
participate.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
fit_slice: str
|
|
80
|
+
apply_slices: tuple[str, ...]
|
|
81
|
+
selectors: tuple[ThresholdSelector, ...]
|
|
82
|
+
name: str = "transferred"
|
|
83
|
+
scorer_names: tuple[str, ...] = ()
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Validate the spec while preserving tuple fields for immutability."""
|
|
87
|
+
object.__setattr__(self, "apply_slices", tuple(self.apply_slices))
|
|
88
|
+
object.__setattr__(self, "selectors", tuple(self.selectors))
|
|
89
|
+
object.__setattr__(self, "scorer_names", tuple(self.scorer_names))
|
|
90
|
+
if not self.fit_slice:
|
|
91
|
+
raise ValueError("fit_slice must be non-empty")
|
|
92
|
+
if not self.apply_slices:
|
|
93
|
+
raise ValueError("apply_slices must be non-empty")
|
|
94
|
+
if not self.selectors:
|
|
95
|
+
raise ValueError("selectors must be non-empty")
|
|
96
|
+
if not self.name:
|
|
97
|
+
raise ValueError("name must be non-empty")
|
|
98
|
+
if any(not s for s in self.apply_slices):
|
|
99
|
+
raise ValueError("apply_slices must not contain empty names")
|
|
100
|
+
if any(not s for s in self.scorer_names):
|
|
101
|
+
raise ValueError("scorer_names must not contain empty names")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fit_operating_points(
|
|
105
|
+
y_true: np.ndarray,
|
|
106
|
+
y_score: np.ndarray,
|
|
107
|
+
selectors: Sequence[ThresholdSelector],
|
|
108
|
+
*,
|
|
109
|
+
fitted_on_slice: str = "",
|
|
110
|
+
scorer_name: str = "",
|
|
111
|
+
) -> dict[str, FittedOperatingPoint]:
|
|
112
|
+
"""Fit selectors on a mixed-class slice and record threshold provenance.
|
|
113
|
+
|
|
114
|
+
Raises from the underlying selector if the data cannot support threshold
|
|
115
|
+
selection, for example single-class labels or no feasible target
|
|
116
|
+
precision/recall/FPR threshold.
|
|
117
|
+
|
|
118
|
+
Raises
|
|
119
|
+
------
|
|
120
|
+
ValueError
|
|
121
|
+
If the fit slice is single-class (the function requires both
|
|
122
|
+
positive and negative labels for threshold fitting). Selector
|
|
123
|
+
raises (e.g. :exc:`RuntimeError` on infeasible target rates) are
|
|
124
|
+
propagated unchanged.
|
|
125
|
+
"""
|
|
126
|
+
y_true_arr = np.asarray(y_true)
|
|
127
|
+
y_score_arr = np.asarray(y_score)
|
|
128
|
+
labels = set(np.unique(y_true_arr).tolist())
|
|
129
|
+
if len(labels) != 2:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"fit_operating_points requires a mixed-class fit slice; " f"got labels {sorted(labels)}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
out: dict[str, FittedOperatingPoint] = {}
|
|
135
|
+
n_positive = int(np.sum(y_true_arr))
|
|
136
|
+
n_fit = int(len(y_true_arr))
|
|
137
|
+
for selector in selectors:
|
|
138
|
+
selected = selector.select(y_true_arr, y_score_arr)
|
|
139
|
+
fit_metrics = metrics_at_threshold(y_true_arr, y_score_arr, selected.threshold)
|
|
140
|
+
out[selected.criterion] = FittedOperatingPoint(
|
|
141
|
+
criterion=selected.criterion,
|
|
142
|
+
threshold=float(selected.threshold),
|
|
143
|
+
fitted_on_slice=fitted_on_slice,
|
|
144
|
+
scorer_name=scorer_name,
|
|
145
|
+
n_fit=n_fit,
|
|
146
|
+
n_positive_fit=n_positive,
|
|
147
|
+
n_negative_fit=n_fit - n_positive,
|
|
148
|
+
fit_metrics=dict(fit_metrics),
|
|
149
|
+
)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_operating_points(
|
|
154
|
+
y_true: np.ndarray,
|
|
155
|
+
y_score: np.ndarray,
|
|
156
|
+
fitted: Mapping[str, FittedOperatingPoint],
|
|
157
|
+
*,
|
|
158
|
+
applied_to_slice: str = "",
|
|
159
|
+
scorer_name: str = "",
|
|
160
|
+
) -> dict[str, dict[str, object]]:
|
|
161
|
+
"""Apply fitted thresholds to a mixed-class or single-class target slice.
|
|
162
|
+
|
|
163
|
+
Raises
|
|
164
|
+
------
|
|
165
|
+
ValueError
|
|
166
|
+
If ``y_true`` contains labels outside ``{0, 1}``.
|
|
167
|
+
"""
|
|
168
|
+
y_true_arr = np.asarray(y_true)
|
|
169
|
+
y_score_arr = np.asarray(y_score)
|
|
170
|
+
labels = set(np.unique(y_true_arr).tolist())
|
|
171
|
+
if not labels <= {0, 1}:
|
|
172
|
+
raise ValueError(f"labels must be binary in {{0, 1}}, got {sorted(labels)}")
|
|
173
|
+
|
|
174
|
+
out: dict[str, dict[str, object]] = {}
|
|
175
|
+
for criterion, op in fitted.items():
|
|
176
|
+
metrics: dict[str, object]
|
|
177
|
+
if len(labels) == 1:
|
|
178
|
+
metrics = dict(single_class_threshold_metrics(y_true_arr, y_score_arr, op.threshold))
|
|
179
|
+
else:
|
|
180
|
+
metrics = dict(metrics_at_threshold(y_true_arr, y_score_arr, op.threshold))
|
|
181
|
+
metrics["threshold_provenance"] = {
|
|
182
|
+
"criterion": op.criterion,
|
|
183
|
+
"fitted_on_slice": op.fitted_on_slice,
|
|
184
|
+
"applied_to_slice": applied_to_slice,
|
|
185
|
+
"scorer_name": scorer_name or op.scorer_name,
|
|
186
|
+
"threshold": op.threshold,
|
|
187
|
+
"n_fit": op.n_fit,
|
|
188
|
+
"n_positive_fit": op.n_positive_fit,
|
|
189
|
+
"n_negative_fit": op.n_negative_fit,
|
|
190
|
+
}
|
|
191
|
+
out[str(criterion)] = metrics
|
|
192
|
+
return out
|
eval_toolkit/paths.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Path normalization helpers for repo-relative path display in config dicts.
|
|
2
|
+
|
|
3
|
+
Pure helpers — no filesystem touch beyond ``Path.resolve()`` for relative-path
|
|
4
|
+
checks. For SHA-256 file hashes and run-dir creation, see :mod:`eval_toolkit.provenance`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"path_for_config",
|
|
15
|
+
"resolve_repo_path",
|
|
16
|
+
"split_provenance_config",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_repo_path(path: Path | str, repo_root: Path | str | None = None) -> Path:
|
|
21
|
+
"""Resolve a possibly repo-relative path without requiring it to exist.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
path : pathlib.Path or str
|
|
26
|
+
repo_root : pathlib.Path or str or None, optional
|
|
27
|
+
Repository root. If ``None``, ``Path.cwd()`` is used.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
pathlib.Path
|
|
32
|
+
Absolute path. If ``path`` is already absolute, returns ``path.resolve()``.
|
|
33
|
+
Otherwise, returns ``(repo_root / path).resolve()``.
|
|
34
|
+
"""
|
|
35
|
+
p = Path(path)
|
|
36
|
+
if p.is_absolute():
|
|
37
|
+
return p.resolve()
|
|
38
|
+
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
|
39
|
+
return (root / p).resolve()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def path_for_config(
|
|
43
|
+
path: Path | str | None,
|
|
44
|
+
repo_root: Path | str | None = None,
|
|
45
|
+
) -> str | None:
|
|
46
|
+
"""Return a stable repo-relative path string when possible, otherwise absolute.
|
|
47
|
+
|
|
48
|
+
Useful for serializing paths into config JSON: keeps repo-internal paths
|
|
49
|
+
portable across machines while still exposing absolute paths for
|
|
50
|
+
repo-external locations.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
path : pathlib.Path or str or None
|
|
55
|
+
``None`` returns ``None`` (so this is null-safe over optional config fields).
|
|
56
|
+
repo_root : pathlib.Path or str or None, optional
|
|
57
|
+
Repository root. If ``None``, ``Path.cwd()`` is used.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
str or None
|
|
62
|
+
"""
|
|
63
|
+
if path is None:
|
|
64
|
+
return None
|
|
65
|
+
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
|
66
|
+
resolved = resolve_repo_path(path, root)
|
|
67
|
+
try:
|
|
68
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
69
|
+
except ValueError:
|
|
70
|
+
return str(resolved)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_provenance_config(
|
|
74
|
+
config: Mapping[str, Any],
|
|
75
|
+
repo_root: Path | str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
path_keys: tuple[str, ...] = ("path", "dir", "file", "splits_dir", "model_path"),
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Walk a config dict and normalize Path-typed values to repo-relative strings.
|
|
80
|
+
|
|
81
|
+
Walks ``config`` recursively. Any value that is a :class:`pathlib.Path` is
|
|
82
|
+
converted via :func:`path_for_config`. Values whose key contains any of
|
|
83
|
+
``path_keys`` (case-insensitive substring match) and is a string are also
|
|
84
|
+
normalized. Non-path values pass through unchanged.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
config : Mapping[str, Any]
|
|
89
|
+
repo_root : pathlib.Path or str or None, optional
|
|
90
|
+
If ``None``, ``Path.cwd()`` is used.
|
|
91
|
+
path_keys : tuple of str, optional
|
|
92
|
+
Substrings (case-insensitive) that mark a string value as a path
|
|
93
|
+
worth normalizing.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
dict[str, Any]
|
|
98
|
+
New dict with normalized path values; original is unmodified.
|
|
99
|
+
|
|
100
|
+
Examples
|
|
101
|
+
--------
|
|
102
|
+
>>> from pathlib import Path
|
|
103
|
+
>>> import tempfile
|
|
104
|
+
>>> with tempfile.TemporaryDirectory() as tmp:
|
|
105
|
+
... cfg = {"model_path": Path(tmp) / "m.pkl", "n_resamples": 1000}
|
|
106
|
+
... out = split_provenance_config(cfg, repo_root=tmp)
|
|
107
|
+
>>> isinstance(out["model_path"], str)
|
|
108
|
+
True
|
|
109
|
+
>>> out["n_resamples"]
|
|
110
|
+
1000
|
|
111
|
+
"""
|
|
112
|
+
root = Path(repo_root) if repo_root is not None else Path.cwd()
|
|
113
|
+
|
|
114
|
+
def _normalize(key: str, value: Any) -> Any:
|
|
115
|
+
if isinstance(value, Mapping):
|
|
116
|
+
return {k: _normalize(k, v) for k, v in value.items()}
|
|
117
|
+
if isinstance(value, list | tuple):
|
|
118
|
+
return type(value)(_normalize(key, v) for v in value)
|
|
119
|
+
if isinstance(value, Path):
|
|
120
|
+
return path_for_config(value, root)
|
|
121
|
+
if isinstance(value, str) and any(pk.lower() in key.lower() for pk in path_keys):
|
|
122
|
+
return path_for_config(value, root)
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
return {k: _normalize(k, v) for k, v in config.items()}
|