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)