Skip to content

Commit 132c9fb

Browse files
authored
Covariate Shift Conformal Prediction Fixes (#1180)
* Covariate CP fixes * small edits to pass checks
1 parent 546c1ad commit 132c9fb

5 files changed

Lines changed: 105 additions & 26 deletions

File tree

docs/api/calib.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ confidence levels:
2121
- :class:`~pyhealth.calib.predictionset.LABEL`: Conformal prediction with bounded error
2222
- :class:`~pyhealth.calib.predictionset.SCRIB`: Class-specific risk control
2323
- :class:`~pyhealth.calib.predictionset.FavMac`: Value-maximizing sets with cost control
24-
- :class:`~pyhealth.calib.predictionset.CovariateLabel`: Covariate shift adaptive conformal
24+
- :class:`~pyhealth.calib.predictionset.CovariateLabel`: Covariate shift adaptive conformal prediction with a finite-sample correction for the calibration/test weighting
2525
- :class:`~pyhealth.calib.predictionset.ClusterLabel`: K-means cluster-based conformal prediction
2626
- :class:`~pyhealth.calib.predictionset.NeighborhoodLabel`: Neighborhood Conformal Prediction (NCP)
2727

examples/cxr/covid19cxr_conformal.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@
171171
print("\nCreating CovariateLabel predictor...")
172172
covariate_predictor = CovariateLabel(model=resnet, alpha=alpha)
173173

174-
# Calibrate with embeddings (KDEs will be fitted automatically)
174+
# Calibrate with embeddings (KDEs will be fitted automatically). The
175+
# calibration weights include a finite-sample correction for the test point.
175176
print("Calibrating CovariateLabel predictor...")
176177
print(" - Fitting KDEs for covariate shift correction...")
177178
covariate_predictor.calibrate(

pyhealth/calib/predictionset/base_conformal/__init__.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,17 @@ class BaseConformal(SetPredictor):
109109
alpha: Target miscoverage rate(s). Can be:
110110
- float: marginal coverage P(Y not in C(X)) <= alpha
111111
- array: class-conditional P(Y not in C(X) | Y=k) <= alpha[k]
112-
score_type: Type of conformity score to use. Options:
113-
- "aps": Adaptive Prediction Sets (default, uses probability scores)
114-
- "threshold": Simple threshold on probabilities
112+
score_type: Type of conformity score to use. Currently only one score
113+
is implemented:
114+
- "threshold" (default): NC score = 1 - p(true class), the score
115+
from Sadinle, Lei, and Wasserman (2019) ("LABEL").
116+
- "aps": accepted as a backward-compatible alias for
117+
"threshold". Despite the name, this does **not** implement
118+
Adaptive Prediction Sets (Romano, Sesia, and Candes 2020) --
119+
that method uses a different score (cumulative sorted class
120+
probabilities) which is not implemented here. If you need
121+
genuine APS, do not rely on this option; it is kept only so
122+
existing calls with ``score_type="aps"`` keep working.
115123
debug: Whether to use debug mode (processes fewer samples)
116124
117125
Examples:
@@ -158,7 +166,7 @@ def __init__(
158166
self,
159167
model: BaseModel,
160168
alpha: Union[float, np.ndarray],
161-
score_type: str = "aps",
169+
score_type: str = "threshold",
162170
debug: bool = False,
163171
**kwargs,
164172
) -> None:
@@ -201,8 +209,7 @@ def _compute_nc_scores(
201209
Non-conformity scores of shape (N,) — higher means less conforming.
202210
"""
203211
N = len(y_true)
204-
if self.score_type == "aps" or self.score_type == "threshold":
205-
# NC score = 1 - p(true class); higher = less conforming
212+
if self.score_type == "threshold" or self.score_type == "aps":
206213
scores = 1.0 - y_prob[np.arange(N), y_true]
207214
else:
208215
raise ValueError(f"Unknown score_type: {self.score_type}")

pyhealth/calib/predictionset/covariate/covariate_label.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -170,25 +170,49 @@ def _compute_likelihood_ratio(
170170

171171

172172
def _query_weighted_quantile(
173-
scores: np.ndarray, alpha: float, weights: np.ndarray
173+
scores: np.ndarray,
174+
alpha: float,
175+
weights: np.ndarray,
176+
test_weight: float = 0.0,
174177
) -> float:
175-
"""Compute weighted quantile of scores.
178+
"""Compute the weighted conformal quantile of scores.
179+
180+
Implements the finite-sample correction for weighted conformal prediction
181+
under covariate shift (Tibshirani et al. 2019).
176182
177183
Args:
178-
scores: Array of conformity scores
179-
alpha: Quantile level (between 0 and 1)
180-
weights: Weights for each score
184+
scores: Array of conformity scores (higher = more conforming).
185+
alpha: Quantile level (between 0 and 1).
186+
weights: Un-normalized weights (likelihood ratios) for each score.
187+
test_weight: Un-normalized weight representing the test point.
188+
Reserves ``test_weight / (sum(weights) + test_weight)`` of
189+
probability mass at conformity ``-inf``. Default 0.0 recovers the
190+
old, uncorrected behavior (kept for backward compatibility with
191+
direct callers of this helper).
181192
182193
Returns:
183-
The weighted alpha-quantile of scores
194+
The weighted alpha-quantile of scores. Returns ``-inf`` if the
195+
reserved test-point mass alone already meets or exceeds ``alpha``,
196+
since there isn't enough calibration mass to justify a stricter,
197+
finite threshold without risking under-coverage.
184198
"""
185-
# Sort scores and corresponding weights
186199
sorted_indices = np.argsort(scores)
187200
sorted_scores = scores[sorted_indices]
188201
sorted_weights = weights[sorted_indices]
189202

190-
# Compute cumulative weights
191-
cum_weights = np.cumsum(sorted_weights) / np.sum(sorted_weights)
203+
total_weight = np.sum(sorted_weights) + test_weight
204+
if total_weight <= 0:
205+
return -np.inf
206+
207+
p_test = test_weight / total_weight
208+
if p_test >= alpha:
209+
# Not enough calibration mass to reach the target coverage without
210+
# dipping into the mass reserved for the test point itself: fall
211+
# back to the maximally permissive (safe) threshold.
212+
return -np.inf
213+
214+
# Compute cumulative weights over the reserved-mass-inclusive total.
215+
cum_weights = np.cumsum(sorted_weights) / total_weight
192216

193217
# Find the index where cumulative weight exceeds alpha
194218
idx = np.searchsorted(cum_weights, alpha, side="left")
@@ -197,7 +221,7 @@ def _query_weighted_quantile(
197221
if idx >= len(sorted_scores):
198222
idx = len(sorted_scores) - 1
199223

200-
return sorted_scores[idx]
224+
return float(sorted_scores[idx])
201225

202226

203227
class CovariateLabel(SetPredictor):
@@ -458,29 +482,29 @@ def calibrate(
458482
self.kde_test, self.kde_cal, X
459483
)
460484

461-
# Normalize weights
462-
weights = likelihood_ratios / np.sum(likelihood_ratios)
485+
# Keep weights un-normalized here
463486
self._sum_cal_weights = np.sum(likelihood_ratios)
464487

465488
# Extract conformity scores (probabilities of true class)
466489
conformity_scores = y_prob[np.arange(N), y_true]
467490

468491
# Compute weighted quantile thresholds
469492
if isinstance(self.alpha, float):
470-
# Marginal coverage: single threshold
471-
t = _query_weighted_quantile(conformity_scores, self.alpha, weights)
493+
test_weight = float(np.mean(likelihood_ratios))
494+
t = _query_weighted_quantile(
495+
conformity_scores, self.alpha, likelihood_ratios, test_weight
496+
)
472497
else:
473498
# Class-conditional coverage: one threshold per class
474499
t = []
475500
for k in range(K):
476501
mask = y_true == k
477502
if np.sum(mask) > 0:
478503
class_scores = conformity_scores[mask]
479-
class_weights = weights[mask]
480-
# Renormalize class weights
481-
class_weights = class_weights / np.sum(class_weights)
504+
class_weights = likelihood_ratios[mask]
505+
class_test_weight = float(np.mean(class_weights))
482506
t_k = _query_weighted_quantile(
483-
class_scores, self.alpha[k], class_weights
507+
class_scores, self.alpha[k], class_weights, class_test_weight
484508
)
485509
else:
486510
# If no calibration examples, use -inf (include all)

tests/core/test_covariate_label.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,59 @@ def test_weighted_quantile_function(self):
317317
weights = np.array([0.1, 0.2, 0.3, 0.2, 0.2])
318318
alpha = 0.5
319319

320+
# Default test_weight=0.0 preserves the old (uncorrected) behavior
321+
# for any direct caller that doesn't opt into the finite-sample
322+
# correction.
320323
quantile = _query_weighted_quantile(scores, alpha, weights)
321324

322325
self.assertIsInstance(quantile, (float, np.floating))
323326
self.assertGreaterEqual(quantile, scores.min())
324327
self.assertLessEqual(quantile, scores.max())
325328

329+
def test_weighted_quantile_reserves_test_point_mass(self):
330+
"""The finite-sample correction should recover the standard (N+1)
331+
reserved-mass fraction in the no-shift limit (uniform weights,
332+
test_weight = mean of calibration weights)."""
333+
from pyhealth.calib.predictionset.covariate.covariate_label import (
334+
_query_weighted_quantile,
335+
)
336+
337+
N = 6
338+
weights = np.ones(N)
339+
test_weight = float(np.mean(weights))
340+
p_test = test_weight / (np.sum(weights) + test_weight)
341+
342+
self.assertAlmostEqual(p_test, 1.0 / (N + 1), places=10)
343+
344+
def test_weighted_quantile_small_calibration_set_is_conservative(self):
345+
"""With very few calibration examples relative to the requested
346+
alpha, the corrected quantile should fall back to -inf (maximally
347+
permissive / safe) rather than returning an overconfident finite
348+
threshold, since there isn't enough calibration mass to support the
349+
target coverage without dipping into the reserved test-point mass."""
350+
from pyhealth.calib.predictionset.covariate.covariate_label import (
351+
_query_weighted_quantile,
352+
)
353+
354+
scores = np.array([0.4, 0.6]) # N=2
355+
weights = np.array([1.1, 1.1])
356+
test_weight = float(np.mean(weights))
357+
358+
# 1/(N+1) = 1/3 ~= 0.333 > alpha=0.3, so there isn't enough
359+
# calibration mass to safely support this target.
360+
result = _query_weighted_quantile(scores, 0.3, weights, test_weight)
361+
self.assertEqual(result, -np.inf)
362+
363+
# A larger, adequately-sized calibration set at the same alpha
364+
# should NOT hit the same fallback.
365+
scores_large = np.linspace(0.0, 1.0, 50)
366+
weights_large = np.ones(50)
367+
test_weight_large = float(np.mean(weights_large))
368+
result_large = _query_weighted_quantile(
369+
scores_large, 0.3, weights_large, test_weight_large
370+
)
371+
self.assertTrue(np.isfinite(result_large))
372+
326373
def test_likelihood_ratio_function(self):
327374
"""Test the likelihood ratio computation."""
328375
from pyhealth.calib.predictionset.covariate.covariate_label import (

0 commit comments

Comments
 (0)