Custom Extensions
Custom extensions are first-class method-research APIs. A researcher should be able to add a Layer 2 representation method, a Layer 3 model, a target-side transform, or a final-matrix preprocessor, then compare the custom method against built-in baselines under the same Layer 0 task, Layer 1 data treatment, split, benchmark, and evaluation path.
See also: Partial layer execution – when developing a custom hook the partial-layer helpers are the fastest way to iterate without re-running upstream every time.
Extension map
Five runtime extension points; all are decorator APIs from
macroforecast.custom (re-exported under the top-level mf namespace):
import macroforecast as mf
mf.custom_feature_block(name, block_kind="temporal" | "rotation" | "factor")
mf.custom_feature_combiner(name)
mf.custom_preprocessor(name)
mf.target_transformer(name)
mf.custom_model(name)
Each section below documents that hook with: decorator usage, required signature, input contract, output contract, a runnable worked example, and the most common errors.
The matching contract metadata (returned as plain dicts) is available from:
mf.custom_method_extension_contracts() # all five at once
mf.custom_model_contract_metadata()
mf.custom_preprocessor_contract_metadata()
mf.target_transformer_contract_metadata()
Custom benchmarks and custom metrics are not decorator APIs yet – benchmark plugins go through benchmark configuration, metric plugins should be added as a separate evaluation-layer contract.
1. Layer 3: custom_model
Use custom_model when the method changes the estimator, loss, likelihood,
optimization, recursive rule, or prediction function but should consume the
same X_train, y_train, X_test interface as built-in models.
Decorator usage
import macroforecast as mf
@mf.custom_model("my_constant_model")
def my_constant_model(X_train, y_train, X_test, context):
return float(y_train.mean())
Required signature
def fn(
X_train: pd.DataFrame, # n_train x n_features
y_train: pd.Series, # n_train, aligned to X_train.index
X_test: pd.DataFrame, # 1 x n_features (one prediction row)
context: dict[str, Any],
) -> float | Sequence[float]:
...
The wrapper iterates row-by-row when L4 calls predict on multi-row
matrices, so the callable always sees a one-row X_test.
Input contract
Argument |
Type |
Shape / contents |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
Required keys: |
Optional context fields appear in
mf.custom_model_contract_metadata()["optional_context_fields"] – e.g.
train_index, forecast_type, forecast_object, recursive_step,
auxiliary_payloads (FRED-SD mixed-frequency payloads only).
Output contract
Return one of:
a Python
float– single forecast value, ora one-element sequence/array (
[value],np.array([value]), etc.).
The wrapper coerces both forms via float(...).
Worked example
import macroforecast as mf
from macroforecast import custom
custom.clear_custom_models()
@mf.custom_model("constant_train_mean")
def constant_train_mean(X_train, y_train, X_test, context):
return float(y_train.mean())
recipe = """
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, 5.0]
2_preprocessing:
fixed_axes: {transform_policy: no_transform, outlier_policy: none,
imputation_policy: none_propagate, frame_edge_policy: keep_unbalanced}
3_feature_engineering:
nodes:
- {id: src_X, type: source, selector: {layer_ref: l2, sink_name: l2_clean_panel_v1, subset: {role: predictors}}}
- {id: src_y, type: source, selector: {layer_ref: l2, sink_name: l2_clean_panel_v1, subset: {role: target}}}
- {id: lag_x, type: step, op: lag, params: {n_lag: 1}, inputs: [src_X]}
- {id: y_h, type: step, op: target_construction,
params: {mode: point_forecast, method: direct, horizon: 1}, inputs: [src_y]}
sinks:
l3_features_v1: {X_final: lag_x, y_final: y_h}
l3_metadata_v1: auto
4_forecasting_model:
nodes:
- {id: src_X, type: source, selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: X_final}}}
- {id: src_y, type: source, selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: y_final}}}
- id: fit_main
type: step
op: fit_model
params: {family: constant_train_mean, min_train_size: 4,
forecast_strategy: direct, training_start_rule: expanding,
refit_policy: every_origin, search_algorithm: none}
inputs: [src_X, src_y]
- {id: predict, type: step, op: predict, inputs: [fit_main, src_X]}
sinks:
l4_forecasts_v1: predict
l4_model_artifacts_v1: fit_main
l4_training_metadata_v1: auto
5_evaluation:
fixed_axes: {primary_metric: mse, point_metrics: [mse]}
"""
result = mf.run(recipe)
forecasts = result.cells[0].runtime_result.artifacts["l4_forecasts_v1"].forecasts
print("first three forecasts:", list(forecasts.values())[:3])
art = result.cells[0].runtime_result.artifacts["l4_model_artifacts_v1"]
fitted = next(iter(art.artifacts.values()))
assert fitted.family == "constant_train_mean"
assert fitted.framework == "custom"
The fitted L4ModelArtifactsArtifact records family = "constant_train_mean" and framework = "custom" – those two fields
distinguish a custom model from a built-in family in the manifest.
Common errors
Symptom |
Cause |
Fix |
|---|---|---|
|
Recipe references |
Import the module before |
|
The runtime called |
Use the canonical |
Predictions are all NaN |
The custom callable returned |
Guard inside the callable; raise |
2. Layer 2: custom_preprocessor
Custom preprocessors are matrix hooks that fit between the L1 raw panel and
the L3 feature DAG. v0.8.6 routes the same registered name to two distinct
points inside L2 depending on applied_at.
Decorator usage
import macroforecast as mf
@mf.custom_preprocessor("clip_x1_at_2")
def clip_x1_at_2(X_train, y_train, X_test, context):
cleaned_train = X_train.copy()
cleaned_test = X_test.copy()
if "x1" in cleaned_train.columns:
cleaned_train["x1"] = cleaned_train["x1"].clip(upper=2.0)
cleaned_test["x1"] = cleaned_test["x1"].clip(upper=2.0)
return cleaned_train, cleaned_test
Required signature
def fn(
X_train: pd.DataFrame, # full L2 panel (or post-pipeline panel; see table below)
y_train: pd.Series | None, # None at the L2 boundary -- target is not yet split out
X_test: pd.DataFrame, # same frame as X_train at the L2 boundary
context: dict[str, Any], # the recipe's L2.leaf_config dict (read-only)
) -> tuple[pd.DataFrame, pd.DataFrame] | pd.DataFrame:
...
At the L2 boundary the runtime substitutes X_train = X_test = frame so the
callable can do a single-pass clean. Returning a single pd.DataFrame is
also accepted – the runtime treats it as both the train and pred output.
Input contract
Field |
Contents |
|---|---|
|
|
|
Predictor names + the target column (the target stays in the panel until L3 splits it out). |
|
|
|
|
|
Copy of |
Output contract
Same
pd.DatetimeIndexasX_train(drop / re-index only when intentional and the target column drop is acceptable).May add or remove columns; must preserve the target column when
applied_at='l2'because the canonical pipeline (transform / outlier / impute / frame_edge) still needs to run on it.For
applied_at='l3'the returned frame becomes the L2 clean panel directly – L3 reads it via thel2_clean_panel_v1selector.
applied_at='l2' vs applied_at='l3'
The same registered callable can be wired in at two distinct timing points
within L2. The choice is made by the recipe author through
Experiment.use_preprocessor(name, applied_at=...) (or by setting the
right L2 leaf_config key directly).
Aspect |
|
|
|---|---|---|
Runs at |
Top of |
Bottom of |
Input |
Raw L1 panel (untransformed; contains official t-codes if a code map was applied at L1) |
L2 clean panel (post-canonical pipeline) |
Output replaces |
The raw input feeding the canonical pipeline |
The clean panel that L3 reads |
Use when |
You need to clean / deflate / normalise before the McCracken-Ng pipeline |
You want to override or replace the canonical pipeline output entirely |
|
|
|
Cleaning-log entry |
|
|
Available since |
v0.8.6 (#PR-A) |
v0.2.5 (#251) |
Worked example
import macroforecast as mf
from macroforecast import custom
custom.clear_custom_preprocessors()
@mf.custom_preprocessor("clip_x1_at_2")
def clip_x1_at_2(X_train, y_train, X_test, context):
out_train = X_train.copy()
out_test = X_test.copy()
if "x1" in out_train.columns:
out_train["x1"] = out_train["x1"].clip(upper=2.0)
out_test["x1"] = out_test["x1"].clip(upper=2.0)
return out_train, out_test
# Set ``custom_postprocessor`` for ``applied_at='l3'`` (post-pipeline);
# set ``custom_preprocessor`` for ``applied_at='l2'`` (pre-pipeline).
recipe = """
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, 5.0]
2_preprocessing:
fixed_axes: {transform_policy: no_transform, outlier_policy: none,
imputation_policy: none_propagate, frame_edge_policy: keep_unbalanced}
leaf_config:
custom_postprocessor: clip_x1_at_2
3_feature_engineering:
nodes:
- {id: src_X, type: source, selector: {layer_ref: l2, sink_name: l2_clean_panel_v1, subset: {role: predictors}}}
- {id: src_y, type: source, selector: {layer_ref: l2, sink_name: l2_clean_panel_v1, subset: {role: target}}}
- {id: lag_x, type: step, op: lag, params: {n_lag: 1}, inputs: [src_X]}
- {id: y_h, type: step, op: target_construction,
params: {mode: point_forecast, method: direct, horizon: 1}, inputs: [src_y]}
sinks:
l3_features_v1: {X_final: lag_x, y_final: y_h}
l3_metadata_v1: auto
4_forecasting_model:
nodes:
- {id: src_X, type: source, selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: X_final}}}
- {id: src_y, type: source, selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: y_final}}}
- id: fit_main
type: step
op: fit_model
params: {family: ridge, alpha: 0.1, min_train_size: 4,
forecast_strategy: direct, training_start_rule: expanding,
refit_policy: every_origin, search_algorithm: none}
inputs: [src_X, src_y]
- {id: predict, type: step, op: predict, inputs: [fit_main, src_X]}
sinks:
l4_forecasts_v1: predict
l4_model_artifacts_v1: fit_main
l4_training_metadata_v1: auto
5_evaluation:
fixed_axes: {primary_metric: mse}
"""
result = mf.run(recipe)
panel = result.cells[0].runtime_result.artifacts["l2_clean_panel_v1"].panel.data
print("max(x1) after clip:", panel["x1"].max())
assert panel["x1"].max() == 2.0
The same callable can be wired in as leaf_config.custom_preprocessor
(applied_at='l2' – pre-pipeline) instead of custom_postprocessor
(applied_at='l3' – post-pipeline). The cleaning-log entry that lands
in l2_clean_panel_v1.cleaning_log['steps'] records which timing point
ran.
If you prefer the high-level builder, the equivalent two lines are:
exp = mf.Experiment(dataset="fred_md", target="INDPRO", horizons=[1])
exp.use_preprocessor("clip_x1_at_2", applied_at="l3") # or "l2"
Note: Experiment(...) always wires the official FRED path; reach for
the recipe-dict / YAML form whenever you need to drive a custom inline
panel like the example above.
Switch to applied_at='l2' to run the same callable before the
McCracken-Ng pipeline – useful when you want the official t-code map to
operate on already-deflated columns, for example.
Common errors
Symptom |
Cause |
Fix |
|---|---|---|
|
Returned a frame that dropped the target column under |
Preserve the target column; the L2 pipeline downstream still needs it. |
Output ignored, no error |
Runtime saw an exception and silently fell back to identity (the dispatcher wraps the call in |
Manually call |
Index drift (rows missing in |
Returned a smaller / re-indexed DataFrame and the canonical pipeline subsequently dropped more rows. |
Preserve the input |
3. Layer 2: target_transformer
target_transformer is the target-side extension. Use it when y itself
must be transformed for fitting and forecasts must be inverted to the raw
target scale before they reach L5 evaluation.
Decorator usage
import macroforecast as mf
import numpy as np
@mf.target_transformer("scale_by_std")
class ScaleByStd:
def fit(self, target_train, context):
self.scale_ = float(target_train.std() or 1.0)
def transform(self, target, context):
return target / self.scale_
def inverse_transform_prediction(self, target_pred, context):
return np.asarray(target_pred) * self.scale_
The decorator accepts either a class (instantiated once per L3 run) or a
factory () -> instance.
Required signature
The registered object (or factory’s product) must implement three methods:
class TargetTransformer:
def fit(self, target_train: pd.Series, context: dict[str, Any]) -> None | "TargetTransformer":
...
def transform(self, target: pd.Series, context: dict[str, Any]) -> pd.Series:
...
def inverse_transform_prediction(
self,
target_pred: Sequence[float] | np.ndarray,
context: dict[str, Any],
) -> np.ndarray:
...
Input / output contract
Method |
Input |
Output |
|---|---|---|
|
|
Side-effects only; return value is ignored. |
|
|
|
|
|
|
Scale rule: model fits on the transformed scale, but every reported forecast
and metric is on the raw target scale. The runtime applies
inverse_transform_prediction between L4 and L5.
Worked example
import macroforecast as mf
from macroforecast import custom
import numpy as np
custom.clear_custom_target_transformers()
@mf.target_transformer("scale_by_std")
class ScaleByStd:
def fit(self, target_train, context):
self.scale_ = float(target_train.std() or 1.0)
def transform(self, target, context):
return target / self.scale_
def inverse_transform_prediction(self, target_pred, context):
return np.asarray(target_pred) * self.scale_
experiment = mf.Experiment(dataset="custom_panel_only", target="y", horizons=[1])
recipe = experiment.to_recipe_dict()
recipe["1_data"]["leaf_config"]["target_transformer"] = "scale_by_std"
result = mf.run(recipe)
forecasts = result.cells[0].runtime_result.artifacts["l4_forecasts_v1"].forecasts
print("raw-scale forecasts:", list(forecasts.values())[:3])
Final forecasts are on the raw target scale – the inverse step ran between L4 and L5.
Common errors
Symptom |
Cause |
Fix |
|---|---|---|
|
Registered object missing one of the three required methods. |
Implement all three; |
Forecast values look 1000x off |
Forgot to wire |
Ensure |
|
The feature runtime is not in the supported allowlist. See Target transformer for the gate. |
Use |
For deeper rules see Target transformer.
4. Layer 3: custom_feature_block
Layer 3 builds the research representation. Use custom_feature_block when
the method changes what predictors exist before the model is fit – e.g.
temporal filters, lag-polynomial summaries, factor construction, rotation,
research-specific transforms.
A registered feature block is dispatched whenever an L3 step node’s op
matches the registered name (the runtime checks the registry before falling
through to built-in ops).
Decorator usage
import macroforecast as mf
@mf.custom_feature_block("double_it", block_kind="temporal")
def double_it(frame, params):
return frame * 2.0
block_kind is one of "temporal", "rotation", "factor" and
governs which Layer 2 axis exposes the registered name.
Required signature
def fn(
frame: pd.DataFrame, # the upstream L3 node's output (predictors)
params: dict[str, Any], # the recipe's params dict for this step node
) -> pd.DataFrame:
...
Input / output contract
Aspect |
Contract |
|---|---|
|
|
|
The L3 step node’s |
Return |
|
Worked example
import macroforecast as mf
from macroforecast import custom
custom.clear_custom_feature_blocks()
@mf.custom_feature_block("double_it", block_kind="temporal")
def double_it(frame, params):
return frame * float(params.get("scale", 2.0))
recipe = {
"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, 5.0],
},
},
},
"2_preprocessing": {"fixed_axes": {"transform_policy": "no_transform", "outlier_policy": "none",
"imputation_policy": "none_propagate", "frame_edge_policy": "keep_unbalanced"}},
"3_feature_engineering": {
"nodes": [
{"id": "src_X", "type": "source",
"selector": {"layer_ref": "l2", "sink_name": "l2_clean_panel_v1", "subset": {"role": "predictors"}}},
{"id": "src_y", "type": "source",
"selector": {"layer_ref": "l2", "sink_name": "l2_clean_panel_v1", "subset": {"role": "target"}}},
{"id": "scaled_x", "type": "step", "op": "double_it",
"params": {"block_kind": "temporal", "scale": 3.0}, "inputs": ["src_X"]},
{"id": "y_h", "type": "step", "op": "target_construction",
"params": {"mode": "point_forecast", "method": "direct", "horizon": 1}, "inputs": ["src_y"]},
],
"sinks": {"l3_features_v1": {"X_final": "scaled_x", "y_final": "y_h"}, "l3_metadata_v1": "auto"},
},
"4_forecasting_model": {
"nodes": [
{"id": "src_X", "type": "source",
"selector": {"layer_ref": "l3", "sink_name": "l3_features_v1", "subset": {"component": "X_final"}}},
{"id": "src_y", "type": "source",
"selector": {"layer_ref": "l3", "sink_name": "l3_features_v1", "subset": {"component": "y_final"}}},
{"id": "fit_main", "type": "step", "op": "fit_model",
"params": {"family": "ridge", "alpha": 1.0, "min_train_size": 4,
"forecast_strategy": "direct", "training_start_rule": "expanding",
"refit_policy": "every_origin", "search_algorithm": "none"},
"inputs": ["src_X", "src_y"]},
{"id": "predict", "type": "step", "op": "predict", "inputs": ["fit_main", "src_X"]},
],
"sinks": {"l4_forecasts_v1": "predict", "l4_model_artifacts_v1": "fit_main", "l4_training_metadata_v1": "auto"},
},
}
result = mf.run(recipe)
print("custom block applied:", "scaled_x" in result.cells[0].runtime_result.artifacts["l3_features_v1"].X_final.column_names or
list(result.cells[0].runtime_result.artifacts["l3_features_v1"].X_final.data.columns))
The L3 step node’s op: double_it triggers the registered callable; the
output DataFrame becomes scaled_x and feeds the L4 fit node.
Common errors
Symptom |
Cause |
Fix |
|---|---|---|
|
Step node references |
Import the module that calls |
Custom block silently ignored, fallback ran instead |
Registered callable raised inside the dispatcher (the dispatcher swallows exceptions for safety). |
Call |
Index drift in the L3 sink |
Block returned a smaller / re-indexed frame and the L3 |
Keep the input |
5. Layer 3: custom_feature_combiner
Feature combiners are broader than feature blocks. Use a combiner when the
method changes how already-built blocks become final Z – nonlinear
interactions across blocks, supervised block weighting, residualized
composition, custom low-dim representations.
Decorator usage
import macroforecast as mf
import pandas as pd
@mf.custom_feature_combiner("merge_concat")
def merge_concat(inputs, params):
if isinstance(inputs, list):
return pd.concat(inputs, axis=1)
return inputs
Required signature
def fn(
inputs: list[pd.DataFrame], # list of upstream block frames
params: dict[str, Any], # the recipe's params dict for this step node
) -> pd.DataFrame:
...
The combiner receives the list of upstream frames (multi-frame merge contract). Always handle both the list form and – defensively – the single frame form, since the dispatcher only routes the list form when the upstream has multiple inputs.
Input / output contract
Aspect |
Contract |
|---|---|
|
|
|
The L3 step node’s |
Return |
|
Worked example
import macroforecast as mf
from macroforecast import custom
import pandas as pd
custom.clear_custom_feature_combiners()
@mf.custom_feature_combiner("merge_concat")
def merge_concat(inputs, params):
return pd.concat(inputs, axis=1)
# Inside a recipe, an L3 step node like
# {id: combine, type: step, op: merge_concat, inputs: [block_a, block_b]}
# triggers the combiner when both upstream blocks are pd.DataFrames.
a = pd.DataFrame({"x": [1.0, 2.0]})
b = pd.DataFrame({"y": [3.0, 4.0]})
# Direct invocation for unit tests / debugging:
spec = mf.custom.get_custom_feature_combiner("merge_concat")
print(spec.function([a, b], {}))
To wire it into a real recipe, define an L3 step node whose op matches
the registered name and whose inputs list the upstream block ids – the
dispatcher routes the list of upstream frames to the combiner.
Common errors
Symptom |
Cause |
Fix |
|---|---|---|
|
Indexed into |
Branch on |
Column collisions in the combined frame |
Two upstream blocks emit a column with the same name. |
Rename inside the combiner – |
Method comparison sweeps
Method researchers often compare combinations:
built-in Layer 2 representation with built-in Layer 4 generator;
custom Layer 2 representation with built-in Layer 4 generator;
built-in Layer 2 representation with custom Layer 4 generator;
custom Layer 2 representation with custom Layer 4 generator.
Use sweep_axes for registry axes and leaf_sweep_axes for
variant-specific configuration names. leaf_sweep_axes materializes into
leaf_config before each variant is compiled, so registered custom names
can vary across variants without editing package internals.
path:
0_meta:
fixed_axes:
study_scope: one_target_compare_methods
failure_policy: continue_on_failure
2_preprocessing:
sweep_axes:
temporal_feature_block: [moving_average_features, custom_temporal_features]
4_forecasting_model:
nodes:
- id: src_X
type: source
selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: X_final}}
- id: src_y
type: source
selector: {layer_ref: l3, sink_name: l3_features_v1, subset: {component: y_final}}
- id: fit_candidate
type: step
op: fit_model
params: {family: my_custom_generator}
inputs: [src_X, src_y]
- id: predict_candidate
type: step
op: predict
inputs: [fit_candidate, src_X]
sinks:
l4_forecasts_v1: predict_candidate
l4_model_artifacts_v1: fit_candidate
l4_training_metadata_v1: auto
If the custom name should apply only when the parent custom axis is
selected, use nested_sweep_axes with a leaf_config.<key> child. This
avoids duplicate built-in variants that differ only by an unused custom name.
path:
2_preprocessing:
nested_sweep_axes:
temporal_feature_block:
moving_average_features: {}
custom_temporal_features:
leaf_config.custom_temporal_feature_block:
- my_temporal_block
- my_second_temporal_block
For custom combiners, bind the combiner name the same way:
path:
2_preprocessing:
nested_sweep_axes:
feature_block_combination:
concatenate_named_blocks: {}
custom_feature_combiner:
leaf_config.custom_feature_combiner:
- my_combiner
The compiler expands the grid first, then validates each variant. With
failure_policy=continue_on_failure, unsupported cells remain visible in
the manifest; supported built-in/custom cells run and appear in the same
study manifest.
FRED-SD mixed-frequency payloads
FRED-SD can emit extra Layer 2 payloads for custom Layer 3 models:
Payload contract |
Context key |
Present when |
|---|---|---|
|
|
|
|
|
|
The native-frequency payload includes blocks, block_order, and
column_to_native_frequency. The adapter payload records the adapter
route and the block-payload contract consumed by the model. This is the
supported place to implement research-specific mixed-frequency likelihoods,
weighting schemes, state updates, or direct forecast rules while leaving
Layer 1 source / t-code policy and Layer 2 representation choices auditable.
Use examples/custom_fred_sd_mixed_frequency_model.py as an executable
Python template and
examples/recipes/templates/fred-sd-custom-mixed-frequency-model.yaml as
the matching recipe skeleton. YAML can select the registered
model_family, but it does not import Python code – import the module
that calls @mf.custom_model(...) before compiling or running the recipe.
Fair-comparison checklist
For a custom-method comparison to be valid:
Keep Layer 0 task axes fixed unless task design is the object of comparison.
Keep Layer 1 raw-data treatment fixed unless raw missing/outlier policy is the object of comparison.
Put representation construction in Layer 2/3, not inside a model closure.
Put estimator changes in Layer 3, not inside a feature block.
Record all fitted choices in
fit_state,provenance, or model tuning payload.Use identical split, horizon, benchmark, and evaluation settings across built-in and custom variants.
This separation matters because most method papers compare combinations: a new representation with existing models, existing representations with a new model, or a custom representation and custom model together. macroforecast should make each of those comparisons executable without package-internal edits.