Spaces:
Running
Running
| """ | |
| THAMAN β Regenerate Model Figures (v2.1) | |
| ======================================== | |
| Produces updated SHAP importance, actual-vs-predicted, and error-by-borough | |
| plots using the current v2.1 model (XGB + LGB + CAT + Ridge stack). | |
| Run from project root: | |
| python scripts/generate_figures.py | |
| """ | |
| import os, sys, json, warnings | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib | |
| matplotlib.use("Agg") # non-interactive backend | |
| import matplotlib.pyplot as plt | |
| import matplotlib.ticker as mticker | |
| import xgboost as xgb | |
| import shap | |
| warnings.filterwarnings("ignore") | |
| BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| PROC = os.path.join(BASE, "data", "processed") | |
| MODEL_DIR = os.path.join(BASE, "models") | |
| sys.path.insert(0, BASE) | |
| from models.scorer import ThamanScorer | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def medape(y_true, y_pred): | |
| return float(np.median(np.abs(y_true - y_pred) / np.maximum(y_true, 1.0)) * 100) | |
| # ββ 1. Load data βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("[1/5] Loading features.csv β¦") | |
| df = pd.read_csv(os.path.join(PROC, "features.csv")) | |
| df["sale_date"] = pd.to_datetime(df["sale_date"], errors="coerce") | |
| df = df.dropna(subset=["sale_date", "sale_price", "latitude", "longitude"]) | |
| print(f" Rows: {len(df):,}") | |
| # ββ 2. Replicate feature engineering from train_stack_v2.py βββββββββββ | |
| print("[2/5] Engineering features β¦") | |
| from sklearn.preprocessing import MinMaxScaler | |
| dist_cols = [c for c in df.columns if c.startswith("dist_")] | |
| for col in dist_cols: | |
| df[f"log_{col}"] = np.log1p(df[col].clip(lower=0)) | |
| GRAVITY = { | |
| "midtown_manhattan": (40.7549, -73.9840), | |
| "downtown_manhattan": (40.7074, -74.0113), | |
| "downtown_brooklyn": (40.6928, -73.9903), | |
| "long_island_city": (40.7447, -73.9485), | |
| } | |
| for name, (clat, clon) in GRAVITY.items(): | |
| df[f"dist_{name}_m"] = ( | |
| np.sqrt((df["latitude"] - clat)**2 + (df["longitude"] - clon)**2) * 111_000 | |
| ) | |
| df["is_manhattan"] = (df["borough"] == 1).astype(int) | |
| df["crime_x_manhattan"] = df["crime_rate_nta"] * df["is_manhattan"] | |
| df["crime_x_non_manhattan"] = df["crime_rate_nta"] * (1 - df["is_manhattan"]) | |
| eps = 1e-6 | |
| walk_comps = pd.DataFrame({ | |
| "transit": 1.0 / df["dist_subway_m"].clip(lower=eps), | |
| "bus": 1.0 / df["dist_bus_m"].clip(lower=eps), | |
| "amenities": df["poi_count_500m"], | |
| "bike": 1.0 / df["dist_bike_lane_m"].clip(lower=eps), | |
| "park": 1.0 / df["dist_park_m"].clip(lower=eps), | |
| }, index=df.index) | |
| ws_scaler = MinMaxScaler() | |
| walk_normed = pd.DataFrame( | |
| ws_scaler.fit_transform(walk_comps), | |
| columns=walk_comps.columns, index=df.index | |
| ) | |
| w = {"transit": 0.35, "bus": 0.15, "amenities": 0.30, "bike": 0.10, "park": 0.10} | |
| df["walk_score_proxy"] = ( | |
| sum(walk_normed[c] * wt for c, wt in w.items()) * 100 | |
| ).clip(lower=0, upper=100) | |
| df["sale_month_sin"] = np.sin(2 * np.pi * df["sale_month"] / 12) | |
| df["sale_month_cos"] = np.cos(2 * np.pi * df["sale_month"] / 12) | |
| # PLUTO assesstot coalesce | |
| _PLUTO = os.path.join(BASE, "data", "raw", "nyc_pluto_25v4_csv", "pluto_25v4.csv") | |
| if os.path.exists(_PLUTO): | |
| _pa = pd.read_csv(_PLUTO, usecols=["bbl","assesstot","assessland"], low_memory=False) | |
| _pa["bbl"] = pd.to_numeric(_pa["bbl"], errors="coerce") | |
| _pa = _pa.dropna(subset=["bbl"]) | |
| _pa["bbl"] = _pa["bbl"].astype("int64") | |
| _bi = pd.to_numeric(df["bbl"], errors="coerce").fillna(0).astype("int64") | |
| df = df.assign(_bbl_int=_bi).merge( | |
| _pa.rename(columns={"bbl":"_bbl_int","assesstot":"_at","assessland":"_al"}), | |
| on="_bbl_int", how="left" | |
| ).drop(columns=["_bbl_int"]) | |
| for _c, _p in [("assesstot","_at"),("assessland","_al")]: | |
| if _c not in df.columns: df[_c] = np.nan | |
| df[_c] = df[_c].combine_first(df[_p]) | |
| df.drop(columns=[_p], inplace=True) | |
| else: | |
| for _c in ["assesstot","assessland"]: | |
| if _c not in df.columns: df[_c] = np.nan | |
| # Target encoding (on full df β only for visualisation, not holdout fidelity) | |
| df["_log_price"] = np.log1p(df["sale_price"]) | |
| global_mean_log = float(df["_log_price"].mean()) | |
| bldg_means = df.groupby("bldgclass")["_log_price"].mean() | |
| df["bldgclass_encoded"] = df["bldgclass"].map(bldg_means).fillna(global_mean_log) | |
| df["borough_bldg_key"] = df["borough"].astype(str) + "_" + df["bldgclass"].str[:1] | |
| bb_means = df.groupby("borough_bldg_key")["_log_price"].mean() | |
| df["borough_bldg_encoded"] = df["borough_bldg_key"].map(bb_means).fillna(global_mean_log) | |
| # Impute prior_sale_price via assesstot ratio | |
| _has = df[(df["prior_sale_price"] > 0) & df["assesstot"].notna() & (df["assesstot"] > 0)] | |
| _ratio_by_boro = {int(b): float((g["prior_sale_price"] / g["assesstot"]).median()) | |
| for b, g in _has.groupby("borough")} | |
| _gr = float((_has["prior_sale_price"] / _has["assesstot"]).median()) if len(_has) else 10.0 | |
| _null_m = df["prior_sale_price"].isna() | (df["prior_sale_price"] == 0) | |
| _ok_m = df["assesstot"].notna() & (df["assesstot"] > 0) | |
| df.loc[_null_m & _ok_m, "prior_sale_price"] = ( | |
| df.loc[_null_m & _ok_m, "assesstot"] * | |
| df.loc[_null_m & _ok_m, "borough"].map(_ratio_by_boro).fillna(_gr) | |
| ) | |
| # ββ 3. Build feature matrix using scorer's feature list βββββββββββββββ | |
| print("[3/5] Building X matrix β¦") | |
| scorer = ThamanScorer() | |
| feat_names = scorer.feature_names | |
| missing = [f for f in feat_names if f not in df.columns] | |
| if missing: | |
| print(f" β Missing {len(missing)} features β filling with 0: {missing[:5]} β¦") | |
| for m in missing: | |
| df[m] = 0.0 | |
| X = df[feat_names].fillna(0).values.astype(np.float32) | |
| y_true_usd = df["sale_price"].values | |
| # Apply winsorization | |
| for col, cap in scorer.winsorize.items(): | |
| if col in feat_names: | |
| idx = feat_names.index(col) | |
| X[:, idx] = np.clip(X[:, idx], None, cap) | |
| # Predictions using full stack | |
| import joblib | |
| from sklearn.linear_model import Ridge | |
| dmat = xgb.DMatrix(X, feature_names=feat_names) | |
| log_xgb = scorer.model.predict(dmat) | |
| if scorer._stack is not None: | |
| log_lgb = scorer._stack["lgb"].predict(X).astype(np.float32) | |
| cols = [log_xgb, log_lgb] | |
| if "cat" in scorer._stack: | |
| log_cat = scorer._stack["cat"].predict(X).astype(np.float32) | |
| cols.append(log_cat) | |
| S = np.column_stack(cols) | |
| log_pred = scorer._stack["meta"].predict(S).astype(np.float32) | |
| else: | |
| log_pred = log_xgb | |
| y_pred_usd = np.expm1(log_pred) | |
| # ββ 4a. SHAP importance β random sample of 3000 βββββββββββββββββββββββ | |
| print("[4/5] Computing SHAP values (sample=3000) β¦") | |
| rng = np.random.default_rng(42) | |
| sidx = rng.choice(len(X), size=min(3000, len(X)), replace=False) | |
| X_shap = X[sidx] | |
| explainer = shap.TreeExplainer(scorer.model) | |
| shap_vals = explainer.shap_values(X_shap) | |
| mean_abs = np.abs(shap_vals).mean(axis=0) | |
| top20_idx = np.argsort(mean_abs)[::-1][:20] | |
| top20_names = [feat_names[i] for i in top20_idx] | |
| top20_vals = mean_abs[top20_idx] | |
| # Prettier feature names | |
| _NAME_MAP = { | |
| "gross_square_feet": "Building size (sq ft)", | |
| "bldgclass_encoded": "Building class (encoded)", | |
| "land_square_feet": "Land size (sq ft)", | |
| "longitude": "Longitude (E-W location)", | |
| "latitude": "Latitude (N-S location)", | |
| "prior_sale_price": "Prior sale price", | |
| "assesstot": "Assessed total value", | |
| "borough_bldg_encoded": "Borough Γ bldg class", | |
| "median_income_nta": "Median income (NTA)", | |
| "school_district": "School district", | |
| "residential_units": "Residential units", | |
| "building_age": "Building age", | |
| "numfloors": "Number of floors", | |
| "airbnb_count_500m": "Airbnb density 500m", | |
| "dist_subway_m": "Distance to subway", | |
| "log_dist_subway_m": "Log dist. subway", | |
| "poi_count_500m": "POI count 500m", | |
| "walk_score_proxy": "Walk score (proxy)", | |
| "crime_rate_nta": "Crime rate (NTA)", | |
| "dist_midtown_manhattan_m":"Dist. Midtown Manhattan", | |
| "dist_downtown_manhattan_m":"Dist. Downtown Manhattan", | |
| "price_appreciation": "Price appreciation", | |
| "builtfar": "Built FAR", | |
| "commfar": "Commercial FAR", | |
| "far_utilization": "FAR utilization", | |
| "sale_year": "Sale year", | |
| "dist_waterfront_m": "Dist. waterfront", | |
| "mortgage_rate_30yr": "Mortgage rate 30yr", | |
| } | |
| pretty_names = [_NAME_MAP.get(n, n.replace("_", " ").title()) for n in top20_names] | |
| fig, ax = plt.subplots(figsize=(9, 7)) | |
| colors = ["#3b82f6" if i < 5 else "#93c5fd" for i in range(20)] | |
| bars = ax.barh(range(20), top20_vals[::-1], color=colors[::-1], edgecolor="none", height=0.7) | |
| ax.set_yticks(range(20)) | |
| ax.set_yticklabels(pretty_names[::-1], fontsize=9.5) | |
| ax.set_xlabel("Mean |SHAP value| (log-price scale)", fontsize=10) | |
| ax.set_title("THAMAN v2.1 β Top 20 Feature Importances (SHAP)", fontsize=13, fontweight="bold", pad=12) | |
| ax.spines[["top","right"]].set_visible(False) | |
| ax.grid(axis="x", linestyle="--", alpha=0.4) | |
| ax.xaxis.set_major_formatter(mticker.FormatStrFormatter("%.3f")) | |
| for i, (bar, val) in enumerate(zip(bars, top20_vals[::-1])): | |
| ax.text(val + 0.001, bar.get_y() + bar.get_height()/2, | |
| f"{val:.3f}", va="center", ha="left", fontsize=8, color="#374151") | |
| plt.tight_layout() | |
| out_shap = os.path.join(MODEL_DIR, "shap_importance.png") | |
| plt.savefig(out_shap, dpi=150, bbox_inches="tight") | |
| plt.close() | |
| print(f" β Saved {out_shap}") | |
| # ββ 4b. Actual vs Predicted βββββββββββββββββββββββββββββββββββββββββββ | |
| print(" Generating actual vs predicted scatter β¦") | |
| mask = (y_true_usd < 10_000_000) & (y_pred_usd < 10_000_000) | |
| yt, yp = y_true_usd[mask] / 1e6, y_pred_usd[mask] / 1e6 | |
| med_ape = medape(y_true_usd, y_pred_usd) | |
| # Density colouring | |
| from scipy.stats import gaussian_kde | |
| xy = np.vstack([yt, yp]) | |
| dsamp = min(5000, len(yt)) | |
| didx = rng.choice(len(yt), dsamp, replace=False) | |
| kde = gaussian_kde(xy[:, didx]) | |
| z = kde(xy[:, didx]) | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| sc = ax.scatter(yt[didx], yp[didx], c=z, s=6, cmap="YlOrRd", alpha=0.6, linewidths=0) | |
| plt.colorbar(sc, ax=ax, label="Density") | |
| lim = max(yt.max(), yp.max()) * 1.05 | |
| ax.plot([0, lim], [0, lim], "b--", lw=1.5, label="Perfect fit") | |
| ax.set_xlim(0, lim); ax.set_ylim(0, lim) | |
| ax.set_xlabel("Actual Price ($M)", fontsize=11) | |
| ax.set_ylabel("Predicted Price ($M)", fontsize=11) | |
| from sklearn.metrics import r2_score | |
| r2 = r2_score(yt, yp[didx[:len(yt)]] if len(yp) != len(yt) else yp) | |
| # recalculate properly | |
| r2 = r2_score(y_true_usd[mask], y_pred_usd[mask]) | |
| ax.set_title(f"THAMAN v2.1 β Actual vs Predicted\nRΒ²={r2:.4f} MedAPE={med_ape:.1f}% (n={mask.sum():,}, cap $10M)", | |
| fontsize=12, fontweight="bold") | |
| ax.legend(fontsize=9) | |
| ax.spines[["top","right"]].set_visible(False) | |
| ax.grid(linestyle="--", alpha=0.3) | |
| ax.xaxis.set_major_formatter(mticker.FormatStrFormatter("$%.1fM")) | |
| ax.yaxis.set_major_formatter(mticker.FormatStrFormatter("$%.1fM")) | |
| plt.tight_layout() | |
| out_avp = os.path.join(MODEL_DIR, "actual_vs_predicted.png") | |
| plt.savefig(out_avp, dpi=150, bbox_inches="tight") | |
| plt.close() | |
| print(f" β Saved {out_avp}") | |
| # ββ 4c. Error by borough ββββββββββββββββββββββββββββββββββββββββββββββ | |
| print(" Generating error-by-borough boxplot β¦") | |
| pct_err = np.abs(y_true_usd - y_pred_usd) / np.maximum(y_true_usd, 1.0) * 100 | |
| boroughs = df["borough"].values | |
| bnames = {1:"Manhattan", 2:"Bronx", 3:"Brooklyn", 4:"Queens", 5:"Staten\nIsland"} | |
| colors_b = ["#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", "#ef4444"] | |
| data_by_b = [] | |
| labels_b = [] | |
| for b in [1, 2, 3, 4, 5]: | |
| mask_b = (boroughs == b) & (pct_err < 200) # cap outliers for visualisation | |
| data_by_b.append(pct_err[mask_b]) | |
| labels_b.append(bnames[b]) | |
| fig, ax = plt.subplots(figsize=(9, 5)) | |
| bp = ax.boxplot(data_by_b, patch_artist=True, notch=False, | |
| medianprops=dict(color="white", linewidth=2), | |
| whiskerprops=dict(linewidth=1.2), | |
| capprops=dict(linewidth=1.2), | |
| flierprops=dict(marker="o", markersize=2, alpha=0.3)) | |
| for patch, color in zip(bp["boxes"], colors_b): | |
| patch.set_facecolor(color) | |
| patch.set_alpha(0.75) | |
| for i, (d, lbl) in enumerate(zip(data_by_b, labels_b)): | |
| med = np.median(d) | |
| ax.text(i + 1, med + 1.5, f"{med:.0f}%", ha="center", va="bottom", | |
| fontsize=9, fontweight="bold", color="#1f2937") | |
| ax.set_xticks(range(1, 6)) | |
| ax.set_xticklabels(labels_b, fontsize=11) | |
| ax.set_ylabel("Absolute % Error", fontsize=11) | |
| ax.set_title("THAMAN v2.1 β Prediction Error by Borough\n(Median MedAPE shown above each box)", | |
| fontsize=12, fontweight="bold") | |
| ax.axhline(med_ape, color="#6b7280", linestyle="--", lw=1.2, label=f"Overall MedAPE {med_ape:.1f}%") | |
| ax.set_ylim(0, min(100, np.percentile(np.concatenate(data_by_b), 95) * 1.3)) | |
| ax.legend(fontsize=9) | |
| ax.spines[["top","right"]].set_visible(False) | |
| ax.grid(axis="y", linestyle="--", alpha=0.4) | |
| plt.tight_layout() | |
| out_err = os.path.join(MODEL_DIR, "error_by_borough.png") | |
| plt.savefig(out_err, dpi=150, bbox_inches="tight") | |
| plt.close() | |
| print(f" β Saved {out_err}") | |
| # ββ 5. Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print(f"\n[5/5] Done.") | |
| print(f" RΒ² : {r2:.4f}") | |
| print(f" MedAPE : {med_ape:.2f}%") | |
| print(f" Top feature: {pretty_names[0]} ({top20_vals[0]:.3f})") | |
| print(f"\n Figures saved to models/") | |
| print(f" β shap_importance.png") | |
| print(f" β actual_vs_predicted.png") | |
| print(f" β error_by_borough.png") | |