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