|
| 1 | +#!/usr/bin/env python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +""" |
| 5 | +P-P (probability-integral-transform) calibration plot for the heteroscedastic |
| 6 | +parameter heads. |
| 7 | +
|
| 8 | +For a well-calibrated Gaussian prediction ``N(mu, sigma)`` the PIT value |
| 9 | +``z = Phi((y - mu) / sigma)`` of the true target ``y`` is uniformly distributed |
| 10 | +on ``[0, 1]``. Plotting the empirical CDF of the PIT values against the diagonal |
| 11 | +therefore reveals miscalibration of the predicted uncertainties: |
| 12 | +
|
| 13 | + - curve on the diagonal -> calibrated |
| 14 | + - curve shallower than diagonal -> over-confident (sigma too small) |
| 15 | + - curve steeper than diagonal -> under-confident (sigma too large) |
| 16 | +
|
| 17 | +This validates the sigma mechanism that the multi-detector consistency |
| 18 | +statistic relies on (it is uncertainty-weighted, so trustworthy sigmas matter), |
| 19 | +and works equally for the merged heteroscedastic heads. It complements |
| 20 | +``plot_calibration_curve`` (which calibrates the *classifier*). |
| 21 | +""" |
| 22 | + |
| 23 | +import os |
| 24 | + |
| 25 | +import numpy as np |
| 26 | +import matplotlib.pyplot as plt |
| 27 | + |
| 28 | +from scipy.special import ndtr # standard-normal CDF, Phi |
| 29 | + |
| 30 | + |
| 31 | +def _pit(mu, sigma, y, eps=1e-12): |
| 32 | + """Probability integral transform ``Phi((y - mu) / sigma)``.""" |
| 33 | + return ndtr((y - mu) / (np.abs(sigma) + eps)) |
| 34 | + |
| 35 | + |
| 36 | +def plot_pp_calibration( |
| 37 | + mu, |
| 38 | + sigma, |
| 39 | + y, |
| 40 | + param_names=None, |
| 41 | + epoch=None, |
| 42 | + export_dir=None, |
| 43 | + save=True, |
| 44 | + title=None, |
| 45 | +): |
| 46 | + """P-P calibration plot of heteroscedastic predictions. |
| 47 | +
|
| 48 | + Parameters |
| 49 | + ---------- |
| 50 | + mu, sigma, y : array-like, shape ``(N,)`` or ``(N, P)`` |
| 51 | + Predicted means, predicted standard deviations (NOT log-variances), and |
| 52 | + the true targets, for ``N`` samples and optionally ``P`` parameters. |
| 53 | + Pass only the supervised samples (e.g. signals); mask out noise first. |
| 54 | + param_names : list[str] or None |
| 55 | + Names for the ``P`` parameters (used in the legend). |
| 56 | + epoch : int or str or None |
| 57 | + Epoch identifier for the title / filename. |
| 58 | + export_dir : str or None |
| 59 | + If given (and ``save``), writes ``calibration/pp_calibration_{epoch}.png``. |
| 60 | + save : bool |
| 61 | + Save to disk if True, else show interactively. |
| 62 | + title : str or None |
| 63 | + Override the default title. |
| 64 | +
|
| 65 | + Returns |
| 66 | + ------- |
| 67 | + dict |
| 68 | + Per-parameter calibration metrics: ``{name: {"ks": float, |
| 69 | + "cov1sigma": float, "cov2sigma": float}}`` where ``ks`` is the |
| 70 | + Kolmogorov-Smirnov distance of the PIT from uniform (0 = perfect). |
| 71 | + """ |
| 72 | + mu = np.asarray(mu, dtype=np.float64) |
| 73 | + sigma = np.asarray(sigma, dtype=np.float64) |
| 74 | + y = np.asarray(y, dtype=np.float64) |
| 75 | + if mu.ndim == 1: |
| 76 | + mu, sigma, y = mu[:, None], sigma[:, None], y[:, None] |
| 77 | + n, p = mu.shape |
| 78 | + names = list(param_names) if param_names is not None else [f"param {i}" for i in range(p)] |
| 79 | + |
| 80 | + plt.figure(figsize=(7, 6)) |
| 81 | + plt.plot([0, 1], [0, 1], ls="--", color="k", label="Perfect calibration") |
| 82 | + |
| 83 | + metrics = {} |
| 84 | + for i in range(p): |
| 85 | + good = np.isfinite(mu[:, i]) & np.isfinite(sigma[:, i]) & np.isfinite(y[:, i]) |
| 86 | + z = (y[good, i] - mu[good, i]) / (np.abs(sigma[good, i]) + 1e-12) |
| 87 | + pit = _pit(mu[good, i], sigma[good, i], y[good, i]) |
| 88 | + pit_sorted = np.sort(pit) |
| 89 | + ecdf = np.arange(1, pit_sorted.size + 1) / pit_sorted.size |
| 90 | + # KS distance of the PIT empirical CDF from the uniform diagonal |
| 91 | + ks = float(np.max(np.abs(ecdf - pit_sorted))) if pit_sorted.size else float("nan") |
| 92 | + cov1 = float(np.mean(np.abs(z) < 1.0)) if z.size else float("nan") |
| 93 | + cov2 = float(np.mean(np.abs(z) < 2.0)) if z.size else float("nan") |
| 94 | + metrics[names[i]] = {"ks": ks, "cov1sigma": cov1, "cov2sigma": cov2} |
| 95 | + plt.plot( |
| 96 | + pit_sorted, ecdf, |
| 97 | + label=f"{names[i]} (KS={ks:.3f}, 1σ:{cov1:.0%}, 2σ:{cov2:.0%})", |
| 98 | + ) |
| 99 | + |
| 100 | + plt.xlabel(r"PIT $\Phi((y-\mu)/\sigma)$") |
| 101 | + plt.ylabel("Empirical CDF") |
| 102 | + plt.xlim(0, 1) |
| 103 | + plt.ylim(0, 1) |
| 104 | + plt.grid(True, ls=":") |
| 105 | + plt.legend(loc="upper left", fontsize=9) |
| 106 | + if title is None: |
| 107 | + title = "σ-calibration P–P plot" |
| 108 | + if epoch is not None: |
| 109 | + title += f" — epoch {epoch}" |
| 110 | + plt.title(title) |
| 111 | + |
| 112 | + if save and export_dir is not None: |
| 113 | + outdir = os.path.join(export_dir, "calibration") |
| 114 | + os.makedirs(outdir, exist_ok=True) |
| 115 | + plt.savefig( |
| 116 | + os.path.join(outdir, f"pp_calibration_epoch_{epoch}.png"), |
| 117 | + dpi=150, bbox_inches="tight", |
| 118 | + ) |
| 119 | + plt.close() |
| 120 | + elif save: |
| 121 | + plt.close() |
| 122 | + else: |
| 123 | + plt.show() |
| 124 | + plt.close() |
| 125 | + |
| 126 | + return metrics |
0 commit comments