SPB Git forge

spb/wp18_uqo

Public
6commits 1branches 0releases
5.4 MBsize
maindefault branch
yesterdaylast push
TeX 53.2% Python 46.6%
29.9 KB · 503 lines python
Raw Blame History
1"""WP18 — publication figures (PDF vector + PNG preview) in results/figures/.23Every figure reads only files under results/data/ (traceability). Sober palette readable in4black and white: a grey-to-black sequential ramp plus distinct line styles and markers.5Usage: python -m src.figures6"""7from __future__ import annotations89import json10import os11from pathlib import Path1213import matplotlib14matplotlib.use("Agg")15import matplotlib.pyplot as plt  # noqa: E40216import numpy as np  # noqa: E40217import pandas as pd  # noqa: E4021819from .model import RESULTS_DIR  # noqa: E4022021DATA = RESULTS_DIR / "data"22FIG = RESULTS_DIR / "figures"23FIG.mkdir(parents=True, exist_ok=True)2425# ---- style ---------------------------------------------------------------------------26plt.rcParams.update({27    "font.family": "serif", "font.size": 9, "axes.titlesize": 9.5, "axes.labelsize": 9, "legend.fontsize": 8,28    "xtick.labelsize": 8, "ytick.labelsize": 8, "axes.spines.top": False, "axes.spines.right": False,29    "figure.dpi": 110, "savefig.dpi": 200, "lines.linewidth": 1.4, "axes.grid": True, "grid.alpha": 0.25,30    "grid.linestyle": ":", "legend.frameon": False,31})32GREYS = ["#111111", "#4d4d4d", "#8a8a8a", "#b8b8b8"]33STYLES = ["-", "--", "-.", ":"]34MARKS = ["o", "s", "^", "D", "v"]35AGE_LABELS = ["25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80"]36373839def _suptitle(fig, text, **kw):40    """In-image titles are off by default: the paper supplies LaTeX captions (set WP18_FIG_TITLES=1 to keep them)."""41    if os.environ.get("WP18_FIG_TITLES", "0") == "1":42        kw.setdefault("x", 0.01); kw.setdefault("ha", "left"); kw.setdefault("fontsize", 9.5)43        _suptitle(fig, text, **kw)444546def _save(fig, name):47    fig.savefig(FIG / f"{name}.pdf", bbox_inches="tight")48    fig.savefig(FIG / f"{name}.png", bbox_inches="tight")49    plt.close(fig)50    print("figure:", name)515253def _json(path):54    with open(path) as fh:55        return json.load(fh)565758def _exists(*paths):59    return all(Path(p).exists() for p in paths)606162def _age_axis(ax):63    ax.set_xticks(range(1, 13))64    ax.set_xticklabels(AGE_LABELS)65    ax.set_xlabel("Age (start of 5-year period)")666768# ======================================================================================69def fig01_timing():70    """Model timing: lifecycle, dynasty link, transfers, mortgage term."""71    fig, ax = plt.subplots(figsize=(6.6, 2.6))72    ax.set_xlim(0.5, 18.5); ax.set_ylim(0, 3.2); ax.axis("off")73    # parent74    ax.plot([1, 12], [2.4, 2.4], color=GREYS[0], lw=2)75    for j in range(1, 13):76        ax.plot([j], [2.4], marker="|", color=GREYS[0], ms=8)77    ax.text(0.9, 2.75, "Parent (ages 1–12, 25–84)", fontsize=8)78    ax.text(6.9, 2.05, "gift $b^g$ at $j_g=7$", fontsize=8, ha="right")79    ax.text(12.1, 2.75, "estate $b^e$ at $J=12$", fontsize=8, ha="center")80    ax.axvspan(9, 12.1, ymin=0.72, ymax=0.80, color=GREYS[3], alpha=0.5)81    ax.text(10.5, 2.1, "retirement", fontsize=7.5, ha="center")82    # child83    ax.plot([7, 18], [1.2, 1.2], color=GREYS[1], lw=2)84    for j in range(7, 19):85        ax.plot([j], [1.2], marker="|", color=GREYS[1], ms=8)86    ax.text(6.9, 0.55, "Child (born at parent's age 7): ages 1–12", fontsize=8)87    ax.annotate("", xy=(7, 1.35), xytext=(7, 2.3), arrowprops=dict(arrowstyle="->", color=GREYS[0]))88    ax.annotate("", xy=(13, 1.35), xytext=(12, 2.3), arrowprops=dict(arrowstyle="->", color=GREYS[0]))89    ax.text(13.2, 1.5, "estate received at child's age 7", fontsize=7.5)90    ax.text(7.2, 1.5, "$a_1 = b^g$", fontsize=8)91    # mortgage term bracket92    ax.plot([8, 12.9], [0.95, 0.95], color=GREYS[2], lw=1)93    ax.text(10.4, 0.78, "e.g. purchase at age 2, $N=5$ payments (25 years)", fontsize=7, ha="center", color=GREYS[1])94    _suptitle(fig, "Figure 1. Timing of the overlapping-generations model (one period = 5 years)")95    _save(fig, "fig01_model_timing")969798def fig02_policy():99    f = DATA / "baseline" / "policy_age1.csv"100    if not f.exists():101        return102    df = pd.read_csv(f)103    df3 = pd.read_csv(DATA / "baseline" / "policy_age3.csv")104    fig, axes = plt.subplots(1, 3, figsize=(8.4, 2.7))105    zs = sorted(df.z_index.unique())106    for k, iz in enumerate(zs):107        d = df[(df.z_index == iz) & (df.cash < 6)]108        axes[0].plot(d.cash, d.W_buy - d.W_rent, color=GREYS[min(k, 3)] if k != 2 else "#000000", ls=STYLES[k % 4], label=f"$z={d.z.iloc[0]:.2f}$")109        a = np.where(d.buy == 1, d.a_buy, d.a_rent)110        axes[1].plot(d.cash, a, color=GREYS[min(k, 3)] if k != 2 else "#000000", ls=STYLES[k % 4])111        d3 = df3[(df3.z_index == iz) & (df3.cash < 6)]112        axes[2].plot(d3.cash, np.where(d3.buy == 1, d3.h_buy + 1, 0), color=GREYS[min(k, 3)] if k != 2 else "#000000", ls=STYLES[k % 4])113    axes[0].axhline(0, color="k", lw=0.6); axes[0].set_ylim(-0.6, 0.25)114    axes[0].set_title("(a) Buy minus rent value, age 1", loc="left"); axes[0].set_xlabel("Cash on hand $x$"); axes[0].set_ylabel("utility units (clipped at $-0.6$)")115    axes[0].legend(ncol=1, loc="lower right")116    axes[1].set_title("(b) Liquid saving $a'$, age 1", loc="left"); axes[1].set_xlabel("Cash on hand $x$")117    axes[2].set_title("(c) House bought (0 = rent), age 3", loc="left"); axes[2].set_xlabel("Cash on hand $x$")118    axes[2].set_yticks([0, 1, 2, 3, 4]); axes[2].set_yticklabels(["rent", "$h_1$", "$h_2$", "$h_3$", "$h_4$"])119    _suptitle(fig, "Figure 2. Policy functions on the cash-on-hand grid, baseline stationary equilibrium", x=0.01, ha="left", fontsize=9.5)120    fig.tight_layout()121    _save(fig, "fig02_policy_functions")122123124def fig03_thresholds():125    f = DATA / "baseline" / "thresholds.csv"126    if not f.exists():127        return128    th = pd.read_csv(f)129    fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.8))130    for k, j in enumerate((1, 2, 3)):131        d = th[th.age == j]132        axes[0].plot(d.z, d.a_hat, marker=MARKS[k], color=GREYS[k], ls=STYLES[k], label=f"age {j} ({25 + 5 * (j - 1)}–{29 + 5 * (j - 1)})")133    axes[0].axhline(0, color="k", lw=0.6)134    axes[0].set_xlabel("Productivity state $z$"); axes[0].set_ylabel("Threshold initial liquid wealth $\\hat a$")135    axes[0].set_title("(a) Ownership-entry threshold by age and $z$", loc="left"); axes[0].legend()136    # theta sweep137    rows = []138    for name, theta in [("theta_005", 0.05), ("theta_010", 0.10), ("theta_020", 0.20), ("theta_030", 0.30), ("theta_040", 0.40)]:139        fj = DATA / "theta_sweep" / f"{name}.json"140        fpe = DATA / "theta_sweep" / f"{name}_pe.json"141        if fj.exists():142            d = _json(fj)143            rows.append(dict(theta=theta, kind="GE", a_hat=d["thresholds"]["1"]["2"], p=d["p"], own=d["moments"]["ownership_rate_all"]))144        if fpe.exists():145            d = _json(fpe)146            rows.append(dict(theta=theta, kind="PE (prices fixed)", a_hat=d["thresholds"]["1"]["2"], p=d["p"], own=d["moments"]["ownership_rate_all"]))147    if rows:148        s = pd.DataFrame(rows)149        for k, kind in enumerate(s.kind.unique()):150            d = s[s.kind == kind].sort_values("theta")151            axes[1].plot(d.theta, d.a_hat, marker=MARKS[k], color=GREYS[k], ls=STYLES[k], label=kind)152        axes[1].set_xlabel("Minimum down payment $\\theta$"); axes[1].set_ylabel("$\\hat a$ at age 1, median $z$")153        axes[1].set_title("(b) Threshold and the down-payment constraint", loc="left"); axes[1].legend()154    _suptitle(fig, "Figure 3. Ownership-entry thresholds (units: average 5-year income)", x=0.01, ha="left", fontsize=9.5)155    fig.tight_layout()156    _save(fig, "fig03_ownership_thresholds")157158159def fig04_profiles():160    f = DATA / "baseline" / "profiles_by_age.csv"161    if not f.exists():162        return163    d = pd.read_csv(f)164    fig, axes = plt.subplots(1, 3, figsize=(8.4, 2.7))165    axes[0].plot(d.age, d.ownership, marker="o", color=GREYS[0], label="ownership rate")166    axes[0].plot(d.age, d.share_mortgage, marker="s", ls="--", color=GREYS[2], label="share with a mortgage")167    axes[0].set_ylim(0, 1); axes[0].set_title("(a) Tenure and mortgages", loc="left"); axes[0].legend(); _age_axis(axes[0])168    axes[1].fill_between(d.age, d.nw_p25, d.nw_p75, color=GREYS[3], alpha=0.6, label="p25–p75 (all)")169    axes[1].plot(d.age, d.nw_median, color=GREYS[0], marker="o", label="median (all)")170    axes[1].plot(d.age, d.nw_owner_median, color=GREYS[1], ls="--", marker="s", label="median, owners")171    axes[1].plot(d.age, d.nw_renter_median, color=GREYS[2], ls="-.", marker="^", label="median, renters")172    axes[1].set_title("(b) Net worth (5-year income units)", loc="left"); axes[1].legend(); _age_axis(axes[1])173    axes[2].plot(d.age, d.debt_owner_mean, color=GREYS[0], marker="o", label="mortgage balance, owners")174    axes[2].plot(d.age, d.liquid_mean, color=GREYS[2], ls="--", marker="s", label="liquid assets, all")175    axes[2].plot(d.age, d.ltv_owner_mean, color=GREYS[1], ls=":", marker="^", label="mean LTV, owners")176    axes[2].set_title("(c) Balance sheets", loc="left"); axes[2].legend(); _age_axis(axes[2])177    _suptitle(fig, "Figure 4. Baseline lifecycle profiles (stationary general equilibrium)", x=0.01, ha="left", fontsize=9.5)178    fig.tight_layout()179    _save(fig, "fig04_lifecycle_profiles")180181182def fig05_equal_income():183    f = DATA / "baseline" / "equal_income_paths.csv"184    if not f.exists():185        return186    d = pd.read_csv(f)187    fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.8))188    paths = [p for p in ["q10", "q50", "q75", "q90"] if p in set(d.path)]189    for k, p in enumerate(paths):190        dd = d[d.path == p]191        axes[0].plot(dd.age, dd.nw, color=GREYS[min(k, 3)], ls=STYLES[k % 4], marker=MARKS[k % 5], ms=3,192                     label=f"{p}: $a_1$ = {dd.a1.iloc[0]:.2f}, buys at age {int(dd[dd.owner == 1].age.min()) if (dd.owner == 1).any() else '–'}")193    axes[0].set_title("(a) Gift quantiles, median income path", loc="left"); axes[0].set_ylabel("Net worth"); axes[0].legend(); _age_axis(axes[0])194    for k, p in enumerate([q for q in ("threshold_minus", "threshold_plus") if q in set(d.path)]):195        dd = d[d.path == p]196        lab = "just below threshold (rents first)" if p == "threshold_minus" else "just above threshold (buys at age 1)"197        axes[1].plot(dd.age, dd.nw, color=GREYS[k * 2], ls=STYLES[k], marker=MARKS[k], ms=3, label=lab)198    axes[1].set_title("(b) Two households straddling the age-1 threshold", loc="left"); axes[1].legend(); _age_axis(axes[1])199    _suptitle(fig, "Figure 5. Equal-income wealth paths with different initial transfers (experiment 2, baseline policies)",200                 x=0.01, ha="left", fontsize=9.5)201    fig.tight_layout()202    _save(fig, "fig05_equal_income_paths")203204205def fig06_gift_quintiles():206    f = DATA / "baseline" / "by_gift_quintile.csv"207    if not f.exists():208        return209    d = pd.read_csv(f)210    fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.8))211    for k, q in enumerate(sorted(d.gift_quintile.unique())):212        dd = d[d.gift_quintile == q]213        col = GREYS[min(4 - k, 3)] if k != 4 else "#000000"214        axes[0].plot(dd.age, dd.ownership, color=col, ls=STYLES[k % 4], marker=MARKS[k], ms=3, label=f"quintile {q}")215        axes[1].plot(dd.age, dd.nw_median, color=col, ls=STYLES[k % 4], marker=MARKS[k], ms=3)216    axes[0].set_title("(a) Ownership rate by age", loc="left"); axes[0].set_ylim(0, 1); axes[0].legend(title="gift received", ncol=2); _age_axis(axes[0])217    axes[1].set_title("(b) Median net worth by age", loc="left"); _age_axis(axes[1])218    _suptitle(fig, "Figure 6. Trajectories by quintile of the inter-vivos transfer received (baseline cross-section)", x=0.01, ha="left", fontsize=9.5)219    fig.tight_layout()220    _save(fig, "fig06_inheritance_quantile_trajectories")221222223def fig07_inequality():224    files = {"baseline": DATA / "baseline" / "profiles_by_age.csv", "bequests off": DATA / "bequests" / "bequests_off_profiles.csv",225             "transfer tax 50 %": DATA / "bequests" / "transfer_tax_050_profiles.csv", "near-fixed supply": DATA / "supply" / "eps_005_profiles.csv"}226    files = {k: v for k, v in files.items() if v.exists()}227    if not files:228        return229    fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.8))230    for k, (lab, f) in enumerate(files.items()):231        d = pd.read_csv(f)232        axes[0].plot(d.age, d.gini_nw, color=GREYS[min(k, 3)], ls=STYLES[k % 4], marker=MARKS[k], ms=3, label=lab)233        axes[0].set_ylim(0, 1)234    axes[0].set_title("(a) Gini of net worth by age", loc="left"); axes[0].legend(); _age_axis(axes[0])235    # mobility bars236    names = {"baseline": DATA / "baseline" / "summary.json", "bequests off": DATA / "bequests" / "bequests_off.json",237             "transfer tax 50 %": DATA / "bequests" / "transfer_tax_050.json", "near-fixed supply": DATA / "supply" / "eps_005.json",238             "$\\theta=0.05$": DATA / "theta_sweep" / "theta_005.json", "$\\theta=0.40$": DATA / "theta_sweep" / "theta_040.json"}239    names = {k: v for k, v in names.items() if v.exists()}240    labs, qro, qoo, rr = [], [], [], []241    for lab, f in names.items():242        d = _json(f)["mobility"]243        labs.append(lab); qro.append(d["q_RO_age3"]); qoo.append(d["q_OO_age3"]); rr.append(d["rank_rank_slope_nw"])244    x = np.arange(len(labs)); w = 0.27245    axes[1].bar(x - w, qro, w, color=GREYS[2], label="P(child owns at 35–39 | parent renter)")246    axes[1].bar(x, qoo, w, color=GREYS[0], label="P(child owns at 35–39 | parent owner)")247    axes[1].bar(x + w, rr, w, color=GREYS[3], edgecolor=GREYS[1], label="rank–rank slope of net worth (age 55–59)")248    axes[1].set_xticks(x); axes[1].set_xticklabels(labs, rotation=20, ha="right"); axes[1].set_ylim(0, 1)249    axes[1].set_title("(b) Intergenerational tenure and wealth persistence", loc="left"); axes[1].legend(fontsize=6.5)250    _suptitle(fig, "Figure 7. Inequality and mobility across stationary equilibria (GE unless stated)", x=0.01, ha="left", fontsize=9.5)251    fig.tight_layout()252    _save(fig, "fig07_inequality_mobility")253254255def fig08_inflation_irf():256    names = [("frm_level", "FRM, level"), ("term5_level", "5-year term, level"), ("arm_level", "ARM, level"),257             ("frm_persistent", "FRM, persistent"), ("term5_persistent", "5-year term, persistent"), ("arm_persistent", "ARM, persistent")]258    avail = [(n, l) for n, l in names if (DATA / "inflation" / f"{n}_path.csv").exists()]259    if not avail:260        return261    fig, axes = plt.subplots(2, 2, figsize=(7.6, 5.2))262    for k, (n, lab) in enumerate(avail):263        d = pd.read_csv(DATA / "inflation" / f"{n}_path.csv")264        meta = _json(DATA / "inflation" / f"{n}.json")265        tag = "" if meta["converged"] else " [not converged]"266        col = GREYS[k % 3]; ls = "-" if "level" in n else "--"267        axes[0, 0].plot(d.years, d.p_pct, color=col, ls=ls, marker=MARKS[k % 5], ms=3, label=lab + tag)268        axes[0, 1].plot(d.years, d.rho_pct, color=col, ls=ls, marker=MARKS[k % 5], ms=3)269        axes[1, 0].plot(d.years, 100 * (d.ownership - d.ownership.iloc[-1]), color=col, ls=ls, marker=MARKS[k % 5], ms=3)270        axes[1, 1].plot(d.years, d.debt_total / d.debt_total.iloc[-1] * 100 - 100, color=col, ls=ls, marker=MARKS[k % 5], ms=3)271    pe = DATA / "inflation" / "frm_level_path_pe.csv"272    if pe.exists():273        d = pd.read_csv(pe)274        axes[1, 0].plot(d.years, 100 * (d.ownership - d.ownership.iloc[-1]), color=GREYS[3], ls=":", label="FRM level, PE (prices fixed)")275        axes[1, 1].plot(d.years, d.debt_total / d.debt_total.iloc[-1] * 100 - 100, color=GREYS[3], ls=":")276    for ax in axes.ravel():277        ax.axhline(0, color="k", lw=0.6); ax.set_xlabel("Years after the surprise")278    axes[0, 0].set_title("(a) Real house price, % from steady state", loc="left")279    axes[0, 1].set_title("(b) Real rent, % from steady state", loc="left")280    axes[1, 0].set_title("(c) Ownership rate, pp from steady state", loc="left")281    axes[1, 1].set_title("(d) Real mortgage debt, % from steady state", loc="left")282    axes[0, 0].legend(fontsize=6.5); axes[1, 0].legend(fontsize=6.5)283    _suptitle(fig, "Figure 8. Perfect-foresight transition after a 10 % unanticipated price-level surprise (GE unless labelled)",284                 x=0.01, ha="left", fontsize=9.5)285    fig.tight_layout()286    _save(fig, "fig08_inflation_transition")287288289def fig09_redistribution():290    f = DATA / "inflation" / "frm_level_impact.csv"291    if not f.exists():292        return293    fig, axes = plt.subplots(1, 3, figsize=(9.4, 2.8))294    d = pd.read_csv(f)295    d = d[d.age != "all"]296    for k, grp in enumerate(["owners", "renters"]):297        dd = d[d.group == grp].copy(); dd["age"] = dd.age.astype(int)298        axes[0].plot(dd.age, dd.net_nominal_gain, color=GREYS[k * 2], ls=STYLES[k], marker=MARKS[k], ms=3, label=grp)299        axes[1].plot(dd.age, dd.d_consumption_pct, color=GREYS[k * 2], ls=STYLES[k], marker=MARKS[k], ms=3, label=f"{grp}, GE")300        axes[1].plot(dd.age, dd.d_consumption_pe_pct, color=GREYS[k * 2], ls=":", marker=MARKS[k], ms=2, alpha=0.7, label=f"{grp}, PE (prices fixed)")301    axes[0].axhline(0, color="k", lw=0.6); axes[1].axhline(0, color="k", lw=0.6)302    axes[0].set_title("(a) Revaluation of nominal positions, FRM", loc="left"); axes[0].set_ylabel("5-year income units"); axes[0].legend(); _age_axis(axes[0])303    axes[1].set_title("(b) Consumption at impact, % vs no shock", loc="left"); axes[1].legend(fontsize=6.5); _age_axis(axes[1])304    # contract comparison of welfare by tenure305    rows = []306    for n, lab in [("frm_level", "FRM lvl"), ("term5_level", "T5 lvl"), ("arm_level", "ARM lvl"), ("frm_persistent", "FRM pers."),307                   ("term5_persistent", "T5 pers."), ("arm_persistent", "ARM pers.")]:308        fw = DATA / "inflation" / f"{n}_welfare.csv"309        if fw.exists():310            w = pd.read_csv(fw)311            w = w[w.age == "all"]312            rows.append(dict(contract=lab, owners_m=float(w[w.group == "owners_with_mortgage"].cev.iloc[0]) if (w.group == "owners_with_mortgage").any() else np.nan,313                             owners_nm=float(w[w.group == "owners_no_mortgage"].cev.iloc[0]) if (w.group == "owners_no_mortgage").any() else np.nan,314                             renters=float(w[w.group == "renters"].cev.iloc[0])))315    if rows:316        r = pd.DataFrame(rows); x = np.arange(len(r)); wdt = 0.27317        axes[2].bar(x - wdt, 100 * r.owners_m, wdt, color=GREYS[0], label="owners with mortgage")318        axes[2].bar(x, 100 * r.owners_nm, wdt, color=GREYS[2], label="owners without mortgage")319        axes[2].bar(x + wdt, 100 * r.renters, wdt, color=GREYS[3], edgecolor=GREYS[1], label="renters")320        axes[2].set_xticks(x); axes[2].set_xticklabels(r.contract, rotation=25, ha="right"); axes[2].axhline(0, color="k", lw=0.6)321        axes[2].set_title("(c) CEV of households alive at the shock, %", loc="left"); axes[2].legend(fontsize=6.5)322    _suptitle(fig, "Figure 9. Redistribution in the shock period: nominal revaluation, consumption and welfare by tenure", x=0.01, ha="left", fontsize=9.5)323    fig.tight_layout()324    _save(fig, "fig09_inflation_redistribution")325326327def fig10_supply_heatmap():328    cells = {(0.05, 0.05): "supply/eps_005_theta005", (0.05, 0.20): "supply/eps_005", (0.05, 0.40): "supply/eps_005_theta040",329             (1.0, 0.05): "theta_sweep/theta_005", (1.0, 0.20): "baseline/summary", (1.0, 0.40): "theta_sweep/theta_040",330             (2.0, 0.05): "supply/eps_200_theta005", (2.0, 0.20): "supply/eps_200", (2.0, 0.40): "supply/eps_200_theta040"}331    eps_v = [0.05, 1.0, 2.0]; th_v = [0.05, 0.20, 0.40]332    mats = {k: np.full((3, 3), np.nan) for k in ("p", "own", "qro", "rr")}333    for (e, t), path in cells.items():334        f = DATA / f"{path}.json"335        if not f.exists():336            continue337        d = _json(f)338        i, j = eps_v.index(e), th_v.index(t)339        mats["p"][i, j] = d["p"]; mats["own"][i, j] = d["moments"]["ownership_rate_all"]340        mats["qro"][i, j] = d["mobility"]["q_RO_age3"]; mats["rr"][i, j] = d["mobility"]["rank_rank_slope_nw"]341    if np.all(np.isnan(mats["p"])):342        return343    fig, axes = plt.subplots(1, 4, figsize=(9.2, 2.6))344    titles = {"p": "(a) House price $p^*$", "own": "(b) Ownership rate", "qro": "(c) $q_{RO}$ at 35–39", "rr": "(d) Rank–rank slope"}345    for ax, (k, m) in zip(axes, mats.items()):346        im = ax.imshow(m, cmap="Greys", origin="lower", aspect="auto")347        for i in range(3):348            for j in range(3):349                if np.isfinite(m[i, j]):350                    ax.text(j, i, f"{m[i, j]:.3f}", ha="center", va="center", fontsize=7.5,351                            color="white" if m[i, j] > np.nanmin(m) + 0.6 * (np.nanmax(m) - np.nanmin(m)) else "black")352        ax.set_xticks(range(3)); ax.set_xticklabels([f"{t:.2f}" for t in th_v]); ax.set_yticks(range(3)); ax.set_yticklabels([f"{e:.2f}" for e in eps_v])353        ax.set_xlabel("down payment $\\theta$"); ax.set_title(titles[k], loc="left"); ax.grid(False)354    axes[0].set_ylabel("supply elasticity $\\varepsilon$")355    _suptitle(fig, "Figure 10. Supply elasticity × down-payment constraint: stationary general equilibria", x=0.01, ha="left", fontsize=9.5)356    fig.tight_layout()357    _save(fig, "fig10_supply_elasticity_heatmap")358359360def fig11_welfare():361    f = DATA / "welfare" / "welfare_all.csv"362    if not f.exists():363        return364    w = pd.read_csv(f)365    w = w[w.gift_quintile.astype(str) != "marginal"].copy()366    w["gift_quintile"] = w.gift_quintile.astype(int)367    # only scenarios with the baseline preferences are comparable (see results_audit.md section 9)368    scen = [s for s in ["theta_005", "theta_040", "transfer_tax_020", "eps_005_theta005", "r_003", "phi_008"] if s in set(w.scenario)]369    if not scen:370        return371    fig, axes = plt.subplots(1, 2, figsize=(7.6, 2.8))372    for k, s in enumerate(scen):373        d = w[w.scenario == s]374        byq = d.groupby("gift_quintile").apply(lambda g: np.nansum(g.cev * g.weight) / np.nansum(g.weight * np.isfinite(g.cev)))375        lab = {"theta_005": r"$\theta = 0.05$", "theta_040": r"$\theta = 0.40$", "transfer_tax_020": "transfer tax 20 % (rebated)",376               "eps_005_theta005": r"$\varepsilon = 0.05$, $\theta = 0.05$", "r_003": "real rate 3 %", "phi_008": r"landlord wedge $\phi = 0.08$"}.get(s, s.replace("_", " "))377        axes[0].plot(byq.index, 100 * byq.values, color=GREYS[k % 4] if k < 4 else "#000000", ls=STYLES[k % 4], marker=MARKS[k % 5], ms=3, label=lab)378    axes[0].axhline(0, color="k", lw=0.6); axes[0].set_xlabel("Quintile of transfer received at age 1"); axes[0].set_ylabel("CEV, % of consumption")379    axes[0].set_title("(a) Entrants vs baseline, by transfer quintile", loc="left"); axes[0].legend(fontsize=6.5)380    ft = DATA / "inflation" / "frm_level_welfare.csv"381    if ft.exists():382        t = pd.read_csv(ft); t = t[t.age != "all"]383        for k, grp in enumerate(["owners_with_mortgage", "owners_no_mortgage", "renters"]):384            dd = t[t.group == grp].copy(); dd["age"] = dd.age.astype(int)385            axes[1].plot(dd.age, 100 * dd.cev, color=GREYS[k], ls=STYLES[k], marker=MARKS[k], ms=3, label=grp.replace("_", " "))386        axes[1].axhline(0, color="k", lw=0.6); axes[1].set_title("(b) 10 % surprise (FRM): CEV by age, GE", loc="left"); axes[1].legend(fontsize=6.5); _age_axis(axes[1])387    _suptitle(fig, "Figure 11. Consumption-equivalent welfare (experiment 9)", x=0.01, ha="left", fontsize=9.5)388    fig.tight_layout()389    _save(fig, "fig11_welfare")390391392def fig12_diagnostics():393    f = DATA / "baseline" / "summary.json"394    if not f.exists():395        return396    fig, axes = plt.subplots(1, 3, figsize=(8.6, 2.7))397    # (a) market-clearing residuals by lottery round (nested regula falsi inside) for a few scenarios398    for k, (lab, logname) in enumerate([("$\\theta = 0.40$", "theta_040"), ("bequests off", "bequests_off"), ("real rate 1 %", "r_001"), ("$\\sigma_z = 0.20$", "sigma_high")]):399        log = RESULTS_DIR / "logs" / f"{logname}.jsonl"400        if not log.exists():401            continue402        ex_o, ex_r = [], []403        with open(log) as fh:404            for line in fh:405                r = json.loads(line)406                if r.get("event") == "ss_outer":407                    ex_o.append(abs(r["excess"][0])); ex_r.append(abs(r["excess"][1]))408        if ex_o:409            axes[0].semilogy(range(1, len(ex_o) + 1), np.maximum(ex_o, 1e-7), color=GREYS[k % 4], marker=MARKS[k], ms=3, ls="-", label=f"{lab}, owner mkt")410            axes[0].semilogy(range(1, len(ex_r) + 1), np.maximum(ex_r, 1e-7), color=GREYS[k % 4], marker=MARKS[k], ms=3, ls=":", label=f"{lab}, rental mkt")411    axes[0].axhline(5e-4, color="k", lw=0.6, ls="--"); axes[0].set_title("(a) |excess demand| after each lottery round", loc="left")412    axes[0].set_xlabel("lottery / rebate round"); axes[0].legend(fontsize=5.5, ncol=2)413    # (b) Bellman residuals across scenarios414    fd = DATA / "diagnostics_table.csv"415    if fd.exists():416        d = pd.read_csv(fd).dropna(subset=["bellman_owner_mean"])417        axes[1].semilogy(range(len(d)), d.bellman_owner_mean, "o", color=GREYS[0], ms=3, label="owners, mean")418        axes[1].semilogy(range(len(d)), d.bellman_renter_mean, "s", color=GREYS[2], ms=3, label="renters, mean")419        axes[1].set_title("(b) Bellman residuals by scenario", loc="left"); axes[1].set_xlabel("scenario index"); axes[1].legend(fontsize=6.5)420    # (c) grid sensitivity421    fg = DATA / "grid_sensitivity.csv"422    if fg.exists():423        g = pd.read_csv(fg).set_index("name")424        keys = [k for k in ["ownership", "p", "gini_nw", "lambda_age7", "mean_gift"] if k in g.columns]425        names = [n for n in ["grid_coarse", "grid_fine", "tol_tight", "sample_small", "sample_large", "seed_alt"] if n in g.index]426        x = np.arange(len(names))427        for k, key in enumerate(keys):428            dev = [100 * (g.loc[n, key] / g.loc["baseline", key] - 1) if g.loc["baseline", key] not in (0, np.nan) else np.nan for n in names]429            axes[2].plot(x, dev, marker=MARKS[k % 5], ls=STYLES[k % 4], color=GREYS[k % 4] if k < 4 else "#000000", ms=3, label=key)430        axes[2].set_xticks(x); axes[2].set_xticklabels(names, rotation=25, ha="right"); axes[2].axhline(0, color="k", lw=0.6)431        axes[2].set_title("(c) Numerical variants, % dev. from baseline", loc="left"); axes[2].legend(fontsize=6)432    _suptitle(fig, "Figure 12. Solver diagnostics: market clearing, Bellman residuals, grid / sample sensitivity", x=0.01, ha="left", fontsize=9.5)433    fig.tight_layout()434    _save(fig, "fig12_solver_diagnostics")435436437def fig13_multiplicity():438    fs = {"baseline ($\\varepsilon=1$)": DATA / "multiplicity" / "price_scan_baseline.csv",439          "near-fixed supply ($\\varepsilon=0.05$)": DATA / "multiplicity" / "price_scan_eps_005.csv",440          "$\\varepsilon=0.05$, $\\theta=0.05$": DATA / "multiplicity" / "price_scan_eps_005_theta005.csv"}441    fs = {k: v for k, v in fs.items() if v.exists()}442    if not fs:443        return444    fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.8))445    for k, (lab, f) in enumerate(fs.items()):446        d = pd.read_csv(f)447        axes[0].plot(d.p, d.excess_owner, color=GREYS[k], ls=STYLES[k], marker=MARKS[k], ms=3, label=lab)448        axes[1].plot(d.p, d.ownership, color=GREYS[k], ls=STYLES[k], marker=MARKS[k], ms=3, label=lab)449    axes[0].axhline(0, color="k", lw=0.6); axes[0].set_xlabel("House price $p$ (rental market cleared)"); axes[0].set_ylabel("rel. excess demand (owner mkt)")450    axes[0].set_title("(a) Owner-market excess demand along $p$", loc="left"); axes[0].legend(fontsize=6.5)451    axes[1].set_xlabel("House price $p$"); axes[1].set_ylabel("ownership rate"); axes[1].set_title("(b) Ownership along the clearing curve", loc="left")452    _suptitle(fig, "Figure 13. Search for multiple stationary equilibria (sign changes of excess demand)", x=0.01, ha="left", fontsize=9.5)453    fig.tight_layout()454    _save(fig, "fig13_multiplicity_search")455456457def fig14_theta_sweep():458    rows = []459    for name, theta in [("theta_005", 0.05), ("theta_010", 0.10), ("theta_020", 0.20), ("theta_030", 0.30), ("theta_040", 0.40)]:460        for kind, fn in [("GE", f"{name}.json"), ("PE (prices fixed)", f"{name}_pe.json")]:461            f = DATA / "theta_sweep" / fn462            if f.exists():463                d = _json(f)464                rows.append(dict(theta=theta, kind=kind, p=d["p"], rho=d["rho"], own=d["moments"]["ownership_rate_all"],465                                 own_u35=d["moments"]["ownership_rate_under35"], qro=d["mobility"]["q_RO_age3"], qoo=d["mobility"]["q_OO_age3"],466                                 rr=d["mobility"]["rank_rank_slope_nw"], gini=d["gini_nw"]))467    if not rows:468        return469    s = pd.DataFrame(rows)470    fig, axes = plt.subplots(1, 3, figsize=(8.6, 2.7))471    for k, kind in enumerate(s.kind.unique()):472        d = s[s.kind == kind].sort_values("theta")473        axes[0].plot(d.theta, d.own, color=GREYS[k * 2], ls=STYLES[k], marker=MARKS[k], ms=3, label=f"all, {kind}")474        axes[0].plot(d.theta, d.own_u35, color=GREYS[k * 2], ls=STYLES[k + 2], marker=MARKS[k + 2], ms=3, label=f"under 35, {kind}")475        if kind == "GE":476            axes[1].plot(d.theta, d.p, color=GREYS[0], marker="o", ms=3, label="house price $p^*$")477            axes[1].plot(d.theta, d.rho / d.rho.iloc[0] * d.p.iloc[0], color=GREYS[2], ls="--", marker="s", ms=3, label="rent (rescaled to $p$ at $\\theta=0.05$)")478        axes[2].plot(d.theta, d.qoo - d.qro, color=GREYS[k * 2], ls=STYLES[k], marker=MARKS[k], ms=3, label=f"$q_{{OO}}-q_{{RO}}$ at 35–39, {kind}")479        axes[2].plot(d.theta, d.rr, color=GREYS[k * 2], ls=STYLES[k + 2], marker=MARKS[k + 2], ms=3, label=f"rank–rank slope, {kind}")480    for ax in axes:481        ax.set_xlabel("minimum down payment $\\theta$")482    axes[0].set_title("(a) Ownership rate", loc="left"); axes[0].legend(fontsize=6.5)483    axes[1].set_title("(b) Equilibrium prices (GE)", loc="left"); axes[1].legend(fontsize=6.5)484    axes[2].set_title("(c) Intergenerational persistence", loc="left"); axes[2].legend(fontsize=6.5)485    _suptitle(fig, "Figure 14. Down-payment sweep (experiment 3): general vs partial equilibrium", x=0.01, ha="left", fontsize=9.5)486    fig.tight_layout()487    _save(fig, "fig14_down_payment_sweep")488489490def main():491    for fn in (fig01_timing, fig02_policy, fig03_thresholds, fig04_profiles, fig05_equal_income, fig06_gift_quintiles,492               fig07_inequality, fig08_inflation_irf, fig09_redistribution, fig10_supply_heatmap, fig11_welfare,493               fig12_diagnostics, fig13_multiplicity, fig14_theta_sweep):494        try:495            fn()496        except Exception as exc:  # noqa: BLE001 — report, do not hide497            print(f"FIGURE FAILED {fn.__name__}: {exc!r}")498            raise499500501if __name__ == "__main__":502    main()503