Skip to content

Commit 80a43f3

Browse files
committed
Add P-P (PIT) sigma-calibration plot for heteroscedastic heads with tests
1 parent 8760139 commit 80a43f3

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

sage/plotting/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .output_uncertainty import plot_output_vs_uncertainty
4242
from .param_distribution import plot_outputbin_param_distribution
4343
from .parameter_recovery import plot_param_recovery_heatmap
44+
from .pp_calibration import plot_pp_calibration
4445
from .paramfrac_above_thresh import plot_paramfrac_detected_above_thresh
4546
from .perturbation_sensitivity import plot_perturbation_sensitivity
4647
from .prediction_probability import plot_prediction_probability

sage/plotting/pp_calibration.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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

tests/test_pp_calibration.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for the P-P (PIT) sigma-calibration plot."""
2+
3+
import numpy as np
4+
5+
import matplotlib
6+
matplotlib.use("Agg") # headless
7+
8+
from sage.plotting import plot_pp_calibration
9+
10+
11+
def _data(scale, n=20000, seed=0):
12+
rng = np.random.default_rng(seed)
13+
mu = rng.normal(0, 1, (n, 2))
14+
sigma = np.abs(rng.normal(1, 0.2, (n, 2))) + 0.5
15+
y = mu + scale * sigma * rng.standard_normal((n, 2))
16+
return mu, sigma, y
17+
18+
19+
def test_calibrated_is_near_diagonal():
20+
m = plot_pp_calibration(*_data(1.0), param_names=["tc", "mchirp"], save=True)
21+
for v in m.values():
22+
assert v["ks"] < 0.02
23+
assert abs(v["cov1sigma"] - 0.68) < 0.03
24+
assert abs(v["cov2sigma"] - 0.955) < 0.02
25+
26+
27+
def test_overconfident_has_low_coverage_and_large_ks():
28+
m = plot_pp_calibration(*_data(2.0), param_names=["tc", "mchirp"], save=True)
29+
for v in m.values():
30+
assert v["ks"] > 0.1
31+
assert v["cov1sigma"] < 0.5 # sigma too small -> under-covers
32+
33+
34+
def test_underconfident_has_high_coverage():
35+
m = plot_pp_calibration(*_data(0.5), param_names=["tc", "mchirp"], save=True)
36+
for v in m.values():
37+
assert v["ks"] > 0.1
38+
assert v["cov1sigma"] > 0.9 # sigma too large -> over-covers
39+
40+
41+
def test_accepts_1d_input():
42+
rng = np.random.default_rng(1)
43+
mu = rng.normal(0, 1, 5000)
44+
sigma = np.ones(5000)
45+
y = mu + sigma * rng.standard_normal(5000)
46+
m = plot_pp_calibration(mu, sigma, y, save=True)
47+
assert len(m) == 1
48+
49+
50+
if __name__ == "__main__":
51+
for name, fn in sorted(globals().items()):
52+
if name.startswith("test_") and callable(fn):
53+
fn()
54+
print(f" PASS {name}")
55+
print(">>> ALL P-P CALIBRATION TESTS PASSED <<<")

0 commit comments

Comments
 (0)