SPB Git

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%
8.5 KB · 204 lines python
Raw Blame History
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