thaman / scripts /riyadh_feature_engineering.py
Turki Almurahhem
THAMAN dual-city AVM — Stack v22 (NYC) + v12 (Riyadh)
2e8a71f
Raw
History Blame Contribute Delete
44.8 kB
"""
Riyadh Feature Engineering Pipeline — THAMAN v2 (Polars)
Produces data/processed/features_riyadh.csv from raw Riyadh data files.
Sources:
- data/raw/quarter_report SI.xlsx (2018-2023, original)
- data/raw/sales_riyadh_2024_Q*.csv (2024 Q1/Q3/Q4, API)
- data/raw/sales_riyadh_2025_Q*.csv (2025 Q1/Q2/Q3, API; Q4 lacks district, skipped)
Steps:
1 Load & normalise transactions from all sources (Polars)
2 District centroid geocoding
3 Metro transit features
4 Bus stop features
5 Traffic intersection features
6 Commercial services features
7 Air quality features
8 Real estate price index
9 Salary macro features
10 District aggregate features + target encoding
Run: python scripts/riyadh_feature_engineering.py
"""
import json
import unicodedata
import warnings
from pathlib import Path
import numpy as np
import polars as pl
from scipy.spatial import cKDTree
from scipy.stats import linregress
from sklearn.neighbors import BallTree
from sklearn.preprocessing import MinMaxScaler
warnings.filterwarnings("ignore")
_ROOT = Path(__file__).resolve().parent.parent
RAW = _ROOT / "data" / "raw"
PROCESSED = _ROOT / "data" / "processed"
PROCESSED.mkdir(parents=True, exist_ok=True)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _normalize_ar(s: str) -> str:
"""Normalize Arabic string: NFKC, strip tatweel and harakat."""
if not isinstance(s, str):
return ""
s = unicodedata.normalize("NFKC", s)
s = s.replace("ـ", "")
s = "".join(c for c in s if not (0x064B <= ord(c) <= 0x065F))
return s.strip()
def _geojson_coords(feat):
"""Return (lat, lon) from GeoJSON feature geometry.coordinates [lon, lat]."""
c = feat["geometry"]["coordinates"]
return c[1], c[0]
def _geo_point_2d(feat):
"""Return (lat, lon) from geo_point_2d property; (None, None) if absent."""
geo = feat["properties"].get("geo_point_2d")
if not geo:
return None, None
return geo.get("lat"), geo.get("lon")
# ── Step 1: Load & normalise transactions ─────────────────────────────────────
print("Step 1 — Loading transactions...")
KEEP_TYPES = {"شقة", "فيلا", "قطعة أرض-سكنى", "عمارة"}
# ── 1a. XLSX (2018-2023) ──────────────────────────────────────────────────────
xlsx_raw = pl.read_excel(RAW / "quarter_report SI.xlsx")
print(f" XLSX raw rows: {len(xlsx_raw)}")
xlsx = (
xlsx_raw
.filter(pl.col("city_ar") == "الرياض")
.filter(pl.col("typecategoryar").is_in(list(KEEP_TYPES)))
.filter(pl.col("deed_counts") >= 3)
# Only keep rows where the IQR-winsorized per-sqm price is present.
# The fallback RealEstatePrice_SUM / deed_counts gives average deal value,
# not price per sqm, producing multi-million SAR/sqm garbage values.
.filter(pl.col("Meter_Price_W_Avg_IQR").is_not_null())
.with_columns([
pl.col("Meter_Price_W_Avg_IQR").cast(pl.Float64, strict=False).alias("sale_price_sar_sqm"),
pl.col("deed_counts").cast(pl.Int64).alias("deed_counts"),
pl.col("yearnumber").cast(pl.Int64).alias("year"),
pl.col("quarternumber").cast(pl.Int64).alias("quarter"),
pl.col("district_ar").cast(pl.Utf8).alias("district_ar"),
pl.col("region_ar").cast(pl.Utf8).alias("region_ar"),
pl.col("city_ar").cast(pl.Utf8).alias("city_ar"),
])
.filter(pl.col("sale_price_sar_sqm").is_not_null() & (pl.col("sale_price_sar_sqm") > 0))
.filter(pl.col("district_ar").is_not_null())
.filter(pl.col("district_ar") != "NULL")
.select([
"year", "quarter", "district_ar", "typecategoryar",
"deed_counts", "sale_price_sar_sqm", "region_ar", "city_ar",
])
)
print(f" XLSX after filter: {len(xlsx)} rows | years: {sorted(xlsx['year'].unique().to_list())}")
# ── 1b. 2024 API CSVs (format: رقم الربع is the quarter number) ───────────────
def _load_2024_csv(path: Path) -> pl.DataFrame:
# Read without renaming to avoid duplicate-column issues (two "الربع" variants)
df = pl.read_csv(path, encoding="utf8-lossy", null_values=["NULL", "null", ""],
infer_schema_length=200)
cols = df.columns
# Quarter number: 'رقم الربع ' (has رقم)
qcol = next((c for c in cols if "رقم" in c and "ربع" in c), None)
if qcol is None:
# Fallback: use الربع col that is numeric
qcol = next((c for c in cols if "ربع" in c), cols[1])
city_col = next((c for c in cols if "المدينة" in c), cols[5])
district_col = next((c for c in cols if "الحي" == c.strip()), next((c for c in cols if "الحي" in c), cols[6]))
type_col = next((c for c in cols if "نوع" in c and "عقار" in c), cols[7])
deed_col = next((c for c in cols if "صكوك" in c), cols[8])
price_col = next((c for c in cols if "متوسط" in c and "سعر" in c), cols[11])
year_col = next((c for c in cols if "السنة" in c), cols[0])
region_col = next((c for c in cols if "المنطقة" in c), cols[4])
return (
df
.filter(pl.col(city_col).str.contains("الرياض"))
.filter(pl.col(deed_col).cast(pl.Int64, strict=False) >= 3)
.filter(pl.col(type_col).is_in(list(KEEP_TYPES)))
.with_columns([
pl.col(year_col).cast(pl.Int64, strict=False).alias("year"),
pl.col(qcol).cast(pl.Int64, strict=False).alias("quarter"),
pl.col(district_col).cast(pl.Utf8).alias("district_ar"),
pl.col(type_col).cast(pl.Utf8).alias("typecategoryar"),
pl.col(deed_col).cast(pl.Int64, strict=False).alias("deed_counts"),
pl.col(price_col).cast(pl.Float64, strict=False).alias("sale_price_sar_sqm"),
pl.col(region_col).cast(pl.Utf8).alias("region_ar"),
pl.lit("الرياض").alias("city_ar"),
])
.filter(pl.col("sale_price_sar_sqm").is_not_null() & (pl.col("sale_price_sar_sqm") > 0))
.filter(pl.col("district_ar").is_not_null())
.select(["year", "quarter", "district_ar", "typecategoryar",
"deed_counts", "sale_price_sar_sqm", "region_ar", "city_ar"])
)
# ── 1c. 2025 API CSVs (format: الربع is the quarter number, تصنيف العقار present) ──
# 2025 property type → standardized mapping
# land type depends on classification column
def _load_2025_csv(path: Path) -> pl.DataFrame:
# Read without global rename — 2025 CSVs have "الربع" (numeric) + "الربع " (text, trailing space)
df = pl.read_csv(path, encoding="utf8-lossy", null_values=["NULL", "null", ""],
infer_schema_length=200)
cols = df.columns
city_col = next((c for c in cols if "المدينة" in c), cols[4])
district_col = next((c for c in cols if "الحي" == c.strip()), next((c for c in cols if "الحي" in c), cols[5]))
type_col = next((c for c in cols if "نوع" in c and "عقار" in c), cols[6])
class_col = next((c for c in cols if "تصنيف" in c), None)
deed_col = next((c for c in cols if "صكوك" in c), cols[8])
price_col = next((c for c in cols if "متوسط" in c and "سعر" in c), cols[11])
year_col = next((c for c in cols if "السنة" in c), cols[0])
region_col = next((c for c in cols if "المنطقة" in c), cols[3])
# Quarter number: find the الربع column that is purely numeric
q_candidates = [c for c in cols if "الربع" in c]
qcol = q_candidates[0]
for cand in q_candidates:
try:
n_null = df[cand].cast(pl.Int64, strict=False).null_count()
if n_null < len(df) * 0.5:
qcol = cand
break
except Exception:
pass
df = df.filter(pl.col(city_col).str.contains("الرياض"))
df = df.filter(pl.col(deed_col).cast(pl.Int64, strict=False) >= 3)
# Map 2025 property types → standardized
if class_col and class_col in df.columns:
type_expr = (
pl.when(pl.col(type_col).is_in(["شقة", "دور"])).then(pl.lit("شقة"))
.when(pl.col(type_col).is_in(["فيلا", "دوبلكس"])).then(pl.lit("فيلا"))
.when((pl.col(type_col) == "أرض") & (pl.col(class_col) == "سكني")).then(pl.lit("قطعة أرض-سكنى"))
.when(pl.col(type_col).is_in(["عمارة", "مبنى"])).then(pl.lit("عمارة"))
.otherwise(pl.lit(None))
)
else:
type_expr = (
pl.when(pl.col(type_col).is_in(["شقة", "دور"])).then(pl.lit("شقة"))
.when(pl.col(type_col).is_in(["فيلا", "دوبلكس"])).then(pl.lit("فيلا"))
.when(pl.col(type_col).is_in(["عمارة", "مبنى"])).then(pl.lit("عمارة"))
.otherwise(pl.lit(None))
)
return (
df
.with_columns([
pl.col(year_col).cast(pl.Int64, strict=False).alias("year"),
pl.col(qcol).cast(pl.Int64, strict=False).alias("quarter"),
pl.col(district_col).cast(pl.Utf8).alias("district_ar"),
type_expr.alias("typecategoryar"),
pl.col(deed_col).cast(pl.Int64, strict=False).alias("deed_counts"),
pl.col(price_col).cast(pl.Float64, strict=False).alias("sale_price_sar_sqm"),
pl.col(region_col).cast(pl.Utf8).alias("region_ar"),
pl.lit("الرياض").alias("city_ar"),
])
.filter(pl.col("typecategoryar").is_not_null())
.filter(pl.col("sale_price_sar_sqm").is_not_null() & (pl.col("sale_price_sar_sqm") > 0))
.filter(pl.col("district_ar").is_not_null())
.select(["year", "quarter", "district_ar", "typecategoryar",
"deed_counts", "sale_price_sar_sqm", "region_ar", "city_ar"])
)
# Load all API CSVs
api_dfs = []
for fname, loader in [
("sales_riyadh_2024_Q1.csv", _load_2024_csv),
("sales_riyadh_2024_Q3.csv", _load_2024_csv),
("sales_riyadh_2024_Q4.csv", _load_2024_csv),
("sales_riyadh_2025_Q1.csv", _load_2025_csv),
("sales_riyadh_2025_Q2.csv", _load_2025_csv),
("sales_riyadh_2025_Q3.csv", _load_2025_csv),
]:
p = RAW / fname
if p.exists():
part = loader(p)
api_dfs.append(part)
print(f" {fname}: {len(part)} rows")
else:
print(f" {fname}: NOT FOUND — skipping")
# Combine all sources
all_parts = [xlsx] + api_dfs
df = pl.concat(all_parts, how="diagonal_relaxed")
# Winsorise price P1-P99 across the full combined dataset
p01 = df["sale_price_sar_sqm"].quantile(0.01)
p99 = df["sale_price_sar_sqm"].quantile(0.99)
df = df.filter(
(pl.col("sale_price_sar_sqm") >= p01) & (pl.col("sale_price_sar_sqm") <= p99)
)
# Derived columns
df = df.with_columns([
(pl.col("year") * 10 + pl.col("quarter")).alias("quarter_id"),
(pl.col("typecategoryar") == "شقة").cast(pl.Int8).alias("is_apartment"),
(pl.col("typecategoryar") == "فيلا").cast(pl.Int8).alias("is_villa"),
(pl.col("typecategoryar") == "قطعة أرض-سكنى").cast(pl.Int8).alias("is_residential_plot"),
(pl.col("typecategoryar") == "عمارة").cast(pl.Int8).alias("is_building"),
pl.col("deed_counts").cast(pl.Float64).log1p().alias("log_deed_count"),
pl.col("year").alias("sale_year"),
pl.col("quarter").alias("sale_quarter"),
(2 * np.pi * pl.col("quarter") / 4).sin().alias("sale_quarter_sin"),
(2 * np.pi * pl.col("quarter") / 4).cos().alias("sale_quarter_cos"),
pl.col("district_ar").map_elements(_normalize_ar, return_dtype=pl.Utf8).alias("district_ar_norm"),
])
print(f"\n Combined rows: {len(df)} | price range: {p01:.0f}{p99:.0f} SAR/sqm")
print(f" Districts: {df['district_ar'].n_unique()} | Years: {sorted(df['year'].unique().to_list())}")
# ── Step 2: District centroid geocoding ───────────────────────────────────────
print("\nStep 2 — Building district centroids...")
with open(RAW / "commercial-services-by-category-sub-municipality-and-district-2024.geojson") as f:
comm_gj = json.load(f)
comm_rows = []
for feat in comm_gj["features"]:
lat, lon = _geo_point_2d(feat)
if lat is None or lon is None:
continue
dist = feat["properties"].get("districtar", "")
if dist and dist not in ("NA", ""):
comm_rows.append({"district_ar_norm": _normalize_ar(dist), "lat": lat, "lon": lon})
comm_pl = pl.DataFrame(comm_rows)
centroids = (
comm_pl
.group_by("district_ar_norm")
.agg([pl.col("lat").mean().alias("district_lat"),
pl.col("lon").mean().alias("district_lon")])
)
FALLBACK_CENTROIDS = {
"الخزامى": (24.8350, 46.6950),
"الخير": (24.8600, 46.7800),
"الراية": (24.7900, 46.6300),
"الرسالة": (24.7150, 46.5900),
"الزاهر": (24.6500, 46.7600),
"الزهور": (24.8100, 46.7200),
"الشعلة": (24.7400, 46.6100),
"النخبة": (24.8200, 46.6500),
"عريض": (24.8800, 46.8200),
"مغرزات": (24.7600, 46.5600),
}
fallback_pl = pl.DataFrame([
{"district_ar_norm": _normalize_ar(d), "district_lat": lat, "district_lon": lon}
for d, (lat, lon) in FALLBACK_CENTROIDS.items()
])
all_centroids = (
pl.concat([centroids, fallback_pl], how="diagonal_relaxed")
.unique("district_ar_norm", keep="first")
)
# Join district centroids → save reference CSV
norm_to_orig = dict(df.select(["district_ar_norm", "district_ar"]).unique().iter_rows())
all_centroids_save = all_centroids.with_columns(
pl.col("district_ar_norm")
.map_elements(lambda n: norm_to_orig.get(n, n), return_dtype=pl.Utf8)
.alias("district_ar")
)
all_centroids_save.write_csv(PROCESSED / "district_centroids.csv")
print(f" Centroids: {len(all_centroids)} districts")
df = df.join(
all_centroids.select(["district_ar_norm", "district_lat", "district_lon"]),
on="district_ar_norm", how="left"
)
# Fallback: Riyadh city center for unmatched districts
RIYADH_CENTER_LAT, RIYADH_CENTER_LON = 24.7136, 46.6753
missing_n = df["district_lat"].null_count()
if missing_n > 0:
unmatched = df.filter(pl.col("district_lat").is_null())["district_ar"].unique().to_list()
print(f" {missing_n} rows ({len(unmatched)} districts) → Riyadh city center fallback")
for d in unmatched:
print(f" '{d}'")
df = df.with_columns([
pl.col("district_lat").fill_null(RIYADH_CENTER_LAT),
pl.col("district_lon").fill_null(RIYADH_CENTER_LON),
])
print(f" After centroid join: {len(df)} rows")
# ── Numpy arrays for spatial queries ─────────────────────────────────────────
query_lats = df["district_lat"].to_numpy()
query_lons = df["district_lon"].to_numpy()
query_pts = np.column_stack([query_lats, query_lons]) # (N, 2) in degrees
R_EARTH = 6_371_000.0
DEG2M = 111_000.0
r_1km = 1000.0 / R_EARTH
r_500 = 500.0 / R_EARTH
# ── Step 3: Metro transit features ────────────────────────────────────────────
print("Step 3 — Metro transit features...")
with open(RAW / "metro-stations-in-riyadh-by-metro-line-and-station-type-2024.geojson") as f:
metro_gj = json.load(f)
metro_coords, metro_lines, metro_types, line1_coords = [], [], [], []
for feat in metro_gj["features"]:
lat, lon = _geojson_coords(feat)
p = feat["properties"]
metro_coords.append([lat, lon])
metro_lines.append(p["metro_line_cd"])
metro_types.append(int(p["metro_station_type_cd"]))
if p["metro_line_cd"] == "Line1":
line1_coords.append([lat, lon])
metro_arr = np.array(metro_coords, dtype=np.float64)
metro_tree = cKDTree(metro_arr)
metro_ball = BallTree(np.radians(metro_arr), metric="haversine")
line1_arr = np.array(line1_coords, dtype=np.float64) if line1_coords else metro_arr
line1_tree = cKDTree(line1_arr)
LINE_ORDER = {"Line1": 1, "Line2": 2, "Line3": 3, "Line4": 4, "Line5": 5, "Line6": 6}
dists_deg, idxs = metro_tree.query(query_pts, k=1)
idxs_flat = idxs.ravel()
dist_metro_m = dists_deg.ravel() * DEG2M
d1_deg, _ = line1_tree.query(query_pts, k=1)
counts_1km = metro_ball.query_radius(np.radians(query_pts), r=r_1km, count_only=True)
df = df.with_columns([
pl.Series("dist_metro_m", dist_metro_m),
pl.Series("log_dist_metro_m", np.log1p(dist_metro_m)),
pl.Series("nearest_metro_line_cd", [metro_lines[i] for i in idxs_flat]),
pl.Series("nearest_metro_type_cd", [metro_types[i] for i in idxs_flat]),
pl.Series("dist_metro_line1_m", d1_deg.ravel() * DEG2M),
pl.Series("metro_stations_1km", counts_1km.astype(np.int32)),
pl.Series("nearest_metro_line_num",
[LINE_ORDER.get(metro_lines[i], 0) for i in idxs_flat]).cast(pl.Int32),
])
print(f" Metro stations: {len(metro_coords)} | Line1: {len(line1_coords)}")
# ── Step 4: Bus stop features ─────────────────────────────────────────────────
print("Step 4 — Bus stop features...")
with open(RAW / "bus-stops-in-riyadh-by-bus-route-direction-and-shelter-type-2024.geojson") as f:
bus_gj = json.load(f)
bus_coords, bus_shelters = [], []
for feat in bus_gj["features"]:
lat, lon = _geo_point_2d(feat)
p = feat["properties"]
bus_coords.append([lat, lon])
bus_shelters.append(1 if str(p.get("bsheltertypecode", "")).startswith("A") else 0)
bus_arr = np.array(bus_coords, dtype=np.float64)
bus_tree = cKDTree(bus_arr)
bus_ball = BallTree(np.radians(bus_arr), metric="haversine")
shelter_arr = bus_arr[np.array(bus_shelters, dtype=bool)]
shelter_ball = BallTree(np.radians(shelter_arr), metric="haversine") if len(shelter_arr) > 0 else None
d_bus_deg, _ = bus_tree.query(query_pts, k=1)
bus_500 = bus_ball.query_radius(np.radians(query_pts), r=r_500, count_only=True)
brt_500 = shelter_ball.query_radius(np.radians(query_pts), r=r_500, count_only=True) if shelter_ball else np.zeros(len(df), dtype=np.int32)
df = df.with_columns([
pl.Series("dist_bus_m", d_bus_deg.ravel() * DEG2M),
pl.Series("log_dist_bus_m", np.log1p(d_bus_deg.ravel() * DEG2M)),
pl.Series("bus_stops_500m", bus_500.astype(np.int32)),
pl.Series("brt_stops_500m", brt_500.astype(np.int32)),
])
print(f" Bus stops: {len(bus_coords)} | BRT shelters: {len(shelter_arr)}")
# ── Step 5: Traffic intersection features ─────────────────────────────────────
print("Step 5 — Traffic intersection features...")
with open(RAW / "traffic-intersections-by-main-street-and-cross-street-2024.geojson") as f:
int_gj = json.load(f)
int_coords = []
for feat in int_gj["features"]:
lat, lon = _geo_point_2d(feat)
if lat is None or lon is None:
continue
int_coords.append([lat, lon])
int_arr = np.array(int_coords, dtype=np.float64)
int_tree = cKDTree(int_arr)
int_ball = BallTree(np.radians(int_arr), metric="haversine")
d_int_deg, _ = int_tree.query(query_pts, k=1)
int_1km = int_ball.query_radius(np.radians(query_pts), r=r_1km, count_only=True)
int_500 = int_ball.query_radius(np.radians(query_pts), r=r_500, count_only=True)
df = df.with_columns([
pl.Series("dist_major_intersection_m", d_int_deg.ravel() * DEG2M),
pl.Series("log_dist_intersection_m", np.log1p(d_int_deg.ravel() * DEG2M)),
pl.Series("intersections_1km", int_1km.astype(np.int32)),
pl.Series("intersections_500m", int_500.astype(np.int32)),
])
print(f" Traffic intersections: {len(int_coords)}")
# ── Step 6: Commercial services features ──────────────────────────────────────
print("Step 6 — Commercial services features...")
COMMERCIAL_BUCKETS = {
"hypermarket": {"HypMkt"},
"supermarket": {"SupMkt", "MktS", "GroS"},
"bank": {"Bank"},
"restaurant": {"Res"},
"hotel": {"Hot", "HotAp"},
"gas_station": {"GasStation", "PetStation"},
"commercial_complex": {"ComC", "ComX"},
}
all_comm_coords, bucket_coords = [], {k: [] for k in COMMERCIAL_BUCKETS}
district_comm: dict[str, dict] = {}
for feat in comm_gj["features"]:
p = feat["properties"]
lat, lon = _geo_point_2d(feat)
if lat is None or lon is None:
continue
cat = p.get("comcatcode", "")
dist = _normalize_ar(p.get("districtar", ""))
all_comm_coords.append([lat, lon])
if dist and dist not in (_normalize_ar("NA"), ""):
entry = district_comm.setdefault(dist, {"count": 0, "cats": set()})
entry["count"] += 1
entry["cats"].add(cat)
for bname, codes in COMMERCIAL_BUCKETS.items():
if cat in codes:
bucket_coords[bname].append([lat, lon])
comm_arr = np.array(all_comm_coords, dtype=np.float64)
comm_ball = BallTree(np.radians(comm_arr), metric="haversine")
bucket_balls: dict = {}
for bname, pts in bucket_coords.items():
if pts:
bucket_balls[bname] = BallTree(np.radians(np.array(pts, dtype=np.float64)), metric="haversine")
print(f" Commercial {bname}: {len(pts)}")
else:
bucket_balls[bname] = None
comm_1km = comm_ball.query_radius(np.radians(query_pts), r=r_1km, count_only=True)
bucket_arrays: dict[str, np.ndarray] = {}
for bname, bt in bucket_balls.items():
bucket_arrays[bname] = (
bt.query_radius(np.radians(query_pts), r=r_1km, count_only=True)
if bt else np.zeros(len(df), dtype=np.int32)
)
df = df.with_columns(
[pl.Series("commercial_count_1km", comm_1km.astype(np.int32))]
+ [pl.Series(f"{bname}_count_1km", arr.astype(np.int32))
for bname, arr in bucket_arrays.items()]
)
# Density score
df = df.with_columns(
(pl.col("hypermarket_count_1km") * 3
+ pl.col("supermarket_count_1km") * 2
+ pl.col("bank_count_1km")
+ pl.col("restaurant_count_1km")
+ pl.col("hotel_count_1km")).alias("commercial_density_score")
)
# District-level commercial stats
norms = df["district_ar_norm"].to_list()
df = df.with_columns([
pl.Series("district_commercial_count",
[district_comm.get(n, {}).get("count", 0) for n in norms]).cast(pl.Int32),
pl.Series("district_commercial_mix",
[len(district_comm.get(n, {}).get("cats", set())) for n in norms]).cast(pl.Int32),
])
# ── Step 6b: Mosque / Mall / School / Hospital / Park / Entertainment features ─
print("Step 6b — QoL POI features (mosques, malls, schools, hospitals, parks)...")
def _load_csv_coords(path: Path, lat_col: str = "latitude", lon_col: str = "longitude") -> np.ndarray:
"""Read a simple lat/lon CSV into an (N,2) float64 array, dropping nulls."""
df_poi = pl.read_csv(path, encoding="utf8-lossy",
null_values=["NULL", "null", ""])
# Rename BOM-prefixed column headers if present
df_poi = df_poi.rename({c: c.lstrip("").strip() for c in df_poi.columns})
lat_col_act = next((c for c in df_poi.columns if lat_col in c.lower()), lat_col)
lon_col_act = next((c for c in df_poi.columns if lon_col in c.lower()), lon_col)
df_poi = df_poi.filter(
pl.col(lat_col_act).is_not_null() & pl.col(lon_col_act).is_not_null()
).with_columns([
pl.col(lat_col_act).cast(pl.Float64, strict=False),
pl.col(lon_col_act).cast(pl.Float64, strict=False),
])
lats = df_poi[lat_col_act].to_numpy()
lons = df_poi[lon_col_act].to_numpy()
# Filter Riyadh bbox
mask = (lats > 23.5) & (lats < 26.0) & (lons > 45.5) & (lons < 48.0)
return np.column_stack([lats[mask], lons[mask]])
QOL_POIS = {
"mosque": (RAW / "riyadh_mosques.csv", "lat", "lon"),
"mall": (RAW / "riyadh_malls.csv", "lat", "lon"),
"school": (RAW / "riyadh_schools.csv", "lat", "lon"),
"hospital": (RAW / "riyadh_hospitals.csv", "lat", "lon"),
"park": (RAW / "riyadh_parks.csv", "lat", "lon"),
"entertain": (RAW / "rcrc_entertainment.csv", "lat", "lon"),
# ── New QoL POIs (OSM, May 2026) ──────────────────────────
"pharmacy": (RAW / "riyadh_qol_pharmacies.csv", "lat", "lon"),
"gym": (RAW / "riyadh_qol_gyms.csv", "lat", "lon"),
"coffee": (RAW / "riyadh_qol_coffee_shops.csv", "lat", "lon"),
"clinic": (RAW / "riyadh_qol_clinics.csv", "lat", "lon"),
"university": (RAW / "riyadh_qol_universities.csv", "lat", "lon"),
"supermarket": (RAW / "riyadh_qol_supermarkets.csv", "lat", "lon"),
"cinema": (RAW / "riyadh_qol_cinemas.csv", "lat", "lon"),
"sports": (RAW / "riyadh_qol_sports_centres.csv","lat", "lon"),
# ── Additional QoL POIs (OSM batch 2, May 2026) ───────────
"restaurant": (RAW / "riyadh_qol_restaurants.csv", "lat", "lon"),
"pool": (RAW / "riyadh_qol_swimming_pools.csv","lat", "lon"),
"atm": (RAW / "riyadh_qol_atms.csv", "lat", "lon"),
"stadium": (RAW / "riyadh_qol_stadiums.csv", "lat", "lon"),
"library": (RAW / "riyadh_qol_libraries.csv", "lat", "lon"),
"police": (RAW / "riyadh_qol_police.csv", "lat", "lon"),
}
qol_trees: dict[str, cKDTree] = {}
qol_balls: dict[str, BallTree] = {}
qol_counts: dict[str, int] = {}
for poi_name, (poi_path, lat_col, lon_col) in QOL_POIS.items():
if not poi_path.exists():
print(f" {poi_name}: NOT FOUND — skipping")
continue
arr = _load_csv_coords(poi_path, lat_col=lat_col, lon_col=lon_col)
if len(arr) < 2:
print(f" {poi_name}: too few points — skipping")
continue
qol_trees[poi_name] = cKDTree(arr)
qol_balls[poi_name] = BallTree(np.radians(arr), metric="haversine")
qol_counts[poi_name] = len(arr)
print(f" {poi_name}: {len(arr)} points")
new_cols = []
for poi_name, tree in qol_trees.items():
d_deg, _ = tree.query(query_pts, k=1)
dist_m = d_deg.ravel() * DEG2M
new_cols.append(pl.Series(f"dist_{poi_name}_m", dist_m))
new_cols.append(pl.Series(f"log_dist_{poi_name}_m", np.log1p(dist_m)))
# 500m density count
ball = qol_balls[poi_name]
cnt = ball.query_radius(np.radians(query_pts), r=r_500, count_only=True)
new_cols.append(pl.Series(f"{poi_name}_count_500m", cnt.astype(np.int32)))
if new_cols:
df = df.with_columns(new_cols)
# ── Step 7: Air quality features ──────────────────────────────────────────────
print("Step 7 — Air quality features...")
AQ_STATION_COORDS = {
"At-Taawun": (24.762272, 46.650878),
"Al-Muruj": (24.758315, 46.671171),
"Al-Jazeera": (24.700139, 46.678500),
"Al-Uraija": (24.685105, 46.703063),
"Al-Khalidiya":(24.766047, 46.761886),
"Ar-Rawabi": (24.751314, 46.868278),
"Ad-Dhubbat": (24.723857, 46.756673),
"Al-Ghurabi": (24.648444, 46.721056),
"Al-Khaleej": (24.598469, 46.744378),
}
aq_raw = pl.read_csv(RAW / "air-quality.csv", separator=";", encoding="utf8-lossy",
null_values=["NULL", "null", ""])
aq_avg = (
aq_raw
.filter(pl.col("Indicator") == "Avg / Hourly")
.filter(pl.col("Component").is_in(["NO2", "SO2", "PM10", "O3"]))
.group_by(["Station", "Component"])
.agg(pl.col("Value").cast(pl.Float64, strict=False).mean().alias("mean_val"))
.pivot(on="Component", index="Station", values="mean_val")
)
# Build station coordinate arrays
valid_stations = [s for s in aq_avg["Station"].to_list() if s in AQ_STATION_COORDS]
aq_lats = np.array([AQ_STATION_COORDS[s][0] for s in valid_stations])
aq_lons = np.array([AQ_STATION_COORDS[s][1] for s in valid_stations])
aq_coords = np.column_stack([aq_lats, aq_lons])
aq_tree = cKDTree(aq_coords)
components = [c for c in ["NO2", "SO2", "PM10", "O3"] if c in aq_avg.columns]
# Station → component value dicts
aq_vals: dict[str, dict] = {}
for row in aq_avg.iter_rows(named=True):
st = row["Station"]
if st in AQ_STATION_COORDS:
aq_vals[st] = {comp: (row.get(comp) or 0.0) for comp in components}
# IDW from 2 nearest stations
for comp in components:
station_comp = np.array([aq_vals.get(s, {}).get(comp, 0.0) for s in valid_stations])
comp_vals = np.zeros(len(df))
for i, (lat, lon) in enumerate(zip(query_lats, query_lons)):
d, idx = aq_tree.query([[lat, lon]], k=min(2, len(aq_coords)))
dm = d.ravel() * DEG2M
dm = np.where(dm < 1, 1, dm)
w = 1.0 / dm
comp_vals[i] = float(np.average(station_comp[idx.ravel()], weights=w))
df = df.with_columns(pl.Series(f"{comp.lower()}_nearest_mean", comp_vals))
d_aq_deg, _ = aq_tree.query(query_pts, k=1)
df = df.with_columns(pl.Series("dist_air_station_m", d_aq_deg.ravel() * DEG2M))
# Composite air quality score (higher = cleaner)
poll_cols = [f"{c.lower()}_nearest_mean" for c in components]
poll_matrix = df.select(poll_cols).to_numpy()
scaler = MinMaxScaler()
scaled = scaler.fit_transform(poll_matrix)
df = df.with_columns(pl.Series("air_quality_score", 100 - scaled.mean(axis=1) * 100))
print(f" AQ stations: {len(valid_stations)} | components: {components}")
# ── Step 8: Real estate price index ───────────────────────────────────────────
print("Step 8 — Real estate price index...")
rei_raw = pl.read_csv(RAW / "real-estate-price-index-by-sector-2023-100.csv",
separator=";", encoding="utf8-lossy", null_values=["NULL","null",""])
rei_res = (
rei_raw
.filter(pl.col("Periodicity") == "Quarterly")
.filter(pl.col("Sector") == "Residential: Total")
.with_columns([
pl.col("Year").cast(pl.Int64, strict=False).alias("Year"),
pl.col("Quarter").str.extract(r"Q(\d)", 1).cast(pl.Int64, strict=False).alias("qnum"),
pl.col("value").cast(pl.Float64, strict=False).alias("rei_residential_qtr_idx"),
])
.with_columns((pl.col("Year") * 10 + pl.col("qnum")).alias("quarter_id"))
.sort("quarter_id")
)
rei_apt = (
rei_raw
.filter(pl.col("Periodicity") == "Quarterly")
.filter(pl.col("Sector") == "Residential: Apartment")
.with_columns([
pl.col("Year").cast(pl.Int64, strict=False).alias("Year"),
pl.col("Quarter").str.extract(r"Q(\d)", 1).cast(pl.Int64, strict=False).alias("qnum"),
pl.col("value").cast(pl.Float64, strict=False).alias("rei_apt_idx"),
])
.with_columns((pl.col("Year") * 10 + pl.col("qnum")).alias("quarter_id"))
)
# Build lookup dicts
rei_idx_lookup: dict[int, float] = {}
rei_yoy_lookup: dict[int, float] = {}
rei_qoq_lookup: dict[int, float] = {}
qids = rei_res["quarter_id"].to_list()
idxs_ = rei_res["rei_residential_qtr_idx"].to_list()
for i, (qid, val) in enumerate(zip(qids, idxs_)):
rei_idx_lookup[qid] = val
yoy = (val / idxs_[i - 4] - 1) if i >= 4 else 0.0
qoq = (val / idxs_[i - 1] - 1) if i >= 1 else 0.0
rei_yoy_lookup[qid] = yoy
rei_qoq_lookup[qid] = qoq
rei_apt_lookup: dict[int, float] = dict(
zip(rei_apt["quarter_id"].to_list(), rei_apt["rei_apt_idx"].to_list())
)
available_qids = sorted(rei_idx_lookup.keys())
def _closest_qid(qid: int) -> int:
return min(available_qids, key=lambda x: abs(x - qid)) if available_qids else qid
tx_qids = df["quarter_id"].to_list()
df = df.with_columns([
pl.Series("rei_residential_qtr_idx",
[rei_idx_lookup.get(q, rei_idx_lookup.get(_closest_qid(q), 100.0)) for q in tx_qids]),
pl.Series("rei_yoy_change",
[rei_yoy_lookup.get(q, rei_yoy_lookup.get(_closest_qid(q), 0.0)) or 0.0 for q in tx_qids]),
pl.Series("rei_qoq_change",
[rei_qoq_lookup.get(q, rei_qoq_lookup.get(_closest_qid(q), 0.0)) or 0.0 for q in tx_qids]),
pl.Series("rei_apt_idx",
[rei_apt_lookup.get(q, rei_idx_lookup.get(q, rei_idx_lookup.get(_closest_qid(q), 100.0)))
for q in tx_qids]),
])
print(f" REI quarters: {sorted(rei_idx_lookup.keys())}")
# ── Step 9: Salary macro features ─────────────────────────────────────────────
print("Step 9 — Salary macro features...")
sal_raw = pl.read_csv(
RAW / "average-salaries-in-the-private-sector-by-main-profession-nationality-and-gende0.csv",
separator=";", encoding="utf8-lossy", null_values=["NULL", "null", ""]
)
saudi_sal = (
sal_raw
.filter(pl.col("Nationality") == "Saudis")
.group_by("Year")
.agg(pl.col("Average Salary").cast(pl.Float64, strict=False).mean().alias("avg_saudi_salary_yr"))
.sort("Year")
)
sal_years = saudi_sal["Year"].to_list()
sal_values = saudi_sal["avg_saudi_salary_yr"].to_list()
sal_dict = dict(zip(sal_years, sal_values))
sal_yoy = {y: (sal_dict[y] / sal_dict[sal_years[i - 1]] - 1) if i > 0 else 0.0
for i, y in enumerate(sal_years)}
def _closest_sal_year(yr: int) -> int:
return min(sal_years, key=lambda y: abs(y - yr))
tx_years = df["year"].to_list()
df = df.with_columns([
pl.Series("avg_saudi_salary_yr",
[sal_dict.get(y, sal_dict.get(_closest_sal_year(y), 0.0)) for y in tx_years]),
pl.Series("salary_yoy_change",
[sal_yoy.get(y, sal_yoy.get(_closest_sal_year(y), 0.0)) for y in tx_years]),
])
print(f" Salary years: {sal_years}")
# ── Step 10: District aggregate features + target encoding ────────────────────
print("Step 10 — District aggregate features...")
# Time-based 80/20 work/hold split to avoid leakage
all_qids_sorted = sorted(df["quarter_id"].unique().to_list())
holdout_cutoff = all_qids_sorted[int(len(all_qids_sorted) * 0.80)]
work_df = df.filter(pl.col("quarter_id") < holdout_cutoff)
# District median price & volume
district_stats = (
work_df
.group_by("district_ar")
.agg([
pl.col("sale_price_sar_sqm").median().alias("district_median_price_sqm"),
pl.col("sale_price_sar_sqm").len().alias("district_transaction_volume"),
])
)
city_median = float(work_df["sale_price_sar_sqm"].median())
district_stats = district_stats.with_columns(
(pl.col("district_median_price_sqm") / city_median - 1.0).alias("district_price_vs_city_avg")
)
# Price trend slope per district (OLS)
trend_rows = []
for dist, grp in work_df.group_by("district_ar"):
grp_pl = grp.sort("quarter_id")
if len(grp_pl) < 3:
trend_rows.append({"district_ar": dist[0], "district_price_trend_slope": 0.0})
continue
try:
slope, *_ = linregress(grp_pl["quarter_id"].to_numpy(),
grp_pl["sale_price_sar_sqm"].to_numpy())
trend_rows.append({"district_ar": dist[0], "district_price_trend_slope": float(slope)})
except Exception:
trend_rows.append({"district_ar": dist[0], "district_price_trend_slope": 0.0})
trend_pl = pl.DataFrame(trend_rows)
# Apartment-specific district median
apt_median = (
work_df
.filter(pl.col("is_apartment") == 1)
.group_by("district_ar")
.agg(pl.col("sale_price_sar_sqm").median().alias("district_median_price_apt_sqm"))
)
# Target encoding: district mean log-price (work set only)
global_mean_log = float(np.log1p(work_df["sale_price_sar_sqm"].to_numpy()).mean())
work_log = work_df.with_columns(
pl.col("sale_price_sar_sqm").log1p().alias("_log_price")
)
district_enc = (
work_log
.group_by("district_ar")
.agg(pl.col("_log_price").mean().alias("district_encoded"))
)
district_type_enc = (
work_log
.group_by(["district_ar", "typecategoryar"])
.agg(pl.col("_log_price").mean().alias("district_type_encoded"))
)
# Apartment-specific district encoding (filters to is_apartment==1)
district_apt_enc = (
work_log
.filter(pl.col("is_apartment") == 1)
.group_by("district_ar")
.agg(pl.col("_log_price").mean().alias("district_apt_encoded"))
)
# Recent-period district encoding (2023+)
district_recent_enc = (
work_log
.filter(pl.col("year") >= 2023)
.group_by("district_ar")
.agg(pl.col("_log_price").mean().alias("district_recent_encoded"))
)
# Apartment + recent (2023+) district encoding
district_apt_recent_enc = (
work_log
.filter((pl.col("is_apartment") == 1) & (pl.col("year") >= 2023))
.group_by("district_ar")
.agg(pl.col("_log_price").mean().alias("district_apt_recent_encoded"))
)
# Join all district stats into main df
df = (
df
.join(district_stats.select(["district_ar", "district_median_price_sqm",
"district_transaction_volume", "district_price_vs_city_avg"]),
on="district_ar", how="left")
.join(trend_pl, on="district_ar", how="left")
.join(apt_median, on="district_ar", how="left")
.join(district_enc, on="district_ar", how="left")
.join(district_type_enc, on=["district_ar", "typecategoryar"], how="left")
.join(district_apt_enc, on="district_ar", how="left")
.join(district_recent_enc, on="district_ar", how="left")
.join(district_apt_recent_enc, on="district_ar", how="left")
)
# Fill nulls in aggregate columns with medians/global mean
agg_fill_cols = ["district_median_price_sqm", "district_transaction_volume",
"district_price_vs_city_avg", "district_price_trend_slope",
"district_median_price_apt_sqm"]
for col in agg_fill_cols:
med = df[col].median()
df = df.with_columns(pl.col(col).fill_null(med if med is not None else 0.0))
df = df.with_columns([
pl.col("district_encoded").fill_null(global_mean_log),
pl.col("district_type_encoded").fill_null(global_mean_log),
pl.col("district_apt_encoded").fill_null(global_mean_log),
pl.col("district_recent_encoded").fill_null(global_mean_log),
pl.col("district_apt_recent_encoded").fill_null(global_mean_log),
])
# ── Riyadh Connectivity Score ─────────────────────────────────────────────────
print("Building Riyadh connectivity score...")
conn_raw = np.column_stack([
df["metro_stations_1km"].to_numpy().astype(float),
df["commercial_count_1km"].to_numpy().astype(float),
df["bus_stops_500m"].to_numpy().astype(float),
1.0 / np.maximum(df["dist_metro_m"].to_numpy(), 1.0),
df["intersections_1km"].to_numpy().astype(float),
])
conn_scaler = MinMaxScaler()
conn_scaled = conn_scaler.fit_transform(np.nan_to_num(conn_raw))
WEIGHTS = np.array([0.30, 0.25, 0.20, 0.15, 0.10])
df = df.with_columns(
pl.Series("riyadh_connectivity_score", (conn_scaled * WEIGHTS).sum(axis=1) * 100)
)
# Save scaler params
conn_feat_names = ["metro_stations_1km", "commercial_count_1km", "bus_stops_500m",
"inv_dist_metro", "intersections_1km"]
conn_scaler_params = {
"feature_names": conn_feat_names,
"weights": WEIGHTS.tolist(),
"data_min_": conn_scaler.data_min_.tolist(),
"data_max_": conn_scaler.data_max_.tolist(),
}
meta_path = Path("models/riyadh_meta.json")
meta_path.parent.mkdir(exist_ok=True)
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
meta.update({
"riyadh_connectivity_scaler": conn_scaler_params,
"global_mean_log": global_mean_log,
"city_median_price_sqm": city_median,
"holdout_cutoff_quarter_id": int(holdout_cutoff),
})
meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False))
print(f" Connectivity scaler saved to {meta_path}")
# ── Final output ──────────────────────────────────────────────────────────────
print("Writing output...")
ID_COLS = ["district_ar", "district_lat", "district_lon",
"year", "quarter", "quarter_id",
"typecategoryar", "deed_counts", "region_ar", "city_ar"]
TARGET = ["sale_price_sar_sqm"]
TYPE_COLS= ["is_apartment", "is_villa", "is_residential_plot", "is_building"]
METRO_COLS = ["dist_metro_m", "log_dist_metro_m", "metro_stations_1km",
"nearest_metro_line_num", "nearest_metro_type_cd", "dist_metro_line1_m"]
BUS_COLS = ["dist_bus_m", "log_dist_bus_m", "bus_stops_500m", "brt_stops_500m"]
INT_COLS = ["dist_major_intersection_m", "log_dist_intersection_m",
"intersections_1km", "intersections_500m"]
COMM_COLS = (["commercial_count_1km", "commercial_density_score",
"district_commercial_count", "district_commercial_mix"]
+ [f"{b}_count_1km" for b in COMMERCIAL_BUCKETS])
QOL_COLS = [f"dist_{n}_m" for n in qol_trees] + [f"log_dist_{n}_m" for n in qol_trees] + [f"{n}_count_500m" for n in qol_trees]
AIR_COLS = [f"{c.lower()}_nearest_mean" for c in components] + ["dist_air_station_m", "air_quality_score"]
MACRO_COLS = ["rei_residential_qtr_idx", "rei_apt_idx", "rei_yoy_change", "rei_qoq_change",
"avg_saudi_salary_yr", "salary_yoy_change"]
DIST_COLS = ["district_median_price_sqm", "district_transaction_volume",
"district_price_vs_city_avg", "district_price_trend_slope",
"district_median_price_apt_sqm", "district_encoded", "district_type_encoded",
"district_apt_encoded", "district_recent_encoded", "district_apt_recent_encoded"]
TIME_COLS = ["sale_year", "sale_quarter", "sale_quarter_sin", "sale_quarter_cos", "log_deed_count"]
SCORE_COLS = ["riyadh_connectivity_score"]
HARAJ_COLS = ["haraj_listing_count", "haraj_median_psqm", "haraj_p25_psqm",
"haraj_p75_psqm", "haraj_iqr_psqm", "haraj_asking_premium"]
# ── Step 11 — Haraj asking-price features ──────────────────────────────────
haraj_path = RAW / "haraj_riyadh_district_agg.csv"
if haraj_path.exists():
print("Step 11 — Haraj asking-price features...")
import pandas as _pd, numpy as _np
haraj = _pd.read_csv(haraj_path)
# Compute asking premium vs district median transaction price
# Use district_median_price_sqm from REI/aggregate step already in df
df_pd = df.to_pandas()
dist_med = df_pd.groupby("district_ar")["sale_price_sar_sqm"].median().reset_index()
dist_med.columns = ["district_ar", "_tx_median_psqm"]
haraj = haraj.merge(dist_med, on="district_ar", how="left")
haraj["haraj_asking_premium"] = _np.where(
haraj["_tx_median_psqm"] > 0,
haraj["haraj_median_psqm"] / haraj["_tx_median_psqm"],
_np.nan
)
keep_haraj = ["district_ar"] + [c for c in HARAJ_COLS if c in haraj.columns]
haraj = haraj[keep_haraj]
df_pd = df_pd.merge(haraj, on="district_ar", how="left")
import polars as _pl
df = _pl.from_pandas(df_pd)
matched = df["haraj_median_psqm"].drop_nulls().len()
print(f" Haraj matched rows: {matched} / {len(df)}")
else:
print("Step 11 — Haraj file not found, skipping.")
HARAJ_COLS = []
all_cols = (ID_COLS + TARGET + TYPE_COLS + METRO_COLS + BUS_COLS
+ INT_COLS + COMM_COLS + QOL_COLS + AIR_COLS + MACRO_COLS + DIST_COLS
+ TIME_COLS + SCORE_COLS + HARAJ_COLS)
final_cols = [c for c in all_cols if c in df.columns]
out = df.select(final_cols)
out_path = PROCESSED / "features_riyadh.csv"
out.write_csv(out_path)
print(f"\n{'='*60}")
print(f"Output: {out_path}")
print(f" Rows: {len(out)}")
print(f" Cols: {len(out.columns)}")
print(f" Districts: {out['district_ar'].n_unique()}")
print(f" Years: {sorted(out['year'].unique().to_list())}")
price_ser = out["sale_price_sar_sqm"]
print(f" Price range: {price_ser.min():.0f}{price_ser.max():.0f} SAR/sqm")
print(f" Median price: {price_ser.median():.0f} SAR/sqm")
# NaN audit
total = len(out)
bad = [(c, out[c].null_count() / total) for c in out.columns if out[c].null_count() / total > 0.20]
if bad:
print("\nWARNING — columns with >20% null:")
for col, pct in bad:
print(f" {col}: {pct:.1%}")
else:
print("\nAll columns < 20% null — OK")
print("=" * 60)