SPB Git

spb/wp3_uqo Public

UQO Working Paper No. 3 — Hedonic housing price models for the US: parametric, quantile, and machine-learning approaches.

TeX 77.8% Python 22.1%
15.1 KB · 427 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3#4"""Regenerate all 11 paper figures from the stored pickles.56Every figure is rebuilt from the data actually used in the paper7(analytical_sample.pkl, model_data.pkl, ml_results.pkl, qr_results.pkl,8shap_data.pkl) in a unified publication style. The script prints9verification statistics (Jarque-Bera, skewness, kurtosis, model R2, OLS10reference coefficients) so the regenerated content can be checked against11the numbers reported in the paper. The six original PNGs remain untouched12in the source archive (immo-wp3-spb-20260519/figures/).1314Usage:15    python scripts/03_make_figures.py               # all 11 figures16    python scripts/03_make_figures.py --figs fig2 fig417"""1819import argparse20import sys21from pathlib import Path2223import matplotlib2425matplotlib.use("Agg")26import matplotlib.pyplot as plt27import numpy as np28import pandas as pd29import scipy.stats as st3031sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))32from wp3 import config, data33from wp3.plotting import (ACCENT, CMAP_DIV, CMAP_SEQ, NEUTRAL, PRIMARY,34                          PRIMARY_LIGHT, apply_paper_style, label, panel_title)3536RNG = np.random.RandomState(config.RANDOM_STATE)3738FILE_NAMES = {39    "fig1": "fig1_price_distribution.png",40    "fig2": "fig2_ols_diagnostics.png",41    "fig3": "fig3_quantile_coefficients.png",42    "fig4": "fig4_model_comparison.png",43    "fig5": "fig5_shap_summary.png",44    "fig6": "fig6_shap_importance.png",45    "fig7": "fig7_geographic_prices.png",46    "fig8": "fig8_regional_prices.png",47    "fig9": "fig9_marginal_effects.png",48    "fig10": "fig10_shap_dependence.png",49    "fig11": "fig11_shap_interactions.png",50}515253def fig1_price_distribution(path):54    """Histograms of price (thousands) and log-price with median markers."""55    df = data.load_analytical_sample()56    price_k = df["price"].values / 1000.05758    fig, axes = plt.subplots(1, 2, figsize=(16, 6))59    ax = axes[0]60    ax.hist(price_k[price_k <= 3000], bins=100, color=PRIMARY, alpha=0.85,61            edgecolor="white", linewidth=0.2)62    med = np.median(price_k)63    ax.axvline(med, color=ACCENT, linestyle="--", linewidth=1.8,64               label=f"Median: \\${med:,.0f}K")65    ax.set_xlabel("Listing Price (\\$ thousands)")66    ax.set_ylabel("Frequency")67    ax.yaxis.set_major_formatter(plt.matplotlib.ticker.StrMethodFormatter("{x:,.0f}"))68    panel_title(ax, "(a) Price Distribution")69    ax.legend()7071    ax = axes[1]72    ax.hist(df["ln_price"], bins=100, color=PRIMARY, alpha=0.85,73            edgecolor="white", linewidth=0.2)74    med_ln = df["ln_price"].median()75    ax.axvline(med_ln, color=ACCENT, linestyle="--", linewidth=1.8,76               label=f"Median: {med_ln:.2f}")77    ax.set_xlabel("Log(Price)")78    ax.set_ylabel("Frequency")79    ax.yaxis.set_major_formatter(plt.matplotlib.ticker.StrMethodFormatter("{x:,.0f}"))80    panel_title(ax, "(b) Log-Price Distribution")81    ax.legend()8283    fig.tight_layout()84    fig.savefig(path)85    plt.close(fig)868788def fig2_ols_diagnostics(path):89    """OLS diagnostics: residuals vs fitted, Q-Q, distribution, scale-location."""90    md = data.load_model_data()91    resid = np.asarray(md["residuals"], dtype=float)92    fitted = np.asarray(md["y_clean"], dtype=float) - resid9394    jb = st.jarque_bera(resid)[0]95    print(f"  [verify] skew={st.skew(resid):.2f} (paper: 0.18)  "96          f"kurtosis={st.kurtosis(resid, fisher=False):.2f} (paper: 5.55)  "97          f"JB={jb:,.0f} (paper: 217,078)")9899    idx = RNG.choice(len(resid), size=100_000, replace=False)100101    fig, axes = plt.subplots(2, 2, figsize=(14, 10))102103    ax = axes[0, 0]104    ax.hexbin(fitted[idx], resid[idx], gridsize=60, cmap=CMAP_SEQ, mincnt=1)105    ax.axhline(0, color=ACCENT, linestyle="--", linewidth=1.5)106    ax.set_xlabel("Fitted Values")107    ax.set_ylabel("Residuals")108    panel_title(ax, "(a) Residuals vs. Fitted")109110    ax = axes[0, 1]111    (osm, osr), (slope, intercept, _) = st.probplot(resid[idx], dist="norm")112    ax.plot(osm, osr, ".", color=PRIMARY, markersize=2.5, rasterized=True)113    ax.plot(osm, slope * osm + intercept, color=ACCENT, linestyle="--", linewidth=1.5)114    ax.set_xlabel("Theoretical Quantiles")115    ax.set_ylabel("Sample Quantiles")116    panel_title(ax, "(b) Normal Q-Q Plot")117118    ax = axes[1, 0]119    ax.hist(resid, bins=100, color=PRIMARY, alpha=0.85, density=True,120            edgecolor="white", linewidth=0.2)121    x = np.linspace(resid.min(), resid.max(), 400)122    ax.plot(x, st.norm.pdf(x, resid.mean(), resid.std()), color=ACCENT,123            linestyle="--", linewidth=1.6, label="Normal density")124    ax.set_xlabel("Residuals")125    ax.set_ylabel("Density")126    panel_title(ax, "(c) Residual Distribution")127    ax.legend()128129    ax = axes[1, 1]130    ax.hexbin(fitted[idx], np.sqrt(np.abs(resid[idx] / resid.std())),131              gridsize=60, cmap=CMAP_SEQ, mincnt=1)132    ax.set_xlabel("Fitted Values")133    ax.set_ylabel(r"$\sqrt{|\mathrm{Standardized\ Residuals}|}$")134    panel_title(ax, "(d) Scale-Location")135136    fig.tight_layout()137    fig.savefig(path)138    plt.close(fig)139140141def fig3_quantile_coefficients(path):142    """Quantile-regression coefficient paths with the OLS benchmark."""143    qr = data.load_result("qr_results.pkl")144    md = data.load_model_data()145    ols_params = md["ols_results"]["params"]146    taus = sorted(qr.keys())147148    plot_vars = ["ln_sqft", "bathrooms", "age", "ln_lot",149                 "has_pool", "has_garage", "luxury_score", "tag_foreclosure"]150    print(f"  [verify] OLS ref ln_sqft={ols_params['ln_sqft']:.3f} "151          f"(original figure: 0.312)")152153    fig, axes = plt.subplots(2, 4, figsize=(19, 9))154    for k, (ax, v) in enumerate(zip(axes.ravel(), plot_vars)):155        coefs = [qr[t]["params"][v] for t in taus]156        ax.plot(taus, coefs, color=PRIMARY, marker="o", markersize=6,157                linewidth=2, zorder=3)158        ax.axhline(ols_params[v], color=ACCENT, linestyle="--", linewidth=1.5,159                   label="OLS")160        ax.set_xticks(taus)161        ax.set_xlabel(r"Quantile $\tau$")162        if k % 4 == 0:163            ax.set_ylabel("Coefficient")164        panel_title(ax, f"({chr(97 + k)}) {label(v)}")165        if k == 0:166            ax.legend(loc="best")167168    fig.tight_layout()169    fig.savefig(path)170    plt.close(fig)171172173def fig4_model_comparison(path):174    """Predicted vs actual log-prices on the random test set: OLS, XGB, LGBM."""175    from sklearn.metrics import r2_score176177    ml = data.load_result("ml_results.pkl")178    y_test = np.asarray(ml["y_test"], dtype=float)179    panels = [180        ("(a) OLS", ml["y_pred_ols"]),181        ("(b) XGBoost", ml["y_pred_xgb"]),182        ("(c) LightGBM", ml["y_pred_lgb"]),183    ]184185    idx = RNG.choice(len(y_test), size=50_000, replace=False)186    lims = (y_test.min() - 0.1, y_test.max() + 0.1)187188    fig, axes = plt.subplots(1, 3, figsize=(18, 6), sharex=True, sharey=True)189    for ax, (title, y_pred) in zip(axes, panels):190        y_pred = np.asarray(y_pred, dtype=float)191        r2 = r2_score(y_test, y_pred)192        print(f"  [verify] {title.split(') ')[1]}: R2={r2:.4f} "193              f"(paper: OLS 0.630 / XGBoost 0.833 / LightGBM 0.809)")194        ax.hexbin(y_test[idx], y_pred[idx], gridsize=70, cmap=CMAP_SEQ, mincnt=1)195        ax.plot(lims, lims, color=ACCENT, linestyle="--", linewidth=1.5)196        ax.set_xlim(lims)197        ax.set_ylim(lims)198        ax.set_xlabel("Actual Log(Price)")199        panel_title(ax, title)200        ax.text(0.05, 0.92, f"$R^2 = {r2:.3f}$", transform=ax.transAxes,201                fontsize=13)202    axes[0].set_ylabel("Predicted Log(Price)")203204    fig.tight_layout()205    fig.savefig(path)206    plt.close(fig)207208209def _shap_frame():210    """SHAP values and features with human-readable column labels."""211    sd = data.load_shap_data()212    X = sd["X_shap"].copy()213    return sd, X214215216def fig5_shap_summary(path):217    """SHAP beeswarm summary plot for the XGBoost model (top 20 features)."""218    import shap219220    sd, X = _shap_frame()221    X_lab = X.rename(columns={c: label(c) for c in X.columns})222    plt.figure(figsize=(12, 10))223    shap.summary_plot(sd["shap_values_xgb"], X_lab, max_display=20, show=False,224                      plot_size=None)225    ax = plt.gca()226    ax.set_xlabel("SHAP Value (impact on predicted log-price)")227    ax.grid(axis="y", visible=False)228    plt.tight_layout()229    plt.savefig(path)230    plt.close()231232233def fig6_shap_importance(path):234    """Mean |SHAP| feature-importance bar chart (top 20)."""235    sd, _ = _shap_frame()236    mean_shap = pd.Series(sd["mean_shap"]).sort_values(ascending=True).tail(20)237238    fig, ax = plt.subplots(figsize=(11, 9))239    ax.barh([label(v) for v in mean_shap.index], mean_shap.values,240            color=PRIMARY, alpha=0.9, height=0.72)241    ax.set_xlabel(r"Mean $|$SHAP Value$|$")242    ax.grid(axis="x", linestyle=":", linewidth=0.7, alpha=0.45, color="#999999")243    ax.grid(axis="y", visible=False)244    fig.tight_layout()245    fig.savefig(path)246    plt.close(fig)247248249def fig7_geographic_prices(path):250    """Map of log listing prices (50,000-listing random subsample)."""251    df = data.load_analytical_sample()252    idx = RNG.choice(len(df), size=50_000, replace=False)253    sub = df.iloc[idx]254255    fig, ax = plt.subplots(figsize=(15, 9))256    sc = ax.scatter(sub["longitude"], sub["latitude"], c=sub["ln_price"],257                    s=3.5, cmap="viridis", alpha=0.75, linewidths=0,258                    rasterized=True)259    cbar = fig.colorbar(sc, ax=ax, shrink=0.75, pad=0.02)260    cbar.set_label("Log(Price)")261    cbar.outline.set_visible(False)262    ax.set_xlabel("Longitude")263    ax.set_ylabel("Latitude")264    ax.grid(False)265    ax.set_axisbelow(True)266267    fig.tight_layout()268    fig.savefig(path)269    plt.close(fig)270271272def fig8_regional_prices(path):273    """Distribution of log listing prices by Census region."""274    df = data.load_analytical_sample()275    order = ["Northeast", "Midwest", "South", "West"]276    groups = [df.loc[df["region"] == r, "ln_price"].values for r in order]277    labels_n = [f"{r}\n(N = {len(g):,})" for r, g in zip(order, groups)]278279    fig, ax = plt.subplots(figsize=(10, 6.5))280    ax.boxplot(groups, tick_labels=labels_n, showfliers=False, patch_artist=True,281               widths=0.55,282               medianprops=dict(color=ACCENT, linewidth=1.8),283               boxprops=dict(facecolor=PRIMARY, alpha=0.75, edgecolor="#333333"),284               whiskerprops=dict(color="#333333", linewidth=1.0),285               capprops=dict(color="#333333", linewidth=1.0))286    ax.set_ylabel("Log(Price)")287    fig.tight_layout()288    fig.savefig(path)289    plt.close(fig)290291292def fig9_marginal_effects(path):293    """Unconditional bivariate relationships: scatter plus binned means."""294    df = data.load_analytical_sample()295    specs = [296        ("living_area_sqft", "Living Area (sqft)", (0, 8000)),297        ("age", "Property Age (years)", (0, 150)),298        ("lot_size_sqft", "Lot Size (sqft)", (0, 45000)),299        ("bedrooms", "Bedrooms", None),300        ("bathrooms", "Bathrooms", (0, 8)),301        ("avg_school_rating", "Avg. School Rating", None),302    ]303    idx = RNG.choice(len(df), size=30_000, replace=False)304    sub = df.iloc[idx]305306    fig, axes = plt.subplots(2, 3, figsize=(18, 10))307    for k, (ax, (col, xlabel, xlim)) in enumerate(zip(axes.ravel(), specs)):308        x, y = sub[col].values, sub["ln_price"].values309        if xlim is not None:310            keep = (x >= xlim[0]) & (x <= xlim[1])311            x, y = x[keep], y[keep]312        ax.scatter(x, y, s=2.5, color=PRIMARY_LIGHT, alpha=0.2, linewidths=0,313                   rasterized=True)314315        # Binned means computed on the full sample316        xf, yf = df[col].values, df["ln_price"].values317        if xlim is not None:318            keepf = (xf >= xlim[0]) & (xf <= xlim[1])319            xf, yf = xf[keepf], yf[keepf]320        bins = np.linspace(np.nanmin(xf), np.nanmax(xf), 25)321        which = np.digitize(xf, bins)322        centers = [xf[which == b].mean() for b in range(1, len(bins))323                   if (which == b).sum() > 50]324        means = [yf[which == b].mean() for b in range(1, len(bins))325                 if (which == b).sum() > 50]326        ax.plot(centers, means, color=ACCENT, linewidth=2.2, marker="o",327                markersize=4.5, label="Binned mean", zorder=3)328329        ax.set_xlabel(xlabel)330        if k % 3 == 0:331            ax.set_ylabel("Log(Price)")332        if col in ("living_area_sqft", "lot_size_sqft"):333            ax.xaxis.set_major_formatter(334                plt.matplotlib.ticker.StrMethodFormatter("{x:,.0f}"))335        panel_title(ax, f"({chr(97 + k)}) {xlabel}")336        if k == 0:337            ax.legend(loc="lower right")338339    fig.tight_layout()340    fig.savefig(path)341    plt.close(fig)342343344def fig10_shap_dependence(path):345    """SHAP dependence plots for the eight features shown in the paper."""346    sd, X = _shap_frame()347    shap_vals = sd["shap_values_xgb"]348    feats = ["ln_sqft", "bathrooms", "ln_lot", "avg_school_rating",349             "property_tax_rate", "age", "walk_score", "luxury_score"]350351    fig, axes = plt.subplots(2, 4, figsize=(20, 9.5))352    for k, (ax, feat) in enumerate(zip(axes.ravel(), feats)):353        j = list(X.columns).index(feat)354        xv = X[feat].values355        ax.scatter(xv, shap_vals[:, j], c=xv, cmap=CMAP_DIV, s=4, alpha=0.6,356                   linewidths=0, rasterized=True)357        ax.axhline(0, color=NEUTRAL, linestyle="--", linewidth=1.1)358        ax.set_xlabel(label(feat))359        if k % 4 == 0:360            ax.set_ylabel("SHAP Value")361        panel_title(ax, f"({chr(97 + k)}) {label(feat)}")362363    fig.tight_layout()364    fig.savefig(path)365    plt.close(fig)366367368def fig11_shap_interactions(path):369    """SHAP values of the two interaction features highlighted in the paper."""370    sd, X = _shap_frame()371    shap_vals = sd["shap_values_xgb"]372    panels = [373        ("waterfront_x_sqft", "(a) Waterfront $\\times$ Living Area Interaction"),374        ("pool_x_south", "(b) Pool $\\times$ South Region Interaction"),375    ]376377    fig, axes = plt.subplots(1, 2, figsize=(16, 6.5))378    for ax, (feat, title) in zip(axes, panels):379        j = list(X.columns).index(feat)380        ax.scatter(X[feat].values, shap_vals[:, j], s=6, color=PRIMARY,381                   alpha=0.35, linewidths=0, rasterized=True)382        ax.axhline(0, color=NEUTRAL, linestyle="--", linewidth=1.1)383        ax.set_xlabel(label(feat))384        ax.set_ylabel("SHAP Value")385        panel_title(ax, title)386387    fig.tight_layout()388    fig.savefig(path)389    plt.close(fig)390391392BUILDERS = {393    "fig1": fig1_price_distribution,394    "fig2": fig2_ols_diagnostics,395    "fig3": fig3_quantile_coefficients,396    "fig4": fig4_model_comparison,397    "fig5": fig5_shap_summary,398    "fig6": fig6_shap_importance,399    "fig7": fig7_geographic_prices,400    "fig8": fig8_regional_prices,401    "fig9": fig9_marginal_effects,402    "fig10": fig10_shap_dependence,403    "fig11": fig11_shap_interactions,404}405406407def main():408    parser = argparse.ArgumentParser(description=__doc__)409    parser.add_argument("--figs", nargs="+", choices=sorted(BUILDERS),410                        help="build only these figures")411    args = parser.parse_args()412413    apply_paper_style()414    config.FIGURES_DIR.mkdir(parents=True, exist_ok=True)415416    names = args.figs if args.figs else list(BUILDERS)417    for name in names:418        path = config.FIGURES_DIR / FILE_NAMES[name]419        print(f"Building {name} -> {path.relative_to(config.PROJECT_ROOT)}")420        BUILDERS[name](path)421422    print("Done.")423424425if __name__ == "__main__":426    main()427