conformalforecast offers for that,
and how to pick between them by asking one question: what
target-domain data do you actually have?
Three verbs
All three return a new fitted object.
calibrate is called on the
method (the configuration); recalibrate and adapt are called on a
fitted object and leave it untouched, so the source-domain object stays
available next to its target-domain counterpart.
recalibrate() is calibrate() pointed at a different panel. The
forecaster is never refitted: looping predict_batch over target
histories and scoring against target truths is the frozen backtest
transfer learning wants. It exists as a separate name for intent, and it
marks calibration_.diagnostics["path"] = "recalibrate" on the object
it returns, so a log can tell the two apart.
adapt() exists because the other strategies have nothing to
calibrate on. Density-ratio weighting needs only the target’s
features; scale alignment needs only the target’s history. Neither can
be expressed as a call to calibrate, because neither has a single
target (prediction, truth) pair to score. adapt is the entry point
for the label-free half of the problem, and it is idempotent: it
always rebuilds from the stored source scores, so calling it twice is
the same as calling it once.
Which strategy your data allows
Work down it and stop at the first row your data supports. Labelled
target pairs beat everything below them: they measure the target’s
errors instead of estimating them from a proxy. The bottom three rows
are what you reach for when that measurement does not exist yet.
All four run through
WeightedSplit, whose
fitted object is the one that implements SupportsTransfer today.
isinstance(fitted, SupportsTransfer) says whether a given fitted
object can adapt at all; on every other one, adapt() does not exist,
and calling recalibrate() still works identically.
The panel
Eight M4 hourly series, which differ in level by three orders of magnitude —H50 averages around 35,000 units an hour and H200 around
20. Six are the source domain; two are held back as a target domain the
calibration never saw.
Row 1 — labelled target pairs: recalibrate
The easy case, and the one to take whenever it is available. You have
run the frozen forecaster over target history and observed what
followed, so the target’s own residuals exist and can be scored
directly.
There is one constraint. A ForecasterAdapter fixes n_series at
construction, so a source adapter built for six series cannot be handed
a two-series target panel. recalibrate checks this and says so:
recalibrate is called on a fitted object, which
carries the forecaster bound by the calibrate / calibrate_cv call
that produced it — there is no forecaster-at-construction in this API —
so the first fit on a brand-new adapter has to go through calibrate
itself, and calibration_.diagnostics["path"] reads "loop".
recalibrate then reuses that binding and returns a new object marked
"recalibrate". Below, the second call repeats the same panel just to
show the naming boundary; in practice it would point at a later
target-domain window.
Row 3 — target history only: scale alignment and pooling="global"
Now delete the target truths. All that is left is the target series
itself: raw observations, no forecasts, no residuals. What that still
tells you is the target’s scale — how much it moves hour to hour —
and scale is most of what an interval width is.
scale_estimator="mad" (or "std", or "iqr") makes WeightedSplit
record a per-series scale, computed on first differences so a level or a
slow trend does not inflate it. fitted.adapt(target_series=...) then
computes the same statistic on the target history and returns a new
fitted object whose quantile is the source quantile rescaled by the
ratio.
That still leaves the series-count problem: a per-series quantile fitted
on six source series has nothing to say about a seventh.
pooling="global" is the answer — it pools scale-normalized calibration
scores across the series axis into one quantile per horizon step. That
pooled quantile has one row per horizon, not one per target series, so
it is adapt(target_series=...) that widens it: rescaling by a
per-target-series ratio is what gives it a row per target series, and
only then can a panel of any width be served. interval on a panel the
quantile has not been widened to raises rather than broadcasting — which
is why the demo below adapts before it predicts.
pooling="global" requires a scale_estimator, and refuses to be
constructed without one:
H50 moves in thousands and H200 in
single units, so a pooled unnormalized quantile is dominated entirely
by H50:
recalibrate measured the target’s actual errors and
produced a band around 0.44 units wide, landing at 87.5% — a shade under
nominal, which 20 calibration windows can easily be. Scale alignment
inferred the target’s volatility from its raw history and transported
the source panel’s normalized error distribution onto it. The source
forecaster is relatively worse on its own six series than on these two,
so the transported quantile is conservative: it covers 100% of the
holdout with a band about thirty times wider (13.64 vs 0.44).
That is the honest summary of this row: it produces a valid, usable
interval where no calibration was possible at all, and it is not
competitive with labelled calibration. Move up the table as soon as
target labels exist.
adaptnever touches the forecasteradaptchanges the conformal quantile only. The point forecasts above still come from the same frozen seasonal-naive rule. If the forecaster is what has gone stale in the target domain, no amount of adaptation to its residuals will fix the forecast — it will only widen the band around a bad one.
Rows 2 and 4 — target features: adapt(weighting=..., target_X=...)
The remaining rows use features.
adapt(weighting=DensityRatio(...), target_X=...) fits a copy of the
weight function on the features of the steps you are about to predict;
DensityRatio fits a classifier that separates source rows from target
rows and reads the importance weight off its odds. The weighted split
conformal page works through a case with a
real shift; here the point is what the call looks like, and what it does
when there is no shift to correct.
The features are two per predicted step: the log of the last 24 hours’
level, and how far into the horizon the step sits. Source is the
calibration block, target is the same six series eight windows later.
adapt
left unweighted untouched and returned weighted as a separate
object, which is what lets the two rows be scored side by side.) The
weight summary shows why: every weight lands between 0.357 and 0.434, so
the classifier cannot tell a calibration window from a holdout window.
There is no covariate shift here to correct, and weighting a set of
scores almost uniformly picks out the same order statistic the
unweighted quantile did.
Weighting is not free insurance, but it is close: when the shift is
absent it degenerates gracefully toward split conformal rather than
damaging a working interval. The failure mode to watch is the opposite
one — a confident and wrong density ratio — which the method page’s
clipping section measures.
Row 4 is the two mechanisms together. They compose because they act on
different parts of the same quantile: weights choose which calibration
scores count, the scale ratio decides what units the resulting
quantile is in.
pooling="global"and test-point weights do not combine The per-test-point weighted quantile — the one that putsw(x_test)in the denominator, per Tibshirani et al. (2019) — runs only underpooling="per_series". Underpooling="global"the weights still shape the pooled calibration quantile, but the test point’s own weight is ignored and one quantile per horizon step is applied to every sample. So the two headline transfer features are individually available and not jointly: weighting with per-test-point normalization, or serving a target panel with a different series count. Choose per problem.
What adaptation cannot do
- A shift in
P(y | x). Both mechanisms assume the conditional error distribution is stable and only the inputs moved. If the relationship itself changed — a new process, a stale model, a structural break — reweighting old residuals describes the old world more precisely. Get target labels, or use an online method that learns from its own misses. - Missing overlap. A density ratio is only estimable where both domains have support. If no source window resembles the target, the weights are an extrapolation with a confident face.
- A degenerate target.
compute_series_scalesraises on a constant series: a flat history carries no scale information, so there is nothing to align to. - Anything on another method.
SupportsTransferis implemented byWeightedSplit’s fitted object only. Every other method has noadapt()at all — extending transfer to them is recorded in the roadmap, not shipped.
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
- Weighted split conformal — the method behind all four rows, with a worked covariate shift.
- Diagnostics — coverage and width together, which is how the rows above should be compared.
- Online updating — the other answer to a moving world.

