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.
@@ -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()}