macroforecast.pipeline#
A comprehensive pseudo-out-of-sample (POOS) forecasting pipeline that orchestrates
the existing macroforecast building blocks. You declare a set of arms (each a
full preprocessing/features/model configuration) and the pipeline runs them through
forecasting.run, evaluates every contender against a benchmark with relative RMSE,
Diebold-Mariano, Clark-West and the Model Confidence Set, builds forecast
combinations, and assembles a single PipelineReport. Targets are resolved from the
FRED-MD/QD t-code, and ML arms can be interpreted (SHAP/ALE/PDP) without re-running.
Terminology#
The pipeline uses four terms consistently.
cell – the execution unit the pipeline manages: one
armapplied to onetargetover the window for a horizon-group. The pipeline enumerates and schedules cells, and each cell is executed by exactly onerun()call. The serial backend groups all horizons of a (target, arm) into one cell; the parallel backend splits one horizon per cell.run() – the atomic forecasting function (
forecasting.run) that executes one cell (one model, one target, over the window). “run” is the function and the verb; the execution unit it produces is a cell, not “a run”.arm – a target-agnostic configuration (preprocessing + features + a single model). It is not a cell by itself; applied to a target and a horizon it forms a cell, and in the evaluation it appears as one contender.
contender – an arm as it appears in the evaluation comparison, the named entity compared via DM/CW/MCS. One arm is one contender; forecast combinations are additional contenders.
In one line: arm × target × horizon → cell = one run(); arm = contender.
Entry points#
Symbol |
Summary |
|---|---|
|
Validating generator that builds a |
|
Build one |
|
Run arms, evaluate, and return a |
|
Deferred multi-method ML interpretation. |
|
Execute every cell into the master forecast frame (lower-level; name retained for back-compat). |
|
Accuracy + DM/CW + MCS + combinations on a master frame. |
|
Append cross-arm combination contenders. |
|
Resolve a target’s (policy, transform). |
|
The contender label for an arm. A contender IS exactly an arm (one model per arm), so this returns |
Configuration objects#
PipelineSpec– validated, frozen run configuration.Arm– a target-agnostic configuration with exactly ONE model: name, model, preprocessing, features, params, interpret. An arm is one contender in the evaluation; applied to a target and horizon it forms a cell. Comparing models means multiple arms identical except formodel; comparing feature cases means arms differing in features. A list/mapping of models in one arm is rejected.TargetSpec– a target and how its forecast object is defined (transform, policy, reduce_i2).ResolvedTarget– a target with its forecast policy and transform resolved.InterpretSpec– ML interpretation request (methods, which_fit, background, top_k).EvalSpec– benchmark, metrics, tests, primary_axis, MCS settings, subsamples.CombinationContender– a forecast combination that becomes an additional contender.PipelineReport– output: forecasts, accuracy, significance, mcs, provenance, leakage_audit, interpretation, failed_cells.failed_cellslists any(target, arm, horizon-group)cell whoserun()raised; the rest of the cells still ran and the failures are also mirrored intoleakage_audit["failed_cells"].TCODE_TARGET_MAP– FRED t-code to (forecast_policy, target_transform) mapping.
t-code to target mapping#
The forecast object is the h-period cumulation (direct_average) of the
transformation implied by the target’s t-code, not the raw single-period transform.
I(2) price/level series are reduced to the first-difference object (reduce_i2).
t-code |
forecast policy |
transform |
|---|---|---|
1 |
direct |
level |
2 |
direct_average |
change |
3 |
direct_average |
change |
4 |
direct |
level |
5 |
direct_average |
log_growth |
6 |
direct_average |
log_growth |
7 |
direct_average |
growth |
Example#
import macroforecast as mf
from macroforecast.pipeline import Arm, EvalSpec, CombinationContender, pipeline_spec, run_pipeline
spec = pipeline_spec(
data=bundle, # carries FRED transform_codes
targets=["INDPRO", "CPIAUCSL"], # resolved from t-codes
horizons=[1, 3, 12],
window=mf.window.from_cutoffs(test_start="1990-01", horizon=1, embargo=0),
arms=[
Arm("AR", model="ar"), # benchmark
Arm("RF", model="random_forest", interpret=("shap", "ale")),
Arm("MARX", model="ridge", preprocessing=marx_spec), # transformation comparison
],
evaluation=EvalSpec(benchmark="AR", tests=["dm", "cw", "mcs"]),
combinations=[CombinationContender("POOL", method="constrained_ls")],
n_jobs=8, # fan (arm × target × horizon) cells across 8 processes
)
report = run_pipeline(spec)
report.accuracy # contender x target x horizon relative RMSE (common sample)
report.significance # Diebold-Mariano and Clark-West p-values vs the benchmark
report.mcs # Model Confidence Set membership
report.leakage_audit # window.validate() warnings (per horizon)
from macroforecast.pipeline import interpret_pipeline
interpret_pipeline(report, methods=("shap", "ale")) # deferred, no re-forecast
Comparing models (model_arms)#
Since an Arm is exactly ONE model, a pure model comparison is “several arms
identical except model”. Writing one Arm per model by hand is verbose, so
model_arms(...) builds them for you:
from macroforecast.pipeline import model_arms, pipeline_spec, EvalSpec
arms = model_arms(["ar", "random_forest", "far"], features=feats, preprocessing=pp)
spec = pipeline_spec(..., arms=arms, evaluation=EvalSpec(benchmark="ar"))
Each model becomes one Arm (one contender; each of its (target, horizon) is one
cell run by one run()), and the contender is the arm name. Arm names default to the model name (str(model) /
ModelSpec.name / callable.__name__); pass a Mapping[name -> model] or
names=[...] to label them explicitly. All arms share the given preprocessing,
features, and evaluation config.
params and model_selection are shared by every arm unless given as a Mapping
whose keys are exactly the arm names, in which case each entry is applied to its
own arm. A plain shared dict of hyperparameters (whose keys are hyperparameter
names, not arm names) is therefore unambiguously shared. nested_in_benchmark is
a bool shared by all, or a set/sequence of arm names that nest the benchmark.
This shares all config, so it is for pure model comparison. Comparing feature
cases (arms differing in features or preprocessing) still needs explicit
Arm objects built by hand.
Parallel execution (n_jobs)#
pipeline_spec(..., n_jobs=N) parallelises the pseudo-out-of-sample replication
natively, so a fan-out across cores no longer needs hand-rolled shell processes.
n_jobs=1(default) keeps the sequential path byte-for-byte: each(target, arm)is one cell run as a consolidated multi-horizonrun()that shares a single per-origin preprocessing cache across horizons (EM imputation is computed once per origin).n_jobs>1splits the work into(arm × target × horizon)cells and runs them across aProcessPoolExecutorwithmin(n_jobs, n_cells)workers. Each cell is a single-horizonrun(), so it trades the cross-horizon EM-sharing for wall-clock parallelism: every cell recomputes its own preprocessing. On a many-core machine this finishes in a fraction of the sequential wall-clock.The parallel path is deterministic: every cell uses
spec.seedand the per-cell computation is independent of sibling cells, so the assembled forecast frame and every downstream accuracy table are numerically identical ton_jobs=1.Workers coordinate through the existing per-
(target, arm, horizon)checkpoints (checkpoint_dir), which are already namespaced so parallel processes never collide. Each worker caps nested BLAS/OpenMP threads to one to avoid oversubscribing cores.Memory scales with
n_jobs: each worker holds its own copy of the data panel, so peak memory is roughlyn_jobs ×the single-process footprint.
n_jobs="auto"#
pipeline_spec(..., n_jobs="auto") removes the need to hand-tune the worker count.
It inspects the core budget (the affinity count, len(os.sched_getaffinity(0)), which
respects cgroup/taskset pinning) and the work structure
(len(targets) × len(arms) × len(horizons) cells), then splits the cores between
cell workers and per-cell model-internal threads so the CPU is saturated
without oversubscription (cell_workers × model_threads ≤ cores). Cell-level
parallelism comes first (one worker per cell up to the core count); leftover cores
become model-internal threads handed to the parallelizable models
(random_forest, gradient_boosting, xgboost, lightgbm) inside each worker.
The single-threaded models (ar, ols, ridge, lasso, elastic_net, far)
ignore the thread budget and are unaffected. The resolved cell-worker count is stored
as PipelineSpec.n_jobs and the per-cell thread budget as PipelineSpec.model_threads.
This also fixes a latent oversubscription: previously a tree model inside a parallel
worker defaulted its internal n_jobs to 'auto' (= full CPU count), so N workers
each spawned cpu_count threads. Each worker now pins its model-internal threads to
model_threads. The split changes only the number of internal threads, not the
numerical result (tree training is deterministic in random_state regardless of the
thread count), so a n_jobs="auto" run is byte-for-byte identical to n_jobs=1.
Notes#
Accuracy uses a common sample: every contender is scored on the origins where all contenders and the realised target are observed, consistent with the MCS.
The leakage audit validates the window for each horizon, surfacing the multi-horizon pseudo-out-of-sample embargo convention.
Models are saved by default so
interpret_pipelineruns without re-forecasting.Arm.nested_in_benchmark(defaultFalse) marks an arm whose model nests the benchmark, for example a data-poor autoregressive benchmark nested in a factor-augmented or penalised model. The evaluator reports a Clark-West statistic only for such contenders, since Clark-West is valid only when the benchmark is nested within the contender. Arms that do not nest the benchmark receive Diebold-Mariano only, and forecast combinations are never treated as nested.