File size: 7,299 Bytes
1b7a999 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """ERT metrics for conditional coverage (arXiv:2512.11779v1).
Table 1 of the paper, transcribed:
name proper score l(p,y) l-ERT formula
L1-ERT sgn(p-(1-a)) (1-a-y) E_X |1-a - p(X)|
L2-ERT Brier, (y-p)^2 E_X (1-a - p(X))^2
KL-ERT log-loss, -log p_y E_X D_KL(p(X) || 1-a)
with l-ERT(h) := R_l(1-a) - R_l(h), i.e. the risk of the constant 1-a
predictor minus the risk of a fitted classifier h of the coverage indicator
Z = 1{y in C(x)}. Conditional coverage holds iff no classifier beats the
constant, so ERT >= 0 measures the violation.
Design choice for the synthetic experiments
-------------------------------------------
The data-generating process here is chosen so that the TRUE conditional
coverage p(x) is available in closed form. With y|x ~ N(f(x), s(x)^2) and a
symmetric interval [yhat(x) - q, yhat(x) + q],
p(x) = Phi((q + yhat(x) - f(x))/s(x)) - Phi((-q + yhat(x) - f(x))/s(x))
so the true L1, L2 and KL deviations can be computed to Monte-Carlo accuracy
and every estimate can be scored against ground truth rather than against
another estimate. The oracle interval [f(x) -+ z_{1-a/2} s(x)] gives
p(x) = 1-a exactly, which is the negative control every metric must return
(near) zero on.
"""
import numpy as np
from scipy.stats import norm
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
from sklearn.model_selection import KFold
from sklearn.tree import DecisionTreeClassifier
EPS = 1e-6
# ------------------------------------------------------------------- losses
def loss_L1(p, y, alpha):
return np.sign(p - (1 - alpha)) * ((1 - alpha) - y)
def loss_L2(p, y, alpha=None):
return (y - p) ** 2
def loss_KL(p, y, alpha=None):
p = np.clip(p, EPS, 1 - EPS)
return -(y * np.log(p) + (1 - y) * np.log(1 - p))
LOSSES = {"L1": loss_L1, "L2": loss_L2, "KL": loss_KL}
def ert(h, z, alpha, kind):
"""l-ERT(h) = R_l(1-a) - R_l(h)."""
const = np.full_like(np.asarray(h, float), 1 - alpha)
f = LOSSES[kind]
if kind == "L1":
return float(np.mean(f(const, z, alpha) - f(h, z, alpha)))
return float(np.mean(f(const, z) - f(h, z)))
def ert_signed(h, z, alpha, kind):
"""Asymmetric decomposition of Section 3.3: restrict the fitted
probability to over-coverage (h > 1-a) and under-coverage (h < 1-a),
replacing it by the constant elsewhere so each part is itself an ERT."""
h = np.asarray(h, float)
h_over = np.where(h > 1 - alpha, h, 1 - alpha) # conservatism
h_under = np.where(h < 1 - alpha, h, 1 - alpha) # aggressiveness
return ert(h_over, z, alpha, kind), ert(h_under, z, alpha, kind)
# --------------------------------------------------------- true (oracle) ERT
def true_ert(p, alpha, kind):
p = np.clip(np.asarray(p, float), EPS, 1 - EPS)
a = 1 - alpha
if kind == "L1":
return float(np.mean(np.abs(a - p)))
if kind == "L2":
return float(np.mean((a - p) ** 2))
return float(np.mean(p * np.log(p / a) + (1 - p) * np.log((1 - p) / (1 - a))))
def true_ert_signed(p, alpha, kind):
p = np.asarray(p, float)
a = 1 - alpha
over = np.where(p > a, p, a)
under = np.where(p < a, p, a)
return true_ert(over, alpha, kind), true_ert(under, alpha, kind)
# ------------------------------------------------------------------- the DGP
def f_mean(X):
return X[:, 0] + X[:, 1] ** 2
def s_sd(X, hetero=True):
if not hetero:
return np.full(len(X), 0.8)
return 0.4 + np.abs(X[:, 0]) + 0.5 * np.abs(X[:, 1])
def sample(n, d, rng, hetero=True, skew=0.0):
X = rng.uniform(-1, 1, size=(n, d))
e = rng.standard_normal(n)
if skew: # asymmetric law for the claim-4 experiment
e = e + skew * (X[:, 2] > 0) * np.abs(rng.standard_normal(n))
y = f_mean(X) + s_sd(X, hetero) * e
return X, y
def true_coverage(X, yhat, q, hetero=True):
"""Closed-form P(|y - yhat(x)| <= q | x) for the Gaussian DGP."""
s = s_sd(X, hetero)
c = yhat - f_mean(X)
return norm.cdf((q + c) / s) - norm.cdf((-q + c) / s)
# ------------------------------------------------------- conformal predictors
def split_conformal(Xtr, ytr, Xcal, ycal, alpha, seed=0):
"""Standard split conformal with a homoskedastic base model: the interval
width is constant, so conditional coverage is violated wherever s(x)
departs from its average."""
m = HistGradientBoostingRegressor(random_state=seed, max_iter=200).fit(Xtr, ytr)
res = np.abs(ycal - m.predict(Xcal))
n = len(res)
k = int(np.ceil((n + 1) * (1 - alpha)))
q = float(np.sort(res)[min(k, n) - 1])
return m, q
def coverage_indicator(y, yhat, q):
return (np.abs(y - yhat) <= q).astype(float)
# ------------------------------------------------------------ estimating ERT
def make_classifier(seed=0, model="hgb"):
"""The classifier used inside Algorithm 1.
The configuration matters more than it looks: an *untuned* boosted-tree
default (max_iter=200, no regularisation) is flexible enough to overfit the
Bernoulli coverage indicator out of fold, and then loses to the constant
1-alpha predictor on the strictly proper losses -- L2- and KL-ERT come out
NEGATIVE. A shallow, strongly regularised model with early stopping
recovers ~88% of the true L1 deviation instead. This is the paper's own
thesis (the classifier is what gives the metric its power) showing up as a
reproduction hazard.
"""
if model == "tree":
return DecisionTreeClassifier(random_state=seed)
return HistGradientBoostingClassifier(
random_state=seed, max_iter=400, learning_rate=0.03, max_leaf_nodes=7,
l2_regularization=5.0, min_samples_leaf=60, early_stopping=True,
validation_fraction=0.15)
def cv_predict(X, z, seed=0, folds=5, model="hgb"):
"""Algorithm 1: out-of-fold predictions so the classifier is never
evaluated on the points it was fitted on."""
out = np.empty(len(z), float)
kf = KFold(folds, shuffle=True, random_state=seed)
for tr, te in kf.split(X):
g = make_classifier(seed, model)
if len(np.unique(z[tr])) < 2:
out[te] = z[tr].mean()
continue
g.fit(X[tr], z[tr])
out[te] = g.predict_proba(X[te])[:, 1]
return np.clip(out, EPS, 1 - EPS)
def in_sample_predict(X, z, seed=0, model="tree"):
if len(np.unique(z)) < 2:
return np.full(len(z), z.mean())
g = make_classifier(seed, model)
g.fit(X, z)
return np.clip(g.predict_proba(X)[:, 1], EPS, 1 - EPS)
# --------------------------------------------------------------- CovGap
def covgap(X, z, alpha, n_groups=10, seed=0):
"""Group-conditional coverage gap: partition X and average the absolute
deviation of each group's empirical coverage from 1-alpha."""
rng = np.random.default_rng(seed)
from sklearn.cluster import KMeans
km = KMeans(n_clusters=n_groups, n_init=4, random_state=seed).fit(X)
g = km.labels_
gaps = []
for k in range(n_groups):
m = g == k
if m.sum() == 0:
continue
gaps.append(abs(z[m].mean() - (1 - alpha)))
return float(np.mean(gaps))
|