ProCreations's picture
Reproduction logbook (paper-vaApZm6MKM)
1b7a999 verified
Raw
History Blame Contribute Delete
7.3 kB
"""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))