Several shocks in one scenario¶
A macro model rarely projects one thing. It gives a wage path and a labour-market path, and both belong in one scenario: the household-level answer to “what does this projection mean” is the answer to both together, not the sum of two separate answers.
So a shock table may carry several channels, and nothing about the scenario document changes when it does:
scenario = {
"country_code": "BE",
"system_name": "BE_2025",
"shocks": {"inline": [
{"channel": "scale", "metric": "employment income", "group": "deh=0-3",
"period": "1", "op": "grow", "value": 0.03},
{"channel": "align", "metric": "inactivity_rate", "group": "deh=0-3",
"period": "1", "op": "add", "value": -0.035},
]},
"params": {"period": "1"},
}
plan = apply_scenario(system, data, scenario)
plan["methodology"] # 'scale_variables+lma_labour_alignment'
plan["methods"] # ['scale_variables', 'lma_labour_alignment']
One channel, one method¶
Dispatch is per channel. Each channel in the table resolves to the method that consumes it, independently of every other channel, and the scenario runs those methods one after another. There is no method for a combination of channels — the combination is a property of the scenario, and the engine composes the methods.
That is a deliberate shape. A method written for a pair of channels would need to be written
again for every other pair, and once more for each triple; and it would be a candidate for
any table carrying only one of its channels, so dispatch would have to explain why the
dedicated method still wins. Per-channel dispatch has neither problem. It also keeps a
mistake local: a mistyped align metric is refused by name at dispatch, whatever else the
table carries, rather than surfacing later as a complaint about some other channel.
The order is the methods’, not yours¶
Each method declares a stage, and methods run in stage order — a property of what the method does to the input, so it holds for every combination without being stated for each:
STAGE_VALUES—scale_variablesArithmetic on what the input records. Rows and people are unchanged.
STAGE_PEOPLE—lma_labour_alignmentWho is in which state: transitions, household weight splits.
Values first, then people. The values a method changes are the environment the later
transitions happen in. lma_labour_alignment pays a new worker their own predicted hourly
wage yivwg — an input variable — so scaling first is what makes an entrant enter at
counterfactual wages. Moving people first and scaling after would leave entrants on baseline
wages, because their earnings land in yem_a, which a yem or ils_udb_yem shock does not
reach: the scaling would silently miss precisely the people the alignment created.
The order you write the records in never matters. A shock table is sorted by
(channel, metric, group, period) and carries no order of its own — which is what keeps the
content id a property of the scenario rather than of how it was typed.
Methods sharing a stage are assumed to commute; within a stage they run in name order, and that order must not matter.
What each method sees¶
Each method is handed only the records of the channels it consumes, and runs on the frame the
previous method produced. check_dataset, income-list expansion, cell collapsing, the
op-commutativity rules, targets, scoring, weight splits and entrant earnings all behave exactly
as they do when the method runs alone — composition adds nothing to a method and takes nothing
from it.
Params and run arguments¶
One scenario has one set of params, so every method reads the same period. The document is
validated against the union of the methods’ params_schema, still with
additionalProperties: false: tolerance_pct is accepted when lma_labour_alignment runs and
refused when it does not, and a param no method declares is an error.
The add-ons and extension switches of a run are likewise the union of what the methods need.
What the baseline is¶
The untouched input: the scenario sets up “all shocks against none”.
plan["baseline"] — the paired baseline — is built by the last method that restructured
rows, on the frame it received. With scaling before alignment it therefore carries the scaling:
its job is to keep the two frames row-aligned so a fixed poverty line or baseline-defined
decile groups mean something, not to undo the earlier stages. Run it with paired_baseline=True
when a baseline-vs-reform statistics template will pair observations.
If you want a shock treated as context rather than as part of the experiment, the scenario’s
constants are applied to both runs; for anything else, run two scenarios.
Reading the diagnostics¶
One method: its diagnostics as they are, flat, exactly as documented for that method.
Several: each method’s diagnostics unflattened under its name, with order naming the
sequence and stages saying which channels each one took:
plan["diagnostics"]
# {'order': ['scale_variables', 'lma_labour_alignment'],
# 'stages': [{'method': 'scale_variables', 'stage': 10, 'channels': ['scale']},
# {'method': 'lma_labour_alignment', 'stage': 20, 'channels': ['align']}],
# 'scale_variables': {'applied': [...], 'income_list_expansions': {...}, ...},
# 'lma_labour_alignment': {'targets': [...], 'grades': {...}, 'transitions': {...}, ...},
# 'warnings': [...],
# 'cell_population': {...}}
Anything you already read from a method on its own is in the same place one level down. The
top-level warnings is the concatenation of every method’s.
Previewing¶
apply_scenario(..., validate_only=True) previews each method in turn. A later stage is
previewed against what the earlier ones would hand it, where an earlier method declares that
its apply() is cheap enough to run on the validation path (preview_by_applying, which
scale_variables does): a preview that sized an alignment against the unscaled frame could
disagree with the run it previews. Nothing is fitted or aligned in a preview.
Reproducing a run¶
The result’s methodology is the methods in run order, joined with +, and it is accepted
back as the scenario’s methodology pin:
scenario["methodology"] = "scale_variables+lma_labour_alignment"
A pin must cover every channel in the table. The scenario fingerprint folds
in every method’s code_fingerprint and their order, so editing either method — or a change
of stage — invalidates a cached result rather than serving one computed by the earlier code.
When not to combine¶
If you want the shocks in a different order, or one of them as context, chain two scenarios and feed the first counterfactual to the second:
plan_a = apply_scenario(system, data, wage_scenario)
plan_b = apply_scenario(system, plan_a["counterfactual"], alignment_scenario)
which makes the order and the baseline yours to state.