| """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 |
|
|
|
|
| |
| 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) |
| h_under = np.where(h < 1 - alpha, h, 1 - alpha) |
| return ert(h_over, z, alpha, kind), ert(h_under, z, alpha, kind) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| 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: |
| 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) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| 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) |
|
|
|
|
| |
| 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)) |
|
|