Skip to main content
Split conformal treats every calibration residual as equally informative about the next one. That is exactly what exchangeability buys, and covariate shift is the assumption failing in the most common way: the steps you are about to predict do not look like the steps you calibrated on. A model calibrated on a quiet, low-volume stretch of a series is asked about a high-volume one; a panel calibrated on one set of stores is asked about another. WeightedSplit keeps the whole split-conformal construction and changes exactly one thing: each calibration score gets a weight saying how likely its features are under the target distribution rather than the source one. Nothing about the forecaster changes, and no target labels are needed.

What it does

Write x for the features of a predicted step. The importance weight is the density ratio
Calibration scores s_1 ... s_n are then normalized including the test point’s own weight, and the interval takes the smallest score whose cumulative normalized weight reaches 1 - alpha:
The interval is [y_hat - q, y_hat + q], one q per (series, sample, horizon) cell — the quantile now moves with the test point, which the unweighted method’s does not. Set every w to 1 and the w(x_test) term becomes the familiar +1 of k = ceil((1 - alpha)(n + 1)), recovering split conformal exactly. That is not a metaphor: before adapt() binds a weight function, WeightedSplit is bit-identical to SplitConformal, and a test asserts it. The ratio itself is estimated, not known. DensityRatio labels source rows 0 and target rows 1, fits any binary classifier you hand it, and reads the ratio off the odds p / (1 - p). Calibration weights are computed out of fold, so a calibration point’s weight never comes from a model that trained on it.

Worked example

AirPassengers has one property that makes it a clean covariate-shift test: its error scale grows with its level. So log of the last twelve months’ mean is a real driver of how large the next residual will be — a genuine covariate, not a label in disguise. The shift is built by splitting one-step-ahead windows on that feature: each window goes to calibration or holdout with a probability that rises with its level. Both halves keep full support — high-level windows appear in calibration too, just rarely — which is the regime weighted conformal is built for, and the regime it needs. A source that never saw the target’s features cannot be reweighted into it.
The two halves are the same series, the same forecaster and the same horizon. Only the feature distribution differs — which is the definition of covariate shift, and the only thing weighting can repair.

Calibrate, then adapt

calibrate(forecaster, histories, truths) is the ordinary source-domain verb: it fits the conformal quantile on source forecasts and source truths. adapt(weighting=..., target_X=...) is the transfer verb, called on the fitted object calibrate returned: it hands a weight function the features of the target domain — no target truths — and returns a new fitted object with the quantile rebuilt, leaving the source fit untouched. See Transfer and adaptation for the full set of verbs.
Uniform weights under-cover: 78.2% against a 90% target, at a mean width of 104.0. The calibration set is dominated by low-level windows whose residuals are small, so the band is too narrow for the high-level windows it is asked about. Density-ratio weights push mass onto the calibration windows that resemble the target, and coverage climbs to 89.1% — a shade under nominal, well within what 65 calibration windows can resolve — at a mean width of 116.3. That trade is the whole method: the extra width is not waste, it is the width the target regime always needed.

Clipping, and how it can cost you coverage

An estimated density ratio is unbounded. One calibration point whose classifier odds come out at 300 can carry more weight than the rest of the panel put together, and the weighted quantile then rests on a single residual. DensityRatio guards against that by clipping weights at their clip_quantile, default 0.99. The guard is not free. Under a strong shift the typical target weight is already large, so a clip calibrated on the source weights binds on most of the test points — and every clipped point is one whose weighting signal was thrown away. The sweep below is the synthetic covariate-shift benchmark this package tests against: feature-dependent noise, calibration drawn at loc=0 and test at loc=2.5, 90% target.
Read that table before accepting the default. Weighting is doing its job — with clipping off, coverage goes from 0.6520 to 0.9167 against a 0.90 target. But at the default clip_quantile=0.99 the same weights deliver only 0.8213, because under this much shift the median test weight saturates at the clip value: the guard binds on 59% of the points and discards most of the signal it was meant to protect. Loosening it to 0.999 recovers part of the gap, at 0.8692.
Tune clip_quantile against measured coverage clip_quantile=0.99 is a safe default for a mild shift and a poor one for a strong shift. If your weights are large across the board, the clip is not trimming outliers — it is flattening the whole weighting. Check the adapted object’s calibration_.diagnostics["weight_summary"]: when the median sits at the maximum, the clip is binding everywhere.

When not to use it

  • The shift is in P(y|x), not P(x). Weighting reweights calibration points by how likely their features are in the target domain. It assumes the conditional error distribution given those features is unchanged. If the relationship itself moved — a new pricing rule, a changed measurement process, a forecaster that has gone stale — no reweighting of the old residuals describes the new ones. That is a job for recalibration on target labels if you have them, or an online method if you do not.
  • The density ratio is badly estimated. The weights are the output of a classifier, and a bad classifier gives confidently wrong weights. Too flexible and it memorises which rows are source and which are target, sending the ratio to zero and infinity; too rigid and it returns a constant, which is uniform weighting under another name. Clipping bounds the damage without removing it — and as the sweep above shows, the clip has its own failure mode.
  • The supports do not overlap. If no calibration window looks anything like the target, d_source(x) is near zero where the target lives, and the ratio is an extrapolation dressed up as a weight. Weighting redistributes evidence; it cannot manufacture it.
  • You have target labels. If you can observe target (prediction, truth) pairs, calibrating on them directly is strictly better than reweighting a proxy. Use recalibrate().
  • You have no features at all. With X=None the weight function cannot fit and WeightedSplit is plain split conformal. Reach for NexCP instead, which down-weights by recency rather than by features and needs no covariates.

References

  • R. J. Tibshirani, R. F. Barber, E. J. Candes, and A. Ramdas, “Conformal prediction under covariate shift,” Advances in Neural Information Processing Systems 32, 2019. https://arxiv.org/abs/1904.06019

Next