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/plotting.py
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
"""Visualization helpers for binary classification evaluation.
|
|
2
|
+
|
|
3
|
+
Six high-level plot helpers (PR curve, reliability diagram, confusion-matrix
|
|
4
|
+
grid, metric bars, score histograms, lift CI) plus style and provenance-aware
|
|
5
|
+
``save_figure``. matplotlib-only.
|
|
6
|
+
|
|
7
|
+
All public functions accept ``np.ndarray`` inputs (callers extract from
|
|
8
|
+
DataFrames via ``.values``). Each helper accepts ``title`` and ``figsize``;
|
|
9
|
+
single-panel helpers also accept ``ax``.
|
|
10
|
+
|
|
11
|
+
The palette uses semantic role names: ``negative``, ``positive``, ``baseline``,
|
|
12
|
+
``accent``. Domain-specific labeling is parameterized via ``class_names``
|
|
13
|
+
(confusion matrix) or ``slice_formatter`` (bar/histogram helpers).
|
|
14
|
+
|
|
15
|
+
``save_figure`` writes provenance to both PNG iTXt chunks and a sidecar JSON
|
|
16
|
+
so saved figures stay file-traceable. Set ``EVAL_TOOLKIT_SKIP_SAVEFIG=1`` in
|
|
17
|
+
the environment to skip writes (useful for dry-run notebook iteration).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from collections.abc import Callable, Container, Iterable, Mapping
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from types import MappingProxyType
|
|
27
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
28
|
+
|
|
29
|
+
import matplotlib.pyplot as plt
|
|
30
|
+
import numpy as np
|
|
31
|
+
from cycler import cycler
|
|
32
|
+
from matplotlib.axes import Axes
|
|
33
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
34
|
+
from matplotlib.figure import Figure
|
|
35
|
+
from matplotlib.patches import Rectangle
|
|
36
|
+
from sklearn.calibration import calibration_curve
|
|
37
|
+
from sklearn.metrics import precision_recall_curve
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from eval_toolkit.bootstrap import BootstrapCI
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_FIGSIZE",
|
|
44
|
+
"PALETTE",
|
|
45
|
+
"PLOT_STYLE",
|
|
46
|
+
"make_palette",
|
|
47
|
+
"plot_bootstrap_distribution",
|
|
48
|
+
"plot_confusion_matrix_grid",
|
|
49
|
+
"plot_lift_ci",
|
|
50
|
+
"plot_metric_bars",
|
|
51
|
+
"plot_pr_curve",
|
|
52
|
+
"plot_reliability_diagram",
|
|
53
|
+
"plot_score_histograms",
|
|
54
|
+
"save_figure",
|
|
55
|
+
"set_plot_style",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Semantic palette roles per Decision (palette role rename from PID's
|
|
59
|
+
# benign/injection to generic negative/positive/baseline/accent).
|
|
60
|
+
# Wrapped in MappingProxyType to prevent inadvertent caller mutation;
|
|
61
|
+
# downstream code can still read all entries normally.
|
|
62
|
+
PALETTE: Mapping[str, str] = MappingProxyType(
|
|
63
|
+
{
|
|
64
|
+
"negative": "#004488", # navy — negative / "good outcome" / TN/TP diagonal
|
|
65
|
+
"positive": "#BB5566", # rose — positive / alert / FP/FN off-diagonal
|
|
66
|
+
"accent": "#DDAA33", # gold — emphasis / threshold marker
|
|
67
|
+
"baseline": "#999999", # gray — reference line / calibration diagonal
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
PLOT_STYLE: dict[str, Any] = {
|
|
72
|
+
"axes.prop_cycle": cycler(
|
|
73
|
+
"color", [PALETTE["negative"], PALETTE["accent"], PALETTE["positive"]]
|
|
74
|
+
),
|
|
75
|
+
"axes.grid": True,
|
|
76
|
+
"axes.axisbelow": True,
|
|
77
|
+
"grid.alpha": 0.3,
|
|
78
|
+
"grid.linestyle": "-",
|
|
79
|
+
"grid.linewidth": 0.5,
|
|
80
|
+
"font.family": "sans-serif",
|
|
81
|
+
"font.sans-serif": ["Roboto", "DejaVu Sans", "sans-serif"],
|
|
82
|
+
"font.size": 11,
|
|
83
|
+
"axes.titlesize": 12,
|
|
84
|
+
"axes.labelsize": 11,
|
|
85
|
+
"xtick.labelsize": 10,
|
|
86
|
+
"ytick.labelsize": 10,
|
|
87
|
+
"legend.fontsize": 10,
|
|
88
|
+
"axes.linewidth": 0.8,
|
|
89
|
+
"axes.edgecolor": "#333333",
|
|
90
|
+
"axes.spines.top": False,
|
|
91
|
+
"axes.spines.right": False,
|
|
92
|
+
"xtick.direction": "out",
|
|
93
|
+
"ytick.direction": "out",
|
|
94
|
+
"xtick.major.size": 4,
|
|
95
|
+
"ytick.major.size": 4,
|
|
96
|
+
"xtick.major.width": 0.8,
|
|
97
|
+
"ytick.major.width": 0.8,
|
|
98
|
+
"legend.frameon": False,
|
|
99
|
+
"legend.handlelength": 1.5,
|
|
100
|
+
"figure.figsize": (6.0, 4.0),
|
|
101
|
+
"figure.dpi": 120,
|
|
102
|
+
"figure.facecolor": "white",
|
|
103
|
+
"savefig.dpi": 300,
|
|
104
|
+
"savefig.bbox": "tight",
|
|
105
|
+
"savefig.pad_inches": 0.05,
|
|
106
|
+
"savefig.facecolor": "white",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
DEFAULT_FIGSIZE: tuple[float, float] = (6.0, 4.0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_palette(
|
|
113
|
+
*,
|
|
114
|
+
negative: str = "#004488",
|
|
115
|
+
positive: str = "#BB5566",
|
|
116
|
+
accent: str = "#DDAA33",
|
|
117
|
+
baseline: str = "#999999",
|
|
118
|
+
**extras: str,
|
|
119
|
+
) -> Mapping[str, str]:
|
|
120
|
+
"""Construct a semantic-role color palette for downstream projects.
|
|
121
|
+
|
|
122
|
+
Returns a frozen mapping (via :class:`types.MappingProxyType`) keyed by
|
|
123
|
+
semantic role names. Standard roles are ``negative`` (good outcome,
|
|
124
|
+
diagonal of confusion matrix), ``positive`` (alert, off-diagonal),
|
|
125
|
+
``accent`` (threshold marker / highlight), and ``baseline`` (reference
|
|
126
|
+
line, calibration diagonal). Pass any number of additional named keyword
|
|
127
|
+
arguments to extend the palette with project-specific roles
|
|
128
|
+
(e.g., ``benign="#004488"``, ``injection="#BB5566"`` for prompt-injection
|
|
129
|
+
framing).
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
negative, positive, accent, baseline : str, optional
|
|
134
|
+
Hex color strings for the four standard semantic roles. Defaults
|
|
135
|
+
match the toolkit's :data:`PALETTE` constant.
|
|
136
|
+
**extras : str
|
|
137
|
+
Project-specific role keys (e.g., ``benign``, ``injection``,
|
|
138
|
+
``emphasis``). All values must be valid hex color strings.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
Mapping[str, str]
|
|
143
|
+
Frozen palette mapping role → hex color. Mutation attempts raise
|
|
144
|
+
``TypeError``.
|
|
145
|
+
|
|
146
|
+
Examples
|
|
147
|
+
--------
|
|
148
|
+
Default palette:
|
|
149
|
+
|
|
150
|
+
>>> p = make_palette()
|
|
151
|
+
>>> p["negative"]
|
|
152
|
+
'#004488'
|
|
153
|
+
|
|
154
|
+
Project-specific extension (PID semantics):
|
|
155
|
+
|
|
156
|
+
>>> p = make_palette(benign="#004488", injection="#BB5566", emphasis="#DDAA33")
|
|
157
|
+
>>> p["benign"]
|
|
158
|
+
'#004488'
|
|
159
|
+
>>> p["injection"]
|
|
160
|
+
'#BB5566'
|
|
161
|
+
>>> p["negative"] # standard roles still present
|
|
162
|
+
'#004488'
|
|
163
|
+
|
|
164
|
+
Mutation prevented:
|
|
165
|
+
|
|
166
|
+
>>> try:
|
|
167
|
+
... p["new_key"] = "#000000"
|
|
168
|
+
... except TypeError:
|
|
169
|
+
... print("frozen")
|
|
170
|
+
frozen
|
|
171
|
+
"""
|
|
172
|
+
base: dict[str, str] = {
|
|
173
|
+
"negative": negative,
|
|
174
|
+
"positive": positive,
|
|
175
|
+
"accent": accent,
|
|
176
|
+
"baseline": baseline,
|
|
177
|
+
}
|
|
178
|
+
base.update(extras)
|
|
179
|
+
return MappingProxyType(base)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def set_plot_style() -> None:
|
|
183
|
+
"""Apply ``PLOT_STYLE`` rcParams + ``PALETTE`` color cycle.
|
|
184
|
+
|
|
185
|
+
Idempotent. Call once per notebook or script before any plotting code.
|
|
186
|
+
"""
|
|
187
|
+
plt.rcParams.update(PLOT_STYLE)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_DEFAULT_PERMITTED_SUFFIXES: frozenset[str] = frozenset({".png", ".pdf", ".svg"})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def save_figure(
|
|
194
|
+
fig: Figure,
|
|
195
|
+
path: Path,
|
|
196
|
+
*,
|
|
197
|
+
dpi: int = 300,
|
|
198
|
+
provenance: dict[str, str] | None = None,
|
|
199
|
+
permitted_suffixes: Container[str] = _DEFAULT_PERMITTED_SUFFIXES,
|
|
200
|
+
skip_env_var: str = "EVAL_TOOLKIT_SKIP_SAVEFIG",
|
|
201
|
+
) -> Path:
|
|
202
|
+
"""Save figure to disk with optional provenance metadata.
|
|
203
|
+
|
|
204
|
+
Honors a configurable skip-env-var (default ``EVAL_TOOLKIT_SKIP_SAVEFIG``)
|
|
205
|
+
— when set to ``"1"``, returns the resolved path without writing.
|
|
206
|
+
|
|
207
|
+
When ``provenance`` is provided, persists the metadata in two places:
|
|
208
|
+
|
|
209
|
+
1. PNG iTXt chunks via ``fig.savefig(metadata=...)``. Travels with the
|
|
210
|
+
PNG when copied/shared. ``.pdf`` and ``.svg`` skip the iTXt step.
|
|
211
|
+
2. Sidecar ``{path.stem}.meta.json`` next to the figure. Human-readable
|
|
212
|
+
and works for all permitted suffixes.
|
|
213
|
+
|
|
214
|
+
Both sidecar and iTXt contain the caller-supplied keys plus auto-fields
|
|
215
|
+
``timestamp_utc`` (ISO-8601), ``matplotlib_version``, ``figure_dpi``.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
fig : matplotlib.figure.Figure
|
|
220
|
+
path : pathlib.Path
|
|
221
|
+
Output path. Suffix must be in ``permitted_suffixes``.
|
|
222
|
+
dpi : int, optional
|
|
223
|
+
Resolution. Default 300.
|
|
224
|
+
provenance : dict[str, str] or None, optional
|
|
225
|
+
Caller-supplied provenance keys (e.g., ``git_sha``, ``run_id``).
|
|
226
|
+
permitted_suffixes : Container[str], optional
|
|
227
|
+
Allowed file-extension suffixes (with leading dot). Default
|
|
228
|
+
``{".png", ".pdf", ".svg"}``. Downstream projects can restrict to a
|
|
229
|
+
single format (e.g. ``permitted_suffixes={".png"}``) for stable
|
|
230
|
+
artifact pipelines, or extend to additional matplotlib-supported
|
|
231
|
+
suffixes (``.eps``, ``.ps``, etc.).
|
|
232
|
+
skip_env_var : str, optional
|
|
233
|
+
Environment-variable name that, when set to ``"1"``, suppresses
|
|
234
|
+
writes and returns the resolved path. Default
|
|
235
|
+
``"EVAL_TOOLKIT_SKIP_SAVEFIG"``. Downstream projects can pass their
|
|
236
|
+
own (e.g. ``skip_env_var="PID_SKIP_SAVEFIG"``) for project-specific
|
|
237
|
+
opt-out controls.
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
pathlib.Path
|
|
242
|
+
The resolved output path.
|
|
243
|
+
|
|
244
|
+
Raises
|
|
245
|
+
------
|
|
246
|
+
ValueError
|
|
247
|
+
If ``path.suffix`` is not in ``permitted_suffixes``, or if ``dpi``
|
|
248
|
+
is non-positive.
|
|
249
|
+
|
|
250
|
+
Notes
|
|
251
|
+
-----
|
|
252
|
+
PNG sidecar JSON is always written when ``provenance`` is supplied;
|
|
253
|
+
iTXt embedded metadata is added for ``.png`` only (matplotlib supports
|
|
254
|
+
iTXt natively via ``metadata=`` kwarg). ``.pdf`` and ``.svg`` ship the
|
|
255
|
+
same sidecar JSON without embedded metadata.
|
|
256
|
+
"""
|
|
257
|
+
if path.suffix not in permitted_suffixes:
|
|
258
|
+
# Container is the broadest acceptable type, but most realistic
|
|
259
|
+
# implementations (set, frozenset, list, tuple) are also iterable.
|
|
260
|
+
if isinstance(permitted_suffixes, Iterable):
|
|
261
|
+
permitted_repr: object = sorted(permitted_suffixes)
|
|
262
|
+
else:
|
|
263
|
+
permitted_repr = permitted_suffixes
|
|
264
|
+
raise ValueError(f"path must end in {permitted_repr}, got {path.suffix!r}")
|
|
265
|
+
if dpi <= 0:
|
|
266
|
+
raise ValueError(f"dpi must be positive, got {dpi}")
|
|
267
|
+
|
|
268
|
+
resolved = path.resolve()
|
|
269
|
+
if os.environ.get(skip_env_var) == "1":
|
|
270
|
+
return resolved
|
|
271
|
+
|
|
272
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
|
|
274
|
+
if provenance is not None:
|
|
275
|
+
# Use the canonical figure_metadata helper from provenance.py for the
|
|
276
|
+
# sidecar payload — keeps the provenance schema consistent across
|
|
277
|
+
# save_figure / make_run_dir / file_sha256 callers.
|
|
278
|
+
from eval_toolkit.provenance import figure_metadata # noqa: PLC0415
|
|
279
|
+
|
|
280
|
+
combined = figure_metadata(dict(provenance), dpi=dpi)
|
|
281
|
+
# iTXt embedded metadata only supported for PNG.
|
|
282
|
+
if path.suffix == ".png":
|
|
283
|
+
fig.savefig(path, dpi=dpi, metadata=combined)
|
|
284
|
+
else:
|
|
285
|
+
fig.savefig(path, dpi=dpi)
|
|
286
|
+
sidecar = path.with_suffix(".meta.json")
|
|
287
|
+
sidecar.write_text(json.dumps(combined, indent=2, sort_keys=True))
|
|
288
|
+
else:
|
|
289
|
+
fig.savefig(path, dpi=dpi)
|
|
290
|
+
return resolved
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _ensure_ndarray(name: str, value: object) -> np.ndarray:
|
|
294
|
+
"""Strict ndarray contract."""
|
|
295
|
+
if not isinstance(value, np.ndarray):
|
|
296
|
+
raise TypeError(f"{name} must be np.ndarray, got {type(value).__name__}")
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _validate_pair(y_true: np.ndarray, y_other: np.ndarray, *, other_name: str) -> None:
|
|
301
|
+
"""Shared shape + domain validation for PR / reliability inputs."""
|
|
302
|
+
if y_true.shape != y_other.shape:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"y_true and {other_name} must have the same shape, "
|
|
305
|
+
f"got {y_true.shape} vs {y_other.shape}"
|
|
306
|
+
)
|
|
307
|
+
if y_true.size == 0:
|
|
308
|
+
raise ValueError("y_true is empty")
|
|
309
|
+
if not np.isfinite(y_other).all():
|
|
310
|
+
raise ValueError(f"{other_name} contains NaN or inf")
|
|
311
|
+
classes = np.unique(y_true)
|
|
312
|
+
if classes.size < 2:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"y_true must contain at least one positive and one negative example; "
|
|
315
|
+
"PR / reliability metrics are undefined for a single class"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_axes(
|
|
320
|
+
ax: Axes | None,
|
|
321
|
+
figsize: tuple[float, float] | None,
|
|
322
|
+
) -> tuple[Figure, Axes]:
|
|
323
|
+
"""Reuse caller's Axes (and parent Figure) or create fresh."""
|
|
324
|
+
if ax is not None:
|
|
325
|
+
return cast(Figure, ax.figure), ax
|
|
326
|
+
fig, axes = plt.subplots(figsize=figsize or DEFAULT_FIGSIZE)
|
|
327
|
+
return fig, axes
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _maybe_add_legend(axes: Axes) -> None:
|
|
331
|
+
"""Add a legend only when at least one labeled artist exists."""
|
|
332
|
+
handles, _ = axes.get_legend_handles_labels()
|
|
333
|
+
if handles:
|
|
334
|
+
axes.legend(loc="best")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def plot_pr_curve(
|
|
338
|
+
y_true: np.ndarray,
|
|
339
|
+
y_score: np.ndarray,
|
|
340
|
+
*,
|
|
341
|
+
label: str | None = None,
|
|
342
|
+
threshold: float | None = None,
|
|
343
|
+
prevalence: float | None = None,
|
|
344
|
+
baseline_curve: tuple[np.ndarray, np.ndarray] | None = None,
|
|
345
|
+
baseline_label: str = "baseline",
|
|
346
|
+
title: str | None = None,
|
|
347
|
+
figsize: tuple[float, float] | None = None,
|
|
348
|
+
ax: Axes | None = None,
|
|
349
|
+
) -> Figure:
|
|
350
|
+
"""Precision-recall curve.
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
y_true, y_score : np.ndarray
|
|
355
|
+
Labels and scores.
|
|
356
|
+
label : str or None, optional
|
|
357
|
+
Legend label for the main curve.
|
|
358
|
+
threshold : float or None, optional
|
|
359
|
+
Draw a star marker at the (recall, precision) point closest to this
|
|
360
|
+
threshold.
|
|
361
|
+
prevalence : float or None, optional
|
|
362
|
+
Draw a horizontal reference line at this y-value (e.g., positive
|
|
363
|
+
class prevalence).
|
|
364
|
+
baseline_curve : tuple of (recall, precision) np.ndarrays, optional
|
|
365
|
+
Optional baseline curve to overlay (e.g., a simpler reference model).
|
|
366
|
+
baseline_label : str, optional
|
|
367
|
+
Legend label for the baseline overlay (default ``"baseline"``).
|
|
368
|
+
title : str or None, optional
|
|
369
|
+
figsize : tuple of float or None, optional
|
|
370
|
+
ax : matplotlib Axes or None, optional
|
|
371
|
+
|
|
372
|
+
Returns
|
|
373
|
+
-------
|
|
374
|
+
matplotlib.figure.Figure
|
|
375
|
+
|
|
376
|
+
Raises
|
|
377
|
+
------
|
|
378
|
+
ValueError
|
|
379
|
+
If ``y_true``/``y_score`` fail shape/dtype/value-range checks
|
|
380
|
+
(re-raised from validators); if ``threshold`` or ``prevalence`` is
|
|
381
|
+
outside [0, 1]; or if ``baseline_curve`` is not a length-2 tuple
|
|
382
|
+
with matching-shape ``(recall, precision)`` arrays.
|
|
383
|
+
"""
|
|
384
|
+
y_true = _ensure_ndarray("y_true", y_true)
|
|
385
|
+
y_score = _ensure_ndarray("y_score", y_score)
|
|
386
|
+
_validate_pair(y_true, y_score, other_name="y_score")
|
|
387
|
+
|
|
388
|
+
if threshold is not None and not 0.0 <= threshold <= 1.0:
|
|
389
|
+
raise ValueError(f"threshold must be in [0, 1], got {threshold}")
|
|
390
|
+
if prevalence is not None and not 0.0 <= prevalence <= 1.0:
|
|
391
|
+
raise ValueError(f"prevalence must be in [0, 1], got {prevalence}")
|
|
392
|
+
if baseline_curve is not None:
|
|
393
|
+
if not (isinstance(baseline_curve, tuple) and len(baseline_curve) == 2):
|
|
394
|
+
raise ValueError("baseline_curve must be a (recall, precision) tuple")
|
|
395
|
+
bl_recall = _ensure_ndarray("baseline_curve[0]", baseline_curve[0])
|
|
396
|
+
bl_precision = _ensure_ndarray("baseline_curve[1]", baseline_curve[1])
|
|
397
|
+
if bl_recall.shape != bl_precision.shape:
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"baseline_curve recall and precision must have same shape, "
|
|
400
|
+
f"got {bl_recall.shape} vs {bl_precision.shape}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
fig, axes = _resolve_axes(ax, figsize)
|
|
404
|
+
|
|
405
|
+
precision, recall, thresholds = precision_recall_curve(y_true, y_score)
|
|
406
|
+
axes.plot(recall, precision, color=PALETTE["negative"], label=label, linewidth=1.5)
|
|
407
|
+
|
|
408
|
+
if baseline_curve is not None:
|
|
409
|
+
bl_recall = np.asarray(baseline_curve[0])
|
|
410
|
+
bl_precision = np.asarray(baseline_curve[1])
|
|
411
|
+
axes.plot(
|
|
412
|
+
bl_recall,
|
|
413
|
+
bl_precision,
|
|
414
|
+
color=PALETTE["baseline"],
|
|
415
|
+
linewidth=1.0,
|
|
416
|
+
linestyle="--",
|
|
417
|
+
label=baseline_label,
|
|
418
|
+
zorder=1,
|
|
419
|
+
)
|
|
420
|
+
if prevalence is not None:
|
|
421
|
+
axes.axhline(
|
|
422
|
+
prevalence,
|
|
423
|
+
color=PALETTE["baseline"],
|
|
424
|
+
linestyle="--",
|
|
425
|
+
linewidth=0.8,
|
|
426
|
+
alpha=0.7,
|
|
427
|
+
label=f"prevalence={prevalence:.3f}",
|
|
428
|
+
)
|
|
429
|
+
if threshold is not None:
|
|
430
|
+
idx = int(np.argmin(np.abs(thresholds - threshold)))
|
|
431
|
+
axes.scatter(
|
|
432
|
+
recall[idx],
|
|
433
|
+
precision[idx],
|
|
434
|
+
color=PALETTE["accent"],
|
|
435
|
+
marker="*",
|
|
436
|
+
s=120,
|
|
437
|
+
zorder=5,
|
|
438
|
+
label=f"τ={threshold:.3f}",
|
|
439
|
+
edgecolor="black",
|
|
440
|
+
linewidth=0.5,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
axes.set_xlabel("Recall")
|
|
444
|
+
axes.set_ylabel("Precision")
|
|
445
|
+
axes.set_xlim(0.0, 1.0)
|
|
446
|
+
axes.set_ylim(0.0, 1.05)
|
|
447
|
+
if title is not None:
|
|
448
|
+
axes.set_title(title)
|
|
449
|
+
_maybe_add_legend(axes)
|
|
450
|
+
fig.tight_layout()
|
|
451
|
+
return fig
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def plot_reliability_diagram(
|
|
455
|
+
y_true: np.ndarray,
|
|
456
|
+
y_prob: np.ndarray,
|
|
457
|
+
*,
|
|
458
|
+
n_bins: int = 10,
|
|
459
|
+
bin_counts: np.ndarray | None = None,
|
|
460
|
+
xlabel: str = "Mean Predicted Probability",
|
|
461
|
+
ylabel: str = "Observed Fraction of Positives",
|
|
462
|
+
title: str | None = None,
|
|
463
|
+
figsize: tuple[float, float] | None = None,
|
|
464
|
+
ax: Axes | None = None,
|
|
465
|
+
) -> Figure:
|
|
466
|
+
"""Equal-width-binned reliability diagram with diagonal reference.
|
|
467
|
+
|
|
468
|
+
When ``bin_counts`` is provided, scales point sizes by counts (bubble
|
|
469
|
+
encoding) so sparsely-populated bins look smaller, signaling lower trust.
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
y_true, y_prob : np.ndarray
|
|
474
|
+
Labels and calibrated probabilities.
|
|
475
|
+
n_bins : int, optional
|
|
476
|
+
Number of equal-width bins. Default 10.
|
|
477
|
+
bin_counts : np.ndarray or None, optional
|
|
478
|
+
Per-bin counts (shape ``(n_bins,)``); enables bubble encoding.
|
|
479
|
+
xlabel, ylabel : str, optional
|
|
480
|
+
Axis labels.
|
|
481
|
+
title : str or None, optional
|
|
482
|
+
figsize : tuple of float or None, optional
|
|
483
|
+
ax : matplotlib Axes or None, optional
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
matplotlib.figure.Figure
|
|
488
|
+
|
|
489
|
+
Raises
|
|
490
|
+
------
|
|
491
|
+
ValueError
|
|
492
|
+
If ``y_true``/``y_prob`` fail shape/dtype/value-range checks
|
|
493
|
+
(re-raised from validators); if ``bin_counts`` does not have
|
|
494
|
+
shape ``(n_bins,)``; or if ``bin_counts`` contains negative
|
|
495
|
+
values.
|
|
496
|
+
"""
|
|
497
|
+
y_true = _ensure_ndarray("y_true", y_true)
|
|
498
|
+
y_prob = _ensure_ndarray("y_prob", y_prob)
|
|
499
|
+
_validate_pair(y_true, y_prob, other_name="y_prob")
|
|
500
|
+
|
|
501
|
+
if bin_counts is not None:
|
|
502
|
+
bin_counts = _ensure_ndarray("bin_counts", bin_counts)
|
|
503
|
+
if bin_counts.shape != (n_bins,):
|
|
504
|
+
raise ValueError(f"bin_counts must have shape ({n_bins},), got {bin_counts.shape}")
|
|
505
|
+
if (bin_counts < 0).any():
|
|
506
|
+
raise ValueError("bin_counts must be non-negative")
|
|
507
|
+
|
|
508
|
+
fig, axes = _resolve_axes(ax, figsize)
|
|
509
|
+
|
|
510
|
+
fraction_of_positives, mean_predicted_value = calibration_curve(
|
|
511
|
+
y_true, y_prob, n_bins=n_bins, strategy="uniform"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
axes.plot(
|
|
515
|
+
[0, 1],
|
|
516
|
+
[0, 1],
|
|
517
|
+
color=PALETTE["baseline"],
|
|
518
|
+
linestyle="--",
|
|
519
|
+
linewidth=0.8,
|
|
520
|
+
label="Perfect calibration",
|
|
521
|
+
zorder=1,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if bin_counts is not None and bin_counts.size > 0:
|
|
525
|
+
bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
|
|
526
|
+
bin_idx = np.clip(np.digitize(mean_predicted_value, bin_edges) - 1, 0, n_bins - 1)
|
|
527
|
+
sizes = 30.0 + 4.0 * bin_counts[bin_idx]
|
|
528
|
+
else:
|
|
529
|
+
sizes = np.full_like(mean_predicted_value, 50.0)
|
|
530
|
+
|
|
531
|
+
axes.scatter(
|
|
532
|
+
mean_predicted_value,
|
|
533
|
+
fraction_of_positives,
|
|
534
|
+
s=sizes,
|
|
535
|
+
color=PALETTE["negative"],
|
|
536
|
+
edgecolor="black",
|
|
537
|
+
linewidth=0.5,
|
|
538
|
+
zorder=3,
|
|
539
|
+
)
|
|
540
|
+
axes.plot(
|
|
541
|
+
mean_predicted_value,
|
|
542
|
+
fraction_of_positives,
|
|
543
|
+
color=PALETTE["negative"],
|
|
544
|
+
linewidth=1.0,
|
|
545
|
+
zorder=2,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
axes.set_xlabel(xlabel)
|
|
549
|
+
axes.set_ylabel(ylabel)
|
|
550
|
+
axes.set_xlim(0.0, 1.0)
|
|
551
|
+
axes.set_ylim(0.0, 1.0)
|
|
552
|
+
if title is not None:
|
|
553
|
+
axes.set_title(title)
|
|
554
|
+
_maybe_add_legend(axes)
|
|
555
|
+
fig.tight_layout()
|
|
556
|
+
return fig
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def plot_confusion_matrix_grid(
|
|
560
|
+
matrices: dict[str, np.ndarray],
|
|
561
|
+
*,
|
|
562
|
+
class_names: tuple[str, str] = ("negative", "positive"),
|
|
563
|
+
title: str | None = None,
|
|
564
|
+
figsize: tuple[float, float] | None = None,
|
|
565
|
+
) -> Figure:
|
|
566
|
+
"""N-panel confusion-matrix grid (one per scorer).
|
|
567
|
+
|
|
568
|
+
Two-tone semantic encoding: diagonal cells (TP, TN — "correct") use a
|
|
569
|
+
white→PALETTE['negative'] gradient; off-diagonal cells (FP, FN — "errors")
|
|
570
|
+
use a white→PALETTE['positive'] gradient. Each cell shows
|
|
571
|
+
``'{count}\\n({percent:.0%})'``.
|
|
572
|
+
|
|
573
|
+
Parameters
|
|
574
|
+
----------
|
|
575
|
+
matrices : dict[str, np.ndarray]
|
|
576
|
+
``{scorer_name: 2x2 confusion matrix}``.
|
|
577
|
+
class_names : tuple of (str, str), optional
|
|
578
|
+
Tick labels for the two classes (default ``("negative", "positive")``).
|
|
579
|
+
title : str or None, optional
|
|
580
|
+
figsize : tuple of float or None, optional
|
|
581
|
+
|
|
582
|
+
Returns
|
|
583
|
+
-------
|
|
584
|
+
matplotlib.figure.Figure
|
|
585
|
+
|
|
586
|
+
Raises
|
|
587
|
+
------
|
|
588
|
+
ValueError
|
|
589
|
+
If ``matrices`` is empty or any matrix is not 2x2.
|
|
590
|
+
"""
|
|
591
|
+
if not matrices:
|
|
592
|
+
raise ValueError("matrices must contain at least one panel")
|
|
593
|
+
for key, matrix in matrices.items():
|
|
594
|
+
matrix = _ensure_ndarray(f"matrices[{key!r}]", matrix)
|
|
595
|
+
if matrix.shape != (2, 2):
|
|
596
|
+
raise ValueError(f"matrices must be 2x2, got shape {matrix.shape} for key {key!r}")
|
|
597
|
+
|
|
598
|
+
n = len(matrices)
|
|
599
|
+
actual_figsize = figsize or (3.5 * n, 3.5)
|
|
600
|
+
fig, axes_list = plt.subplots(1, n, figsize=actual_figsize, squeeze=False)
|
|
601
|
+
axes_arr = axes_list[0]
|
|
602
|
+
|
|
603
|
+
negative_cmap = LinearSegmentedColormap.from_list(
|
|
604
|
+
"white_to_negative", ["#ffffff", PALETTE["negative"]]
|
|
605
|
+
)
|
|
606
|
+
positive_cmap = LinearSegmentedColormap.from_list(
|
|
607
|
+
"white_to_positive", ["#ffffff", PALETTE["positive"]]
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
diag_mask = np.eye(2, dtype=bool)
|
|
611
|
+
for axes, (name, matrix) in zip(axes_arr, matrices.items(), strict=True):
|
|
612
|
+
m = np.asarray(matrix, dtype=float)
|
|
613
|
+
total = m.sum()
|
|
614
|
+
diag_max = max(float(m[diag_mask].max()), 1.0)
|
|
615
|
+
offdiag_max = max(float(m[~diag_mask].max()), 1.0)
|
|
616
|
+
|
|
617
|
+
for (i, j), value in np.ndenumerate(m):
|
|
618
|
+
on_diag = i == j
|
|
619
|
+
norm = value / (diag_max if on_diag else offdiag_max)
|
|
620
|
+
cmap = negative_cmap if on_diag else positive_cmap
|
|
621
|
+
axes.add_patch(
|
|
622
|
+
Rectangle(
|
|
623
|
+
(j - 0.5, i - 0.5),
|
|
624
|
+
1.0,
|
|
625
|
+
1.0,
|
|
626
|
+
facecolor=cmap(norm),
|
|
627
|
+
edgecolor="white",
|
|
628
|
+
linewidth=1.0,
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
cell_total = total if total > 0 else 1.0
|
|
632
|
+
percent = value / cell_total
|
|
633
|
+
text_color = "white" if norm > 0.55 else "#222222"
|
|
634
|
+
axes.text(
|
|
635
|
+
j,
|
|
636
|
+
i,
|
|
637
|
+
f"{int(value)}\n({percent:.0%})",
|
|
638
|
+
ha="center",
|
|
639
|
+
va="center",
|
|
640
|
+
color=text_color,
|
|
641
|
+
fontsize=10,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
axes.set_xticks([0, 1])
|
|
645
|
+
axes.set_yticks([0, 1])
|
|
646
|
+
axes.set_xticklabels(list(class_names))
|
|
647
|
+
axes.set_yticklabels(list(class_names))
|
|
648
|
+
axes.set_xlabel("Predicted")
|
|
649
|
+
axes.set_ylabel("Actual")
|
|
650
|
+
axes.set_title(name)
|
|
651
|
+
axes.set_xlim(-0.5, 1.5)
|
|
652
|
+
axes.set_ylim(1.5, -0.5)
|
|
653
|
+
axes.set_aspect("equal")
|
|
654
|
+
axes.grid(False)
|
|
655
|
+
for spine in axes.spines.values():
|
|
656
|
+
spine.set_visible(False)
|
|
657
|
+
|
|
658
|
+
if title is not None:
|
|
659
|
+
fig.suptitle(title)
|
|
660
|
+
fig.tight_layout()
|
|
661
|
+
return fig
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def plot_metric_bars(
|
|
665
|
+
values: dict[str, float],
|
|
666
|
+
*,
|
|
667
|
+
color: str | None = None,
|
|
668
|
+
ylabel: str | None = None,
|
|
669
|
+
title: str | None = None,
|
|
670
|
+
figsize: tuple[float, float] | None = None,
|
|
671
|
+
label_formatter: Callable[[str], str] | None = None,
|
|
672
|
+
sort_key: Callable[[str], Any] | None = None,
|
|
673
|
+
) -> Figure:
|
|
674
|
+
"""Bar chart for a ``{label: metric}`` mapping.
|
|
675
|
+
|
|
676
|
+
Parameters
|
|
677
|
+
----------
|
|
678
|
+
values : dict[str, float]
|
|
679
|
+
color : str or None, optional
|
|
680
|
+
Bar color. Default is ``PALETTE["negative"]``.
|
|
681
|
+
ylabel, title : str or None, optional
|
|
682
|
+
figsize : tuple of float or None, optional
|
|
683
|
+
label_formatter : callable or None, optional
|
|
684
|
+
Maps raw key → display label. Default is identity.
|
|
685
|
+
sort_key : callable or None, optional
|
|
686
|
+
Maps raw key → sort key. Default is alphabetical.
|
|
687
|
+
|
|
688
|
+
Returns
|
|
689
|
+
-------
|
|
690
|
+
matplotlib.figure.Figure
|
|
691
|
+
|
|
692
|
+
Raises
|
|
693
|
+
------
|
|
694
|
+
ValueError
|
|
695
|
+
If ``values`` is empty.
|
|
696
|
+
"""
|
|
697
|
+
if not values:
|
|
698
|
+
raise ValueError("values must contain at least one entry")
|
|
699
|
+
|
|
700
|
+
fmt = label_formatter or (lambda k: k)
|
|
701
|
+
skey = sort_key or (lambda k: k)
|
|
702
|
+
sorted_items = sorted(values.items(), key=lambda kv: skey(kv[0]))
|
|
703
|
+
labels = [fmt(k) for k, _ in sorted_items]
|
|
704
|
+
bar_values = [v for _, v in sorted_items]
|
|
705
|
+
|
|
706
|
+
fig, axes = plt.subplots(figsize=figsize or DEFAULT_FIGSIZE)
|
|
707
|
+
bar_color = color or PALETTE["negative"]
|
|
708
|
+
axes.bar(labels, bar_values, color=bar_color, edgecolor="black", linewidth=0.5)
|
|
709
|
+
upper = max(bar_values)
|
|
710
|
+
axes.set_ylim(0.0, max(1.0, upper * 1.05))
|
|
711
|
+
if ylabel is not None:
|
|
712
|
+
axes.set_ylabel(ylabel)
|
|
713
|
+
if title is not None:
|
|
714
|
+
axes.set_title(title)
|
|
715
|
+
axes.tick_params(axis="x", rotation=30)
|
|
716
|
+
for tick in axes.get_xticklabels():
|
|
717
|
+
tick.set_horizontalalignment("right")
|
|
718
|
+
fig.tight_layout()
|
|
719
|
+
return fig
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def plot_score_histograms(
|
|
723
|
+
scores_by_slice: dict[str, np.ndarray],
|
|
724
|
+
*,
|
|
725
|
+
scorer_name: str | None = None,
|
|
726
|
+
bins: int = 30,
|
|
727
|
+
title: str | None = None,
|
|
728
|
+
figsize: tuple[float, float] | None = None,
|
|
729
|
+
label_formatter: Callable[[str], str] | None = None,
|
|
730
|
+
sort_key: Callable[[str], Any] | None = None,
|
|
731
|
+
) -> Figure:
|
|
732
|
+
"""Overlaid score-distribution histograms, one per slice.
|
|
733
|
+
|
|
734
|
+
Parameters
|
|
735
|
+
----------
|
|
736
|
+
scores_by_slice : dict[str, np.ndarray]
|
|
737
|
+
``{slice_name: 1-D score array}``.
|
|
738
|
+
scorer_name : str or None, optional
|
|
739
|
+
If set and ``title`` is None, used as part of an auto-generated title.
|
|
740
|
+
bins : int, optional
|
|
741
|
+
Histogram bins. Default 30.
|
|
742
|
+
title, figsize : optional
|
|
743
|
+
label_formatter, sort_key : callable or None, optional
|
|
744
|
+
See :func:`plot_metric_bars`.
|
|
745
|
+
|
|
746
|
+
Returns
|
|
747
|
+
-------
|
|
748
|
+
matplotlib.figure.Figure
|
|
749
|
+
|
|
750
|
+
Raises
|
|
751
|
+
------
|
|
752
|
+
ValueError
|
|
753
|
+
If ``scores_by_slice`` is empty, or if any slice array is not 1-D,
|
|
754
|
+
is empty, or contains NaN/inf.
|
|
755
|
+
"""
|
|
756
|
+
if not scores_by_slice:
|
|
757
|
+
raise ValueError("scores_by_slice must contain at least one slice")
|
|
758
|
+
|
|
759
|
+
fmt = label_formatter or (lambda k: k)
|
|
760
|
+
skey = sort_key or (lambda k: k)
|
|
761
|
+
|
|
762
|
+
sorted_items: list[tuple[str, np.ndarray]] = []
|
|
763
|
+
for key, arr in scores_by_slice.items():
|
|
764
|
+
validated = _ensure_ndarray(f"scores_by_slice[{key!r}]", arr)
|
|
765
|
+
if validated.ndim != 1:
|
|
766
|
+
raise ValueError(f"scores_by_slice[{key!r}] must be 1D, got shape {validated.shape}")
|
|
767
|
+
if validated.size == 0:
|
|
768
|
+
raise ValueError(f"scores_by_slice[{key!r}] is empty")
|
|
769
|
+
if not np.isfinite(validated).all():
|
|
770
|
+
raise ValueError(f"scores_by_slice[{key!r}] contains NaN or inf")
|
|
771
|
+
sorted_items.append((key, validated))
|
|
772
|
+
sorted_items.sort(key=lambda kv: skey(kv[0]))
|
|
773
|
+
|
|
774
|
+
palette_cycle = [
|
|
775
|
+
PALETTE["negative"],
|
|
776
|
+
PALETTE["positive"],
|
|
777
|
+
PALETTE["accent"],
|
|
778
|
+
PALETTE["baseline"],
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
fig, axes = plt.subplots(figsize=figsize or DEFAULT_FIGSIZE)
|
|
782
|
+
for i, (key, arr) in enumerate(sorted_items):
|
|
783
|
+
color = palette_cycle[i % len(palette_cycle)]
|
|
784
|
+
axes.hist(
|
|
785
|
+
arr,
|
|
786
|
+
bins=bins,
|
|
787
|
+
range=(0.0, 1.0),
|
|
788
|
+
density=True,
|
|
789
|
+
histtype="stepfilled",
|
|
790
|
+
alpha=0.4,
|
|
791
|
+
edgecolor=color,
|
|
792
|
+
facecolor=color,
|
|
793
|
+
linewidth=1.0,
|
|
794
|
+
label=fmt(key),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
axes.set_xlabel("Score")
|
|
798
|
+
axes.set_ylabel("Density")
|
|
799
|
+
axes.set_xlim(0.0, 1.0)
|
|
800
|
+
full_title = title
|
|
801
|
+
if full_title is None and scorer_name is not None:
|
|
802
|
+
full_title = f"Score distribution: {scorer_name}"
|
|
803
|
+
if full_title is not None:
|
|
804
|
+
axes.set_title(full_title)
|
|
805
|
+
_maybe_add_legend(axes)
|
|
806
|
+
fig.tight_layout()
|
|
807
|
+
return fig
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def plot_lift_ci(
|
|
811
|
+
estimates: dict[str, BootstrapCI],
|
|
812
|
+
*,
|
|
813
|
+
zero_line: bool = True,
|
|
814
|
+
xlabel: str = "Δ (lift over reference)",
|
|
815
|
+
title: str | None = None,
|
|
816
|
+
figsize: tuple[float, float] | None = None,
|
|
817
|
+
ax: Axes | None = None,
|
|
818
|
+
) -> Figure:
|
|
819
|
+
"""Forest-plot-style CIs over comparisons.
|
|
820
|
+
|
|
821
|
+
Each row = one comparison key × its 95% CI. When ``zero_line=True``,
|
|
822
|
+
draws a vertical reference at x=0: a CI overlapping zero means the lift
|
|
823
|
+
is not statistically significant.
|
|
824
|
+
|
|
825
|
+
Parameters
|
|
826
|
+
----------
|
|
827
|
+
estimates : dict[str, BootstrapCI]
|
|
828
|
+
Anything with ``point_estimate``, ``ci_low``, ``ci_high`` attributes
|
|
829
|
+
is accepted (duck-typed; not strictly the toolkit's
|
|
830
|
+
:class:`~eval_toolkit.bootstrap.BootstrapCI`).
|
|
831
|
+
zero_line : bool, optional
|
|
832
|
+
Draw a vertical reference at x=0. Default True.
|
|
833
|
+
xlabel : str, optional
|
|
834
|
+
X-axis label (default ``"Δ (lift over reference)"``).
|
|
835
|
+
title, figsize, ax : optional
|
|
836
|
+
|
|
837
|
+
Returns
|
|
838
|
+
-------
|
|
839
|
+
matplotlib.figure.Figure
|
|
840
|
+
|
|
841
|
+
Raises
|
|
842
|
+
------
|
|
843
|
+
ValueError
|
|
844
|
+
If ``estimates`` is empty.
|
|
845
|
+
TypeError
|
|
846
|
+
If any value in ``estimates`` lacks ``point_estimate``, ``ci_low``,
|
|
847
|
+
or ``ci_high`` attributes (BootstrapCI-like duck typing).
|
|
848
|
+
"""
|
|
849
|
+
if not estimates:
|
|
850
|
+
raise ValueError("estimates must contain at least one comparison")
|
|
851
|
+
for key, est in estimates.items():
|
|
852
|
+
for attr in ("point_estimate", "ci_low", "ci_high"):
|
|
853
|
+
if not hasattr(est, attr):
|
|
854
|
+
raise TypeError(
|
|
855
|
+
f"estimates[{key!r}] must be a BootstrapCI-like object with "
|
|
856
|
+
f"point_estimate/ci_low/ci_high attributes"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
keys = list(estimates.keys())
|
|
860
|
+
points = np.array([estimates[k].point_estimate for k in keys])
|
|
861
|
+
ci_low = np.array([estimates[k].ci_low for k in keys])
|
|
862
|
+
ci_high = np.array([estimates[k].ci_high for k in keys])
|
|
863
|
+
yerr = np.array([points - ci_low, ci_high - points])
|
|
864
|
+
|
|
865
|
+
resolved_figsize = figsize or (DEFAULT_FIGSIZE[0], max(2.5, 0.5 * len(keys) + 1.5))
|
|
866
|
+
fig, axes = _resolve_axes(ax, resolved_figsize)
|
|
867
|
+
y_positions = np.arange(len(keys))[::-1]
|
|
868
|
+
|
|
869
|
+
if zero_line:
|
|
870
|
+
axes.axvline(
|
|
871
|
+
0.0,
|
|
872
|
+
color=PALETTE["baseline"],
|
|
873
|
+
linestyle="--",
|
|
874
|
+
linewidth=0.8,
|
|
875
|
+
alpha=0.7,
|
|
876
|
+
zorder=1,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
axes.errorbar(
|
|
880
|
+
points,
|
|
881
|
+
y_positions,
|
|
882
|
+
xerr=np.abs(yerr),
|
|
883
|
+
fmt="o",
|
|
884
|
+
color=PALETTE["negative"],
|
|
885
|
+
ecolor=PALETTE["negative"],
|
|
886
|
+
elinewidth=1.2,
|
|
887
|
+
capsize=4,
|
|
888
|
+
markersize=6,
|
|
889
|
+
zorder=3,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
axes.set_yticks(y_positions)
|
|
893
|
+
axes.set_yticklabels(keys)
|
|
894
|
+
axes.set_xlabel(xlabel)
|
|
895
|
+
if title is not None:
|
|
896
|
+
axes.set_title(title)
|
|
897
|
+
fig.tight_layout()
|
|
898
|
+
return fig
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def plot_bootstrap_distribution(
|
|
902
|
+
deltas: np.ndarray,
|
|
903
|
+
*,
|
|
904
|
+
ci_low: float | None = None,
|
|
905
|
+
ci_high: float | None = None,
|
|
906
|
+
bins: int = 30,
|
|
907
|
+
title: str | None = None,
|
|
908
|
+
xlabel: str = "Δ (bootstrap-sampled)",
|
|
909
|
+
figsize: tuple[float, float] | None = None,
|
|
910
|
+
ax: Axes | None = None,
|
|
911
|
+
) -> Figure:
|
|
912
|
+
"""Histogram of bootstrap-sampled deltas with optional CI overlay.
|
|
913
|
+
|
|
914
|
+
Useful for diagnosing CI shape (skew, multimodality, normality
|
|
915
|
+
assumption violations) that the scalar ``(ci_low, ci_high)`` summary
|
|
916
|
+
hides. If ``ci_low`` and ``ci_high`` are supplied, vertical lines
|
|
917
|
+
are drawn at the CI bounds plus a reference at zero.
|
|
918
|
+
|
|
919
|
+
Parameters
|
|
920
|
+
----------
|
|
921
|
+
deltas : np.ndarray, shape (n_resamples,)
|
|
922
|
+
1-D array of bootstrap-sampled deltas.
|
|
923
|
+
ci_low, ci_high : float or None, optional
|
|
924
|
+
CI bounds to overlay as vertical lines. Both must be supplied
|
|
925
|
+
together or left as ``None``.
|
|
926
|
+
bins : int, optional
|
|
927
|
+
Histogram bin count. Default 30.
|
|
928
|
+
title, xlabel : optional
|
|
929
|
+
figsize : tuple, optional
|
|
930
|
+
ax : matplotlib.axes.Axes, optional
|
|
931
|
+
Reuse caller's axes.
|
|
932
|
+
|
|
933
|
+
Returns
|
|
934
|
+
-------
|
|
935
|
+
matplotlib.figure.Figure
|
|
936
|
+
|
|
937
|
+
Raises
|
|
938
|
+
------
|
|
939
|
+
ValueError
|
|
940
|
+
If ``deltas`` is empty / non-1-D / contains NaN/Inf, or if exactly
|
|
941
|
+
one of ``ci_low`` / ``ci_high`` is supplied.
|
|
942
|
+
|
|
943
|
+
Notes
|
|
944
|
+
-----
|
|
945
|
+
Skewed distributions visible in this plot indicate that the
|
|
946
|
+
half-width-derived ``σ̂`` in :func:`eval_toolkit.bootstrap.mde_from_ci`
|
|
947
|
+
is biased; in that case prefer :func:`eval_toolkit.bootstrap.paired_mde`
|
|
948
|
+
which computes ``σ`` from the deltas directly.
|
|
949
|
+
|
|
950
|
+
See Also
|
|
951
|
+
--------
|
|
952
|
+
eval_toolkit.plotting.plot_lift_ci :
|
|
953
|
+
Forest plot of multiple CI summaries (when shape diagnostics are
|
|
954
|
+
not needed).
|
|
955
|
+
"""
|
|
956
|
+
deltas_arr = _ensure_ndarray("deltas", deltas)
|
|
957
|
+
if deltas_arr.ndim != 1:
|
|
958
|
+
raise ValueError(f"deltas must be 1-D, got shape {deltas_arr.shape}")
|
|
959
|
+
if deltas_arr.size == 0:
|
|
960
|
+
raise ValueError("deltas is empty")
|
|
961
|
+
if not np.isfinite(deltas_arr).all():
|
|
962
|
+
raise ValueError("deltas contains NaN or inf")
|
|
963
|
+
if (ci_low is None) != (ci_high is None):
|
|
964
|
+
raise ValueError("ci_low and ci_high must both be supplied or both None")
|
|
965
|
+
|
|
966
|
+
fig, axes = _resolve_axes(ax, figsize)
|
|
967
|
+
axes.hist(
|
|
968
|
+
deltas_arr,
|
|
969
|
+
bins=bins,
|
|
970
|
+
color=PALETTE["negative"],
|
|
971
|
+
edgecolor="black",
|
|
972
|
+
linewidth=0.4,
|
|
973
|
+
alpha=0.85,
|
|
974
|
+
)
|
|
975
|
+
axes.axvline(0.0, color=PALETTE["baseline"], linestyle="--", linewidth=0.8, alpha=0.7)
|
|
976
|
+
if ci_low is not None and ci_high is not None:
|
|
977
|
+
for x in (ci_low, ci_high):
|
|
978
|
+
axes.axvline(
|
|
979
|
+
x,
|
|
980
|
+
color=PALETTE["accent"],
|
|
981
|
+
linestyle="-",
|
|
982
|
+
linewidth=1.2,
|
|
983
|
+
label=f"CI bound: {x:+.4f}",
|
|
984
|
+
)
|
|
985
|
+
_maybe_add_legend(axes)
|
|
986
|
+
axes.set_xlabel(xlabel)
|
|
987
|
+
axes.set_ylabel("count")
|
|
988
|
+
if title is not None:
|
|
989
|
+
axes.set_title(title)
|
|
990
|
+
fig.tight_layout()
|
|
991
|
+
return fig
|