Skip to main content
Each conformal method in conformalforecast is a pair of classes:
  • a method — SplitConformal, ConformalizedQuantileRegression, AdaptiveConformalInference, AggregatedAdaptiveConformalInference, NonexchangeableConformalPrediction, SequentialPredictiveConformalInference, WeightedSplit — which is pure configuration: alpha, the nonconformity score, and the method’s own knobs. Fitting never modifies it.
  • a fitted object — what the method’s fit, calibrate and calibrate_cv return. It holds everything learned from calibration, plus a copy of the config it was fitted under (method_), and it is what produces intervals.
Split conformal and CQR share a fitted class because, once fitted, both are the same thing: a fixed per-cell offset applied through the score. Which entry point you call depends on who owns the cross-validation loop. The array surface. method.fit(predictions, truths) returns the fitted object; fitted.interval(predictions) and fitted.update(prediction, truth) use it. Pure array-in/array-out calibration math, taking no forecaster at all. An array-surface signature may only accept numpy-coercible arrays and plain-Python config — never adapters, DataFrames, or library types. This is the surface for libraries that already own their cross-validation and predict orchestration: statsforecast, mlforecast, neuralforecast. The forecaster surface. method.calibrate(forecaster, histories, truths) / method.calibrate_cv(forecaster, n_windows) return the fitted object, and fitted.predict(history) uses it. Written once in the base classes and never overridden, it dispatches through a ForecasterAdapter. This is the surface for standalone users, and the one the rest of these pages use. Both surfaces run the same math: a page that calibrates through an adapter and a library that hands over its own residual cube get the same fitted object.

Using the array surface

fitted.calibration_ is a CalibrationResult: n_calibration_samples (per-series effective window counts) and a diagnostics dict of method-specific values. An object fitted this way has no forecaster, so fitted.predict raises CalibrationError telling you to use interval — see Embedding.

Construction is keyword-only, and config is a schema

Every method is a pydantic model. Construction is therefore keyword-only — SplitConformal(alpha=0.1), never SplitConformal(0.1) — an unknown keyword is a ValidationError rather than a silently ignored argument (extra="forbid"), and an out-of-range alpha is a pydantic.ValidationError, which is a ValueError subclass:
Each config field carries Field constraints and a description, so model_json_schema() is a usable description of the knobs — the constraint alpha ∈ (0, 1) and the sentence explaining what gamma trades off are both in the schema, not only in the prose:
Because a method is only config, it round-trips through model_validate(model_dump()) whether or not it has been used to fit.

The score is config

The nonconformity score is an ordinary constructor field, score=. Leave it out to take the method’s default:
A score the method cannot invert is rejected at construction, as a pydantic.ValidationError: SPCI needs a signed residual, CQR a quantile-shaped score, WeightedSplit a symmetric one, and asymmetric split conformal a signed one. Each check accepts subclasses, so a custom score must subclass the type the method requires. A score serializes through its kind tag ("absolute", "signed", "quantile"), which is what lets a configured method survive a dump and reload. A custom score declares its own tag — kind: Literal["scaled"] = "scaled" — and subclassing a built-in without one is a TypeError when the class is defined, because it would otherwise reload as its parent. fit works on a copy of the score, so a score with fitted parameters of its own never carries state from one fit into the next.

Config versus fitted state

Following sklearn’s convention:
  • Config lives on the method. It is set at construction, has no trailing underscore, and is a pydantic Field with constraints and a description: alpha, seed, score, gamma, rho, window_size, gammas, symmetric.
  • Fitted state lives on the fitted object only, with a trailing underscore: score_quantile_, alpha_t_, n_calibration_samples_, residuals_, forecaster_. The fitted object reads config through fitted.method_, a copy taken at fit, so later edits to the method never reach an object that was already fitted.
There is no “not yet fitted” state to check for: a fitted object exists only once fitting succeeded, and a method is never fitted. REQUIRED_CAPABILITIES, CONSUMES_QUANTILES, SCORE_TYPE and IS_ONLINE are ClassVar class metadata on the method rather than fields, so they stay off the constructor and off the schema and are readable on the class itself with no instance in hand.

The forecaster arrives at calibration

