Concept: Conformal Modes¶
What it is¶
Conformal prediction attaches honest uncertainty intervals to point forecasts by calibrating against past residuals:
residual r = obs − sim
half-width = quantile_α( |r| )
interval = [sim − half-width, sim + half-width]
The non-trivial design choice is which residuals to pool. The residual_mode
field answers this. Four modes were introduced in PR #361 as part of the
CalibrationResult refactor. Each produces one persistable CalibrationResult
sidecar; predict_interval dispatches on which internal dict field is non-None.
Where it lives in the code¶
| Symbol | Location | Notes |
|---|---|---|
ResidualMode (type alias) |
models/meta_models/types.py:16 |
Four-string Literal |
_MODE_DISPATCH |
models/meta_models/conformalise.py:571 |
Dict keying each mode to its recipe |
apply_conformal (experiment overload) |
conformalise.py:584 |
Dispatches through _MODE_DISPATCH |
CalibrationResult.predict_interval |
conformalise.py:360 |
Applies half-widths to sim_yield |
fit_and_save_all_configured |
stages/run_meta_models.py:85 |
Fits + saves every mode in config.postprocess.conformalise |
primary_calibration |
stages/run_meta_models.py:119 |
Returns calibration for config.forecast.residual_mode |
Key invariants¶
- Exactly one of
per_init_md,per_year,pooledis non-Noneon any givenCalibrationResult(invariant atconformalise.py:106). fit_and_save_all_configuredruns before the fold loop inpostprocess_experiment(run_meta_models.py:168) so all calibrations are complete beforebuild_rowsis called.postprocess.conformalise(list) controls which sidecars are written;forecast.residual_mode(single mandatory field since PR #372) selects which is applied at runtime. The axes are independent: a forecast can request a mode absent frompostprocess.conformalise—get_or_fit_calibration(run_meta_models.py:100) fits it on demand.
Mode comparison table¶
| Mode | What is pooled | When preferred | Calibration-set size | Bias risk |
|---|---|---|---|---|
hindcast_oos_per_init_date |
OOS residuals grouped by "MM-DD" init label |
Default; captures season-of-year uncertainty gradient | n_folds × n_inits ÷ n_labels per label |
Low — honest OOS |
hindcast_oos_per_year |
Walk-forward bootstrap; fold k uses folds < k |
Diagnosing calibration over time; per-year CI evolution | Grows from 0; earliest fold → NaN | Low — honest OOS |
hindcast_oos_fully_pooled |
All (year, init_date) OOS residuals |
Baseline; robust for small calibration sets | n_folds × n_inits (full pool) |
Low — loses seasonal structure |
in_sample_pooled |
Production fold train_preds residuals |
Fallback when no CV folds exist | n_train_years × n_inits |
High — biased narrow; in-sample |
in_sample_pooled emits logger.warning labelling intervals as biased narrow
(conformalise.py:546). The validate_residual_mode gate (run_forecast.py:91)
rejects OOS modes when no CV folds exist, making it the only legal choice for
fit_production-only run_dirs.
PR #361 worked-example numbers (95% half-width, kg/ha, corn)¶
hindcast_oos_per_init_date[01-01]: 309
hindcast_oos_per_init_date[04-08]: 305
hindcast_oos_per_init_date[07-08]: 422 ← wider mid-season
hindcast_oos_per_init_date[10-15]: 309
hindcast_oos_fully_pooled: 347
hindcast_oos_per_year[2020]: NaN ← no prior residuals
hindcast_oos_per_year[2021]: 83 ← only one prior year
hindcast_oos_per_year[2022]: 330
hindcast_oos_per_year[2023]: 317
hindcast_oos_per_year[2024]: 304
The seasonal gradient in per_init_date (309 → 422 → 309) is the key reason it
is preferred: intervals widen correctly at mid-season when accumulated weather
information is most uncertain.
predict_interval dispatch (conformalise.py:360)¶
per_init_mdset → circular calendar interpolation oninit_md; non-exact hits emitlogger.warning(conformalise.py:279).per_yearset → exact-hit orper_year_fallbackreduce onfold_year(conformalise.py:319); linear interpolation intentionally absent (adjacent fold-years have non-comparable residual sets).pooledset → broadcasts single half-width; ignores both kwargs (conformalise.py:395).
Per-mode parquet sidecar layout¶
e.g. runs/20260505_corn_usa/conformal/hindcast_oos_per_init_date.parquet. Canonical path via
calibration_path(run_dir, mode) (run_meta_models.py:60). Long-format schema:
fold_year: Int32 (NA for per_init_date)
fold_init_md: category (NA for per_year / pooled)
level: float64
half_width_kg_ha: float64
n_residuals: int64
residual_mode: category
method: category
commodity: category
PR #361 live demo (corn, per_init_date): Total rows: 210 = 42 init_mds × 5 levels.
The commodity_ prefix that previously appeared in sidecar filenames was removed in PR #361.
How it interacts with the pipeline¶
During hindcast POSTPROCESS, fit_and_save_all_configured writes one sidecar per
configured mode. During forecast POSTPROCESS, primary_calibration loads the
primary sidecar and applies it in build_rows (residuals.py:52). See
residual_modes.md for the mandatory forecast.residual_mode
field and validate_residual_mode gate.
Pitfalls and historical bugs¶
per_yearNaN for earliest fold: No prior residuals →NaNhalf-widths.strict=Falsereturns(nan, nan)silently.commodity_prefix removed in PR #361: All consumers must usecalibration_path()rather than hard-coding the path string.to_frame()empty-row guard (PR #372): Before PR #372, a misconfiguredCalibrationResultraised an opaqueKeyError: 'fold_year'. PR #372 added aValueErrorwith a diagnostic message atconformalise.py:187.
Related entities and concepts¶
CalibrationResult— the persistent dataclass holding the fitted half-widthsresidual_modes.md— companion page on the mandatoryforecast.residual_modefieldwalk_forward_cv.md— produces the OOS residuals feeding the threehindcast_oos_*modes
PRs and commits¶
| PR | Relevance |
|---|---|
| PR-361 | Introduced all four modes, CalibrationResult, mode-keyed sidecars, _MODE_DISPATCH |
| PR-372 | Made forecast.residual_mode mandatory; extracted ResidualMode to types.py; added to_frame() guard |
Open questions¶
per_year_fallbackis persisted in all modes for round-trip consistency but is only consulted inper_yearmode. The design rationale is not documented.MapieConformalRegressor(conformalise.py:645) provides a feature-conditioned calibration pathway unused in the main pipeline; its relationship toCalibrationResultis undocumented.