Calibration¶
The default workflow relies on several separate calibrations that each
transform outputs of a dedicated solve into a git-tracked input consumed
by every subsequent solve. Running tools/calibrate regenerates them
all in the right dependency order; individual steps can also be run
directly.
The calibration artefacts live under data/curated/calibration/ and
are version-controlled so that ordinary builds don’t need to re-solve
anything.
Note
Canonical configuration pattern. Every calibration section has two
flags: enabled controls whether the calibration is applied at
solve/build time, and generate controls whether the workflow
produces the calibration file from a source scenario. The canonical
pattern for generation is enabled: false, generate: true so that
enabled is the single source of truth at runtime. The alternative
(enabled: true, generate: true) is rejected by
workflow/validation/calibration.py to keep configurations
unambiguous. The same validator also checks that the named source
scenario is defined under config["scenarios"] when
generate: true, and that referenced calibration files exist on
disk when enabled: true, generate: false.
Step |
Config |
Produces |
Purpose |
|---|---|---|---|
|
|
Per-country corrections that balance ruminant-forage and monogastric/ruminant-protein supply against the GLEAM3-derived baseline demand. |
|
|
|
Per-food-group multiplier on the consumer-side waste fraction that absorbs systematic food-bus surpluses/shortages relative to the GDD-IA intake baseline. |
|
|
|
Per-food global multiplier on the baseline-diet |
|
|
|
Additive production-cost corrections derived from stability- constraint duals (observed allocation -> optimal allocation). |
|
|
|
The L1 penalty pair \((\ell^c_1, \ell^a_1)\) that brings both land-use and animal-feed deviations to ~5 % of observed totals. |
Dependency order¶
When upstream data or build logic changes, rerun in this order:
feed — other calibrations solve against a model whose feed slack is already closed by the forage and protein corrections.
food_waste — uses the calibrated feed behaviour so that food-bus slack is not contaminated by feed-side mismatches.
food_demand — uses the calibrated food-waste fractions so that any per-food gap that remains reflects a genuine supply / demand mismatch (FAOSTAT QCL vs GDD-IA) and not a waste mis-attribution.
cost — the cost-calibration solve uses the calibrated feed, waste, and demand behaviour to extract duals that make economic sense. Without the food-demand step, residual per-food mismatch leaks into the cost-calibration duals as spurious sign (e.g. olive-oil cost driven negative, coffee/cocoa pegged at the slack ceiling) and inflates the stability L1 cost downstream.
stability — the L1 Broyden iteration uses all previous corrections so that the observed deviations reflect the fully-calibrated baseline.
Running the calibrations¶
Everything is wrapped by tools/calibrate:
tools/calibrate # all, in dependency order
tools/calibrate feed # one step (forage + protein feed slack)
tools/calibrate food_waste
tools/calibrate food_demand
tools/calibrate cost
tools/calibrate stability
tools/calibrate --check # per-step staleness, no execution
The wrapper invokes tools/smk with the matching config and the
appropriate output targets. Any extra flags are passed through, e.g.
tools/calibrate cost -j8 --slurm.
The stability calibration runs locally in-process and is inherently sequential (each Broyden step depends on the previous solve), so HPC offloading isn’t worthwhile at this size. Each iteration is one paired solve (baseline + main), and 3–5 iterations are typically enough.
Consuming the calibrated values¶
All calibration outputs are consumed automatically by the default workflow when their configuration blocks are enabled (the default):
grazing.grassland_forage_calibration.enabled: trueloads the three forage-side CSVs at solve time (see Grassland Forage Calibration).feed_protein_calibration.enabled: trueloadsexogenous_protein.csvand injects free per-country generators on the monogastric/ruminant protein feed buses (see Exogenous Protein Feed).food_demand_calibration.enabled: trueloadsdata/curated/calibration/food_demand.csvat solve time and applies each per-food multiplier uniformly to the baseline-diettarget_mtin_match_baseline_to_consume_links(see Food-demand calibration).cost_calibration.enabled: trueloads the three cost-correction CSVs at build time (see Calibration Correction).deviation_penalty.calibration.enabled: trueresolves the sentinel"calibrated"on any ofdeviation_penalty.{land,feed,diet}.l1_costfromdata/curated/calibration/deviation_penalty.yamlat solve time (see Deviation Penalty for the config reference). Scenarios that want an explicit numeric value simply override the sentinel with a number; scenarios that want to scan around the calibrated value can leave the sentinel in place and set the matchingl1_cost_factor.
Feed calibration¶
The feed step generates two parallel sets of corrections from a single validation solve:
Forage corrections (surplus + deficit on
feed:ruminant_forage:*). Surplus countries get a per-country multiplier on grassland yield and fodder-conversion efficiency; deficit countries get an exogenous-forage supply written todata/curated/calibration/exogenous_forage.csv. See Grassland Forage Calibration in the livestock chapter for the algorithm.Protein corrections (deficit side only, on
feed:monogastric_protein:*andfeed:ruminant_protein:*). The positive slack on each protein feed bus is written todata/curated/calibration/exogenous_protein.csv. See Exogenous Protein Feed for what real-world sources it stands in for.
Both rules read the same solved validation network. The relevant
Snakemake rules are compute_grassland_calibration (forage) and
compute_protein_feed_calibration (protein), both in
workflow/rules/animals.smk. generate: true lives in
config/calibration/feed.yaml and is false everywhere else,
which breaks the otherwise circular dependency.
Food-waste calibration¶
See the food-loss-and-waste discussion in Food Processing & Trade for
the underlying SDG 12.3 derivation. Rule:
compute_food_waste_calibration in workflow/rules/diet.smk;
generate: true lives in config/calibration/food_waste.yaml.
Food-demand calibration¶
Even after the food-waste step closes the group-level gap between
FAOSTAT-derived supply and GDD-IA-derived intake, per-food residuals
remain: foods within a group can be jointly consistent at the group
total while individual foods are systematically over- or
under-demanded. The food-demand calibration absorbs this residual into
a per-food global multiplier on the baseline-diet target_mt.
The multiplier is derived from the global food-bus balance reported by
an uncalibrated validation-mode solve (scenario: uncalibrated in
config/calibration/food_demand.yaml, with
food_demand_calibration.enabled: false and generate: true so
the solve does not read the calibration file it is about to write,
following the
canonical pattern):
where \(C_f = \sum_c p^0_{\ell_{c,f}}\) is total food-consumption
flow for food \(f\) and
\(N_f = \mathrm{slack}^{+}_{f} - \mathrm{slack}^{-}_{f}\) is the
net food-bus slack on the two slack_positive_food /
slack_negative_food generators (positive slack = LP had to invoke a
shortage filler, so demand was too high and the multiplier shrinks;
negative slack = LP absorbed excess, so demand was too low and the
multiplier grows). Both quantities are summed over countries for each
food. The clip bounds (min_multiplier / max_multiplier in the
config) default to [0.5, 2.0]; they are tight on purpose so that an
out-of-range value flags a structural data issue rather than being
silently absorbed.
At solve time _match_baseline_to_consume_links (in
workflow/scripts/solve_model/core.py) applies the multiplier
uniformly across all countries for each food when
food_demand_calibration.enabled is true.
Rule: compute_food_demand_calibration in
workflow/rules/diet.smk. Script:
workflow/scripts/compute_food_demand_calibration.py.
Cost calibration¶
The cost calibration is a two-step paired solve:
Step 1 (consumer-value extraction). A baseline solve with
enforce_baseline_diet: true extracts food-bus duals to build the
piecewise consumer-utility blocks used downstream. Step 1 enables hard
production-stability bounds at +/-20 % for crops, grassland, and
animals; this prevents the LP from idealising supply patterns and
pushing the consumer-value duals below realistic supply cost, which in
earlier versions forced step 2 to absorb a large negative correction.
The +/-20 % band is loose enough to accommodate the structural
FAOSTAT-vs-FBS mismatch carried by most foods. For the small set of
foods whose mismatch still exceeds the band (buckwheat, plantain,
coffee, tea, olive-oil), a file-level
validation.slack_marginal_cost: 5.0 ($5 000/t) override caps the
slack-driven duals at the upper end of realistic wholesale prices –
instead of the default ~$50 000/t slack ceiling – while leaving
enough headroom for legitimately high-value foods.
Step 2 (cost-correction extraction). A second solve activates the piecewise utility built in step 1 and tightens production stability to +/-1 %. The dual \(\mu^+_\ell - \mu^-_\ell\) on each tight production-stability constraint indicates how much the link’s marginal cost would need to shift for the observed allocation to be cost-optimal; the per-group median becomes an additive correction. See Calibration Correction for how the corrections are applied at build time.
Rule: extract_cost_calibration in workflow/rules/crops.smk.
Script: workflow/scripts/extract_cost_calibration.py. The two
scenarios (baseline and calibration) live in
config/calibration/cost.yaml.
Production-stability L1 calibration¶
Motivation¶
A pure cost-minimisation solve of a global food system model is free to reorganise production arbitrarily: if a country produces wheat more cheaply than its neighbour, the optimiser will shift the neighbour’s entire wheat output across the border. This is unrealistic — real production patterns reflect a long tail of frictions (rotations, contracts, infrastructure, labour, insurance, policy) that the model does not represent. Without a counterweight, the optimal allocation diverges sharply from observed production and analyses that build on top (marginal-cost attribution, counterfactual comparisons, sensitivity analysis) become different to relate to the current food system.
The model therefore adds a production-stability penalty (see Deviation Penalty for the full configuration reference) that discourages departures from the observed-year baseline. Every crop, grassland and animal-feed production link \(\ell\) carries a linear \(L_1\) term in the objective,
where \(\bar x_\ell\) is the baseline activity of the link (area in Mha for crops / grassland, feed use in Mt DM for animals) and \(\ell^c_1\), \(\ell^a_1\) are the two penalty coefficients calibrated here. The \(L_1\) form is convenient: it can be implemented linearly so the LP stays an LP.
Why two coefficients?¶
Land activity and animal-feed activity are measured in different units and have different baseline totals (roughly 4,000 Mha of land vs 6,500 Mt DM of feed). A single shared coefficient would penalise one axis much more strongly than the other. Splitting the penalty into a crop/grassland coefficient \(\ell^c_1\) (bn USD per Mha of deviation) and an animal-feed coefficient \(\ell^a_1\) (bn USD per Mt DM) lets us tune each axis independently.
Calibration target¶
We pick \((\ell^c_1, \ell^a_1)\) so that the optimal solution exhibits ~5 % total deviation on each axis (summed absolute deviation divided by the baseline total). The 5 % target is a compromise: large enough that the optimiser can still express meaningful shifts in response to scenarios (carbon prices, diet changes, yield shocks), small enough that the resulting production pattern stays recognisably close to observed production and interpretation remains tractable.
Formally the calibration solves
Broyden iteration¶
The deviation map
is monotone and near-affine in log-log coordinates (raising \(\ell^c_1\) mainly tightens land deviation, raising \(\ell^a_1\) mainly tightens feed deviation, with small cross coupling). Calibration is therefore a 2-D root-finding problem, solved with Broyden’s quasi-Newton method on \(x = (\log \ell^c_1, \log \ell^a_1)\) with residual \(r(x) = (\log(\text{land\_dev}/t),\, \log(\text{feed\_dev}/t))\). A trust-region cap of \(\lvert \Delta x \rvert_\infty \le \log 2\) prevents single-step overshoot near the zero-baseline growth caps.
Each iteration is one paired solve (baseline with
enforce_baseline_diet=true to derive consumer values, then main
with piecewise utility active). Convergence is typically reached in
3–5 iterations from a cold start and 1–2 from a warm start (the
previously calibrated YAML is auto-detected and used as the seed). The
initial Jacobian is \(\mathrm{diag}(-1, -1)\), which is the exact
log-log slope for a relationship of the form
\(\text{dev} \propto 1/\ell_1\).
Convergence target: \(\lvert \log(\text{dev}/t) \rvert_\infty
< 0.02\), i.e. all calibrated deviations within +/-2 % of the target.
The calibrated coefficients are written to
data/curated/calibration/deviation_penalty.yaml under
l1_costs.<component> and resolved at solve time wherever the
sentinel "calibrated" appears in
deviation_penalty.{land,feed,diet}.l1_cost (see
Deviation Penalty).
A per-iteration diagnostic CSV is written to
results/{name}/calibration/deviation_penalty_trace.csv with the
per-component iterate, achieved deviations, and residual norm for each
step.
The set of components driven simultaneously is configured via
deviation_penalty.calibration.components (default
[land, feed]). Diet calibration is available as an opt-in
components: [land, feed, diet] profile for specific investigations
where the priced optimum would otherwise reshuffle the diet
substantially.
Implementation¶
Rule: calibrate_deviation_penalty in
workflow/rules/deviation_penalty.smk. Script:
workflow/scripts/calibrate_deviation_penalty.py.
The calibrated L1 centre also defines the reference regime for the GSA
scenario groups (gsa, gsa-l1-low, gsa-l1-high); see
Deviation-penalty regime (gsa-l1-low / gsa-l1-high scenario groups).
Staleness detection¶
tools/smk prints a one-line reminder when any file under
data/curated/ (excluding data/curated/calibration/ itself) is
newer than the oldest file in data/curated/calibration/. This is a
cheap mtime heuristic and may produce false positives after
git pull (because checkout touches mtimes); for the authoritative
answer run
tools/calibrate --check
which performs a Snakemake dry-run against each calibration target and
reports [up-to-date] / [STALE] per step.
Set SMK_SKIP_CALIBRATION_HINT=1 to silence the reminder in scripted
contexts.