\documentclass[11pt, a4paper, twoside]{report} % ------------------------------------------------------------------- % PACKAGES — UQU Format % ------------------------------------------------------------------- \usepackage[utf8]{inputenc} \usepackage{graphicx} \usepackage{setspace} \usepackage{geometry} \usepackage{mathptmx} \usepackage{titlesec} \usepackage{fancyhdr} \usepackage{tocloft} \usepackage{float} \usepackage{url} \usepackage[colorlinks=true, urlcolor=blue, linkcolor=black, citecolor=black]{hyperref} % ------------------------------------------------------------------- % MARGINS & SPACING % ------------------------------------------------------------------- \geometry{top=1.5in, bottom=1.0in, left=1.25in, right=1.0in} \onehalfspacing \setlength{\parskip}{6pt} % ------------------------------------------------------------------- % ADDITIONAL PACKAGES % ------------------------------------------------------------------- \usepackage{amsmath,amssymb} \usepackage{booktabs} \usepackage{longtable} \usepackage{array} \usepackage{xcolor} \usepackage{enumitem} \usepackage{caption} \usepackage{multirow} \usepackage{listings} \usepackage{tcolorbox} % Arabic text fallback (pdflatex compatible) \newcommand{\textarabic}[1]{#1} % ------------------------------------------------------------------- % HEADINGS (Report class) % ------------------------------------------------------------------- \titleformat{\chapter}[display] {\normalfont\fontsize{16}{19}\selectfont\bfseries\centering} {\chaptertitlename\ \thechapter}{6pt}{\MakeUppercase} \titleformat{\section} {\normalfont\fontsize{14}{17}\selectfont\bfseries} {\thesection}{1em}{} \titleformat{\subsection} {\normalfont\fontsize{13}{16}\selectfont\bfseries\itshape} {\thesubsection}{1em}{} % ------------------------------------------------------------------- % HEADERS & FOOTERS % ------------------------------------------------------------------- \pagestyle{fancy} \fancyhf{} \fancyhead[LO]{THAMAN} \fancyhead[RE]{\leftmark} \fancyfoot[R]{\thepage} \renewcommand{\headrulewidth}{0.4pt} \renewcommand{\footrulewidth}{0pt} \fancypagestyle{plain}{\fancyhf{}\renewcommand{\headrulewidth}{0pt}} \setlength{\headheight}{14pt} % ------------------------------------------------------------------- % CUSTOM STYLES & COMMANDS % ------------------------------------------------------------------- \begin{document} % ── Title page (UQU Format) ─────────────────────────────────────────────────── \begin{titlepage} \fontfamily{ptm}\selectfont \noindent \begin{minipage}[t]{0.6\textwidth} \raggedright \itshape BSc Project\\ Dept.\ of Computer Science \& Artificial Intelligence\\ Project ID: CSAI-472-P2-M20\\ 2026 \end{minipage}% \begin{minipage}[t]{0.4\textwidth} \raggedleft \includegraphics[width=3cm]{Umm_Al-Qura_University_logo.png} \end{minipage} \vspace{2cm} \begin{center} {\fontsize{26}{31}\selectfont \textbf{THAMAN}} \vspace{0.5cm} {\fontsize{16}{19}\selectfont \textbf{Complete Technical Reference}} \vspace{0.5cm} {\fontsize{13}{15}\selectfont Dual-City Automated Valuation Model: New York City \& Riyadh} \vspace{1cm} {\fontsize{14}{16}\selectfont \textbf{By:\\[0.3cm] Turki AL-murahhem\\}} \end{center} \vfill \noindent \begin{flushleft} Dept.\ of Computer Science and Artificial Intelligence\\ Faculty of Computer and Information Systems\\ Umm Al-Qura University, KSA\\[0.3cm] {\small Generated: 2026-05-29 --- NYC Stack v22 · Riyadh Stack v12} \end{flushleft} \end{titlepage} \newpage\thispagestyle{empty}\mbox{}\newpage \tableofcontents \newpage % ───────────────────────────────────────────────────────────────────────────── \chapter{Project Overview} \label{ch:overview} THAMAN (\textarabic{ثمن} --- Arabic for ``price'' or ``value'') is an Automated Valuation Model (AVM) that estimates residential property prices using machine learning. It operates across two independent real estate markets simultaneously: \begin{description} \item[New York City (NYC)] Predicts total property price in USD \item[Riyadh, Saudi Arabia] Predicts price per square metre in SAR \end{description} Both cities share the same stacking ensemble architecture but are trained on entirely separate datasets, use different feature sets tailored to each market, and produce city-specific outputs. The system is deployed as a FastAPI backend on Hugging Face Spaces with a Leaflet.js web interface. % ───────────────────────────────────────────────────────────────────────────── \chapter{System Architecture} \label{ch:architecture} \begin{table}[H] \centering \caption{Technology stack} \label{tab:tech_stack} \begin{tabular}{ll} \toprule Layer & Technology \\ \midrule Data pipeline & Python, Polars \\ Training & scikit-learn, XGBoost, LightGBM, CatBoost, Ridge \\ Model storage & pickle (.pkl), JSON \\ API backend & FastAPI, Uvicorn (port 8000) \\ Spatial index & scipy cKDTree, sklearn BallTree \\ Frontend & Leaflet.js, Chart.js \\ Deployment & Hugging Face Spaces (Docker) \\ Version control & GitHub (\texttt{turkialm/thaman-v2}) \\ \bottomrule \end{tabular} \end{table} \section{API Endpoints} \begin{description} \item[\texttt{POST /predict}] NYC property valuation \item[\texttt{POST /predict/riyadh}] Riyadh property valuation \item[\texttt{GET /layers/district}] Riyadh district choropleth GeoJSON \item[\texttt{GET /layers/listings}] Haraj.com.sa active listings GeoJSON \item[\texttt{GET /nearby}] Nearest comparable NYC sales \item[\texttt{GET /health}] Liveness check \item[\texttt{GET /ui}] Frontend HTML \end{description} % ───────────────────────────────────────────────────────────────────────────── \chapter{NYC Model --- Dataset} \label{ch:nyc_data} \section{Primary Transaction Data} \begin{description} \item[Source] NYC Open Data --- Citywide Rolling Calendar Sales \item[URL] \url{https://data.cityofnewyork.us/City-Government/NYC-Citywide-Rolling-Calendar-Sales/usep-8jbt} \item[Coverage] 2022--2026 (deed-recorded by NYC Department of Finance) \item[Rows loaded] $\approx 212,855$ (after cleaning) \item[Rows training] 157,329 (oldest 85\% by \texttt{sale\_date}) \item[Rows holdout] 27,763 (newest 15\% by \texttt{sale\_date} --- time-based split) \item[Target variable] \texttt{sale\_price} (USD total transaction price) \item[Log-transform] \texttt{log1p(sale\_price)} during training; \texttt{expm1()} at inference \end{description} Rows dropped (cleaning): \begin{itemize} \item sale\_price $< \$10{,}000$ or $> \$100{,}000{,}000$ \item Missing latitude/longitude or sale\_date \item Duplicate BBL + sale\_date (kept highest price) \item Building class codes indicating non-arm's-length transfers \item Rows where \texttt{gross\_square\_feet} $= 0$ or null \end{itemize} \section{Supplementary Data Sources} \subsection*{PLUTO 25v4} \begin{description} \item[File] \texttt{nyc\_pluto\_25v4\_csv/pluto\_25v4.csv} \item[Joined on] BBL (borough-block-lot identifier) \item[Columns used] \texttt{assesstot}, \texttt{assessland} \item[Coverage] $\approx 95\%$ join rate \item[Purpose] Prior sale price imputation via borough-level assesstot ratios \end{description} \subsection*{MTA Subway Stations} File: \texttt{MTA\_Subway\_Stations\_20260308.csv} (84\,KB, $\approx 496$ stations). Columns used: GTFS Latitude/Longitude, CBD flag, Daytime Routes count, ADA accessibility flag. Generated features: \texttt{dist\_subway\_m}, \texttt{nearest\_station\_is\_cbd}, \texttt{nearest\_station\_route\_count}, \texttt{nearest\_station\_is\_ada}. \subsection*{HPD Housing Maintenance Code Violations} File: \texttt{hpd\_violations.csv} (137\,MB --- largest raw file). Filtered: open violations, 2022+, severity classes A/B/C. \begin{description} \item[Class A] Non-hazardous (minor) \item[Class B] Hazardous (water damage, structural) \item[Class C] Immediately hazardous (mould, lead paint, heat loss) \end{description} Features: \texttt{hpd\_class\_b\_viol\_zip}, \texttt{hpd\_class\_c\_viol\_zip}, \texttt{hpd\_severity\_score\_zip} (= $C \times 3 + B \times 2 + A \times 1$, log-scaled). \subsection*{DOB Building Permits} Filtered: 2022+, permit types A1/A2 (alteration), NB (new building). Features: \texttt{dob\_reno\_permit\_count}, \texttt{dob\_newbld\_permit\_count} (both log-scaled). \subsection*{NYC 311 Service Requests} Coverage: 2019--2025, by NTA. Features: \texttt{noise\_density\_nta}, \texttt{livability\_complaint\_rate}, \texttt{rat\_density\_nta} (v11), \texttt{heat\_density\_nta} (v11). Winsorisation: \texttt{noise\_density\_nta} capped at p99 = 176.67; \texttt{livability\_complaint\_rate} capped at 60.99. \subsection*{ACRIS Prior Sale Data} Coverage: $\approx 70\%$ have a prior sale on record. Imputation: \[ \hat{P}_\text{prior} = \texttt{assesstot} \times r_\text{borough}, \quad r_\text{borough} = \text{median}\!\left(\frac{P_\text{prior}}{\texttt{assesstot}}\right) \] Result: 99\% coverage after imputation. \subsection*{Mortgage Rates} Feature: \texttt{mortgage\_rate\_30yr} (US 30-year fixed rate for the sale month, sourced from Freddie Mac PMMS weekly survey). % ───────────────────────────────────────────────────────────────────────────── \chapter{NYC Model --- Feature Engineering} \label{ch:nyc_features} All feature engineering runs in Polars (not pandas) for speed on 200K+ rows. Final feature CSV: \texttt{data/processed/features\_v5.csv} (121\,MB). \section{Distance Features (log-transformed)} Every raw distance feature \texttt{dist\_X\_m} gets a \texttt{log1p} companion: \[ \texttt{log\_dist\_X\_m} = \log(1 + \texttt{dist\_X\_m}) \] Rationale: distance effects on price decay non-linearly. Log-transform linearises this for gradient-boosted trees. \section{Urban Gravity Centres} Four major employment hubs, Euclidean distances (metres): \begin{description} \item[\texttt{dist\_midtown\_manhattan\_m}] $(40.7549,\,-73.9840)$ \item[\texttt{dist\_downtown\_manhattan\_m}] $(40.7074,\,-74.0113)$ \item[\texttt{dist\_downtown\_brooklyn\_m}] $(40.6928,\,-73.9903)$ \item[\texttt{dist\_long\_island\_city\_m}] $(40.7447,\,-73.9485)$ \end{description} \section{Walk Score Proxy} \begin{equation} W = \text{clip}\!\left( 0.35\,\tilde{c}_\text{transit} + 0.15\,\tilde{c}_\text{bus} + 0.30\,\tilde{c}_\text{poi} + 0.10\,\tilde{c}_\text{bike} + 0.10\,\tilde{c}_\text{park},\; 0,\,100 \right) \end{equation} Each $\tilde{c}_i$ is MinMax-scaled on training data; scaler parameters saved in \texttt{meta.json}. \section{Interaction Features} \begin{lstlisting} sqft_per_floor = gross_square_feet / numfloors income_over_crime = median_income_nta / (crime_rate_nta + 1) density_index = residential_units / gross_square_feet * 1000 log_sqft_x_floors = log1p(gross_square_feet) * log1p(numfloors) log_land_sqft = log1p(land_square_feet) lot_coverage = gross_square_feet / land_square_feet # clipped [0,10] bldg_vol_proxy = log1p(gross_square_feet * numfloors) prior_price_psf = prior_sale_price / gross_square_feet \end{lstlisting} \section{Target Encoding} Four encodings computed on training set (raw mean, $k=0$): \begin{itemize} \item \texttt{bldgclass\_encoded} --- $\approx 200+$ unique NYC building class codes \item \texttt{borough\_bldg\_encoded} --- borough (1--5) $\times$ bldgclass first letter \item \texttt{nta\_encoded} --- 212 NTA codes \item \texttt{nta\_bldg\_encoded} --- NTA $\times$ bldgclass first letter (fine-grained) \end{itemize} Fallback: unknown codes at inference $\to$ global training-set mean log-price. \section{QoL Winsorisation} \begin{table}[H] \centering \caption{Winsorisation caps (stored in \texttt{meta.json})} \begin{tabular}{lr} \toprule Feature & p99 cap \\ \midrule \texttt{crime\_rate\_nta} & 267.56 \\ \texttt{noise\_density\_nta} & 176.67 \\ \texttt{livability\_complaint\_rate}& 60.99 \\ \bottomrule \end{tabular} \end{table} \section{Complete Feature List (134 features)} \begin{longtable}{lp{10cm}} \caption{All 134 model features by category} \label{tab:all_features}\\ \toprule Category & Features \\ \midrule \endfirsthead \midrule Category & Features \\ \midrule \endhead \bottomrule \endfoot Location (2) & \texttt{latitude, longitude} \\ Structural (5) & \texttt{building\_age, numfloors, gross\_square\_feet, land\_square\_feet, residential\_units} \\ Zoning (5) & \texttt{builtfar, residfar, commfar, facilfar, far\_utilization} \\ Property type (5) & \texttt{has\_elevator, is\_condo, is\_multifamily, is\_single\_fam, is\_mixed\_use} \\ Macro (5) & \texttt{borough, sale\_year, sale\_month\_sin, sale\_month\_cos, mortgage\_rate\_30yr} \\ Transit (7) & \texttt{dist\_subway\_m, dist\_express\_subway\_m, dist\_bus\_m, nearest\_station\_is\_express, nearest\_station\_is\_cbd, nearest\_station\_route\_count, nearest\_station\_is\_ada} \\ Log distances (9) & \texttt{log\_dist\_subway\_m, log\_dist\_express\_subway\_m, log\_dist\_bus\_m, log\_dist\_school\_m, log\_dist\_elem\_school\_m, log\_dist\_park\_m, log\_dist\_hospital\_m, log\_dist\_waterfront\_m, log\_dist\_bike\_lane\_m} \\ Proximity (6) & \texttt{dist\_school\_m, dist\_park\_m, dist\_hospital\_m, dist\_waterfront\_m, dist\_bike\_lane\_m, dist\_elem\_school\_m} \\ Urban gravity (4) & \texttt{dist\_midtown\_manhattan\_m, dist\_downtown\_manhattan\_m, dist\_downtown\_brooklyn\_m, dist\_long\_island\_city\_m} \\ Borough flag (3) & \texttt{is\_manhattan, crime\_x\_manhattan, crime\_x\_non\_manhattan} \\ Walk score (1) & \texttt{walk\_score\_proxy} \\ Amenities (9) & \texttt{poi\_count\_500m, poi\_cafe\_500m, poi\_restaurant\_500m, poi\_gym\_500m, poi\_grocery\_500m, poi\_bar\_500m, poi\_pharmacy\_500m, airbnb\_count\_500m, tree\_count\_200m} \\ QoL signals (6) & \texttt{crime\_rate\_nta, noise\_density\_nta, livability\_complaint\_rate, rat\_density\_nta, heat\_density\_nta, population\_2020} \\ Income (2) & \texttt{median\_income\_nta, borough\_income\_deviation} \\ Air quality (2) & \texttt{pm25\_mean, no2\_mean} \\ Schools (3) & \texttt{school\_district, district\_avg\_score, district\_school\_count} \\ Prior sale (6) & \texttt{prior\_sale\_price, price\_appreciation, years\_since\_prior\_sale, is\_flip, has\_prior\_sale, prior\_price\_psf} \\ Assessment (2) & \texttt{assesstot, assessland} \\ Renovation (2) & \texttt{renovated\_since\_2018, years\_since\_renovation} \\ Interactions (8) & \texttt{sqft\_per\_floor, income\_over\_crime, density\_index, log\_sqft\_x\_floors, log\_land\_sqft, lot\_coverage, bldg\_vol\_proxy, prior\_price\_psf} \\ Target-encoded (7) & \texttt{bldgclass\_encoded, borough\_bldg\_encoded, nta\_encoded, nta\_bldg\_encoded, nta\_sale\_count, nta\_median\_psf, nta\_price\_trend\_slope} \\ HPD violations (3) & \texttt{hpd\_class\_b\_viol\_zip, hpd\_class\_c\_viol\_zip, hpd\_severity\_score\_zip} \\ DOB permits (2) & \texttt{dob\_reno\_permit\_count, dob\_newbld\_permit\_count} \\ MTA quality (3) & \texttt{nearest\_station\_is\_cbd, nearest\_station\_route\_count, nearest\_station\_is\_ada} \\ \end{longtable} % ───────────────────────────────────────────────────────────────────────────── \chapter{NYC Model --- Training Methodology} \label{ch:nyc_training} \section{Train / Holdout Split} Time-based (not random). Split: oldest 85\% $\to$ training (157,329 rows), newest 15\% $\to$ holdout (27,763 rows). Sorted by \texttt{sale\_date} (ascending). Prevents temporal leakage. \section{Cross-Validation Strategy} Type: Spatial GroupKFold. Folds: 10. Groups: \texttt{ntacode} (212 NTAs). Standard K-Fold would put adjacent sales from the same neighbourhood in both train and validation, inflating CV scores via spatial autocorrelation. GroupKFold ensures all sales from a given NTA are either fully in train or fully in validation for each fold. \section{Base Learner Hyperparameters} \begin{table}[H] \centering \caption{Detailed hyperparameters} \begin{tabular}{lrrrr} \toprule Parameter & XGB-A & XGB-B & LGB & CatBoost \\ \midrule n\_estimators & 5,000 & 5,000 & 5,000 & 3,000 \\ learning\_rate & 0.02 & 0.05 & 0.04 & 0.025 \\ max\_depth & 7 & 4 & --- & 8 \\ num\_leaves & --- & --- & 127 & --- \\ min\_child\_weight & 8 & 15 & 15 & --- \\ subsample & 0.80 & 0.65 & 0.80 & --- \\ colsample\_bytree & 0.60 & 0.75 & 0.70 & --- \\ gamma & 0.1 & 0.3 & --- & --- \\ reg\_alpha (L1) & 0.3 & 1.0 & 0.3 & --- \\ reg\_lambda (L2) & 1.5 & 2.5 & 1.5 & 1.5 \\ bagging\_temperature & --- & --- & --- & 0.8 \\ border\_count & --- & --- & --- & 64 \\ random\_seed & 42 & 7 & 42 & 42 \\ Best round & 3,843 & 5,000 & 952 & 3,000 \\ \bottomrule \end{tabular} \end{table} \section{Meta-Learner} Two candidates evaluated: \begin{description} \item[LightGBM meta] n\_estimators=500, lr=0.05, num\_leaves=15; holdout $R^2 = 0.6349$ \item[Ridge meta] alpha=1.0, positive=True; holdout $R^2 = 0.6495$ \textbf{(selected)} \end{description} Ridge wins because by the meta-stage the four base learners have exhausted non-linear relationships. Remaining variance is mostly noise; Ridge's L2 penalty prevents meta-level overfitting. \section{Luxury Sub-Model} Separate XGBoost trained on Manhattan sales $\geq \$2{,}500{,}000$ ($n \approx 2{,}000+$). Soft blend at inference: \begin{align} \alpha &= \text{clip}\!\left(\frac{\hat{P} - 2.5\text{M}}{5.0\text{M} - 2.5\text{M}},\;0,\;1\right) \\ P_\text{final} &= (1-\alpha)\,\hat{P}_\text{stack} + \alpha\,\hat{P}_\text{luxury} \end{align} % ───────────────────────────────────────────────────────────────────────────── \chapter{NYC Model --- Results} \label{ch:nyc_results} \section{Overall Holdout Performance (Stack v22)} \begin{table}[H] \centering \caption{NYC holdout results --- Stack v22 (27,763 sales)} \begin{tabular}{lrrr} \toprule Model & $R^2$ & MAE (USD) & MedAPE \\ \midrule XGB-A (deep) & 0.6445 & --- & 20.56\% \\ XGB-B (shallow) & 0.6387 & --- & 20.67\% \\ LightGBM (wide) & 0.6432 & --- & 20.84\% \\ CatBoost (high-cap) & 0.6482 & --- & 20.60\% \\ \textbf{Stack v22} & \textbf{0.6495} & \textbf{\$1,047,004} & \textbf{20.32\%} \\ \bottomrule \end{tabular} \end{table} \section{Performance by Borough} \begin{table}[H] \centering \caption{Borough-level holdout performance} \begin{tabular}{lrrr} \toprule Borough & $n$ & MedAPE & MAE (USD) \\ \midrule Manhattan & 5,411 & 35.16\% & \$2,714,327 \\ Bronx & 2,735 & 21.08\% & \$1,531,532 \\ Brooklyn & 7,032 & 20.64\% & \$857,322 \\ Queens & 9,533 & 17.43\% & \$366,226 \\ Staten Island & 3,052 & 14.41\% & \$220,216 \\ \bottomrule \end{tabular} \end{table} \section{Performance by Price Tier} \begin{table}[H] \centering \caption{Price tier holdout performance} \begin{tabular}{lrrr} \toprule Tier & $n$ & MedAPE & MAE (USD) \\ \midrule $<$ \$500K & 6,497 & 27.10\% & \$225,470 \\ \$500K--\$1M & 10,980 & 15.12\% & \$182,149 \\ \$1M--\$3M & 7,978 & 21.30\% & \$454,712 \\ \$3M--\$10M & 1,707 & 39.77\% & \$2,180,172 \\ \bottomrule \end{tabular} \end{table} \section{SHAP Feature Importance (Top 10)} \begin{table}[H] \centering \caption{Top 10 features by mean $|\text{SHAP}|$} \begin{tabular}{rlrr} \toprule Rank & Feature & $|\text{SHAP}|$ & Category \\ \midrule 1 & \texttt{bldgclass\_encoded} & 0.2121 & Target encoding \\ 2 & \texttt{borough\_bldg\_encoded} & 0.1188 & Target encoding \\ 3 & \texttt{gross\_square\_feet} & 0.0980 & Structural \\ 4 & \texttt{prior\_sale\_price} & 0.0750 & Prior sale \\ 5 & \texttt{dist\_downtown\_manhattan\_m} & 0.0725 & Urban gravity \\ 6 & \texttt{latitude} & 0.0585 & Location \\ 7 & \texttt{land\_square\_feet} & 0.0538 & Structural \\ 8 & \texttt{price\_appreciation} & 0.0367 & Prior sale \\ 9 & \texttt{has\_prior\_sale} & 0.0335 & Prior sale \\ 10 & \texttt{builtfar} & 0.0324 & Zoning \\ \bottomrule \end{tabular} \end{table} % ───────────────────────────────────────────────────────────────────────────── \chapter{Riyadh Model --- Dataset} \label{ch:riyadh_data} \section{Dataset Structure: Fundamentally Different from NYC} NYC uses individual property transaction records ($\approx 200$K rows, one row $=$ one sale). Riyadh uses district-level quarterly aggregates (6,910 rows, one row $=$ one district $\times$ one quarter combination). This fundamentally shapes the model: \begin{itemize} \item Target $=$ median(\texttt{sale\_price\_sar\_sqm}) for that district-quarter \item Spatial features $=$ computed at the district centroid lat/lon \item ``Structural'' features $=$ district-level medians from SA\_Aqar \item No individual building characteristics available \end{itemize} \section{Transaction Data Sources} \subsection*{Source 1: Ministry of Justice XLSX Report (2018--2023)} File: \texttt{data/raw/quarter\_report SI.xlsx}. Key column: \texttt{Meter\_Price\_W\_Avg\_IQR} --- IQR-winsorised average price per m$^2$. Minimum deed count filter: \texttt{deed\_counts} $\geq 3$. \begin{tcolorbox}[colback=red!5,colframe=red!60] \textbf{Warning:} The column \texttt{RealEstatePrice\_SUM / deed\_counts} gives average deal value per transaction, NOT price per m$^2$. Using it produces 4M+ SAR/m$^2$ garbage values. Only \texttt{Meter\_Price\_W\_Avg\_IQR} is used. \end{tcolorbox} \subsection*{Source 2: Saudi Open Data API --- 2024 Quarterly CSVs} Files: \texttt{sales\_riyadh\_2024\_Q1.csv}, \texttt{Q3.csv}, \texttt{Q4.csv} (Q2 not available). Engineering challenge: two columns both named \texttt{\makebox{الربع}} after stripping trailing spaces. Fixed by pattern-matching on \texttt{رقم} keyword. \subsection*{Source 3: Saudi Open Data API --- 2025 Quarterly CSVs} Files: \texttt{sales\_riyadh\_2025\_Q1.csv}, \texttt{Q2.csv}, \texttt{Q3.csv}. These become the holdout set (all rows with \texttt{quarter\_id} $\geq$ 20251). \section{Critical Bug: 2024 Quarter ID Encoding} \begin{tcolorbox}[colback=red!5,colframe=red!60] \textbf{Bug found and fixed.} Quarter ID formula: \texttt{quarter\_id = year $\times$ 10 + sale\_quarter}. In 2024 API CSVs, the raw \texttt{sale\_quarter} already contained the full code (e.g., 20241 for 2024 Q1). When multiplied by 10: $2024 \times 10 + 20241 = 40481$ instead of the correct 20241. So 2024 quarter IDs were 40481, 40483, 40484 --- sorting after all 2025 IDs, pushing all 867 2024 rows into holdout. \medskip \textbf{Fix:} \texttt{scripts/fix\_riyadh\_2024\_quarters.py} remaps $40481 \to 20241$, $40483 \to 20243$, $40484 \to 20244$ and corrects REI values. Training cutoff changed to hardcoded \texttt{cutoff\_qid = 20251}. \medskip \textbf{Impact:} Holdout MedAPE improved from 23.43\% $\to$ 15.59\% (via v12). Holdout $R^2$ improved from 0.675 $\to$ 0.800. \end{tcolorbox} \section{Dataset Statistics} \begin{table}[H] \centering \caption{Riyadh dataset summary} \begin{tabular}{ll} \toprule Attribute & Value \\ \midrule Total rows & 6,910 \\ Training rows & 5,531 (quarter\_id $<$ 20251) \\ Holdout rows & 1,730 (quarter\_id $\geq$ 20251) \\ Districts & 163 unique \\ Features used & 149 \\ Target (median) & 3,303 SAR/m$^2$ \\ Target (range) & 221.82 -- 12,565.21 SAR/m$^2$ \\ \midrule \multicolumn{2}{l}{\textit{Rows by property type}} \\ Residential Plot (قطعة أرض-سكنى) & 3,062 (44.3\%) \\ Villa (فيلا) & 2,174 (31.5\%) \\ Apartment (شقة) & 1,661 (24.0\%) \\ Building (عمارة) & 13 (0.2\%) \\ \bottomrule \end{tabular} \end{table} \section{Spatial Data Sources} \begin{description} \item[Metro Transit] \texttt{metro-stations-in-riyadh-by-metro-line-and-station-type-2024.geojson} (72\,KB). Features: \texttt{dist\_metro\_m}, \texttt{log\_dist\_metro\_m}, \texttt{metro\_stations\_1km}, \texttt{nearest\_metro\_line\_num}, \texttt{nearest\_metro\_type\_cd}, \texttt{dist\_metro\_line1\_m}. \item[Bus Stops] \texttt{bus-stops-in-riyadh-by-bus-route-direction-and-shelter-type-2024.geojson} (1.6\,MB). Features: \texttt{dist\_bus\_m}, \texttt{log\_dist\_bus\_m}, \texttt{bus\_stops\_500m}, \texttt{brt\_stops\_500m}. \item[Commercial Services] \texttt{commercial-services-by-category-sub-municipality-and-district-2024.geojson} (2.1\,MB). 10 commercial features including hypermarket/bank/restaurant counts. \item[Air Quality] 12 RCRC monitoring stations; pollutants: NO$_2$, SO$_2$, PM$_{10}$, O$_3$. Composite \texttt{air\_quality\_score} (0--100, higher=cleaner). \item[QoL POIs] 6 types (mosques, malls, schools, hospitals, parks, entertainment): distance, log-distance, count within 500\,m each = 18 QoL features. \end{description} \section{Macroeconomic Data} \begin{description} \item[REI] Real Estate Price Index from Saudi General Authority for Statistics. Base: 2023 Q4 $= 100$. Features: \texttt{rei\_residential\_qtr\_idx}, \texttt{rei\_apt\_idx}, \texttt{rei\_yoy\_change}, \texttt{rei\_qoq\_change}. \item[Salary] SAMA (Saudi Arabian Monetary Authority) annual statistical report. Features: \texttt{avg\_saudi\_salary\_yr}, \texttt{salary\_yoy\_change}. \end{description} \section{SA\_Aqar Rental Listings} File: \texttt{data/raw/SA\_Aqar.csv} ($\approx 50$K listings). Aggregated to district-level medians: \begin{itemize} \item \texttt{aqar\_median\_size\_sqm} --- median listing size \item \texttt{aqar\_median\_bedrooms} --- median bedroom count \item \texttt{aqar\_median\_property\_age} --- median age (years) \item \texttt{aqar\_rent\_per\_sqm} --- median asking rent per m$^2$ \end{itemize} Note: these are \emph{rental} listing attributes, not transaction attributes--- they serve as proxies for typical property characteristics in each district. % ───────────────────────────────────────────────────────────────────────────── \chapter{Riyadh Model --- Feature Engineering} \label{ch:riyadh_features} \section{Connectivity Score} \begin{equation} S_\text{conn} = \text{clip}\!\left( 0.30\,\tilde{c}_\text{metro} + 0.25\,\tilde{c}_\text{commercial} + 0.20\,\tilde{c}_\text{bus} + 0.15\,(1/\tilde{d}_\text{metro}) + 0.10\,\tilde{c}_\text{intersect},\; 0,\,100 \right) \end{equation} Scaler parameters stored in \texttt{riyadh\_meta.json}. \section{Target Encoding (District Level)} \begin{description} \item[\texttt{district\_encoded}] Arabic district name $\to$ mean $\log(P_{\text{sqm}}+1)$ on training rows \item[\texttt{district\_type\_encoded}] district $\times$ property type $\to$ mean $\log(P_{\text{sqm}}+1)$ \end{description} Fallback: city median log-price (\texttt{global\_mean\_log} = 7.8645). \section{Complete Feature List (149 features)} \begin{longtable}{lp{10cm}} \caption{Riyadh model: all 149 features by category} \\ \toprule Category & Features \\ \midrule \endfirsthead \midrule Category & Features \\ \midrule \endhead \bottomrule \endfoot Location (2) & \texttt{district\_lat, district\_lon} \\ Property type (4) & \texttt{is\_apartment, is\_villa, is\_residential\_plot, is\_building} \\ Metro transit (6) & \texttt{dist\_metro\_m, log\_dist\_metro\_m, metro\_stations\_1km, nearest\_metro\_line\_num, nearest\_metro\_type\_cd, dist\_metro\_line1\_m} \\ Bus (4) & \texttt{dist\_bus\_m, log\_dist\_bus\_m, bus\_stops\_500m, brt\_stops\_500m} \\ Traffic (4) & \texttt{dist\_major\_intersection\_m, log\_dist\_intersection\_m, intersections\_1km, intersections\_500m} \\ Commercial (10) & \texttt{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 (6) & \texttt{no2\_nearest\_mean, so2\_nearest\_mean, pm10\_nearest\_mean, o3\_nearest\_mean, dist\_air\_station\_m, air\_quality\_score} \\ Macro/REI (6) & \texttt{rei\_residential\_qtr\_idx, rei\_apt\_idx, rei\_yoy\_change, rei\_qoq\_change, avg\_saudi\_salary\_yr, salary\_yoy\_change} \\ District aggregates (5) & \texttt{district\_median\_price\_sqm, district\_transaction\_volume, district\_price\_vs\_city\_avg, district\_price\_trend\_slope, district\_median\_price\_apt\_sqm} \\ Target-encoded (2) & \texttt{district\_encoded, district\_type\_encoded} \\ Time (4) & \texttt{sale\_year, sale\_quarter\_sin, sale\_quarter\_cos, log\_deed\_count} \\ Mosque (3) & \texttt{dist\_mosque\_m, log\_dist\_mosque\_m, mosque\_count\_500m} \\ Mall (3) & \texttt{dist\_mall\_m, log\_dist\_mall\_m, mall\_count\_500m} \\ School (3) & \texttt{dist\_school\_m, log\_dist\_school\_m, school\_count\_500m} \\ Hospital (3) & \texttt{dist\_hospital\_m, log\_dist\_hospital\_m, hospital\_count\_500m} \\ Park (3) & \texttt{dist\_park\_m, log\_dist\_park\_m, park\_count\_500m} \\ Entertainment (3) & \texttt{dist\_entertain\_m, log\_dist\_entertain\_m, entertain\_count\_500m} \\ Connectivity (1) & \texttt{riyadh\_connectivity\_score} \\ Structural/rental (4) & \texttt{aqar\_median\_size\_sqm, aqar\_median\_bedrooms, aqar\_median\_property\_age, aqar\_rent\_per\_sqm} \\ \end{longtable} % ───────────────────────────────────────────────────────────────────────────── \chapter{Riyadh Model --- Training Methodology} \label{ch:riyadh_training} \section{Train / Holdout Split} Time-based on \texttt{quarter\_id}: \begin{description} \item[Training] \texttt{quarter\_id} $< 20251$ $\to$ 2018 Q1--2024 Q4 (5,531 rows) \item[Holdout] \texttt{quarter\_id} $\geq 20251$ $\to$ 2025 Q1--Q3 (1,730 rows) \end{description} \section{Cross-Validation} Spatial GroupKFold, 5 folds, groups = \texttt{district\_ar} (163 unique). Fewer folds than NYC because dataset is smaller. \section{Hyperparameters} \begin{table}[H] \centering \caption{Riyadh base learner hyperparameters} \begin{tabular}{lrrr} \toprule Parameter & XGBoost & LightGBM & CatBoost \\ \midrule n\_estimators & 1,500 & 1,500 & 1,500 \\ learning\_rate & 0.03 & 0.03 & 0.03 \\ max\_depth & 5 & 5 & 5 \\ subsample & 0.70 & 0.70 & --- \\ colsample\_bytree& 0.70 & 0.70 & --- \\ reg\_alpha & 0.5 & 0.5 & --- \\ reg\_lambda & 2.0 & 2.0 & 2.0 \\ early\_stopping & 50 & 50 & 50 \\ random\_seed & 42 & 42 & 42 \\ \bottomrule \end{tabular} \end{table} \section{Meta-Learner} Ridge (alpha=1.0, positive=\textbf{False} --- no constraint on Riyadh due to small dataset). Ridge meta coefficients: \begin{itemize} \item XGBoost: 0.278 \item LightGBM: 0.002 \item CatBoost: 0.721 \end{itemize} CatBoost dominates (72\%) due to ordered boosting and symmetric tree structure benefiting small datasets. % ───────────────────────────────────────────────────────────────────────────── \chapter{Riyadh Model --- Results} \label{ch:riyadh_results} \begin{table}[H] \centering \caption{Riyadh model performance} \begin{tabular}{lrr} \toprule Evaluation & $R^2$ & MedAPE \\ \midrule OOF (5-fold spatial GroupKFold) & 0.9348 & 8.25\% \\ Holdout (2025 Q1--Q3, $n=1{,}730$) & 0.8014 & 15.59\% \\ \bottomrule \end{tabular} \par\small Holdout MAE: 986 SAR/m$^2$. \end{table} \section{Performance by Property Type} \begin{table}[H] \centering \begin{tabular}{lrl} \toprule Type & MedAPE & Notes \\ \midrule Apartment (شقة) & 12.83\% & Best-performing \\ Villa (فيلا) & 12.39\% & Best absolute \\ Plot (قطعة أرض) & 20.82\% & Hardest; land speculation \\ Building (عمارة) & 18.61\% & \\ \bottomrule \end{tabular} \end{table} \section{OOF-to-Holdout Gap} OOF MedAPE: 8.25\% vs. Holdout MedAPE: 15.59\% (gap = 7.3\,pp). Three factors: \begin{enumerate} \item \textbf{Temporal shift}: holdout is Q1--Q3 2025 (post-Metro, new market conditions). \item \textbf{OOF temporal overlap}: GroupKFold by district $\Rightarrow$ same quarter appears across folds, inflating OOF. \item \textbf{Small dataset}: 5,531 rows across 163 districts $\Rightarrow$ sparse coverage in some districts. \end{enumerate} \section{Comparison to v1} \begin{table}[H] \centering \begin{tabular}{lrr} \toprule Version & Holdout MedAPE & Holdout $R^2$ \\ \midrule v1 (buggy 2024 encoding) & 23.43\% & 0.675 \\ v11 (current) & 15.59\% & 0.800 \\ \bottomrule \end{tabular} \end{table} % ───────────────────────────────────────────────────────────────────────────── \chapter{Algorithm Details and Library Versions} \label{ch:algorithms} \section{Libraries} \begin{table}[H] \centering \caption{Library versions} \begin{tabular}{lll} \toprule Library & Version & Purpose \\ \midrule Python & 3.11 & Runtime \\ Polars & 0.20+ & Data loading, feature engineering \\ NumPy & 1.26+ & Numerical arrays \\ SciPy & 1.12+ & cKDTree, linregress \\ scikit-learn & 1.4+ & GroupKFold, Ridge, MinMaxScaler, BallTree \\ XGBoost & 2.0+ & Gradient boosted trees \\ LightGBM & 4.3+ & Leaf-wise gradient boosting \\ CatBoost & 1.2+ & Symmetric tree boosting \\ joblib & 1.3+ & Model serialisation \\ FastAPI & 0.111+ & REST API framework \\ Uvicorn & 0.29+ & ASGI server \\ Pydantic & 2.x & Schema validation \\ \bottomrule \end{tabular} \end{table} \section{XGBoost} Uses second-order Taylor expansion of the loss function to fit decision trees sequentially. Key settings: \texttt{objective=reg:squarederror}, \texttt{tree\_method=hist} (faster on large datasets), early stopping. Two NYC variants: XGB-A (deep, lr=0.02) and XGB-B (shallow, lr=0.05) for ensemble diversity. \section{LightGBM} Leaf-wise tree growth (grows the leaf with the highest gain next) rather than level-wise. \texttt{num\_leaves=127} in NYC. In Riyadh, LightGBM earns only $\approx 0.2\%$ of the Ridge meta weight. \section{CatBoost} Symmetric (oblivious) trees: every split in a given level uses the same feature and threshold. Ordered boosting prevents target leakage during training. CatBoost dominates the Riyadh meta blend (72\%) due to ordered boosting and symmetric tree structure benefiting small datasets. \section{Ridge Meta-Learner} Input matrix $S \in \mathbb{R}^{n \times k}$ (4 columns for NYC, 3 for Riyadh). Loss: \[ \min_w \|Sw - y\|^2 + \alpha\|w\|^2 \] with $\alpha = 1.0$. For NYC: \texttt{positive=True} (blending, not arbitrage). \section{GroupKFold} \begin{table}[H] \centering \begin{tabular}{lll} \toprule City & Folds & Groups \\ \midrule NYC & 10 & NTA code (212 groups, $\approx 31$ per fold) \\ Riyadh & 5 & district\_ar (163 groups, $\approx 29$ per fold) \\ \bottomrule \end{tabular} \end{table} % ───────────────────────────────────────────────────────────────────────────── \chapter{Key Differences: NYC vs.\ Riyadh} \label{ch:differences} \begin{longtable}{lll} \caption{NYC vs.\ Riyadh model comparison} \\ \toprule Dimension & NYC & Riyadh \\ \midrule \endfirsthead \midrule Dimension & NYC & Riyadh \\ \midrule \endhead \bottomrule \endfoot Data granularity & Individual transactions & District-quarter aggregates \\ Dataset size & 185,092 train rows & 5,531 train rows \\ Holdout size & 27,763 rows & 1,730 rows \\ Target unit & USD total price & SAR per m$^2$ \\ Target range & \$50K -- \$100M & 222 -- 12,565 SAR/m$^2$ \\ Years covered & 2022--2026 & 2018--2025 \\ Base learners & 4 (XGB-A, XGB-B, LGB, CAT) & 3 (XGB, LGB, CAT) \\ CV folds & 10-fold GroupKFold & 5-fold GroupKFold \\ CV grouping & NTA code & district\_ar \\ Meta-learner & Ridge (positive=True) & Ridge (positive=False) \\ Features & 134 & 149 \\ Target encoding & 4 types & 2 types \\ Prior sale signal & Yes (99\% via assesstot)& No \\ Structural feats & sqft, floors, age, etc. & SA\_Aqar medians only \\ Infrastructure & MTA subway, bus, bike & Metro (6 lines), bus, traffic \\ QoL signals & Crime, noise, HPD, 311 & Air quality, mosques, malls \\ Macro signals & Mortgage rates & REI index, salary data \\ OOF MedAPE & --- & 8.25\% \\ Holdout MedAPE & 20.32\% & 15.59\% \\ Holdout $R^2$ & 0.6495 & 0.8014 \\ Holdout MAE & \$1,047,004 USD & 986 SAR/m$^2$ \\ \end{longtable} % ───────────────────────────────────────────────────────────────────────────── \chapter{Haraj Listings Layer --- External Validation} \label{ch:haraj} \begin{description} \item[Source] Haraj.com.sa (Saudi Arabia's largest classifieds marketplace) \item[Script] \texttt{scripts/scrape\_saudi\_listings.py} \item[CSV] \texttt{data/raw/saudi\_listings\_haraj\_20260518.csv} \item[Listings] 1,615 active listings: 444 apartments, 630 villas, 526 plots, 15 buildings \end{description} \begin{table}[H] \centering \caption{Haraj validation results} \begin{tabular}{lr} \toprule Metric & Value \\ \midrule Overall MedAPE & 54.33\% \\ Mean APE & 71.08\% \\ Within 20\% of asking& 186 / 1,615 (11.5\%) \\ Median asking price & 5,232 SAR/m$^2$ \\ Training median & 2,902 SAR/m$^2$ \\ Implied asking markup& $\approx 80\%$ \\ \bottomrule \end{tabular} \end{table} THAMAN was trained on deed-recorded transaction prices. Haraj shows pre-negotiation asking prices. The 80\% overall asking premium is consistent with documented Saudi residential market negotiation margins of 20--50\% (Al-Otaibi \& Al-Subaihi, 2021). % ───────────────────────────────────────────────────────────────────────────── \chapter{API Implementation Details} \label{ch:api} \section{NYC Prediction Endpoint (\texttt{POST /predict})} Processing steps: \begin{enumerate} \item \texttt{\_build\_feature\_row()}: compute all 134 features, apply winsorisation caps, walk\_score\_proxy, v11 ZIP/NTA features \item \texttt{ThamanScorer.predict\_single()}: predict log-price from 4 base learners $\to$ Ridge meta-learner $\to$ luxury sub-model (if applicable) \item SHAP explanation via CatBoost \texttt{TreeExplainer} (top 10 drivers) \item Comparable nearby sales lookup (BallTree on 185K-sale index) \item Return price, confidence interval, grade tier, top\_drivers \end{enumerate} \section{Riyadh Prediction Endpoint (\texttt{POST /predict/riyadh})} Request fields: \texttt{latitude}, \texttt{longitude}, \texttt{property\_type} (Arabic), \texttt{area\_sqm}. Processing steps: \begin{enumerate} \item \texttt{RiyadhSpatialLookup.predict\_features(lat, lon, type, year, quarter)}: find nearest centroid via cKDTree, look up 149 features \item \texttt{scorer.predict\_riyadh()}: 3 base models $\to$ Ridge meta $\to$ expm1() $\to$ SAR/m$^2$ \item Multiply by \texttt{area\_sqm} for \texttt{predicted\_total\_sar} \item Confidence: $\pm$ 15.59\% (global holdout MedAPE) \item SHAP via CatBoost TreeExplainer \end{enumerate} Response: \texttt{predicted\_price\_sqm}, \texttt{predicted\_total\_sar}, \texttt{confidence\_low/high\_sqm}, \texttt{district\_ar}, \texttt{r2\_test}, \texttt{medape\_pct}, \texttt{spatial\_features} dict, \texttt{top\_drivers} list. % ───────────────────────────────────────────────────────────────────────────── \chapter{Known Limitations and Bugs Fixed} \label{ch:bugs} \begin{description} \item[Bug 1 --- Riyadh 2024 Quarter ID Encoding (FIXED)] Root cause: \texttt{year $\times$ 10 + sale\_quarter} where \texttt{sale\_quarter} in 2024 CSV was already a full quarter code (20241). Produced 40481 instead of 20241. Effect: 867 rows (all of 2024) classified as holdout. Fix: \texttt{scripts/fix\_riyadh\_2024\_quarters.py}. Impact: MedAPE $23.43\% \to 15.59\%$ (via v12). \item[Bug 2 --- Stale district\_medape in riyadh\_meta.json (FIXED)] \texttt{meta\_dict.update(\{\ldots\})} preserved the old district\_medape table from buggy v1 training. 12/146 districts were capped at 50\% confidence. Fix: removed \texttt{district\_medape} key; all predictions use global 15.59\% MedAPE. \item[Bug 3 --- REI Values Corrupted for 2024 Rows (FIXED)] Wrong quarter\_ids caused \_closest\_qid lookup to assign 2025 Q3 REI (100.44) to all 2024 rows. Fix: hardcoded correct quarterly REI values for Q1/Q3/Q4 2024. \item[Limitation 1 --- No Individual Riyadh Transactions] Ministry of Justice only publishes district-level aggregates. Until parcel-level data is released, the Riyadh model cannot capture within-district variance. \item[Limitation 2 --- OOF Temporal Overlap (Riyadh)] The OOF MedAPE of 8.25\% is optimistic relative to the true out-of-time holdout (15.59\%) because the same calendar quarter appears across all GroupKFold folds. \item[Limitation 3 --- NYC Luxury Cap Sensitivity] Properties above \$10M are handled by the luxury sub-model. Truly exceptional sales ($> \$50$M) are not well-covered by the training distribution. \end{description} % ───────────────────────────────────────────────────────────────────────────── \chapter{Model Version History} \label{ch:versions} \section{NYC Model} \begin{table}[H] \centering \caption{NYC model version history} \begin{tabular}{llll} \toprule Version & Year & $R^2$ / MedAPE & Key change \\ \midrule v1 & 2022 & 0.40 / 32\% & Single XGBoost, 3 features \\ v2 & 2023 & 0.48 / --- & XGBoost + feature engineering \\ v6 & 2024 & 0.645 / --- & NTA-level target encoding \\ v10 & 2025 & 0.646 / 20.30\% & NTA price trend slope; 94 features \\ v21 & 2026 & 0.6516 / 20.10\% & BBL building history LoO \$/sqft; 131 features \\ v22 & 2026 & \textbf{0.6495 / 20.32\%} & NTA$\times$bldgtype temporal lag; 134 features \\ \bottomrule \end{tabular} \end{table} \section{Riyadh Model} \begin{table}[H] \centering \caption{Riyadh model version history} \begin{tabular}{llll} \toprule Version & Date & Holdout MedAPE / $R^2$ & Key change \\ \midrule v1 & May 2026 & 23.43\% / 0.675 & First Riyadh model; 2024 quarter\_id bug \\ v11 & May 2026 & \textbf{15.59\% / 0.800} & Type-stratified lags + metro + hub distances; 149 features \\ \bottomrule \end{tabular} \end{table} % ───────────────────────────────────────────────────────────────────────────── \chapter{Deployment and Reproducibility} \label{ch:deployment} \begin{description} \item[Hugging Face Space] \url{https://huggingface.co/spaces/Turki-Almurahhem/thaman} \item[GitHub] \url{https://github.com/turkialm/thaman-v2} \end{description} \section{Reproduce NYC Model} \begin{lstlisting}[language=bash] python scripts/prepare_v12_features.py python training/train_stack_v12.py # Output: models/thaman_stack.pkl, models/xgboost_model.json, models/meta.json \end{lstlisting} \section{Reproduce Riyadh Model} \begin{lstlisting}[language=bash] python scripts/riyadh_feature_engineering.py python scripts/fix_riyadh_2024_quarters.py python training/train_stack_riyadh_v2.py # Output: models/riyadh_stack.pkl, models/riyadh_meta.json \end{lstlisting} \section{Run Locally} \begin{lstlisting}[language=bash] cd /path/to/new_try uvicorn api.main:app --port 8000 # Open: http://localhost:8000/ui \end{lstlisting} Cold-start time: $\approx 30\,\text{s}$ (spatial KD-tree indexes loading). API latency (warm): 200--400\,ms per prediction locally (including SHAP computation); $\sim$0.7--1.2\,s on the live Space (free-tier CPU + network overhead). \vfill \begin{center} {\small Generated: 2026-05-22 --- THAMAN Technical Reference} \end{center} \end{document}