thaman / scripts /validate_predictions.py
Turki Almurahhem
THAMAN dual-city AVM β€” Stack v22 (NYC) + v12 (Riyadh)
2e8a71f
Raw
History Blame Contribute Delete
17.4 kB
"""
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()