thaman / scripts /prepare_v11_features.py
Turki Almurahhem
THAMAN dual-city AVM β€” Stack v22 (NYC) + v12 (Riyadh)
2e8a71f
Raw
History Blame Contribute Delete
19.4 kB
"""
scripts/prepare_v11_features.py
================================
Enriches features_v4.csv with 4 new data groups β†’ features_v5.csv
New feature groups (8 total new columns):
A. HPD building-health by ZIP β€” Class B/C open violation intensity (2022+)
B. DOB construction activity β€” Renovation + new-build permit density by ZIP
C. Rodent / heat complaints β€” 311-derived QoL signals by NTA (local parquet)
D. MTA station quality β€” CBD flag + route count at nearest station
Run:
cd /Users/totam/Desktop/new_try
python scripts/prepare_v11_features.py
"""
import os, sys, json, time
import urllib.request, urllib.parse
import numpy as np
import polars as pl
from scipy.spatial import KDTree
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(BASE, "data", "raw")
PROC = os.path.join(BASE, "data", "processed")
INPUT = os.path.join(PROC, "features_v4.csv")
OUTPUT = os.path.join(PROC, "features_v5.csv")
def banner(msg):
print(f"\n{'='*60}")
print(f" {msg}")
print(f"{'='*60}")
def socrata_get(dataset_id, params, timeout=25, label=""):
url = f"https://data.cityofnewyork.us/resource/{dataset_id}.json?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers={"Accept": "application/json",
"X-App-Token": ""})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.loads(r.read())
print(f" βœ“ {label or dataset_id}: {len(data)} rows ({time.time()-t0:.1f}s)")
return data
except Exception as e:
print(f" βœ— {label or dataset_id}: {e}")
return []
# ── Load base data ─────────────────────────────────────────────────────
banner("Loading features_v4.csv")
df = pl.read_csv(INPUT, schema_overrides={
"zip_code": pl.Float64, "latitude": pl.Float64, "longitude": pl.Float64,
"population_2020": pl.Float64,
})
print(f" Loaded: {len(df):,} rows Γ— {df.shape[1]} cols")
# Normalise zip_code to 5-digit string
df = df.with_columns(
pl.col("zip_code").cast(pl.Int64, strict=False).cast(pl.Utf8)
.str.zfill(5).alias("_zip_str")
)
# ══════════════════════════════════════════════════════════════════════
# A. HPD Housing Maintenance Code Violations by ZIP (2022+)
# Features: hpd_class_b_viol_zip, hpd_class_c_viol_zip
# Signal: Class C = immediately hazardous (mold, heat loss, lead).
# Class B = hazardous conditions. Depresses valuation.
# ══════════════════════════════════════════════════════════════════════
banner("A β€” HPD open violations by ZIP + class (2022+)")
hpd_raw = socrata_get("wvxf-dwi5", {
"$select": "zip, class, count(*) as viol_count",
"$where": "violationstatus='Open' AND novissueddate >= '2022-01-01T00:00:00'",
"$group": "zip, class",
"$limit": "5000",
}, label="HPD violations agg")
if hpd_raw:
# Filter rows with valid zip (Socrata omits the key when null)
hpd_raw = [r for r in hpd_raw if r.get("zip") and r.get("class")]
hpd_df = pl.DataFrame({
"zip": [r["zip"] for r in hpd_raw],
"viol_class": [r["class"] for r in hpd_raw],
"viol_count": [int(r["viol_count"]) for r in hpd_raw],
})
# Pivot to wide: one column per class (A/B/C)
hpd_wide = (
hpd_df.pivot(on="viol_class", index="zip", values="viol_count", aggregate_function="sum")
.rename({c: f"hpd_class_{c.lower()}_raw" for c in ["A","B","C"]
if c in hpd_df["viol_class"].unique().to_list()})
)
for col in ["hpd_class_a_raw", "hpd_class_b_raw", "hpd_class_c_raw"]:
if col not in hpd_wide.columns:
hpd_wide = hpd_wide.with_columns(pl.lit(0).alias(col))
hpd_wide = hpd_wide.with_columns([
pl.col("hpd_class_b_raw").fill_null(0).alias("hpd_class_b_raw"),
pl.col("hpd_class_c_raw").fill_null(0).alias("hpd_class_c_raw"),
# Severity-weighted score: C counts 3Γ—, B counts 2Γ—, A counts 1Γ—
(pl.col("hpd_class_c_raw").fill_null(0) * 3.0 +
pl.col("hpd_class_b_raw").fill_null(0) * 2.0 +
pl.col("hpd_class_a_raw").fill_null(0) * 1.0
).alias("hpd_severity_score_zip"),
pl.col("zip").str.zfill(5).alias("_zip_str"),
])
# Keep only the two most informative (model sees B, C, and composite)
hpd_wide = hpd_wide.select([
"_zip_str",
pl.col("hpd_class_b_raw").log1p().alias("hpd_class_b_viol_zip"),
pl.col("hpd_class_c_raw").log1p().alias("hpd_class_c_viol_zip"),
pl.col("hpd_severity_score_zip").log1p(),
])
df = df.join(hpd_wide, on="_zip_str", how="left")
for c in ["hpd_class_b_viol_zip", "hpd_class_c_viol_zip", "hpd_severity_score_zip"]:
med = float(df[c].drop_nulls().median() or 0.0)
df = df.with_columns(pl.col(c).fill_null(med))
print(f" Added: hpd_class_b_viol_zip, hpd_class_c_viol_zip, hpd_severity_score_zip")
print(f" Coverage: {(df['hpd_class_c_viol_zip'] > 0).sum()/len(df)*100:.1f}%")
else:
print(" Skipped β€” API unavailable, filling zeros")
for c in ["hpd_class_b_viol_zip","hpd_class_c_viol_zip","hpd_severity_score_zip"]:
df = df.with_columns(pl.lit(0.0).alias(c))
# ══════════════════════════════════════════════════════════════════════
# B. DOB Construction + Renovation Permits by ZIP (2022+)
# Features: dob_reno_permit_count, dob_newbld_permit_count
# Signal: active renovation = building improvement β†’ premium.
# new building density = development pressure β†’ appreciation.
# ══════════════════════════════════════════════════════════════════════
banner("B β€” DOB renovation + new-build permits by ZIP (2022+)")
dob_reno = socrata_get("ipu4-2q9a", {
"$select": "zip_code, count(*) as permit_count",
"$where": "filing_date >= '01/01/2022' AND (job_type='A1' OR job_type='A2')",
"$group": "zip_code",
"$limit": "500",
}, label="DOB A1/A2 reno by ZIP")
dob_nb = socrata_get("ipu4-2q9a", {
"$select": "zip_code, count(*) as nb_count",
"$where": "filing_date >= '01/01/2022' AND job_type='NB'",
"$group": "zip_code",
"$limit": "500",
}, label="DOB NB new-build by ZIP")
def build_dob_series(rows, count_key, col_name):
if not rows:
return None
d = {r["zip_code"].zfill(5): int(r[count_key]) for r in rows if r.get("zip_code")}
return pl.DataFrame({
"_zip_str": list(d.keys()),
col_name: [float(v) for v in d.values()],
})
reno_df = build_dob_series(dob_reno, "permit_count", "dob_reno_permit_count")
nb_df = build_dob_series(dob_nb, "nb_count", "dob_newbld_permit_count")
for frame, cols in [(reno_df, ["dob_reno_permit_count"]),
(nb_df, ["dob_newbld_permit_count"])]:
if frame is not None:
df = df.join(frame, on="_zip_str", how="left")
for c in cols:
med = float(df[c].drop_nulls().median() or 0.0)
df = df.with_columns(
pl.col(c).fill_null(med).log1p().alias(c) # log-transform in place
)
print(f" Added: {', '.join(cols)}")
else:
for c in cols:
df = df.with_columns(pl.lit(0.0).alias(c))
print(f" Skipped {c} β€” API unavailable")
# ══════════════════════════════════════════════════════════════════════
# C. Rodent + Heat complaint density by NTA (from local parquet)
# Features: rat_density_nta, heat_density_nta
# Source: data/raw/livability_complaints.parquet
# Method: shapely point-in-polygon β†’ ntacode β†’ count / population_2020
# ══════════════════════════════════════════════════════════════════════
banner("C β€” Rodent + heat complaint density by NTA (local parquet)")
LIVABILITY_PATH = os.path.join(RAW, "livability_complaints.parquet")
NTA_GJ_PATH = os.path.join(RAW, "nta_boundaries.geojson")
try:
from shapely.geometry import shape, Point
from shapely.strtree import STRtree
liv = pl.read_parquet(LIVABILITY_PATH)
print(f" Livability complaints: {len(liv):,} rows")
print(f" Types: {dict(zip(liv['complaint_type'].value_counts()['complaint_type'].to_list(), liv['complaint_type'].value_counts()['count'].to_list()))}")
# Filter to rodent and heat/hot water
rat_df = liv.filter(pl.col("complaint_type") == "Rodent").drop_nulls(["latitude","longitude"])
heat_df = liv.filter(pl.col("complaint_type").is_in(["Heat/Hot Water","Non-Residential Heat"])).drop_nulls(["latitude","longitude"])
print(f" Rodent: {len(rat_df):,} | Heat: {len(heat_df):,}")
# Load NTA boundaries
with open(NTA_GJ_PATH) as f:
nta_gj = json.load(f)
nta_geoms = []
nta_codes = []
for feat in nta_gj["features"]:
props = feat.get("properties", {})
code = props.get("nta2020") or props.get("ntacode") or ""
if code:
try:
geom = shape(feat["geometry"])
nta_geoms.append(geom)
nta_codes.append(code)
except Exception:
pass
print(f" NTA boundaries loaded: {len(nta_codes)} polygons")
tree = STRtree(nta_geoms)
def assign_nta_bulk(lat_arr, lon_arr):
"""Returns list of NTA codes (or None) for each point."""
pts = [Point(lon, lat) for lat, lon in zip(lat_arr, lon_arr)]
results = []
for pt in pts:
idxs = tree.query(pt)
matched = None
for idx in idxs:
if nta_geoms[idx].contains(pt):
matched = nta_codes[idx]
break
results.append(matched)
return results
# Assign NTAs (batch β€” may take ~30s for 155K rodent + 7K heat)
print(" Assigning NTAs to rodent complaints …")
t0 = time.time()
rat_nta = assign_nta_bulk(rat_df["latitude"].to_numpy(),
rat_df["longitude"].to_numpy())
print(f" Done: {sum(x is not None for x in rat_nta):,} assigned ({time.time()-t0:.0f}s)")
print(" Assigning NTAs to heat complaints …")
t0 = time.time()
heat_nta = assign_nta_bulk(heat_df["latitude"].to_numpy(),
heat_df["longitude"].to_numpy())
print(f" Done: {sum(x is not None for x in heat_nta):,} assigned ({time.time()-t0:.0f}s)")
# Count per NTA
rat_counts = {}
heat_counts = {}
for code in rat_nta:
if code:
rat_counts[code] = rat_counts.get(code, 0) + 1
for code in heat_nta:
if code:
heat_counts[code] = heat_counts.get(code, 0) + 1
# Build per-NTA population lookup from training data
nta_pop = (
df.filter(pl.col("ntacode").is_not_null() & (pl.col("population_2020") > 0))
.group_by("ntacode")
.agg(pl.col("population_2020").median().alias("pop"))
)
pop_map = {r["ntacode"]: float(r["pop"]) for r in nta_pop.iter_rows(named=True)}
global_pop = float(np.median(list(pop_map.values()))) if pop_map else 50000.0
# Build NTA-level feature frame
all_nta_codes = list(set(list(rat_counts) + list(heat_counts) + list(pop_map)))
rat_feat = []
heat_feat = []
for code in all_nta_codes:
pop = pop_map.get(code, global_pop)
# Per 1000 residents, log-scaled
rat_feat.append(float(np.log1p(rat_counts.get(code, 0) / (pop / 1000.0 + 1e-6))))
heat_feat.append(float(np.log1p(heat_counts.get(code, 0) / (pop / 1000.0 + 1e-6))))
nta_feat_df = pl.DataFrame({
"ntacode": all_nta_codes,
"rat_density_nta": rat_feat,
"heat_density_nta": heat_feat,
})
df = df.join(nta_feat_df, on="ntacode", how="left")
for c in ["rat_density_nta", "heat_density_nta"]:
med = float(df[c].drop_nulls().median() or 0.0)
df = df.with_columns(pl.col(c).fill_null(med))
print(f" Added: rat_density_nta, heat_density_nta")
cov = (df["rat_density_nta"] > 0).sum() / len(df) * 100
print(f" Coverage: rat={cov:.1f}% heat={(df['heat_density_nta'] > 0).sum()/len(df)*100:.1f}%")
except ImportError:
print(" shapely not available β€” filling median zeros")
for c in ["rat_density_nta", "heat_density_nta"]:
df = df.with_columns(pl.lit(0.0).alias(c))
except Exception as e:
print(f" Error in NTA spatial join: {e}")
for c in ["rat_density_nta", "heat_density_nta"]:
df = df.with_columns(pl.lit(0.0).alias(c))
# ══════════════════════════════════════════════════════════════════════
# D. MTA Station Quality (from existing MTA_Subway_Stations CSV)
# Features: nearest_station_is_cbd, nearest_station_route_count
# Method: KDTree nearest-neighbor on property lat/lon
# ══════════════════════════════════════════════════════════════════════
banner("D β€” MTA station quality (CBD + route count)")
MTA_PATH = os.path.join(RAW, "MTA_Subway_Stations_20260308.csv")
try:
mta = pl.read_csv(MTA_PATH)
print(f" MTA stations: {len(mta):,} rows | Columns: {mta.columns[:8]}")
# Parse lat/lon
mta = mta.with_columns([
pl.col("GTFS Latitude").cast(pl.Float64, strict=False).alias("_slat"),
pl.col("GTFS Longitude").cast(pl.Float64, strict=False).alias("_slon"),
]).drop_nulls(subset=["_slat", "_slon"])
# CBD flag: can be bool or "true"/"false" string depending on Polars inference
cbd_col = "CBD"
if cbd_col in mta.columns:
if mta[cbd_col].dtype == pl.Boolean:
mta = mta.with_columns(pl.col(cbd_col).cast(pl.Int32).alias("_is_cbd"))
else:
mta = mta.with_columns(
(pl.col(cbd_col).cast(pl.Utf8).str.to_lowercase() == "true")
.cast(pl.Int32).alias("_is_cbd")
)
else:
mta = mta.with_columns(pl.lit(0).alias("_is_cbd"))
# Route count: parse "Daytime Routes" β€” e.g. "N W" β†’ 2, "4 5 6" β†’ 3
routes_col = "Daytime Routes"
if routes_col in mta.columns:
mta = mta.with_columns(
pl.col(routes_col).str.strip_chars()
.str.split(" ")
.list.len()
.alias("_route_count")
)
else:
mta = mta.with_columns(pl.lit(1).alias("_route_count"))
# ADA accessibility
ada_col = "ADA"
if ada_col in mta.columns:
mta = mta.with_columns(
(pl.col(ada_col).cast(pl.Int32, strict=False) > 0).cast(pl.Int32).alias("_is_ada")
)
else:
mta = mta.with_columns(pl.lit(0).alias("_is_ada"))
# Complex-level dedup: one station per Complex ID, keep max route count, any CBD
complex_col = "Complex ID"
if complex_col in mta.columns:
mta_cplx = (
mta.group_by("Complex ID")
.agg([
pl.col("_slat").first(),
pl.col("_slon").first(),
pl.col("_is_cbd").max(),
pl.col("_route_count").max(),
pl.col("_is_ada").max(),
])
)
else:
mta_cplx = mta.select(["_slat","_slon","_is_cbd","_route_count","_is_ada"])
station_lats = mta_cplx["_slat"].to_numpy()
station_lons = mta_cplx["_slon"].to_numpy()
station_cbd = mta_cplx["_is_cbd"].to_numpy()
station_rts = mta_cplx["_route_count"].to_numpy()
station_ada = mta_cplx["_is_ada"].to_numpy()
print(f" Station complexes: {len(station_lats)} | CBD stations: {int(station_cbd.sum())}")
# KDTree on station lat/lon (degree units β€” fine for nearest)
ktree = KDTree(np.column_stack([station_lats, station_lons]))
# Property lat/lon
prop_ll = df.select(["latitude","longitude"]).fill_null(0).to_numpy()
valid_mask = (prop_ll[:,0] != 0) & (prop_ll[:,1] != 0)
is_cbd_col = np.zeros(len(df), dtype=np.int32)
route_ct_col = np.ones(len(df), dtype=np.int32)
ada_col_arr = np.zeros(len(df), dtype=np.int32)
if valid_mask.sum() > 0:
_, idxs = ktree.query(prop_ll[valid_mask], k=1)
is_cbd_col[valid_mask] = station_cbd[idxs]
route_ct_col[valid_mask] = station_rts[idxs]
ada_col_arr[valid_mask] = station_ada[idxs]
df = df.with_columns([
pl.Series("nearest_station_is_cbd", is_cbd_col.tolist()),
pl.Series("nearest_station_route_count", route_ct_col.tolist()),
pl.Series("nearest_station_is_ada", ada_col_arr.tolist()),
])
print(f" Added: nearest_station_is_cbd, nearest_station_route_count, nearest_station_is_ada")
print(f" CBD coverage: {is_cbd_col.mean()*100:.1f}% | Mean routes: {route_ct_col.mean():.2f}")
except Exception as e:
print(f" Error in MTA join: {e}")
for c in ["nearest_station_is_cbd", "nearest_station_route_count", "nearest_station_is_ada"]:
df = df.with_columns(pl.lit(0).alias(c))
# ══════════════════════════════════════════════════════════════════════
# Final: drop helper column, save output
# ══════════════════════════════════════════════════════════════════════
banner("Saving features_v5.csv")
df = df.drop("_zip_str")
NEW_COLS = [
"hpd_class_b_viol_zip", "hpd_class_c_viol_zip", "hpd_severity_score_zip",
"dob_reno_permit_count", "dob_newbld_permit_count",
"rat_density_nta", "heat_density_nta",
"nearest_station_is_cbd", "nearest_station_route_count", "nearest_station_is_ada",
]
present = [c for c in NEW_COLS if c in df.columns]
print(f"\n New feature columns ({len(present)}):")
for c in present:
vals = df[c].drop_nulls()
print(f" {c:<38} min={float(vals.min()):.3f} med={float(vals.median()):.3f} max={float(vals.max()):.3f}")
print(f"\n Output: {OUTPUT}")
print(f" Shape: {df.shape[0]:,} rows Γ— {df.shape[1]} cols")
df.write_csv(OUTPUT)
print(f" βœ“ Saved successfully")
print(f"\n Original features: {df.shape[1] - len(present)} β†’ New total: {df.shape[1]}")
print(f" These {len(present)} new columns feed directly into train_stack_v11.py")