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%
7.7 KB · 211 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3#4"""Quantile-regression stability, inter-quantile tests, imputation and5winsorization sensitivity (refactor of the original v3_qr_imputation_analysis.py).67Produces results/v3_qr_imputation_results.pkl with:8  1. QR coefficient stability at tau=0.50 over 10 subsamples of 150,0009  2. Inter-quantile Wald tests (tau=0.10 vs tau=0.90)10  3. Imputation sensitivity: OLS re-estimated after dropping observations with11     originally missing year_built / lot_size_sqft (missingness read from DuckDB)12  4. Winsorization sensitivity: lot size capped at the 99.5th percentile,13     bike score capped at 1001415Usage:  python scripts/02_qr_imputation.py [--output results/v3_qr_imputation_results.pkl]16"""1718import argparse19import pickle20import sys21import time22import warnings23from pathlib import Path2425import duckdb26import numpy as np27import pandas as pd28import statsmodels.api as sm29from sklearn.linear_model import LinearRegression3031sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))32from wp3 import config, data3334warnings.filterwarnings("ignore")3536OLS_FEATURES = ["ln_sqft", "bedrooms", "bathrooms", "age", "age_sq",37                "ln_lot", "has_pool", "has_garage", "luxury_score",38                "tag_foreclosure", "on_waterfront"]394041def qr_stability(X_const, y_clean):42    """Median-regression coefficient stability across 10 random subsamples."""43    coef_matrix = {v: [] for v in config.KEY_VARS}44    for i, seed in enumerate(config.QR_STABILITY_SEEDS):45        rng = np.random.RandomState(seed)46        idx = rng.choice(len(X_const), size=config.QR_SUBSAMPLE_SIZE, replace=False)47        print(f"  QR subsample {i + 1}/{len(config.QR_STABILITY_SEEDS)} (seed={seed})...")48        res = sm.QuantReg(y_clean.iloc[idx], X_const.iloc[idx]).fit(q=0.50, max_iter=1000)49        for v in config.KEY_VARS:50            coef_matrix[v].append(res.params[v])5152    stability = {}53    for v, vals_list in coef_matrix.items():54        vals = np.array(vals_list)55        m, s = vals.mean(), vals.std()56        stability[v] = {57            "mean": m, "std": s,58            "cv": abs(s / m) if abs(m) > 1e-10 else np.inf,59            "min": vals.min(), "max": vals.max(),60            "sign_stability_pct": float(np.mean(np.sign(vals) == np.sign(m)) * 100),61        }62    return stability636465def inter_quantile_test(X_const, y_clean):66    """Wald z-tests for coefficient equality between tau=0.10 and tau=0.90."""67    rng = np.random.RandomState(config.RANDOM_STATE)68    idx = rng.choice(len(X_const), size=config.QR_SUBSAMPLE_SIZE, replace=False)69    X_sub, y_sub = X_const.iloc[idx], y_clean.iloc[idx]7071    print("  Fitting QR at tau=0.10 and tau=0.90...")72    res10 = sm.QuantReg(y_sub, X_sub).fit(q=0.10, max_iter=1000)73    res90 = sm.QuantReg(y_sub, X_sub).fit(q=0.90, max_iter=1000)7475    out = {}76    for v in config.KEY_VARS:77        diff = res10.params[v] - res90.params[v]78        z = diff / np.sqrt(res10.bse[v] ** 2 + res90.bse[v] ** 2)79        out[v] = {80            "beta_010": res10.params[v], "beta_090": res90.params[v],81            "difference": diff, "z_stat": z,82            "significant_5pct": bool(abs(z) > 1.96),83        }84    return out858687def load_missingness_flags():88    """Read original missingness of year_built / lot_size_sqft from the raw DB."""89    con = duckdb.connect(str(config.DUCKDB_PATH), read_only=True)90    flags = con.execute("""91        SELECT zpid,92               CASE WHEN year_built IS NULL THEN 1 ELSE 0 END AS yb_missing,93               CASE WHEN lot_size_sqft IS NULL THEN 1 ELSE 0 END AS lot_missing94        FROM properties95    """).fetchdf()96    con.close()97    flags["zpid"] = flags["zpid"].astype(np.int64)98    return flags99100101def build_unstandardized_matrix(df_merged, X_const):102    """Rebuild the OLS design matrix on the raw (unstandardized) scale."""103    xcols = [c for c in X_const.columns if c != "const"]104    missing = [c for c in xcols if c not in df_merged.columns]105    if missing:106        for cat_col in config.CATEGORICAL_COLS:107            if cat_col not in df_merged.columns:108                continue109            dummies = pd.get_dummies(df_merged[cat_col], prefix=cat_col)110            for dc in dummies.columns:111                if dc in missing:112                    df_merged[dc] = dummies[dc].astype(float)113    return df_merged[xcols].values.astype(np.float64), xcols114115116def imputation_sensitivity(df_merged, X_unstd, xcols):117    """OLS on subsets that drop originally-missing year_built / lot_size rows."""118    y = df_merged["ln_price"].values119120    def run(mask, label):121        lr = LinearRegression()122        lr.fit(X_unstd[mask], y[mask])123        coefs = dict(zip(xcols, lr.coef_))124        return {"label": label, "N": int(mask.sum()),125                "R2": lr.score(X_unstd[mask], y[mask]),126                "key_coefficients": {v: coefs[v] for v in OLS_FEATURES}}127128    no_yb = df_merged["yb_missing"].values == 0129    no_lot = df_merged["lot_missing"].values == 0130    scenarios = [131        (np.ones(len(df_merged), dtype=bool), "Full sample (baseline)"),132        (no_yb, "Drop missing year_built"),133        (no_lot, "Drop missing lot_size_sqft"),134        (no_yb & no_lot, "Drop both missing"),135    ]136    out = []137    for mask, label in scenarios:138        r = run(mask, label)139        out.append(r)140        print(f"  {label:<30} N={r['N']:>9,}  R2={r['R2']:.4f}")141    return out142143144def winsorization_sensitivity(df_merged, X_unstd, xcols):145    """Re-estimate OLS with lot size winsorized at p99.5 and bike score capped at 100."""146    df_w = df_merged.copy()147    y = df_merged["ln_price"].values148149    lot_p995 = df_w["lot_size_sqft"].quantile(0.995)150    n_lot = int((df_w["lot_size_sqft"] > lot_p995).sum())151    df_w["lot_size_sqft"] = df_w["lot_size_sqft"].clip(upper=lot_p995)152    df_w["ln_lot"] = np.where(df_w["lot_size_sqft"] > 0, np.log(df_w["lot_size_sqft"]), 0.0)153154    n_bike = int((df_w["bike_score"] > 100).sum())155    df_w["bike_score"] = df_w["bike_score"].clip(upper=100)156157    def fit(X):158        lr = LinearRegression()159        lr.fit(X, y)160        coefs = dict(zip(xcols, lr.coef_))161        return {"R2": lr.score(X, y),162                "key_coefficients": {v: coefs[v] for v in OLS_FEATURES}}163164    X_w = df_w[xcols].values.astype(np.float64)165    return {166        "baseline": fit(X_unstd),167        "winsorized": fit(X_w),168        "lot_p995": lot_p995,169        "n_lot_winsorized": n_lot,170        "n_bike_capped": n_bike,171    }172173174def main():175    parser = argparse.ArgumentParser(description=__doc__)176    parser.add_argument("--output", type=Path,177                        default=config.RESULTS_DIR / "v3_qr_imputation_results.pkl")178    args = parser.parse_args()179180    t0 = time.time()181    print("Loading model data...")182    md = data.load_model_data()183    X_const, y_clean, df_clean = md["X_const"], md["y_clean"], md["df_clean"]184185    results = {}186187    print("\n1. QR stability (tau=0.50, 10 subsamples)...")188    results["qr_stability"] = qr_stability(X_const, y_clean)189190    print("\n2. Inter-quantile difference test (tau=0.10 vs 0.90)...")191    results["iqr_test"] = inter_quantile_test(X_const, y_clean)192193    print("\n3. Imputation sensitivity...")194    flags = load_missingness_flags()195    df_merged = df_clean.merge(flags, on="zpid", how="left")196    X_unstd, xcols = build_unstandardized_matrix(df_merged, X_const)197    results["imputation_sensitivity"] = imputation_sensitivity(df_merged, X_unstd, xcols)198199    print("\n4. Winsorization sensitivity...")200    results["winsorization_sensitivity"] = winsorization_sensitivity(201        df_merged, X_unstd, xcols)202203    args.output.parent.mkdir(parents=True, exist_ok=True)204    with open(args.output, "wb") as f:205        pickle.dump(results, f)206    print(f"\nSaved to {args.output}  ({time.time() - t0:.0f}s total)")207208209if __name__ == "__main__":210    main()211