The forecaster is never a constructor argument. It is passed to calibrate / calibrate_cv and kept on the returned object as forecaster_, where predict reuses it. Calibrating twice gives two independent fitted objects, so calibrating the same method against two forecasters is just two calls. The two collaborators that are configurable objects rather than plain values — AgACI’s aggregator=EWA(eta=2.0) (the default is EWA(), eta=1.0) and SPCI’s regressor=QRFQuantileRegressor(n_estimators=200) (the default is None, resolved to QRFQuantileRegressor(random_state=seed)) — are prototypes: config on the method, deep-copied into the fitted object, and never fitted themselves.
Capability failures happen where the forecaster is bound A method declares what it needs from an adapter through REQUIRED_CAPABILITIES, checked where the forecaster is bound — the first thing calibrate and calibrate_cv do. Calibrating ConformalizedQuantileRegression on a forecaster that cannot produce quantiles raises UnsupportedCapability before any forecasting happens. Constructing it does not: with no forecaster in hand there is nothing to check. And predict never raises it — a fitted object is only ever built after the check passed. See Adapters for the matrix.

Shapes

Every array in the public API follows these conventions, and the leading axis is always n_series — iterate over it the way you would iterate over groupby keys. CQR consumes quantile forecasts rather than point forecasts, so the predictions its fit and its fitted interval take carry a trailing (q_lo, q_hi) axis: (n_series, n_windows | n_samples, horizon, 2). A NaN cell in predictions/truths at fit time means “this window does not exist for this series”. The math propagates NaN through scoring — never impute — and takes per-(series, horizon) quantiles with masked helpers. Each series needs enough effective windows or fit raises InsufficientCalibrationSamples naming the offending series. The floor is ceil(1/alpha) for most methods; asymmetric split takes one quantile per tail and needs max(ceil(1/alpha_lo), ceil(1/(1 - alpha_hi))), SPCI needs max(window_size + 1, ceil(1/alpha)), and NexCP and WeightedSplit test the weighted effective sample size (Σw)² / Σw² rather than a raw count. Split and CQR take the exact k = ceil((1 - alpha)(n + 1)) order statistic, not an interpolated quantile — matching the conformal literature. ACI and AgACI need a quantile at a time-varying level, so they use a numpy-version-stable linear quantile; NexCP and WeightedSplit take weighted quantiles.

Ragged panels and long-format data

conformalforecast.panel converts between long format (one row per (series, timestamp)) and the (n_series, T) panels the array surface consumes. It imports only numpy — no pandas — so it is embeddable wherever the array surface is.

Calibration entry points

Both are concrete on ConformalMethod, never overridden by a subclass, and both return a new fitted object:
  • calibrate(forecaster, histories, truths, *, X=None) — explicit arrays, any adapter, looping via forecaster.predict_batch.
  • calibrate_cv(forecaster, n_windows, *, step_size=1, refit=False, aligned=False, X=None) — delegates to the adapter’s cross-validation, automatically requesting quantile predictions when the method CONSUMES_QUANTILES.
aligned=False (the default) gives dense per-series windows, where the window axis means “k steps before this series’ own end”. aligned=True gives calendar-aligned cutoffs with NaN cells for late-starting series. The recency-weighted methods — NexCP and SPCI — interpret that axis accordingly, so the choice matters for them in a way it does not for split conformal. fitted.recalibrate(target_histories, target_truths) is calibrate pointed at a target-domain panel with the forecaster and score already bound; it, too, returns a new object.

Online methods update in place

The fitted objects of the four online methods advance through update(prediction, truth), which mutates the fitted object in place and returns None. That is deliberate: a version returning a new object would make fitted.update(...) without reassignment a silent no-op, and the intervals would quietly stop adapting. To keep a snapshot — before a backtest, or to compare two futures — copy it explicitly:
update is concrete on the base class and delegates to an _update algorithm hook. The online fitted classes implement _update; the offline ones inherit a no-op.

Migrating a library

conformalforecast.compat.mlforecast_shim is a reference implementation: make_interval_method adapts a method’s array surface to mlforecast’s interval hook, and register_interval_method puts it in mlforecast’s _INTERVAL_METHODS registry (requires mlforecast >= 1.1).
Embedding walks through this in full: the layout contract, the arguments the shim does not support (weighted and transfer conformal), and the two upstream changes needed for full method coverage.

Performance

The calibration math is fully batched: nothing loops over the (n_series × horizon) grid in Python. The per-cell quantile is a single gather, and the online methods keep their growing history in one incrementally-sorted buffer instead of re-quantiling it from scratch at every step. SPCI is the sanctioned exception — it fits one quantile regressor per cell. ACI and AgACI fit is still quadratic in n_windows by construction — an online method has to roll alpha_t forward one step at a time, and each step looks at the whole history — but the per-step work is array work rather than a Python loop, and AgACI’s expert axis is batched into the same gather.