Spaces:
Running
Running
File size: 39,135 Bytes
eb7e023 21c2d9b eb7e023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 | """
THAMAN Riyadh Model β Stack Training v2
========================================
Extends v1 with 6 Bayut listing features (per-district asking price signals):
bayut_listing_count, bayut_median_psqm, bayut_p25_psqm,
bayut_p75_psqm, bayut_iqr_psqm, bayut_asking_premium
Run: python scripts/enrich_riyadh_bayut.py # generates features_riyadh_v2.csv
python training/train_stack_riyadh_v2.py
"""
import json
import os
import sys
import pickle
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.stats import linregress
from scipy.spatial import cKDTree as _cKDTree
from sklearn.linear_model import Ridge
from sklearn.model_selection import GroupKFold
from sklearn.metrics import r2_score
import xgboost as xgb
import lightgbm as lgb
import catboost as cb
warnings.filterwarnings("ignore")
BASE = Path(__file__).parent.parent
PROC = BASE / "data" / "processed"
MDIR = BASE / "models"
MDIR.mkdir(exist_ok=True)
# ββ Feature list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FEATURES = [
# Location
"district_lat", "district_lon",
# Property type
"is_apartment", "is_villa", "is_residential_plot", "is_building",
# Metro transit (old slot β dist_metro_m/log/bus_stops_500m handled by v10 block below)
"metro_stations_1km",
"nearest_metro_line_num", "nearest_metro_type_cd", "dist_metro_line1_m",
# Bus (old slot β bus_stops_500m in v10 block; keep dist_bus_m if present)
"dist_bus_m", "log_dist_bus_m", "brt_stops_500m",
# Traffic
"dist_major_intersection_m", "log_dist_intersection_m",
"intersections_1km", "intersections_500m",
# Commercial
"commercial_count_1km", "commercial_density_score",
"district_commercial_count", "district_commercial_mix",
"hypermarket_count_1km", "supermarket_count_1km",
"bank_count_1km", "restaurant_count_1km",
"hotel_count_1km", "gas_station_count_1km",
# Air quality
"no2_nearest_mean", "so2_nearest_mean", "pm10_nearest_mean",
"o3_nearest_mean", "dist_air_station_m", "air_quality_score",
# Macro / price index
"rei_residential_qtr_idx", "rei_apt_idx",
"rei_yoy_change", "rei_qoq_change",
"rei_type_idx", # v8: type-matched REI (aptβApt, villaβVilla, plotβPlot, bldgβFloor)
# Geographic hub distances (v9) β explicit hub proximity (lat/lon alone too coarse for trees)
"dist_kafd_m", "log_dist_kafd_m", # KAFD (premium zone, corr=-0.517)
"dist_old_city_m", "log_dist_old_city_m", # historic core
"dist_industrial_m", "log_dist_industrial_m", # industrial zone (negative premium)
"dist_airport_m", "log_dist_airport_m", # airport proximity
# Riyadh Metro (v10) β 94 stations, 6 lines, opened Dec 2024 (Vision 2030)
"dist_metro_m", "log_dist_metro_m", # nearest metro station
"metro_500m", "metro_1km", # walkable / near metro
"nearest_metro_line", # line ordinal 1-6 (Blue=1 β¦ Purple=6)
"bus_stops_500m", # bus stop density (transit richness)
"avg_saudi_salary_yr", "salary_yoy_change",
# District aggregates
"district_median_price_sqm", "district_transaction_volume",
"district_price_vs_city_avg", "district_price_trend_slope",
"district_median_price_apt_sqm",
# Target-encoded
"district_encoded", "district_type_encoded",
"district_apt_encoded", "district_recent_encoded", "district_apt_recent_encoded",
# Time
"sale_year", "sale_quarter_sin", "sale_quarter_cos",
"log_deed_count",
# QoL POI proximity & density (mosques, malls, schools, hospitals, parks, entertainment)
"dist_mosque_m", "log_dist_mosque_m", "mosque_count_500m",
"dist_mall_m", "log_dist_mall_m", "mall_count_500m",
"dist_school_m", "log_dist_school_m", "school_count_500m",
"dist_hospital_m", "log_dist_hospital_m", "hospital_count_500m",
"dist_park_m", "log_dist_park_m", "park_count_500m",
"dist_entertain_m", "log_dist_entertain_m", "entertain_count_500m",
# Connectivity
"riyadh_connectivity_score",
# Structural (SA_Aqar rental listings β district-level medians)
"aqar_median_size_sqm",
"aqar_median_bedrooms",
"aqar_median_property_age",
"aqar_rent_per_sqm",
# Bayut listing signals (current asking prices β market demand/heat indicators)
"bayut_listing_count",
"bayut_median_psqm",
"bayut_p25_psqm",
"bayut_p75_psqm",
"bayut_iqr_psqm",
"bayut_asking_premium",
# Bayut structural (apt/plot/villa medians)
"bayut_apt_median_psqm",
"bayut_plot_median_psqm",
"bayut_villa_median_psqm",
"bayut_apt_median_area_sqm",
"bayut_plot_median_area_sqm",
"bayut_villa_median_area_sqm",
"bayut_apt_median_rooms",
"bayut_plot_median_rooms",
"bayut_villa_median_rooms",
# Haraj structural (type-stratified area + age β v12)
"haraj_apt_area_sqm",
"haraj_plot_area_sqm",
"haraj_villa_area_sqm",
"haraj_apt_age_yr",
"haraj_plot_age_yr",
"haraj_villa_age_yr",
# New QoL POIs (OSM, May 2026)
"dist_pharmacy_m", "log_dist_pharmacy_m", "pharmacy_count_500m",
"dist_gym_m", "log_dist_gym_m", "gym_count_500m",
"dist_coffee_m", "log_dist_coffee_m", "coffee_count_500m",
"dist_clinic_m", "log_dist_clinic_m", "clinic_count_500m",
"dist_university_m", "log_dist_university_m", "university_count_500m",
"dist_supermarket_m", "log_dist_supermarket_m", "supermarket_count_500m",
"dist_cinema_m", "log_dist_cinema_m", "cinema_count_500m",
"dist_sports_m", "log_dist_sports_m", "sports_count_500m",
# Haraj listing signals (corr=0.735 with transaction prices)
"haraj_listing_count",
"haraj_median_psqm",
"haraj_p25_psqm",
"haraj_p75_psqm",
"haraj_iqr_psqm",
"haraj_asking_premium",
# District temporal lag features (v4 β computed in training script)
"district_lag1q_median_psqm", # district median price last quarter
"district_lag2q_median_psqm", # district median price 2 quarters ago
"district_lag_momentum", # lag1 β lag2 (price trend direction)
# v11: type-stratified lags + data-density signal
"district_type_lag1q_psqm", # lag-1q median per district Γ property_type
"district_type_lag2q_psqm", # lag-2q median per district Γ property_type
"district_lag1q_std_psqm", # lag-1q price std dev (volatility / sparse-market signal)
"log_suhail_n_trans", # log count of individual suhail txns in district-quarter
]
TARGET = "sale_price_sar_sqm"
GROUP_COL = "district_ar"
# ββ Hyperparameters βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
XGB_PARAMS = dict(
n_estimators=1500, learning_rate=0.03, max_depth=5,
min_child_weight=5, subsample=0.7, colsample_bytree=0.7,
gamma=0.2, reg_alpha=0.5, reg_lambda=2.0,
tree_method="hist", random_state=42,
)
LGB_PARAMS = dict(
n_estimators=1500, learning_rate=0.03, max_depth=5,
min_child_samples=10, subsample=0.7, colsample_bytree=0.7,
min_split_gain=0.2, reg_alpha=0.5, reg_lambda=2.0,
random_state=42, verbose=-1,
)
CAT_PARAMS = dict(
iterations=1500, learning_rate=0.03, depth=5,
l2_leaf_reg=2.0, random_strength=0.2, bagging_temperature=0.5,
random_seed=42, verbose=0,
)
def medape(y_true, y_pred):
return float(np.median(np.abs((np.expm1(y_pred) - np.expm1(y_true)) / np.expm1(y_true))) * 100)
def mae_sar(y_true, y_pred):
return float(np.mean(np.abs(np.expm1(y_pred) - np.expm1(y_true))))
# ββ Load data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
print("Loading features_riyadh_v2.csv...")
df = pd.read_csv(PROC / "features_riyadh_v2.csv", encoding="utf-8-sig")
print(f" Rows: {len(df)} | Cols: {len(df.columns)}")
# ββ Fix 2024 quarter_id encoding bug βββββββββββββββββββββββββββββββββββββββββ
# 2024 rows have quarter stored as full quarter_id (20241/20243/20244) instead of
# quarter number (1/3/4), so quarter_id = year*10 + quarter = 2024*10 + 20241 = 40481.
# Correct: use year*10 + (quarter % 10) for those rows.
bug_mask = df["quarter_id"] >= 40000
if bug_mask.sum() > 0:
df.loc[bug_mask, "quarter_id"] = (
df.loc[bug_mask, "year"] * 10 + df.loc[bug_mask, "quarter"] % 10
).astype(int)
print(f" Fixed {bug_mask.sum()} rows with corrupt quarter_id (2024 data restored to training)")
# ββ Extend with Suhail 2025Q4/2026Q1/Q2 βββββββββββββββββββββββββββββββββββββ
# Individual Suhail transactions cover 2025Q2β2026Q2 but only 2025Q4+ are new.
# Aggregate by districtΓquarterΓtype β append as new training rows.
_suhail_path = BASE / "data" / "raw" / "suhail_riyadh_tx_raw.csv"
if _suhail_path.exists():
import polars as _pl
_type_map = {
"Ψ΄ΩΨ©": "Ψ΄ΩΨ©",
"ΩΩΩΨ§": "ΩΩΩΨ§",
"Ω
Ψ¨ΩΩ Ψ³ΩΩΩ": "ΨΉΩ
Ψ§Ψ±Ψ©",
"ΩΨ·ΨΉΨ© Ψ£Ψ±ΨΆ": "ΩΨ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩΩΩ",
"Ψ£Ψ±ΨΆ ΩΨΆΨ§Ψ‘": "ΩΨ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩΩΩ",
}
_tx = (
_pl.read_csv(_suhail_path, ignore_errors=True)
.filter(_pl.col("province_name") == "Ψ§ΩΨ±ΩΨ§ΨΆ")
.with_columns([
_pl.col("date").str.slice(0, 4).cast(_pl.Int64).alias("year"),
_pl.col("date").str.slice(5, 2).cast(_pl.Int64).alias("month"),
])
.with_columns(((_pl.col("month") - 1) // 3 + 1).alias("quarter"))
.with_columns(_pl.col("property_type").replace(_type_map).alias("typecategoryar"))
.filter(_pl.col("typecategoryar").is_in(list(set(_type_map.values()))))
.filter((_pl.col("psqm") >= 500) & (_pl.col("psqm") <= 50_000))
# Only quarters NOT already in training data (2025Q4, 2026Q1, 2026Q2)
.filter(
((_pl.col("year") == 2025) & (_pl.col("quarter") == 4)) |
(_pl.col("year") == 2026)
)
)
_agg = (
_tx.group_by(["district_ar", "year", "quarter", "typecategoryar"])
.agg([
_pl.col("psqm").median().alias(TARGET),
_pl.len().alias("deed_counts"),
])
.filter(_pl.col("deed_counts") >= 3)
).to_pandas()
if len(_agg) > 0:
# Spatial template: most recent row per district (forward-fill spatial features)
_df_tmpl = (
df.sort_values("quarter_id")
.groupby("district_ar", as_index=False)
.last()
)
# Columns to take from template (all spatial/static; override temporal below)
_temporal_cols = [
TARGET, "sale_year", "sale_quarter", "quarter", "year", "quarter_id",
"deed_counts", "log_deed_count", "sale_quarter_sin", "sale_quarter_cos",
"is_apartment", "is_villa", "is_residential_plot", "is_building",
"typecategoryar", "district_lag1q_median_psqm",
"district_lag2q_median_psqm", "district_lag_momentum",
# v11 (computed post-merge, not from template)
"district_type_lag1q_psqm", "district_type_lag2q_psqm",
"district_lag1q_std_psqm", "log_suhail_n_trans", "_ptype",
]
_spatial_cols = [c for c in _df_tmpl.columns if c not in _temporal_cols]
_ext = _agg.merge(_df_tmpl[_spatial_cols], on="district_ar", how="inner")
# Compute temporal features
_q_arr = _ext["quarter"].values.astype(int)
_ext["sale_year"] = _ext["year"].astype(int)
_ext["sale_quarter"] = _q_arr
_ext["quarter_id"] = _ext["year"].astype(int) * 10 + _q_arr
_ext["sale_quarter_sin"] = np.sin(2 * np.pi * _q_arr / 4)
_ext["sale_quarter_cos"] = np.cos(2 * np.pi * _q_arr / 4)
_ext["log_deed_count"] = np.log1p(_ext["deed_counts"])
_tc = _ext["typecategoryar"]
_ext["is_apartment"] = (_tc == "Ψ΄ΩΨ©").astype(int)
_ext["is_villa"] = (_tc == "ΩΩΩΨ§").astype(int)
_ext["is_residential_plot"]= (_tc == "ΩΨ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩΩΩ").astype(int)
_ext["is_building"] = (_tc == "ΨΉΩ
Ψ§Ψ±Ψ©").astype(int)
n_before = len(df)
df = pd.concat([df, _ext], ignore_index=True)
_new_qs = sorted(_ext[["year","quarter"]].drop_duplicates().apply(
lambda r: f"{int(r.year)}Q{int(r.quarter)}", axis=1).tolist())
print(f" Suhail extension: +{len(df)-n_before} rows | quarters: {_new_qs} | total: {len(df)}")
else:
print(" Suhail extension: no new rows after filter")
else:
print(" Suhail extension: file not found, skipping")
# ββ Refresh haraj features from May 2026 snapshot ββββββββββββββββββββββββββββ
_haraj_new_path = BASE / "data" / "raw" / "saudi_listings_haraj_20260518.csv"
if _haraj_new_path.exists():
import polars as _pl, re as _re
_h = _pl.read_csv(_haraj_new_path, ignore_errors=True)
_arabic_re = _re.compile(r'[Ψ-ΫΏ]')
_h = (_h
.filter(_h["district"].map_elements(lambda x: bool(_arabic_re.search(str(x))), return_dtype=_pl.Boolean))
.filter((_pl.col("price_per_sqm") >= 500) & (_pl.col("price_per_sqm") <= 50_000))
.filter(_pl.col("price_per_sqm").is_not_null())
.rename({"district": "district_ar"})
)
# Overall aggregates (update existing haraj columns)
_h_overall = (
_h.group_by("district_ar")
.agg([
_pl.len().alias("haraj_listing_count"),
_pl.col("price_per_sqm").median().alias("haraj_median_psqm"),
_pl.col("price_per_sqm").quantile(0.25).alias("haraj_p25_psqm"),
_pl.col("price_per_sqm").quantile(0.75).alias("haraj_p75_psqm"),
(_pl.col("price_per_sqm").quantile(0.75) - _pl.col("price_per_sqm").quantile(0.25)).alias("haraj_iqr_psqm"),
])
).to_pandas()
# Type-specific aggregates (new features)
_type_map_h = {"apartment": "haraj_apt_median_psqm",
"villa": "haraj_villa_median_psqm",
"plot": "haraj_plot_median_psqm"}
_h_type_dfs = []
for _ptyp, _col in _type_map_h.items():
_sub = (_h.filter(_pl.col("property_type_en") == _ptyp)
.group_by("district_ar")
.agg(_pl.col("price_per_sqm").median().alias(_col))
.to_pandas())
_h_type_dfs.append(_sub)
# Update overall haraj columns in df via merge
_haraj_update_cols = ["haraj_listing_count","haraj_median_psqm","haraj_p25_psqm","haraj_p75_psqm","haraj_iqr_psqm"]
df = df.drop(columns=[c for c in _haraj_update_cols if c in df.columns])
df = df.merge(_h_overall[["district_ar"] + _haraj_update_cols], on="district_ar", how="left")
# Recompute haraj_asking_premium from updated median
if "district_median_price_sqm" in df.columns:
df["haraj_asking_premium"] = df["haraj_median_psqm"] / df["district_median_price_sqm"].replace(0, np.nan)
# Add type-specific columns
for _sub in _h_type_dfs:
_col = [c for c in _sub.columns if c != "district_ar"][0]
if _col in df.columns:
df = df.drop(columns=[_col])
df = df.merge(_sub, on="district_ar", how="left")
_matched_pct = df["haraj_median_psqm"].notna().mean() * 100
print(f" Haraj refresh (May 2026): {len(_h_overall)} districts | match rate: {_matched_pct:.1f}%")
# Save haraj district lookup for inference (so spatial.py uses same fresh values)
_haraj_lu_src = _h_overall[["district_ar","haraj_listing_count","haraj_median_psqm","haraj_p25_psqm","haraj_p75_psqm","haraj_iqr_psqm"]].copy()
_HARAJ_DISTRICT_LOOKUP = {}
for _, _hrow in _haraj_lu_src.iterrows():
_dname = _hrow["district_ar"]
_dmedian = _hrow["haraj_median_psqm"]
if pd.isna(_dmedian):
continue
_HARAJ_DISTRICT_LOOKUP[_dname] = {
"haraj_listing_count": int(_hrow["haraj_listing_count"]) if not pd.isna(_hrow["haraj_listing_count"]) else None,
"haraj_median_psqm": round(float(_dmedian), 2),
"haraj_p25_psqm": round(float(_hrow["haraj_p25_psqm"]), 2) if not pd.isna(_hrow["haraj_p25_psqm"]) else None,
"haraj_p75_psqm": round(float(_hrow["haraj_p75_psqm"]), 2) if not pd.isna(_hrow["haraj_p75_psqm"]) else None,
"haraj_iqr_psqm": round(float(_hrow["haraj_iqr_psqm"]), 2) if not pd.isna(_hrow["haraj_iqr_psqm"]) else None,
}
else:
print(" Haraj refresh: file not found, skipping")
# ββ Type-specific REI index (v8) βββββββββββββββββββββββββββββββββββββββββββββ
# Existing rei_apt_idx applies apartment index to ALL property types.
# rei_type_idx: correct type-matched REI per row.
_REI_SECTOR_PATH = BASE / "data" / "raw" / "real-estate-price-index-by-sector-2023-100.csv"
if _REI_SECTOR_PATH.exists():
_rei_df = pd.read_csv(_REI_SECTOR_PATH, sep=";")
_rei_df = _rei_df[(_rei_df["Measure"] == "Index") & (_rei_df["Quarter"].str.startswith("Q"))].copy()
_sector_map = {
"Residential: Apartment": "apartment",
"Residential: Villa": "villa",
"Residential: Residential Plot": "residential_plot",
"Residential: Floor": "building",
}
_rei_df = _rei_df[_rei_df["Sector"].isin(_sector_map)].copy()
_rei_df["ptype"] = _rei_df["Sector"].map(_sector_map)
_rei_df["quarter_id"] = (_rei_df["Year"].astype(str) + _rei_df["Quarter"].str[1]).astype(int)
# pivot: quarter_id Γ ptype β index value
_rei_pivot = _rei_df.pivot_table(index="quarter_id", columns="ptype", values="value", aggfunc="first")
# global fallbacks per type (mean of available values)
_rei_glob = _rei_pivot.mean().to_dict()
def _get_rei_type(row):
"""Return type-specific REI for this row's property type and quarter."""
if row["is_apartment"] == 1: pt = "apartment"
elif row["is_villa"] == 1: pt = "villa"
elif row["is_residential_plot"] == 1: pt = "residential_plot"
elif row["is_building"] == 1: pt = "building"
else: pt = "apartment" # fallback
qid = int(row["quarter_id"]) if not pd.isna(row["quarter_id"]) else 0
if qid in _rei_pivot.index and pt in _rei_pivot.columns:
val = _rei_pivot.loc[qid, pt]
if not pd.isna(val):
return float(val)
return float(_rei_glob.get(pt, 100.0))
df["rei_type_idx"] = df.apply(_get_rei_type, axis=1)
print(f" rei_type_idx: {df['rei_type_idx'].nunique()} unique values | "
f"apt={df[df['is_apartment']==1]['rei_type_idx'].mean():.1f} "
f"villa={df[df['is_villa']==1]['rei_type_idx'].mean():.1f} "
f"plot={df[df['is_residential_plot']==1]['rei_type_idx'].mean():.1f}")
else:
df["rei_type_idx"] = 100.0
print(" rei_type_idx: sector REI file not found, defaulting to 100")
# ββ Geographic hub distance features (v9) ββββββββββββββββββββββββββββββββββββ
# Explicit distance to key economic hubs β corr(dist_kafd, psqm)=-0.517
# Trees need many splits to rediscover this from raw lat/lon; explicit = cleaner signal.
_HUBS = {
"kafd": (24.771, 46.637), # King Abdullah Financial District (premium zone)
"old_city": (24.690, 46.722), # Historical Qasr Al-Hokm / Dira (traditional centre)
"industrial": (24.620, 46.873), # Industrial City (eastern industrial zone β negative premium)
"airport": (24.957, 46.699), # King Khalid International Airport
}
_DEG2M_V9 = 111_000.0
for _hub, (_hlat, _hlon) in _HUBS.items():
_col = f"dist_{_hub}_m"
df[_col] = np.sqrt((df["district_lat"] - _hlat)**2 + (df["district_lon"] - _hlon)**2) * _DEG2M_V9
df[f"log_{_col}"] = np.log1p(df[_col])
print(f" Hub distances: {list(_HUBS.keys())} | kafd corr={df['dist_kafd_m'].corr(df[TARGET]):.3f}")
# ββ Riyadh Metro + Bus transit features (v10) ββββββββββββββββββββββββββββββββ
# 94 metro stations (6 lines, opened Dec 2024); 3,010 bus stops
_METRO_GJ = BASE / "data/raw/metro-stations-in-riyadh-by-metro-line-and-station-type-2024.geojson"
_BUS_GJ = BASE / "data/raw/bus-stops-in-riyadh-by-bus-route-direction-and-shelter-type-2024.geojson"
_DEG2M_V10 = 111_000.0
with open(_METRO_GJ) as _f:
_metro_feats = json.load(_f)["features"]
_LINE_ORD = {"Line1": 1, "Line2": 2, "Line3": 3, "Line4": 4, "Line5": 5, "Line6": 6}
_metro_lats = np.array([_s["geometry"]["coordinates"][1] for _s in _metro_feats])
_metro_lons = np.array([_s["geometry"]["coordinates"][0] for _s in _metro_feats])
_metro_lines = np.array([_LINE_ORD.get(_s["properties"]["metro_line_cd"], 0) for _s in _metro_feats])
_metro_pts = np.column_stack([_metro_lats, _metro_lons])
_metro_tree = _cKDTree(_metro_pts)
with open(_BUS_GJ) as _f:
_bus_feats = json.load(_f)["features"]
_bus_pts = np.array([[_b["geometry"]["coordinates"][1], _b["geometry"]["coordinates"][0]]
for _b in _bus_feats])
_bus_tree = _cKDTree(_bus_pts)
_500m_deg = 500.0 / _DEG2M_V10
# Per unique district centroid β map back to df rows
_dist_metro = {}
_dist_keys = df[["district_ar", "district_lat", "district_lon"]].drop_duplicates()
for _, _row in _dist_keys.iterrows():
_pt = np.array([[_row["district_lat"], _row["district_lon"]]])
_dd, _di = _metro_tree.query(_pt, k=1)
_dm = float(_dd[0]) * _DEG2M_V10
_line = int(_metro_lines[_di[0]])
_nb = int(_bus_tree.query_ball_point(_pt[0], r=_500m_deg, return_length=True))
_dist_metro[_row["district_ar"]] = (_dm, _line, _nb)
df["dist_metro_m"] = df["district_ar"].map(lambda d: _dist_metro.get(d, (np.nan, 0, 0))[0])
df["log_dist_metro_m"] = np.log1p(df["dist_metro_m"].fillna(df["dist_metro_m"].median()))
df["metro_500m"] = (df["dist_metro_m"] < 500).astype(int)
df["metro_1km"] = (df["dist_metro_m"] < 1000).astype(int)
df["nearest_metro_line"] = df["district_ar"].map(lambda d: _dist_metro.get(d, (np.nan, 0, 0))[1])
df["bus_stops_500m"] = df["district_ar"].map(lambda d: _dist_metro.get(d, (np.nan, 0, 0))[2])
_metro_corr = df["dist_metro_m"].corr(df[TARGET])
_m500 = df["metro_500m"].mean() * 100
_m1km = df["metro_1km"].mean() * 100
print(f" Metro: 94 stations | dist_metro corr={_metro_corr:.3f} | "
f"metro_500m={_m500:.1f}% | metro_1km={_m1km:.1f}% | bus_stops_500m median={df['bus_stops_500m'].median():.0f}")
# ββ District temporal lag features βββββββββββββββββββββββββββββββββββββββββββ
# Compute district median price per quarter, then lag-1 and momentum.
# Computed pre-split so all quarters are available, but each row only sees past quarters.
_qseq = sorted(df["quarter_id"].unique())
_qprev = {q: _qseq[i - 1] for i, q in enumerate(_qseq) if i > 0} # q β q-1 map
_dist_qmed = (
df.groupby(["district_ar", "quarter_id"])[TARGET]
.median()
.rename("_dq_med")
.reset_index()
)
_dist_qmed.columns = ["district_ar", "quarter_id", "_dq_med"]
def _lag_med(row):
pq = _qprev.get(row["quarter_id"])
if pq is None:
return np.nan
match = _dist_qmed[(
_dist_qmed["district_ar"] == row["district_ar"]) &
(_dist_qmed["quarter_id"] == pq)]["_dq_med"]
return float(match.iloc[0]) if len(match) else np.nan
def _lag2_med(row):
pq = _qprev.get(row["quarter_id"])
ppq = _qprev.get(pq) if pq else None
if ppq is None:
return np.nan
match = _dist_qmed[(
_dist_qmed["district_ar"] == row["district_ar"]) &
(_dist_qmed["quarter_id"] == ppq)]["_dq_med"]
return float(match.iloc[0]) if len(match) else np.nan
print(" Computing district lag features...")
# Vectorised merge is faster than row-apply
_lag1_df = _dist_qmed.copy()
_lag1_df["quarter_id"] = _lag1_df["quarter_id"].map(
{v: k for k, v in _qprev.items()}) # shift: value becomes next quarter's lag
_lag1_df = _lag1_df.dropna(subset=["quarter_id"]).rename(columns={"_dq_med": "district_lag1q_median_psqm"})
_lag2_df = _dist_qmed.copy()
_qprev2 = {q: _qseq[i - 2] for i, q in enumerate(_qseq) if i > 1}
_lag2_df["quarter_id"] = _lag2_df["quarter_id"].map(
{v: k for k, v in _qprev2.items()})
_lag2_df = _lag2_df.dropna(subset=["quarter_id"]).rename(columns={"_dq_med": "district_lag2q_median_psqm"})
df = df.merge(_lag1_df[["district_ar","quarter_id","district_lag1q_median_psqm"]],
on=["district_ar","quarter_id"], how="left")
df = df.merge(_lag2_df[["district_ar","quarter_id","district_lag2q_median_psqm"]],
on=["district_ar","quarter_id"], how="left")
df["district_lag_momentum"] = df["district_lag1q_median_psqm"] - df["district_lag2q_median_psqm"]
lag_fill_pct = df["district_lag1q_median_psqm"].notna().mean() * 100
print(f" Lag-1 fill rate: {lag_fill_pct:.1f}%")
# ββ v11: type-stratified lags + volatility + density βββββββββββββββββββββββββ
print(" Computing v11 features (type-stratified lag + std + suhail density)...")
# Infer property type per row (for grouping)
_type_col = pd.Series("other", index=df.index)
_type_col[df["is_apartment"] == 1] = "apt"
_type_col[df["is_villa"] == 1] = "villa"
_type_col[df["is_residential_plot"] == 1] = "plot"
_type_col[df["is_building"] == 1] = "bldg"
df["_ptype"] = _type_col
# district Γ type Γ quarter β median psqm + std
_dtq_agg = (
df.groupby(["district_ar", "_ptype", "quarter_id"])[TARGET]
.agg(["median", "std"])
.reset_index()
.rename(columns={"median": "_dtq_med", "std": "_dtq_std"})
)
_dtq_dict = {(r["district_ar"], r["_ptype"], r["quarter_id"]): r["_dtq_med"]
for _, r in _dtq_agg.iterrows()}
_dtq_std_dict = {(r["district_ar"], r["_ptype"], r["quarter_id"]): r["_dtq_std"]
for _, r in _dtq_agg.iterrows()}
_city_med_v11 = float(df[TARGET].median())
# lag-1 type-stratified median
def _dtq_lag1(row):
pq = _qprev.get(row["quarter_id"])
if pq is None: return np.nan
return _dtq_dict.get((row["district_ar"], row["_ptype"], pq), np.nan)
def _dtq_lag2(row):
pq = _qprev.get(row["quarter_id"])
ppq = _qprev.get(pq) if pq else None
if ppq is None: return np.nan
return _dtq_dict.get((row["district_ar"], row["_ptype"], ppq), np.nan)
def _dq_lag1_std(row):
pq = _qprev.get(row["quarter_id"])
if pq is None: return np.nan
return _dtq_std_dict.get((row["district_ar"], row["_ptype"], pq), np.nan)
df["district_type_lag1q_psqm"] = df.apply(_dtq_lag1, axis=1)
df["district_type_lag2q_psqm"] = df.apply(_dtq_lag2, axis=1)
df["district_lag1q_std_psqm"] = df.apply(_dq_lag1_std, axis=1)
# Fill NaN with district-level lag or city median
df["district_type_lag1q_psqm"].fillna(df["district_lag1q_median_psqm"].fillna(_city_med_v11), inplace=True)
df["district_type_lag2q_psqm"].fillna(df["district_lag2q_median_psqm"].fillna(_city_med_v11), inplace=True)
df["district_lag1q_std_psqm"].fillna(df["district_lag1q_std_psqm"].median(), inplace=True)
v11_fill = df["district_type_lag1q_psqm"].notna().mean() * 100
print(f" district_type_lag1q fill rate: {v11_fill:.1f}%")
# log_suhail_n_trans β individual suhail transaction count per district-quarter
_suhail_all_path = BASE / "data" / "raw" / "suhail_riyadh_tx_raw.csv"
if _suhail_all_path.exists():
import polars as _pl2
_stx = (
_pl2.read_csv(_suhail_all_path, ignore_errors=True)
.filter(_pl2.col("province_name") == "Ψ§ΩΨ±ΩΨ§ΨΆ")
.with_columns([
_pl2.col("date").str.slice(0, 4).cast(_pl2.Int64).alias("_sy"),
_pl2.col("date").str.slice(5, 2).cast(_pl2.Int64).alias("_sm"),
])
.with_columns((_pl2.col("_sy") * 10 + (_pl2.col("_sm") - 1) // 3 + 1).alias("quarter_id"))
.filter((_pl2.col("psqm") >= 500) & (_pl2.col("psqm") <= 50_000))
.group_by(["district_ar", "quarter_id"])
.agg(_pl2.len().alias("_s_cnt"))
).to_pandas()
_s_dict = {(r["district_ar"], r["quarter_id"]): r["_s_cnt"]
for _, r in _stx.iterrows()}
def _suhail_lag1_cnt(row):
pq = _qprev.get(row["quarter_id"])
if pq is None: return 0
return _s_dict.get((row["district_ar"], pq), 0)
df["log_suhail_n_trans"] = np.log1p(df.apply(_suhail_lag1_cnt, axis=1))
s_fill = (df["log_suhail_n_trans"] > 0).mean() * 100
print(f" log_suhail_n_trans coverage (>0): {s_fill:.1f}%")
else:
df["log_suhail_n_trans"] = 0.0
print(" log_suhail_n_trans: suhail file not found β 0")
df.drop(columns=["_ptype"], inplace=True, errors="ignore")
# Only keep columns that exist
feat_cols = [f for f in FEATURES if f in df.columns]
missing = set(FEATURES) - set(feat_cols)
if missing:
print(f" WARNING: {len(missing)} feature(s) not in CSV: {sorted(missing)}")
print(f" Using {len(feat_cols)} features")
# Log-transform target
df["log_price"] = np.log1p(df[TARGET])
# ββ Train / holdout split (time-based) ββββββββββββββββββββββββββββββββββββββββ
all_qids = sorted(df["quarter_id"].unique())
# Fixed cutoff at 2025 Q1 β trains on 2018-2024, holds out 2025 Q1-Q3.
cutoff_qid = 20251
work_mask = df["quarter_id"] < cutoff_qid
work = df[work_mask].copy()
hold = df[~work_mask].copy()
print(f" Work set: {len(work)} rows (quarter_id < {cutoff_qid})")
print(f" Holdout: {len(hold)} rows (quarter_id >= {cutoff_qid})")
X_work = work[feat_cols].fillna(0).values.astype(np.float32)
y_work = work["log_price"].values
groups = work[GROUP_COL].values
X_hold = hold[feat_cols].fillna(0).values.astype(np.float32)
y_hold = hold["log_price"].values
# ββ 5-fold Spatial GroupKFold cross-validation ββββββββββββββββββββββββββββββββ
N_FOLDS = 5
gkf = GroupKFold(n_splits=N_FOLDS)
oof_xgb = np.zeros(len(work))
oof_lgb = np.zeros(len(work))
oof_cat = np.zeros(len(work))
xgb_models, lgb_models, cat_models = [], [], []
print(f"\nRunning {N_FOLDS}-fold Spatial GroupKFold CV...")
for fold, (tr_idx, va_idx) in enumerate(gkf.split(X_work, y_work, groups)):
X_tr, X_va = X_work[tr_idx], X_work[va_idx]
y_tr, y_va = y_work[tr_idx], y_work[va_idx]
print(f" Fold {fold+1}: train={len(tr_idx)} val={len(va_idx)}", end=" ", flush=True)
# XGBoost
m_xgb = xgb.XGBRegressor(**XGB_PARAMS)
m_xgb.fit(X_tr, y_tr,
eval_set=[(X_va, y_va)],
verbose=False)
oof_xgb[va_idx] = m_xgb.predict(X_va)
xgb_models.append(m_xgb)
# LightGBM
m_lgb = lgb.LGBMRegressor(**LGB_PARAMS)
m_lgb.fit(X_tr, y_tr,
eval_set=[(X_va, y_va)],
callbacks=[lgb.early_stopping(50, verbose=False), lgb.log_evaluation(period=-1)])
oof_lgb[va_idx] = m_lgb.predict(X_va)
lgb_models.append(m_lgb)
# CatBoost
m_cat = cb.CatBoostRegressor(**CAT_PARAMS)
m_cat.fit(X_tr, y_tr,
eval_set=[(X_va, y_va)],
early_stopping_rounds=50)
oof_cat[va_idx] = m_cat.predict(X_va)
cat_models.append(m_cat)
fold_medape = medape(y_va, oof_xgb[va_idx])
print(f"| XGB MedAPE={fold_medape:.1f}%")
# OOF evaluation
oof_stack = np.column_stack([oof_xgb, oof_lgb, oof_cat])
oof_r2 = r2_score(y_work, oof_stack.mean(axis=1))
oof_medape = medape(y_work, oof_stack.mean(axis=1))
print(f"\nOOF (ensemble mean): RΒ²={oof_r2:.4f} | MedAPE={oof_medape:.2f}%")
# ββ Ridge meta-learner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
print("Training Ridge meta-learner...")
meta = Ridge(alpha=1.0)
meta.fit(oof_stack, y_work)
oof_meta = meta.predict(oof_stack)
oof_meta_r2 = r2_score(y_work, oof_meta)
oof_meta_medape = medape(y_work, oof_meta)
print(f"OOF meta: RΒ²={oof_meta_r2:.4f} | MedAPE={oof_meta_medape:.2f}%")
# ββ Retrain final models on full work set βββββββββββββββββββββββββββββββββββββ
print("\nRetraining final models on full work set...")
final_xgb = xgb.XGBRegressor(**XGB_PARAMS)
final_xgb.fit(X_work, y_work, verbose=False)
final_lgb = lgb.LGBMRegressor(**LGB_PARAMS)
final_lgb.fit(X_work, y_work, callbacks=[lgb.log_evaluation(period=-1)])
final_cat = cb.CatBoostRegressor(**CAT_PARAMS)
final_cat.fit(X_work, y_work)
# ββ Holdout evaluation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
print("\nEvaluating on holdout set...")
hold_preds = np.column_stack([
final_xgb.predict(X_hold),
final_lgb.predict(X_hold),
final_cat.predict(X_hold),
])
hold_meta_preds = meta.predict(hold_preds)
hold_r2 = r2_score(y_hold, hold_meta_preds)
hold_medape = medape(y_hold, hold_meta_preds)
hold_mae = mae_sar(y_hold, hold_meta_preds)
print(f" Holdout RΒ²: {hold_r2:.4f}")
print(f" Holdout MedAPE: {hold_medape:.2f}%")
print(f" Holdout MAE: {hold_mae:,.0f} SAR/sqm")
# Per-type metrics
_seg_by_type = {}
for ptype in ["apartment", "villa", "residential_plot", "building"]:
mask_col = f"is_{ptype}"
if mask_col in hold.columns:
hmask = hold[mask_col].values.astype(bool)
if hmask.sum() >= 10:
t_r2 = r2_score(y_hold[hmask], hold_meta_preds[hmask])
t_medape = medape(y_hold[hmask], hold_meta_preds[hmask])
print(f" [{ptype:>18}] RΒ²={t_r2:.4f} | MedAPE={t_medape:.2f}% | n={hmask.sum()}")
_seg_by_type[ptype] = {"r2": round(t_r2, 4), "medape": round(t_medape, 2), "n": int(hmask.sum())}
# Per-row holdout prediction dump β error-analysis artifact
_dump = hold[["district_ar", "quarter_id", "is_apartment", "is_villa",
"is_residential_plot", "is_building", "deed_counts"]].copy()
_dump["y_true_psqm"] = np.expm1(y_hold)
_dump["y_pred_psqm"] = np.expm1(hold_meta_preds)
_dump["ape_pct"] = (np.abs(_dump["y_true_psqm"] - _dump["y_pred_psqm"])
/ _dump["y_true_psqm"] * 100)
_dump_path = PROC / "holdout_preds_riyadh.csv"
_dump.to_csv(_dump_path, index=False, encoding="utf-8-sig")
print(f" Holdout predictions dumped: {_dump_path} ({len(_dump)} rows)")
# ββ Save models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
print("\nSaving models...")
# ββ Build district lag lookup for prediction-time use ββββββββββββββββββββββ
# Use most recent available quarter as lag1, one before as lag2.
_recent_qids = sorted(df["quarter_id"].unique())[-4:] # last 4 available quarters
_lkp = {}
for dist in df["district_ar"].unique():
_ddf = _dist_qmed[_dist_qmed["district_ar"] == dist].sort_values("quarter_id")
if len(_ddf) < 2:
continue
lag1_val = float(_ddf.iloc[-1]["_dq_med"])
lag2_val = float(_ddf.iloc[-2]["_dq_med"]) if len(_ddf) >= 2 else lag1_val
_lkp[dist] = {
"lag1": lag1_val,
"lag2": lag2_val,
"momentum": lag1_val - lag2_val,
}
city_lag1 = float(_dist_qmed.groupby("quarter_id")["_dq_med"].median().iloc[-1])
city_lag2 = float(_dist_qmed.groupby("quarter_id")["_dq_med"].median().iloc[-2])
print(f" Lag lookup: {len(_lkp)} districts | city lag1={city_lag1:.0f} lag2={city_lag2:.0f}")
stack_path = MDIR / "riyadh_stack.pkl"
with open(stack_path, "wb") as f:
pickle.dump({
"xgb": final_xgb,
"lgb": final_lgb,
"cat": final_cat,
"meta": meta,
"district_lag_map": _lkp,
"city_lag1_median": city_lag1,
"city_lag2_median": city_lag2,
}, f, protocol=5)
print(f" Saved: {stack_path}")
# Update riyadh_meta.json
meta_path = MDIR / "riyadh_meta.json"
if meta_path.exists():
with open(meta_path) as f:
meta_dict = json.load(f)
else:
meta_dict = {}
meta_dict.update({
"feature_names": feat_cols,
"n_features": len(feat_cols),
"holdout_r2": round(hold_r2, 4),
"holdout_medape_pct": round(hold_medape, 2),
"holdout_mae_sar_sqm": round(hold_mae, 2),
"oof_r2": round(oof_meta_r2, 4),
"oof_medape_pct": round(oof_meta_medape, 2),
"model_version": "riyadh_v12",
"n_folds": N_FOLDS,
"train_rows": len(work),
"holdout_rows": len(hold),
"holdout_cutoff_quarter_id": int(cutoff_qid),
"meta_coefficients": meta.coef_.tolist(),
"meta_intercept": float(meta.intercept_),
"target": "log1p(sale_price_sar_sqm)",
"y_unit": "SAR/sqm",
"segment_by_type": _seg_by_type,
})
# Save haraj district lookup if refresh was run
try:
if _HARAJ_DISTRICT_LOOKUP:
meta_dict["haraj_district_lookup"] = _HARAJ_DISTRICT_LOOKUP
print(f" haraj_district_lookup: {len(_HARAJ_DISTRICT_LOOKUP)} districts saved to meta.json")
except NameError:
pass
# Save rei_type_idx lookup for inference (most recent quarter per type)
try:
_rei_latest_qid = sorted(_rei_pivot.index)[-1]
meta_dict["rei_type_idx_latest"] = {
pt: float(_rei_pivot.loc[_rei_latest_qid, pt])
for pt in _rei_pivot.columns
if not pd.isna(_rei_pivot.loc[_rei_latest_qid, pt])
}
meta_dict["rei_type_idx_lookup"] = {
str(qid): {pt: float(val) for pt, val in row.items() if not pd.isna(val)}
for qid, row in _rei_pivot.iterrows()
}
print(f" rei_type_idx_lookup: {len(_rei_pivot)} quarters | latest={_rei_latest_qid}")
except NameError:
pass
# v10: save metro district lookup for inference
try:
meta_dict["metro_district_lookup"] = {
d: {"dist_metro_m": round(v[0], 1), "nearest_metro_line": v[1], "bus_stops_500m": v[2]}
for d, v in _dist_metro.items()
}
print(f" metro_district_lookup: {len(_dist_metro)} districts saved to meta.json")
except NameError:
pass
with open(meta_path, "w") as f:
json.dump(meta_dict, f, indent=2)
print(f" Updated: {meta_path}")
print("\n" + "=" * 60)
print(f"THAMAN Riyadh v12 β Training complete")
print(f" OOF RΒ²={oof_meta_r2:.4f} MedAPE={oof_meta_medape:.2f}%")
print(f" Hold RΒ²={hold_r2:.4f} MedAPE={hold_medape:.2f}% MAE={hold_mae:,.0f} SAR/sqm")
print(f" vs v10: ΞRΒ²={hold_r2-0.7634:+.4f} ΞMedAPE={18.45-hold_medape:+.2f}pp")
print("=" * 60)
|