assembly-economics/simulations/gadm02/calibrate_data.py

239 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Calibrate GADM-0.4 starting states to measured data (ASSEMBLY-WP-0013-T02/T03).
Targets: calibration_targets.json (ranges per jurisdiction; operators in
models/Calibration.md). Per jurisdiction:
1. Set stocks with targets directly (power channels, E, Sigma) and fit the
parameters that place them at rest: channel counters (gadm04.calibrate),
ecological regeneration `ereg` (dE = 0), base labour share `wL0` (claims
breadth B on target), attention extraction `aext` (attention at rest on
target, bisection). Where a measured trend of B exists (US), the
automation strength `auto` is fitted to it. Archetype levers of the nearest archetype
(models/AdoptionBand.md § 4.4) are applied except `perm`.
2. Relax wealth, cohort attention and cohort agency to equilibrium.
3. Adoption: with an anchor's adoption parameters scaled by k, find the model
time t* where K_Omega's one-year ratio equals the observed ratio, and fit k
so that K_Omega(t*) hits the level target. Fixed anchors (k = 1) are also
aligned by growth alone, and their level miss is reported: that is the
data's verdict on each anchor.
4. The calibrated start is the full model state at t* with the fitted
parameters (results/calibrated_starts.json).
Residuals are z-scores: (model - centre) / half-width of the target range;
|z| <= 1 means inside the range.
Usage: python3 -B simulations/gadm02/calibrate_data.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
sys.dont_write_bytecode = True
import gadm04 as g # noqa: E402
import grounding as gr # noqa: E402
from jurisdictions import ARCHETYPES # noqa: E402
HERE = Path(__file__).resolve().parent
OUT = HERE / "results"
TARGETS = json.loads((HERE / "calibration_targets.json").read_text())["targets"]
NEAREST = {"US": "J3", "EU": "J1", "DE": "J1", "AR": "J2", "CN": "J4"}
FALLBACK = {"DE": "EU"} # DE uses EU targets where it has none
CHANNEL_KEYS = ("Pm", "Pi", "Pd", "Pmem", "Plin")
ADOPT = ("aO", "ell", "oseed")
STEP = 0.1
BASE_ANCHOR = "B2" # adoption parameters scaled by k start from B2
def targets(jur: str) -> dict:
t = dict(TARGETS.get(FALLBACK.get(jur, ""), {}))
t.update(TARGETS[jur])
return t
def centre(t: dict) -> float:
return 0.5 * (t["lo"] + t["hi"])
def z(value: float, t: dict) -> float:
return (value - centre(t)) / (0.5 * (t["hi"] - t["lo"]))
def relax(s: dict, p: dict, keys=gr.EQ_KEYS, steps: int = 4000, h: float = 0.25) -> dict:
s = dict(s)
for _ in range(steps):
d, _a = g.derivatives(s, p)
for k in keys:
s[k] = min(1.0, max(0.0, s[k] + h * d[k]))
return s
def start_state(jur: str, anchor: str, auto: float | None = None) -> tuple[dict, dict]:
"""(params, init) at rest with the jurisdiction's state targets, before adoption alignment."""
t = targets(jur)
vp, vi = gr.variant(gr.BAND[anchor])
_title, lev, _s0 = ARCHETYPES[NEAREST[jur]]
p = {**g.PARAMS, **vp, **{k: v for k, v in lev.items() if k != "perm"}}
if auto is not None:
p["auto"] = auto
s = {**g.INIT, **vi}
for k in CHANNEL_KEYS:
if k in t:
s[k] = centre(t[k])
if "E" in t:
s["E"] = centre(t["E"])
if "Sig" in t:
s["Sig"] = s["Lg"] = centre(t["Sig"])
p["sig_ref"] = s["Sig"] # BR-009: sentiment anchored at its measured level
for _ in range(2):
if "B" in t: # base labour share so that B(t0) is on target
c = p["dshare"] + p["cp"]
k0 = c + max(0.0, 1 - c) * p["od"]
wl = (centre(t["B"]) - k0) / (1 - k0)
p["wL0"] = max(0.05, min(0.95, wl / (1 - p["auto"] * s["Ko"])))
d, a = g.derivatives(s, p)
if "E" in t: # regeneration so that E is at rest
drain = (p["e1"] * s["Kp"] * a["dem"] * p["thr"] + p["e2"] * s["Ko"])
p["ereg"] = drain * s["E"] / (1 - s["E"])
p.update(g.calibrate(p, s))
s = relax(s, p)
if "A" in t:
p["aext"], s = fit_attention(s, p, centre(t["A"]))
return p, s
def fit_attention(s: dict, p: dict, target: float) -> tuple[float, dict]:
"""Bisection on the extraction strength aext so that attention at rest equals `target`."""
lo, hi = 0.05, 3.0
for _ in range(14):
mid = 0.5 * (lo + hi)
s2 = relax(s, {**p, "aext": mid}, steps=1500)
_d, a = g.derivatives(s2, {**p, "aext": mid})
lo, hi = (mid, hi) if a["A"] > target else (lo, mid)
aext = 0.5 * (lo + hi)
return aext, relax(s, {**p, "aext": aext})
def align(rows: list[dict], ratio: float) -> int | None:
"""First row after the growth peak whose one-year K_Omega ratio falls to `ratio`."""
k = int(round(1 / STEP))
rs = [rows[i + k]["Ko"] / rows[i]["Ko"] for i in range(len(rows) - k)]
peak = rs.index(max(rs))
if rs[peak] < ratio:
return None
return next((i for i in range(peak, len(rs)) if rs[i] <= ratio), None)
def adoption_path(p: dict, s: dict, kfac: float, anchor_params: dict | None = None) -> list[dict]:
q = dict(p)
if anchor_params is not None:
q.update({k: anchor_params[k] for k in ADOPT if k in anchor_params})
q["selfenh"] = anchor_params.get("selfenh", 0.0)
for k in ADOPT:
q[k] = q[k] * kfac
return g.simulate(q, s, T=20, record_every=1), q
def fit_k(jur: str, p: dict, s: dict) -> tuple[float, int, list[dict], dict]:
t = targets(jur)
ratio, ko_t = centre(t["Ko_ratio_1y"]), t["Ko"]
best = None
for i in range(-16, 17): # k in [0.25, 4], log grid
kf = 2 ** (i / 8)
rows, q = adoption_path(p, s, kf)
j = align(rows, ratio)
if j is None:
continue
err = z(rows[j]["Ko"], ko_t) ** 2
if best is None or err < best[0]:
best = (err, kf, j, rows, q)
if best is None:
raise RuntimeError(f"{jur}: no adoption speed reproduces the observed growth")
return best[1], best[2], best[3], best[4]
def residuals(jur: str, row: dict, p: dict, rows: list[dict], j: int) -> dict:
t = targets(jur)
k = int(round(1 / STEP))
model = {"Ko": row["Ko"], "Ko_ratio_1y": rows[j + k]["Ko"] / rows[j]["Ko"] if j + k < len(rows) else math.nan,
"B": row["B"], "A": row["A"], "E": row["E"], "Sig": row["Sig"],
**{c: row[c] for c in CHANNEL_KEYS}}
if "B_trend" in t and j + k < len(rows):
model["B_trend"] = rows[j + k]["B"] - row["B"]
return {name: (model[name], z(model[name], tt)) for name, tt in t.items() if name in model}
def main() -> None:
OUT.mkdir(exist_ok=True)
lines = [f"# {g.MODEL_VERSION}: calibration to measured data", "",
"Generated by `simulations/gadm02/calibrate_data.py` (ASSEMBLY-WP-0013-T02/T03).",
"Targets: `calibration_targets.json`; z = (model − centre) / half-width, |z| ≤ 1 inside the",
f"range. Adoption scaled from anchor {BASE_ANCHOR} by k; t* aligned by observed growth.", ""]
starts = {}
lines += ["## Fitted adoption speed and alignment", "",
"| Jurisdiction | Archetype levers | k | t* (y) | K_Ω(t*) | fitted wL0 | fitted ereg | fitted aext | auto |",
"|---|---|---|---|---|---|---|---|---|"]
verdict = ["", "## Fixed anchors aligned by growth: K_Ω level miss", "",
"| Jurisdiction | " + " | ".join(gr.BAND) + " |", "|---|" + "---|" * len(gr.BAND)]
resid_lines = []
resid_all = {}
for jur in ("US", "EU", "DE", "AR", "CN"):
t = targets(jur)
p, s = start_state(jur, BASE_ANCHOR)
if "Ko_ratio_1y" not in t:
lines.append(f"| {jur} | {NEAREST[jur]} | — | — | — | {p['wL0']:.3f} | {p['ereg']:.4f} | {p['aext']:.3f} | {p['auto']:.2f} |")
starts[jur] = {"params": {k: p[k] for k in p if p[k] != g.PARAMS.get(k)}, "init": s, "t_star": None,
"note": "no measured adoption growth; adoption left at anchor B2 from its start"}
continue
kf, j, rows, q = fit_k(jur, p, s)
if "B_trend" in t: # automation strength so that B's one-year change is on target
lo, hi = 0.05, 1.2
for _ in range(8):
mid = 0.5 * (lo + hi)
p, s = start_state(jur, BASE_ANCHOR, auto=mid)
kf, j, rows, q = fit_k(jur, p, s)
k1 = j + int(round(1 / STEP))
lo, hi = (lo, mid) if rows[k1]["B"] - rows[j]["B"] < centre(t["B_trend"]) else (mid, hi)
p, s = start_state(jur, BASE_ANCHOR, auto=0.5 * (lo + hi))
kf, j, rows, q = fit_k(jur, p, s)
row = rows[j]
lines.append(f"| {jur} | {NEAREST[jur]} | {kf:.2f} | {row['t']:.1f} | {row['Ko']:.3f} | {q['wL0']:.3f}"
f" | {q['ereg']:.4f} | {q['aext']:.3f} | {q['auto']:.2f} |")
init = {k: row[k] for k in g.STOCKS}
starts[jur] = {"params": {k: q[k] for k in q if q[k] != g.PARAMS.get(k)}, "init": init,
"t_star": row["t"], "k": kf}
res = residuals(jur, row, q, rows, j)
resid_all[jur] = res
resid_lines += [f"### {jur}", "", "| Target | Range | Model at t* | z | basis |", "|---|---|---|---|---|"]
for name, (val, zz) in res.items():
tt = t[name]
flag = "" if abs(zz) <= 1 else " ⚠"
resid_lines.append(f"| {name} | {tt['lo']}–{tt['hi']} | {val:.3f} | {zz:+.2f}{flag} | {tt['basis']} |")
resid_lines.append("")
cells = []
for a in gr.BAND:
vp, _vi = gr.variant(gr.BAND[a])
rows_a, _q = adoption_path(p, s, 1.0, anchor_params=vp)
ja = align(rows_a, centre(t["Ko_ratio_1y"]))
cells.append("cannot reach growth" if ja is None else
f"K_Ω {rows_a[ja]['Ko']:.3f}, z {z(rows_a[ja]['Ko'], t['Ko']):+.1f}")
verdict.append(f"| {jur} | " + " | ".join(cells) + " |")
n_in = sum(1 for r in resid_all.values() for _v, zz in r.values() if abs(zz) <= 1)
n_all = sum(len(r) for r in resid_all.values())
n_meas = sum(1 for jur, r in resid_all.items() for name in r if targets(jur)[name]["basis"] == "measured")
n_meas_in = sum(1 for jur, r in resid_all.items() for name, (_v, zz) in r.items()
if targets(jur)[name]["basis"] == "measured" and abs(zz) <= 1)
lines += ["", f"**Fit summary:** {n_in} of {n_all} targets inside their range; measured targets "
f"{n_meas_in} of {n_meas}."]
lines += verdict + ["", "## Residuals at the calibrated start", ""] + resid_lines
(OUT / "calibration.md").write_text("\n".join(lines))
(OUT / "calibrated_starts.json").write_text(json.dumps(starts, indent=1, sort_keys=True))
print("\n".join(lines))
if __name__ == "__main__":
main()