Spaces:
Running
Running
Turki Almurahhem
Refresh v11-era borough/tier tables to v22 canonical; regenerate scatter data
3e830b4 | """ | |
| generate_scatter.py | |
| =================== | |
| Generates predicted-vs-actual scatter plot data from the NYC v22 model. | |
| Approach: | |
| 1. Load features_v6.csv + apply same time-based 15% holdout split | |
| 2. Apply minimal feature engineering (log transforms, encodings from meta.json) | |
| 3. Run model inference on holdout rows | |
| 4. Save data/processed/scatter_nyc.json (actual, predicted, borough, bldgclass) | |
| Run: | |
| cd /Users/totam/Desktop/new_try | |
| python scripts/generate_scatter.py | |
| """ | |
| import os, sys, json, warnings | |
| import numpy as np | |
| import polars as pl | |
| import joblib | |
| 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") | |
| print("=" * 60) | |
| print(" THAMAN β Scatter Plot Data Generator (NYC v22)") | |
| print("=" * 60) | |
| # ββ 1. Load data βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _FLOAT_OVERRIDES = { | |
| "dist_waterfront_m": pl.Float64, "dist_bike_lane_m": pl.Float64, | |
| "prior_sale_price": pl.Float64, "years_since_prior_sale": pl.Float64, | |
| "price_appreciation": pl.Float64, "is_flip": pl.Float64, | |
| } | |
| _csv_path = os.path.join(PROC, "features_v6.csv") | |
| if not os.path.exists(_csv_path): | |
| _csv_path = os.path.join(PROC, "features_v5.csv") | |
| print(" β features_v6.csv not found β using v5") | |
| df = ( | |
| pl.read_csv(_csv_path, schema_overrides=_FLOAT_OVERRIDES) | |
| .with_columns(pl.col("sale_date").str.to_datetime(format=None, strict=False)) | |
| .drop_nulls(subset=["sale_date", "sale_price", "latitude", "longitude"]) | |
| ) | |
| print(f" Loaded: {len(df):,} rows") | |
| # ββ 2. Time-based holdout (last 15%) βββββββββββββββββββββββββββββ | |
| df_sorted = df.sort("sale_date") | |
| n_hold = int(len(df_sorted) * 0.15) | |
| df_hold = df_sorted[-n_hold:] | |
| print(f" Holdout: {len(df_hold):,} rows (last 15%)") | |
| # ββ 3. Load model + meta βββββββββββββββββββββββββββββββββββββββββ | |
| stack_path = os.path.join(MODEL_DIR, "thaman_stack.pkl") | |
| meta_path = os.path.join(MODEL_DIR, "meta.json") | |
| print(" Loading model stack β¦") | |
| stk = joblib.load(stack_path) | |
| with open(meta_path) as f: | |
| meta = json.load(f) | |
| feat_names = meta["feature_names"] | |
| global_mean = meta.get("global_mean_log", 13.5) | |
| bldg_means = meta.get("bldgclass_means", {}) | |
| bb_means = meta.get("borough_bldg_means", {}) | |
| nta_means = meta.get("nta_means", {}) | |
| ntab_means = meta.get("nta_bldg_means", {}) | |
| nta_stats = meta.get("nta_stats", {}) | |
| bbl_lookup = meta.get("bbl_median_lookup", {}) | |
| bbl_global = float(meta.get("bbl_hist_psf_global", 0.0)) | |
| nta_trend = meta.get("nta_price_trend_slope_map", {}) # may not exist | |
| lag1_map = meta.get("nta_lag_q_map", {}) | |
| lag_glb = meta.get("nta_lag_q_globals", {}) | |
| g_logp = float(lag_glb.get("mean_logp", 13.0)) | |
| g_psf = float(lag_glb.get("median_psf", 500.0)) | |
| g_cnt = float(lag_glb.get("count", 50.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), | |
| } | |
| DIST_COLS = [ | |
| "dist_subway_m","dist_school_m","dist_park_m","dist_hospital_m", | |
| "dist_bus_m","dist_bike_lane_m","dist_elem_school_m","dist_express_subway_m", | |
| "dist_waterfront_m","dist_commuter_rail_m","dist_large_park_m","dist_flagship_park_m", | |
| ] | |
| v11_zip_lu = meta.get("v11_zip_lookup", {}) | |
| v11_nta_lu = meta.get("v11_nta_lookup", {}) | |
| v16_lu = meta.get("v16_nta_lookup", {}) | |
| v17_lu = meta.get("v17_nta_log_exempt", {}) | |
| v18_lu = meta.get("v18_nta_log_income", {}) | |
| v16_gh = float(meta.get("v16_global_hist_rate", 0.08)) | |
| v16_gf = float(meta.get("v16_global_flood_rate", 0.08)) | |
| v17_glob = float(meta.get("v17_global_log_exempt", 5.17)) | |
| v18_glob = float(meta.get("v18_global_log_income", 11.37)) | |
| MTA_MEDIANS = { | |
| "nearest_station_is_cbd": 0, | |
| "nearest_station_route_count": 2, | |
| "nearest_station_is_ada": 0, | |
| } | |
| # ββ 4. Build feature matrix for holdout ββββββββββββββββββββββββββ | |
| print(" Engineering features for holdout β¦") | |
| # Add computed columns that training script creates | |
| # Log-transformed distances | |
| _dist_cols_exist = [c for c in DIST_COLS if c in df_hold.columns] | |
| for col in _dist_cols_exist: | |
| df_hold = df_hold.with_columns( | |
| pl.col(col).cast(pl.Float64, strict=False).clip(lower_bound=0).log1p().alias(f"log_{col}") | |
| ) | |
| # Gravity distances | |
| lats = df_hold["latitude"].to_numpy() | |
| lons = df_hold["longitude"].to_numpy() | |
| for name, (clat, clon) in GRAVITY.items(): | |
| col_vals = np.sqrt((lats - clat)**2 + (lons - clon)**2) * 111_000.0 | |
| df_hold = df_hold.with_columns(pl.Series(f"dist_{name}_m", col_vals, dtype=pl.Float64)) | |
| # Borough interaction | |
| df_hold = df_hold.with_columns([ | |
| (pl.col("borough") == 1).cast(pl.Int32).alias("is_manhattan"), | |
| (pl.col("crime_rate_nta") * (pl.col("borough") == 1).cast(pl.Int32)).alias("crime_x_manhattan"), | |
| (pl.col("crime_rate_nta") * (1 - (pl.col("borough") == 1).cast(pl.Int32))).alias("crime_x_non_manhattan"), | |
| ]) | |
| # Cyclical month | |
| df_hold = df_hold.with_columns([ | |
| (pl.col("sale_month") * (2 * np.pi / 12)).sin().alias("sale_month_sin"), | |
| (pl.col("sale_month") * (2 * np.pi / 12)).cos().alias("sale_month_cos"), | |
| ]) | |
| # v5 interactions | |
| df_hold = df_hold.with_columns([ | |
| (pl.col("gross_square_feet") / pl.col("numfloors").clip(lower_bound=1)).alias("sqft_per_floor"), | |
| (pl.col("median_income_nta") / (pl.col("crime_rate_nta") + 1.0)).alias("income_over_crime"), | |
| (pl.col("residential_units") / pl.col("gross_square_feet").clip(lower_bound=1) * 1000.0).alias("density_index"), | |
| ((pl.col("gross_square_feet").clip(lower_bound=1).log1p()) * | |
| (pl.col("numfloors").clip(lower_bound=1).log1p())).alias("log_sqft_x_floors"), | |
| ]) | |
| # v6 structural | |
| df_hold = df_hold.with_columns([ | |
| pl.col("land_square_feet").clip(lower_bound=1).log1p().alias("log_land_sqft"), | |
| (pl.col("gross_square_feet") / pl.col("land_square_feet").clip(lower_bound=1)) | |
| .clip(upper_bound=10).alias("lot_coverage"), | |
| (pl.col("gross_square_feet") * pl.col("numfloors").clip(lower_bound=1)).log1p() | |
| .alias("bldg_vol_proxy"), | |
| (pl.col("prior_sale_price") / pl.col("gross_square_feet").clip(lower_bound=1)) | |
| .alias("prior_price_psf"), | |
| ]) | |
| # Walk score proxy (simplified β use walk_score_proxy from CSV if present) | |
| if "walk_score_proxy" not in df_hold.columns: | |
| df_hold = df_hold.with_columns(pl.lit(50.0).alias("walk_score_proxy")) | |
| # Target encodings from meta.json | |
| _bc_arr = df_hold["bldgclass"].to_list() | |
| _bor_arr = df_hold["borough"].to_list() | |
| _nta_arr = df_hold["ntacode"].to_list() if "ntacode" in df_hold.columns else [""] * len(df_hold) | |
| bldg_enc = [float(bldg_means.get(bc or "", global_mean)) for bc in _bc_arr] | |
| bb_enc = [float(bb_means.get(f"{bo}_{(bc or '')[:1]}", global_mean)) for bo, bc in zip(_bor_arr, _bc_arr)] | |
| nta_enc = [float(nta_means.get(nt or "", global_mean)) for nt in _nta_arr] | |
| ntab_enc = [float(ntab_means.get(f"{(nt or '')}_{(bc or '')[:1]}", | |
| float(nta_means.get(nt or "", global_mean)))) for nt, bc in zip(_nta_arr, _bc_arr)] | |
| nta_cnt = [float(nta_stats.get(nt, {}).get("sale_count", 0)) for nt in _nta_arr] | |
| nta_psf = [float(nta_stats.get(nt, {}).get("median_psf", 0.0)) for nt in _nta_arr] | |
| # NTA trend slope β stored in nta_stats per NTA, or use global fallback | |
| _global_trend = float(meta.get("global_trend", 0.02)) | |
| nta_slope = [float(nta_stats.get(nt or "", {}).get("trend_slope", _global_trend)) | |
| for nt in _nta_arr] | |
| df_hold = df_hold.with_columns([ | |
| pl.Series("bldgclass_encoded", bldg_enc, dtype=pl.Float64), | |
| pl.Series("borough_bldg_encoded", bb_enc, dtype=pl.Float64), | |
| pl.Series("nta_encoded", nta_enc, dtype=pl.Float64), | |
| pl.Series("nta_bldg_encoded", ntab_enc, dtype=pl.Float64), | |
| pl.Series("nta_sale_count", nta_cnt, dtype=pl.Float64), | |
| pl.Series("nta_median_psf", nta_psf, dtype=pl.Float64), | |
| pl.Series("nta_price_trend_slope",nta_slope,dtype=pl.Float64), | |
| ]) | |
| # v11 features: use NTA/ZIP lookup medians as fallbacks | |
| for col in ["hpd_class_b_viol_zip","hpd_class_c_viol_zip","hpd_severity_score_zip", | |
| "dob_reno_permit_count","dob_newbld_permit_count"]: | |
| if col not in df_hold.columns: | |
| df_hold = df_hold.with_columns(pl.lit(0.0).cast(pl.Float64).alias(col)) | |
| for col in ["rat_density_nta","heat_density_nta"]: | |
| if col not in df_hold.columns: | |
| df_hold = df_hold.with_columns(pl.lit(0.0).cast(pl.Float64).alias(col)) | |
| for col, val in MTA_MEDIANS.items(): | |
| if col not in df_hold.columns: | |
| df_hold = df_hold.with_columns(pl.lit(val).cast(pl.Float64).alias(col)) | |
| # v12: NTA temporal lag β use per-row sale quarter for correct lag lookup | |
| import datetime as _dt | |
| _yr_arr = df_hold["sale_year"].to_list() if "sale_year" in df_hold.columns else [2023]*len(_nta_arr) | |
| _mo_arr = df_hold["sale_month"].to_list() if "sale_month" in df_hold.columns else [6] *len(_nta_arr) | |
| lag1_logp_arr = []; lag1_psf_arr = []; lag1_cnt_arr = [] | |
| lag2_logp_arr = []; momentum_arr = [] | |
| for nt, yr, mo in zip(_nta_arr, _yr_arr, _mo_arr): | |
| # current quarter index and previous two | |
| yrq = (int(yr) - 2018) * 4 + (int(mo) - 1) // 3 | |
| yrq1 = max(0, yrq - 1) # lag-1 | |
| yrq2 = max(0, yrq - 2) # lag-2 | |
| r1 = lag1_map.get(f"{nt}_{yrq1}", lag1_map.get(f"{nt or ''}_{yrq1}", {})) | |
| r2 = lag1_map.get(f"{nt}_{yrq2}", lag1_map.get(f"{nt or ''}_{yrq2}", {})) | |
| ml1 = float(r1.get("mean_logp", g_logp)) | |
| mp1 = float(r1.get("median_psf", g_psf)) | |
| mc1 = float(r1.get("count", g_cnt)) | |
| ml2 = float(r2.get("mean_logp", g_logp)) | |
| lag1_logp_arr.append(ml1); lag1_psf_arr.append(mp1); lag1_cnt_arr.append(mc1) | |
| lag2_logp_arr.append(ml2) | |
| momentum_arr.append(ml1 - ml2) | |
| df_hold = df_hold.with_columns([ | |
| pl.Series("nta_lag1q_mean_logp", lag1_logp_arr, dtype=pl.Float64), | |
| pl.Series("nta_lag1q_median_psf", lag1_psf_arr, dtype=pl.Float64), | |
| pl.Series("nta_lag1q_count", lag1_cnt_arr, dtype=pl.Float64), | |
| pl.Series("nta_lag2q_mean_logp", lag2_logp_arr, dtype=pl.Float64), | |
| pl.Series("nta_logp_momentum", momentum_arr, dtype=pl.Float64), | |
| ]) | |
| # v16: historic/flood (NTA lookup) | |
| v16_hist = [float(v16_lu.get(nt, {}).get("is_historic_dist", v16_gh)) for nt in _nta_arr] | |
| v16_flood = [float(v16_lu.get(nt, {}).get("in_flood_zone", v16_gf)) for nt in _nta_arr] | |
| v16_lm = [float(v16_lu.get(nt, {}).get("is_landmark", 0.0)) for nt in _nta_arr] | |
| v17_ex = [float(v17_lu.get(nt, v17_glob)) for nt in _nta_arr] | |
| v18_inc = [float(v18_lu.get(nt, v18_glob)) for nt in _nta_arr] | |
| df_hold = df_hold.with_columns([ | |
| pl.Series("is_historic_dist", v16_hist, dtype=pl.Float64), | |
| pl.Series("in_flood_zone", v16_flood, dtype=pl.Float64), | |
| pl.Series("is_landmark", v16_lm, dtype=pl.Float64), | |
| pl.Series("log_exempt_amount", v17_ex, dtype=pl.Float64), | |
| pl.Series("log_tract_median_income",v18_inc, dtype=pl.Float64), | |
| ]) | |
| # v21: BBL hist psf | |
| bbl_arr = df_hold["bbl"].cast(pl.Float64, strict=False).fill_null(0).cast(pl.Int64).to_list() \ | |
| if "bbl" in df_hold.columns else [0] * len(df_hold) | |
| bbl_hist = [float(bbl_lookup.get(b, nta_psf[i] or bbl_global)) | |
| for i, b in enumerate(bbl_arr)] | |
| df_hold = df_hold.with_columns(pl.Series("bbl_hist_psf", bbl_hist, dtype=pl.Float64)) | |
| # Also compute log_dist_citibike_m if dist_citibike_m exists | |
| if "dist_citibike_m" in df_hold.columns and "log_dist_citibike_m" not in df_hold.columns: | |
| df_hold = df_hold.with_columns( | |
| pl.col("dist_citibike_m").cast(pl.Float64, strict=False).clip(lower_bound=0).log1p() | |
| .alias("log_dist_citibike_m") | |
| ) | |
| # ββ 5. Build X_hold matrix ββββββββββββββββββββββββββββββββββββββββ | |
| print(" Building feature matrix β¦") | |
| # Add any remaining missing feat_name columns as 0 | |
| for fn in feat_names: | |
| if fn not in df_hold.columns: | |
| df_hold = df_hold.with_columns(pl.lit(0.0).cast(pl.Float64).alias(fn)) | |
| missing_filled = [fn for fn in feat_names if fn not in df_hold.columns] | |
| print(f" Features: {len(feat_names)} total | missing still: {len(missing_filled)}") | |
| X_hold = df_hold.select(feat_names).fill_null(0).fill_nan(0.0).to_numpy().astype(np.float32) | |
| # ββ 6. Run stack predictions ββββββββββββββββββββββββββββββββββββββ | |
| print(" Running stack inference β¦") | |
| final_xa = stk["xgb_a"]; final_xb = stk["xgb_b"] | |
| final_lg = stk["lgb"]; final_ct = stk["cat"] | |
| meta_model = stk["meta"]; meta_type = stk.get("meta_type", "ridge") | |
| p_xa = final_xa.predict(X_hold) | |
| p_xb = final_xb.predict(X_hold) | |
| p_lg = final_lg.predict(X_hold) | |
| p_ct = final_ct.predict(X_hold).astype(np.float32) | |
| S_hold = np.column_stack([p_xa, p_xb, p_lg, p_ct]) | |
| pred_log = meta_model.predict(S_hold) | |
| y_actual = np.expm1(df_hold["sale_price"].log1p().to_numpy()) | |
| y_pred = np.expm1(pred_log) | |
| # ββ 7. Compute metrics ββββββββββββββββββββββββββββββββββββββββββββ | |
| from sklearn.metrics import r2_score | |
| from sklearn.metrics import mean_absolute_error | |
| medape_val = float(np.median(np.abs(y_actual - y_pred) / np.maximum(y_actual, 1.0)) * 100) | |
| r2_val = r2_score(np.log1p(y_actual), pred_log) | |
| print(f" Scatter: RΒ²={r2_val:.4f} | MedAPE={medape_val:.2f}%") | |
| # Per-borough + per-tier diagnostics (same holdout predictions) | |
| _BM = {1: "Manhattan", 2: "Bronx", 3: "Brooklyn", 4: "Queens", 5: "Staten Island"} | |
| _boro_arr = np.array(df_hold["borough"].to_list()) | |
| _ape = np.abs(y_actual - y_pred) / np.maximum(y_actual, 1.0) * 100 | |
| print(" ββ Per-borough (holdout) ββ") | |
| for _bn, _bname in _BM.items(): | |
| _m = _boro_arr == _bn | |
| if _m.sum() == 0: | |
| continue | |
| _r2 = r2_score(np.log1p(y_actual[_m]), pred_log[_m]) | |
| print(f" {_bname:<14} n={_m.sum():>6,} RΒ²={_r2:.4f} MedAPE={np.median(_ape[_m]):.2f}%") | |
| print(" ββ Per-tier (holdout) ββ") | |
| for _lo, _hi, _tname in [(0, 5e5, "<$500K"), (5e5, 1e6, "$500Kβ1M"), | |
| (1e6, 3e6, "$1Mβ3M"), (3e6, 1e7, "$3Mβ10M")]: | |
| _m = (y_actual >= _lo) & (y_actual < _hi) | |
| if _m.sum() == 0: | |
| continue | |
| _r2 = r2_score(np.log1p(y_actual[_m]), pred_log[_m]) | |
| print(f" {_tname:<10} n={_m.sum():>6,} RΒ²={_r2:.4f} MedAPE={np.median(_ape[_m]):.2f}%") | |
| # ββ 8. Sample for scatter plot (2000 pts, stratified by borough) ββ | |
| BOROUGH_MAP = {1:"Manhattan",2:"Bronx",3:"Brooklyn",4:"Queens",5:"Staten Island"} | |
| boros = df_hold["borough"].to_list() | |
| np.random.seed(42) | |
| out_rows = [] | |
| for bn, bname in BOROUGH_MAP.items(): | |
| mask = [i for i, b in enumerate(boros) if b == bn] | |
| n_sample = min(400, len(mask)) | |
| chosen = np.random.choice(mask, n_sample, replace=False) | |
| for idx in chosen: | |
| out_rows.append({ | |
| "actual": round(float(y_actual[idx])), | |
| "predicted": round(float(y_pred[idx])), | |
| "borough": bname, | |
| "bldgclass": _bc_arr[idx], | |
| "error_pct": round(abs(y_actual[idx] - y_pred[idx]) / max(y_actual[idx], 1) * 100, 1), | |
| }) | |
| print(f" Sample: {len(out_rows)} points ({sum(1 for r in out_rows if r['borough']=='Manhattan')} MAN, " | |
| f"{sum(1 for r in out_rows if r['borough']=='Brooklyn')} BKN, etc.)") | |
| # ββ 9. Save βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| out = { | |
| "meta": { | |
| "model": "NYC v22", | |
| "r2": round(r2_val, 4), | |
| "medape": round(medape_val, 2), | |
| "n_total": len(df_hold), | |
| "n_sample": len(out_rows), | |
| "generated": str(_dt.date.today()), | |
| }, | |
| "points": out_rows, | |
| } | |
| out_path = os.path.join(PROC, "scatter_nyc.json") | |
| with open(out_path, "w") as f: | |
| json.dump(out, f) | |
| print(f" Saved β {out_path}") | |
| # ββ Riyadh scatter ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n Generating Riyadh scatter β¦") | |
| import pickle as _pkl | |
| riy_csv = os.path.join(PROC, "features_riyadh_v2.csv") | |
| rmeta_path = os.path.join(MODEL_DIR, "riyadh_meta.json") | |
| rstack_path = os.path.join(MODEL_DIR, "riyadh_stack.pkl") | |
| if os.path.exists(riy_csv) and os.path.exists(rstack_path): | |
| import pandas as _pd | |
| import warnings; warnings.filterwarnings("ignore") | |
| from math import radians, sin, cos, sqrt, atan2 | |
| _rdf = _pd.read_csv(riy_csv, encoding="utf-8-sig") | |
| with open(rmeta_path) as f: rmeta = json.load(f) | |
| with open(rstack_path, "rb") as f: rstk = _pkl.load(f) | |
| # ββ Fix corrupt 2024 quarter_id (same as training script) ββββββββββββββββ | |
| _bug_mask = _rdf["quarter_id"] >= 40000 | |
| if _bug_mask.sum() > 0: | |
| _rdf.loc[_bug_mask, "quarter_id"] = ( | |
| _rdf.loc[_bug_mask, "year"].astype(int) * 10 + | |
| _rdf.loc[_bug_mask, "quarter_id"] % 10 | |
| ).astype(int) | |
| print(f" Fixed {_bug_mask.sum()} corrupt 2024 quarter_id rows") | |
| _TARGET = "sale_price_sar_sqm" | |
| _cutoff = 20251 | |
| _train_r = _rdf[_rdf["quarter_id"] < _cutoff].copy() | |
| _hold_r = _rdf[_rdf["quarter_id"] >= _cutoff].copy() | |
| print(f" Riyadh holdout: {len(_hold_r)} rows (train: {len(_train_r)})") | |
| # ββ Engineer missing features βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. Hub distances (haversine from district_lat/lon) | |
| _HUBS_R = { | |
| "kafd": (24.771, 46.637), | |
| "old_city": (24.690, 46.722), | |
| "industrial": (24.620, 46.873), | |
| "airport": (24.957, 46.699), | |
| } | |
| def _haversine_m(lat1, lon1, lat2, lon2): | |
| R = 6371000 | |
| phi1, phi2 = radians(lat1), radians(lat2) | |
| dphi = radians(lat2 - lat1) | |
| dlam = radians(lon2 - lon1) | |
| a = sin(dphi/2)**2 + cos(phi1)*cos(phi2)*sin(dlam/2)**2 | |
| return R * 2 * atan2(sqrt(a), sqrt(1-a)) | |
| for _hub, (_hlat, _hlon) in _HUBS_R.items(): | |
| col = f"dist_{_hub}_m" | |
| lcol = f"log_dist_{_hub}_m" | |
| _hold_r[col] = _hold_r.apply( | |
| lambda r: _haversine_m(r["district_lat"], r["district_lon"], _hlat, _hlon) | |
| if _pd.notna(r.get("district_lat")) else 5000.0, axis=1).astype(np.float32) | |
| _hold_r[lcol] = np.log1p(_hold_r[col]).astype(np.float32) | |
| # 2. rei_type_idx β type-matched REI from meta lookup | |
| _rei_lookup = rmeta.get("rei_type_idx_lookup", {}) | |
| _rei_latest = rmeta.get("rei_type_idx_latest", {}) | |
| _TYPE_REI_MAP = {"is_apartment": "apartment", "is_villa": "villa", | |
| "is_residential_plot": "residential_plot", "is_building": "building"} | |
| def _get_rei_type(row): | |
| qid = str(int(row["quarter_id"])) if _pd.notna(row.get("quarter_id")) else "" | |
| ptype = next((v for k, v in _TYPE_REI_MAP.items() if row.get(k, 0) == 1), "apartment") | |
| qdata = _rei_lookup.get(qid, _rei_latest) | |
| return float(qdata.get(ptype, _rei_latest.get(ptype, 100.0))) | |
| _hold_r["rei_type_idx"] = _hold_r.apply(_get_rei_type, axis=1).astype(np.float32) | |
| # 3. Metro binary features + nearest_metro_line from metro_district_lookup | |
| _metro_lkp = rmeta.get("metro_district_lookup", {}) | |
| _METRO_500_THRESH = 500.0 | |
| _METRO_1K_THRESH = 1000.0 | |
| def _metro_feats(row): | |
| dist = float(row.get("dist_metro_m", 9999)) | |
| dist_m = dist if _pd.notna(dist) else 9999.0 | |
| lkp = _metro_lkp.get(str(row.get("district_ar", "")), {}) | |
| if lkp: | |
| dist_m = float(lkp.get("dist_metro_m", dist_m)) | |
| return ( | |
| int(dist_m <= _METRO_500_THRESH), | |
| int(dist_m <= _METRO_1K_THRESH), | |
| int(lkp.get("nearest_metro_line", 0)), | |
| ) | |
| _metro_vals = _hold_r.apply(_metro_feats, axis=1, result_type="expand") | |
| _hold_r["metro_500m"] = _metro_vals[0].astype(np.float32) | |
| _hold_r["metro_1km"] = _metro_vals[1].astype(np.float32) | |
| _hold_r["nearest_metro_line"] = _metro_vals[2].astype(np.float32) | |
| # 4. District lag features from training portion | |
| _dq_med = (_train_r.groupby(["district_ar", "quarter_id"])[_TARGET] | |
| .median().reset_index().rename(columns={_TARGET: "_dq_med"})) | |
| _qseq = sorted(_train_r["quarter_id"].unique()) | |
| _qprev = {q: _qseq[i-1] if i > 0 else None for i, q in enumerate(_qseq)} | |
| _qprev2 = {q: _qseq[i-2] if i > 1 else None for i, q in enumerate(_qseq)} | |
| _dq_dict = {(r["district_ar"], r["quarter_id"]): r["_dq_med"] | |
| for _, r in _dq_med.iterrows()} | |
| _city_med = float(_train_r[_TARGET].median()) | |
| def _lag_feats(row): | |
| dist = str(row.get("district_ar", "")) | |
| q = int(row.get("quarter_id", 0)) | |
| # find prev quarter in train sequence (or nearest) | |
| pq = _qprev.get(q) or (_qseq[-1] if _qseq else None) | |
| pq2 = _qprev2.get(q) or (_qseq[-2] if len(_qseq) > 1 else pq) | |
| l1 = _dq_dict.get((dist, pq), _city_med) if pq else _city_med | |
| l2 = _dq_dict.get((dist, pq2), _city_med) if pq2 else _city_med | |
| mom = (l1 - l2) / max(l2, 1.0) | |
| return float(l1), float(l2), float(mom) | |
| _lag_vals = _hold_r.apply(_lag_feats, axis=1, result_type="expand") | |
| _hold_r["district_lag1q_median_psqm"] = _lag_vals[0].astype(np.float32) | |
| _hold_r["district_lag2q_median_psqm"] = _lag_vals[1].astype(np.float32) | |
| _hold_r["district_lag_momentum"] = _lag_vals[2].astype(np.float32) | |
| # 5. v11: type-stratified lags + price std + suhail density (from training portion) | |
| _TYPE_COL_MAP = {"is_apartment": "apt", "is_villa": "villa", | |
| "is_residential_plot": "plot", "is_building": "bldg"} | |
| def _ptype(row): | |
| for col, label in _TYPE_COL_MAP.items(): | |
| if row.get(col, 0) == 1: return label | |
| return "apt" | |
| # compute districtΓtypeΓquarter agg from training portion | |
| _dtq_agg = ( | |
| _train_r.copy() | |
| .assign(_ptype=[_ptype(r) for _, r in _train_r.iterrows()]) | |
| .groupby(["district_ar", "_ptype", "quarter_id"])[_TARGET] | |
| .agg(["median", "std"]) | |
| .reset_index() | |
| .rename(columns={"median": "_dtq_m", "std": "_dtq_s"}) | |
| ) | |
| _dtq_dict2 = {(r["district_ar"], r["_ptype"], r["quarter_id"]): r["_dtq_m"] | |
| for _, r in _dtq_agg.iterrows()} | |
| _dtq_std2 = {(r["district_ar"], r["_ptype"], r["quarter_id"]): r["_dtq_s"] | |
| for _, r in _dtq_agg.iterrows()} | |
| _global_std = float(_train_r[_TARGET].std()) | |
| def _v11_feats(row): | |
| dist = str(row.get("district_ar", "")) | |
| q = int(row.get("quarter_id", 0)) | |
| pt = _ptype(row) | |
| pq = _qprev.get(q) or (_qseq[-1] if _qseq else None) | |
| pq2 = _qprev2.get(q) or (_qseq[-2] if len(_qseq) > 1 else pq) | |
| l1t = _dtq_dict2.get((dist, pt, pq), _dq_dict.get((dist, pq), _city_med)) if pq else _city_med | |
| l2t = _dtq_dict2.get((dist, pt, pq2), _dq_dict.get((dist, pq2), _city_med)) if pq2 else _city_med | |
| std1 = _dtq_std2.get((dist, pt, pq), _global_std) if pq else _global_std | |
| return float(l1t), float(l2t), float(std1) | |
| _v11_vals = _hold_r.apply(_v11_feats, axis=1, result_type="expand") | |
| _hold_r["district_type_lag1q_psqm"] = _v11_vals[0].astype(np.float32) | |
| _hold_r["district_type_lag2q_psqm"] = _v11_vals[1].astype(np.float32) | |
| _hold_r["district_lag1q_std_psqm"] = _v11_vals[2].astype(np.float32) | |
| # log_suhail_n_trans: lag-1q individual transaction count from suhail raw | |
| _suhail_all = os.path.join(BASE, "data", "raw", "suhail_riyadh_tx_raw.csv") | |
| if os.path.exists(_suhail_all): | |
| import polars as _pl3 | |
| _stx = ( | |
| _pl3.read_csv(str(_suhail_all), ignore_errors=True) | |
| .filter(_pl3.col("province_name") == "Ψ§ΩΨ±ΩΨ§ΨΆ") | |
| .with_columns([ | |
| _pl3.col("date").str.slice(0, 4).cast(_pl3.Int64).alias("_sy"), | |
| _pl3.col("date").str.slice(5, 2).cast(_pl3.Int64).alias("_sm"), | |
| ]) | |
| .with_columns((_pl3.col("_sy") * 10 + (_pl3.col("_sm") - 1) // 3 + 1).alias("quarter_id")) | |
| .filter((_pl3.col("psqm") >= 500) & (_pl3.col("psqm") <= 50_000)) | |
| .group_by(["district_ar", "quarter_id"]) | |
| .agg(_pl3.len().alias("_s_cnt")) | |
| ).to_pandas() | |
| _s_dict2 = {(r["district_ar"], r["quarter_id"]): r["_s_cnt"] for _, r in _stx.iterrows()} | |
| def _suhail_cnt(row): | |
| pq = _qprev.get(int(row.get("quarter_id", 0))) | |
| return _s_dict2.get((str(row.get("district_ar", "")), pq), 0) if pq else 0 | |
| _hold_r["log_suhail_n_trans"] = np.log1p(_hold_r.apply(_suhail_cnt, axis=1)).astype(np.float32) | |
| else: | |
| _hold_r["log_suhail_n_trans"] = np.float32(0.0) | |
| # ββ Build feature matrix ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| rfeat = rmeta.get("feature_names", []) | |
| rfeat_ok = [f for f in rfeat if f in _hold_r.columns] | |
| missing_r = [f for f in rfeat if f not in _hold_r.columns] | |
| if missing_r: | |
| print(f" Still missing (β0): {missing_r}") | |
| X_r = np.zeros((len(_hold_r), len(rfeat)), dtype=np.float32) | |
| for i, fn in enumerate(rfeat): | |
| if fn in _hold_r.columns: | |
| X_r[:, i] = _hold_r[fn].fillna(0).values.astype(np.float32) | |
| rp = np.column_stack([ | |
| rstk["xgb"].predict(X_r), | |
| rstk["lgb"].predict(X_r), | |
| rstk["cat"].predict(X_r), | |
| ]) | |
| ry_pred_log = rstk["meta"].predict(rp) | |
| ry_actual = _hold_r[_TARGET].values.astype(np.float32) # already raw SAR/sqm | |
| ry_pred = np.expm1(ry_pred_log) # model predicts log1p space | |
| TYPE_MAP = {"is_apartment":"Apartment","is_villa":"Villa", | |
| "is_residential_plot":"Plot","is_building":"Building"} | |
| r_types = [] | |
| for _, row in _hold_r.iterrows(): | |
| t = "Other" | |
| for col, label in TYPE_MAP.items(): | |
| if col in row and row[col] == 1: | |
| t = label; break | |
| r_types.append(t) | |
| np.random.seed(42) | |
| chosen_r = np.random.choice(len(_hold_r), min(800, len(_hold_r)), replace=False) | |
| r_out = [{"actual": round(float(ry_actual[i])), | |
| "predicted": round(float(ry_pred[i])), | |
| "type": r_types[i], | |
| "error_pct": round(abs(ry_actual[i]-ry_pred[i])/max(ry_actual[i],1)*100,1)} | |
| for i in chosen_r] | |
| r_medape = float(np.median(np.abs(ry_actual-ry_pred)/np.maximum(ry_actual,1))*100) | |
| r_r2 = r2_score(np.log1p(np.maximum(ry_actual, 1)), ry_pred_log) | |
| rout = { | |
| "meta": {"model":"Riyadh v10","r2":round(r_r2,4), | |
| "medape":round(r_medape,2),"n_total":len(_hold_r),"n_sample":len(r_out), | |
| "generated":str(_dt.date.today())}, | |
| "points": r_out, | |
| } | |
| rout_path = os.path.join(PROC, "scatter_riyadh.json") | |
| with open(rout_path, "w") as f: | |
| json.dump(rout, f) | |
| print(f" Riyadh: RΒ²={r_r2:.4f} MedAPE={r_medape:.2f}% | saved β {rout_path}") | |
| else: | |
| print(" Riyadh data/model not found β skipping") | |
| print("\n Done β") | |