Spaces:
Running
Running
| """ | |
| validate_predictions.py | |
| ======================= | |
| Load scraped listing CSVs from data/raw/saudi_listings_*.csv, | |
| call the local Thaman API (/predict/riyadh) for each valid listing, | |
| compute prediction errors vs asking prices, and produce: | |
| - docs/prediction_vs_market.csv β summary table by type + district | |
| - docs/validation_scatter.png β scatter: predicted vs asking (SAR/sqm) | |
| Usage: | |
| python scripts/validate_predictions.py | |
| python scripts/validate_predictions.py --api-url http://localhost:8000 --max-rows 500 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import glob | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from pathlib import Path | |
| # ββ third-party βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import pandas as pd | |
| import numpy as np | |
| import requests | |
| import matplotlib | |
| matplotlib.use("Agg") # headless | |
| import matplotlib.pyplot as plt | |
| except ImportError as exc: | |
| sys.exit(f"[ERROR] Missing dependency: {exc}. Run: pip install pandas numpy requests matplotlib") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Config | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| DATA_RAW = PROJECT_ROOT / "data" / "raw" | |
| DOCS_DIR = PROJECT_ROOT / "docs" | |
| # Riyadh bounding box (must match API validation in models.py) | |
| LAT_MIN, LAT_MAX = 23.5, 26.0 | |
| LON_MIN, LON_MAX = 45.5, 48.0 | |
| # Arabic β API property_type string (must match RiyadhPredictRequest) | |
| TYPE_API_MAP = { | |
| "apartment": "Ψ΄ΩΨ©", | |
| "villa": "ΩΩΩΨ§", | |
| "plot": "ΩΨ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩΩΩ", | |
| "building": "ΨΉΩ Ψ§Ψ±Ψ©", | |
| "duplex": "Ψ΄ΩΨ©", # closest equivalent | |
| "studio": "Ψ΄ΩΨ©", | |
| "office": "ΨΉΩ Ψ§Ψ±Ψ©", | |
| "other": "Ψ΄ΩΨ©", # default fallback | |
| } | |
| CHROME_UA = ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
| "AppleWebKit/537.36 (KHTML, like Gecko) " | |
| "Chrome/124.0.0.0 Safari/537.36" | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Load scraped data | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_scraped(data_raw: Path) -> pd.DataFrame: | |
| """ | |
| Load all saudi_listings_*.csv files from data/raw/ and concatenate. | |
| Filters out rows missing lat, lon, area_sqm, price_sar. | |
| """ | |
| pattern = str(data_raw / "saudi_listings_*.csv") | |
| files = sorted(glob.glob(pattern)) | |
| if not files: | |
| sys.exit(f"[ERROR] No scraped files found matching {pattern}. Run scrape_saudi_listings.py first.") | |
| print(f"[INFO] Found {len(files)} scraped file(s):") | |
| dfs = [] | |
| for f in files: | |
| try: | |
| df = pd.read_csv(f, encoding="utf-8", low_memory=False) | |
| print(f" {Path(f).name}: {len(df)} rows") | |
| dfs.append(df) | |
| except Exception as exc: | |
| print(f" [WARN] Could not read {f}: {exc}") | |
| if not dfs: | |
| sys.exit("[ERROR] All scraped files failed to load.") | |
| combined = pd.concat(dfs, ignore_index=True) | |
| print(f"[INFO] Total rows before filter: {len(combined)}") | |
| # Coerce numeric columns | |
| for col in ["lat", "lon", "area_sqm", "price_sar", "price_per_sqm"]: | |
| if col in combined.columns: | |
| combined[col] = pd.to_numeric(combined[col], errors="coerce") | |
| # Drop duplicates by (source, listing_id) | |
| if "listing_id" in combined.columns: | |
| combined.drop_duplicates(subset=["source", "listing_id"], inplace=True) | |
| # Filter: need lat, lon, area_sqm, price_sar | |
| mask = ( | |
| combined["lat"].notna() & combined["lon"].notna() & | |
| combined["area_sqm"].notna() & combined["area_sqm"].gt(0) & | |
| combined["price_sar"].notna() & combined["price_sar"].gt(0) | |
| ) | |
| # Riyadh bounding box | |
| mask &= ( | |
| combined["lat"].between(LAT_MIN, LAT_MAX) & | |
| combined["lon"].between(LON_MIN, LON_MAX) | |
| ) | |
| valid = combined[mask].copy() | |
| valid.reset_index(drop=True, inplace=True) | |
| print(f"[INFO] Valid rows after filter: {len(valid)}") | |
| return valid | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # API caller | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def check_api(api_url: str) -> bool: | |
| """Check that the API is reachable at /health.""" | |
| try: | |
| resp = requests.get(f"{api_url}/health", timeout=5, | |
| headers={"User-Agent": CHROME_UA}) | |
| if resp.status_code == 200: | |
| print(f"[INFO] API healthy at {api_url}") | |
| return True | |
| print(f"[WARN] API returned {resp.status_code} at {api_url}/health") | |
| return False | |
| except requests.RequestException as exc: | |
| print(f"[ERROR] Cannot reach API at {api_url}: {exc}") | |
| return False | |
| def predict_one(api_url: str, row: pd.Series) -> dict | None: | |
| """ | |
| Call /predict/riyadh for one listing row. | |
| Returns the JSON response dict or None on failure. | |
| """ | |
| prop_en = str(row.get("property_type_en", "") or "other").lower() | |
| prop_ar = TYPE_API_MAP.get(prop_en, "Ψ΄ΩΨ©") | |
| payload = { | |
| "latitude": float(row["lat"]), | |
| "longitude": float(row["lon"]), | |
| "property_type": prop_ar, | |
| "area_sqm": float(row["area_sqm"]), | |
| } | |
| try: | |
| resp = requests.post( | |
| f"{api_url}/predict/riyadh", | |
| json=payload, | |
| timeout=15, | |
| headers={"Content-Type": "application/json", "User-Agent": CHROME_UA}, | |
| ) | |
| if resp.status_code == 200: | |
| return resp.json() | |
| # Log once per error type, not every row | |
| return None | |
| except requests.RequestException: | |
| return None | |
| def batch_predict(df: pd.DataFrame, api_url: str, max_rows: int, | |
| delay: float = 0.05) -> pd.DataFrame: | |
| """ | |
| Run predictions for all valid rows, add result columns to df. | |
| Returns df with columns: predicted_sqm, predicted_total, ape, abs_error_sqm. | |
| """ | |
| if max_rows and len(df) > max_rows: | |
| print(f"[INFO] Sampling {max_rows} rows from {len(df)} for validation") | |
| df = df.sample(n=max_rows, random_state=42).copy() | |
| predicted_sqm = [] | |
| predicted_total = [] | |
| api_district = [] | |
| errors_count = 0 | |
| total = len(df) | |
| for i, (_, row) in enumerate(df.iterrows()): | |
| if i % 50 == 0: | |
| print(f" [{i}/{total}] calling API β¦") | |
| result = predict_one(api_url, row) | |
| if result: | |
| predicted_sqm.append(result.get("predicted_price_sqm")) | |
| predicted_total.append(result.get("predicted_total_sar")) | |
| api_district.append(result.get("district_ar")) | |
| else: | |
| predicted_sqm.append(None) | |
| predicted_total.append(None) | |
| api_district.append(None) | |
| errors_count += 1 | |
| time.sleep(delay) | |
| df = df.copy() | |
| df["predicted_sqm"] = predicted_sqm | |
| df["predicted_total"] = predicted_total | |
| df["api_district"] = api_district | |
| # Compute price_per_sqm from scraped data if missing | |
| mask = df["price_per_sqm"].isna() & df["price_sar"].notna() & df["area_sqm"].gt(0) | |
| df.loc[mask, "price_per_sqm"] = df.loc[mask, "price_sar"] / df.loc[mask, "area_sqm"] | |
| # Absolute percentage error | |
| valid_mask = df["predicted_sqm"].notna() & df["price_per_sqm"].notna() & df["price_per_sqm"].gt(0) | |
| df["ape"] = None | |
| df.loc[valid_mask, "ape"] = ( | |
| (df.loc[valid_mask, "predicted_sqm"] - df.loc[valid_mask, "price_per_sqm"]).abs() | |
| / df.loc[valid_mask, "price_per_sqm"] | |
| * 100 | |
| ) | |
| df["abs_error_sqm"] = None | |
| df.loc[valid_mask, "abs_error_sqm"] = ( | |
| (df.loc[valid_mask, "predicted_sqm"] - df.loc[valid_mask, "price_per_sqm"]).abs() | |
| ) | |
| print(f"[INFO] API errors: {errors_count}/{total} ({errors_count/total*100:.1f}%)") | |
| return df | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Metrics + outputs | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_medape(df: pd.DataFrame) -> float: | |
| """Overall median APE (%) against asking price.""" | |
| ape = pd.to_numeric(df["ape"], errors="coerce").dropna() | |
| if len(ape) == 0: | |
| return float("nan") | |
| return float(ape.median()) | |
| def make_summary_table(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Summary table: MedAPE by property_type_en and district. | |
| """ | |
| df2 = df.copy() | |
| df2["ape"] = pd.to_numeric(df2["ape"], errors="coerce") | |
| df2["price_per_sqm"] = pd.to_numeric(df2["price_per_sqm"], errors="coerce") | |
| df2["predicted_sqm"] = pd.to_numeric(df2["predicted_sqm"], errors="coerce") | |
| mask = df2["ape"].notna() | |
| df2 = df2[mask] | |
| group_cols = [] | |
| if "property_type_en" in df2.columns: | |
| group_cols.append("property_type_en") | |
| if "district" in df2.columns: | |
| group_cols.append("district") | |
| if not group_cols: | |
| return pd.DataFrame() | |
| agg = ( | |
| df2.groupby(group_cols, dropna=False) | |
| .agg( | |
| n_listings = ("ape", "count"), | |
| medape_pct = ("ape", "median"), | |
| mean_asking_sqm = ("price_per_sqm", "mean"), | |
| mean_predicted_sqm = ("predicted_sqm", "mean"), | |
| mean_abs_error_sqm = ("abs_error_sqm", "mean"), | |
| ) | |
| .reset_index() | |
| .sort_values("medape_pct") | |
| ) | |
| agg = agg.round({"medape_pct": 2, "mean_asking_sqm": 0, | |
| "mean_predicted_sqm": 0, "mean_abs_error_sqm": 0}) | |
| return agg | |
| def make_scatter(df: pd.DataFrame, out_path: Path) -> None: | |
| """ | |
| Scatter plot: predicted SAR/sqm vs asking SAR/sqm, coloured by property type. | |
| Saves PNG to out_path. | |
| """ | |
| df2 = df.copy() | |
| for col in ["price_per_sqm", "predicted_sqm"]: | |
| df2[col] = pd.to_numeric(df2[col], errors="coerce") | |
| df2 = df2.dropna(subset=["price_per_sqm", "predicted_sqm"]) | |
| if len(df2) == 0: | |
| print("[WARN] No rows with both price_per_sqm and predicted_sqm β skipping scatter.") | |
| return | |
| # Remove extreme outliers (top/bottom 1%) | |
| lo_ask = df2["price_per_sqm"].quantile(0.01) | |
| hi_ask = df2["price_per_sqm"].quantile(0.99) | |
| lo_pred = df2["predicted_sqm"].quantile(0.01) | |
| hi_pred = df2["predicted_sqm"].quantile(0.99) | |
| df2 = df2[ | |
| df2["price_per_sqm"].between(lo_ask, hi_ask) & | |
| df2["predicted_sqm"].between(lo_pred, hi_pred) | |
| ] | |
| fig, ax = plt.subplots(figsize=(9, 7)) | |
| types = df2["property_type_en"].fillna("other").unique() | |
| colors = plt.cm.tab10.colors # type: ignore[attr-defined] | |
| for i, ptype in enumerate(sorted(types)): | |
| sub = df2[df2["property_type_en"].fillna("other") == ptype] | |
| ax.scatter(sub["price_per_sqm"], sub["predicted_sqm"], | |
| label=ptype, alpha=0.55, s=18, | |
| color=colors[i % len(colors)]) | |
| # 45Β° line | |
| lims = [ | |
| min(df2["price_per_sqm"].min(), df2["predicted_sqm"].min()), | |
| max(df2["price_per_sqm"].max(), df2["predicted_sqm"].max()), | |
| ] | |
| ax.plot(lims, lims, "k--", linewidth=1, label="Perfect prediction") | |
| overall_medape = compute_medape(df) | |
| n_pts = len(df2) | |
| ax.set_xlabel("Asking Price (SAR / mΒ²)", fontsize=12) | |
| ax.set_ylabel("Predicted Price (SAR / mΒ²)", fontsize=12) | |
| ax.set_title( | |
| f"Thaman Model vs Market Asking Prices\n" | |
| f"n={n_pts:,} listings | Overall MedAPE = {overall_medape:.1f}%", | |
| fontsize=13, | |
| ) | |
| ax.legend(title="Property type", fontsize=9) | |
| ax.grid(True, linestyle="--", alpha=0.4) | |
| # Source breakdown annotation | |
| if "source" in df2.columns: | |
| src_counts = df2["source"].value_counts().to_dict() | |
| src_text = " ".join(f"{k}: {v}" for k, v in sorted(src_counts.items())) | |
| ax.annotate(f"Sources: {src_text}", xy=(0.02, 0.97), xycoords="axes fraction", | |
| fontsize=8, va="top", color="gray") | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| fig.tight_layout() | |
| fig.savefig(out_path, dpi=150) | |
| plt.close(fig) | |
| print(f"[INFO] Scatter plot saved: {out_path}") | |
| def print_report(df: pd.DataFrame, summary: pd.DataFrame) -> None: | |
| """Print a concise validation report to stdout.""" | |
| overall_medape = compute_medape(df) | |
| n_valid = df["ape"].notna().sum() | |
| print("\n" + "=" * 60) | |
| print(" THAMAN MODEL VALIDATION vs MARKET ASKING PRICES") | |
| print("=" * 60) | |
| print(f" Valid comparisons : {n_valid:,}") | |
| print(f" Overall MedAPE : {overall_medape:.2f}%") | |
| if "source" in df.columns: | |
| for src, grp in df[df["ape"].notna()].groupby("source"): | |
| medape = compute_medape(grp) | |
| print(f" {src:10s} : {medape:.2f}% ({len(grp)} listings)") | |
| print() | |
| if len(summary) > 0: | |
| # Top 10 best-predicted (lowest MedAPE) | |
| print(" Best predicted segments (lowest MedAPE):") | |
| top10 = summary.head(10) | |
| for _, r in top10.iterrows(): | |
| tag = " | ".join(str(r[c]) for c in summary.columns if c in ["property_type_en", "district"]) | |
| print(f" {tag:40s} MedAPE={r['medape_pct']:.1f}% n={int(r['n_listings'])}") | |
| print("=" * 60 + "\n") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Main | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Validate Thaman model predictions against scraped Saudi listing prices." | |
| ) | |
| parser.add_argument("--api-url", default="http://localhost:8000", | |
| help="Thaman API base URL (default: http://localhost:8000)") | |
| parser.add_argument("--max-rows", type=int, default=0, | |
| help="Max listing rows to validate (0 = all, default: 0)") | |
| parser.add_argument("--data-dir", default=str(DATA_RAW), | |
| help="Directory containing saudi_listings_*.csv files") | |
| args = parser.parse_args() | |
| data_raw = Path(args.data_dir) | |
| DOCS_DIR.mkdir(parents=True, exist_ok=True) | |
| # 1. Load scraped data | |
| df = load_scraped(data_raw) | |
| if len(df) == 0: | |
| sys.exit("[ERROR] No valid listings found.") | |
| # 2. Check API | |
| if not check_api(args.api_url): | |
| print("[WARN] API not reachable. Check that `uvicorn api.main:app --port 8000` is running.") | |
| print(" Continuing β all predictions will be empty.") | |
| # 3. Run predictions | |
| max_rows = args.max_rows if args.max_rows > 0 else len(df) | |
| df = batch_predict(df, args.api_url, max_rows=max_rows) | |
| # 4. Print report | |
| summary = make_summary_table(df) | |
| print_report(df, summary) | |
| # 5. Save summary CSV | |
| summary_path = DOCS_DIR / "prediction_vs_market.csv" | |
| summary.to_csv(summary_path, index=False, encoding="utf-8") | |
| print(f"[INFO] Summary table saved: {summary_path}") | |
| # 6. Save scatter plot | |
| scatter_path = DOCS_DIR / "validation_scatter.png" | |
| make_scatter(df, scatter_path) | |
| # 7. Save full detail CSV (optional, useful for deep dives) | |
| detail_path = DOCS_DIR / "prediction_vs_market_detail.csv" | |
| save_cols = [c for c in df.columns if c in [ | |
| "source", "listing_id", "district", "lat", "lon", | |
| "property_type_en", "property_type_ar", | |
| "price_sar", "area_sqm", "price_per_sqm", | |
| "predicted_sqm", "predicted_total", "ape", "abs_error_sqm", | |
| "bedrooms", "listing_date", "url", | |
| ]] | |
| df[save_cols].to_csv(detail_path, index=False, encoding="utf-8") | |
| print(f"[INFO] Full detail saved: {detail_path}") | |
| if __name__ == "__main__": | |
| main() | |