Spaces:
Running
Running
| """ | |
| THAMAN Stack v22 β train_stack_v12.py | |
| ====================================== | |
| Current NYC production training pipeline (134 features): | |
| Quarterly lagged NTA market features (zero leakage): | |
| β’ nta_lag1q_mean_logp β NTA mean log-price from previous quarter | |
| β’ nta_lag1q_median_psf β NTA median $/sqft from previous quarter | |
| β’ nta_lag1q_count β NTA sale count from previous quarter (market heat) | |
| β’ nta_lag2q_mean_logp β NTA mean log-price from 2 quarters ago | |
| β’ nta_logp_momentum β Price trend: lag1 β lag2 (positive = appreciating NTA) | |
| All temporal stats computed from df_work only, joined on Q-1/Q-2 offset. | |
| Holdout rows see only training-window stats β no future leakage. | |
| Data source: data/processed/features_v6.csv (fallback: v5/v4) | |
| Run: | |
| cd /Users/totam/Desktop/new_try | |
| python training/train_stack_v12.py | |
| """ | |
| import os, sys, json, warnings, joblib, datetime | |
| import numpy as np | |
| import polars as pl | |
| import xgboost as xgb | |
| import lightgbm as lgb | |
| from catboost import CatBoostRegressor | |
| from sklearn.model_selection import GroupKFold, train_test_split | |
| from sklearn.linear_model import Ridge | |
| from sklearn.metrics import r2_score, mean_absolute_error | |
| from sklearn.preprocessing import MinMaxScaler | |
| 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("=" * 70) | |
| print(" THAMAN Stack v22 β Quarterly NTA Temporal Lookback + BBL/NTAΓBT (134 features)") | |
| print("=" * 70) | |
| def safe_clip_min(arr, minval=1.0): | |
| return np.maximum(arr, minval) | |
| def medape(y_true_usd, y_pred_usd): | |
| return float(np.median(np.abs(y_true_usd - y_pred_usd) / safe_clip_min(y_true_usd)) * 100) | |
| # ββ 1. Load data βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _FLOAT_OVERRIDES = { | |
| "dist_waterfront_m": pl.Float64, "dist_bike_lane_m": pl.Float64, | |
| "school_district": pl.Float64, "district_avg_score": pl.Float64, | |
| "district_school_count": pl.Float64, | |
| "prior_sale_price": pl.Float64, "years_since_prior_sale": pl.Float64, | |
| "price_appreciation": pl.Float64, "is_flip": pl.Float64, | |
| } | |
| print("\n[1/9] Loading features_v7.csv (v22 enriched + 2026 Q1/Q2 appended) β¦") | |
| _V7_PATH = os.path.join(PROC, "features_v7.csv") | |
| _V6_PATH = os.path.join(PROC, "features_v6.csv") | |
| _V5_PATH = os.path.join(PROC, "features_v5.csv") | |
| _V4_PATH = os.path.join(PROC, "features_v4.csv") | |
| if os.path.exists(_V7_PATH): | |
| _csv_path = _V7_PATH | |
| elif os.path.exists(_V6_PATH): | |
| _csv_path = _V6_PATH | |
| print(" β features_v7.csv not found β falling back to v6") | |
| elif os.path.exists(_V5_PATH): | |
| _csv_path = _V5_PATH | |
| print(" β features_v7.csv not found β falling back to v5") | |
| else: | |
| _csv_path = _V4_PATH | |
| print(" β features_v7.csv not found β falling back to v4") | |
| 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" Rows: {len(df):,} | Cols: {df.shape[1]}") | |
| # ββ 1b. PLUTO join βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _PLUTO_PATH = os.path.join(BASE, "data", "raw", "nyc_pluto_25v4_csv", "pluto_25v4.csv") | |
| if os.path.exists(_PLUTO_PATH): | |
| print(" Joining PLUTO β¦") | |
| _pluto = ( | |
| pl.read_csv(_PLUTO_PATH, | |
| columns=["bbl", "assesstot", "assessland", "histdist", "pfirm15_flag", "landmark", "exempttot"], | |
| ignore_errors=True) | |
| .with_columns(pl.col("bbl").cast(pl.Float64, strict=False)) | |
| .drop_nulls(subset=["bbl"]) | |
| .with_columns([ | |
| pl.col("bbl").cast(pl.Int64).alias("_bbl_int"), | |
| # is_historic_dist: 1 if property is in an LPC-designated historic district | |
| pl.col("histdist").is_not_null().cast(pl.Int8).alias("_is_hist"), | |
| # in_flood_zone: 1 if in 2015 FEMA preliminary flood zone | |
| (pl.col("pfirm15_flag").cast(pl.Int64, strict=False) == 1).cast(pl.Int8).alias("_flood"), | |
| # is_landmark: 1 if individually designated NYC landmark | |
| pl.col("landmark").is_not_null().cast(pl.Int8).alias("_is_lm"), | |
| # exempttot: tax exemption amount (421-a, J-51, co-op, etc.) | |
| pl.col("exempttot").cast(pl.Float64, strict=False).fill_null(0.0).alias("_exempt"), | |
| ]) | |
| .drop(["bbl", "histdist", "pfirm15_flag", "landmark", "exempttot"]) | |
| .rename({"assesstot": "_at", "assessland": "_al"}) | |
| ) | |
| df = (df.with_columns( | |
| pl.col("bbl").cast(pl.Float64, strict=False).fill_null(0).cast(pl.Int64).alias("_bbl_int")) | |
| .join(_pluto, on="_bbl_int", how="left").drop("_bbl_int")) | |
| for c, p in [("assesstot","_at"),("assessland","_al")]: | |
| if c not in df.columns: df = df.with_columns(pl.lit(None).cast(pl.Float64).alias(c)) | |
| df = df.with_columns(pl.coalesce([pl.col(c), pl.col(p)]).alias(c)).drop(p) | |
| # Fill new binary features with 0 for unmatched lots | |
| # _is_hist/_flood/_is_lm come from the left join (null = no match β 0) | |
| for c, p in [("is_historic_dist","_is_hist"),("in_flood_zone","_flood"),("is_landmark","_is_lm")]: | |
| if p in df.columns: | |
| df = df.with_columns(pl.col(p).fill_null(0).cast(pl.Int8).alias(c)).drop(p) | |
| elif c not in df.columns: | |
| df = df.with_columns(pl.lit(0).cast(pl.Int8).alias(c)) | |
| # exempt_amount: fill unmatched lots with 0 (no exemption) | |
| if "_exempt" in df.columns: | |
| df = df.with_columns(pl.col("_exempt").fill_null(0.0).alias("exempt_amount")).drop("_exempt") | |
| else: | |
| df = df.with_columns(pl.lit(0.0).cast(pl.Float64).alias("exempt_amount")) | |
| df = df.with_columns(pl.col("exempt_amount").log1p().alias("log_exempt_amount")) | |
| print(f" assesstot coverage: {df['assesstot'].is_not_null().mean()*100:.1f}%") | |
| print(f" historic_dist: {df['is_historic_dist'].sum()} lots | " | |
| f"flood_zone: {df['in_flood_zone'].sum()} lots | " | |
| f"landmark: {df['is_landmark'].sum()} lots") | |
| print(f" exempt_amount > 0: {(df['exempt_amount'] > 0).sum()} lots " | |
| f"({(df['exempt_amount']>0).mean()*100:.1f}%)") | |
| else: | |
| for c in ["assesstot","assessland","is_historic_dist","in_flood_zone","is_landmark", | |
| "exempt_amount","log_exempt_amount"]: | |
| if c not in df.columns: df = df.with_columns(pl.lit(None).cast(pl.Float64).alias(c)) | |
| # ββ 1c. Census tract income join (v18) ββββββββββββββββββββββββββββββββββ | |
| _CENSUS_INCOME_PATH = os.path.join(BASE, "data", "raw", "census_tract_income.csv") | |
| _CENSUS_TRACTS_SHP = os.path.join(BASE, "data", "raw", "census_tracts", "tl_2020_36_tract.shp") | |
| if os.path.exists(_CENSUS_INCOME_PATH) and os.path.exists(_CENSUS_TRACTS_SHP): | |
| print(" Joining census tract income (v18) β¦") | |
| import geopandas as _gpd | |
| import pandas as _pd_ct | |
| # Load & filter to NYC boroughs (FIPS county codes) | |
| _NYC_COUNTIES = {"061", "005", "047", "081", "085"} | |
| _tracts = _gpd.read_file(_CENSUS_TRACTS_SHP) | |
| _tracts = _tracts[_tracts["COUNTYFP"].isin(_NYC_COUNTIES)].copy() | |
| _tracts = _tracts.to_crs("EPSG:4326") | |
| # Merge income table (drop ACS null sentinel -666666666) | |
| _income = _pd_ct.read_csv(_CENSUS_INCOME_PATH) | |
| _income["GEOID"] = _income["geoid"].astype(str).str.zfill(11) | |
| _income = _income[_income["median_income"] > 0][["GEOID", "median_income"]] | |
| _tracts = _tracts.merge(_income, on="GEOID", how="left") | |
| # Build point GeoDataFrame from property lat/lon | |
| _lats = df["latitude"].to_numpy() | |
| _lons = df["longitude"].to_numpy() | |
| _pts = _gpd.GeoDataFrame( | |
| {"_row_idx": np.arange(len(_lats))}, | |
| geometry=_gpd.points_from_xy(_lons, _lats), | |
| crs="EPSG:4326", | |
| ) | |
| # Point-in-polygon join; keep only left_index to re-align with original rows | |
| _joined = _gpd.sjoin(_pts, _tracts[["median_income", "geometry"]], how="left", predicate="within") | |
| # sjoin may produce duplicates for points on boundaries β take first match per row | |
| _joined = _joined[~_joined.index.duplicated(keep="first")] | |
| _income_vals = _joined.reindex(np.arange(len(_lats)))["median_income"].to_numpy(dtype=np.float64) | |
| # log1p transform (NaN β 0 fill applied at feature-select time) | |
| _log_income = np.where( | |
| np.isnan(_income_vals) | (_income_vals <= 0), | |
| np.nan, | |
| np.log1p(_income_vals), | |
| ) | |
| df = df.with_columns(pl.Series("log_tract_median_income", _log_income, dtype=pl.Float64)) | |
| _cov = float(np.isfinite(_log_income).mean()) * 100 | |
| _med = float(np.nanmedian(_income_vals[_income_vals > 0])) if (_income_vals > 0).any() else 0.0 | |
| print(f" Census income coverage: {_cov:.1f}% | median tract income: ${_med:,.0f}") | |
| else: | |
| df = df.with_columns(pl.lit(None).cast(pl.Float64).alias("log_tract_median_income")) | |
| print(" Census tract files not found β log_tract_median_income set to null") | |
| # ββ 1d. Park size-stratified distances (v19) βββββββββββββββββββββββββββββββββ | |
| _PARKS_PATH = os.path.join(BASE, "data", "raw", "parks_with_coords.csv") | |
| if os.path.exists(_PARKS_PATH): | |
| from scipy.spatial import cKDTree as _cKDTree | |
| import pandas as _pd_pk | |
| _parks = _pd_pk.read_csv(_PARKS_PATH).dropna(subset=["latitude","longitude","ACRES"]) | |
| _parks["ACRES"] = _pd_pk.to_numeric(_parks["ACRES"], errors="coerce").fillna(0.0) | |
| _lrg = _parks[_parks["ACRES"] >= 10.0][["latitude","longitude"]].values # large β₯10 acres | |
| _flag = _parks[_parks["ACRES"] >= 100.0][["latitude","longitude"]].values # flagship β₯100 acres | |
| print(f" Parks: {len(_parks)} total | large β₯10ac: {len(_lrg)} | flagship β₯100ac: {len(_flag)}") | |
| _prop_pts = df.select(["latitude","longitude"]).to_numpy().astype(np.float64) | |
| _DEG2M = 111_000.0 | |
| def _kd_dist_m(tree_pts, query_pts, deg2m=_DEG2M): | |
| if len(tree_pts) == 0: | |
| return np.full(len(query_pts), np.nan) | |
| tree = _cKDTree(tree_pts) | |
| dists, _ = tree.query(query_pts, k=1) | |
| return dists * deg2m | |
| _d_large = _kd_dist_m(_lrg, _prop_pts) | |
| _d_flag = _kd_dist_m(_flag, _prop_pts) | |
| df = df.with_columns([ | |
| pl.Series("dist_large_park_m", _d_large, dtype=pl.Float64), | |
| pl.Series("dist_flagship_park_m", _d_flag, dtype=pl.Float64), | |
| pl.Series("log_dist_large_park_m", np.log1p(_d_large), dtype=pl.Float64), | |
| pl.Series("log_dist_flagship_park_m", np.log1p(_d_flag), dtype=pl.Float64), | |
| ]) | |
| print(f" dist_large_park: median {np.nanmedian(_d_large):.0f}m | " | |
| f"dist_flagship: median {np.nanmedian(_d_flag):.0f}m") | |
| else: | |
| for _c in ["dist_large_park_m","dist_flagship_park_m","log_dist_large_park_m","log_dist_flagship_park_m"]: | |
| df = df.with_columns(pl.lit(None).cast(pl.Float64).alias(_c)) | |
| print(" parks_with_coords.csv not found β park size features null") | |
| # ββ 1e. Waterfront / coastline proximity (v20) βββββββββββββββββββββββββββββββ | |
| _COAST_PATH = os.path.join(BASE, "data", "raw", "nyc_coastline_pts.npy") | |
| if os.path.exists(_COAST_PATH): | |
| from scipy.spatial import cKDTree as _cKDTree_wf | |
| _coast_pts = np.load(_COAST_PATH).astype(np.float64) # (27116, 2) [lat, lon] | |
| _coast_tree = _cKDTree_wf(_coast_pts) | |
| _prop_pts_wf = df.select(["latitude","longitude"]).to_numpy().astype(np.float64) | |
| _dists_wf, _ = _coast_tree.query(_prop_pts_wf, k=1) | |
| _d_wf = _dists_wf * 111_000.0 # degrees β metres | |
| df = df.with_columns([ | |
| pl.Series("dist_waterfront_m", _d_wf, dtype=pl.Float64), | |
| pl.Series("log_dist_waterfront_m", np.log1p(_d_wf), dtype=pl.Float64), | |
| pl.Series("waterfront_200m", (_d_wf < 200).astype(np.int8), dtype=pl.Int8), | |
| ]) | |
| _wf_log_price = np.log1p(df["sale_price"].to_numpy().astype(np.float64)) | |
| _wf_corr = np.corrcoef(_d_wf, _wf_log_price)[0, 1] | |
| print(f" Coastline pts: {len(_coast_pts):,} | dist_waterfront median " | |
| f"{np.median(_d_wf):.0f}m | corr(dist_wf, log_price)={_wf_corr:.3f} " | |
| f"| waterfront_200m={(_d_wf < 200).mean() * 100:.1f}%") | |
| else: | |
| for _c in ["dist_waterfront_m", "log_dist_waterfront_m", "waterfront_200m"]: | |
| df = df.with_columns(pl.lit(None).cast(pl.Float64).alias(_c)) | |
| print(" nyc_coastline_pts.npy not found β waterfront features null") | |
| # ββ 2. Feature engineering βββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[2/9] Engineering features β¦") | |
| dist_cols = [c for c in df.columns if c.startswith("dist_")] | |
| df = df.with_columns([pl.col(col).cast(pl.Float64, strict=False).alias(col) for col in dist_cols]) | |
| df = df.with_columns([pl.col(col).clip(lower_bound=0).log1p().alias(f"log_{col}") for col in dist_cols]) | |
| 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), | |
| } | |
| df = df.with_columns([ | |
| (((pl.col("latitude") - clat) ** 2 + (pl.col("longitude") - clon) ** 2) ** 0.5 * 111_000) | |
| .alias(f"dist_{name}_m") | |
| for name, (clat, clon) in GRAVITY.items() | |
| ]) | |
| df = df.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"), | |
| ]) | |
| eps = 1e-6 | |
| walk_cols_order = ["transit", "bus", "amenities", "bike", "park"] | |
| walk_comps_np = np.column_stack([ | |
| 1.0 / df["dist_subway_m"].clip(lower_bound=eps).to_numpy(), | |
| 1.0 / df["dist_bus_m"].clip(lower_bound=eps).to_numpy(), | |
| df["poi_count_500m"].to_numpy(), | |
| 1.0 / df["dist_bike_lane_m"].clip(lower_bound=eps).to_numpy(), | |
| 1.0 / df["dist_park_m"].clip(lower_bound=eps).to_numpy(), | |
| ]) | |
| ws_scaler = MinMaxScaler() | |
| walk_normed_np = ws_scaler.fit_transform(walk_comps_np) | |
| walk_score_np = np.clip(walk_normed_np @ np.array([0.35, 0.15, 0.30, 0.10, 0.10]) * 100, 0, 100) | |
| df = df.with_columns(pl.Series("walk_score_proxy", walk_score_np)) | |
| walk_score_scaler_params = { | |
| col: {"data_min": float(ws_scaler.data_min_[i]), "data_max": float(ws_scaler.data_max_[i]), | |
| "scale": float(ws_scaler.scale_[i])} | |
| for i, col in enumerate(walk_cols_order) | |
| } | |
| df = df.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 interaction features ββββββββββββββββββββββββββββββββββββββββββββ | |
| df = df.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"), | |
| ]) | |
| # ββ NEW v6 structural features ββββββββββββββββββββββββββββββββββββββββββ | |
| print(" Adding v6 structural features β¦") | |
| df = df.with_columns([ | |
| # Log of land sqft β captures lot size non-linearly | |
| pl.col("land_square_feet").clip(lower_bound=1).log1p().alias("log_land_sqft"), | |
| # Lot coverage ratio: how much of the land is the building? | |
| (pl.col("gross_square_feet") / pl.col("land_square_feet").clip(lower_bound=1)) | |
| .clip(upper_bound=10).alias("lot_coverage"), | |
| # Volumetric proxy: sqft Γ floors (total internal volume) | |
| (pl.col("gross_square_feet") * pl.col("numfloors").clip(lower_bound=1)).log1p() | |
| .alias("bldg_vol_proxy"), | |
| # Prior price per sqft (very strong signal where available) | |
| (pl.col("prior_sale_price") / pl.col("gross_square_feet").clip(lower_bound=1)) | |
| .alias("prior_price_psf"), | |
| ]) | |
| print(" v6 structural: log_land_sqft, lot_coverage, bldg_vol_proxy, prior_price_psf") | |
| # ββ 3. Time-based holdout ββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[3/9] Time-based holdout (last 15%) β¦") | |
| df_sorted = df.sort("sale_date") | |
| n_hold = int(len(df_sorted) * 0.15) | |
| df_work = df_sorted[:-n_hold] | |
| df_hold = df_sorted[-n_hold:] | |
| print(f" Work: {len(df_work):,} | Hold: {len(df_hold):,}") | |
| # ββ 3b. Impute prior_sale_price ββββββββββββββββββββββββββββββββββββββββ | |
| print("\n Imputing prior_sale_price via assesstot β¦") | |
| _has_both = df_work.filter( | |
| (pl.col("prior_sale_price") > 0) & | |
| pl.col("assesstot").is_not_null() & (pl.col("assesstot") > 0) | |
| ).with_columns((pl.col("prior_sale_price") / pl.col("assesstot")).alias("_pr")) | |
| _ratio_df = _has_both.group_by("borough").agg(pl.col("_pr").median().alias("ratio")) | |
| _ratio_map = {int(r["borough"]): float(r["ratio"]) for r in _ratio_df.iter_rows(named=True)} | |
| _glob_r = float(_has_both["_pr"].median()) if len(_has_both) else 10.0 | |
| _rl = pl.DataFrame({"borough": list(_ratio_map.keys()), "_ir": list(_ratio_map.values())}) \ | |
| .with_columns(pl.col("borough").cast(df_work.schema["borough"])) | |
| def _impute(fr): | |
| fr = fr.join(_rl, on="borough", how="left").with_columns(pl.col("_ir").fill_null(_glob_r)) | |
| return fr.with_columns( | |
| pl.when((pl.col("prior_sale_price").is_null() | (pl.col("prior_sale_price") == 0)) & | |
| pl.col("assesstot").is_not_null() & (pl.col("assesstot") > 0)) | |
| .then(pl.col("assesstot") * pl.col("_ir")).otherwise(pl.col("prior_sale_price")) | |
| .alias("prior_sale_price") | |
| ).drop("_ir") | |
| df_work = _impute(df_work); df_hold = _impute(df_hold) | |
| print(f" prior_sale_price coverage: {(df_work['prior_sale_price'] > 0).sum()/len(df_work)*100:.1f}%") | |
| # ββ 3c. Quarterly NTA temporal lookback features βββββββββββββββββββββββ | |
| print("\n Computing quarterly NTA lookback features (lag-1 / lag-2) β¦") | |
| _psf_clip = 1.0 | |
| for _fr_name, _fr in [("work", df_work), ("hold", df_hold)]: | |
| pass # will assign below | |
| df_work = df_work.with_columns( | |
| ((pl.col("sale_year") - 2018) * 4 + (pl.col("sale_month") - 1) // 3).alias("_yrq") | |
| ) | |
| df_hold = df_hold.with_columns( | |
| ((pl.col("sale_year") - 2018) * 4 + (pl.col("sale_month") - 1) // 3).alias("_yrq") | |
| ) | |
| # Compute quarterly NTA stats from training data only (no holdout leakage) | |
| _nta_q = ( | |
| df_work | |
| .with_columns([ | |
| pl.col("sale_price").log1p().alias("_log_sp"), | |
| (pl.col("sale_price") / pl.col("gross_square_feet").clip(lower_bound=_psf_clip)).alias("_psf"), | |
| ]) | |
| .group_by(["ntacode", "_yrq"]) | |
| .agg([ | |
| pl.col("_log_sp").mean().alias("_mean_logp"), | |
| pl.col("_psf").median().alias("_median_psf"), | |
| pl.len().alias("_count"), | |
| ]) | |
| ) | |
| _global_logp = float(_nta_q["_mean_logp"].median()) | |
| _global_psf = float(_nta_q["_median_psf"].median()) | |
| _global_cnt = float(_nta_q["_count"].median()) | |
| _lag1 = ( | |
| _nta_q.with_columns(pl.col("_yrq") + 1) | |
| .rename({"_mean_logp": "nta_lag1q_mean_logp", | |
| "_median_psf": "nta_lag1q_median_psf", | |
| "_count": "nta_lag1q_count"}) | |
| ) | |
| _lag2 = ( | |
| _nta_q.with_columns(pl.col("_yrq") + 2) | |
| .select(["ntacode", "_yrq", pl.col("_mean_logp").alias("nta_lag2q_mean_logp")]) | |
| ) | |
| df_work = ( | |
| df_work | |
| .join(_lag1, on=["ntacode", "_yrq"], how="left") | |
| .join(_lag2, on=["ntacode", "_yrq"], how="left") | |
| ) | |
| df_hold = ( | |
| df_hold | |
| .join(_lag1, on=["ntacode", "_yrq"], how="left") | |
| .join(_lag2, on=["ntacode", "_yrq"], how="left") | |
| ) | |
| for _col, _fallback in [ | |
| ("nta_lag1q_mean_logp", _global_logp), | |
| ("nta_lag1q_median_psf", _global_psf), | |
| ("nta_lag1q_count", _global_cnt), | |
| ("nta_lag2q_mean_logp", _global_logp), | |
| ]: | |
| df_work = df_work.with_columns(pl.col(_col).fill_null(_fallback)) | |
| df_hold = df_hold.with_columns(pl.col(_col).fill_null(_fallback)) | |
| df_work = df_work.with_columns( | |
| (pl.col("nta_lag1q_mean_logp") - pl.col("nta_lag2q_mean_logp")).alias("nta_logp_momentum") | |
| ) | |
| df_hold = df_hold.with_columns( | |
| (pl.col("nta_lag1q_mean_logp") - pl.col("nta_lag2q_mean_logp")).alias("nta_logp_momentum") | |
| ) | |
| df_work = df_work.with_columns(pl.col("nta_logp_momentum").fill_null(0.0)) | |
| df_hold = df_hold.with_columns(pl.col("nta_logp_momentum").fill_null(0.0)) | |
| # Save lookup for inference | |
| _nta_q_lag1_save = {} | |
| for _r in _lag1.iter_rows(named=True): | |
| _key = f"{_r['ntacode']}_{_r['_yrq']}" | |
| _ml = _r["nta_lag1q_mean_logp"] | |
| _mp = _r["nta_lag1q_median_psf"] | |
| _nc = _r["nta_lag1q_count"] | |
| if _ml is None or _mp is None: continue | |
| _nta_q_lag1_save[_key] = { | |
| "mean_logp": round(float(_ml), 6), | |
| "median_psf": round(float(_mp), 2), | |
| "count": int(_nc) if _nc is not None else 0, | |
| } | |
| _nta_q_lag2_save = {} | |
| for _r in _lag2.iter_rows(named=True): | |
| _key = f"{_r['ntacode']}_{_r['_yrq']}" | |
| _ml2 = _r["nta_lag2q_mean_logp"] | |
| if _ml2 is None: continue | |
| _nta_q_lag2_save[_key] = round(float(_ml2), 6) | |
| print(f" Lag-1 entries: {len(_lag1)} | Lag-2 entries: {len(_lag2)}") | |
| work_null = df_work["nta_lag1q_mean_logp"].is_null().sum() | |
| print(f" nta_lag1q_mean_logp nulls after fill: {work_null}/{len(df_work)}") | |
| # ββ 4. Target encoding ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[4/9] Target encoding (bldgclass + borough_bldg + NTA + NTAΓbldg) β¦") | |
| LOG_TARGET = "log_price" | |
| df_work = df_work.with_columns(pl.col("sale_price").log1p().alias(LOG_TARGET)) | |
| df_hold = df_hold.with_columns(pl.col("sale_price").log1p().alias(LOG_TARGET)) | |
| global_mean_log = float(df_work[LOG_TARGET].mean()) | |
| # bldgclass encoding | |
| bm_df = df_work.group_by("bldgclass").agg(pl.col(LOG_TARGET).mean().alias("bldgclass_encoded")) | |
| bm = {r["bldgclass"]: r["bldgclass_encoded"] for r in bm_df.iter_rows(named=True)} | |
| df_work = df_work.join(bm_df, on="bldgclass", how="left").with_columns(pl.col("bldgclass_encoded").fill_null(global_mean_log)) | |
| df_hold = df_hold.join(bm_df, on="bldgclass", how="left").with_columns(pl.col("bldgclass_encoded").fill_null(global_mean_log)) | |
| # boroughΓbldg encoding | |
| df_work = df_work.with_columns((pl.col("borough").cast(pl.Utf8) + "_" + pl.col("bldgclass").str.slice(0,1)).alias("_bbk")) | |
| df_hold = df_hold.with_columns((pl.col("borough").cast(pl.Utf8) + "_" + pl.col("bldgclass").str.slice(0,1)).alias("_bbk")) | |
| bb_df = df_work.group_by("_bbk").agg(pl.col(LOG_TARGET).mean().alias("borough_bldg_encoded")) | |
| bb = {r["_bbk"]: r["borough_bldg_encoded"] for r in bb_df.iter_rows(named=True)} | |
| df_work = df_work.join(bb_df, on="_bbk", how="left").with_columns(pl.col("borough_bldg_encoded").fill_null(global_mean_log)).drop("_bbk") | |
| df_hold = df_hold.join(bb_df, on="_bbk", how="left").with_columns(pl.col("borough_bldg_encoded").fill_null(global_mean_log)).drop("_bbk") | |
| # ββ NEW v6: NTA-level target encoding βββββββββββββββββββββββββββββββββ | |
| if "ntacode" in df_work.columns: | |
| # NTA mean log-price | |
| nta_df = df_work.group_by("ntacode").agg(pl.col(LOG_TARGET).mean().alias("nta_encoded")) | |
| nta_map = {r["ntacode"]: r["nta_encoded"] for r in nta_df.iter_rows(named=True)} | |
| df_work = df_work.join(nta_df, on="ntacode", how="left").with_columns(pl.col("nta_encoded").fill_null(global_mean_log)) | |
| df_hold = df_hold.join(nta_df, on="ntacode", how="left").with_columns(pl.col("nta_encoded").fill_null(global_mean_log)) | |
| # NTA Γ building class category (fine-grained) | |
| df_work = df_work.with_columns((pl.col("ntacode") + "_" + pl.col("bldgclass").str.slice(0,1)).alias("_ntab")) | |
| df_hold = df_hold.with_columns((pl.col("ntacode") + "_" + pl.col("bldgclass").str.slice(0,1)).alias("_ntab")) | |
| ntab_df = df_work.group_by("_ntab").agg(pl.col(LOG_TARGET).mean().alias("nta_bldg_encoded")) | |
| df_work = df_work.join(ntab_df, on="_ntab", how="left").with_columns(pl.col("nta_bldg_encoded").fill_null(global_mean_log)).drop("_ntab") | |
| df_hold = df_hold.join(ntab_df, on="_ntab", how="left").with_columns(pl.col("nta_bldg_encoded").fill_null(global_mean_log)).drop("_ntab") | |
| # NTA market stats: sale count + median $/sqft in training window | |
| nta_stats = (df_work.with_columns( | |
| (pl.col("sale_price") / pl.col("gross_square_feet").clip(lower_bound=1)).alias("_psf")) | |
| .group_by("ntacode") | |
| .agg([ | |
| pl.count("sale_price").alias("nta_sale_count"), | |
| pl.col("_psf").median().alias("nta_median_psf"), | |
| ])) | |
| df_work = df_work.join(nta_stats, on="ntacode", how="left") | |
| df_hold = df_hold.join(nta_stats, on="ntacode", how="left") | |
| for c in ["nta_sale_count","nta_median_psf"]: | |
| med = float(df_work[c].drop_nulls().median() or 0) | |
| df_work = df_work.with_columns(pl.col(c).fill_null(med)) | |
| df_hold = df_hold.with_columns(pl.col(c).fill_null(med)) | |
| # ββ v10: NTA price trend slope (OLS of mean log_price ~ sale_year) ββ | |
| # Groups by (ntacode, sale_year), computes mean log_price per year per NTA, | |
| # then fits a simple OLS slope: slope = cov(year, price) / var(year) | |
| # Positive slope = appreciating NTA; negative = declining NTA | |
| trend_by_nta = ( | |
| df_work.group_by(["ntacode", "sale_year"]) | |
| .agg(pl.col(LOG_TARGET).mean().alias("yr_mean_logp")) | |
| .sort(["ntacode", "sale_year"]) | |
| ) | |
| # Vectorised OLS per NTA using numpy | |
| nta_trend_map = {} | |
| global_trend = 0.0 # flat trend fallback for unseen NTAs | |
| for key, grp in trend_by_nta.partition_by("ntacode", as_dict=True).items(): | |
| # Polars β₯0.19 returns tuple keys even for single-column partition | |
| nta_code = key[0] if isinstance(key, (tuple, list)) else key | |
| years = grp["sale_year"].to_numpy().astype(np.float64) | |
| prices = grp["yr_mean_logp"].to_numpy().astype(np.float64) | |
| if len(years) >= 2: | |
| yr_c = years - years.mean() | |
| pr_c = prices - prices.mean() | |
| slope = float(np.dot(yr_c, pr_c) / (np.dot(yr_c, yr_c) + 1e-10)) | |
| else: | |
| slope = 0.0 | |
| nta_trend_map[str(nta_code)] = round(slope, 6) | |
| global_trend = float(np.median(list(nta_trend_map.values()))) | |
| trend_df = pl.DataFrame({ | |
| "ntacode": [str(k) for k in nta_trend_map.keys()], | |
| "nta_price_trend_slope": [float(v) for v in nta_trend_map.values()], | |
| }) | |
| df_work = df_work.join(trend_df, on="ntacode", how="left").with_columns( | |
| pl.col("nta_price_trend_slope").fill_null(global_trend) | |
| ) | |
| df_hold = df_hold.join(trend_df, on="ntacode", how="left").with_columns( | |
| pl.col("nta_price_trend_slope").fill_null(global_trend) | |
| ) | |
| print(f" NTA trend slopes: {len(nta_trend_map)} NTAs " | |
| f"| range [{min(nta_trend_map.values()):.4f}, {max(nta_trend_map.values()):.4f}]" | |
| f" | global_trend={global_trend:.4f}") | |
| nta_features = ["nta_encoded", "nta_bldg_encoded", "nta_sale_count", | |
| "nta_median_psf", "nta_price_trend_slope"] | |
| print(f" NTA encoding: {len(nta_map)} NTA codes | {len(df_work.filter(pl.col('nta_encoded') != global_mean_log)):,} rows matched") | |
| nta_map_save = {k: round(float(v),6) for k,v in nta_map.items()} | |
| nta_bldg_map_save = {r["_ntab"]: round(float(r["nta_bldg_encoded"]),6) | |
| for r in ntab_df.iter_rows(named=True)} | |
| nta_stats_save = {r["ntacode"]: {"sale_count": int(r["nta_sale_count"]), | |
| "median_psf": round(float(r["nta_median_psf"]),2)} | |
| for r in nta_stats.iter_rows(named=True)} | |
| nta_trend_save = {k: round(float(v),6) for k,v in nta_trend_map.items()} | |
| else: | |
| print(" β ntacode column not found β skipping NTA features") | |
| nta_features = [] | |
| nta_map_save = {} | |
| nta_bldg_map_save = {} | |
| nta_stats_save = {} | |
| nta_trend_save = {} | |
| global_trend = 0.0 | |
| # ββ v21: BBL building-level price history (leave-one-out, May 2026) βββββββ | |
| print("\n Computing BBL building-level price history (v21) β¦") | |
| if "bbl" in df_work.columns: | |
| # Cast BBL to Int64 for clean grouping | |
| df_work = df_work.with_columns( | |
| pl.col("bbl").cast(pl.Float64, strict=False).cast(pl.Int64, strict=False).fill_null(0).alias("_bbl_key") | |
| ) | |
| df_hold = df_hold.with_columns( | |
| pl.col("bbl").cast(pl.Float64, strict=False).cast(pl.Int64, strict=False).fill_null(0).alias("_bbl_key") | |
| ) | |
| # Per-BBL aggregate from training data only: sum + count for LOO, median for holdout | |
| _bbl_agg = ( | |
| df_work | |
| .with_columns( | |
| (pl.col("sale_price") / pl.col("gross_square_feet").clip(lower_bound=1.0)).alias("_psf_raw") | |
| ) | |
| .group_by("_bbl_key") | |
| .agg([ | |
| pl.col("_psf_raw").sum().alias("_bbl_sum"), | |
| pl.len().alias("_bbl_cnt"), | |
| pl.col("_psf_raw").median().alias("_bbl_med"), | |
| ]) | |
| ) | |
| # Training: LOO mean = (sum β self) / (cnt β 1) when cnt β₯ 2; else null | |
| df_work = ( | |
| df_work | |
| .with_columns( | |
| (pl.col("sale_price") / pl.col("gross_square_feet").clip(lower_bound=1.0)).alias("_psf_raw") | |
| ) | |
| .join(_bbl_agg, on="_bbl_key", how="left") | |
| .with_columns( | |
| pl.when(pl.col("_bbl_cnt") >= 2) | |
| .then((pl.col("_bbl_sum") - pl.col("_psf_raw")) / (pl.col("_bbl_cnt").cast(pl.Float64) - 1.0)) | |
| .otherwise(pl.lit(None).cast(pl.Float64)) | |
| .alias("bbl_hist_psf") | |
| ) | |
| .drop(["_psf_raw", "_bbl_sum", "_bbl_cnt", "_bbl_med", "_bbl_key"]) | |
| ) | |
| # Holdout: direct BBL median from training data (no leakage β aggregate is from df_work) | |
| df_hold = ( | |
| df_hold | |
| .join(_bbl_agg.select(["_bbl_key", "_bbl_med"]), on="_bbl_key", how="left") | |
| .rename({"_bbl_med": "bbl_hist_psf"}) | |
| .drop("_bbl_key") | |
| ) | |
| # Fill nulls: NTA median psf β global fallback | |
| _global_bbl_psf = float(df_work["bbl_hist_psf"].drop_nulls().median() or 0.0) | |
| if "nta_median_psf" in df_work.columns: | |
| df_work = df_work.with_columns(pl.col("bbl_hist_psf").fill_null(pl.col("nta_median_psf"))) | |
| df_hold = df_hold.with_columns(pl.col("bbl_hist_psf").fill_null(pl.col("nta_median_psf"))) | |
| df_work = df_work.with_columns(pl.col("bbl_hist_psf").fill_null(_global_bbl_psf)) | |
| df_hold = df_hold.with_columns(pl.col("bbl_hist_psf").fill_null(_global_bbl_psf)) | |
| _bbl_multi_ct = int(_bbl_agg.filter(pl.col("_bbl_cnt") >= 2)["_bbl_key"].len()) | |
| print(f" BBL buildings with 2+ sales: {_bbl_multi_ct:,} | global_fallback_psf=${_global_bbl_psf:,.0f}") | |
| # Save median lookup for inference (BBL int β median $/sqft) | |
| bbl_median_lookup = { | |
| int(r["_bbl_key"]): round(float(r["_bbl_med"]), 2) | |
| for r in _bbl_agg.iter_rows(named=True) | |
| if r["_bbl_med"] is not None and r["_bbl_key"] != 0 | |
| } | |
| bbl_hist_psf_global = round(_global_bbl_psf, 2) | |
| else: | |
| df_work = df_work.with_columns(pl.lit(0.0).cast(pl.Float64).alias("bbl_hist_psf")) | |
| df_hold = df_hold.with_columns(pl.lit(0.0).cast(pl.Float64).alias("bbl_hist_psf")) | |
| bbl_median_lookup = {} | |
| bbl_hist_psf_global = 0.0 | |
| print(" β bbl column not found β bbl_hist_psf = 0") | |
| # ββ v22: NTA Γ building-type temporal lag (May 2026) βββββββββββββββββββββββββ | |
| print("\n Computing NTA Γ building-type temporal lags (v22) β¦") | |
| if "ntacode" in df_work.columns and "bldgclass" in df_work.columns and "_yrq" in df_work.columns: | |
| # Rebuild _ntab_bt key (ntacode + first letter of bldgclass, e.g. "BX39_D") | |
| df_work = df_work.with_columns( | |
| (pl.col("ntacode") + "_" + pl.col("bldgclass").str.slice(0, 1)).alias("_ntab_bt") | |
| ) | |
| df_hold = df_hold.with_columns( | |
| (pl.col("ntacode") + "_" + pl.col("bldgclass").str.slice(0, 1)).alias("_ntab_bt") | |
| ) | |
| # Aggregate log-price by NTA Γ bldgtype Γ quarter (training only β no holdout leakage) | |
| _nta_bt_q = ( | |
| df_work | |
| .with_columns(pl.col("sale_price").log1p().alias("_log_sp")) | |
| .group_by(["_ntab_bt", "_yrq"]) | |
| .agg(pl.col("_log_sp").mean().alias("_mean_logp_bt")) | |
| ) | |
| _global_logp_bt = float(_nta_bt_q["_mean_logp_bt"].median()) | |
| _bt_lag1 = ( | |
| _nta_bt_q.with_columns(pl.col("_yrq") + 1) | |
| .rename({"_mean_logp_bt": "nta_bt_lag1q_mean_logp"}) | |
| ) | |
| _bt_lag2 = ( | |
| _nta_bt_q.with_columns(pl.col("_yrq") + 2) | |
| .select(["_ntab_bt", "_yrq", pl.col("_mean_logp_bt").alias("nta_bt_lag2q_mean_logp")]) | |
| ) | |
| df_work = ( | |
| df_work | |
| .join(_bt_lag1, on=["_ntab_bt", "_yrq"], how="left") | |
| .join(_bt_lag2, on=["_ntab_bt", "_yrq"], how="left") | |
| ) | |
| df_hold = ( | |
| df_hold | |
| .join(_bt_lag1, on=["_ntab_bt", "_yrq"], how="left") | |
| .join(_bt_lag2, on=["_ntab_bt", "_yrq"], how="left") | |
| ) | |
| for _col_bt, _fb_bt in [("nta_bt_lag1q_mean_logp", _global_logp_bt), | |
| ("nta_bt_lag2q_mean_logp", _global_logp_bt)]: | |
| df_work = df_work.with_columns(pl.col(_col_bt).fill_null(_fb_bt)) | |
| df_hold = df_hold.with_columns(pl.col(_col_bt).fill_null(_fb_bt)) | |
| df_work = df_work.with_columns( | |
| (pl.col("nta_bt_lag1q_mean_logp") - pl.col("nta_bt_lag2q_mean_logp")).alias("nta_bt_logp_momentum") | |
| ) | |
| df_hold = df_hold.with_columns( | |
| (pl.col("nta_bt_lag1q_mean_logp") - pl.col("nta_bt_lag2q_mean_logp")).alias("nta_bt_logp_momentum") | |
| ) | |
| df_work = df_work.with_columns(pl.col("nta_bt_logp_momentum").fill_null(0.0)) | |
| df_hold = df_hold.with_columns(pl.col("nta_bt_logp_momentum").fill_null(0.0)) | |
| df_work = df_work.drop("_ntab_bt") | |
| df_hold = df_hold.drop("_ntab_bt") | |
| # Save lookup for inference: key = "{ntacode}_{bldgclass_letter}_{yrq}" | |
| _nta_bt_lag1_save = {} | |
| for _r in _bt_lag1.iter_rows(named=True): | |
| _key = f"{_r['_ntab_bt']}_{_r['_yrq']}" | |
| _ml = _r["nta_bt_lag1q_mean_logp"] | |
| if _ml is None: continue | |
| _nta_bt_lag1_save[_key] = round(float(_ml), 6) | |
| _nta_bt_lag2_save = {} | |
| for _r in _bt_lag2.iter_rows(named=True): | |
| _key = f"{_r['_ntab_bt']}_{_r['_yrq']}" | |
| _ml2 = _r["nta_bt_lag2q_mean_logp"] | |
| if _ml2 is None: continue | |
| _nta_bt_lag2_save[_key] = round(float(_ml2), 6) | |
| _nta_bt_nulls = int(df_work["nta_bt_lag1q_mean_logp"].is_null().sum()) | |
| _nta_bt_combos = int(len(_nta_bt_lag1_save)) | |
| print(f" NTAΓBT lag-1 entries: {_nta_bt_combos:,} | nulls after fill: {_nta_bt_nulls}/{len(df_work)}") | |
| else: | |
| _nta_bt_lag1_save = {} | |
| _nta_bt_lag2_save = {} | |
| _global_logp_bt = 0.0 | |
| print(" β ntacode/bldgclass/_yrq not found β skipping v22 NTAΓBT lag") | |
| # ββ 5. Feature matrix ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[5/9] Building feature matrix β¦") | |
| V4_BASE = [ | |
| "latitude","longitude","borough","building_age","numfloors", | |
| "gross_square_feet","land_square_feet","residential_units", | |
| "dist_subway_m","dist_school_m","dist_park_m","dist_hospital_m", | |
| "poi_count_500m","crime_rate_nta","noise_density_nta", | |
| "population_2020","median_income_nta","dist_bus_m", | |
| "renovated_since_2018","years_since_renovation", | |
| "dist_waterfront_m","dist_bike_lane_m","dist_elem_school_m", | |
| "dist_express_subway_m","nearest_station_is_express", | |
| "livability_complaint_rate","borough_income_deviation", | |
| "sale_year","sale_month_sin","sale_month_cos","mortgage_rate_30yr", | |
| "builtfar","residfar","commfar","facilfar","far_utilization", | |
| "has_elevator","is_condo","is_multifamily","is_single_fam","is_mixed_use", | |
| "airbnb_count_500m", | |
| "prior_sale_price","price_appreciation","years_since_prior_sale", | |
| "is_flip","school_district","district_avg_score","district_school_count", | |
| "has_prior_sale","assesstot","assessland", | |
| "poi_cafe_500m","poi_restaurant_500m","poi_gym_500m", | |
| "poi_grocery_500m","poi_bar_500m","poi_pharmacy_500m", | |
| *[f"log_{c}" for c in dist_cols], | |
| "dist_midtown_manhattan_m","dist_downtown_manhattan_m", | |
| "dist_downtown_brooklyn_m","dist_long_island_city_m", | |
| "is_manhattan","crime_x_manhattan","crime_x_non_manhattan", | |
| "walk_score_proxy","bldgclass_encoded","borough_bldg_encoded", | |
| "tree_count_200m","pm25_mean","no2_mean","hpd_viol_rate_nta", | |
| ] | |
| V5_FEATS = ["sqft_per_floor","income_over_crime","density_index","log_sqft_x_floors"] | |
| V6_FEATS = ["log_land_sqft","lot_coverage","bldg_vol_proxy","prior_price_psf"] + nta_features | |
| # ββ v11: Building health, construction activity, QoL, transit quality ββ | |
| V11_FEATS = [ | |
| # HPD housing-maintenance code violations by ZIP (2022+, open, severity-classified) | |
| "hpd_class_b_viol_zip", # Class B = hazardous (water, structural) | |
| "hpd_class_c_viol_zip", # Class C = immediately hazardous (mold, lead, heat loss) | |
| "hpd_severity_score_zip", # Weighted composite: CΓ3 + BΓ2 + AΓ1 | |
| # DOB construction + renovation permits by ZIP (2022+) | |
| "dob_reno_permit_count", # A1/A2 alteration permits β renovation signal | |
| "dob_newbld_permit_count", # NB new-building permits β development pressure | |
| # 311 quality-of-life signals by NTA (rodent + heat complaints) | |
| "rat_density_nta", # Rodent complaints per 1000 residents (log) | |
| "heat_density_nta", # Heat/hot-water complaints per 1000 residents (log) | |
| # MTA transit quality at nearest station | |
| "nearest_station_is_cbd", # 1 = nearest subway is in CBD (Midtown/Downtown core) | |
| "nearest_station_route_count", # Number of subway lines (hub vs local station) | |
| "nearest_station_is_ada", # 1 = ADA accessible station | |
| ] | |
| V12_FEATS = [ | |
| "nta_lag1q_mean_logp", # NTA mean log-price from previous quarter | |
| "nta_lag1q_median_psf", # NTA median $/sqft from previous quarter | |
| "nta_lag1q_count", # NTA sale count from previous quarter | |
| "nta_lag2q_mean_logp", # NTA mean log-price from 2 quarters ago | |
| "nta_logp_momentum", # Price trend: lag1 β lag2 | |
| ] | |
| # ββ v13: New Overture POI categories (May 2026) ββββββββββββββββββββββββββ | |
| V13_FEATS = [ | |
| "poi_atm_500m", # ATM density (financial services access) | |
| "poi_urgent_care_500m", # Urgent care / medical clinic density | |
| "poi_cinema_500m", # Cinema density (entertainment) | |
| "poi_library_500m", # Public library density | |
| "poi_childcare_500m", # Childcare / preschool density | |
| "poi_beauty_500m", # Beauty salon density (commercial vibrancy) | |
| "poi_hotel_500m", # Hotel density (tourism/business district) | |
| ] | |
| # ββ v14: Citi Bike access (May 2026) βββββββββββββββββββββββββββββββββββββ | |
| V14_FEATS = [ | |
| "citibike_500m", # Citi Bike stations within 500m (bikeability) | |
| "dist_citibike_m", # Distance to nearest Citi Bike station | |
| ] | |
| # ββ v15: Commuter rail access (May 2026) ββββββββββββββββββββββββββββββββββ | |
| V15_FEATS = [ | |
| "dist_commuter_rail_m", # Distance to nearest LIRR/Metro-North/SIR station | |
| # log_dist_commuter_rail_m already included via V4_BASE dist_cols expansion | |
| "commuter_rail_1km", # Commuter rail station within 1km (binary-ish) | |
| ] | |
| # ββ v16: LPC historic district + FEMA flood zone + landmark (May 2026) βββββ | |
| V16_FEATS = [ | |
| "is_historic_dist", # In LPC-designated historic district (premium) | |
| "in_flood_zone", # In FEMA 2015 preliminary flood zone (risk discount) | |
| "is_landmark", # Individually designated NYC landmark | |
| ] | |
| # ββ v17: PLUTO tax exemption (May 2026) βββββββββββββββββββββββββββββββββββββ | |
| V17_FEATS = [ | |
| "log_exempt_amount", # log1p(exempttot) β captures 421-a abatements, J-51, co-op exemptions | |
| ] | |
| # ββ v18: Census ACS tract median household income (May 2026) ββββββββββββββββ | |
| V18_FEATS = [ | |
| "log_tract_median_income", # log1p(ACS median HH income) β strongest socioeconomic predictor | |
| ] | |
| # ββ v19: Park size-stratified distances (May 2026) βββββββββββββββββββββββββββ | |
| V19_FEATS = [ | |
| "log_dist_large_park_m", # Distance to nearest park β₯10 acres (neighborhood parks) | |
| "log_dist_flagship_park_m", # Distance to nearest park β₯100 acres (Central Park, Prospect Parkβ¦) | |
| ] | |
| # ββ v20: NYC waterfront / coastline proximity (May 2026) βββββββββββββββββββββ | |
| V20_FEATS = [ | |
| "log_dist_waterfront_m", # log dist to nearest NYC coastline point (27k pts) | |
| "waterfront_200m", # binary flag: within 2 blocks of water | |
| ] | |
| # ββ v21: BBL building-level historical price signal (May 2026) ββββββββββββββ | |
| V21_FEATS = [ | |
| "bbl_hist_psf", # Leave-one-out median $/sqft from same BBL (building history signal) | |
| ] | |
| # ββ v22: NTA Γ building-type temporal lag (May 2026) βββββββββββββββββββββββββ | |
| V22_FEATS = [ | |
| "nta_bt_lag1q_mean_logp", # NTA Γ bldgtype mean log-price from previous quarter | |
| "nta_bt_lag2q_mean_logp", # NTA Γ bldgtype mean log-price from 2 quarters ago | |
| "nta_bt_logp_momentum", # Price trend: lag1 β lag2 (NTA Γ bldgtype) | |
| ] | |
| _all_feats = V4_BASE + V5_FEATS + V6_FEATS + V11_FEATS + V12_FEATS + V13_FEATS + V14_FEATS + V15_FEATS + V16_FEATS + V17_FEATS + V18_FEATS + V19_FEATS + V20_FEATS + V21_FEATS + V22_FEATS | |
| FEATURE_NAMES = list(dict.fromkeys(f for f in _all_feats if f in df_work.columns)) | |
| n_v11 = len([f for f in V11_FEATS if f in df_work.columns]) | |
| n_v12 = len([f for f in V12_FEATS if f in df_work.columns]) | |
| n_v21 = len([f for f in V21_FEATS if f in df_work.columns]) | |
| n_v22 = len([f for f in V22_FEATS if f in df_work.columns]) | |
| print(f" Total features: {len(FEATURE_NAMES)} " | |
| f"(+{len([f for f in V6_FEATS if f in df_work.columns])} v6, +{n_v11} v11, +{n_v12} v12, +{n_v21} v21, +{n_v22} v22)") | |
| acris_cols = ["prior_sale_price","price_appreciation","years_since_prior_sale"] | |
| acris_medians = {c: float(df_work.filter(pl.col(c).is_not_null()&(pl.col(c)!=0))[c].median() or 0) | |
| for c in acris_cols} | |
| qol_cols = ["crime_rate_nta","noise_density_nta","livability_complaint_rate"] | |
| winsorize_p99 = {c: float(np.percentile(df_work[c].drop_nulls().to_numpy(), 99)) for c in qol_cols} | |
| X_work = df_work.select(FEATURE_NAMES).fill_null(0).fill_nan(0.0).to_numpy().astype(np.float32) | |
| y_work = df_work[LOG_TARGET].to_numpy().astype(np.float32) | |
| X_hold = df_hold.select(FEATURE_NAMES).fill_null(0).fill_nan(0.0).to_numpy().astype(np.float32) | |
| y_hold = df_hold[LOG_TARGET].to_numpy().astype(np.float32) | |
| for col, cap in winsorize_p99.items(): | |
| if col in FEATURE_NAMES: | |
| idx = FEATURE_NAMES.index(col) | |
| X_work[:,idx] = np.clip(X_work[:,idx], None, cap) | |
| X_hold[:,idx] = np.clip(X_hold[:,idx], None, cap) | |
| # ββ 6. OOF with 4 diverse learners ββββββββββββββββββββββββββββββββββββ | |
| print("\n[6/9] Generating OOF predictions (spatial GroupKFold Γ5) β¦") | |
| groups = df_work["ntacode"].fill_null("UNK").to_numpy() if "ntacode" in df_work.columns \ | |
| else np.zeros(len(X_work), dtype=str) | |
| gkf = GroupKFold(n_splits=10) | |
| XGB_A = dict(objective="reg:squarederror", eval_metric="rmse", | |
| n_estimators=5000, learning_rate=0.02, max_depth=7, | |
| min_child_weight=8, subsample=0.80, colsample_bytree=0.60, | |
| gamma=0.1, reg_alpha=0.3, reg_lambda=1.5, | |
| early_stopping_rounds=400, random_state=42, n_jobs=-1) | |
| XGB_B = dict(objective="reg:squarederror", eval_metric="rmse", | |
| n_estimators=5000, learning_rate=0.05, max_depth=4, | |
| min_child_weight=15, subsample=0.65, colsample_bytree=0.75, | |
| gamma=0.3, reg_alpha=1.0, reg_lambda=2.5, | |
| early_stopping_rounds=400, random_state=7, n_jobs=-1) | |
| LGB_P = dict(objective="regression", metric="rmse", | |
| n_estimators=5000, learning_rate=0.04, num_leaves=127, | |
| min_child_samples=15, feature_fraction=0.70, bagging_fraction=0.80, | |
| bagging_freq=5, reg_alpha=0.3, reg_lambda=1.5, | |
| n_jobs=-1, random_state=42, verbose=-1) | |
| CAT_P = dict(iterations=3000, learning_rate=0.025, depth=8, | |
| l2_leaf_reg=1.5, random_strength=0.5, bagging_temperature=0.8, | |
| border_count=64, early_stopping_rounds=400, random_seed=42, verbose=0) | |
| oof_xa = np.zeros(len(X_work), dtype=np.float32) | |
| oof_xb = np.zeros(len(X_work), dtype=np.float32) | |
| oof_lg = np.zeros(len(X_work), dtype=np.float32) | |
| oof_ct = np.zeros(len(X_work), dtype=np.float32) | |
| for fold, (tr, va) in enumerate(gkf.split(X_work, y_work, groups), 1): | |
| Xtr, Xva, ytr, yva = X_work[tr], X_work[va], y_work[tr], y_work[va] | |
| ma = xgb.XGBRegressor(**XGB_A); ma.fit(Xtr, ytr, eval_set=[(Xva,yva)], verbose=False); oof_xa[va]=ma.predict(Xva) | |
| mb = xgb.XGBRegressor(**XGB_B); mb.fit(Xtr, ytr, eval_set=[(Xva,yva)], verbose=False); oof_xb[va]=mb.predict(Xva) | |
| ml = lgb.LGBMRegressor(**LGB_P) | |
| ml.fit(Xtr, ytr, eval_set=[(Xva,yva)], callbacks=[lgb.early_stopping(400,verbose=False),lgb.log_evaluation(-1)]) | |
| oof_lg[va] = ml.predict(Xva) | |
| mc = CatBoostRegressor(**CAT_P); mc.fit(Xtr, ytr, eval_set=(Xva,yva), verbose=False); oof_ct[va]=mc.predict(Xva).astype(np.float32) | |
| print(f" Fold {fold}: XGB-A {r2_score(yva,oof_xa[va]):.4f} XGB-B {r2_score(yva,oof_xb[va]):.4f} LGB {r2_score(yva,oof_lg[va]):.4f} CAT {r2_score(yva,oof_ct[va]):.4f}") | |
| for nm, oo in [("XGB-A",oof_xa),("XGB-B",oof_xb),("LGB",oof_lg),("CAT",oof_ct)]: | |
| print(f" OOF {nm:<6} RΒ²={r2_score(y_work,oo):.4f} MedAPE={medape(np.expm1(y_work),np.expm1(oo)):.2f}%") | |
| # ββ LightGBM meta-learner (non-linear blending) ββββββββββββββββββββββββ | |
| print("\n Training LGB meta-learner on OOF stack β¦") | |
| S_work = np.column_stack([oof_xa, oof_xb, oof_lg, oof_ct]) | |
| Sm_tr, Sm_va, ym_tr, ym_va = train_test_split(S_work, y_work, test_size=0.15, random_state=42) | |
| META_LGB = dict(objective="regression", metric="rmse", | |
| n_estimators=500, learning_rate=0.05, num_leaves=15, | |
| min_child_samples=30, feature_fraction=1.0, | |
| reg_alpha=0.5, reg_lambda=2.0, | |
| n_jobs=-1, random_state=42, verbose=-1) | |
| meta_lgb = lgb.LGBMRegressor(**META_LGB) | |
| meta_lgb.fit(Sm_tr, ym_tr, eval_set=[(Sm_va, ym_va)], | |
| callbacks=[lgb.early_stopping(50, verbose=False), lgb.log_evaluation(-1)]) | |
| oof_stack = meta_lgb.predict(S_work) | |
| print(f" OOF Stack(LGB-meta) RΒ²={r2_score(y_work,oof_stack):.4f} MedAPE={medape(np.expm1(y_work),np.expm1(oof_stack)):.2f}%") | |
| print(f" Meta feature importances: XGB-A={meta_lgb.feature_importances_[0]} XGB-B={meta_lgb.feature_importances_[1]} LGB={meta_lgb.feature_importances_[2]} CAT={meta_lgb.feature_importances_[3]}") | |
| # Also keep a Ridge for backward-compat baseline | |
| ridge_pos = Ridge(alpha=1.0, positive=True) | |
| ridge_pos.fit(S_work, y_work) | |
| oof_ridge = ridge_pos.predict(S_work) | |
| print(f" OOF Stack(Ridge) RΒ²={r2_score(y_work,oof_ridge):.4f} MedAPE={medape(np.expm1(y_work),np.expm1(oof_ridge)):.2f}%") | |
| # ββ 7. Retrain final models ββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[7/9] Retraining final 4-model ensemble β¦") | |
| Xtr_f, Xva_f, ytr_f, yva_f = train_test_split(X_work, y_work, test_size=0.12, random_state=42) | |
| print(" XGB-A β¦"); final_xa = xgb.XGBRegressor(**XGB_A); final_xa.fit(Xtr_f, ytr_f, eval_set=[(Xva_f,yva_f)], verbose=500) | |
| print(" XGB-B β¦"); final_xb = xgb.XGBRegressor(**XGB_B); final_xb.fit(Xtr_f, ytr_f, eval_set=[(Xva_f,yva_f)], verbose=500) | |
| print(" LGB β¦") | |
| final_lg = lgb.LGBMRegressor(**LGB_P) | |
| final_lg.fit(Xtr_f, ytr_f, eval_set=[(Xva_f,yva_f)], callbacks=[lgb.early_stopping(400,verbose=False),lgb.log_evaluation(500)]) | |
| print(" CAT β¦"); final_ct = CatBoostRegressor(**CAT_P); final_ct.fit(Xtr_f, ytr_f, eval_set=(Xva_f,yva_f), verbose=False) | |
| # ββ 8. Luxury sub-model ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[8/9] Luxury sub-model (Manhattan β₯$2.5M) β¦") | |
| LUXURY_THRESH = 2_500_000 | |
| lux_mask = (df_work["borough"]==1) & (df_work["sale_price"]>=LUXURY_THRESH) | |
| X_lux = df_work.filter(lux_mask).select(FEATURE_NAMES).fill_null(0).fill_nan(0.0).to_numpy().astype(np.float32) | |
| y_lux = df_work.filter(lux_mask)[LOG_TARGET].to_numpy().astype(np.float32) | |
| print(f" Luxury samples: {len(X_lux):,}") | |
| has_luxury = False | |
| if len(X_lux) >= 200: | |
| Xl_tr, Xl_va, yl_tr, yl_va = train_test_split(X_lux, y_lux, test_size=0.15, random_state=42) | |
| lux_xgb = xgb.XGBRegressor(objective="reg:squarederror", eval_metric="rmse", | |
| n_estimators=2000, learning_rate=0.015, max_depth=6, | |
| min_child_weight=5, subsample=0.85, colsample_bytree=0.70, | |
| gamma=0.05, reg_alpha=0.5, reg_lambda=2.0, | |
| early_stopping_rounds=100, random_state=42, n_jobs=-1) | |
| lux_xgb.fit(Xl_tr, yl_tr, eval_set=[(Xl_va,yl_va)], verbose=False) | |
| print(f" Luxury val: RΒ²={r2_score(yl_va,lux_xgb.predict(Xl_va)):.4f} " | |
| f"MedAPE={medape(np.expm1(yl_va),np.expm1(lux_xgb.predict(Xl_va))):.2f}%") | |
| lux_xgb.save_model(os.path.join(MODEL_DIR, "luxury_model.json")) | |
| has_luxury = True | |
| # ββ 9. Holdout evaluation ββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("\n[9/9] Holdout evaluation β¦") | |
| 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_lgb_meta = meta_lgb.predict(S_hold) | |
| pred_ridge = ridge_pos.predict(S_hold) | |
| def ev(name, yt, yp): | |
| r2 = r2_score(yt, yp) | |
| mae = mean_absolute_error(np.expm1(yt), np.expm1(yp)) | |
| mp = medape(np.expm1(yt), np.expm1(yp)) | |
| print(f" {name:<22} RΒ²={r2:.4f} MAE=${mae:>12,.0f} MedAPE={mp:.2f}%") | |
| return r2, mae, mp | |
| print() | |
| r2_xa, mae_xa, mp_xa = ev("XGB-A (deep)", y_hold, p_xa) | |
| r2_xb, mae_xb, mp_xb = ev("XGB-B (shallow)", y_hold, p_xb) | |
| r2_lg, mae_lg, mp_lg = ev("LGB (wide)", y_hold, p_lg) | |
| r2_ct, mae_ct, mp_ct = ev("CAT (high-cap)", y_hold, p_ct) | |
| r2_lm, mae_lm, mp_lm = ev("Stack(LGB-meta) β", y_hold, pred_lgb_meta) | |
| r2_rd, mae_rd, mp_rd = ev("Stack(Ridge)", y_hold, pred_ridge) | |
| # Use LGB meta if it beats Ridge, else fall back | |
| use_lgb_meta = r2_lm >= r2_rd | |
| r2_stk, mae_stk, mp_stk = (r2_lm, mae_lm, mp_lm) if use_lgb_meta else (r2_rd, mae_rd, mp_rd) | |
| pred_stack = pred_lgb_meta if use_lgb_meta else pred_ridge | |
| print(f"\n β Using {'LGB' if use_lgb_meta else 'Ridge'} meta-learner (RΒ²={r2_stk:.4f})") | |
| BOROUGH_MAP = {1:"Manhattan",2:"Bronx",3:"Brooklyn",4:"Queens",5:"Staten Island"} | |
| boroughs_hold = df_hold["borough"].to_numpy(); prices_hold = np.expm1(y_hold) | |
| segment_by_borough = {} | |
| for bn, bname in BOROUGH_MAP.items(): | |
| mask = boroughs_hold == bn | |
| if mask.sum() < 10: continue | |
| bor_mp = medape(prices_hold[mask], np.expm1(pred_stack[mask])) | |
| be = mean_absolute_error(prices_hold[mask], np.expm1(pred_stack[mask])) | |
| segment_by_borough[bname] = {"n": int(mask.sum()), "medape": round(bor_mp,2), "mae": round(be,0)} | |
| print(f" {bname:<16} n={mask.sum():>5} RΒ²={r2_score(y_hold[mask],pred_stack[mask]):.4f} MedAPE={bor_mp:.2f}%") | |
| TIER_BINS = [(0,500_000,"<$500K"),(500_000,1_000_000,"$500Kβ1M"), | |
| (1_000_000,3_000_000,"$1Mβ3M"),(3_000_000,10_000_000,"$3Mβ10M")] | |
| segment_by_tier = {} | |
| for lo, hi, label in TIER_BINS: | |
| mask = (prices_hold>=lo)&(prices_hold<hi) | |
| if mask.sum()<10: continue | |
| tm = medape(prices_hold[mask], np.expm1(pred_stack[mask])) | |
| te = mean_absolute_error(prices_hold[mask], np.expm1(pred_stack[mask])) | |
| segment_by_tier[label] = {"n": int(mask.sum()), "medape": round(tm,2), "mae": round(te,0)} | |
| # ββ Save βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| stack_path = os.path.join(MODEL_DIR, "thaman_stack.pkl") | |
| joblib.dump({ | |
| "xgb_a": final_xa, "xgb_b": final_xb, | |
| "lgb": final_lg, "cat": final_ct, | |
| "meta": meta_lgb if use_lgb_meta else ridge_pos, | |
| "meta_type": "lgb" if use_lgb_meta else "ridge", | |
| "version": "v22", | |
| }, stack_path) | |
| print(f"\n thaman_stack.pkl saved ({os.path.getsize(stack_path)/1e6:.1f} MB)") | |
| final_xa.save_model(os.path.join(MODEL_DIR, "xgboost_model.json")) | |
| meta_path = os.path.join(MODEL_DIR, "meta.json") | |
| with open(meta_path) as f: meta = json.load(f) | |
| meta.update({ | |
| "feature_names": FEATURE_NAMES, "n_features": len(FEATURE_NAMES), | |
| "n_train": len(df_work), "n_holdout": len(df_hold), | |
| "walk_score_scaler": walk_score_scaler_params, | |
| "bldgclass_means": {k: round(float(v),6) for k,v in bm.items()}, | |
| "borough_bldg_means": {k: round(float(v),6) for k,v in bb.items()}, | |
| "nta_means": nta_map_save, | |
| "nta_bldg_means": nta_bldg_map_save, | |
| "nta_stats": nta_stats_save, | |
| "global_mean_log": round(global_mean_log,6), | |
| "luxury_threshold": LUXURY_THRESH, "has_luxury_model": has_luxury, | |
| "acris_medians": {k: round(float(v),6) for k,v in acris_medians.items()}, | |
| "winsorize_p99": {k: round(float(v),6) for k,v in winsorize_p99.items()}, | |
| "segment_by_borough": segment_by_borough, | |
| "segment_by_tier": segment_by_tier, | |
| "stack": { | |
| "version": "v22", | |
| "base_learners": ["xgb_a","xgb_b","lightgbm","catboost"], | |
| "meta_learner": "lgb" if use_lgb_meta else "ridge", | |
| "r2_holdout": round(r2_stk,4), "mae_holdout": round(mae_stk,0), | |
| "medape_holdout": round(mp_stk,2), | |
| "xgb_a": {"r2_holdout":round(r2_xa,4),"medape_holdout":round(mp_xa,2),"best_round":int(final_xa.best_iteration+1)}, | |
| "xgb_b": {"r2_holdout":round(r2_xb,4),"medape_holdout":round(mp_xb,2),"best_round":int(final_xb.best_iteration+1)}, | |
| "lightgbm": {"r2_holdout":round(r2_lg,4),"medape_holdout":round(mp_lg,2),"best_round":int(final_lg.best_iteration_)}, | |
| "catboost": {"r2_holdout":round(r2_ct,4),"medape_holdout":round(mp_ct,2),"best_round":int(final_ct.best_iteration_+1)}, | |
| "r2_improvement": round(r2_stk - 0.645, 4), | |
| "medape_improvement": round(20.32 - mp_stk, 2), | |
| }, | |
| # v12 quarterly NTA lookback β for inference | |
| "nta_lag_q_map": _nta_q_lag1_save, | |
| "nta_lag_q2_map": _nta_q_lag2_save, | |
| "nta_lag_q_globals": { | |
| "mean_logp": round(_global_logp, 6), | |
| "median_psf": round(_global_psf, 2), | |
| "count": round(_global_cnt, 1), | |
| }, | |
| # v21: BBL building-level price history for inference | |
| "bbl_median_lookup": bbl_median_lookup, | |
| "bbl_hist_psf_global": bbl_hist_psf_global, | |
| # v22: NTA Γ building-type temporal lag for inference | |
| "nta_bt_lag_q_map": _nta_bt_lag1_save, | |
| "nta_bt_lag_q2_map": _nta_bt_lag2_save, | |
| "nta_bt_lag_global_logp": round(_global_logp_bt, 6), | |
| # v11 inference lookups β used by api/main.py _build_feature_row() | |
| "v11_zip_lookup": { | |
| "hpd_class_b_viol_zip": {}, # populated below | |
| "hpd_class_c_viol_zip": {}, | |
| "hpd_severity_score_zip": {}, | |
| "dob_reno_permit_count": {}, | |
| "dob_newbld_permit_count":{}, | |
| }, | |
| "v11_nta_lookup": { | |
| "rat_density_nta": {}, # populated below | |
| "heat_density_nta": {}, | |
| }, | |
| }) | |
| # Populate v11 inference lookups from training data medians | |
| _v11_zip_cols = ["hpd_class_b_viol_zip","hpd_class_c_viol_zip","hpd_severity_score_zip", | |
| "dob_reno_permit_count","dob_newbld_permit_count"] | |
| _v11_nta_cols = ["rat_density_nta","heat_density_nta"] | |
| if "zip_code" in df_work.columns: | |
| _zip_lookup = {} | |
| for _col in _v11_zip_cols: | |
| if _col in df_work.columns: | |
| _zip_agg = (df_work.with_columns( | |
| pl.col("zip_code").cast(pl.Float64, strict=False) | |
| .cast(pl.Int64, strict=False).cast(pl.Utf8).str.zfill(5)) | |
| .group_by("zip_code") | |
| .agg(pl.col(_col).median().alias("med"))) | |
| for _r in _zip_agg.iter_rows(named=True): | |
| if _r["zip_code"] not in _zip_lookup: | |
| _zip_lookup[_r["zip_code"]] = {} | |
| _zip_lookup[_r["zip_code"]][_col] = round(float(_r["med"]),6) | |
| meta["v11_zip_lookup"] = _zip_lookup | |
| print(f" v11_zip_lookup: {len(_zip_lookup)} ZIP entries") | |
| if "ntacode" in df_work.columns: | |
| _nta_lookup = {} | |
| for _col in _v11_nta_cols: | |
| if _col in df_work.columns: | |
| _nta_agg = (df_work.group_by("ntacode") | |
| .agg(pl.col(_col).median().alias("med"))) | |
| for _r in _nta_agg.iter_rows(named=True): | |
| if _r["ntacode"] not in _nta_lookup: | |
| _nta_lookup[_r["ntacode"]] = {} | |
| _nta_lookup[_r["ntacode"]][_col] = round(float(_r["med"]),6) | |
| meta["v11_nta_lookup"] = _nta_lookup | |
| print(f" v11_nta_lookup: {len(_nta_lookup)} NTA entries") | |
| # MTA station lookup for inference (lat/lon + features) | |
| _mta_path = os.path.join(BASE, "data", "raw", "MTA_Subway_Stations_20260308.csv") | |
| if os.path.exists(_mta_path): | |
| _mta = pl.read_csv(_mta_path) | |
| _mta = _mta.with_columns([ | |
| pl.col("GTFS Latitude").cast(pl.Float64, strict=False).alias("_lat"), | |
| pl.col("GTFS Longitude").cast(pl.Float64, strict=False).alias("_lon"), | |
| ]) | |
| if _mta["CBD"].dtype == pl.Boolean: | |
| _mta = _mta.with_columns(pl.col("CBD").cast(pl.Int32).alias("_cbd")) | |
| else: | |
| _mta = _mta.with_columns((pl.col("CBD").cast(pl.Utf8).str.to_lowercase()=="true").cast(pl.Int32).alias("_cbd")) | |
| _mta = _mta.with_columns( | |
| pl.col("Daytime Routes").str.strip_chars().str.split(" ").list.len().alias("_routes") | |
| ) | |
| _mta = _mta.with_columns( | |
| (pl.col("ADA").cast(pl.Int32, strict=False) > 0).cast(pl.Int32).alias("_ada") | |
| ) | |
| _mta_stations = [] | |
| for _r in _mta.drop_nulls(["_lat","_lon"]).iter_rows(named=True): | |
| _mta_stations.append({ | |
| "lat": float(_r["_lat"]), "lon": float(_r["_lon"]), | |
| "is_cbd": int(_r["_cbd"]), "route_count": int(_r["_routes"]), "is_ada": int(_r["_ada"]) | |
| }) | |
| meta["mta_stations"] = _mta_stations | |
| print(f" mta_stations: {len(_mta_stations)} stored in meta.json") | |
| # ββ v16: NTA-level averages for historic district + flood zone (inference fallback) ββ | |
| if "ntacode" in df_work.columns and "is_historic_dist" in df_work.columns: | |
| _v16_nta = {} | |
| for _col in ["is_historic_dist", "in_flood_zone", "is_landmark"]: | |
| if _col not in df_work.columns: | |
| continue | |
| _agg = (df_work.group_by("ntacode") | |
| .agg(pl.col(_col).cast(pl.Float64).mean().alias("_mean")) | |
| .to_pandas()) | |
| for _, _row in _agg.iterrows(): | |
| _nta = _row["ntacode"] | |
| if _nta not in _v16_nta: | |
| _v16_nta[_nta] = {} | |
| _v16_nta[_nta][_col] = round(float(_row["_mean"]), 4) | |
| meta["v16_nta_lookup"] = _v16_nta | |
| _hist_rate = df_work["is_historic_dist"].cast(pl.Float64).mean() | |
| _flood_rate = df_work["in_flood_zone"].cast(pl.Float64).mean() | |
| meta["v16_global_hist_rate"] = round(float(_hist_rate), 4) | |
| meta["v16_global_flood_rate"] = round(float(_flood_rate), 4) | |
| print(f" v16_nta_lookup: {len(_v16_nta)} NTAs | " | |
| f"hist_rate={_hist_rate:.2%} flood_rate={_flood_rate:.2%}") | |
| # ββ v17: NTA-level mean log_exempt_amount for inference fallback ββββββββββββββ | |
| if "ntacode" in df_work.columns and "log_exempt_amount" in df_work.columns: | |
| _v17_nta = {} | |
| _ex_agg = (df_work.group_by("ntacode") | |
| .agg(pl.col("log_exempt_amount").cast(pl.Float64).mean().alias("_mean")) | |
| .to_pandas()) | |
| for _, _row in _ex_agg.iterrows(): | |
| _v17_nta[_row["ntacode"]] = round(float(_row["_mean"]), 4) | |
| meta["v17_nta_log_exempt"] = _v17_nta | |
| meta["v17_global_log_exempt"] = round(float(df_work["log_exempt_amount"].cast(pl.Float64).mean()), 4) | |
| print(f" v17_nta_log_exempt: {len(_v17_nta)} NTAs | " | |
| f"global_mean={meta['v17_global_log_exempt']:.3f}") | |
| # ββ v18: NTA-level mean log_tract_median_income for inference fallback ββββββββ | |
| if "ntacode" in df_work.columns and "log_tract_median_income" in df_work.columns: | |
| _v18_nta = {} | |
| _inc_agg = (df_work | |
| .filter(pl.col("log_tract_median_income").is_not_null() & pl.col("log_tract_median_income").is_finite()) | |
| .group_by("ntacode") | |
| .agg(pl.col("log_tract_median_income").mean().alias("_mean")) | |
| .to_pandas()) | |
| for _, _row in _inc_agg.iterrows(): | |
| _v18_nta[_row["ntacode"]] = round(float(_row["_mean"]), 4) | |
| _v18_glob = round(float(df_work.filter( | |
| pl.col("log_tract_median_income").is_not_null() & pl.col("log_tract_median_income").is_finite() | |
| )["log_tract_median_income"].mean()), 4) | |
| meta["v18_nta_log_income"] = _v18_nta | |
| meta["v18_global_log_income"] = _v18_glob | |
| print(f" v18_nta_log_income: {len(_v18_nta)} NTAs | global_mean={_v18_glob:.3f}") | |
| meta["trained_at"] = datetime.date.today().strftime("%Y-%m-%d") | |
| with open(meta_path,"w") as f: json.dump(meta, f, indent=2) | |
| print(" meta.json updated β") | |
| print("\n" + "="*70) | |
| print(" THAMAN Stack v22 β Complete") | |
| print("="*70) | |
| print(f"\n {'Model':<24} {'RΒ²':>7} {'MAE':>14} {'MedAPE':>9}") | |
| print(f" {'-'*60}") | |
| for nm, r, m, mp_ in [("XGB-A",r2_xa,mae_xa,mp_xa),("XGB-B",r2_xb,mae_xb,mp_xb), | |
| ("LGB",r2_lg,mae_lg,mp_lg),("CAT",r2_ct,mae_ct,mp_ct), | |
| ("Stack v22",r2_stk,mae_stk,mp_stk)]: | |
| print(f" {nm:<24} {r:>7.4f} ${m:>13,.0f} {mp_:>8.2f}%") | |
| print(f"\n vs v21: ΞRΒ²={r2_stk-0.6516:+.4f} ΞMedAPE={20.10-mp_stk:+.2f}pp") | |
| print(f" NTA features: {nta_features}") | |
| print(f" v11 new features: {[f for f in V11_FEATS if f in FEATURE_NAMES]}") | |
| print(f" v12 new features: {[f for f in V12_FEATS if f in FEATURE_NAMES]}") | |
| print(f" v21 new features: {[f for f in V21_FEATS if f in FEATURE_NAMES]}") | |
| print(f" v22 new features: {[f for f in V22_FEATS if f in FEATURE_NAMES]}") | |
| print(f" Total features: {len(FEATURE_NAMES)}") | |