SPB Git

spb/wp11_uqo

Public

UQO Working Paper No. 11 — Half a million prices, twenty models: a systematic assessment of hedonic specifications.

11.9 KB · 275 lines python
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 06 — Inference and robustness (added in the 2026-10-17 revision).45Reads the test-set predictions stored by step 02 and runs the additional6analyses requested by the editorial review:781. cluster-bootstrap confidence intervals (municipality clusters) for the9   MdAPE of every model, and Diebold–Mariano / Giacomini–White-type tests10   of equal accuracy against the spline hedonic (A6) and gradient11   boosting (D4), under both holdouts            -> inference_<scheme>.csv122. same-period comparison: random-split models evaluated on the 2025–2613   sales of the random test set versus forward-in-time models on the14   full 2025–26 test set                            -> same_period.csv153. forward-in-time accuracy by test quarter        -> by_quarter.csv164. leakage decomposition of the random holdout: accuracy on test sales17   whose parcel / coordinate pair also appears in training versus the18   others                                           -> leakage.csv195. leakage-free holdouts: the full race under parcel-grouped and20   coordinate-grouped 80/20 splits   -> horserace_group_parcel.csv,21                                        horserace_group_coord.csv226. nested spatial fixed effects (municipality + grid)  -> nested_fe.csv237. Box–Cox on a finer lambda grid                       -> boxcox_fine.csv248. gradient-boosting hyper-parameter sensitivity       -> hgb_sensitivity.csv2510. targeted pairwise comparisons along each axis (bootstrap CI of the26    MdAPE difference and clustered DM tests)              -> pairs.csv279. level-bias decomposition: median log residual (aggregate level error)28   and MdAPE after re-levelling predictions by the test-set median ratio29   (relative pricing skill net of the level error)       -> relevel.csv3031Usage:  python scripts/06_robustness.py          (about 15 min)32"""33import sys34import time35from pathlib import Path3637import numpy as np38import pandas as pd3940sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))4142from wp11 import config, inference, models, sample  # noqa: E4024344OUT = config.REPRODUCED45MODEL_NAMES = [m["name"] for m in models.model_registry()]46A6, D4, A5 = ("A6 Semi-log + splines", "D4 Gradient boosting",47              "A5 Semi-log + quadratics")484950def _mdape(y, p):51    return float(np.median(np.abs(p - y) / y) * 100)525354def step_inference(P: dict):55    for scheme, Ps in P.items():56        t0 = time.time()57        boot = inference.cluster_bootstrap_mdape(Ps, MODEL_NAMES, ref=A6)58        boot_d4 = inference.cluster_bootstrap_mdape(Ps, MODEL_NAMES, ref=D4)59        boot = boot.merge(boot_d4[["model", "diff_vs_ref", "diff_lo",60                                   "diff_hi"]]61                          .rename(columns={"diff_vs_ref": "diff_vs_d4",62                                           "diff_lo": "diff_d4_lo",63                                           "diff_hi": "diff_d4_hi"}),64                          on="model", how="left")65        rows = []66        for m in MODEL_NAMES:67            r = {"model": m}68            for ref, tag in ((A6, "a6"), (D4, "d4")):69                if m == ref:70                    continue71                for loss, ltag in ((inference.ape, "ape"),72                                   (inference.sq_log, "sqlog")):73                    t = inference.dm_test(Ps, m, ref, loss=loss)74                    r[f"dm_{ltag}_vs_{tag}_t"] = t["t"]75                    r[f"dm_{ltag}_vs_{tag}_p"] = t["p"]76                    r[f"dm_{ltag}_vs_{tag}_diff"] = t["mean_diff"]77            rows.append(r)78        res = boot.merge(pd.DataFrame(rows), on="model")79        res["n_clusters"] = Ps["muni"].nunique()80        res.to_csv(OUT / f"inference_{scheme}.csv", index=False)81        print(f"  inference [{scheme}] done in {time.time() - t0:.0f}s")828384def step_same_period(P: dict):85    cut = pd.Timestamp(config.TEMPORAL_CUTOFF)86    Pr = P["random"][P["random"]["sale_date"] >= cut]87    Pt = P["temporal"]88    rows = []89    for m in MODEL_NAMES:90        rows.append({"model": m,91                     "mdape_random_all": _mdape(P["random"]["price"].to_numpy(),92                                                P["random"][m].to_numpy()),93                     "mdape_random_2025_26": _mdape(Pr["price"].to_numpy(),94                                                    Pr[m].to_numpy()),95                     "mdape_forward_2025_26": _mdape(Pt["price"].to_numpy(),96                                                     Pt[m].to_numpy()),97                     "n_random_2025_26": len(Pr), "n_forward": len(Pt)})98    pd.DataFrame(rows).to_csv(OUT / "same_period.csv", index=False)99    print("  same-period comparison done")100101102def step_by_quarter(P: dict):103    Pt = P["temporal"].copy()104    Pt["q"] = Pt["sale_date"].dt.to_period("Q").astype(str)105    rows = []106    for q, g in Pt.groupby("q"):107        for m in (A5, A6, "D2 Ridge (cross-validated)", "D3 Random forest",108                  D4):109            rows.append({"quarter": q, "model": m, "n": len(g),110                         "mdape": _mdape(g["price"].to_numpy(),111                                         g[m].to_numpy())})112    pd.DataFrame(rows).to_csv(OUT / "by_quarter.csv", index=False)113    print("  by-quarter accuracy done")114115116def step_leakage(P: dict):117    Pr = P["random"]118    rows = []119    for key in ("in_train_parcel", "in_train_coord"):120        for flag in (True, False):121            g = Pr[Pr[key] == flag]122            for m in (A5, A6, "D2 Ridge (cross-validated)",123                      "D3 Random forest", D4,124                      "D5 Gradient boosting, no coordinates",125                      "D6 Spatial k-NN comparables"):126                rows.append({"key": key, "seen_in_training": flag,127                             "segment": "all", "n": len(g), "model": m,128                             "mdape": _mdape(g["price"].to_numpy(),129                                             g[m].to_numpy())})130            gc = g[g["prop_class"] == "condo"]131            for m in (A5, D4):132                rows.append({"key": key, "seen_in_training": flag,133                             "segment": "condo", "n": len(gc), "model": m,134                             "mdape": _mdape(gc["price"].to_numpy(),135                                             gc[m].to_numpy())})136    pd.DataFrame(rows).to_csv(OUT / "leakage.csv", index=False)137    print("  leakage decomposition done")138139140def step_relevel(P: dict):141    """Decompose forward-in-time error into an aggregate level error and142    relative pricing skill. For each model the median log residual143    b = median(ln y - ln p) measures the common level error (staleness);144    the re-levelled MdAPE uses p * exp(b), i.e. an oracle time adjustment145    of the kind practitioners obtain from a published index."""146    rows = []147    for scheme, Ps in P.items():148        y = Ps["price"].to_numpy(float)149        for m in MODEL_NAMES:150            p = Ps[m].to_numpy(float)151            b = float(np.median(np.log(y) - np.log(p)))152            rows.append({"scheme": scheme, "model": m,153                         "median_log_resid": b,154                         "mdape": _mdape(y, p),155                         "mdape_relevelled": _mdape(y, p * np.exp(b)),156                         "mape_relevelled": float(np.mean(157                             np.abs(p * np.exp(b) - y) / y) * 100),158                         "rmse_ln_centered": float(np.sqrt(np.mean(159                             (np.log(y) - np.log(p) - b) ** 2)))})160    pd.DataFrame(rows).to_csv(OUT / "relevel.csv", index=False)161    print("  level-bias decomposition done")162163164PAIRS = [("A2 Semi-log", "A1 Linear (levels)"),165         ("A6 Semi-log + splines", "A2 Semi-log"),166         ("A4 Box-Cox", "A2 Semi-log"),167         ("A3 Log-log", "A2 Semi-log"),168         ("B2 Year FE", "B1 No time effects"),169         ("B4 Month FE", "B3 Quarter FE"),170         ("C3 Grid-cell FE (~5.5 km)", "C2 Municipality FE"),171         ("C4 Grid-cell FE (~1.1 km)", "C2 Municipality FE"),172         ("D2 Ridge (cross-validated)", "D1 OLS (muni + month FE)"),173         ("D4 Gradient boosting", "D3 Random forest"),174         ("D4 Gradient boosting", "D5 Gradient boosting, no coordinates"),175         ("D4 Gradient boosting", "A1 Linear (levels)"),176         ("A6 Semi-log + splines", "A1 Linear (levels)")]177178179def step_pairs(P: dict):180    """Targeted comparisons: for each (A, B) the MdAPE difference A - B181    with its cluster-bootstrap interval and the clustered DM statistics182    on the APE and squared-log losses (positive t = B more accurate)."""183    rows = []184    for scheme, Ps in P.items():185        for a, b in PAIRS:186            boot = inference.cluster_bootstrap_mdape(Ps, [a, b], ref=b)187            x = boot.set_index("model").loc[a]188            t1 = inference.dm_test(Ps, a, b, loss=inference.ape)189            t2 = inference.dm_test(Ps, a, b, loss=inference.sq_log)190            rows.append({"scheme": scheme, "model_a": a, "model_b": b,191                         "mdape_a": x["mdape"],192                         "mdape_b": boot.set_index("model").loc[b, "mdape"],193                         "diff": x["diff_vs_ref"], "diff_lo": x["diff_lo"],194                         "diff_hi": x["diff_hi"],195                         "dm_ape_t": t1["t"], "dm_ape_p": t1["p"],196                         "dm_sqlog_t": t2["t"], "dm_sqlog_p": t2["p"]})197    pd.DataFrame(rows).to_csv(OUT / "pairs.csv", index=False)198    print("  targeted pairwise comparisons done")199200201def step_group_splits(df):202    for scheme in ("group_parcel", "group_coord"):203        res = models.run_horserace(df, scheme)204        res.to_csv(OUT / f"horserace_{scheme}.csv", index=False)205206207def step_nested_fe(df):208    rows = []209    for scheme in ("random", "temporal"):210        res = models.run_horserace(df, scheme,211                                   registry=models.robustness_registry())212        res["scheme"] = scheme213        rows.append(res)214    pd.concat(rows).to_csv(OUT / "nested_fe.csv", index=False)215216217def step_boxcox_fine(df):218    d = models.add_derived(df)219    d["sale_year_c"] = d["sale_year"].astype(str)220    train, test = models.split(d, "random")221    grid = [round(x, 2) for x in np.arange(-0.25, 0.751, 0.05)]222    lam, prof = models.fit_boxcox(train, models.NUM_LEVELS,223                                  models.CATS + ["muni", "quarter"],224                                  grid=grid)225    prof["chosen"] = prof["lambda"] == lam226    # out-of-sample accuracy of the Box–Cox model at the refined lambda227    spec = dict(name="A4 fine", group="x", kind="boxcox",228                numeric=models.NUM_LEVELS, fe=["muni", "quarter"],229                boxcox_grid=[lam])230    pred, _ = models.fit_predict(spec, train, test)231    prof.attrs = {}232    prof["mdape_at_chosen"] = np.where(prof["chosen"],233                                       _mdape(test["price"].to_numpy(float),234                                              np.clip(pred, config.PRED_FLOOR,235                                                      None)), np.nan)236    prof.to_csv(OUT / "boxcox_fine.csv", index=False)237    print(f"  Box–Cox fine grid: lambda* = {lam}")238239240def step_hgb_sensitivity(df):241    rows = []242    for scheme in ("random", "temporal"):243        reg = [dict(name="D4 Gradient boosting (baseline)", group="S",244                    kind="hgb")]245        reg += [dict(name=f"D4 {label}", group="S", kind="hgb",246                     hgb_params=params)247                for label, params in models.HGB_ALTERNATIVES.items()]248        res = models.run_horserace(df, scheme, registry=reg)249        res["scheme"] = scheme250        rows.append(res)251    pd.concat(rows).to_csv(OUT / "hgb_sensitivity.csv", index=False)252253254def main() -> None:255    config.ensure_dirs()256    df = sample.load()257    P = {s: pd.read_parquet(config.PROCESSED / f"predictions_{s}.parquet")258         for s in ("random", "temporal")}259    t0 = time.time()260    step_inference(P)261    step_same_period(P)262    step_by_quarter(P)263    step_leakage(P)264    step_relevel(P)265    step_pairs(P)266    step_group_splits(df)267    step_nested_fe(df)268    step_boxcox_fine(df)269    step_hgb_sensitivity(df)270    print(f"Robustness step finished in {(time.time() - t0) / 60:.1f} min")271272273if __name__ == "__main__":274    main()275
Files · main
Menu

Theme