Partial Layer Execution
Most recipe-author work is iterative: tweak one knob, rerun, inspect.
macroforecast.run(...) executes the entire L1 → L8 cell loop, which is
overkill when you only care about whether the L2 outlier policy actually
flagged what you expected, or whether your new L3 op produces the right
X_final.
The macroforecast.core runtime exposes per-layer materialization helpers
that do exactly that. Each helper accepts the parsed recipe dict and the
upstream artifacts, and returns the same artifact dataclasses that the full
pipeline would have produced – so you can inspect intermediate sinks
without invoking L4 / L5 / L6 / L7 / L8.
See also: Custom hooks – developing a custom hook almost always involves L1+L2 once and then iterating on the layer the hook is registered against.
Why this exists
Use case |
Helper(s) |
|---|---|
“Did L2 actually flag my outliers?” |
|
“Does my new L3 op produce the X_final I expect?” |
|
“Walk forward through L1 → L5 once, no L6/L7/L8” |
|
“Bridge from a custom-panel YAML straight to the L2 sink” |
|
“Replay one DAG node from cache” |
|
Public API surface
All six helpers live on macroforecast.core:
from macroforecast.core import (
materialize_l1,
materialize_l2,
materialize_l3_minimal,
materialize_l4_minimal,
materialize_l5_minimal,
execute_l1_l2,
execute_minimal_forecast,
execute_node,
)
Function |
Input |
Returns |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
as listed |
|
|
|
|
|
|
|
|
one DAG |
the materialized node value (cached on disk). |
RuntimeResult (from macroforecast.core) is a frozen dataclass with
artifacts: dict[str, Any] (sink_name → artifact),
resolved_axes: dict[str, dict] (per-layer resolved axis values), and
runtime_durations: dict[str, float] (L1 / L2 / L3 / … wall-clock
seconds). Access a single sink with rt.sink("l2_clean_panel_v1").
Worked sequence
The example below uses the same 10-row inline custom panel as
examples/recipes/l4_minimal_ridge.yaml and walks through L1 → L3 by
hand.
import macroforecast as mf
from macroforecast.core import (
materialize_l1, materialize_l2, materialize_l3_minimal,
materialize_l4_minimal, materialize_l5_minimal,
)
recipe = mf.core.parse_recipe_yaml(open("examples/recipes/l4_minimal_ridge.yaml").read())
# --- L1 ---------------------------------------------------------------
l1_artifact, regime_artifact, l1_axes = materialize_l1(recipe)
print("L1 frequency :", l1_artifact.frequency)
print("L1 target :", l1_artifact.target)
print("L1 raw_panel :", l1_artifact.raw_panel.data.shape, "rows x cols")
print("L1 axes keys :", sorted(l1_axes)[:6])
# --- L2 ---------------------------------------------------------------
l2_artifact, l2_axes = materialize_l2(recipe, l1_artifact)
print("L2 panel :", l2_artifact.panel.data.shape)
print("L2 cleaning_log steps:", [step for step in l2_artifact.cleaning_log["steps"]])
print("L2 n_outliers:", l2_artifact.n_outliers_flagged)
print("L2 n_imputed :", l2_artifact.n_imputed_cells)
# --- L3 ---------------------------------------------------------------
l3_features, l3_metadata = materialize_l3_minimal(recipe, l1_artifact, l2_artifact)
print("L3 X_final :", l3_features.X_final.data.shape)
print("L3 y_final :", l3_features.y_final.shape, l3_features.y_final.name)
print("L3 horizons :", l3_features.horizon_set)
print("L3 sample_ix :", l3_features.sample_index[:3].tolist())
Expected output (the inline panel is deterministic):
L1 frequency : monthly
L1 target : y
L1 raw_panel : (12, 2) rows x cols
L1 axes keys : ['custom_source_policy', 'dataset', 'frequency', ...]
L2 panel : (12, 2)
L2 cleaning_log steps: [{'transform': 'no_transform'}, {'outlier': 'none'}, ...]
L2 n_outliers: 0
L2 n_imputed : 0
L3 X_final : (10, 1)
L3 y_final : (10,) y
L3 horizons : (1,)
L3 sample_ix : [Timestamp('2018-02-01 00:00:00'), Timestamp('2018-03-01 00:00:00'), ...]
The L3 step drops the first two rows (lag 1 + h=1 target shift), giving 10
rows of X_final / y_final. From here you could continue:
l4_forecasts, l4_models, l4_training = materialize_l4_minimal(recipe, l3_features)
print("L4 model_ids :", l4_forecasts.model_ids)
print("L4 forecasts :", list(l4_forecasts.forecasts.values())[:3])
l5_eval = materialize_l5_minimal(recipe, l1_artifact, l3_features, l4_forecasts, l4_models)
print("L5 metrics :", l5_eval.metrics_table.head())
Convenience helpers
When you do not need the artifact dataclasses directly, two helpers wrap the
materialize calls and return a RuntimeResult:
from macroforecast.core import execute_l1_l2, execute_minimal_forecast
# L1 + L2 only -- no L3+ overhead. Good for "did the cleaner do its job?"
rt = execute_l1_l2(open("examples/recipes/l2_minimal.yaml").read())
print("sinks :", sorted(rt.artifacts))
panel = rt.sink("l2_clean_panel_v1").panel.data
print("panel shape :", panel.shape)
print("L2 axes :", sorted(rt.resolved_axes["l2"])[:6])
# L1 → L5 (plus any enabled L1.5 / L2.5 / L3.5 / L4.5 / L6 / L7 / L8 sinks).
rt5 = execute_minimal_forecast(open("examples/recipes/l4_minimal_ridge.yaml").read())
print("durations :", rt5.runtime_durations)
print("forecasts :", rt5.sink("l4_forecasts_v1").model_ids)
Use execute_l1_l2 while debugging L2 settings; use
execute_minimal_forecast when you want a full minimal end-to-end pass
without going through execute_recipe (which writes a manifest and
manages the cell loop).
For the full multi-cell run(...) API see macroforecast.core.execute_recipe.
Schemas of the intermediate sinks
The artifacts are frozen dataclasses defined in macroforecast/core/types.py.
L1DataDefinitionArtifact
Field |
Type |
Notes |
|---|---|---|
|
|
Resolved from L1 fixed_axes. |
|
|
None for |
|
|
Resolved frequency. |
|
|
None for custom-panel runs. |
|
|
– |
|
|
The single-target name (or first of |
|
|
The full list when |
|
enum or |
– |
|
enums or |
FRED-SD only. |
|
enums |
– |
|
str / |
– |
|
|
|
|
|
The materialized predictor + target frame. |
|
|
Echo of L1.leaf_config; useful for reading |
There is no separate target_series field; the target column lives inside
raw_panel.data[target] until the L3 stage splits it out.
L1RegimeMetadataArtifact
Field |
Type |
When |
|---|---|---|
|
|
Always set. |
|
|
– |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Empty for external regimes. |
L2CleanPanelArtifact
Inherits from Panel; therefore exposes data, shape, column_names, index, metadata directly and repeats them through the panel field.
Field |
Type |
Notes |
|---|---|---|
|
|
The cleaned panel. |
|
|
Per-column dtype string and other column-level audit info. |
|
|
|
|
|
Total cells the imputer filled. |
|
|
Total cells the outlier policy touched. |
|
|
Rows the frame-edge policy dropped. |
|
|
|
|
|
Records the per-stage temporal rule ( |
|
|
Populated by the cell loop only – empty in raw materialize calls. |
L3FeaturesArtifact
Field |
Type |
Notes |
|---|---|---|
|
|
The final predictor matrix. |
|
|
The final target series; |
|
|
The aligned index of |
|
|
Per-recipe target horizons. |
|
|
Populated by the cell loop only. |
L3MetadataArtifact
Field |
Type |
Notes |
|---|---|---|
|
|
column → |
|
|
One entry per L3 pipeline. |
|
|
Cascade-DAG adjacency. |
|
|
Per-column step chain. |
|
|
Per-column source variable ids. |
L4ForecastsArtifact
Field |
Type |
Notes |
|---|---|---|
|
|
|
|
|
|
|
|
– |
|
|
Sorted unique forecast origins. |
|
|
– |
|
|
Populated by the cell loop only. |
L4ModelArtifactsArtifact
Field |
Type |
Notes |
|---|---|---|
|
|
model_id → fitted |
|
|
model_id → |
|
|
– |
L4TrainingMetadataArtifact
Records forecast_origins, refit_origins, training_window_per_origin,
runtime_per_origin, cache_hits_per_origin, tuning_log,
upstream_hashes – one row per (model_id, origin) walk-forward step.
L5EvaluationArtifact
Field |
Type |
When empty |
|---|---|---|
|
|
Per-(model, target, horizon) metric rows. |
|
|
Sorted by primary metric. |
|
|
– |
|
|
|
|
|
|
|
|
FRED-SD only. |
|
|
– |
|
|
Empty when L5 took the summary-only fallback path. |
|
|
Resolved L5 axes. |
Use case 1: Did my outlier policy actually flag values?
import macroforecast as mf
from macroforecast.core import materialize_l1, materialize_l2
recipe_str = """
0_meta:
fixed_axes: {failure_policy: fail_fast, reproducibility_mode: seeded_reproducible}
1_data:
fixed_axes: {custom_source_policy: custom_panel_only, frequency: monthly, horizon_set: custom_list}
leaf_config:
target: y
target_horizons: [1]
custom_panel_inline:
date: [2018-01-01, 2018-02-01, 2018-03-01, 2018-04-01, 2018-05-01,
2018-06-01, 2018-07-01, 2018-08-01, 2018-09-01, 2018-10-01]
y: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
x1: [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 99.0]
2_preprocessing:
fixed_axes:
transform_policy: no_transform
outlier_policy: zscore_threshold
outlier_action: flag_as_nan
imputation_policy: none_propagate
frame_edge_policy: keep_unbalanced
"""
recipe = mf.core.parse_recipe_yaml(recipe_str)
l1_artifact, _, _ = materialize_l1(recipe)
l2_artifact, _ = materialize_l2(recipe, l1_artifact)
print("flagged cells :", l2_artifact.n_outliers_flagged)
for step in l2_artifact.cleaning_log["steps"]:
print(" -", step)
The cleaning_log['steps'] entry for the outlier stage tells you exactly
which policy ran, what action it took, and how many cells it flagged.
Use case 2: Iterating on L3 only
import macroforecast as mf
from macroforecast.core import materialize_l1, materialize_l2, materialize_l3_minimal
recipe = mf.core.parse_recipe_yaml(open("examples/recipes/l3_minimal_lag_only.yaml").read())
# Run L1 + L2 once; cache the artifacts.
l1_artifact, _, _ = materialize_l1(recipe)
l2_artifact, _ = materialize_l2(recipe, l1_artifact)
# Iterate on L3 -- swap ops, change params, re-run only this step.
recipe["3_feature_engineering"]["nodes"][2]["params"]["n_lag"] = 3
l3_features, l3_metadata = materialize_l3_minimal(recipe, l1_artifact, l2_artifact)
print("X_final shape:", l3_features.X_final.data.shape)
recipe["3_feature_engineering"]["nodes"][2]["params"]["n_lag"] = 6
l3_features, l3_metadata = materialize_l3_minimal(recipe, l1_artifact, l2_artifact)
print("X_final shape:", l3_features.X_final.data.shape)
Each L3 iteration reuses the same l1_artifact and l2_artifact, so
the experiment is bounded by L3 cost rather than full L1 → L8 cost.
When developing a custom L3 feature_block or feature_combiner
(Custom hooks), this loop is the canonical inner cycle:
register the callable once, then call materialize_l3_minimal repeatedly
with different parameter values.
execute_node – the cache-aware primitive
execute_node(node, dag, runtime_context, cache_dir) is the foundation
primitive that execute_recipe calls per DAG node. It hashes the node +
its inputs, checks the on-disk cache at
cache_dir/nodes/<node_hash>/result.pickle, returns the cached value if
present, and otherwise computes and caches the result. Most recipe authors
do not need execute_node directly – the materialize helpers above cover
inspection use cases. Reach for it only when you are writing a custom
runtime layer (rare).