Skip to content

Commit ef4194f

Browse files
committed
Add ensemble calibration diagnostics to inst/ilamb
Adds a module of ensemble calibration diagnostics (rank histogram, spread-skill ratio, coverage, reliability) that assess whether an ensemble's spread is well calibrated against observations, complementing the existing ensemble-mean benchmarking. Includes synthetic-ensemble tests, an example driver, and an optional plotting script.
1 parent 9c6ab9b commit ef4194f

7 files changed

Lines changed: 770 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ For more information about this file see also [Keep a Changelog](http://keepacha
99
## Unreleased
1010

1111
### Added
12+
- Added ensemble calibration diagnostics to `inst/ilamb/` in PEcAn.benchmark (rank histogram, spread-skill ratio, coverage, reliability) for assessing whether an ensemble's spread is well calibrated against observations, complementing the ensemble-mean benchmarking.
1213
- Added `make_scorecard.sh` and documentation to `inst/ilamb/` in PEcAn.benchmark for generating and serving the ILAMB HTML scorecard.
1314
- New function `PEcAn.utils::netcdf2df()` flattens all dims and vars of a netCDF into a dataframe,
1415
with units attached as an attribute.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Ensemble Calibration Diagnostics
2+
3+
Diagnostics for assessing whether an ensemble's *spread* is trustworthy, not
4+
just whether its *mean* is accurate. This complements the ILAMB benchmarking
5+
already in this directory, which scores ensemble-mean skill. Here we ask a
6+
different question: when a model reports uncertainty through an ensemble, is that
7+
uncertainty well calibrated against observations?
8+
9+
## Why this matters
10+
11+
Carbon reanalyses and multi-model ensembles increasingly report ensemble spread
12+
as an uncertainty estimate. A skillful mean is not enough. If the spread is too
13+
narrow the product is overconfident and the observation will routinely fall
14+
outside the ensemble range. If it is too wide the product is underconfident.
15+
These diagnostics quantify where an ensemble sits on that spectrum.
16+
17+
## The diagnostics
18+
19+
All functions take `members` (a NumPy array of shape `[n_members, ...]`) and
20+
`obs` (an array matching the per-cell shape). Missing values in either are
21+
handled by masking to cells where the observation and all members are present.
22+
23+
- **`rank_histogram(members, obs)`** — the histogram of where each observation
24+
falls within its sorted ensemble. A flat histogram indicates calibration, a
25+
U shape indicates overconfidence (the observation keeps landing outside the
26+
ensemble), and a dome indicates underconfidence.
27+
28+
- **`spread_skill(members, obs)`** — the ratio of ensemble spread to ensemble
29+
error, with a small-sample correction. A ratio near 1 indicates calibration,
30+
well below 1 indicates overconfidence, and above 1 indicates underconfidence.
31+
This ratio is the metric to use when comparing ensembles of different sizes,
32+
because it carries the sqrt((n+1)/n) correction (see the note below).
33+
34+
- **`coverage(members, obs, interval=None)`** — the fraction of observations
35+
that fall inside the ensemble range, or inside a central interval if one is
36+
given (for example `interval=0.9` for the central 90 percent). For a
37+
calibrated ensemble this should match the expected value, which for the full
38+
range is (n-1)/(n+1).
39+
40+
- **`reliability(members, obs, threshold)`** — for a chosen threshold, compares
41+
the ensemble's forecast probability of exceeding it against the observed
42+
frequency. A calibrated ensemble lies on the diagonal.
43+
44+
## A note on comparing ensembles of different sizes
45+
46+
Fixed-interval coverage is not directly comparable across ensembles with very
47+
different member counts. A small ensemble cannot resolve, say, a 90 percent
48+
central interval, so its coverage is legitimately below the nominal target even
49+
when the ensemble is calibrated. When comparing a large ensemble against a small
50+
one, prefer the bias-corrected `spread_skill` ratio, which is designed to be
51+
fair across sizes. The test suite documents this behavior explicitly.
52+
53+
## Usage
54+
55+
```python
56+
import numpy as np
57+
import ensemble_calibration as ec
58+
59+
# members: shape [n_members, n_cells], obs: shape [n_cells]
60+
ratio = ec.spread_skill(members, obs)["ratio"]
61+
cov = ec.coverage(members, obs, interval=0.9)["coverage"]
62+
counts, edges = ec.rank_histogram(members, obs)
63+
64+
if ratio < 0.8:
65+
print("ensemble is overconfident relative to these observations")
66+
elif ratio > 1.2:
67+
print("ensemble is underconfident")
68+
else:
69+
print("ensemble spread is well sized")
70+
```
71+
72+
See `example_calibration.py` for a full example that loads model output and
73+
benchmark fields and reports the diagnostics for several variables.
74+
75+
## Testing
76+
77+
`test_ensemble_calibration.py` validates every diagnostic against synthetic
78+
ensembles whose calibration is known by construction. An ensemble built to be
79+
overconfident must be flagged as overconfident, a calibrated one must read as
80+
calibrated, and so on. The suite also checks that the calibration verdict is
81+
stable across member counts from 8 to 100, and that the diagnostics handle
82+
ragged, partially missing model output without error. Run with:
83+
84+
```
85+
python test_ensemble_calibration.py
86+
```
87+
88+
## Visualizing the results
89+
90+
`plot_calibration.py` produces a two-panel figure summarizing the calibration
91+
comparison: panel (a) the bias-corrected spread-to-error ratio per variable,
92+
and panel (b) the fraction of observations inside the ensemble 90 percent band.
93+
Both panels are comparable across ensembles of different sizes. An example of
94+
the output is shown below.
95+
96+
![Calibration comparison](calibration_figure.png)
97+
98+
The plotting script additionally requires matplotlib and xarray. The core
99+
diagnostics in `ensemble_calibration.py` remain pure NumPy and have no plotting
100+
or file-format dependencies.
71.2 KB
Loading
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""
2+
Ensemble calibration diagnostics for PEcAn benchmarking.
3+
4+
The multi-model comparison in the ILAMB benchmarking evaluates ensemble MEANS:
5+
is the average field accurate? These diagnostics ask a different question, is
6+
the ensemble SPREAD the right size? An ensemble can have an accurate mean yet
7+
be overconfident (spread too narrow for its error) or underconfident (spread
8+
too wide).
9+
10+
Four diagnostics are provided:
11+
12+
rank_histogram For each grid cell, the rank of the observation among the
13+
sorted ensemble members. Aggregated over cells, a flat
14+
histogram indicates calibration; a U shape indicates the
15+
ensemble is overconfident (truth falls outside the members
16+
too often); a dome indicates underconfidence.
17+
18+
spread_skill Ensemble spread (standard deviation across members) versus
19+
ensemble error (absolute deviation of the mean from the
20+
observation). For a well-calibrated ensemble these are
21+
comparable in magnitude; spread much smaller than error
22+
indicates overconfidence.
23+
24+
coverage Fraction of grid cells where the observation falls within
25+
the ensemble range (min to max, or a central interval).
26+
Reported against the value expected for a calibrated
27+
ensemble of the same size.
28+
29+
reliability For an event defined by a threshold, the ensemble forecast
30+
probability (fraction of members exceeding it) binned
31+
against the observed frequency of the event. A calibrated
32+
ensemble lies on the diagonal.
33+
34+
A stochastic-member ensemble (such as a PEcAn data-assimilation reanalysis) is
35+
a true ensemble, so these diagnostics apply to it directly. A multi-model
36+
ensemble (different models, not draws from one distribution) can be scored the
37+
same way, but the diagnostics then test whether that inter-model spread behaves
38+
like a calibrated uncertainty distribution, which is a distinct and largely
39+
untested question.
40+
41+
All functions take numpy arrays. Members are stacked on the first axis:
42+
`members` has shape (n_members, ...) and `obs` has shape (...), matching the
43+
trailing dimensions. NaNs (missing cells) are handled by masking to cells
44+
where the observation and all members are finite.
45+
"""
46+
47+
import numpy as np
48+
49+
50+
def _valid_mask(members, obs):
51+
"""Cells where obs and every member are finite."""
52+
return np.isfinite(obs) & np.all(np.isfinite(members), axis=0)
53+
54+
55+
def rank_histogram(members, obs, n_bins=None):
56+
"""
57+
Rank of the observation within the ensemble, per valid cell.
58+
59+
Returns (counts, edges): `counts` has length n_members + 1 (the number of
60+
possible ranks), giving how many observations fell at each rank. Ties are
61+
broken by adding a small uniform jitter, the standard treatment so ties do
62+
not artificially pile up at one rank. The jitter uses a fixed seed so the
63+
result is reproducible.
64+
"""
65+
members = np.asarray(members, dtype=float)
66+
obs = np.asarray(obs, dtype=float)
67+
n = members.shape[0]
68+
mask = _valid_mask(members, obs)
69+
70+
m = members[:, mask]
71+
o = obs[mask]
72+
73+
rng = np.random.default_rng(0)
74+
jitter = rng.uniform(-1e-9, 1e-9, size=m.shape)
75+
mj = m + jitter
76+
oj = o + rng.uniform(-1e-9, 1e-9, size=o.shape)
77+
ranks = (mj < oj).sum(axis=0)
78+
79+
counts = np.bincount(ranks, minlength=n + 1)
80+
edges = np.arange(n + 2)
81+
return counts, edges
82+
83+
84+
def spread_skill(members, obs):
85+
"""
86+
Ensemble spread versus error.
87+
88+
Returns a dict with:
89+
spread mean over cells of the ensemble standard deviation
90+
rmse root mean square error of the ensemble mean vs obs
91+
ratio spread / rmse (near 1 for a well-calibrated ensemble;
92+
much less than 1 indicates overconfidence)
93+
The spread is scaled by sqrt((n+1)/n) so it is comparable to the error of
94+
the mean for a finite ensemble.
95+
"""
96+
members = np.asarray(members, dtype=float)
97+
obs = np.asarray(obs, dtype=float)
98+
n = members.shape[0]
99+
mask = _valid_mask(members, obs)
100+
101+
m = members[:, mask]
102+
o = obs[mask]
103+
mean = m.mean(axis=0)
104+
std = m.std(axis=0, ddof=1)
105+
106+
spread = float(np.sqrt((n + 1) / n) * std.mean())
107+
rmse = float(np.sqrt(np.mean((mean - o) ** 2)))
108+
ratio = spread / rmse if rmse > 0 else float("nan")
109+
return {"spread": spread, "rmse": rmse, "ratio": ratio}
110+
111+
112+
def coverage(members, obs, interval=None):
113+
"""
114+
Fraction of cells where obs falls within the ensemble range.
115+
116+
interval=None uses the full member min-max. Otherwise pass a central
117+
fraction (e.g. 0.9) to use that central interval via member quantiles.
118+
Returns a dict with the observed coverage and, for the full range, the
119+
value expected for a calibrated ensemble of this size: (n-1)/(n+1).
120+
"""
121+
members = np.asarray(members, dtype=float)
122+
obs = np.asarray(obs, dtype=float)
123+
n = members.shape[0]
124+
mask = _valid_mask(members, obs)
125+
126+
m = members[:, mask]
127+
o = obs[mask]
128+
129+
if interval is None:
130+
lo = m.min(axis=0)
131+
hi = m.max(axis=0)
132+
expected = (n - 1) / (n + 1)
133+
else:
134+
q = (1 - interval) / 2
135+
lo = np.quantile(m, q, axis=0)
136+
hi = np.quantile(m, 1 - q, axis=0)
137+
expected = interval
138+
139+
inside = float(np.mean((o >= lo) & (o <= hi)))
140+
return {"coverage": inside, "expected": expected, "n_members": n}
141+
142+
143+
def reliability(members, obs, threshold, n_bins=10):
144+
"""
145+
Reliability of the ensemble probability for an event obs > threshold.
146+
147+
The forecast probability at each cell is the fraction of members exceeding
148+
the threshold. Cells are grouped into probability bins; for each bin the
149+
mean forecast probability and the observed event frequency are returned.
150+
A calibrated ensemble lies on the diagonal (forecast == observed).
151+
152+
Returns a dict with bin_prob (mean forecast probability per bin),
153+
obs_freq (observed frequency per bin), and count (cells per bin).
154+
"""
155+
members = np.asarray(members, dtype=float)
156+
obs = np.asarray(obs, dtype=float)
157+
mask = _valid_mask(members, obs)
158+
159+
m = members[:, mask]
160+
o = obs[mask]
161+
p = (m > threshold).mean(axis=0)
162+
y = (o > threshold).astype(float)
163+
164+
edges = np.linspace(0, 1, n_bins + 1)
165+
idx = np.clip(np.digitize(p, edges) - 1, 0, n_bins - 1)
166+
167+
bin_prob = np.full(n_bins, np.nan)
168+
obs_freq = np.full(n_bins, np.nan)
169+
count = np.zeros(n_bins, dtype=int)
170+
for b in range(n_bins):
171+
sel = idx == b
172+
count[b] = sel.sum()
173+
if count[b] > 0:
174+
bin_prob[b] = p[sel].mean()
175+
obs_freq[b] = y[sel].mean()
176+
return {"bin_prob": bin_prob, "obs_freq": obs_freq, "count": count}

0 commit comments

Comments
 (0)