Spaces:
Running
Running
| \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} | |