spb/wp9_uqo Public
UQO Working Paper No. 9 — A grand hedonic model of the Canadian housing market: decomposing structure and location value.
TeX 60.1%
Python 39.8%
1# 🏘️ A Grand Hedonic Model of the Canadian Housing Market23**UQO Working Paper No. 9** — *Decomposing the Value of Structure and Location across4140,931 MLS Listings with High-Dimensional Neighbourhood Fixed Effects*56[-b31b1b?logo=latex&logoColor=white)](paper/main.pdf)7[](requirements.txt)8[](data/raw/README.md)9[-4051b5)](src/wp9/models.py)10[](scripts/)11[](AUDIT.md)12[](https://uqo.ca)13[](mailto:contact@spboucher.ai)14[](mailto:contact@spboucher.ai)1516> **TL;DR** — On 140,931 Canadian MLS listings, absorbing 1,153 neighbourhood (FSA) fixed17> effects lifts explained log-price variance from **46% → 77%**. Location alone is worth18> ~30 percentage points of R² — more than every structural attribute combined. The model19> values held-out homes with a median absolute error of **15.8%** (OOS R² = 0.764),20> competitive with commercial AVMs, while staying fully transparent.2122---2324## 📖 Table of contents25261. [What this paper does](#-what-this-paper-does)272. [Headline results](#-headline-results)283. [Repository layout](#-repository-layout)294. [Quick start](#-quick-start)305. [The pipeline, step by step](#-the-pipeline-step-by-step)316. [Data](#-data)327. [Methodology](#-methodology)338. [The two results tiers (reference vs. reproduced)](#-the-two-results-tiers-reference-vs-reproduced)349. [Figures & tables inventory](#-figures--tables-inventory)3510. [Reproduction verification](#-reproduction-verification)3611. [Limitations](#-limitations)3712. [Citation](#-citation)3813. [Author & contact](#-author--contact)3940---4142## 🎯 What this paper does4344A dwelling is the archetypal heterogeneous good: no two houses are identical, and the most45important attribute — *where it stands* — is unobservable as a scalar. This project46estimates a **semi-logarithmic hedonic price equation** at national scale for Canada:4748```49ln P_i = α + β·ln(Area_i) + x_i'γ + d_i'δ + μ_f(i) + ε_i50```5152where `μ_f(i)` is a fixed effect for the **Forward Sortation Area** (FSA — the first three53characters of the postal code) of listing *i*. With 1,153 absorbed FSA intercepts, every54neighbourhood gets an arbitrary price level that soaks up schools, transit, coastline,55employment density — observed or not — and the structural implicit prices (β, γ) are56identified purely from **within-neighbourhood** variation. Estimation is by absorbing57least squares (numerically identical to full-dummy OLS); inference is clustered by FSA.5859Beyond the headline decomposition the paper delivers: out-of-sample valuation accuracy60(80/20 split, Duan-smeared retransformation), robustness across six sample cuts, quantile61hedonic regressions, a quadratic test of diminishing returns to floor space, an urban62price gradient in distance to the nine major metros, Moran's I spatial diagnostics,63leave-one-province-out transferability, and a ranked map of Canada's most and least64expensive neighbourhoods net of structure.6566## 🏆 Headline results6768| Quantity | Value |69|---|---|70| Estimation sample | **140,931** listings — 82,334 houses, 57,857 condos, 9 provinces |71| Absorbed neighbourhood effects | **1,153** FSAs |72| R²: structural only (M1) | 0.464 |73| R²: + dwelling type & ownership (M2) | 0.469 |74| R²: + province FE (M3) | 0.567 |75| R²: grand model, FSA FE (M5) | **0.767** |76| Living-area elasticity (M5) | **0.547** (cluster SE 0.009) |77| Full-bathroom premium | **0.109 log points ≈ +11%** per bathroom |78| Bedrooms, conditional on area | ≈ 0 (the textbook hedonic result) |79| Out-of-sample R² (log price) | **0.764** |80| Median absolute valuation error | **15.8%** (59% of homes priced within ±20%) |81| Moran's I of residuals, structural → grand | **0.46 → 0.08** (−82%) |82| Urban gradient | −8.5% location premium per doubling of distance to metro |83| Neighbourhood premia span | ×9 between the most and least expensive FSAs |8485The most expensive neighbourhoods net of structure are all in the **City of Vancouver**86(V6S, V8E, V6T: +150–200% vs. the national median); the cheapest are in rural87Saskatchewan, Manitoba and Newfoundland (−60 to −67%).8889## 📁 Repository layout9091```92wp9_uqo/93├── README.md ← you are here94├── AUDIT.md forensic audit of the original project + verification95├── CHANGES.md restructuring report (what moved, what was rewritten)96├── requirements.txt pinned Python dependencies97├── .gitignore98├── data/99│ ├── raw/100│ │ ├── realtor_mls_unique.duckdb ⚠ 747 MB — NOT in git (see Data section)101│ │ └── README.md102│ └── processed/103│ └── analysis.parquet estimation sample, 142k rows (generated by step 01)104├── src/wp9/ analysis package105│ ├── config.py paths, constants, seeds, metro coordinates106│ ├── parsing.py parsers for the semi-structured MLS fields107│ ├── sample.py raw DuckDB → estimation sample108│ ├── models.py design matrix, M1–M5 ladder, AbsorbingLS, FE recovery109│ └── plotstyle.py shared matplotlib style110├── scripts/ numbered pipeline entry points111│ ├── 01_build_sample.py112│ ├── 02_estimate_core.py113│ ├── 03_estimate_extended.py114│ ├── 04_make_figures.py (--results reference|reproduced)115│ └── 05_make_tables.py (--results reference|reproduced)116├── figures/ all 17 paper figures (PNG, generated)117├── results/118│ ├── reference/ ORIGINAL published outputs — canonical paper numbers119│ ├── reproduced/ outputs regenerated end-to-end by this pipeline120│ └── tables/ the 6 LaTeX tables consumed by the paper121└── paper/122 ├── main.tex preamble + metadata; \input's the sections123 ├── main.pdf compiled paper (26 pages)124 ├── sections/ titlepage, introduction, literature, data,125 │ methodology, results, robustness, conclusion126 ├── references.bib 32 entries, natbib author-year, aer style127 ├── Makefile / .latexmkrc build config128 └── uq_logo.jpg129```130131## 🚀 Quick start132133```bash134git clone https://github.com/spboucher-ai/wp9_uqo.git135cd wp9_uqo136python3 -m pip install -r requirements.txt137138# Full pipeline (needs data/raw/realtor_mls_unique.duckdb — see Data section):139python3 scripts/01_build_sample.py # duckdb → data/processed/analysis.parquet140python3 scripts/02_estimate_core.py # M1–M5 ladder → results/reproduced/141python3 scripts/03_estimate_extended.py # OOS, robustness, quantile, Moran, LOPO…142python3 scripts/04_make_figures.py # figures/ (17 PNG)143python3 scripts/05_make_tables.py # results/tables/ (6 .tex)144145# Paper:146cd paper && latexmk -pdf main.tex # or `make`147```148149Without the raw DuckDB you can still run steps **02→05**: the committed150`data/processed/analysis.parquet` (6 MB) is the estimation sample produced by step 01.151152## 🔬 The pipeline, step by step153154| Step | Script | Input | Output | Runtime* |155|---|---|---|---|---|156| 01 | `01_build_sample.py` | raw DuckDB (172,019 rows) | `analysis.parquet` (142k rows, 21 cols) | ~40 s |157| 02 | `02_estimate_core.py` | parquet | `fit.json`, `coef_M{1,2,3,5}.csv`, `grand_model.parquet`, `fsa_premia.csv` | ~1 min |158| 03 | `03_estimate_extended.py` | parquet + step 02 | `oos.json`, `robustness.csv`, `heterogeneity.csv`, `quantile.csv`, `lopo.csv`, `ext2.json`, Moran arrays, gradient/nonlinearity bands | ~6 min |159| 04 | `04_make_figures.py` | results tier + parquet | 17 PNG figures | ~30 s |160| 05 | `05_make_tables.py` | results tier | 6 LaTeX tables | ~5 s |161162\* Apple Silicon, single process.163164**Step 01 in detail** (documented in `src/wp9/parsing.py` / `sample.py`):165166- bedrooms `"3 + 1"` (main + basement) → summed;167- living area: prefers the explicit floor-area measurement; banded entries168 (`"1100-1500 sqft"`) map to the **upper bound** of the band; falls back to169 `size_interior`; sqft → m² at 0.0929;170- lot size parsed from free text (`"6000 sqft"`, `"0.14 ac"`, `"under 1/2 acre"`,171 `"50 x 120"` frontage×depth) with a missingness indicator; the top 1% of positive172 parsed lots (multi-acre rural acreage strings) treated as "no usable lot info";173- dwelling type consolidated to 8 groups, ownership to 6 groups;174- FSA from the postal code; FSAs with < 25 listings pooled into `<PROV>_other`;175- filters: strictly positive price, non-missing living area / bedrooms / bathrooms,176 valid FSA; then the extreme 1% tails of price and living area are trimmed.177178## 📊 Data179180**Source file:** `data/raw/realtor_mls_unique.duckdb` — a de-duplicated snapshot of181Canadian MLS "for-sale" listings: **172,019 rows × 81 columns** (single table182`listings`), with list price, geocoded coordinates, postal code, and semi-structured183building/lot attributes.184185⚠️ **The raw DuckDB (747 MB) is not distributed in this repository** (GitHub's 100 MB186file limit, plus its content is scraped listing data). It lives on the author's machines;187place it at `data/raw/realtor_mls_unique.duckdb` to run step 01. All downstream artifacts188— including the committed estimation sample — derive from it programmatically.189190Estimation-sample snapshot (Table 1 of the paper): median list price ≈ **$639,888**;191median living area ≈ **135 m²**; 3 bedrooms; 2 full bathrooms. Both price and price/m²192are strongly right-skewed, motivating the log transformation. Ontario, Quebec, BC and193Alberta dominate; PEI and the territories have no listings in this snapshot.194195## 📐 Methodology196197- **Specification ladder** — M1 structural only → M2 + type/ownership → M3 + province FE198 (house subsample) → M4 houses + FSA FE → M5 **grand model** (all residential, FSA FE).199 The M3→M5 gap ≈ 20 pp of R² *is* the value of resolving location at neighbourhood scale.200- **Absorbing least squares** (`linearmodels.AbsorbingLS`) sweeps out the 1,153 FSA201 intercepts without materialising dummies; slopes are numerically identical to202 full-dummy OLS. SEs clustered by FSA throughout.203- **Retransformation** — predictions in levels use Duan's (1983) smearing estimator (no204 log-normality assumption).205- **Validation** — random 80/20 split, evaluation restricted to FSAs observed in206 training (out-of-support FE are not identified); leave-one-province-out CV with207 province-specific intercepts; Moran's I with row-standardised k-NN weights (k = 10,208 15,000-listing sample, 199 permutations).209210## 🧭 The two results tiers (reference vs. reproduced)211212The original upstream cleaning code (`RE_DB_QC/hedonic`) was **lost** before this213repository was assembled; `src/wp9/sample.py` is a careful reconstruction from the214paper's own data section, calibrated against every stored output (see `AUDIT.md`). It215reproduces the published sample to **+0.86%** and all headline estimates closely — but216not to the last digit. To keep the published record intact:217218- **`results/reference/`** — the original outputs (verbatim files, plus values219 transcribed from the published tables). **Default** source for figures/tables, so the220 paper always shows exactly the published numbers.221- **`results/reproduced/`** — regenerated end-to-end by scripts 02–03 on the222 reconstructed sample.223224```bash225python3 scripts/04_make_figures.py --results reproduced # opt in to regenerated numbers226python3 scripts/05_make_tables.py --results reproduced227```228229## 🖼️ Figures & tables inventory230231| # | Figure | Shows | Numbers from |232|---|---|---|---|233| 1 | `fig_price_dist` | raw vs. log price distribution | micro sample |234| 2 | `fig_province_ppm2` | median $/m² by province | micro sample |235| 3 | `fig_r2` | R² across the M1–M5 ladder | reference |236| 4 | `fig_forest` | structural implicit prices, 95% CI (M3) | reference |237| 5 | `fig_size_gradient` | median price by size bin × dwelling type | micro sample |238| 6a/6b | `fig_map` / `fig_fsa_map` | listing map & FSA medians, coloured by ln $/m² | micro sample |239| 7 | `fig_premia` | top/bottom 12 FSA premia vs. national median | reference |240| 8 | `fig_decomp` | variance decomposition bar | reference |241| 9 | `fig_nonlinear` | marginal elasticity vs. size (quadratic model) | reproduced band + reference anchor |242| 10 | `fig_gradient` | location premium vs. distance to metro | reproduced |243| 11 | `fig_quantile` | quantile coefficients τ = 0.1…0.9 | reference |244| 12 | `fig_moran` | Moran scatterplots, structural vs. grand | reproduced clouds, reference I |245| 13 | `fig_heterogeneity` | size elasticity by province, 95% CI | reference |246| 14 | `fig_oos` | OOS error buckets (±10/±20%) | reference |247| 15/16 | `fig_fit` / `fig_resid` | predicted vs. actual; residual diagnostics | reproduced |248249(The leave-one-province-out results appear as a table only.)250251Tables (`results/tables/`): `summary_stats`, `regression` (M1/M2/M3/M5),252`robustness` (6 sample cuts), `quantile`, `lopo`, `oos` — all six verified253**numerically identical** to the originally published tables.254255## ✅ Reproduction verification256257Full side-by-side in [`AUDIT.md`](AUDIT.md) §5. Highlights (original → reproduced):258259- Sample: 140,931 → 142,146 (+0.86%); condos match to 3 listings.260- R² ladder: 0.464/0.469/0.567/0.762/0.767 → 0.467/0.470/0.569/0.766/0.770.261- OOS: R² 0.764 → 0.770; median APE 15.8% → 15.6%.262- Moran's I: 0.459→0.082 vs. 0.494→0.072 (same −82~85% conclusion).263- Known gaps (flagged, not hidden): M5 living elasticity 0.547 vs. 0.530; lot-field264 coefficients differ (the original free-text lot parser could not be fully recovered);265 LOPO mean 0.362 vs. 0.315 with identical province ranking.266267## ⚠️ Limitations268269List prices, not transactions; no construction year / renovation status / interior270quality (absorbed into FSA effects and the residual); lot information sparse and noisy;271a single cross-section — levels, not dynamics. See the paper's conclusion for the272research agenda these imply.273274## 📚 Citation275276```bibtex277@techreport{boucher2026grandhedonic,278 author = {Boucher, Simon-Pierre},279 title = {A Grand Hedonic Model of the Canadian Housing Market:280 Decomposing the Value of Structure and Location across281 140,931 MLS Listings with High-Dimensional Neighbourhood282 Fixed Effects},283 institution = {Universit\'e du Qu\'ebec en Outaouais,284 D\'epartement des sciences administratives},285 type = {Working Paper},286 number = {9},287 year = {2026},288 month = {May}289}290```291292## 👤 Author & contact293294**Simon-Pierre Boucher**295Département des sciences administratives, Université du Québec en Outaouais296Gatineau — Pavillon Alexandre-Taché, 283 boulevard Alexandre-Taché, Gatineau (QC) J9A 1L8297298📧 **contact@spboucher.ai** · 🌐 [spboucher.ai](https://www.spboucher.ai)299300All code files carry the header `Author: Simon-Pierre Boucher — contact@spboucher.ai`.301302© 2026 Simon-Pierre Boucher. All rights reserved. The listing data snapshot is not303redistributed; code and paper are shared for research reproducibility.304