Spaces:
Running
Running
| """ | |
| Riyadh Structural Feature Enrichment | |
| ====================================== | |
| Adds per-district structural proxy features to features_riyadh.csv from: | |
| 1. SA_Aqar rental listings โ aqar_* (size, age, bedrooms, rent/sqm) | |
| 2. Bayut sales listings โ per-type area and rooms | |
| 3. Haraj sales listings โ per-type area and age | |
| Run: python scripts/riyadh_structural_features.py | |
| """ | |
| import json | |
| import re | |
| import warnings | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| warnings.filterwarnings("ignore") | |
| _ROOT = Path(__file__).resolve().parent.parent | |
| RAW = _ROOT / "data" / "raw" | |
| PROC = _ROOT / "data" / "processed" | |
| def _normalize_ar(s: str) -> str: | |
| if not isinstance(s, str): | |
| return "" | |
| s = s.strip() | |
| s = re.sub(r"^ุญู\s+", "", s) # strip leading "ุญู " | |
| s = re.sub(r"\s+", " ", s) | |
| return s | |
| # โโ Load master features โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| print("Loading features_riyadh.csv...") | |
| feat = pd.read_csv(PROC / "features_riyadh.csv", encoding="utf-8-sig") | |
| feat["district_ar"] = feat["district_ar"].apply(_normalize_ar) | |
| feat_districts = set(feat["district_ar"].unique()) | |
| print(f" {len(feat)} rows | {len(feat_districts)} districts") | |
| # โโ 1. SA_Aqar rental listings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| print("\nProcessing SA_Aqar...") | |
| df_aqar = pd.read_csv(RAW / "SA_Aqar.csv") | |
| ryd = df_aqar[df_aqar["city"].str.strip() == "ุงูุฑูุงุถ"].copy() | |
| ryd["district"] = ryd["district"].apply(_normalize_ar) | |
| ryd = ryd[ryd["size"] > 0].copy() | |
| # rent per sqm (annual SAR / sqm) | |
| ryd["rent_per_sqm"] = ryd["price"] / ryd["size"] | |
| # Remove outliers: size > 3000 or < 50, rent > 2000 SAR/sqm/yr | |
| ryd = ryd[(ryd["size"] >= 50) & (ryd["size"] <= 3000)] | |
| ryd = ryd[(ryd["rent_per_sqm"] > 0) & (ryd["rent_per_sqm"] < 2000)] | |
| aqar_agg = ryd.groupby("district").agg( | |
| aqar_median_size_sqm = ("size", "median"), | |
| aqar_median_bedrooms = ("bedrooms", "median"), | |
| aqar_median_property_age= ("property_age", "median"), | |
| aqar_rent_per_sqm = ("rent_per_sqm", "median"), | |
| _aqar_n = ("size", "count"), | |
| ).reset_index() | |
| overlap = len(set(aqar_agg["district"]) & feat_districts) | |
| print(f" SA_Aqar: {len(ryd)} rows | {len(aqar_agg)} districts | {overlap} matching") | |
| # Global fallbacks | |
| aqar_global = { | |
| "aqar_median_size_sqm": float(ryd["size"].median()), | |
| "aqar_median_bedrooms": float(ryd["bedrooms"].median()), | |
| "aqar_median_property_age": float(ryd["property_age"].median()), | |
| "aqar_rent_per_sqm": float(ryd["rent_per_sqm"].median()), | |
| } | |
| print(f" Globals: {aqar_global}") | |
| # โโ 2. Bayut sales listings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| print("\nProcessing Bayut listings...") | |
| with open(PROC / "bayut_listings_riyadh.json") as f: | |
| bl = json.load(f) | |
| df_b = pd.DataFrame(bl) | |
| df_b["district_ar"] = df_b["district_ar"].apply(_normalize_ar) | |
| df_b["area"] = pd.to_numeric(df_b["area"], errors="coerce") | |
| df_b["rooms"] = pd.to_numeric(df_b["rooms"], errors="coerce") | |
| df_b["psqm"] = pd.to_numeric(df_b["psqm"], errors="coerce") | |
| # Filter reasonable sizes and prices | |
| df_b = df_b[(df_b["area"] >= 30) & (df_b["area"] <= 2000)] | |
| df_b = df_b[(df_b["psqm"] > 500) & (df_b["psqm"] < 50000)] | |
| # Per-type aggregations | |
| type_map = { | |
| "Villas": "villa", | |
| "Apartments": "apt", | |
| "Floors": "apt", # floor units โ apartments | |
| "Residential Lands": "plot", | |
| } | |
| df_b["type_group"] = df_b["property_type"].map(type_map) | |
| df_b_res = df_b[df_b["type_group"].notna()].copy() | |
| bayut_agg = df_b_res.groupby(["district_ar", "type_group"]).agg( | |
| _median_area = ("area", "median"), | |
| _median_rooms = ("rooms", "median"), | |
| _median_psqm = ("psqm", "median"), | |
| _n = ("area", "count"), | |
| ).reset_index() | |
| # Pivot to wide format (one row per district) | |
| bayut_wide = bayut_agg.pivot(index="district_ar", columns="type_group", | |
| values=["_median_area", "_median_rooms", "_median_psqm"]).reset_index() | |
| bayut_wide.columns = [ | |
| "district_ar" if c[1] == "" else f"bayut_{c[1]}_median_{c[0][1:]}" | |
| for c in bayut_wide.columns | |
| ] | |
| # Clean up column names | |
| rename = {} | |
| for col in bayut_wide.columns: | |
| if col == "district_ar": continue | |
| # e.g. "bayut_villa_median__median_area" โ "bayut_villa_area_sqm" | |
| col_clean = col.replace("_median_area", "_area_sqm").replace("_median_rooms", "_rooms").replace("_median_psqm", "_psqm") | |
| rename[col] = col_clean | |
| bayut_wide = bayut_wide.rename(columns=rename) | |
| overlap_b = len(set(bayut_wide["district_ar"]) & feat_districts) | |
| print(f" Bayut: {len(df_b_res)} residential rows | {len(bayut_wide)} districts | {overlap_b} matching") | |
| print(f" Bayut columns: {[c for c in bayut_wide.columns if c != 'district_ar']}") | |
| # โโ 3. Haraj listings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| print("\nProcessing Haraj listings...") | |
| df_h = pd.read_csv(RAW / "saudi_listings_haraj_20260518.csv") | |
| df_h["district"] = df_h["district"].apply(_normalize_ar) | |
| df_h["area_sqm"] = pd.to_numeric(df_h["area_sqm"], errors="coerce") | |
| df_h["age_years"] = pd.to_numeric(df_h["age_years"], errors="coerce") | |
| df_h["bedrooms"] = pd.to_numeric(df_h["bedrooms"], errors="coerce") | |
| # Normalize property types | |
| def _haraj_type(s): | |
| if not isinstance(s, str): return None | |
| s = s.strip() | |
| if s in ("ุดูุฉ", "Apartment", "ุดูุฉ ุณูููุฉ (ุบุฑูุชูู ูุตุงูุฉ).", "ุดูุฉ.", "ุดูุฉ ุจุงูุฏูุฑ ุงูุซุงูู"): return "apt" | |
| if s in ("ูููุง", "Villa", "ูููุง.", "ูููุง ", "ูููุง ุฏุฑุฌ ุฏุงุฎูู + ุดูุชูู", "ูููุง ุณูููุฉ ุจููู ุฉ ุฃุฑุถ", "ูุทุนุฉ ุฃุฑุถ ุนูููุง ุจูุงุก (ูููุง)"): return "villa" | |
| if s in ("ุงุฑุถ", "Land", "ูุทุนุฉ ุฃุฑุถ ุณูููุฉ", "ุฃุฑุถ ุณูููุฉ", "ุฃุฑุถ"): return "plot" | |
| return None | |
| df_h["type_group"] = df_h["property_type_ar"].apply(_haraj_type) | |
| df_h_res = df_h[df_h["type_group"].notna() & df_h["area_sqm"].notna()].copy() | |
| df_h_res = df_h_res[(df_h_res["area_sqm"] >= 30) & (df_h_res["area_sqm"] <= 3000)] | |
| haraj_agg = df_h_res.groupby(["district", "type_group"]).agg( | |
| _median_area = ("area_sqm", "median"), | |
| _median_age = ("age_years", lambda x: x.dropna().median() if x.notna().sum() >= 2 else np.nan), | |
| _n = ("area_sqm", "count"), | |
| ).reset_index() | |
| haraj_wide = haraj_agg.pivot(index="district", columns="type_group", | |
| values=["_median_area", "_median_age"]).reset_index() | |
| haraj_wide.columns = [ | |
| "district_ar" if c[1] == "" else f"haraj_{c[1]}_{c[0][1:]}" | |
| for c in haraj_wide.columns | |
| ] | |
| haraj_rename = {} | |
| for col in haraj_wide.columns: | |
| if col == "district_ar": continue | |
| haraj_rename[col] = col.replace("_median_area", "_area_sqm").replace("_median_age", "_age_yr") | |
| haraj_wide = haraj_wide.rename(columns=haraj_rename) | |
| overlap_h = len(set(haraj_wide["district_ar"]) & feat_districts) | |
| print(f" Haraj: {len(df_h_res)} rows | {len(haraj_wide)} districts | {overlap_h} matching") | |
| print(f" Haraj columns: {[c for c in haraj_wide.columns if c != 'district_ar']}") | |
| # โโ 4. Merge all into features_riyadh.csv โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| print("\nMerging into features_riyadh.csv...") | |
| # Get per-district unique rows (structural features are district-level, not per-row) | |
| new_feat = feat.copy() | |
| # SA_Aqar | |
| new_feat = new_feat.merge( | |
| aqar_agg.drop(columns=["_aqar_n"]), | |
| left_on="district_ar", right_on="district", how="left" | |
| ).drop(columns=["district"], errors="ignore") | |
| for col, val in aqar_global.items(): | |
| new_feat[col] = new_feat[col].fillna(val) | |
| # Bayut | |
| new_feat = new_feat.merge(bayut_wide, on="district_ar", how="left") | |
| # Fill Bayut NaN with global medians | |
| bayut_globals = { | |
| col: float(bayut_wide[col].median()) | |
| for col in bayut_wide.columns | |
| if col != "district_ar" and bayut_wide[col].notna().sum() > 5 | |
| } | |
| for col, val in bayut_globals.items(): | |
| if col in new_feat.columns: | |
| new_feat[col] = new_feat[col].fillna(val) | |
| # Haraj | |
| new_feat = new_feat.merge(haraj_wide, on="district_ar", how="left") | |
| haraj_globals = { | |
| col: float(haraj_wide[col].median()) | |
| for col in haraj_wide.columns | |
| if col != "district_ar" and haraj_wide[col].notna().sum() > 2 | |
| } | |
| for col, val in haraj_globals.items(): | |
| if col in new_feat.columns: | |
| new_feat[col] = new_feat[col].fillna(val) | |
| # Report new columns | |
| new_cols = [c for c in new_feat.columns if c not in feat.columns] | |
| print(f" Added {len(new_cols)} new columns: {new_cols}") | |
| print(f" Output rows: {len(new_feat)} (was {len(feat)})") | |
| for col in new_cols: | |
| non_null = new_feat[col].notna().sum() | |
| print(f" {col}: {non_null}/{len(new_feat)} non-null ({non_null/len(new_feat)*100:.0f}%)") | |
| # โโ Save enriched CSV โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| out_path = PROC / "features_riyadh.csv" | |
| new_feat.to_csv(out_path, index=False, encoding="utf-8-sig") | |
| print(f"\nSaved: {out_path} ({len(new_feat)} rows x {len(new_feat.columns)} cols)") | |