Spaces:
Running
Running
| """ | |
| 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) | |