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