thaman / scripts /generate_figures.py
Turki Almurahhem
THAMAN dual-city AVM β€” Stack v22 (NYC) + v12 (Riyadh)
2e8a71f
Raw
History Blame Contribute Delete
14.2 kB
"""
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")