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,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