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