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%
6.4 KB · 168 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3#4"""Spatial and robustness analysis (refactor of the original run_v3_spatial.py).56Produces results/v3_spatial_results.pkl with:7  1. ZIP3 fixed-effects OLS (in/out-of-sample R2)8  2. Moran's I on OLS residuals (3 random subsamples of 5,000, KNN k=8)9  3. XGBoost with latitude/longitude10  4. XGBoost without any geographic features11  5. Ablation analysis over 6 progressive feature sets1213Usage:  python scripts/01_spatial_robustness.py [--output results/v3_spatial_results.pkl]14"""1516import argparse17import pickle18import sys19import time20import warnings21from pathlib import Path2223import numpy as np24import pandas as pd25from sklearn.linear_model import LinearRegression26from sklearn.metrics import mean_squared_error, r2_score27from sklearn.model_selection import train_test_split2829sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))30from wp3 import config, data31from wp3.models import train_xgb3233warnings.filterwarnings("ignore")343536def zip3_fixed_effects_ols(df, X_full, y, idx_train, idx_test):37    """OLS with 3-digit-ZIP fixed effects, evaluated on the random holdout."""38    zip3 = df["address_zipcode"].fillna("000").str[:3]39    zip3_dummies = pd.get_dummies(zip3, prefix="zip3", drop_first=True).astype(np.int8)40    X_zip3 = pd.concat([X_full, zip3_dummies], axis=1)4142    ols = LinearRegression(n_jobs=-1)43    ols.fit(X_zip3.iloc[idx_train].values, y[idx_train])44    y_pred_train = ols.predict(X_zip3.iloc[idx_train].values)45    y_pred_test = ols.predict(X_zip3.iloc[idx_test].values)4647    return {48        "r2_insample": r2_score(y[idx_train], y_pred_train),49        "r2_outsample": r2_score(y[idx_test], y_pred_test),50        "rmse_outsample": float(np.sqrt(mean_squared_error(y[idx_test], y_pred_test))),51        "n_zip3": int(zip3.nunique()),52        "n_features": X_zip3.shape[1],53    }545556def morans_i_robustness(df, X_full, y, idx_train, n_subsamples=3, size=5000, k=8):57    """Moran's I on baseline-OLS residuals over independent random subsamples."""58    from esda.moran import Moran59    from libpysal.weights import KNN6061    ols = LinearRegression(n_jobs=-1)62    ols.fit(X_full.iloc[idx_train].values, y[idx_train])63    residuals = y - ols.predict(X_full.values)6465    coords = df[["latitude", "longitude"]].values66    values, pvalues = [], []67    for i in range(n_subsamples):68        rng = np.random.RandomState(config.RANDOM_STATE + i)69        idx_sub = rng.choice(len(df), size=size, replace=False)70        w = KNN.from_array(coords[idx_sub], k=k)71        w.transform = "r"72        mi = Moran(residuals[idx_sub], w)73        values.append(mi.I)74        pvalues.append(mi.p_sim)75        print(f"  Subsample {i + 1}: Moran's I = {mi.I:.4f}, p = {mi.p_sim:.4f}")7677    return {78        "values": values,79        "pvalues": pvalues,80        "mean": float(np.mean(values)),81        "std": float(np.std(values)),82        "pval_mean": float(np.mean(pvalues)),83        "n_subsamples": n_subsamples,84        "subsample_size": size,85        "k_neighbors": k,86    }878889def main():90    parser = argparse.ArgumentParser(description=__doc__)91    parser.add_argument("--output", type=Path,92                        default=config.RESULTS_DIR / "v3_spatial_results.pkl")93    args = parser.parse_args()9495    t0 = time.time()96    print("Loading analytical sample...")97    df = data.load_analytical_sample()98    X_full, parts = data.build_feature_matrix(df)99    y = df["ln_price"].values100    print(f"  {df.shape[0]:,} rows, {X_full.shape[1]} regressors")101102    idx_train, idx_test = train_test_split(103        np.arange(len(df)), test_size=0.2, random_state=config.RANDOM_STATE)104    geo_mask = df["address_state"].isin(config.HOLDOUT_STATES).values105    idx_geo_train, idx_geo_test = np.where(~geo_mask)[0], np.where(geo_mask)[0]106107    results = {}108109    print("\n1. ZIP3 fixed-effects OLS...")110    results["zip3_fe_ols"] = zip3_fixed_effects_ols(df, X_full, y, idx_train, idx_test)111    print(f"   R2 in={results['zip3_fe_ols']['r2_insample']:.4f} "112          f"out={results['zip3_fe_ols']['r2_outsample']:.4f}")113114    print("\n2. Moran's I robustness...")115    results["morans_i_robustness"] = morans_i_robustness(df, X_full, y, idx_train)116117    print("\n3. XGBoost with latitude/longitude...")118    X_latlon = pd.concat([X_full, df[["latitude", "longitude"]]], axis=1)119    results["xgb_with_latlon"] = train_xgb(120        X_latlon.values.astype(np.float32), y,121        idx_train, idx_test, idx_geo_train, idx_geo_test)122    print(f"   R2 random={results['xgb_with_latlon']['r2_random']:.4f} "123          f"geo={results['xgb_with_latlon']['r2_geo']:.4f}")124125    print("\n4. XGBoost without geographic features...")126    X_nogeo = pd.concat(127        [df[config.ALL_BASE_FEATS],128         parts["cat_dummies"][parts["pure_categorical_dummies"]]], axis=1)129    results["xgb_no_geography"] = train_xgb(130        X_nogeo.values.astype(np.float32), y,131        idx_train, idx_test, idx_geo_train, idx_geo_test)132    print(f"   R2 random={results['xgb_no_geography']['r2_random']:.4f} "133          f"geo={results['xgb_no_geography']['r2_geo']:.4f}")134135    print("\n5. Ablation analysis...")136    ablation_specs = [137        ("1_structural", config.STRUCTURAL_FEATS, []),138        ("2_plus_lot", config.STRUCTURAL_FEATS + config.LOT_FEATS, []),139        ("3_plus_amenities",140         config.STRUCTURAL_FEATS + config.LOT_FEATS + config.AMENITY_FEATS, []),141        ("4_plus_neighborhood",142         config.STRUCTURAL_FEATS + config.LOT_FEATS + config.AMENITY_FEATS143         + config.NEIGHBORHOOD_FEATS, []),144        ("5_plus_market",145         config.STRUCTURAL_FEATS + config.LOT_FEATS + config.AMENITY_FEATS146         + config.NEIGHBORHOOD_FEATS + config.MARKET_FEATS, []),147        ("6_full_model", config.ALL_BASE_FEATS, list(parts["cat_dummies"].columns)),148    ]149    ablation = {}150    for name, feats, dummy_cols in ablation_specs:151        X_abl = (pd.concat([df[feats], parts["cat_dummies"][dummy_cols]], axis=1)152                 if dummy_cols else df[feats])153        res = train_xgb(X_abl.values.astype(np.float32), y,154                        idx_train, idx_test, idx_geo_train, idx_geo_test)155        res["features"] = list(X_abl.columns)156        ablation[name] = res157        print(f"   {name}: R2 random={res['r2_random']:.4f} geo={res['r2_geo']:.4f}")158    results["ablation"] = ablation159160    args.output.parent.mkdir(parents=True, exist_ok=True)161    with open(args.output, "wb") as f:162        pickle.dump(results, f)163    print(f"\nSaved to {args.output}  ({time.time() - t0:.0f}s total)")164165166if __name__ == "__main__":167    main()168