spb/wp11_uqo Public
UQO Working Paper No. 11 — Half a million prices, twenty models: a systematic assessment of hedonic specifications.
TeX 54.7%
Python 45.2%
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 04 — Publication figures (print-journal calibre).45Reads the step-02/03 outputs and writes eight PNG figures to ``figures/``.6Same conventions as WP10: no in-figure titles on single panels, bold7"Panel A/B" headers on multi-panel figures, one accent hue doubled by a8marker/linestyle difference, direct labels over legend boxes.910Usage: python scripts/04_make_figures.py11"""12import sys13from pathlib import Path1415import numpy as np16import pandas as pd1718sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))1920from wp11 import config, sample # noqa: E40221from wp11.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH, # noqa: E40222 apply_style, panel_label, ygrid)2324import matplotlib.pyplot as plt # noqa: E4022526OUT = config.FIGURES27RES = config.REPRODUCED282930def _save(fig, name):31 fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02)32 plt.close(fig)333435def _forest(ax, res, metric="mdape", xlabel="Median absolute error (%)"):36 y = np.arange(len(res))[::-1].astype(float)37 ax.barh(y, res[metric], height=0.62, color=LIGHT, edgecolor=INK,38 linewidth=0.4)39 for yy, v in zip(y, res[metric]):40 ax.text(v + 0.25, yy, f"{v:.1f}", va="center", fontsize=7.5)41 ax.set_yticks(y)42 ax.set_yticklabels(res["name"], fontsize=8)43 ax.set_xlabel(xlabel)44 ax.spines["left"].set_visible(False)45 ax.tick_params(axis="y", length=0)464748# ---------------------------------------------------------------- fig 1-449def fig_groups():50 """One figure per design axis: MdAPE bars, random holdout."""51 r = pd.read_csv(RES / "horserace_random.csv")52 for gkey, fname in (("A.", "fig_forms.png"), ("B.", "fig_time.png"),53 ("C.", "fig_space.png"), ("D.", "fig_methods.png")):54 res = r[r["group"].str.startswith(gkey)].reset_index(drop=True)55 fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH,56 0.55 + 0.42 * len(res)))57 _forest(ax, res)58 ax.set_xlim(0, res["mdape"].max() * 1.15)59 _save(fig, fname)606162# ---------------------------------------------------------------- fig 563def fig_random_vs_temporal():64 """The generalization gap: random vs forward-in-time holdout."""65 a = pd.read_csv(RES / "horserace_random.csv")66 b = pd.read_csv(RES / "horserace_temporal.csv")67 m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t"))68 keep = m["name"].str.match(r"(A5|B4|C4|D)")69 m = m[keep].reset_index(drop=True)70 fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.7))71 y = np.arange(len(m))[::-1].astype(float)72 ax.scatter(m["mdape_r"], y + 0.14, s=22, color=BLUE, zorder=3,73 label="Random 80/20 holdout")74 ax.scatter(m["mdape_t"], y - 0.14, s=24, facecolor="white",75 edgecolor=RED, marker="s", zorder=3,76 label="Train $<$ 2025, test 2025–26")77 for yy, r0, t0 in zip(y, m["mdape_r"], m["mdape_t"]):78 ax.plot([r0, t0], [yy + 0.14, yy - 0.14], color=GREY, lw=0.6,79 zorder=2)80 ax.set_yticks(y)81 ax.set_yticklabels(m["name"], fontsize=8)82 ax.set_xlabel("Median absolute error (%)")83 ax.legend(loc="upper left", fontsize=8, bbox_to_anchor=(0.02, 0.35))84 ax.spines["left"].set_visible(False)85 ax.tick_params(axis="y", length=0)86 _save(fig, "fig_generalization.png")878889# ---------------------------------------------------------------- fig 690def fig_index():91 """Constant-quality monthly indices across specifications."""92 ix = pd.read_csv(RES / "index.csv")93 ix["date"] = pd.PeriodIndex(ix["month"], freq="M").to_timestamp()94 fig, ax = plt.subplots(figsize=(TEXTWIDTH, 3.2))95 forms = ["Semi-log", "Log-log", "Semi-log + quadratics",96 "Semi-log + splines", "Gradient boosting"]97 styles = [(BLUES[2], "-"), (BLUES[3], (0, (5, 2))), (BLUES[4], "-"),98 (BLUES[5], (0, (1, 1.2))), (RED, "-")]99 for f, (c, ls) in zip(forms, styles):100 g = ix[ix["form"] == f].sort_values("date")101 lw = 1.5 if f == "Gradient boosting" else 1.0102 ax.plot(g["date"], g["index"], color=c, ls=ls, lw=lw)103 # the four linear forms end within ~2 points — one bundle label104 lin_end = (ix[ix["form"] != "Gradient boosting"]105 .sort_values("date").groupby("form")["index"].last())106 gb = ix[ix["form"] == "Gradient boosting"].sort_values("date")107 last_date = gb["date"].iloc[-1]108 ax.annotate("Four linear forms", xy=(last_date, lin_end.mean()),109 xytext=(6, 4), textcoords="offset points", fontsize=7.5,110 color=BLUES[4], va="center")111 ax.annotate("Gradient boosting", xy=(last_date, gb["index"].iloc[-1]),112 xytext=(6, -4), textcoords="offset points", fontsize=7.5,113 color=RED, va="center")114 ax.set_ylabel("Constant-quality index (Jan 2021 = 100)")115 ax.margins(x=0.14)116 ygrid(ax)117 _save(fig, "fig_index.png")118119120# ---------------------------------------------------------------- fig 7121def fig_profiles():122 """Implicit ln-price profiles: OLS quadratic vs gradient boosting."""123 p = pd.read_csv(RES / "profiles.csv")124 fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 3.0))125 for ax, var, xlabel, ptitle in (126 (axes[0], "age", "Building age (years)", "Panel A. Age profile"),127 (axes[1], "area", "Floor area (m$^2$)",128 "Panel B. Floor-area profile")):129 for model, color, ls in (("OLS quadratic", BLUE, "-"),130 ("Gradient boosting", RED, (0, (5, 2)))):131 g = p[(p["var"] == var) & (p["model"] == model)]132 ax.plot(g["x"], g["y"], color=color, ls=ls, lw=1.3, label=model)133 ax.set_xlabel(xlabel)134 ax.set_ylabel("ln price, relative to leftmost point")135 panel_label(ax, ptitle)136 axes[0].legend(fontsize=8)137 fig.subplots_adjust(wspace=0.28)138 _save(fig, "fig_profiles.png")139140141# ---------------------------------------------------------------- fig 8142def fig_learning():143 """Accuracy versus training-set size."""144 l = pd.read_csv(RES / "learning.csv")145 fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.2))146 for model, color, mk in (("OLS quadratic (muni+quarter FE)", BLUE, "o"),147 ("Gradient boosting", RED, "s")):148 g = l[l["model"] == model].sort_values("n_train")149 ax.plot(g["n_train"], g["mdape"], marker=mk, ms=4.5, color=color,150 lw=1.2, mfc="white" if mk == "s" else color, label=model)151 ax.set_xscale("log")152 ax.set_xlabel("Training sales (log scale)")153 ax.set_ylabel("Median absolute error (%)")154 ax.legend(fontsize=8)155 ygrid(ax)156 _save(fig, "fig_learning.png")157158159# ---------------------------------------------------------------- fig 9160def fig_segments():161 """MdAPE by market segment, OLS vs GB."""162 s = pd.read_csv(RES / "segments.csv")163 order = [x for x in ["Class: single_family", "Class: condo",164 "Class: plex", "Class: cottage",165 "Muni < 1k sales", "Muni 1k–10k sales",166 "Muni > 10k sales"] if x in set(s["segment"])]167 labels = {"Class: single_family": "Single-family", "Class: condo":168 "Condominium", "Class: plex": "Plex", "Class: cottage":169 "Cottage", "Muni < 1k sales": "Muni $<$ 1k sales",170 "Muni 1k–10k sales": "Muni 1k–10k sales",171 "Muni > 10k sales": "Muni $>$ 10k sales"}172 y = np.arange(len(order))[::-1].astype(float)173 fig, ax = plt.subplots(figsize=(0.8 * TEXTWIDTH, 3.4))174 for model, color, mk, dy in (("OLS quadratic", BLUE, "o", 0.14),175 ("Gradient boosting", RED, "s", -0.14)):176 g = s[s["model"] == model].set_index("segment").loc[order]177 ax.scatter(g["mdape"], y + dy, s=22, color=color, marker=mk,178 facecolor="white" if mk == "s" else color,179 edgecolor=color, label=model, zorder=3)180 ax.set_yticks(y)181 ax.set_yticklabels([labels[o] for o in order], fontsize=8)182 ax.set_xlabel("Median absolute error (%)")183 ax.legend(fontsize=8, loc="lower right")184 ax.spines["left"].set_visible(False)185 ax.tick_params(axis="y", length=0)186 _save(fig, "fig_segments.png")187188189def main() -> None:190 config.ensure_dirs()191 apply_style()192 fig_groups()193 fig_random_vs_temporal()194 fig_index()195 fig_profiles()196 fig_learning()197 fig_segments()198 made = sorted(p.name for p in OUT.glob("fig_*.png"))199 print(f"{len(made)} figures written:", ", ".join(made))200201202if __name__ == "__main__":203 main()204