WP11 — Half a Million Prices, Twenty Models: full research project
Controlled horse race of 20 hedonic models across four design axes (functional form, time FE, spatial FE, estimation method) on 514,212 Quebec sales, random + forward-in-time holdouts, price indices, implicit-price profiles, learning curves, segments; 9 figures, 7 tables, 23-page paper with literature review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 54 changed files with +3,213 and −0
added
.gitignore
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +# Raw & processed data — transaction microdata are NOT redistributed | |
| 2 | +data/raw/*.parquet | |
| 3 | +data/raw/*.csv | |
| 4 | +data/processed/*.parquet | |
| 5 | + | |
| 6 | +# Python | |
| 7 | +__pycache__/ | |
| 8 | +*.pyc | |
| 9 | + | |
| 10 | +# LaTeX build artifacts (paper/main.pdf IS committed) | |
| 11 | +paper/*.aux | |
| 12 | +paper/*.log | |
| 13 | +paper/*.out | |
| 14 | +paper/*.fls | |
| 15 | +paper/*.fdb_latexmk | |
| 16 | +paper/*.bbl | |
| 17 | +paper/*.blg | |
| 18 | +paper/*.synctex.gz | |
| 19 | + | |
| 20 | +# macOS | |
| 21 | +.DS_Store | |
added
README.md
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai --> | |
| 2 | +# 🏇 Half a Million Prices, Twenty Models | |
| 3 | + | |
| 4 | +**UQO Working Paper No. 11** — *A Systematic Assessment of Hedonic Specifications | |
| 5 | +and Estimation Methods for the Quebec Housing Market, 2021–2026* | |
| 6 | + | |
| 7 | +[-b31b1b?logo=latex&logoColor=white)](paper/main.pdf) | |
| 8 | +[](requirements.txt) | |
| 9 | +[](data/raw/README.md) | |
| 10 | +[](src/wp11/models.py) | |
| 11 | +[](scripts/) | |
| 12 | +[](https://uqo.ca) | |
| 13 | +[](mailto:contact@spboucher.ai) | |
| 14 | +[](mailto:contact@spboucher.ai) | |
| 15 | + | |
| 16 | +> **TL;DR** — A controlled horse race of **20 hedonic models** on 514,212 Quebec sales | |
| 17 | +> with assessor-grade structural attributes: 6 functional forms × time-FE ladder × | |
| 18 | +> spatial-FE ladder × 6 estimation methods, all scored on the **same price-level | |
| 19 | +> scoreboard** (Duan smearing / Box–Cox inversion) under **two holdouts** (random 80/20 | |
| 20 | +> and forward-in-time). Functional form is second-order (±2 pp of MdAPE); spatial | |
| 21 | +> controls are first-order (~10 pp) but overfit at ~1 km granularity; **gradient | |
| 22 | +> boosting wins under random validation** (MdAPE ≈ 14.4% vs 16.5% for the best linear | |
| 23 | +> model) — but under the forward-in-time split *every* model degrades and the ML | |
| 24 | +> advantage **reverses** (splines 19.1% vs boosting 21.2%, identical R²_ln): random | |
| 25 | +> cross-validation is the wrong experiment for valuation models. Implied price indices | |
| 26 | +> and implicit-price profiles agree across forms far more than the accuracy gap suggests. | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## 🎯 The four design axes | |
| 31 | + | |
| 32 | +| Axis | Models | Question | | |
| 33 | +|---|---|---| | |
| 34 | +| **A. Functional form** | linear, semi-log, log-log, Box–Cox (λ̂ by profile likelihood), quadratics, cubic splines | does curvature matter? | | |
| 35 | +| **B. Time effects** | none / year / quarter / month FE | how fine must the time dummies be? | | |
| 36 | +| **C. Spatial controls** | none / municipality / ~5.5 km grid / ~1.1 km grid FE | how fine can location FE get before overfitting? | | |
| 37 | +| **D. Estimation method** | OLS, ridge (CV), random forest, gradient boosting (± coordinates), spatial k-NN comparables | what do the machines actually buy? | | |
| 38 | + | |
| 39 | +Every model: same sales, same attributes (floor area, lot, age, storeys, units, | |
| 40 | +class, physical link), metrics on price levels (MdAPE, MAPE, RMSE_ln, R²_ln), | |
| 41 | +municipality-clustered design, and **two splits** — random 80/20 and | |
| 42 | +train < 2025 / test 2025–26 with carry-forward time effects. | |
| 43 | + | |
| 44 | +## 🗂 Repository layout | |
| 45 | + | |
| 46 | +``` | |
| 47 | +wp11_uqo/ | |
| 48 | +├── data/raw/ # snapshot (NOT in git — see data/raw/README.md) | |
| 49 | +├── src/wp11/ # config, sample, models (the harness), plotstyle | |
| 50 | +├── scripts/ | |
| 51 | +│ ├── 01_build_sample.py # 745,119 → 514,212 sales | |
| 52 | +│ ├── 02_horserace.py # 20 models × 2 splits + Box–Cox profile | |
| 53 | +│ ├── 03_extensions.py # indices, implicit-price profiles, learning curves, segments | |
| 54 | +│ ├── 04_make_figures.py # 9 journal-calibre figures | |
| 55 | +│ └── 05_make_tables.py # 7 LaTeX tables | |
| 56 | +├── results/{reproduced,tables}/ | |
| 57 | +├── figures/ | |
| 58 | +└── paper/ # main.tex + sections/ + references.bib → main.pdf | |
| 59 | +``` | |
| 60 | + | |
| 61 | +## 🚀 Quick start | |
| 62 | + | |
| 63 | +```bash | |
| 64 | +cd wp11_uqo | |
| 65 | +python3 -m pip install -r requirements.txt | |
| 66 | +# place the parquet snapshot in data/raw/ (not distributed), then: | |
| 67 | +for s in scripts/0*.py; do python3 "$s"; done | |
| 68 | +cd paper && make | |
| 69 | +``` | |
| 70 | + | |
| 71 | +Runtime ≈ 10 minutes on an Apple-silicon laptop (the boosting and forest fits | |
| 72 | +dominate); peak RAM ≈ 8 GB. | |
| 73 | + | |
| 74 | +## 📝 Citation | |
| 75 | + | |
| 76 | +```bibtex | |
| 77 | +@techreport{boucher2026horserace, | |
| 78 | + author = {Boucher, Simon-Pierre}, | |
| 79 | + title = {Half a Million Prices, Twenty Models: A Systematic Assessment | |
| 80 | + of Hedonic Specifications and Estimation Methods for the | |
| 81 | + Quebec Housing Market}, | |
| 82 | + institution = {Universit\'e du Qu\'ebec en Outaouais, | |
| 83 | + D\'epartement des sciences administratives}, | |
| 84 | + type = {Working Paper}, | |
| 85 | + number = {11}, | |
| 86 | + year = {2026}, | |
| 87 | + month = {August} | |
| 88 | +} | |
| 89 | +``` | |
| 90 | + | |
| 91 | +## 👤 Author & contact | |
| 92 | + | |
| 93 | +**Simon-Pierre Boucher** — Département des sciences administratives, | |
| 94 | +Université du Québec en Outaouais (UQO), Gatineau, QC. | |
| 95 | +📧 [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
added
data/raw/README.md
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai --> | |
| 2 | +# Raw data | |
| 3 | + | |
| 4 | +`transactions_700k_avec_registre_foncier.parquet` — 745,119 Quebec | |
| 5 | +residential-market transactions (January 2021 – July 2026) matched at the | |
| 6 | +parcel level to the municipal assessment roll in force at the sale date. | |
| 7 | +Same snapshot as UQO WP10; see that repository's data documentation for | |
| 8 | +the full column dictionary. | |
| 9 | + | |
| 10 | +Columns used by WP11: sale price/date/coordinates, and the roll's | |
| 11 | +structural descriptors — floor area (`role_aire_etages_m2`), lot area | |
| 12 | +(`role_superficie_terrain_m2`), year built (`role_annee_construction`), | |
| 13 | +units (`role_nb_logements`), storeys (`role_nb_etages`), physical link | |
| 14 | +(`role_lien_physique`), use code (`role_cubf`), municipality | |
| 15 | +(`role_code_mun`). | |
| 16 | + | |
| 17 | +The file is **not redistributed** with the repository (transaction | |
| 18 | +microdata; 100 MB). Place it in this directory before running the | |
| 19 | +pipeline. | |
added
figures/fig_forms.png
+0 −0
Binary file not shown.
added
figures/fig_generalization.png
+0 −0
Binary file not shown.
added
figures/fig_index.png
+0 −0
Binary file not shown.
added
figures/fig_learning.png
+0 −0
Binary file not shown.
added
figures/fig_methods.png
+0 −0
Binary file not shown.
added
figures/fig_profiles.png
+0 −0
Binary file not shown.
added
figures/fig_segments.png
+0 −0
Binary file not shown.
added
figures/fig_space.png
+0 −0
Binary file not shown.
added
figures/fig_time.png
+0 −0
Binary file not shown.
added
paper/.latexmkrc
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +$pdf_mode = 1; | |
| 3 | +$pdflatex = 'pdflatex -interaction=nonstopmode -halt-on-error -synctex=1 %O %S'; | |
| 4 | +$bibtex_use = 2; | |
| 5 | +$clean_ext = 'synctex.gz run.xml bbl bcf fdb_latexmk fls log aux out toc lof lot blg'; | |
| 6 | +@default_files = ('main.tex'); | |
added
paper/Makefile
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +all: | |
| 3 | + latexmk -pdf main.tex | |
| 4 | + | |
| 5 | +clean: | |
| 6 | + latexmk -c | |
| 7 | + | |
| 8 | +distclean: | |
| 9 | + latexmk -C | |
added
paper/main.pdf
+0 −0
Binary file not shown.
added
paper/main.tex
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +% UQO Working Paper No. 11 | |
| 4 | +% Half a Million Prices, Twenty Models | |
| 5 | +% | |
| 6 | +% Build: latexmk -pdf main.tex (or `make` in this directory) | |
| 7 | +% Figures are read from ../figures/, tables from ../results/tables/. | |
| 8 | +% ============================================================================ | |
| 9 | +\documentclass[12pt,letterpaper]{article} | |
| 10 | + | |
| 11 | +\usepackage[utf8]{inputenc} | |
| 12 | +\usepackage[T1]{fontenc} | |
| 13 | +\usepackage[english]{babel} | |
| 14 | + | |
| 15 | +\usepackage[letterpaper,margin=1in]{geometry} | |
| 16 | +\usepackage{setspace} | |
| 17 | +\onehalfspacing | |
| 18 | + | |
| 19 | +\usepackage{mathptmx} | |
| 20 | +\usepackage{microtype} | |
| 21 | + | |
| 22 | +\usepackage{amsmath,amssymb,amsthm} | |
| 23 | + | |
| 24 | +\usepackage{booktabs} | |
| 25 | +\usepackage{threeparttable} | |
| 26 | +\usepackage{makecell} | |
| 27 | + | |
| 28 | +\usepackage{graphicx} | |
| 29 | +\usepackage{subcaption} | |
| 30 | +\graphicspath{{../figures/}{./}} | |
| 31 | + | |
| 32 | +\usepackage[font=small,labelfont=bf,labelsep=period,justification=justified,singlelinecheck=false]{caption} | |
| 33 | + | |
| 34 | +\usepackage[dvipsnames]{xcolor} | |
| 35 | +\usepackage[colorlinks=true,linkcolor=NavyBlue,citecolor=NavyBlue,urlcolor=NavyBlue,breaklinks=true]{hyperref} | |
| 36 | + | |
| 37 | +\usepackage[authoryear,round,semicolon]{natbib} | |
| 38 | +\bibliographystyle{aer} | |
| 39 | + | |
| 40 | +\usepackage{titlesec} | |
| 41 | +\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{} | |
| 42 | +\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{} | |
| 43 | +\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{} | |
| 44 | +\usepackage{fancyhdr} | |
| 45 | +\pagestyle{fancy}\fancyhf{} | |
| 46 | +\renewcommand{\headrulewidth}{0pt} | |
| 47 | +\fancyfoot[C]{\thepage} | |
| 48 | +\usepackage{enumitem} | |
| 49 | +\setlist{nosep,leftmargin=*} | |
| 50 | + | |
| 51 | +% ============================================================================ | |
| 52 | +% METADATA | |
| 53 | +% ============================================================================ | |
| 54 | +\newcommand{\WPnumber}{11} | |
| 55 | +\newcommand{\WPtitle}{Half a Million Prices, Twenty Models} | |
| 56 | +\newcommand{\WPsubtitle}{A Systematic Assessment of Hedonic Specifications and | |
| 57 | + Estimation Methods for the Quebec Housing Market, 2021--2026} | |
| 58 | +\newcommand{\WPdate}{August 2026} | |
| 59 | +\newcommand{\WPversion}{1.0} | |
| 60 | +\newcommand{\WPkeywords}{Hedonic pricing, Functional form, House price | |
| 61 | + indexes, Machine learning, Automated valuation, Out-of-sample prediction} | |
| 62 | +\newcommand{\WPjel}{R31, C21, C52, C53} | |
| 63 | + | |
| 64 | +\newcommand{\WPauthor}{Simon-Pierre Boucher} | |
| 65 | +\newcommand{\WPaffiliation}{D\'epartement des sciences administratives\\ | |
| 66 | + Universit\'e du Qu\'ebec en Outaouais} | |
| 67 | +\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca} | |
| 68 | +\newcommand{\WPaddress}{Gatineau -- Pavillon Alexandre-Tach\'e\\ | |
| 69 | + 283, boulevard Alexandre-Tach\'e\\ Gatineau, Qu\'ebec, Canada J9A 1L8} | |
| 70 | + | |
| 71 | +\newcommand{\WPabstract}{% | |
| 72 | +Fifty years after \mbox{Rosen (1974)}, applied hedonic practice still rests | |
| 73 | +on a series of specification choices --- functional form, temporal controls, | |
| 74 | +spatial controls, estimation method --- that are usually made by convention | |
| 75 | +and rarely stress-tested jointly. Using 514{,}212 Quebec residential sales | |
| 76 | +(2021--2026) whose structural descriptors come from the provincial | |
| 77 | +assessment roll, we run a controlled horse race of twenty hedonic models | |
| 78 | +organised along those four axes, scoring every model on identical holdouts | |
| 79 | +and on the same price-level scoreboard (log models are Duan-retransformed, | |
| 80 | +Box--Cox inverted). Four lessons emerge. (1)~Functional form is a | |
| 81 | +second-order choice: from linear to splines, the median absolute error | |
| 82 | +spans 16.5--18.6\%, and the data-driven Box--Cox exponent | |
| 83 | +($\hat\lambda=0.25$) buys nothing out of sample. (2)~Controls are | |
| 84 | +first-order: dropping spatial fixed effects costs ten error points --- | |
| 85 | +location granularity matters far more than curvature, but only up to the | |
| 86 | +point where cells thin out (a $\sim$1~km grid \emph{overfits}). | |
| 87 | +(3)~Under random validation, estimation method matters most: gradient | |
| 88 | +boosting reaches 14.4\% --- a 13\% improvement over the best linear model | |
| 89 | +--- with random forests close behind, and coordinates alone reproduce | |
| 90 | +what thousands of dummies buy. (4)~But the ranking is an artifact of the | |
| 91 | +split: under a forward-in-time holdout (train $<$~2025, test 2025--26) | |
| 92 | +every model degrades and the machine-learning advantage \emph{reverses} | |
| 93 | +--- the spline hedonic posts a lower median error than gradient boosting | |
| 94 | +and an identical log-scale $R^2$ --- a stark warning against evaluating | |
| 95 | +valuation models with random cross-validation alone. Constant-quality | |
| 96 | +monthly indices implied by the competing forms agree to within a few | |
| 97 | +index points, and the boosted model's implicit age and floor-area | |
| 98 | +profiles track the quadratic OLS closely --- interpretation and | |
| 99 | +prediction disagree less than the random-validation gap suggests.} | |
| 100 | + | |
| 101 | +\begin{document} | |
| 102 | + | |
| 103 | +\input{sections/titlepage} | |
| 104 | + | |
| 105 | +\setcounter{page}{1} | |
| 106 | +\input{sections/introduction} | |
| 107 | +\input{sections/literature} | |
| 108 | +\input{sections/data} | |
| 109 | +\input{sections/methodology} | |
| 110 | +\input{sections/results} | |
| 111 | +\input{sections/extensions} | |
| 112 | +\input{sections/discussion} | |
| 113 | +\input{sections/conclusion} | |
| 114 | + | |
| 115 | +\newpage | |
| 116 | +\bibliography{references} | |
| 117 | + | |
| 118 | +\end{document} | |
added
paper/references.bib
+321 −0
@@ -0,0 +1,321 @@ | ||
| 1 | +@comment{ Author: Simon-Pierre Boucher (contact at spboucher.ai) -- Bibliography for UQO Working Paper No 11 } | |
| 2 | + | |
| 3 | +@article{rosen1974hedonic, | |
| 4 | + author = {Rosen, Sherwin}, | |
| 5 | + title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition}, | |
| 6 | + journal = {Journal of Political Economy}, | |
| 7 | + year = {1974}, | |
| 8 | + volume = {82}, | |
| 9 | + number = {1}, | |
| 10 | + pages = {34--55} | |
| 11 | +} | |
| 12 | + | |
| 13 | +@article{halvorsen1981choice, | |
| 14 | + author = {Halvorsen, Robert and Pollakowski, Henry O.}, | |
| 15 | + title = {Choice of Functional Form for Hedonic Price Equations}, | |
| 16 | + journal = {Journal of Urban Economics}, | |
| 17 | + year = {1981}, | |
| 18 | + volume = {10}, | |
| 19 | + number = {1}, | |
| 20 | + pages = {37--49} | |
| 21 | +} | |
| 22 | + | |
| 23 | +@article{cassel1985cost, | |
| 24 | + author = {Cassel, Eric and Mendelsohn, Robert}, | |
| 25 | + title = {The Choice of Functional Forms for Hedonic Price Equations: Comment}, | |
| 26 | + journal = {Journal of Urban Economics}, | |
| 27 | + year = {1985}, | |
| 28 | + volume = {18}, | |
| 29 | + number = {2}, | |
| 30 | + pages = {135--142} | |
| 31 | +} | |
| 32 | + | |
| 33 | +@article{cropper1988choice, | |
| 34 | + author = {Cropper, Maureen L. and Deck, Leland B. and McConnell, Kenneth E.}, | |
| 35 | + title = {On the Choice of Functional Form for Hedonic Price Functions}, | |
| 36 | + journal = {Review of Economics and Statistics}, | |
| 37 | + year = {1988}, | |
| 38 | + volume = {70}, | |
| 39 | + number = {4}, | |
| 40 | + pages = {668--675} | |
| 41 | +} | |
| 42 | + | |
| 43 | +@article{box1964analysis, | |
| 44 | + author = {Box, George E. P. and Cox, David R.}, | |
| 45 | + title = {An Analysis of Transformations}, | |
| 46 | + journal = {Journal of the Royal Statistical Society, Series B}, | |
| 47 | + year = {1964}, | |
| 48 | + volume = {26}, | |
| 49 | + number = {2}, | |
| 50 | + pages = {211--252} | |
| 51 | +} | |
| 52 | + | |
| 53 | +@incollection{malpezzi2003hedonic, | |
| 54 | + author = {Malpezzi, Stephen}, | |
| 55 | + title = {Hedonic Pricing Models: A Selective and Applied Review}, | |
| 56 | + booktitle = {Housing Economics and Public Policy}, | |
| 57 | + editor = {O'Sullivan, Tony and Gibb, Kenneth}, | |
| 58 | + publisher = {Blackwell}, | |
| 59 | + address = {Oxford}, | |
| 60 | + year = {2003}, | |
| 61 | + pages = {67--89} | |
| 62 | +} | |
| 63 | + | |
| 64 | +@article{sirmans2005composition, | |
| 65 | + author = {Sirmans, G. Stacy and Macpherson, David A. and Zietz, Emily N.}, | |
| 66 | + title = {The Composition of Hedonic Pricing Models}, | |
| 67 | + journal = {Journal of Real Estate Literature}, | |
| 68 | + year = {2005}, | |
| 69 | + volume = {13}, | |
| 70 | + number = {1}, | |
| 71 | + pages = {1--44} | |
| 72 | +} | |
| 73 | + | |
| 74 | +@article{duan1983smearing, | |
| 75 | + author = {Duan, Naihua}, | |
| 76 | + title = {Smearing Estimate: A Nonparametric Retransformation Method}, | |
| 77 | + journal = {Journal of the American Statistical Association}, | |
| 78 | + year = {1983}, | |
| 79 | + volume = {78}, | |
| 80 | + number = {383}, | |
| 81 | + pages = {605--610} | |
| 82 | +} | |
| 83 | + | |
| 84 | +@article{goodman1998housing, | |
| 85 | + author = {Goodman, Allen C. and Thibodeau, Thomas G.}, | |
| 86 | + title = {Housing Market Segmentation}, | |
| 87 | + journal = {Journal of Housing Economics}, | |
| 88 | + year = {1998}, | |
| 89 | + volume = {7}, | |
| 90 | + number = {2}, | |
| 91 | + pages = {121--143} | |
| 92 | +} | |
| 93 | + | |
| 94 | +@article{dubin1998spatial, | |
| 95 | + author = {Dubin, Robin A.}, | |
| 96 | + title = {Spatial Autocorrelation: A Primer}, | |
| 97 | + journal = {Journal of Housing Economics}, | |
| 98 | + year = {1998}, | |
| 99 | + volume = {7}, | |
| 100 | + number = {4}, | |
| 101 | + pages = {304--327} | |
| 102 | +} | |
| 103 | + | |
| 104 | +@book{anselin1988spatial, | |
| 105 | + author = {Anselin, Luc}, | |
| 106 | + title = {Spatial Econometrics: Methods and Models}, | |
| 107 | + publisher = {Kluwer Academic}, | |
| 108 | + address = {Dordrecht}, | |
| 109 | + year = {1988} | |
| 110 | +} | |
| 111 | + | |
| 112 | +@article{hill2013hedonic, | |
| 113 | + author = {Hill, Robert J.}, | |
| 114 | + title = {Hedonic Price Indexes for Residential Housing: A Survey, Evaluation and Taxonomy}, | |
| 115 | + journal = {Journal of Economic Surveys}, | |
| 116 | + year = {2013}, | |
| 117 | + volume = {27}, | |
| 118 | + number = {5}, | |
| 119 | + pages = {879--914} | |
| 120 | +} | |
| 121 | + | |
| 122 | +@article{silver2018house, | |
| 123 | + author = {Silver, Mick}, | |
| 124 | + title = {How to Measure Hedonic Property Price Indexes Better}, | |
| 125 | + journal = {EURONA — Eurostat Review on National Accounts and Macroeconomic Indicators}, | |
| 126 | + year = {2018}, | |
| 127 | + volume = {1}, | |
| 128 | + pages = {35--66} | |
| 129 | +} | |
| 130 | + | |
| 131 | +@article{diewert2004hedonic, | |
| 132 | + author = {Diewert, W. Erwin}, | |
| 133 | + title = {Hedonic Regressions: A Review of Some Unresolved Issues}, | |
| 134 | + journal = {Mimeo, University of British Columbia}, | |
| 135 | + year = {2004}, | |
| 136 | + volume = {}, | |
| 137 | + pages = {} | |
| 138 | +} | |
| 139 | + | |
| 140 | +@article{mullainathan2017machine, | |
| 141 | + author = {Mullainathan, Sendhil and Spiess, Jann}, | |
| 142 | + title = {Machine Learning: An Applied Econometric Approach}, | |
| 143 | + journal = {Journal of Economic Perspectives}, | |
| 144 | + year = {2017}, | |
| 145 | + volume = {31}, | |
| 146 | + number = {2}, | |
| 147 | + pages = {87--106} | |
| 148 | +} | |
| 149 | + | |
| 150 | +@article{athey2019machine, | |
| 151 | + author = {Athey, Susan and Imbens, Guido W.}, | |
| 152 | + title = {Machine Learning Methods That Economists Should Know About}, | |
| 153 | + journal = {Annual Review of Economics}, | |
| 154 | + year = {2019}, | |
| 155 | + volume = {11}, | |
| 156 | + pages = {685--725} | |
| 157 | +} | |
| 158 | + | |
| 159 | +@article{breiman2001random, | |
| 160 | + author = {Breiman, Leo}, | |
| 161 | + title = {Random Forests}, | |
| 162 | + journal = {Machine Learning}, | |
| 163 | + year = {2001}, | |
| 164 | + volume = {45}, | |
| 165 | + number = {1}, | |
| 166 | + pages = {5--32} | |
| 167 | +} | |
| 168 | + | |
| 169 | +@article{friedman2001greedy, | |
| 170 | + author = {Friedman, Jerome H.}, | |
| 171 | + title = {Greedy Function Approximation: A Gradient Boosting Machine}, | |
| 172 | + journal = {Annals of Statistics}, | |
| 173 | + year = {2001}, | |
| 174 | + volume = {29}, | |
| 175 | + number = {5}, | |
| 176 | + pages = {1189--1232} | |
| 177 | +} | |
| 178 | + | |
| 179 | +@article{ke2017lightgbm, | |
| 180 | + author = {Ke, Guolin and Meng, Qi and Finley, Thomas and Wang, Taifeng and Chen, Wei and Ma, Weidong and Ye, Qiwei and Liu, Tie-Yan}, | |
| 181 | + title = {{LightGBM}: A Highly Efficient Gradient Boosting Decision Tree}, | |
| 182 | + journal = {Advances in Neural Information Processing Systems}, | |
| 183 | + year = {2017}, | |
| 184 | + volume = {30}, | |
| 185 | + pages = {3146--3154} | |
| 186 | +} | |
| 187 | + | |
| 188 | +@article{mayer2019estimation, | |
| 189 | + author = {Mayer, Michael and Bourassa, Steven C. and Hoesli, Martin and Scognamiglio, Donato}, | |
| 190 | + title = {Estimation and Updating Methods for Hedonic Valuation}, | |
| 191 | + journal = {Journal of European Real Estate Research}, | |
| 192 | + year = {2019}, | |
| 193 | + volume = {12}, | |
| 194 | + number = {1}, | |
| 195 | + pages = {134--150} | |
| 196 | +} | |
| 197 | + | |
| 198 | +@article{bogin2020house, | |
| 199 | + author = {Bogin, Alexander N. and Shui, Jessica}, | |
| 200 | + title = {Appraisal Accuracy and Automated Valuation Models in Rural Areas}, | |
| 201 | + journal = {Journal of Real Estate Finance and Economics}, | |
| 202 | + year = {2020}, | |
| 203 | + volume = {60}, | |
| 204 | + number = {1}, | |
| 205 | + pages = {40--52} | |
| 206 | +} | |
| 207 | + | |
| 208 | +@article{steurer2021metrics, | |
| 209 | + author = {Steurer, Miriam and Hill, Robert J. and Pfeifer, Norbert}, | |
| 210 | + title = {Metrics for Evaluating the Performance of Machine Learning Based Automated Valuation Models}, | |
| 211 | + journal = {Journal of Property Research}, | |
| 212 | + year = {2021}, | |
| 213 | + volume = {38}, | |
| 214 | + number = {2}, | |
| 215 | + pages = {99--129} | |
| 216 | +} | |
| 217 | + | |
| 218 | +@article{pace1997spatial, | |
| 219 | + author = {Pace, R. Kelley and Gilley, Otis W.}, | |
| 220 | + title = {Using the Spatial Configuration of the Data to Improve Estimation}, | |
| 221 | + journal = {Journal of Real Estate Finance and Economics}, | |
| 222 | + year = {1997}, | |
| 223 | + volume = {14}, | |
| 224 | + number = {3}, | |
| 225 | + pages = {333--340} | |
| 226 | +} | |
| 227 | + | |
| 228 | +@article{case2004modeling, | |
| 229 | + author = {Case, Bradford and Clapp, John and Dubin, Robin and Rodriguez, Mauricio}, | |
| 230 | + title = {Modeling Spatial and Temporal House Price Patterns: A Comparison of Four Models}, | |
| 231 | + journal = {Journal of Real Estate Finance and Economics}, | |
| 232 | + year = {2004}, | |
| 233 | + volume = {29}, | |
| 234 | + number = {2}, | |
| 235 | + pages = {167--191} | |
| 236 | +} | |
| 237 | + | |
| 238 | +@article{bourassa2010predicting, | |
| 239 | + author = {Bourassa, Steven C. and Cantoni, Eva and Hoesli, Martin}, | |
| 240 | + title = {Predicting House Prices with Spatial Dependence: A Comparison of Alternative Methods}, | |
| 241 | + journal = {Journal of Real Estate Research}, | |
| 242 | + year = {2010}, | |
| 243 | + volume = {32}, | |
| 244 | + number = {2}, | |
| 245 | + pages = {139--159} | |
| 246 | +} | |
| 247 | + | |
| 248 | +@article{desrosiers2000hedonic, | |
| 249 | + author = {Des Rosiers, Fran{\c c}ois and Th{\'e}riault, Marius and Villeneuve, Paul-Y.}, | |
| 250 | + title = {Sorting Out Access and Neighbourhood Factors in Hedonic Price Modelling}, | |
| 251 | + journal = {Journal of Property Investment \& Finance}, | |
| 252 | + year = {2000}, | |
| 253 | + volume = {18}, | |
| 254 | + number = {3}, | |
| 255 | + pages = {291--315} | |
| 256 | +} | |
| 257 | + | |
| 258 | +@article{gencay1996nonlinear, | |
| 259 | + author = {Gen{\c c}ay, Ramazan and Yang, Xian}, | |
| 260 | + title = {A Forecast Comparison of Residential Housing Prices by Parametric versus Semiparametric Conditional Mean Estimators}, | |
| 261 | + journal = {Economics Letters}, | |
| 262 | + year = {1996}, | |
| 263 | + volume = {52}, | |
| 264 | + number = {2}, | |
| 265 | + pages = {129--135} | |
| 266 | +} | |
| 267 | + | |
| 268 | +@article{kok2017big, | |
| 269 | + author = {Kok, Nils and Koponen, Eija-Leena and Mart{\'i}nez-Barbosa, Carmen Adriana}, | |
| 270 | + title = {Big Data in Real Estate? From Manual Appraisal to Automated Valuation}, | |
| 271 | + journal = {Journal of Portfolio Management}, | |
| 272 | + year = {2017}, | |
| 273 | + volume = {43}, | |
| 274 | + number = {6}, | |
| 275 | + pages = {202--211} | |
| 276 | +} | |
| 277 | + | |
| 278 | +@article{hong2020machine, | |
| 279 | + author = {Hong, Jengei and Choi, Heeyoul and Kim, Woo-sung}, | |
| 280 | + title = {A House Price Valuation Based on the Random Forest Approach: The Mass Appraisal of Residential Property in South Korea}, | |
| 281 | + journal = {International Journal of Strategic Property Management}, | |
| 282 | + year = {2020}, | |
| 283 | + volume = {24}, | |
| 284 | + number = {3}, | |
| 285 | + pages = {140--152} | |
| 286 | +} | |
| 287 | + | |
| 288 | +@article{knight2003listing, | |
| 289 | + author = {Knight, John R.}, | |
| 290 | + title = {Hedonic Modeling of the Home Selling Process}, | |
| 291 | + journal = {Journal of Real Estate Finance and Economics}, | |
| 292 | + year = {2003}, | |
| 293 | + volume = {}, | |
| 294 | + pages = {} | |
| 295 | +} | |
| 296 | + | |
| 297 | +@book{iaao2013standard, | |
| 298 | + author = {{International Association of Assessing Officers}}, | |
| 299 | + title = {Standard on Ratio Studies}, | |
| 300 | + publisher = {IAAO}, | |
| 301 | + address = {Kansas City, MO}, | |
| 302 | + year = {2013} | |
| 303 | +} | |
| 304 | + | |
| 305 | +@article{clapp2002predicting, | |
| 306 | + author = {Clapp, John M. and Giaccotto, Carmelo}, | |
| 307 | + title = {Evaluating House Price Forecasts}, | |
| 308 | + journal = {Journal of Real Estate Research}, | |
| 309 | + year = {2002}, | |
| 310 | + volume = {24}, | |
| 311 | + number = {1}, | |
| 312 | + pages = {1--26} | |
| 313 | +} | |
| 314 | + | |
| 315 | +@techreport{oecd2013handbook, | |
| 316 | + author = {{Eurostat, ILO, IMF, OECD, UNECE, World Bank}}, | |
| 317 | + title = {Handbook on Residential Property Prices Indices ({RPPIs})}, | |
| 318 | + institution = {Eurostat}, | |
| 319 | + address = {Luxembourg}, | |
| 320 | + year = {2013} | |
| 321 | +} | |
added
paper/sections/conclusion.tex
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Conclusion} | |
| 4 | +\label{sec:concl} | |
| 5 | + | |
| 6 | +We ran the hedonic specification debate as a controlled experiment: | |
| 7 | +twenty models, four design axes, one attribute set, one scoreboard, two | |
| 8 | +holdouts, half a million sales. The verdict inverts the profession's | |
| 9 | +implicit priorities. The choices that consume the most methodological | |
| 10 | +attention --- functional form, transformation parameters --- move | |
| 11 | +out-of-sample accuracy by at most two points of median error, while the | |
| 12 | +choices often made by default --- whether and how finely to absorb space | |
| 13 | +and time --- move it by three to ten. Machine-learning estimators | |
| 14 | +dominate the standard scoreboard, but the standard scoreboard asks the | |
| 15 | +wrong question: when the test set lies in the future, as it always does | |
| 16 | +in deployment, the boosted ensemble's advantage reverses against a | |
| 17 | +spline hedonic with good controls, because most of what it learned was a | |
| 18 | +spatially fine price configuration that does not transfer across the | |
| 19 | +market cycle. | |
| 20 | + | |
| 21 | +Meanwhile the objects economists actually harvest from hedonic models | |
| 22 | +prove reassuringly stable: constant-quality indices agree across forms | |
| 23 | +to within a few points over a full boom--bust--recovery cycle, and the | |
| 24 | +machine's implicit age and area gradients replicate the quadratic OLS. | |
| 25 | +The practical synthesis is almost embarrassingly simple: a semi-log with | |
| 26 | +splines, quarter effects, and spatial fixed effects at a granularity | |
| 27 | +matched to market thickness is within two points of the frontier in | |
| 28 | +deployment conditions --- transparent, auditable, and fast. Where the | |
| 29 | +extra accuracy of learning methods is worth its retraining cadence --- | |
| 30 | +high-frequency AVMs, collateral monitoring --- our decomposition says to | |
| 31 | +spend the flexibility budget on the spatial surface, and to validate, | |
| 32 | +always, against the future rather than against a shuffle of the past. | |
added
paper/sections/data.tex
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Data} | |
| 4 | +\label{sec:data} | |
| 5 | + | |
| 6 | +\subsection{Sources} | |
| 7 | + | |
| 8 | +We use a registry of 745{,}119 Quebec residential-market transactions | |
| 9 | +recorded between January 2021 and July 2026 --- sale price, date, address | |
| 10 | +and coordinates --- each matched at the parcel level to the municipal | |
| 11 | +assessment roll in force at the sale date (median match distance | |
| 12 | +0.6~metres, validated against the roll's recorded value). The roll | |
| 13 | +contributes the structural descriptors that are notoriously missing from | |
| 14 | +transaction registries: total floor area, lot area, year of construction, | |
| 15 | +number of dwelling units, number of storeys, physical configuration | |
| 16 | +(detached, semi-detached, row, apartment-linked) and the standardized use | |
| 17 | +code. This combination --- market prices with assessor-grade structure | |
| 18 | +for half a million sales across an entire province --- is what makes a | |
| 19 | +controlled specification race feasible: every model sees exactly the same | |
| 20 | +attributes. | |
| 21 | + | |
| 22 | +\subsection{Sample} | |
| 23 | + | |
| 24 | +We keep residential use codes (dwellings, cottages, mobile homes), require | |
| 25 | +a high-confidence roll match (distance $\le 50$~m, score $\ge 150$), a | |
| 26 | +price of at least \$50{,}000, a plausible floor area (20--2{,}000~m$^2$) | |
| 27 | +and a known year of construction, and trim prices and floor areas at the | |
| 28 | +1st and 99th percentiles within each sale year. The estimation sample | |
| 29 | +contains \textbf{514{,}212 sales}: 348{,}253 single-family homes, 75{,}734 | |
| 30 | +condominiums, 74{,}909 plexes (2--5 units), 10{,}349 cottages and 4{,}881 | |
| 31 | +mobile homes, spread over 1{,}115 municipalities, 5{,}255 grid cells of | |
| 32 | +$\sim$5.5~km and 30{,}548 cells of $\sim$1.1~km. | |
| 33 | +Table~\ref{tab:sumstats} reports the descriptives. Prices average | |
| 34 | +\$462{,}000 (median \$399{,}000); the window covers the tail of the | |
| 35 | +post-pandemic boom, the 2022--2023 rate correction and the subsequent | |
| 36 | +recovery --- a demanding environment for any static price model, which we | |
| 37 | +exploit in the forward-in-time evaluation. | |
| 38 | + | |
| 39 | +\begin{table}[t] | |
| 40 | +\centering | |
| 41 | +\begin{threeparttable} | |
| 42 | +\caption{Summary statistics, estimation sample} | |
| 43 | +\label{tab:sumstats} | |
| 44 | +\small | |
| 45 | +\input{../results/tables/summary_stats} | |
| 46 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 47 | +\item \textit{Notes:} 514{,}212 residential sales, January 2021 -- July | |
| 48 | +2026. Structural descriptors from the assessment roll in force at the | |
| 49 | +sale date. Lot area is zero for condominiums and other units without an | |
| 50 | +exclusive lot. | |
| 51 | +\end{tablenotes} | |
| 52 | +\end{threeparttable} | |
| 53 | +\end{table} | |
added
paper/sections/discussion.tex
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Discussion} | |
| 4 | +\label{sec:disc} | |
| 5 | + | |
| 6 | +\subsection{A hierarchy of specification choices} | |
| 7 | + | |
| 8 | +Read jointly, the four axes imply a clear hierarchy of returns to | |
| 9 | +specification effort, useful to anyone building a hedonic model: | |
| 10 | + | |
| 11 | +\begin{enumerate} | |
| 12 | +\item \textbf{Get location right} ($\sim$10 points of median error): | |
| 13 | +any spatial absorption beats none by a margin that dwarfs every other | |
| 14 | +choice; the optimal granularity is interior --- around 5~km here --- | |
| 15 | +because finer cells trade bias for variance. | |
| 16 | +\item \textbf{Include some time control} ($\sim$3 points): in a moving | |
| 17 | +market, a model without time effects mistakes appreciation for | |
| 18 | +attributes; granularity beyond quarters is cosmetic. | |
| 19 | +\item \textbf{Choose the estimator for the job} ($\sim$2 points | |
| 20 | +deployed, $\sim$4 under random validation): boosting buys real accuracy | |
| 21 | +in interpolation-heavy uses (filling gaps within a period, mass | |
| 22 | +appraisal with contemporaneous comparables) and much less when the | |
| 23 | +target is the future. | |
| 24 | +\item \textbf{Stop worrying about functional form} ($\lesssim$2 | |
| 25 | +points): splines are a cheap upgrade; the Box--Cox machinery is not | |
| 26 | +worth its complexity, exactly as \citet{cropper1988choice} concluded | |
| 27 | +from simulations four decades ago. | |
| 28 | +\end{enumerate} | |
| 29 | + | |
| 30 | +\subsection{Why the machines lose their edge out of time} | |
| 31 | + | |
| 32 | +The decomposition rows explain the generalization gap. The boosting | |
| 33 | +model's random-split advantage comes almost entirely from a flexible | |
| 34 | +spatial surface (removing coordinates costs it 8.8 points). That surface | |
| 35 | +is estimated \emph{jointly} with the time path, and trees clamp: beyond | |
| 36 | +the last training month the model prices 2025--26 sales at a frozen | |
| 37 | +level, exactly like the carry-forward linear models, while its | |
| 38 | +fine-grained spatial fit --- calibrated on the 2021--24 price | |
| 39 | +configuration --- partially decays as relative prices shift. The linear | |
| 40 | +models, coarser but more rigid, carry a structure that transfers better. | |
| 41 | +This is not an indictment of machine learning --- retrained monthly, the | |
| 42 | +boosting model would presumably keep its interpolation advantage --- but | |
| 43 | +it prices the \emph{retraining requirement} that random cross-validation | |
| 44 | +hides, and it matches the practitioner evidence that AVM accuracy decays | |
| 45 | +quickly out of sample period \citep{bogin2020house, kok2017big}. | |
| 46 | + | |
| 47 | +\subsection{Implications} | |
| 48 | + | |
| 49 | +\paragraph{For research.} Hedonic coefficients and indices are robust | |
| 50 | +objects: form and even estimator perturb them little | |
| 51 | +(Section~\ref{sec:ext}). Researchers using hedonics to \emph{measure} | |
| 52 | +--- indices, implicit prices, capitalization effects --- can keep simple | |
| 53 | +forms with good controls and report validation under a temporal split | |
| 54 | +when prediction claims are made. | |
| 55 | + | |
| 56 | +\paragraph{For mass appraisal.} Quebec's assessors face exactly this | |
| 57 | +design problem every three years. Our results suggest the largest | |
| 58 | +accuracy gains lie not in exotic estimators but in spatial resolution | |
| 59 | +chosen by market thickness --- and that any model, linear or boosted, | |
| 60 | +frozen at a reference date degrades by 3--7 points of median error | |
| 61 | +within two years. This quantifies the staleness mechanism behind the | |
| 62 | +assessment inequities documented in our companion paper (UQO WP10): the | |
| 63 | +valuation technology's out-of-time decay is of the same magnitude as the | |
| 64 | +inequities measured there. | |
| 65 | + | |
| 66 | +\paragraph{For the AVM literature.} Every comparison should report a | |
| 67 | +forward-in-time split alongside random cross-validation | |
| 68 | +\citep{steurer2021metrics}. On our data, the choice of split changes not | |
| 69 | +just magnitudes but the \emph{winner}. | |
| 70 | + | |
| 71 | +\subsection{Limitations} | |
| 72 | + | |
| 73 | +Our attribute set, though assessor-grade, omits listing-level quality | |
| 74 | +(renovations, finishes, views); richer features would likely raise the | |
| 75 | +machines' interpolation advantage. Hyper-parameters were deliberately | |
| 76 | +conventional --- a tuned boosted model would gain somewhat, as would a | |
| 77 | +spatially smarter linear model (e.g.\ kriging residuals, | |
| 78 | +\citealp{case2004modeling}). The forward split covers one specific | |
| 79 | +regime change (the 2025--26 recovery); other windows would shift | |
| 80 | +magnitudes. And Quebec's institutional homogeneity aids transferability | |
| 81 | +of results across municipalities but may limit it across countries. | |
added
paper/sections/extensions.tex
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Beyond the scoreboard: what the models imply} | |
| 4 | +\label{sec:ext} | |
| 5 | + | |
| 6 | +Accuracy is not the only output economists take from a hedonic model. This | |
| 7 | +section asks whether the specification choices that move the scoreboard | |
| 8 | +also move the two objects hedonic models are most often built to deliver: | |
| 9 | +constant-quality price indices and implicit attribute prices. | |
| 10 | + | |
| 11 | +\subsection{Price indices are form-robust} | |
| 12 | + | |
| 13 | +Figure~\ref{fig:index} plots the constant-quality monthly index implied by | |
| 14 | +the month fixed effects of four linear forms --- semi-log, log-log, | |
| 15 | +quadratic and spline --- together with a gradient-boosting index obtained | |
| 16 | +by repricing a fixed 20{,}000-sale reference portfolio at each month | |
| 17 | +(an imputation \emph{\`a la} \citealp{hill2013hedonic}). All five track | |
| 18 | +the same cycle --- the 2021--22 boom, the 2022--23 correction, the | |
| 19 | +2024--26 recovery --- and the four linear forms agree to within a few | |
| 20 | +index points throughout. The choice that dominates the accuracy | |
| 21 | +scoreboard barely perturbs the index: reassuring news for statistical | |
| 22 | +agencies, and consistent with the RPPI handbook's pragmatism about form | |
| 23 | +\citep{oecd2013handbook, silver2018house}. | |
| 24 | + | |
| 25 | +\begin{figure}[t] | |
| 26 | +\centering | |
| 27 | +\includegraphics[width=\textwidth]{fig_index.png} | |
| 28 | +\caption{Constant-quality monthly price indices implied by competing | |
| 29 | +specifications (January 2021 = 100). Linear forms: exponentiated month | |
| 30 | +fixed effects. Gradient boosting: mean repriced value of a fixed | |
| 31 | +20{,}000-sale reference portfolio.} | |
| 32 | +\label{fig:index} | |
| 33 | +\end{figure} | |
| 34 | + | |
| 35 | +\subsection{Implicit prices: the machine agrees with the quadratic} | |
| 36 | + | |
| 37 | +Figure~\ref{fig:profiles} compares the ln-price profiles in building age | |
| 38 | +and floor area implied by the quadratic OLS coefficients with the partial-% | |
| 39 | +dependence profiles of the boosting model \citep{friedman2001greedy}. | |
| 40 | +The two agree over the bulk of the data: steep initial depreciation that | |
| 41 | +flattens with age, and strongly concave returns to floor space. The | |
| 42 | +visible divergences are where the quadratic \emph{cannot} bend --- the | |
| 43 | +machine sees a milder gradient for very old stock (a vintage effect) and | |
| 44 | +a small new-construction premium spike --- but through the ages and areas | |
| 45 | +where 90\% of sales live, the curves sit within a tenth of a log point. The boosting model's advantage evidently comes from | |
| 46 | +higher-order interactions and spatial flexibility, \emph{not} from a | |
| 47 | +different view of the main attribute gradients --- so the interpretable | |
| 48 | +quadratic form remains a faithful summary of the price surface even where | |
| 49 | +it loses the prediction race \citep{mullainathan2017machine, | |
| 50 | +athey2019machine}. | |
| 51 | + | |
| 52 | +\begin{figure}[t] | |
| 53 | +\centering | |
| 54 | +\includegraphics[width=\textwidth]{fig_profiles.png} | |
| 55 | +\caption{Implicit ln-price profiles: quadratic OLS coefficients versus | |
| 56 | +gradient-boosting partial dependence, each normalized to its leftmost | |
| 57 | +point. Panel A: building age. Panel B: floor area.} | |
| 58 | +\label{fig:profiles} | |
| 59 | +\end{figure} | |
| 60 | + | |
| 61 | +\subsection{Learning curves: data beat flexibility only at scale} | |
| 62 | + | |
| 63 | +Figure~\ref{fig:learning} and Table~\ref{tab:learning} trace accuracy | |
| 64 | +against training size from 10{,}000 to 400{,}000 sales. The OLS quadratic | |
| 65 | +is essentially flat from the very first step (18.4\% at $n=10{,}000$, | |
| 66 | +18.1\% at $n=400{,}000$) --- its bias floor binds almost immediately --- | |
| 67 | +while the boosting model improves monotonically through the full sample | |
| 68 | +(17.6\% $\to$ 14.5\%): the machine's advantage grows from under one | |
| 69 | +error point at $n = 10{,}000$ to 3.6 points at | |
| 70 | +$n = 400{,}000$. Flexible methods | |
| 71 | +are not a free lunch in small markets: below a few tens of thousands of | |
| 72 | +training sales, the machine's edge is modest, one reason rural and thin-% | |
| 73 | +market AVMs underperform \citep{bogin2020house}. | |
| 74 | + | |
| 75 | +\begin{figure}[t] | |
| 76 | +\centering | |
| 77 | +\includegraphics[width=0.72\textwidth]{fig_learning.png} | |
| 78 | +\caption{Learning curves: median absolute error against training-set | |
| 79 | +size (random holdout fixed), OLS quadratic versus gradient boosting.} | |
| 80 | +\label{fig:learning} | |
| 81 | +\end{figure} | |
| 82 | + | |
| 83 | +\begin{table}[t] | |
| 84 | +\centering | |
| 85 | +\begin{threeparttable} | |
| 86 | +\caption{Learning curves} | |
| 87 | +\label{tab:learning} | |
| 88 | +\small | |
| 89 | +\input{../results/tables/learning} | |
| 90 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 91 | +\item \textit{Notes:} MdAPE (\%) on the fixed random holdout, training on | |
| 92 | +seeded subsamples of the indicated size. | |
| 93 | +\end{tablenotes} | |
| 94 | +\end{threeparttable} | |
| 95 | +\end{table} | |
| 96 | + | |
| 97 | +\subsection{Where the machine wins: segments} | |
| 98 | + | |
| 99 | +Table~\ref{tab:segments} and Figure~\ref{fig:segments} decompose the | |
| 100 | +random-holdout MdAPE by property class and municipality size, and the | |
| 101 | +pattern identifies the machine's edge with striking precision: it is | |
| 102 | +spatial resolution. The gap is enormous for condominiums (9.1\% versus | |
| 103 | +20.4\%) and in the largest cities (11.9\% versus 17.8\%) --- segments | |
| 104 | +where structure is homogeneous but \emph{micro}-location varies hugely | |
| 105 | +within a municipality, exactly the variation that municipality dummies | |
| 106 | +cannot see and raw coordinates can. Once location is resolved, a condo | |
| 107 | +is the most predictable object in the market. At the other extreme, | |
| 108 | +cottages defeat both models (32.1\% versus 33.9\%): idiosyncratic, | |
| 109 | +waterfront-driven stock is hard for everyone, and the machine's spatial | |
| 110 | +surface cannot rescue attributes it does not observe. The complement of | |
| 111 | +the assessment-inequity anatomy in our companion paper (UQO WP10) is | |
| 112 | +instructive: the segments where assessors are most regressive | |
| 113 | +(heterogeneous, land-heavy stock) are also those where \emph{no} | |
| 114 | +standard technology, linear or boosted, prices accurately from roll | |
| 115 | +attributes alone. | |
| 116 | + | |
| 117 | +\begin{figure}[t] | |
| 118 | +\centering | |
| 119 | +\includegraphics[width=0.8\textwidth]{fig_segments.png} | |
| 120 | +\caption{Median absolute error by market segment (random holdout): | |
| 121 | +OLS quadratic versus gradient boosting.} | |
| 122 | +\label{fig:segments} | |
| 123 | +\end{figure} | |
| 124 | + | |
| 125 | +\begin{table}[t] | |
| 126 | +\centering | |
| 127 | +\begin{threeparttable} | |
| 128 | +\caption{Accuracy by market segment} | |
| 129 | +\label{tab:segments} | |
| 130 | +\small | |
| 131 | +\input{../results/tables/segments} | |
| 132 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 133 | +\item \textit{Notes:} MdAPE (\%) on the random holdout, by property class | |
| 134 | +and by the number of 2021--2026 sales in the municipality. | |
| 135 | +\end{tablenotes} | |
| 136 | +\end{threeparttable} | |
| 137 | +\end{table} | |
added
paper/sections/introduction.tex
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Introduction} | |
| 4 | +\label{sec:intro} | |
| 5 | + | |
| 6 | +Every applied hedonic study begins with a series of quiet decisions. Log | |
| 7 | +the price or not? Dummies for years or for months? Neighbourhood fixed | |
| 8 | +effects, and how fine? Least squares or, increasingly, a gradient-boosted | |
| 9 | +ensemble? Fifty years after \citet{rosen1974hedonic} established that | |
| 10 | +theory places no restriction on the shape of the price surface, these | |
| 11 | +choices are still made largely by convention --- the semi-log survives as | |
| 12 | +the default \citep{sirmans2005composition}, the classic form | |
| 13 | +comparisons predate the out-of-sample era \citep{halvorsen1981choice, | |
| 14 | +cropper1988choice}, and the machine-learning comparisons that have | |
| 15 | +multiplied since \citet{mullainathan2017machine} typically change the | |
| 16 | +sample, the features and the validation scheme all at once, making it | |
| 17 | +impossible to say \emph{which} ingredient buys the reported gains. | |
| 18 | + | |
| 19 | +This paper runs the comparison the way one would design an experiment. | |
| 20 | +From 514{,}212 Quebec residential sales (2021--2026) matched at the | |
| 21 | +parcel level to the provincial assessment roll --- so that every model | |
| 22 | +sees the same assessor-grade attribute set: floor area, lot area, age, | |
| 23 | +storeys, units, property class, physical configuration --- we estimate | |
| 24 | +\textbf{twenty models organised along four axes}, varying one design | |
| 25 | +choice at a time: six functional forms (linear to Box--Cox to splines), | |
| 26 | +a ladder of time controls (none to month fixed effects), a ladder of | |
| 27 | +spatial controls (none to a $\sim$1~km grid), and six estimation methods | |
| 28 | +(OLS to random forests, gradient boosting, and a $k$-nearest-neighbour | |
| 29 | +comparables rule). Every model is scored on the same price-level | |
| 30 | +scoreboard --- log models retransformed with Duan smearing, Box--Cox | |
| 31 | +inverted --- and, crucially, under \textbf{two holdouts}: the standard | |
| 32 | +random 80/20 split, and a forward-in-time split (train before 2025, test | |
| 33 | +2025--26) that mimics how a valuation model is actually deployed. | |
| 34 | + | |
| 35 | +Four results organise the paper. | |
| 36 | + | |
| 37 | +\textbf{First, functional form is a second-order choice.} Across linear, | |
| 38 | +semi-log, log-log, profile-likelihood Box--Cox | |
| 39 | +($\hat\lambda = 0.25$), quadratic and spline specifications --- holding | |
| 40 | +controls fixed --- the median absolute error spans 16.5\% to 18.6\%. | |
| 41 | +Splines beat the textbook semi-log by 1.6 points; the data-driven | |
| 42 | +Box--Cox transformation, the great hope of the 1980s debate, buys | |
| 43 | +nothing out of sample, vindicating \citet{cropper1988choice} and | |
| 44 | +\citet{cassel1985cost} on modern data at scale. | |
| 45 | + | |
| 46 | +\textbf{Second, controls are first-order --- with a twist.} Removing all | |
| 47 | +spatial controls costs ten error points (27.9\% versus 17.5\%), an order | |
| 48 | +of magnitude more than any curvature decision; a $\sim$5.5~km grid beats | |
| 49 | +both municipality effects and, notably, a $\sim$1.1~km grid, whose | |
| 50 | +30{,}548 cells overfit --- the bias--variance trade-off in absorbing | |
| 51 | +location is real and non-monotonic. Time effects show the same shape in | |
| 52 | +miniature: anything beats nothing (three points), granularity beyond | |
| 53 | +quarters buys nothing. | |
| 54 | + | |
| 55 | +\textbf{Third, under random validation the estimation method dominates.} | |
| 56 | +Gradient boosting reaches a median error of 14.4\% and random forests | |
| 57 | +14.6\%, against 16.5\% for the best linear model --- a 13\% improvement | |
| 58 | +--- and stripping the coordinates from the boosting model degrades it to | |
| 59 | +23.2\%, showing that what the machine mainly learns is a flexible price | |
| 60 | +surface over space \citep{bourassa2010predicting}. The na\"ive | |
| 61 | +$k$-NN comparables rule (21.2\%) confirms that neither raw proximity nor | |
| 62 | +raw flexibility suffices: the gains come from their combination. | |
| 63 | + | |
| 64 | +\textbf{Fourth --- our headline --- the ranking is an artifact of the | |
| 65 | +validation scheme.} Under the forward-in-time split every model degrades, | |
| 66 | +but not equally: the machine-learning advantage \emph{reverses} on the | |
| 67 | +median error (gradient boosting 21.2\% versus 19.1\% for the spline | |
| 68 | +hedonic) and equalizes exactly on log-scale $R^2$ (0.52 for both). | |
| 69 | +Random cross-validation lets flexible models interpolate the price level | |
| 70 | +of their own test period; when the test period lies in the future --- the | |
| 71 | +only case that matters for deployment --- that advantage is leakage, not | |
| 72 | +skill. Comparisons of valuation models that report random cross-validation | |
| 73 | +alone, i.e.\ most of the applied ML literature, systematically overstate | |
| 74 | +the practical value of model flexibility \citep{clapp2002predicting, | |
| 75 | +steurer2021metrics}. | |
| 76 | + | |
| 77 | +The paper then asks what the specification choices do to the objects | |
| 78 | +economists actually extract from hedonic models. Constant-quality monthly | |
| 79 | +indices implied by the month effects of four linear forms --- and by | |
| 80 | +repricing a fixed portfolio with the boosting model --- agree to within a | |
| 81 | +few index points across the largest housing cycle in recent Canadian | |
| 82 | +history. And the boosting model's partial-dependence profiles in age and | |
| 83 | +floor area track the quadratic OLS closely: the machine agrees with the | |
| 84 | +economist about the gradients and disagrees mainly about the residual | |
| 85 | +surface. Interpretation and prediction, in other words, conflict far less | |
| 86 | +than the accuracy scoreboard suggests. | |
| 87 | + | |
| 88 | +Section~\ref{sec:lit} situates the exercise in the functional-form, | |
| 89 | +spatial-hedonic and ML-valuation literatures. Section~\ref{sec:data} | |
| 90 | +describes the data, Section~\ref{sec:method} the experimental design. | |
| 91 | +Section~\ref{sec:results} reports the horse race, | |
| 92 | +Section~\ref{sec:ext} the index, implicit-price, learning-curve and | |
| 93 | +segment extensions, Section~\ref{sec:disc} discusses implications, and | |
| 94 | +Section~\ref{sec:concl} concludes. | |
added
paper/sections/literature.tex
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Related literature} | |
| 4 | +\label{sec:lit} | |
| 5 | + | |
| 6 | +\subsection{The functional-form debate} | |
| 7 | + | |
| 8 | +Hedonic theory is silent about functional form. \citet{rosen1974hedonic} | |
| 9 | +showed that the price surface is an equilibrium envelope of bids and | |
| 10 | +offers, so nothing structural pins down its curvature --- | |
| 11 | +form is an empirical choice. The classical exchange settled into a | |
| 12 | +three-cornered debate: \citet{halvorsen1981choice} advocated data-driven | |
| 13 | +Box--Cox transformations \citep{box1964analysis}; | |
| 14 | +\citet{cassel1985cost} cautioned that flexible transformations optimize | |
| 15 | +in-sample fit while degrading the implicit prices researchers actually | |
| 16 | +use; and \citet{cropper1988choice}, in an influential simulation study, | |
| 17 | +found that simple forms --- linear and semi-log --- are the most robust to | |
| 18 | +the misspecification and omitted variables that characterize real data. | |
| 19 | +Fifty years on, the semi-log remains the profession's default | |
| 20 | +\citep{malpezzi2003hedonic, sirmans2005composition}, but systematic | |
| 21 | +head-to-head evidence on modern samples remains scarce, and the classic | |
| 22 | +studies predate the out-of-sample validation culture. Two further | |
| 23 | +technical strands feed our design: retransformation bias in log models | |
| 24 | +\citep{duan1983smearing}, which we correct with the smearing factor so | |
| 25 | +that every form competes in levels; and forecast-evaluation methodology | |
| 26 | +for house prices \citep{clapp2002predicting, steurer2021metrics}. | |
| 27 | + | |
| 28 | +\subsection{Space and time in hedonic models} | |
| 29 | + | |
| 30 | +A second literature concerns what the covariates cannot capture. House | |
| 31 | +prices are spatially autocorrelated at fine scale | |
| 32 | +\citep{dubin1998spatial, anselin1988spatial}, and the practical remedies | |
| 33 | +range from submarket segmentation \citep{goodman1998housing} to spatial | |
| 34 | +econometrics and simple location fixed effects; comparative studies | |
| 35 | +generally find that flexibly absorbing location buys more predictive | |
| 36 | +accuracy than any parametric spatial process \citep{case2004modeling, | |
| 37 | +bourassa2010predicting, pace1997spatial}. In the Quebec context, | |
| 38 | +\citet{desrosiers2000hedonic} document the importance of access and | |
| 39 | +neighbourhood attributes in Quebec City. On the time dimension, hedonic | |
| 40 | +price-index theory --- the time-dummy versus imputation debate --- | |
| 41 | +is surveyed by \citet{hill2013hedonic} and codified in the RPPI handbook | |
| 42 | +\citep{oecd2013handbook, silver2018house}; a byproduct of our month-FE | |
| 43 | +models is a direct test of how sensitive a constant-quality index is to | |
| 44 | +the underlying form. | |
| 45 | + | |
| 46 | +\subsection{Machine learning and automated valuation} | |
| 47 | + | |
| 48 | +The third strand is the rapid colonization of mass valuation by machine | |
| 49 | +learning. \citet{mullainathan2017machine} frame prediction as ML's | |
| 50 | +comparative advantage, with hedonic pricing as their running example; | |
| 51 | +\citet{athey2019machine} survey the toolkit. Applied comparisons --- | |
| 52 | +random forests \citep{breiman2001random}, gradient boosting | |
| 53 | +\citep{friedman2001greedy, ke2017lightgbm} --- consistently report 20--40\% | |
| 54 | +error reductions over linear hedonics \citep{mayer2019estimation, | |
| 55 | +hong2020machine, kok2017big}, with accuracy degrading in thin rural | |
| 56 | +markets \citep{bogin2020house}; \citet{gencay1996nonlinear} anticipated | |
| 57 | +the pattern with semiparametric estimators. Three gaps motivate our | |
| 58 | +design. First, most comparisons change several ingredients at once | |
| 59 | +(features, sample, validation scheme), so the \emph{sources} of the ML | |
| 60 | +advantage --- curvature? interactions? spatial flexibility? --- are rarely | |
| 61 | +isolated; our axis design holds features constant and varies one choice | |
| 62 | +at a time. Second, almost all published comparisons use random | |
| 63 | +cross-validation, which leaks future price levels into training and | |
| 64 | +flatters any flexible model; we score every model under a | |
| 65 | +forward-in-time split as well. Third, comparisons seldom examine what the | |
| 66 | +flexible models \emph{imply} --- indices, implicit prices --- which is | |
| 67 | +what economists need; our extensions section does exactly that. | |
added
paper/sections/methodology.tex
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{Methodology: the design of the horse race} | |
| 4 | +\label{sec:method} | |
| 5 | + | |
| 6 | +The comparison is organised so that each design choice varies while | |
| 7 | +everything else is held fixed. All models price the same sales from the | |
| 8 | +same attribute set; only the functional mapping changes. | |
| 9 | + | |
| 10 | +\subsection{The four axes} | |
| 11 | + | |
| 12 | +\paragraph{A. Functional form.} With municipality and quarter fixed | |
| 13 | +effects and the base attributes (floor area, lot area, building age, | |
| 14 | +storeys, units, property class, physical configuration), we estimate: | |
| 15 | +(A1) fully linear in levels; (A2) semi-log, $\ln P$ on levels | |
| 16 | +\citep{malpezzi2003hedonic}; (A3) log-log, $\ln P$ on logged areas; | |
| 17 | +(A4) Box--Cox on the price, with $\lambda$ chosen by profile likelihood | |
| 18 | +on the training set over a grid \citep{box1964analysis, | |
| 19 | +halvorsen1981choice}; (A5) semi-log with quadratics in area and age; and | |
| 20 | +(A6) semi-log with cubic $B$-splines (six knots) in area, age and lot. | |
| 21 | + | |
| 22 | +\paragraph{B. Time effects.} On the A5 backbone: no time controls, | |
| 23 | +year fixed effects, quarter fixed effects, month fixed effects --- | |
| 24 | +the granularity ladder of the time-dummy index literature | |
| 25 | +\citep{hill2013hedonic}. | |
| 26 | + | |
| 27 | +\paragraph{C. Spatial controls.} On the A5 backbone with quarter FE: | |
| 28 | +no spatial controls, municipality FE (1{,}115), a $\sim$5.5~km grid | |
| 29 | +(5{,}255 cells), and a $\sim$1.1~km grid (30{,}548 cells) --- a | |
| 30 | +granularity ladder that brackets the bias--variance trade-off in | |
| 31 | +absorbing location \citep{case2004modeling, bourassa2010predicting}. | |
| 32 | + | |
| 33 | +\paragraph{D. Estimation method.} Holding the attribute set fixed: | |
| 34 | +(D1) OLS with municipality and month FE; (D2) cross-validated ridge on | |
| 35 | +the same standardized design; (D3) a random forest | |
| 36 | +\citep{breiman2001random} and (D4) gradient boosting | |
| 37 | +\citep{friedman2001greedy} on the attributes plus raw coordinates and a | |
| 38 | +continuous month counter; (D5) gradient boosting \emph{without} | |
| 39 | +coordinates, to price what spatial flexibility is worth to the machine; | |
| 40 | +and (D6) a spatial $k$-nearest-neighbour comparables rule --- the | |
| 41 | +distance-weighted price per square metre of the ten nearest training | |
| 42 | +sales --- as the na\"ive appraiser benchmark. | |
| 43 | + | |
| 44 | +\subsection{One scoreboard: price levels} | |
| 45 | + | |
| 46 | +Comparing forms whose dependent variables differ requires care. Every | |
| 47 | +model is scored on \emph{predicted price levels}: log models are | |
| 48 | +retransformed with Duan's smearing factor estimated on training | |
| 49 | +residuals \citep{duan1983smearing}; Box--Cox predictions are analytically | |
| 50 | +inverted; level predictions are floored at \$20{,}000. We report the | |
| 51 | +median and mean absolute percentage errors (MdAPE, MAPE) --- the | |
| 52 | +automated-valuation industry's standards \citep{steurer2021metrics, | |
| 53 | +iaao2013standard} --- together with the RMSE and $R^2$ of log predicted | |
| 54 | +versus log actual prices, which are well-defined for every model. | |
| 55 | + | |
| 56 | +\subsection{Two holdouts} | |
| 57 | + | |
| 58 | +Each model is evaluated under two splits. The \emph{random} split holds | |
| 59 | +out 20\% of sales (seeded). The \emph{forward-in-time} split trains on | |
| 60 | +sales before January 2025 (355{,}824) and tests on 2025--2026 | |
| 61 | +(158{,}388) --- the deployment situation of any valuation model. Static | |
| 62 | +models carry no forecast of the price level, so we apply the standard | |
| 63 | +carry-forward rule: unseen time categories in the test period are priced | |
| 64 | +at the last level observed in training; the machine-learning models | |
| 65 | +receive the same information through the clamped month counter. Random | |
| 66 | +cross-validation lets every model interpolate the price level of its own | |
| 67 | +test period --- the forward split reveals how much of measured accuracy | |
| 68 | +is such leakage \citep{mullainathan2017machine, clapp2002predicting}. | |
| 69 | + | |
| 70 | +\subsection{Estimation details} | |
| 71 | + | |
| 72 | +All linear models are estimated as sparse ridge regressions with a | |
| 73 | +vanishing penalty ($\alpha = 10^{-6}$) --- numerically OLS, but robust to | |
| 74 | +collinear dummies, and unseen categories at prediction time are priced at | |
| 75 | +the reference level. Dummies are one-hot with a dropped reference; the | |
| 76 | +ridge (D2) standardizes columns and cross-validates | |
| 77 | +$\alpha \in [10^{-6}, 10^{2}]$. The forest uses 120 trees | |
| 78 | +(minimum leaf 5, half the features per split); the boosting machine uses | |
| 79 | +600 trees of at most 63 leaves (learning rate 0.08, minimum leaf 20). | |
| 80 | +Hyper-parameters were fixed a priori at library-conventional values --- | |
| 81 | +the exercise compares model \emph{classes} as used in practice, not tuned | |
| 82 | +frontier performance. | |
added
paper/sections/results.tex
+193 −0
@@ -0,0 +1,193 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +\section{The horse race} | |
| 4 | +\label{sec:results} | |
| 5 | + | |
| 6 | +Table~\ref{tab:race_random} reports the full scoreboard under the random | |
| 7 | +holdout (411{,}283 training sales, 102{,}929 test sales); | |
| 8 | +Table~\ref{tab:race_temporal} repeats it under the forward-in-time split. | |
| 9 | +Figures~\ref{fig:forms}--\ref{fig:methods} visualize each axis. | |
| 10 | + | |
| 11 | +\begin{table}[p] | |
| 12 | +\centering | |
| 13 | +\begin{threeparttable} | |
| 14 | +\caption{The scoreboard, random 80/20 holdout} | |
| 15 | +\label{tab:race_random} | |
| 16 | +\small | |
| 17 | +\input{../results/tables/horserace_random} | |
| 18 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 19 | +\item \textit{Notes:} All metrics on the held-out 20\% (102{,}929 sales). | |
| 20 | +MdAPE/MAPE: median/mean absolute percentage error on price levels (log | |
| 21 | +models retransformed with Duan smearing; Box--Cox inverted). | |
| 22 | +RMSE$_{\ln}$ and $R^2_{\ln}$: computed on log predicted vs.\ log actual | |
| 23 | +prices. Backbone rows repeat across axes by construction (A5 $=$ B3 $=$ | |
| 24 | +C2; B4 $=$ D1). Bold: best in axis. | |
| 25 | +\end{tablenotes} | |
| 26 | +\end{threeparttable} | |
| 27 | +\end{table} | |
| 28 | + | |
| 29 | +\begin{table}[p] | |
| 30 | +\centering | |
| 31 | +\begin{threeparttable} | |
| 32 | +\caption{The scoreboard, forward-in-time holdout} | |
| 33 | +\label{tab:race_temporal} | |
| 34 | +\small | |
| 35 | +\input{../results/tables/horserace_temporal} | |
| 36 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 37 | +\item \textit{Notes:} Training on sales before January 2025 (355{,}824); | |
| 38 | +testing on 2025--2026 (158{,}388). Unseen time categories are priced at | |
| 39 | +the last training period (carry-forward); the machine-learning models | |
| 40 | +receive the same information through the clamped month counter. | |
| 41 | +\end{tablenotes} | |
| 42 | +\end{threeparttable} | |
| 43 | +\end{table} | |
| 44 | + | |
| 45 | +\subsection{Axis A: functional form is second-order} | |
| 46 | + | |
| 47 | +Under the random split (Figure~\ref{fig:forms}), the six forms span | |
| 48 | +16.5--18.6\% MdAPE. The spline specification wins (16.5\%), the log-log | |
| 49 | +is second (17.0\%) --- floor area enters multiplicatively, as intuition | |
| 50 | +suggests --- and the linear, semi-log and quadratic forms cluster within | |
| 51 | +half a point of each other. The Box--Cox transformation selects | |
| 52 | +$\hat\lambda = 0.25$ by profile likelihood (Table~\ref{tab:boxcox}), i.e.\ | |
| 53 | +the data reject both the linear ($\lambda=1$) and the log ($\lambda=0$) | |
| 54 | +--- and then \emph{underperforms} the simple semi-log out of sample | |
| 55 | +(18.6\%), because optimizing curvature in-sample does not survive | |
| 56 | +prediction, precisely the warning of \citet{cassel1985cost} and | |
| 57 | +\citet{cropper1988choice}. The ordering is preserved under the temporal | |
| 58 | +split. Two free lessons: curvature in \emph{attributes} (splines) is | |
| 59 | +worth more than curvature in the \emph{price} (Box--Cox); and no form | |
| 60 | +choice moves the needle by more than two points. | |
| 61 | + | |
| 62 | +\begin{figure}[t] | |
| 63 | +\centering | |
| 64 | +\includegraphics[width=0.85\textwidth]{fig_forms.png} | |
| 65 | +\caption{Axis A --- functional form. MdAPE on the random holdout; | |
| 66 | +municipality and quarter fixed effects and the base attribute set held | |
| 67 | +fixed throughout.} | |
| 68 | +\label{fig:forms} | |
| 69 | +\end{figure} | |
| 70 | + | |
| 71 | +\begin{table}[t] | |
| 72 | +\centering | |
| 73 | +\begin{threeparttable} | |
| 74 | +\caption{Box--Cox profile likelihood} | |
| 75 | +\label{tab:boxcox} | |
| 76 | +\small | |
| 77 | +\input{../results/tables/boxcox} | |
| 78 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 79 | +\item \textit{Notes:} $^{\dagger}$Thousands, relative to the maximum | |
| 80 | +(zero = chosen $\lambda$). Estimated on the random-split training sample | |
| 81 | +with the A-axis design. | |
| 82 | +\end{tablenotes} | |
| 83 | +\end{threeparttable} | |
| 84 | +\end{table} | |
| 85 | + | |
| 86 | +\subsection{Axis B: time controls --- anything beats nothing} | |
| 87 | + | |
| 88 | +Omitting time controls entirely costs three points (21.1\% versus 18.1\%) | |
| 89 | +in a window containing a boom, a correction and a recovery | |
| 90 | +(Figure~\ref{fig:time}). But the granularity ladder flattens immediately: | |
| 91 | +year, quarter and month effects are indistinguishable to within a quarter | |
| 92 | +point under the random split. Under the temporal split the ordering | |
| 93 | +inverts at the bottom --- \emph{no} time effects (25.4\%) is the worst | |
| 94 | +choice again, but month effects with carry-forward (20.3\%) beat quarter | |
| 95 | +effects (20.5\%) only marginally: once the level must be extrapolated, | |
| 96 | +fine within-sample time resolution has little to offer. | |
| 97 | + | |
| 98 | +\begin{figure}[t] | |
| 99 | +\centering | |
| 100 | +\includegraphics[width=0.85\textwidth]{fig_time.png} | |
| 101 | +\caption{Axis B --- time effects (quadratic semi-log backbone, | |
| 102 | +municipality FE). MdAPE on the random holdout.} | |
| 103 | +\label{fig:time} | |
| 104 | +\end{figure} | |
| 105 | + | |
| 106 | +\subsection{Axis C: spatial controls are first-order --- up to a point} | |
| 107 | + | |
| 108 | +Location dominates every other design margin | |
| 109 | +(Figure~\ref{fig:space}). Stripping all spatial controls costs | |
| 110 | +\emph{ten} points (27.9\% versus 17.5\%) --- five times the entire | |
| 111 | +functional-form range. The granularity ladder, however, is | |
| 112 | +non-monotonic: the $\sim$5.5~km grid (5{,}255 cells, 17.5\%) beats | |
| 113 | +municipality effects (18.1\%), but the $\sim$1.1~km grid (30{,}548 | |
| 114 | +cells, 19.4\%) \emph{overfits} --- its cells average seventeen K sales | |
| 115 | +each, unseen cells in the holdout revert to the baseline, and estimated | |
| 116 | +effects are noisy. The optimal spatial resolution is an interior | |
| 117 | +solution, echoing \citet{goodman1998housing} on submarket definition: | |
| 118 | +finer is better only while cells remain thick enough to estimate. | |
| 119 | + | |
| 120 | +\begin{figure}[t] | |
| 121 | +\centering | |
| 122 | +\includegraphics[width=0.85\textwidth]{fig_space.png} | |
| 123 | +\caption{Axis C --- spatial controls (quadratic semi-log backbone, | |
| 124 | +quarter FE). MdAPE on the random holdout.} | |
| 125 | +\label{fig:space} | |
| 126 | +\end{figure} | |
| 127 | + | |
| 128 | +\subsection{Axis D: the machines --- and what they actually buy} | |
| 129 | + | |
| 130 | +Under the random split (Figure~\ref{fig:methods}), gradient boosting | |
| 131 | +posts 14.4\% and the random forest 14.6\%, against 16.5\% for the best | |
| 132 | +linear model and 18.3\% for the textbook OLS: a 13--21\% error | |
| 133 | +reduction, squarely in the range reported by the AVM literature | |
| 134 | +\citep{mayer2019estimation, hong2020machine}. The decomposition rows | |
| 135 | +identify the source. Boosting \emph{without coordinates} collapses to | |
| 136 | +23.2\% --- worse than plain OLS with municipality dummies --- so the | |
| 137 | +machine's edge is overwhelmingly a flexible surface over space, not | |
| 138 | +exotic structure--attribute interactions. Pure proximity without | |
| 139 | +structure (the $k$-NN comparables rule, 21.2\%) fails too: it is the | |
| 140 | +\emph{combination} of spatial flexibility with attribute adjustment that | |
| 141 | +wins. The cross-validated ridge (16.7\%) confirms that regularizing the | |
| 142 | +high-dimensional dummy design buys a little; it cannot manufacture the | |
| 143 | +missing interactions. | |
| 144 | + | |
| 145 | +\begin{figure}[t] | |
| 146 | +\centering | |
| 147 | +\includegraphics[width=0.85\textwidth]{fig_methods.png} | |
| 148 | +\caption{Axis D --- estimation methods on the identical attribute set. | |
| 149 | +MdAPE on the random holdout.} | |
| 150 | +\label{fig:methods} | |
| 151 | +\end{figure} | |
| 152 | + | |
| 153 | +\subsection{The generalization gap: when the test set is the future} | |
| 154 | + | |
| 155 | +Table~\ref{tab:generalization} and Figure~\ref{fig:generalization} | |
| 156 | +contain the paper's central result. Moving from the random to the | |
| 157 | +forward-in-time split degrades every model --- the price level of | |
| 158 | +2025--26 must be carried forward, not interpolated --- but the | |
| 159 | +degradation is systematically larger for the flexible methods. | |
| 160 | +Gradient boosting loses 6.8 points of MdAPE (14.4 $\to$ 21.2) and the | |
| 161 | +random forest 6.7, while the spline hedonic loses 2.6 (16.5 $\to$ 19.1) | |
| 162 | +and the log-log 2.2. The ranking \emph{reverses}: the best linear models | |
| 163 | +beat the machines on the median error in the deployment scenario, and | |
| 164 | +tie them exactly on $R^2_{\ln}$ ($\approx 0.52$). The machines remain | |
| 165 | +better on MAPE (29.7 versus 30.5), i.e.\ in the tails, but the headline | |
| 166 | +random-validation gap --- the number the ML-valuation literature | |
| 167 | +reports --- overstates their deployable advantage entirely. The lesson | |
| 168 | +is methodological and general: for models whose job is to price the | |
| 169 | +future, random cross-validation is the wrong experiment | |
| 170 | +\citep{mullainathan2017machine, clapp2002predicting}. | |
| 171 | + | |
| 172 | +\begin{table}[t] | |
| 173 | +\centering | |
| 174 | +\begin{threeparttable} | |
| 175 | +\caption{Random versus forward-in-time evaluation} | |
| 176 | +\label{tab:generalization} | |
| 177 | +\small | |
| 178 | +\input{../results/tables/generalization} | |
| 179 | +\begin{tablenotes}[flushleft]\footnotesize | |
| 180 | +\item \textit{Notes:} Selected models (the backbone and the full D axis) | |
| 181 | +under both splits. | |
| 182 | +\end{tablenotes} | |
| 183 | +\end{threeparttable} | |
| 184 | +\end{table} | |
| 185 | + | |
| 186 | +\begin{figure}[t] | |
| 187 | +\centering | |
| 188 | +\includegraphics[width=0.85\textwidth]{fig_generalization.png} | |
| 189 | +\caption{The generalization gap. MdAPE under the random holdout (filled | |
| 190 | +circles) and the forward-in-time holdout (open squares); the grey | |
| 191 | +segment joins the two evaluations of the same model.} | |
| 192 | +\label{fig:generalization} | |
| 193 | +\end{figure} | |
added
paper/sections/titlepage.tex
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +% Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +% ============================================================================ | |
| 3 | +% Title and abstract pages | |
| 4 | +% ============================================================================ | |
| 5 | +\thispagestyle{empty} | |
| 6 | + | |
| 7 | +\begin{center} | |
| 8 | + | |
| 9 | +\includegraphics[width=4cm]{uq_logo.jpg} | |
| 10 | + | |
| 11 | +\vspace{0.6cm} | |
| 12 | + | |
| 13 | +{\footnotesize\textsc{Universit\'e du Qu\'ebec en Outaouais}}\\[0.15cm] | |
| 14 | +{\footnotesize\textsc{D\'epartement des sciences administratives}} | |
| 15 | + | |
| 16 | +\vspace{0.8cm} | |
| 17 | + | |
| 18 | +{\footnotesize\textsc{Working Paper No.~\WPnumber}} | |
| 19 | + | |
| 20 | +\vspace{1.2cm} | |
| 21 | + | |
| 22 | +{\LARGE\bfseries \WPtitle\par} | |
| 23 | + | |
| 24 | +\vspace{0.4cm} | |
| 25 | +{\large\itshape \WPsubtitle\par} | |
| 26 | + | |
| 27 | +\vspace{1.2cm} | |
| 28 | + | |
| 29 | +{\large \WPauthor}\\[0.3cm] | |
| 30 | +{\normalsize \WPaffiliation}\\[0.15cm] | |
| 31 | +{\normalsize \href{mailto:\WPemail}{\WPemail}}\\[0.15cm] | |
| 32 | +{\small \WPaddress} | |
| 33 | + | |
| 34 | +\vspace{0.8cm} | |
| 35 | + | |
| 36 | +{\normalsize \WPdate}\\[0.1cm] | |
| 37 | +{\small Version~\WPversion} | |
| 38 | + | |
| 39 | +\end{center} | |
| 40 | + | |
| 41 | +\vfill | |
| 42 | + | |
| 43 | +\newpage | |
| 44 | + | |
| 45 | +% ---------------------------------------------------------------- abstract | |
| 46 | +\thispagestyle{empty} | |
| 47 | + | |
| 48 | +\vspace*{1cm} | |
| 49 | + | |
| 50 | +\noindent\rule{\textwidth}{0.4pt} | |
| 51 | +\vspace{0.3cm} | |
| 52 | + | |
| 53 | +\noindent\textbf{Abstract} | |
| 54 | + | |
| 55 | +\vspace{0.15cm} | |
| 56 | + | |
| 57 | +\noindent\WPabstract | |
| 58 | + | |
| 59 | +\vspace{0.4cm} | |
| 60 | + | |
| 61 | +\noindent\textbf{Keywords:} \WPkeywords | |
| 62 | + | |
| 63 | +\vspace{0.15cm} | |
| 64 | + | |
| 65 | +\noindent\textbf{JEL Classification:} \WPjel | |
| 66 | + | |
| 67 | +\vspace{0.3cm} | |
| 68 | +\noindent\rule{\textwidth}{0.4pt} | |
| 69 | + | |
| 70 | +\newpage | |
added
paper/uq_logo.jpg
+0 −0
Binary file not shown.
added
requirements.txt
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +# Python >= 3.11. Versions pinned to the environment used for the analysis. | |
| 3 | +numpy==2.4.4 | |
| 4 | +pandas==3.0.2 | |
| 5 | +pyarrow>=16.0 | |
| 6 | +scikit-learn==1.6.1 | |
| 7 | +matplotlib==3.10.9 | |
added
results/reproduced/boxcox.csv
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +lambda,loglik,chosen | |
| 2 | +-0.5,-4986026.044606864,False | |
| 3 | +-0.25,-4938790.1592812985,False | |
| 4 | +0.0,-4896133.282457808,False | |
| 5 | +0.25,-4879097.21142742,True | |
| 6 | +0.5,-4882782.367887354,False | |
| 7 | +0.75,-4893437.953075144,False | |
| 8 | +1.0,-4924881.838890304,False | |
added
results/reproduced/horserace_random.csv
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +name,group,mdape,mape,rmse_ln,r2_ln,seconds,n_train,n_test | |
| 2 | +A1 Linear (levels),A. Functional form,18.247735441468322,35.554833553436424,0.4197326280373289,0.4770941777730925,0.5,411283,102929 | |
| 3 | +A2 Semi-log,A. Functional form,18.174885439840832,34.4795393377222,0.39663369462465925,0.5330641412991447,0.6,411283,102929 | |
| 4 | +A3 Log-log,A. Functional form,16.965877929132013,32.69009595755662,0.38125191583247026,0.5685782006102371,0.9,411283,102929 | |
| 5 | +A4 Box-Cox,A. Functional form,18.567718342220903,33.001281736849975,0.3903031252718359,0.5478504801090737,4.3,411283,102929 | |
| 6 | +A5 Semi-log + quadratics,A. Functional form,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.7,411283,102929 | |
| 7 | +A6 Semi-log + splines,A. Functional form,16.52578223473714,32.10851818544987,0.37644836156803374,0.5793810460819198,2.6,411283,102929 | |
| 8 | +B1 No time effects,B. Time effects,21.1450312090911,38.37918215557233,0.42321207728034005,0.4683888002641269,0.5,411283,102929 | |
| 9 | +B2 Year FE,B. Time effects,18.091245071945412,34.663597249791685,0.39704996735836223,0.5320835152583053,0.7,411283,102929 | |
| 10 | +B3 Quarter FE,B. Time effects,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.6,411283,102929 | |
| 11 | +B4 Month FE,B. Time effects,18.322750827125915,34.80915561246471,0.3982300277671944,0.5292980206991789,0.7,411283,102929 | |
| 12 | +C1 No spatial controls,C. Spatial controls,27.922352076812572,48.39219298797349,0.4944639622666287,0.2743164045351353,0.5,411283,102929 | |
| 13 | +C2 Municipality FE,C. Spatial controls,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.7,411283,102929 | |
| 14 | +C3 Grid-cell FE (~5.5 km),C. Spatial controls,17.53978076473437,34.03632099310664,0.3918420758470441,0.5442778333854188,0.7,411283,102929 | |
| 15 | +C4 Grid-cell FE (~1.1 km),C. Spatial controls,19.389847159546143,37.50800513353381,0.41828640746256024,0.48069139310554865,0.8,411283,102929 | |
| 16 | +D1 OLS (muni + month FE),D. Estimation method,18.322750827125915,34.80915561246471,0.3982300277671944,0.5292980206991789,0.7,411283,102929 | |
| 17 | +D2 Ridge (cross-validated),D. Estimation method,16.671722823569887,32.2521071548757,0.37758629277068595,0.5768343018663071,10.8,411283,102929 | |
| 18 | +D3 Random forest,D. Estimation method,14.62750218203232,28.181960170119,0.35257338948901984,0.6310418755396041,7.6,411283,102929 | |
| 19 | +D4 Gradient boosting,D. Estimation method,14.388300681085338,27.766499817027036,0.3471568687492957,0.6422912683312305,42.5,411283,102929 | |
| 20 | +"D5 Gradient boosting, no coordinates",D. Estimation method,23.225090677534933,38.30004358013209,0.4398837230475839,0.4256801846835939,17.7,411283,102929 | |
| 21 | +D6 Spatial k-NN comparables,D. Estimation method,21.18226600985226,36.73270267320528,0.43669681006238664,0.43397181715912814,0.1,411283,102929 | |
added
results/reproduced/horserace_temporal.csv
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +name,group,mdape,mape,rmse_ln,r2_ln,seconds,n_train,n_test | |
| 2 | +A1 Linear (levels),A. Functional form,20.369511213481317,32.86005001443525,0.39728743801570204,0.4552239614410175,0.4,355824,158388 | |
| 3 | +A2 Semi-log,A. Functional form,20.611640132255836,33.09824319242239,0.3964345928808567,0.45756036006756484,0.5,355824,158388 | |
| 4 | +A3 Log-log,A. Functional form,19.189006525695262,30.749155508273613,0.3757555688314752,0.5126744490905257,0.8,355824,158388 | |
| 5 | +A4 Box-Cox,A. Functional form,20.957181720703545,31.940298396980698,0.38962419749983584,0.47603753989498354,3.8,355824,158388 | |
| 6 | +A5 Semi-log + quadratics,A. Functional form,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388 | |
| 7 | +A6 Semi-log + splines,A. Functional form,19.097413080353302,30.49261711294047,0.37228529384601217,0.5216342331571537,2.1,355824,158388 | |
| 8 | +B1 No time effects,B. Time effects,25.384810383977687,33.162107080537304,0.41811180324070163,0.39661683797944225,0.5,355824,158388 | |
| 9 | +B2 Year FE,B. Time effects,21.006054587504984,32.62538772051225,0.39253658926914514,0.46817515745089067,0.6,355824,158388 | |
| 10 | +B3 Quarter FE,B. Time effects,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388 | |
| 11 | +B4 Month FE,B. Time effects,20.347788061301657,32.47788422275795,0.38952457659313394,0.4763054439041552,0.6,355824,158388 | |
| 12 | +C1 No spatial controls,C. Spatial controls,26.45586672540444,41.108374756471996,0.45691339109589413,0.2794301211279968,0.4,355824,158388 | |
| 13 | +C2 Municipality FE,C. Spatial controls,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388 | |
| 14 | +C3 Grid-cell FE (~5.5 km),C. Spatial controls,21.279243946393596,32.712624877555356,0.39371599730320833,0.464974534611328,0.6,355824,158388 | |
| 15 | +C4 Grid-cell FE (~1.1 km),C. Spatial controls,22.091478382056824,36.02185035008858,0.41700099046919065,0.39981863895351477,0.7,355824,158388 | |
| 16 | +D1 OLS (muni + month FE),D. Estimation method,20.347788061301657,32.47788422275795,0.38952457659313394,0.4763054439041552,0.6,355824,158388 | |
| 17 | +D2 Ridge (cross-validated),D. Estimation method,19.520136279141678,30.594351068378263,0.3747175016019065,0.5153633136379877,8.6,355824,158388 | |
| 18 | +D3 Random forest,D. Estimation method,21.34011982105225,29.76443050482997,0.37332513269156825,0.5189582317282835,6.8,355824,158388 | |
| 19 | +D4 Gradient boosting,D. Estimation method,21.21243467837447,29.71192710420828,0.37236811207724885,0.5214213758996993,41.5,355824,158388 | |
| 20 | +"D5 Gradient boosting, no coordinates",D. Estimation method,26.207233073287274,35.736727341535484,0.44725743322834494,0.3095639406099918,21.1,355824,158388 | |
| 21 | +D6 Spatial k-NN comparables,D. Estimation method,25.63120139114109,34.234843881635754,0.4653714833711972,0.2525057333957209,0.1,355824,158388 | |
added
results/reproduced/index.csv
+336 −0
@@ -0,0 +1,336 @@ | ||
| 1 | +month,form,index | |
| 2 | +2021-01,Semi-log,100.0 | |
| 3 | +2021-02,Semi-log,101.58673535019909 | |
| 4 | +2021-03,Semi-log,105.15833463820046 | |
| 5 | +2021-04,Semi-log,110.05359361584941 | |
| 6 | +2021-05,Semi-log,113.3476197370347 | |
| 7 | +2021-06,Semi-log,116.78945849819837 | |
| 8 | +2021-07,Semi-log,117.25427261682117 | |
| 9 | +2021-08,Semi-log,117.57882194626126 | |
| 10 | +2021-09,Semi-log,114.84274152729432 | |
| 11 | +2021-10,Semi-log,115.67995573214243 | |
| 12 | +2021-11,Semi-log,117.22226898450423 | |
| 13 | +2021-12,Semi-log,119.13647099686591 | |
| 14 | +2022-01,Semi-log,117.6766710787268 | |
| 15 | +2022-02,Semi-log,114.79677886201225 | |
| 16 | +2022-03,Semi-log,120.9920404921965 | |
| 17 | +2022-04,Semi-log,125.75252465227376 | |
| 18 | +2022-05,Semi-log,130.42632038880743 | |
| 19 | +2022-06,Semi-log,135.47345849560585 | |
| 20 | +2022-07,Semi-log,132.98143525500856 | |
| 21 | +2022-08,Semi-log,132.8291602738497 | |
| 22 | +2022-09,Semi-log,127.81273393290273 | |
| 23 | +2022-10,Semi-log,125.67721580003195 | |
| 24 | +2022-11,Semi-log,124.5376598091201 | |
| 25 | +2022-12,Semi-log,121.42336425178925 | |
| 26 | +2023-01,Semi-log,122.52017019338413 | |
| 27 | +2023-02,Semi-log,116.32309831165357 | |
| 28 | +2023-03,Semi-log,122.7787681784388 | |
| 29 | +2023-04,Semi-log,127.81389693092997 | |
| 30 | +2023-05,Semi-log,130.97263662998472 | |
| 31 | +2023-06,Semi-log,136.65632652573856 | |
| 32 | +2023-07,Semi-log,134.83263792979906 | |
| 33 | +2023-08,Semi-log,136.96348385951148 | |
| 34 | +2023-09,Semi-log,132.91313088965063 | |
| 35 | +2023-10,Semi-log,133.2365475663534 | |
| 36 | +2023-11,Semi-log,130.5645082681891 | |
| 37 | +2023-12,Semi-log,129.4227692111657 | |
| 38 | +2024-01,Semi-log,130.85188265820088 | |
| 39 | +2024-02,Semi-log,125.99887235870726 | |
| 40 | +2024-03,Semi-log,133.3924105059048 | |
| 41 | +2024-04,Semi-log,137.96098170039664 | |
| 42 | +2024-05,Semi-log,143.7624116169281 | |
| 43 | +2024-06,Semi-log,147.13139249789288 | |
| 44 | +2024-07,Semi-log,144.83585652642233 | |
| 45 | +2024-08,Semi-log,146.8968076189641 | |
| 46 | +2024-09,Semi-log,142.64794058747063 | |
| 47 | +2024-10,Semi-log,144.351122714208 | |
| 48 | +2024-11,Semi-log,145.12652850202633 | |
| 49 | +2024-12,Semi-log,142.69800866412788 | |
| 50 | +2025-01,Semi-log,145.40663710499652 | |
| 51 | +2025-02,Semi-log,144.3591478393837 | |
| 52 | +2025-03,Semi-log,148.71265137564697 | |
| 53 | +2025-04,Semi-log,151.33940900295545 | |
| 54 | +2025-05,Semi-log,157.02547758982448 | |
| 55 | +2025-06,Semi-log,161.6883679603725 | |
| 56 | +2025-07,Semi-log,159.66796623712878 | |
| 57 | +2025-08,Semi-log,161.99037914121666 | |
| 58 | +2025-09,Semi-log,156.1589266096066 | |
| 59 | +2025-10,Semi-log,158.24670896609604 | |
| 60 | +2025-11,Semi-log,157.93907357133844 | |
| 61 | +2025-12,Semi-log,157.40164200715827 | |
| 62 | +2026-01,Semi-log,157.26405345738493 | |
| 63 | +2026-02,Semi-log,153.76917995549775 | |
| 64 | +2026-03,Semi-log,159.97375646706652 | |
| 65 | +2026-04,Semi-log,163.56733471065738 | |
| 66 | +2026-05,Semi-log,169.15316452282033 | |
| 67 | +2026-06,Semi-log,174.1961239017598 | |
| 68 | +2026-07,Semi-log,169.84650134335135 | |
| 69 | +2021-01,Log-log,100.0 | |
| 70 | +2021-02,Log-log,101.16133168740143 | |
| 71 | +2021-03,Log-log,104.68288933485195 | |
| 72 | +2021-04,Log-log,109.41941698870998 | |
| 73 | +2021-05,Log-log,112.69498141605109 | |
| 74 | +2021-06,Log-log,116.1652161651087 | |
| 75 | +2021-07,Log-log,116.48263736781233 | |
| 76 | +2021-08,Log-log,116.92062844228472 | |
| 77 | +2021-09,Log-log,114.27795754947768 | |
| 78 | +2021-10,Log-log,115.21217348466351 | |
| 79 | +2021-11,Log-log,116.6053300069431 | |
| 80 | +2021-12,Log-log,118.60818906058934 | |
| 81 | +2022-01,Log-log,117.08746382474162 | |
| 82 | +2022-02,Log-log,114.442914464912 | |
| 83 | +2022-03,Log-log,120.39020848352888 | |
| 84 | +2022-04,Log-log,125.00474627824644 | |
| 85 | +2022-05,Log-log,129.56534387932552 | |
| 86 | +2022-06,Log-log,134.46889998433656 | |
| 87 | +2022-07,Log-log,132.12966907890026 | |
| 88 | +2022-08,Log-log,131.9325308544368 | |
| 89 | +2022-09,Log-log,126.86433094068288 | |
| 90 | +2022-10,Log-log,124.88771889312255 | |
| 91 | +2022-11,Log-log,123.713591257508 | |
| 92 | +2022-12,Log-log,120.74808060473094 | |
| 93 | +2023-01,Log-log,121.42131218765795 | |
| 94 | +2023-02,Log-log,115.75618623462842 | |
| 95 | +2023-03,Log-log,121.85351692626989 | |
| 96 | +2023-04,Log-log,126.84974258215837 | |
| 97 | +2023-05,Log-log,129.74039397561964 | |
| 98 | +2023-06,Log-log,135.53464949369334 | |
| 99 | +2023-07,Log-log,133.849235386329 | |
| 100 | +2023-08,Log-log,135.9277878186335 | |
| 101 | +2023-09,Log-log,132.0159524794275 | |
| 102 | +2023-10,Log-log,132.2328522907643 | |
| 103 | +2023-11,Log-log,129.93287079815477 | |
| 104 | +2023-12,Log-log,128.66580504001163 | |
| 105 | +2024-01,Log-log,129.95742376250348 | |
| 106 | +2024-02,Log-log,125.53175903352141 | |
| 107 | +2024-03,Log-log,132.67490119743292 | |
| 108 | +2024-04,Log-log,136.88927173622355 | |
| 109 | +2024-05,Log-log,142.5768642960935 | |
| 110 | +2024-06,Log-log,146.03332291132114 | |
| 111 | +2024-07,Log-log,143.63461559502682 | |
| 112 | +2024-08,Log-log,145.67190854722085 | |
| 113 | +2024-09,Log-log,141.74995383419818 | |
| 114 | +2024-10,Log-log,143.26759775226904 | |
| 115 | +2024-11,Log-log,143.96023462876832 | |
| 116 | +2024-12,Log-log,141.70609531013278 | |
| 117 | +2025-01,Log-log,144.33799468587358 | |
| 118 | +2025-02,Log-log,143.40444566855706 | |
| 119 | +2025-03,Log-log,147.61363528963724 | |
| 120 | +2025-04,Log-log,150.40148514375616 | |
| 121 | +2025-05,Log-log,155.81660754992984 | |
| 122 | +2025-06,Log-log,160.3941492790868 | |
| 123 | +2025-07,Log-log,158.5887517002987 | |
| 124 | +2025-08,Log-log,160.84916883944067 | |
| 125 | +2025-09,Log-log,155.1172806147217 | |
| 126 | +2025-10,Log-log,157.164153035006 | |
| 127 | +2025-11,Log-log,156.9922296423208 | |
| 128 | +2025-12,Log-log,156.51959529639282 | |
| 129 | +2026-01,Log-log,156.24625834416705 | |
| 130 | +2026-02,Log-log,152.87882718125252 | |
| 131 | +2026-03,Log-log,158.98909028605362 | |
| 132 | +2026-04,Log-log,162.38523086106298 | |
| 133 | +2026-05,Log-log,167.88888535753074 | |
| 134 | +2026-06,Log-log,173.0699056577055 | |
| 135 | +2026-07,Log-log,168.63140563125273 | |
| 136 | +2021-01,Semi-log + quadratics,100.0 | |
| 137 | +2021-02,Semi-log + quadratics,101.0912732505944 | |
| 138 | +2021-03,Semi-log + quadratics,104.42262321513078 | |
| 139 | +2021-04,Semi-log + quadratics,109.37226200063293 | |
| 140 | +2021-05,Semi-log + quadratics,112.59748004514188 | |
| 141 | +2021-06,Semi-log + quadratics,116.30435317237504 | |
| 142 | +2021-07,Semi-log + quadratics,116.47173029607461 | |
| 143 | +2021-08,Semi-log + quadratics,116.97293425396234 | |
| 144 | +2021-09,Semi-log + quadratics,114.45575447119076 | |
| 145 | +2021-10,Semi-log + quadratics,115.35952362304862 | |
| 146 | +2021-11,Semi-log + quadratics,116.78444933413272 | |
| 147 | +2021-12,Semi-log + quadratics,118.75914785626908 | |
| 148 | +2022-01,Semi-log + quadratics,116.93782165076203 | |
| 149 | +2022-02,Semi-log + quadratics,114.3386843694858 | |
| 150 | +2022-03,Semi-log + quadratics,120.32316864472405 | |
| 151 | +2022-04,Semi-log + quadratics,125.08636243558213 | |
| 152 | +2022-05,Semi-log + quadratics,129.73545311526638 | |
| 153 | +2022-06,Semi-log + quadratics,134.69981308856902 | |
| 154 | +2022-07,Semi-log + quadratics,132.37066721196783 | |
| 155 | +2022-08,Semi-log + quadratics,131.99550321336687 | |
| 156 | +2022-09,Semi-log + quadratics,127.28489718350421 | |
| 157 | +2022-10,Semi-log + quadratics,125.2851100278592 | |
| 158 | +2022-11,Semi-log + quadratics,124.18260462984489 | |
| 159 | +2022-12,Semi-log + quadratics,121.05919175735458 | |
| 160 | +2023-01,Semi-log + quadratics,121.55418103294132 | |
| 161 | +2023-02,Semi-log + quadratics,116.04686395173907 | |
| 162 | +2023-03,Semi-log + quadratics,122.01210808628566 | |
| 163 | +2023-04,Semi-log + quadratics,127.03041796151922 | |
| 164 | +2023-05,Semi-log + quadratics,130.09782563085352 | |
| 165 | +2023-06,Semi-log + quadratics,135.81357299276803 | |
| 166 | +2023-07,Semi-log + quadratics,133.99886985109012 | |
| 167 | +2023-08,Semi-log + quadratics,136.15514590130437 | |
| 168 | +2023-09,Semi-log + quadratics,132.31098740516646 | |
| 169 | +2023-10,Semi-log + quadratics,132.55793273829264 | |
| 170 | +2023-11,Semi-log + quadratics,130.17274128070497 | |
| 171 | +2023-12,Semi-log + quadratics,128.93669068862442 | |
| 172 | +2024-01,Semi-log + quadratics,130.30060804724272 | |
| 173 | +2024-02,Semi-log + quadratics,125.6663360800061 | |
| 174 | +2024-03,Semi-log + quadratics,133.07825950663835 | |
| 175 | +2024-04,Semi-log + quadratics,137.23362278542456 | |
| 176 | +2024-05,Semi-log + quadratics,143.04870094961964 | |
| 177 | +2024-06,Semi-log + quadratics,146.41659476532632 | |
| 178 | +2024-07,Semi-log + quadratics,144.01839530533434 | |
| 179 | +2024-08,Semi-log + quadratics,145.92847409272198 | |
| 180 | +2024-09,Semi-log + quadratics,142.14509783719683 | |
| 181 | +2024-10,Semi-log + quadratics,143.73179957797396 | |
| 182 | +2024-11,Semi-log + quadratics,144.6300279028595 | |
| 183 | +2024-12,Semi-log + quadratics,142.26528364486543 | |
| 184 | +2025-01,Semi-log + quadratics,144.9120001944002 | |
| 185 | +2025-02,Semi-log + quadratics,143.60284630600765 | |
| 186 | +2025-03,Semi-log + quadratics,147.82965565359797 | |
| 187 | +2025-04,Semi-log + quadratics,150.76832704543722 | |
| 188 | +2025-05,Semi-log + quadratics,156.37842118705146 | |
| 189 | +2025-06,Semi-log + quadratics,161.01977142532053 | |
| 190 | +2025-07,Semi-log + quadratics,159.02517078211636 | |
| 191 | +2025-08,Semi-log + quadratics,161.4582429851579 | |
| 192 | +2025-09,Semi-log + quadratics,155.89611291489777 | |
| 193 | +2025-10,Semi-log + quadratics,157.7415552940419 | |
| 194 | +2025-11,Semi-log + quadratics,157.33526323503708 | |
| 195 | +2025-12,Semi-log + quadratics,157.1024598292604 | |
| 196 | +2026-01,Semi-log + quadratics,156.89128899951555 | |
| 197 | +2026-02,Semi-log + quadratics,153.7323623687321 | |
| 198 | +2026-03,Semi-log + quadratics,159.8847880323458 | |
| 199 | +2026-04,Semi-log + quadratics,163.20838540709002 | |
| 200 | +2026-05,Semi-log + quadratics,168.76323617947057 | |
| 201 | +2026-06,Semi-log + quadratics,173.88636049318322 | |
| 202 | +2026-07,Semi-log + quadratics,169.35280403619177 | |
| 203 | +2021-01,Semi-log + splines,100.0 | |
| 204 | +2021-02,Semi-log + splines,101.86542395532908 | |
| 205 | +2021-03,Semi-log + splines,105.24287289345533 | |
| 206 | +2021-04,Semi-log + splines,110.0753748482258 | |
| 207 | +2021-05,Semi-log + splines,113.51226965711332 | |
| 208 | +2021-06,Semi-log + splines,117.12926449404905 | |
| 209 | +2021-07,Semi-log + splines,117.33124106236158 | |
| 210 | +2021-08,Semi-log + splines,117.91256336220968 | |
| 211 | +2021-09,Semi-log + splines,115.44205952163804 | |
| 212 | +2021-10,Semi-log + splines,116.2483941248449 | |
| 213 | +2021-11,Semi-log + splines,117.66086049567832 | |
| 214 | +2021-12,Semi-log + splines,119.75832998905855 | |
| 215 | +2022-01,Semi-log + splines,117.96452417745165 | |
| 216 | +2022-02,Semi-log + splines,115.24885761191399 | |
| 217 | +2022-03,Semi-log + splines,121.33105847128638 | |
| 218 | +2022-04,Semi-log + splines,126.09002074053011 | |
| 219 | +2022-05,Semi-log + splines,130.77542343594016 | |
| 220 | +2022-06,Semi-log + splines,135.7974268497703 | |
| 221 | +2022-07,Semi-log + splines,133.48756597492573 | |
| 222 | +2022-08,Semi-log + splines,132.9868190484367 | |
| 223 | +2022-09,Semi-log + splines,128.30100770145222 | |
| 224 | +2022-10,Semi-log + splines,126.28169354928811 | |
| 225 | +2022-11,Semi-log + splines,125.1044579005194 | |
| 226 | +2022-12,Semi-log + splines,122.04033159253818 | |
| 227 | +2023-01,Semi-log + splines,122.63688240412338 | |
| 228 | +2023-02,Semi-log + splines,116.92896754893749 | |
| 229 | +2023-03,Semi-log + splines,123.0294761555768 | |
| 230 | +2023-04,Semi-log + splines,128.00114663186017 | |
| 231 | +2023-05,Semi-log + splines,131.23083427450524 | |
| 232 | +2023-06,Semi-log + splines,136.97517974696984 | |
| 233 | +2023-07,Semi-log + splines,135.16601168892598 | |
| 234 | +2023-08,Semi-log + splines,137.36861294103227 | |
| 235 | +2023-09,Semi-log + splines,133.51256070039653 | |
| 236 | +2023-10,Semi-log + splines,133.54140440526615 | |
| 237 | +2023-11,Semi-log + splines,131.24473978397685 | |
| 238 | +2023-12,Semi-log + splines,130.10173643757705 | |
| 239 | +2024-01,Semi-log + splines,131.51492232192732 | |
| 240 | +2024-02,Semi-log + splines,126.9041346661383 | |
| 241 | +2024-03,Semi-log + splines,134.18315970301032 | |
| 242 | +2024-04,Semi-log + splines,138.5664529434532 | |
| 243 | +2024-05,Semi-log + splines,144.2536503040894 | |
| 244 | +2024-06,Semi-log + splines,147.6632328263899 | |
| 245 | +2024-07,Semi-log + splines,145.27442862198907 | |
| 246 | +2024-08,Semi-log + splines,147.2348987165921 | |
| 247 | +2024-09,Semi-log + splines,143.49222253101885 | |
| 248 | +2024-10,Semi-log + splines,145.0684610323443 | |
| 249 | +2024-11,Semi-log + splines,145.87718550298047 | |
| 250 | +2024-12,Semi-log + splines,143.6071311658821 | |
| 251 | +2025-01,Semi-log + splines,146.1699022929873 | |
| 252 | +2025-02,Semi-log + splines,144.94008820347236 | |
| 253 | +2025-03,Semi-log + splines,149.17131940453797 | |
| 254 | +2025-04,Semi-log + splines,152.18333061668295 | |
| 255 | +2025-05,Semi-log + splines,157.72024490030327 | |
| 256 | +2025-06,Semi-log + splines,162.4653006702696 | |
| 257 | +2025-07,Semi-log + splines,160.45226600771628 | |
| 258 | +2025-08,Semi-log + splines,162.88604107331653 | |
| 259 | +2025-09,Semi-log + splines,157.23067628044703 | |
| 260 | +2025-10,Semi-log + splines,159.134855162167 | |
| 261 | +2025-11,Semi-log + splines,158.87144641498278 | |
| 262 | +2025-12,Semi-log + splines,158.45430448942963 | |
| 263 | +2026-01,Semi-log + splines,158.33352096831015 | |
| 264 | +2026-02,Semi-log + splines,155.21935155939082 | |
| 265 | +2026-03,Semi-log + splines,161.38038945070147 | |
| 266 | +2026-04,Semi-log + splines,164.7454033908841 | |
| 267 | +2026-05,Semi-log + splines,170.3218592099857 | |
| 268 | +2026-06,Semi-log + splines,175.55732652817687 | |
| 269 | +2026-07,Semi-log + splines,170.9761927074845 | |
| 270 | +2021-01,Gradient boosting,100.0 | |
| 271 | +2021-02,Gradient boosting,98.81093967843219 | |
| 272 | +2021-03,Gradient boosting,102.04903427895738 | |
| 273 | +2021-04,Gradient boosting,107.27637825324533 | |
| 274 | +2021-05,Gradient boosting,111.64259460314013 | |
| 275 | +2021-06,Gradient boosting,115.28081932192127 | |
| 276 | +2021-07,Gradient boosting,114.65823177639984 | |
| 277 | +2021-08,Gradient boosting,114.81671708425627 | |
| 278 | +2021-09,Gradient boosting,113.48392784769084 | |
| 279 | +2021-10,Gradient boosting,113.52504569352182 | |
| 280 | +2021-11,Gradient boosting,115.17934858225296 | |
| 281 | +2021-12,Gradient boosting,115.65180328106477 | |
| 282 | +2022-01,Gradient boosting,115.26424839066175 | |
| 283 | +2022-02,Gradient boosting,114.72480050047574 | |
| 284 | +2022-03,Gradient boosting,119.04674291815279 | |
| 285 | +2022-04,Gradient boosting,123.82509452745393 | |
| 286 | +2022-05,Gradient boosting,126.4829518746023 | |
| 287 | +2022-06,Gradient boosting,132.16310661784829 | |
| 288 | +2022-07,Gradient boosting,130.00851314807352 | |
| 289 | +2022-08,Gradient boosting,128.7864941203398 | |
| 290 | +2022-09,Gradient boosting,124.43627137411872 | |
| 291 | +2022-10,Gradient boosting,123.748568288378 | |
| 292 | +2022-11,Gradient boosting,121.88952975280279 | |
| 293 | +2022-12,Gradient boosting,118.77376517016326 | |
| 294 | +2023-01,Gradient boosting,118.19441603622532 | |
| 295 | +2023-02,Gradient boosting,118.28793615042244 | |
| 296 | +2023-03,Gradient boosting,118.5149581464299 | |
| 297 | +2023-04,Gradient boosting,124.7545613709178 | |
| 298 | +2023-05,Gradient boosting,126.12206249461191 | |
| 299 | +2023-06,Gradient boosting,131.2717630732005 | |
| 300 | +2023-07,Gradient boosting,131.19793605157096 | |
| 301 | +2023-08,Gradient boosting,131.19130175687172 | |
| 302 | +2023-09,Gradient boosting,128.619821814608 | |
| 303 | +2023-10,Gradient boosting,128.60461738160117 | |
| 304 | +2023-11,Gradient boosting,127.85718637615204 | |
| 305 | +2023-12,Gradient boosting,127.37452603809588 | |
| 306 | +2024-01,Gradient boosting,126.46943297873648 | |
| 307 | +2024-02,Gradient boosting,126.00157862927615 | |
| 308 | +2024-03,Gradient boosting,127.28267199034268 | |
| 309 | +2024-04,Gradient boosting,132.24998091010997 | |
| 310 | +2024-05,Gradient boosting,139.7906201202798 | |
| 311 | +2024-06,Gradient boosting,141.48131606446856 | |
| 312 | +2024-07,Gradient boosting,139.83825978883485 | |
| 313 | +2024-08,Gradient boosting,140.07061407756328 | |
| 314 | +2024-09,Gradient boosting,139.40875369614565 | |
| 315 | +2024-10,Gradient boosting,139.77690306753448 | |
| 316 | +2024-11,Gradient boosting,139.79308784958624 | |
| 317 | +2024-12,Gradient boosting,139.4527803418063 | |
| 318 | +2025-01,Gradient boosting,139.57443965589766 | |
| 319 | +2025-02,Gradient boosting,139.0724018182482 | |
| 320 | +2025-03,Gradient boosting,142.79288446020632 | |
| 321 | +2025-04,Gradient boosting,144.98390219097055 | |
| 322 | +2025-05,Gradient boosting,149.42841324546987 | |
| 323 | +2025-06,Gradient boosting,153.80976280002778 | |
| 324 | +2025-07,Gradient boosting,153.2091824004246 | |
| 325 | +2025-08,Gradient boosting,154.12363532269353 | |
| 326 | +2025-09,Gradient boosting,150.80575599999182 | |
| 327 | +2025-10,Gradient boosting,151.28464473812545 | |
| 328 | +2025-11,Gradient boosting,151.30710195621506 | |
| 329 | +2025-12,Gradient boosting,151.24197247127526 | |
| 330 | +2026-01,Gradient boosting,149.09185237388857 | |
| 331 | +2026-02,Gradient boosting,148.7062004952243 | |
| 332 | +2026-03,Gradient boosting,153.6606237883838 | |
| 333 | +2026-04,Gradient boosting,154.7372061662432 | |
| 334 | +2026-05,Gradient boosting,159.71561841103357 | |
| 335 | +2026-06,Gradient boosting,163.30359451297505 | |
| 336 | +2026-07,Gradient boosting,160.46582864536248 | |
added
results/reproduced/learning.csv
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +n_train,model,mdape,mape,rmse_ln,r2_ln | |
| 2 | +10000,OLS quadratic (muni+quarter FE),18.38436183742385,34.439941317616764,0.39722768960842764,0.531664536345561 | |
| 3 | +10000,Gradient boosting,17.55817415103931,31.777076909845096,0.386462587914794,0.5567048988691092 | |
| 4 | +25000,OLS quadratic (muni+quarter FE),18.13123412223344,34.71752541710843,0.3987497135923394,0.5280686972389094 | |
| 5 | +25000,Gradient boosting,16.117235891494122,29.823687390173077,0.36598650222996054,0.6024349909662393 | |
| 6 | +50000,OLS quadratic (muni+quarter FE),17.85098430954251,34.291813175933555,0.394668048572139,0.5376807754235925 | |
| 7 | +50000,Gradient boosting,15.46029775645706,29.149938590306927,0.3602557920963508,0.6147878645125984 | |
| 8 | +100000,OLS quadratic (muni+quarter FE),17.858528109660003,33.97231033671203,0.39154828747222153,0.5449609437080014 | |
| 9 | +100000,Gradient boosting,15.201224181879457,28.789207604531935,0.35629888749041316,0.6232034228186022 | |
| 10 | +200000,OLS quadratic (muni+quarter FE),17.926781291106366,34.286171684612285,0.3940853634933891,0.5390448972702662 | |
| 11 | +200000,Gradient boosting,14.75286425569271,28.18065043214689,0.3510692811837181,0.6341831746695208 | |
| 12 | +400000,OLS quadratic (muni+quarter FE),18.06403273608106,34.590640697337626,0.3967353319490308,0.5328248061516689 | |
| 13 | +400000,Gradient boosting,14.485229661097984,27.850855998019064,0.3478423367115942,0.640877268082553 | |
added
results/reproduced/profiles.csv
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +var,x,model,y | |
| 2 | +age,0,OLS quadratic,0.0 | |
| 3 | +age,5,OLS quadratic,-0.04546016510740408 | |
| 4 | +age,10,OLS quadratic,-0.08890937386177093 | |
| 5 | +age,15,OLS quadratic,-0.13034762626310056 | |
| 6 | +age,20,OLS quadratic,-0.16977492231139296 | |
| 7 | +age,25,OLS quadratic,-0.2071912620066481 | |
| 8 | +age,30,OLS quadratic,-0.2425966453488661 | |
| 9 | +age,35,OLS quadratic,-0.27599107233804676 | |
| 10 | +age,40,OLS quadratic,-0.3073745429741903 | |
| 11 | +age,45,OLS quadratic,-0.3367470572572966 | |
| 12 | +age,50,OLS quadratic,-0.3641086151873656 | |
| 13 | +age,55,OLS quadratic,-0.3894592167643974 | |
| 14 | +age,60,OLS quadratic,-0.41279886198839205 | |
| 15 | +age,65,OLS quadratic,-0.4341275508593494 | |
| 16 | +age,70,OLS quadratic,-0.4534452833772695 | |
| 17 | +age,75,OLS quadratic,-0.4707520595421524 | |
| 18 | +age,80,OLS quadratic,-0.4860478793539982 | |
| 19 | +age,85,OLS quadratic,-0.4993327428128066 | |
| 20 | +age,90,OLS quadratic,-0.5106066499185778 | |
| 21 | +age,95,OLS quadratic,-0.5198696006713118 | |
| 22 | +age,100,OLS quadratic,-0.5271215950710086 | |
| 23 | +age,105,OLS quadratic,-0.5323626331176683 | |
| 24 | +age,110,OLS quadratic,-0.5355927148112904 | |
| 25 | +age,115,OLS quadratic,-0.5368118401518757 | |
| 26 | +age,120,OLS quadratic,-0.5360200091394236 | |
| 27 | +area,50,OLS quadratic,0.0 | |
| 28 | +area,75,OLS quadratic,0.1540482284114693 | |
| 29 | +area,100,OLS quadratic,0.29507549352288237 | |
| 30 | +area,125,OLS quadratic,0.4230817953342389 | |
| 31 | +area,150,OLS quadratic,0.5380671338455387 | |
| 32 | +area,175,OLS quadratic,0.6400315090567827 | |
| 33 | +area,200,OLS quadratic,0.7289749209679699 | |
| 34 | +area,225,OLS quadratic,0.8048973695791006 | |
| 35 | +area,250,OLS quadratic,0.8677988548901752 | |
| 36 | +area,275,OLS quadratic,0.9176793769011928 | |
| 37 | +area,300,OLS quadratic,0.9545389356121543 | |
| 38 | +area,325,OLS quadratic,0.9783775310230596 | |
| 39 | +area,350,OLS quadratic,0.9891951631339087 | |
| 40 | +area,375,OLS quadratic,0.9869918319447009 | |
| 41 | +area,400,OLS quadratic,0.9717675374554366 | |
| 42 | +age,0,Gradient boosting,0.0 | |
| 43 | +age,5,Gradient boosting,0.06022362794499614 | |
| 44 | +age,10,Gradient boosting,-0.005032206446413667 | |
| 45 | +age,15,Gradient boosting,-0.05994112503617899 | |
| 46 | +age,20,Gradient boosting,-0.07127803977000902 | |
| 47 | +age,25,Gradient boosting,-0.08746246223557996 | |
| 48 | +age,30,Gradient boosting,-0.13688306112882742 | |
| 49 | +age,35,Gradient boosting,-0.1651092607357718 | |
| 50 | +age,40,Gradient boosting,-0.19815957087055303 | |
| 51 | +age,45,Gradient boosting,-0.23304192483616326 | |
| 52 | +age,50,Gradient boosting,-0.25315924378123533 | |
| 53 | +age,55,Gradient boosting,-0.27590408944412204 | |
| 54 | +age,60,Gradient boosting,-0.28428243040351475 | |
| 55 | +age,65,Gradient boosting,-0.319171736553443 | |
| 56 | +age,70,Gradient boosting,-0.3431363607058149 | |
| 57 | +age,75,Gradient boosting,-0.37182171513782336 | |
| 58 | +age,80,Gradient boosting,-0.39166657486250145 | |
| 59 | +age,85,Gradient boosting,-0.4038026638189365 | |
| 60 | +age,90,Gradient boosting,-0.4051548521390185 | |
| 61 | +age,95,Gradient boosting,-0.40635586541562674 | |
| 62 | +age,100,Gradient boosting,-0.4076605522328567 | |
| 63 | +age,105,Gradient boosting,-0.4139522349833751 | |
| 64 | +age,110,Gradient boosting,-0.4180651627994525 | |
| 65 | +age,115,Gradient boosting,-0.4328961974199519 | |
| 66 | +age,120,Gradient boosting,-0.4362886014528371 | |
| 67 | +area,50,Gradient boosting,0.0 | |
| 68 | +area,75,Gradient boosting,0.18829979560266175 | |
| 69 | +area,100,Gradient boosting,0.34718915583661314 | |
| 70 | +area,125,Gradient boosting,0.4609024483577322 | |
| 71 | +area,150,Gradient boosting,0.5737430678733197 | |
| 72 | +area,175,Gradient boosting,0.6473792262306208 | |
| 73 | +area,200,Gradient boosting,0.714730774490473 | |
| 74 | +area,225,Gradient boosting,0.7759301267880261 | |
| 75 | +area,250,Gradient boosting,0.8380377927153972 | |
| 76 | +area,275,Gradient boosting,0.8772665644381696 | |
| 77 | +area,300,Gradient boosting,0.9163093623875032 | |
| 78 | +area,325,Gradient boosting,0.9397872441141804 | |
| 79 | +area,350,Gradient boosting,0.9519873322394119 | |
| 80 | +area,375,Gradient boosting,0.9529180642873598 | |
| 81 | +area,400,Gradient boosting,0.9599879330265484 | |
added
results/reproduced/sample_counts.csv
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +,0 | |
| 2 | +n_sales,514212 | |
| 3 | +n_munis,1115 | |
| 4 | +n_grid,30548 | |
| 5 | +n_grid5,5255 | |
| 6 | +n_condo,75734 | |
| 7 | +n_cottage,10349 | |
| 8 | +n_mobile,4881 | |
| 9 | +n_other,86 | |
| 10 | +n_plex,74909 | |
| 11 | +n_single_family,348253 | |
added
results/reproduced/segments.csv
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +segment,model,n,mdape,mape,rmse_ln,r2_ln | |
| 2 | +Class: single_family,OLS quadratic,69835,17.15697828017771,35.09144196354416,0.4018837509330969,0.5269797760968626 | |
| 3 | +Class: single_family,Gradient boosting,69835,15.08887366879076,29.429305197147464,0.3608165339944521,0.6187132829338855 | |
| 4 | +Class: condo,OLS quadratic,14848,20.42743440870622,27.094418864984917,0.32651881058598764,0.4074914996647663 | |
| 5 | +Class: condo,Gradient boosting,14848,9.071686847966884,14.485209967611992,0.20596083242967297,0.7642523075225905 | |
| 6 | +Class: plex,OLS quadratic,15115,18.50099130749293,36.98046001501068,0.41103973211699246,0.5454994242149013 | |
| 7 | +Class: plex,Gradient boosting,15115,16.52761611544543,30.817583909526963,0.36806946728568257,0.635559683430239 | |
| 8 | +Class: cottage,OLS quadratic,2109,33.91947109274449,53.22661347754297,0.5482899168598493,0.3136130552394867 | |
| 9 | +Class: cottage,Gradient boosting,2109,32.09640332106328,45.661889354803385,0.5034612691648649,0.4212638262473417 | |
| 10 | +Muni < 1k sales,OLS quadratic,29253,23.257955712558935,42.00606759369818,0.4521889409919308,0.4895905571585859 | |
| 11 | +Muni < 1k sales,Gradient boosting,29253,18.686729079471053,32.7580249582477,0.38939943295154483,0.621496886358641 | |
| 12 | +Muni 1k–10k sales,OLS quadratic,37419,15.193131785630404,32.29520768278317,0.3776824491741606,0.5133709854088806 | |
| 13 | +Muni 1k–10k sales,Gradient boosting,37419,14.504639160801878,28.498332907599565,0.3521566764912874,0.576926089020657 | |
| 14 | +Muni > 10k sales,OLS quadratic,36257,17.82460628493704,30.99741904583036,0.3669850806325136,0.46899139390723055 | |
| 15 | +Muni > 10k sales,Gradient boosting,36257,11.91348390081652,22.983932385296278,0.30299483947274597,0.6380278097783667 | |
added
results/reproduced/summary_stats.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +variable,n,mean,sd,p10,p50,p90 | |
| 2 | +Sale price ($),514212,440207.3699233001,245715.68560162513,170000.0,397375.0,759000.0 | |
| 3 | +Floor area (m2),514212,132.3595075571943,61.5418900414627,76.9,111.7,217.4 | |
| 4 | +Lot area (m2),514212,1059.7684570760696,1783.1060765884122,114.46100000000006,557.4,2473.8 | |
| 5 | +Building age (years),514212,43.90011318288955,29.78225299985444,10.0,40.0,80.0 | |
| 6 | +Storeys,514212,1.361457920079656,0.5123188318543891,1.0,1.0,2.0 | |
| 7 | +Dwelling units,514212,1.2390025903712865,0.6855596301480114,1.0,1.0,2.0 | |
added
results/tables/boxcox.tex
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +\begin{tabular}{lccccccc} | |
| 2 | +\toprule | |
| 3 | +$\lambda$ & -0.50 & -0.25 & 0.00 & 0.25 & 0.50 & 0.75 & 1.00 \\ | |
| 4 | +\midrule | |
| 5 | +Profile log-likelihood$^{\dagger}$ & -106.9 & -59.7 & -17.0 & 0.0 & -3.7 & -14.3 & -45.8 \\ | |
| 6 | +\bottomrule | |
| 7 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/generalization.tex
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +\begin{tabular}{lcc c cc} | |
| 2 | +\toprule | |
| 3 | + & \multicolumn{2}{c}{Random holdout} & & \multicolumn{2}{c}{Forward-in-time} \\ | |
| 4 | +\cmidrule{2-3}\cmidrule{5-6} | |
| 5 | +Model & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & $R^2_{\ln}$ \\ | |
| 6 | +\midrule | |
| 7 | +A5 Semi-log + quadratics & 18.1 & 0.533 & & 20.5 & 0.479 \\ | |
| 8 | +B4 Month FE & 18.3 & 0.529 & & 20.3 & 0.476 \\ | |
| 9 | +C4 Grid-cell FE ($\sim$1.1 km) & 19.4 & 0.481 & & 22.1 & 0.400 \\ | |
| 10 | +D1 OLS (muni + month FE) & 18.3 & 0.529 & & 20.3 & 0.476 \\ | |
| 11 | +D2 Ridge (cross-validated) & 16.7 & 0.577 & & 19.5 & 0.515 \\ | |
| 12 | +D3 Random forest & 14.6 & 0.631 & & 21.3 & 0.519 \\ | |
| 13 | +D4 Gradient boosting & 14.4 & 0.642 & & 21.2 & 0.521 \\ | |
| 14 | +D5 Gradient boosting, no coordinates & 23.2 & 0.426 & & 26.2 & 0.310 \\ | |
| 15 | +D6 Spatial k-NN comparables & 21.2 & 0.434 & & 25.6 & 0.253 \\ | |
| 16 | +\bottomrule | |
| 17 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/horserace_random.tex
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +\begin{tabular}{lcccc c} | |
| 2 | +\toprule | |
| 3 | +Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & $R^2_{\ln}$ & Fit (s) \\ | |
| 4 | +\midrule | |
| 5 | +\multicolumn{6}{l}{\itshape A. Functional form}\\ | |
| 6 | +\quad A1 Linear (levels) & 18.2 & 35.6 & 0.420 & 0.477 & 0 \\ | |
| 7 | +\quad A2 Semi-log & 18.2 & 34.5 & 0.397 & 0.533 & 1 \\ | |
| 8 | +\quad A3 Log-log & 17.0 & 32.7 & 0.381 & 0.569 & 1 \\ | |
| 9 | +\quad A4 Box-Cox & 18.6 & 33.0 & 0.390 & 0.548 & 4 \\ | |
| 10 | +\quad A5 Semi-log + quadratics & 18.1 & 34.6 & 0.397 & 0.533 & 1 \\ | |
| 11 | +\quad A6 Semi-log + splines & \textbf{16.5} & 32.1 & 0.376 & 0.579 & 3 \\ | |
| 12 | +\midrule | |
| 13 | +\multicolumn{6}{l}{\itshape B. Time effects}\\ | |
| 14 | +\quad B1 No time effects & 21.1 & 38.4 & 0.423 & 0.468 & 0 \\ | |
| 15 | +\quad B2 Year FE & 18.1 & 34.7 & 0.397 & 0.532 & 1 \\ | |
| 16 | +\quad B3 Quarter FE & \textbf{18.1} & 34.6 & 0.397 & 0.533 & 1 \\ | |
| 17 | +\quad B4 Month FE & 18.3 & 34.8 & 0.398 & 0.529 & 1 \\ | |
| 18 | +\midrule | |
| 19 | +\multicolumn{6}{l}{\itshape C. Spatial controls}\\ | |
| 20 | +\quad C1 No spatial controls & 27.9 & 48.4 & 0.494 & 0.274 & 0 \\ | |
| 21 | +\quad C2 Municipality FE & 18.1 & 34.6 & 0.397 & 0.533 & 1 \\ | |
| 22 | +\quad C3 Grid-cell FE ($\sim$5.5 km) & \textbf{17.5} & 34.0 & 0.392 & 0.544 & 1 \\ | |
| 23 | +\quad C4 Grid-cell FE ($\sim$1.1 km) & 19.4 & 37.5 & 0.418 & 0.481 & 1 \\ | |
| 24 | +\midrule | |
| 25 | +\multicolumn{6}{l}{\itshape D. Estimation method}\\ | |
| 26 | +\quad D1 OLS (muni + month FE) & 18.3 & 34.8 & 0.398 & 0.529 & 1 \\ | |
| 27 | +\quad D2 Ridge (cross-validated) & 16.7 & 32.3 & 0.378 & 0.577 & 11 \\ | |
| 28 | +\quad D3 Random forest & 14.6 & 28.2 & 0.353 & 0.631 & 8 \\ | |
| 29 | +\quad D4 Gradient boosting & \textbf{14.4} & 27.8 & 0.347 & 0.642 & 42 \\ | |
| 30 | +\quad D5 Gradient boosting, no coordinates & 23.2 & 38.3 & 0.440 & 0.426 & 18 \\ | |
| 31 | +\quad D6 Spatial k-NN comparables & 21.2 & 36.7 & 0.437 & 0.434 & 0 \\ | |
| 32 | +\bottomrule | |
| 33 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/horserace_temporal.tex
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +\begin{tabular}{lcccc c} | |
| 2 | +\toprule | |
| 3 | +Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & $R^2_{\ln}$ & Fit (s) \\ | |
| 4 | +\midrule | |
| 5 | +\multicolumn{6}{l}{\itshape A. Functional form}\\ | |
| 6 | +\quad A1 Linear (levels) & 20.4 & 32.9 & 0.397 & 0.455 & 0 \\ | |
| 7 | +\quad A2 Semi-log & 20.6 & 33.1 & 0.396 & 0.458 & 0 \\ | |
| 8 | +\quad A3 Log-log & 19.2 & 30.7 & 0.376 & 0.513 & 1 \\ | |
| 9 | +\quad A4 Box-Cox & 21.0 & 31.9 & 0.390 & 0.476 & 4 \\ | |
| 10 | +\quad A5 Semi-log + quadratics & 20.5 & 32.1 & 0.388 & 0.479 & 1 \\ | |
| 11 | +\quad A6 Semi-log + splines & \textbf{19.1} & 30.5 & 0.372 & 0.522 & 2 \\ | |
| 12 | +\midrule | |
| 13 | +\multicolumn{6}{l}{\itshape B. Time effects}\\ | |
| 14 | +\quad B1 No time effects & 25.4 & 33.2 & 0.418 & 0.397 & 0 \\ | |
| 15 | +\quad B2 Year FE & 21.0 & 32.6 & 0.393 & 0.468 & 1 \\ | |
| 16 | +\quad B3 Quarter FE & 20.5 & 32.1 & 0.388 & 0.479 & 1 \\ | |
| 17 | +\quad B4 Month FE & \textbf{20.3} & 32.5 & 0.390 & 0.476 & 1 \\ | |
| 18 | +\midrule | |
| 19 | +\multicolumn{6}{l}{\itshape C. Spatial controls}\\ | |
| 20 | +\quad C1 No spatial controls & 26.5 & 41.1 & 0.457 & 0.279 & 0 \\ | |
| 21 | +\quad C2 Municipality FE & \textbf{20.5} & 32.1 & 0.388 & 0.479 & 1 \\ | |
| 22 | +\quad C3 Grid-cell FE ($\sim$5.5 km) & 21.3 & 32.7 & 0.394 & 0.465 & 1 \\ | |
| 23 | +\quad C4 Grid-cell FE ($\sim$1.1 km) & 22.1 & 36.0 & 0.417 & 0.400 & 1 \\ | |
| 24 | +\midrule | |
| 25 | +\multicolumn{6}{l}{\itshape D. Estimation method}\\ | |
| 26 | +\quad D1 OLS (muni + month FE) & 20.3 & 32.5 & 0.390 & 0.476 & 1 \\ | |
| 27 | +\quad D2 Ridge (cross-validated) & \textbf{19.5} & 30.6 & 0.375 & 0.515 & 9 \\ | |
| 28 | +\quad D3 Random forest & 21.3 & 29.8 & 0.373 & 0.519 & 7 \\ | |
| 29 | +\quad D4 Gradient boosting & 21.2 & 29.7 & 0.372 & 0.521 & 42 \\ | |
| 30 | +\quad D5 Gradient boosting, no coordinates & 26.2 & 35.7 & 0.447 & 0.310 & 21 \\ | |
| 31 | +\quad D6 Spatial k-NN comparables & 25.6 & 34.2 & 0.465 & 0.253 & 0 \\ | |
| 32 | +\bottomrule | |
| 33 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/learning.tex
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +\begin{tabular}{rcc} | |
| 2 | +\toprule | |
| 3 | +Training sales & Gradient boosting & OLS quadratic (muni+quarter FE) \\ | |
| 4 | +\midrule | |
| 5 | +10,000 & 17.6 & 18.4 \\ | |
| 6 | +25,000 & 16.1 & 18.1 \\ | |
| 7 | +50,000 & 15.5 & 17.9 \\ | |
| 8 | +100,000 & 15.2 & 17.9 \\ | |
| 9 | +200,000 & 14.8 & 17.9 \\ | |
| 10 | +400,000 & 14.5 & 18.1 \\ | |
| 11 | +\bottomrule | |
| 12 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/segments.tex
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +\begin{tabular}{lrcc} | |
| 2 | +\toprule | |
| 3 | +Segment & $n$ (test) & OLS quadratic & Gradient boosting \\ | |
| 4 | +\midrule | |
| 5 | +Condo & 14,848 & 20.4 & 9.1 \\ | |
| 6 | +Cottage & 2,109 & 33.9 & 32.1 \\ | |
| 7 | +Plex & 15,115 & 18.5 & 16.5 \\ | |
| 8 | +Single family & 69,835 & 17.2 & 15.1 \\ | |
| 9 | +Muni 1k--10k sales & 37,419 & 15.2 & 14.5 \\ | |
| 10 | +Muni $<$ 1k sales & 29,253 & 23.3 & 18.7 \\ | |
| 11 | +Muni $>$ 10k sales & 36,257 & 17.8 & 11.9 \\ | |
| 12 | +\bottomrule | |
| 13 | +\end{tabular} | |
| \ No newline at end of file | ||
added
results/tables/summary_stats.tex
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +\begin{tabular}{lrrrrrr} | |
| 2 | +\toprule | |
| 3 | +Variable & $n$ & Mean & SD & P10 & Median & P90 \\ | |
| 4 | +\midrule | |
| 5 | +Sale price (\$) & 514,212 & 440,207 & 245,716 & 170,000 & 397,375 & 759,000 \\ | |
| 6 | +Floor area (m$^2$) & 514,212 & 132.36 & 61.54 & 76.90 & 111.70 & 217.40 \\ | |
| 7 | +Lot area (m$^2$) & 514,212 & 1,060 & 1,783 & 114 & 557 & 2,474 \\ | |
| 8 | +Building age (years) & 514,212 & 43.90 & 29.78 & 10.00 & 40.00 & 80.00 \\ | |
| 9 | +Storeys & 514,212 & 1.36 & 0.51 & 1.00 & 1.00 & 2.00 \\ | |
| 10 | +Dwelling units & 514,212 & 1.24 & 0.69 & 1.00 & 1.00 & 2.00 \\ | |
| 11 | +\bottomrule | |
| 12 | +\end{tabular} | |
| \ No newline at end of file | ||
added
scripts/01_build_sample.py
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +"""Step 01 — Build the estimation sample. | |
| 4 | + | |
| 5 | +Usage: python scripts/01_build_sample.py | |
| 6 | +""" | |
| 7 | +import sys | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 11 | + | |
| 12 | +from wp11 import sample # noqa: E402 | |
| 13 | + | |
| 14 | + | |
| 15 | +def main() -> None: | |
| 16 | + s = sample.build_and_save() | |
| 17 | + print(f"\nEstimation sample written: {len(s):,} sales") | |
| 18 | + print(f" municipalities : {s['muni'].nunique():,}") | |
| 19 | + print(f" grid cells : {s['grid'].nunique():,}") | |
| 20 | + print(f" grid5 cells : {s['grid5'].nunique():,}") | |
| 21 | + print(f" classes : {s.groupby('prop_class').size().to_dict()}") | |
| 22 | + | |
| 23 | + | |
| 24 | +if __name__ == "__main__": | |
| 25 | + main() | |
added
scripts/02_horserace.py
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +"""Step 02 — The horse race. | |
| 4 | + | |
| 5 | +Fits the twenty registry models under (i) a random 80/20 holdout and | |
| 6 | +(ii) a forward-in-time holdout (train < 2025, test 2025–2026), plus the | |
| 7 | +Box–Cox profile likelihood and summary statistics. | |
| 8 | + | |
| 9 | +Writes: horserace_random.csv, horserace_temporal.csv, boxcox.csv, | |
| 10 | +summary_stats.csv, sample_counts.csv | |
| 11 | + | |
| 12 | +Usage: python scripts/02_horserace.py | |
| 13 | +""" | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +import pandas as pd | |
| 18 | + | |
| 19 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 20 | + | |
| 21 | +from wp11 import config, models, sample # noqa: E402 | |
| 22 | + | |
| 23 | + | |
| 24 | +def main() -> None: | |
| 25 | + config.ensure_dirs() | |
| 26 | + df = sample.load() | |
| 27 | + out = config.REPRODUCED | |
| 28 | + | |
| 29 | + # ------------------------------------------------------------ Table 1 | |
| 30 | + desc = {"price": "Sale price ($)", "area": "Floor area (m2)", | |
| 31 | + "lot": "Lot area (m2)", "age": "Building age (years)", | |
| 32 | + "floors": "Storeys", "units": "Dwelling units"} | |
| 33 | + rows = [] | |
| 34 | + for var, label in desc.items(): | |
| 35 | + s = df[var] | |
| 36 | + rows.append({"variable": label, "n": len(s), "mean": s.mean(), | |
| 37 | + "sd": s.std(), "p10": s.quantile(.10), "p50": s.median(), | |
| 38 | + "p90": s.quantile(.90)}) | |
| 39 | + pd.DataFrame(rows).to_csv(out / "summary_stats.csv", index=False) | |
| 40 | + counts = {"n_sales": len(df), "n_munis": df["muni"].nunique(), | |
| 41 | + "n_grid": df["grid"].nunique(), | |
| 42 | + "n_grid5": df["grid5"].nunique()} | |
| 43 | + for k, v in df.groupby("prop_class").size().items(): | |
| 44 | + counts[f"n_{k}"] = int(v) | |
| 45 | + pd.Series(counts).to_csv(out / "sample_counts.csv") | |
| 46 | + | |
| 47 | + # ------------------------------------------------------------ Box–Cox profile | |
| 48 | + d = models.add_derived(df) | |
| 49 | + d["sale_year_c"] = d["sale_year"].astype(str) | |
| 50 | + train, _ = models.split(d, "random") | |
| 51 | + lam, prof = models.fit_boxcox(train, models.NUM_LEVELS, | |
| 52 | + models.CATS + ["muni", "quarter"]) | |
| 53 | + prof["chosen"] = prof["lambda"] == lam | |
| 54 | + prof.to_csv(out / "boxcox.csv", index=False) | |
| 55 | + print(f"Box–Cox lambda* = {lam}") | |
| 56 | + | |
| 57 | + # ------------------------------------------------------------ the race | |
| 58 | + for scheme in ("random", "temporal"): | |
| 59 | + res = models.run_horserace(df, scheme) | |
| 60 | + res.to_csv(out / f"horserace_{scheme}.csv", index=False) | |
| 61 | + | |
| 62 | + | |
| 63 | +if __name__ == "__main__": | |
| 64 | + main() | |
added
scripts/03_extensions.py
+198 −0
@@ -0,0 +1,198 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +"""Step 03 — Extensions beyond the scoreboard. | |
| 4 | + | |
| 5 | +1. Constant-quality price indices implied by the month fixed effects of | |
| 6 | + four linear functional forms, plus a gradient-boosting index obtained | |
| 7 | + by repricing a fixed reference sample at each month. | |
| 8 | +2. Implicit-price comparison: the age and floor-area profiles implied by | |
| 9 | + the quadratic OLS versus the partial-dependence profile of the | |
| 10 | + gradient-boosting model. | |
| 11 | +3. Learning curves: accuracy versus training-set size for OLS and GB. | |
| 12 | +4. Segment analysis: MdAPE by property class and municipality size. | |
| 13 | + | |
| 14 | +Writes: index.csv, profiles.csv, learning.csv, segments.csv | |
| 15 | + | |
| 16 | +Usage: python scripts/03_extensions.py | |
| 17 | +""" | |
| 18 | +import sys | |
| 19 | +from pathlib import Path | |
| 20 | + | |
| 21 | +import numpy as np | |
| 22 | +import pandas as pd | |
| 23 | + | |
| 24 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 25 | + | |
| 26 | +from wp11 import config, models, sample # noqa: E402 | |
| 27 | +from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402 | |
| 28 | + | |
| 29 | + | |
| 30 | +def month_index_from_linear(spec, train, months): | |
| 31 | + """Fit a month-FE linear model and read the index off the dummies. | |
| 32 | + | |
| 33 | + Numeric regressors are z-scored first: month-dummy coefficients are | |
| 34 | + invariant to that reparametrization, but the conjugate-gradient | |
| 35 | + solver needs the improved conditioning for the individual | |
| 36 | + coefficients (not just the fit) to be accurate when levels like lot | |
| 37 | + area (up to 10^5 m2) share the design with dummies. | |
| 38 | + """ | |
| 39 | + d2 = train.copy() | |
| 40 | + for c in spec.get("numeric", []): | |
| 41 | + sd = d2[c].std() | |
| 42 | + if sd > 0: | |
| 43 | + d2[c] = (d2[c] - d2[c].mean()) / sd | |
| 44 | + for c in spec.get("spline", []): | |
| 45 | + d2[c] = train[c] # splines keep their raw support | |
| 46 | + train = d2 | |
| 47 | + pred, pipe = models.fit_predict(dict(spec), train, train.head(50)) | |
| 48 | + ct = pipe.named_steps["ct"] | |
| 49 | + names = ct.get_feature_names_out() | |
| 50 | + coefs = pipe.named_steps["reg"].coef_ | |
| 51 | + idx = {} | |
| 52 | + for nm, c in zip(names, coefs): | |
| 53 | + if "month_" in nm: | |
| 54 | + idx[nm.split("month_")[-1]] = float(c) | |
| 55 | + base = sorted(months)[0] | |
| 56 | + ref = idx.get(base, 0.0) | |
| 57 | + return {m: 100 * float(np.exp(idx.get(m, 0.0) - ref)) for m in months} | |
| 58 | + | |
| 59 | + | |
| 60 | +def main() -> None: | |
| 61 | + config.ensure_dirs() | |
| 62 | + df = sample.load() | |
| 63 | + out = config.REPRODUCED | |
| 64 | + d = models.add_derived(df) | |
| 65 | + d["sale_year_c"] = d["sale_year"].astype(str) | |
| 66 | + months = sorted(d["month"].unique()) | |
| 67 | + | |
| 68 | + # ------------------------------------------------------------ 1. indices | |
| 69 | + forms = { | |
| 70 | + "Semi-log": dict(kind="linear_log", numeric=models.NUM_LEVELS, | |
| 71 | + fe=["muni", "month"]), | |
| 72 | + "Log-log": dict(kind="linear_log", numeric=models.NUM_LOGS, | |
| 73 | + fe=["muni", "month"]), | |
| 74 | + "Semi-log + quadratics": dict(kind="linear_log", | |
| 75 | + numeric=models.NUM_QUAD, | |
| 76 | + fe=["muni", "month"]), | |
| 77 | + "Semi-log + splines": dict(kind="linear_log", | |
| 78 | + numeric=models.NUM_LEVELS, | |
| 79 | + fe=["muni", "month"], | |
| 80 | + spline=["area", "age", "lot"]), | |
| 81 | + } | |
| 82 | + rows = [] | |
| 83 | + for label, spec in forms.items(): | |
| 84 | + spec["name"], spec["group"] = label, "index" | |
| 85 | + idx = month_index_from_linear(spec, d, months) | |
| 86 | + rows += [{"month": m, "form": label, "index": v} | |
| 87 | + for m, v in idx.items()] | |
| 88 | + print(f" index from {label}: done") | |
| 89 | + | |
| 90 | + # gradient boosting: reprice a fixed 20k reference sample each month | |
| 91 | + rng = np.random.default_rng(config.SEED) | |
| 92 | + hgb = HistGradientBoostingRegressor(max_iter=600, learning_rate=0.08, | |
| 93 | + max_leaf_nodes=63, min_samples_leaf=20, | |
| 94 | + l2_regularization=1e-2, | |
| 95 | + random_state=config.SEED) | |
| 96 | + hgb.fit(models._ml_frame(d), d["ln_price"].to_numpy()) | |
| 97 | + ref = d.sample(20_000, random_state=config.SEED).copy() | |
| 98 | + t_by_month = d.groupby("month")["t"].mean() | |
| 99 | + base_val = None | |
| 100 | + for m in months: | |
| 101 | + ref["t"] = t_by_month[m] | |
| 102 | + v = float(np.exp(hgb.predict(models._ml_frame(ref))).mean()) | |
| 103 | + base_val = base_val or v | |
| 104 | + rows.append({"month": m, "form": "Gradient boosting", | |
| 105 | + "index": 100 * v / base_val}) | |
| 106 | + pd.DataFrame(rows).to_csv(out / "index.csv", index=False) | |
| 107 | + print(" index from Gradient boosting: done") | |
| 108 | + | |
| 109 | + # ------------------------------------------------------------ 2. profiles | |
| 110 | + train, _ = models.split(d, "random") | |
| 111 | + spec = dict(name="A5", group="x", kind="linear_log", | |
| 112 | + numeric=models.NUM_QUAD, fe=["muni", "quarter"]) | |
| 113 | + _, pipe = models.fit_predict(spec, train, train.head(50)) | |
| 114 | + names = pipe.named_steps["ct"].get_feature_names_out() | |
| 115 | + coefs = dict(zip(names, pipe.named_steps["reg"].coef_)) | |
| 116 | + prof_rows = [] | |
| 117 | + ages = np.arange(0, 121, 5) | |
| 118 | + b_age = coefs.get("num__age", 0.0) | |
| 119 | + b_age2 = coefs.get("num__age2", 0.0) | |
| 120 | + for a in ages: | |
| 121 | + y = b_age * a + b_age2 * (a / 10.0) ** 2 | |
| 122 | + prof_rows.append({"var": "age", "x": a, "model": "OLS quadratic", | |
| 123 | + "y": y - (b_age * ages[0])}) | |
| 124 | + areas = np.arange(50, 401, 25) | |
| 125 | + b_ar = coefs.get("num__area", 0.0) | |
| 126 | + b_ar2 = coefs.get("num__area2", 0.0) | |
| 127 | + y0 = b_ar * areas[0] + b_ar2 * (areas[0] / 100.0) ** 2 | |
| 128 | + for a in areas: | |
| 129 | + y = b_ar * a + b_ar2 * (a / 100.0) ** 2 | |
| 130 | + prof_rows.append({"var": "area", "x": a, "model": "OLS quadratic", | |
| 131 | + "y": y - y0}) | |
| 132 | + | |
| 133 | + # partial dependence of the GB model (average prediction on a grid) | |
| 134 | + sub = models._ml_frame(train.sample(30_000, random_state=config.SEED)) | |
| 135 | + for var, grid in (("age", ages), ("area", areas)): | |
| 136 | + vals = [] | |
| 137 | + for g in grid: | |
| 138 | + s2 = sub.copy() | |
| 139 | + s2[var] = g | |
| 140 | + vals.append(float(hgb.predict(s2).mean())) | |
| 141 | + v0 = vals[0] | |
| 142 | + prof_rows += [{"var": var, "x": g, "model": "Gradient boosting", | |
| 143 | + "y": v - v0} for g, v in zip(grid, vals)] | |
| 144 | + pd.DataFrame(prof_rows).to_csv(out / "profiles.csv", index=False) | |
| 145 | + print(" implicit-price profiles: done") | |
| 146 | + | |
| 147 | + # ------------------------------------------------------------ 3. learning curves | |
| 148 | + train_full, test = models.split(d, "random") | |
| 149 | + y_test = test["price"].to_numpy(float) | |
| 150 | + rows = [] | |
| 151 | + for n in config.LEARNING_SIZES: | |
| 152 | + if n > len(train_full): | |
| 153 | + continue | |
| 154 | + tr = train_full.sample(n, random_state=config.SEED) | |
| 155 | + for label, spec in ( | |
| 156 | + ("OLS quadratic (muni+quarter FE)", | |
| 157 | + dict(name="A5", group="x", kind="linear_log", | |
| 158 | + numeric=models.NUM_QUAD, fe=["muni", "quarter"])), | |
| 159 | + ("Gradient boosting", | |
| 160 | + dict(name="D4", group="x", kind="hgb"))): | |
| 161 | + pred, _ = models.fit_predict(spec, tr, test) | |
| 162 | + met = models._metrics(y_test, pred) | |
| 163 | + rows.append({"n_train": n, "model": label, **met}) | |
| 164 | + print(f" learning n={n:>7,} {label:<34} " | |
| 165 | + f"MdAPE={met['mdape']:.1f}%") | |
| 166 | + pd.DataFrame(rows).to_csv(out / "learning.csv", index=False) | |
| 167 | + | |
| 168 | + # ------------------------------------------------------------ 4. segments | |
| 169 | + rows = [] | |
| 170 | + muni_sales = d.groupby("muni")["muni"].transform("size") | |
| 171 | + segs = {f"Class: {c}": d["prop_class"] == c | |
| 172 | + for c in ("single_family", "condo", "plex", "cottage")} | |
| 173 | + segs.update({"Muni < 1k sales": muni_sales < 1_000, | |
| 174 | + "Muni 1k–10k sales": muni_sales.between(1_000, 10_000), | |
| 175 | + "Muni > 10k sales": muni_sales > 10_000}) | |
| 176 | + train, test = models.split(d, "random") | |
| 177 | + preds = {} | |
| 178 | + for label, spec in ( | |
| 179 | + ("OLS quadratic", dict(name="A5", group="x", kind="linear_log", | |
| 180 | + numeric=models.NUM_QUAD, | |
| 181 | + fe=["muni", "quarter"])), | |
| 182 | + ("Gradient boosting", dict(name="D4", group="x", kind="hgb"))): | |
| 183 | + preds[label], _ = models.fit_predict(spec, train, test) | |
| 184 | + y_test = test["price"].to_numpy(float) | |
| 185 | + for seg, mask in segs.items(): | |
| 186 | + m = mask.loc[test.index].to_numpy() | |
| 187 | + if m.sum() < 500: | |
| 188 | + continue | |
| 189 | + for label, p in preds.items(): | |
| 190 | + met = models._metrics(y_test[m], p[m]) | |
| 191 | + rows.append({"segment": seg, "model": label, "n": int(m.sum()), | |
| 192 | + **met}) | |
| 193 | + pd.DataFrame(rows).to_csv(out / "segments.csv", index=False) | |
| 194 | + print(" segment analysis: done") | |
| 195 | + | |
| 196 | + | |
| 197 | +if __name__ == "__main__": | |
| 198 | + main() | |
added
scripts/04_make_figures.py
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +"""Step 04 — Publication figures (print-journal calibre). | |
| 4 | + | |
| 5 | +Reads the step-02/03 outputs and writes eight PNG figures to ``figures/``. | |
| 6 | +Same conventions as WP10: no in-figure titles on single panels, bold | |
| 7 | +"Panel A/B" headers on multi-panel figures, one accent hue doubled by a | |
| 8 | +marker/linestyle difference, direct labels over legend boxes. | |
| 9 | + | |
| 10 | +Usage: python scripts/04_make_figures.py | |
| 11 | +""" | |
| 12 | +import sys | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +import numpy as np | |
| 16 | +import pandas as pd | |
| 17 | + | |
| 18 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 19 | + | |
| 20 | +from wp11 import config, sample # noqa: E402 | |
| 21 | +from wp11.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH, # noqa: E402 | |
| 22 | + apply_style, panel_label, ygrid) | |
| 23 | + | |
| 24 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 25 | + | |
| 26 | +OUT = config.FIGURES | |
| 27 | +RES = config.REPRODUCED | |
| 28 | + | |
| 29 | + | |
| 30 | +def _save(fig, name): | |
| 31 | + fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02) | |
| 32 | + plt.close(fig) | |
| 33 | + | |
| 34 | + | |
| 35 | +def _forest(ax, res, metric="mdape", xlabel="Median absolute error (%)"): | |
| 36 | + y = np.arange(len(res))[::-1].astype(float) | |
| 37 | + ax.barh(y, res[metric], height=0.62, color=LIGHT, edgecolor=INK, | |
| 38 | + linewidth=0.4) | |
| 39 | + for yy, v in zip(y, res[metric]): | |
| 40 | + ax.text(v + 0.25, yy, f"{v:.1f}", va="center", fontsize=7.5) | |
| 41 | + ax.set_yticks(y) | |
| 42 | + ax.set_yticklabels(res["name"], fontsize=8) | |
| 43 | + ax.set_xlabel(xlabel) | |
| 44 | + ax.spines["left"].set_visible(False) | |
| 45 | + ax.tick_params(axis="y", length=0) | |
| 46 | + | |
| 47 | + | |
| 48 | +# ---------------------------------------------------------------- fig 1-4 | |
| 49 | +def fig_groups(): | |
| 50 | + """One figure per design axis: MdAPE bars, random holdout.""" | |
| 51 | + r = pd.read_csv(RES / "horserace_random.csv") | |
| 52 | + for gkey, fname in (("A.", "fig_forms.png"), ("B.", "fig_time.png"), | |
| 53 | + ("C.", "fig_space.png"), ("D.", "fig_methods.png")): | |
| 54 | + res = r[r["group"].str.startswith(gkey)].reset_index(drop=True) | |
| 55 | + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, | |
| 56 | + 0.55 + 0.42 * len(res))) | |
| 57 | + _forest(ax, res) | |
| 58 | + ax.set_xlim(0, res["mdape"].max() * 1.15) | |
| 59 | + _save(fig, fname) | |
| 60 | + | |
| 61 | + | |
| 62 | +# ---------------------------------------------------------------- fig 5 | |
| 63 | +def fig_random_vs_temporal(): | |
| 64 | + """The generalization gap: random vs forward-in-time holdout.""" | |
| 65 | + a = pd.read_csv(RES / "horserace_random.csv") | |
| 66 | + b = pd.read_csv(RES / "horserace_temporal.csv") | |
| 67 | + m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t")) | |
| 68 | + keep = m["name"].str.match(r"(A5|B4|C4|D)") | |
| 69 | + m = m[keep].reset_index(drop=True) | |
| 70 | + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.7)) | |
| 71 | + y = np.arange(len(m))[::-1].astype(float) | |
| 72 | + ax.scatter(m["mdape_r"], y + 0.14, s=22, color=BLUE, zorder=3, | |
| 73 | + label="Random 80/20 holdout") | |
| 74 | + ax.scatter(m["mdape_t"], y - 0.14, s=24, facecolor="white", | |
| 75 | + edgecolor=RED, marker="s", zorder=3, | |
| 76 | + label="Train $<$ 2025, test 2025–26") | |
| 77 | + for yy, r0, t0 in zip(y, m["mdape_r"], m["mdape_t"]): | |
| 78 | + ax.plot([r0, t0], [yy + 0.14, yy - 0.14], color=GREY, lw=0.6, | |
| 79 | + zorder=2) | |
| 80 | + ax.set_yticks(y) | |
| 81 | + ax.set_yticklabels(m["name"], fontsize=8) | |
| 82 | + ax.set_xlabel("Median absolute error (%)") | |
| 83 | + ax.legend(loc="upper left", fontsize=8, bbox_to_anchor=(0.02, 0.35)) | |
| 84 | + ax.spines["left"].set_visible(False) | |
| 85 | + ax.tick_params(axis="y", length=0) | |
| 86 | + _save(fig, "fig_generalization.png") | |
| 87 | + | |
| 88 | + | |
| 89 | +# ---------------------------------------------------------------- fig 6 | |
| 90 | +def fig_index(): | |
| 91 | + """Constant-quality monthly indices across specifications.""" | |
| 92 | + ix = pd.read_csv(RES / "index.csv") | |
| 93 | + ix["date"] = pd.PeriodIndex(ix["month"], freq="M").to_timestamp() | |
| 94 | + fig, ax = plt.subplots(figsize=(TEXTWIDTH, 3.2)) | |
| 95 | + forms = ["Semi-log", "Log-log", "Semi-log + quadratics", | |
| 96 | + "Semi-log + splines", "Gradient boosting"] | |
| 97 | + styles = [(BLUES[2], "-"), (BLUES[3], (0, (5, 2))), (BLUES[4], "-"), | |
| 98 | + (BLUES[5], (0, (1, 1.2))), (RED, "-")] | |
| 99 | + for f, (c, ls) in zip(forms, styles): | |
| 100 | + g = ix[ix["form"] == f].sort_values("date") | |
| 101 | + lw = 1.5 if f == "Gradient boosting" else 1.0 | |
| 102 | + ax.plot(g["date"], g["index"], color=c, ls=ls, lw=lw) | |
| 103 | + # the four linear forms end within ~2 points — one bundle label | |
| 104 | + lin_end = (ix[ix["form"] != "Gradient boosting"] | |
| 105 | + .sort_values("date").groupby("form")["index"].last()) | |
| 106 | + gb = ix[ix["form"] == "Gradient boosting"].sort_values("date") | |
| 107 | + last_date = gb["date"].iloc[-1] | |
| 108 | + ax.annotate("Four linear forms", xy=(last_date, lin_end.mean()), | |
| 109 | + xytext=(6, 4), textcoords="offset points", fontsize=7.5, | |
| 110 | + color=BLUES[4], va="center") | |
| 111 | + ax.annotate("Gradient boosting", xy=(last_date, gb["index"].iloc[-1]), | |
| 112 | + xytext=(6, -4), textcoords="offset points", fontsize=7.5, | |
| 113 | + color=RED, va="center") | |
| 114 | + ax.set_ylabel("Constant-quality index (Jan 2021 = 100)") | |
| 115 | + ax.margins(x=0.14) | |
| 116 | + ygrid(ax) | |
| 117 | + _save(fig, "fig_index.png") | |
| 118 | + | |
| 119 | + | |
| 120 | +# ---------------------------------------------------------------- fig 7 | |
| 121 | +def fig_profiles(): | |
| 122 | + """Implicit ln-price profiles: OLS quadratic vs gradient boosting.""" | |
| 123 | + p = pd.read_csv(RES / "profiles.csv") | |
| 124 | + fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 3.0)) | |
| 125 | + for ax, var, xlabel, ptitle in ( | |
| 126 | + (axes[0], "age", "Building age (years)", "Panel A. Age profile"), | |
| 127 | + (axes[1], "area", "Floor area (m$^2$)", | |
| 128 | + "Panel B. Floor-area profile")): | |
| 129 | + for model, color, ls in (("OLS quadratic", BLUE, "-"), | |
| 130 | + ("Gradient boosting", RED, (0, (5, 2)))): | |
| 131 | + g = p[(p["var"] == var) & (p["model"] == model)] | |
| 132 | + ax.plot(g["x"], g["y"], color=color, ls=ls, lw=1.3, label=model) | |
| 133 | + ax.set_xlabel(xlabel) | |
| 134 | + ax.set_ylabel("ln price, relative to leftmost point") | |
| 135 | + panel_label(ax, ptitle) | |
| 136 | + axes[0].legend(fontsize=8) | |
| 137 | + fig.subplots_adjust(wspace=0.28) | |
| 138 | + _save(fig, "fig_profiles.png") | |
| 139 | + | |
| 140 | + | |
| 141 | +# ---------------------------------------------------------------- fig 8 | |
| 142 | +def fig_learning(): | |
| 143 | + """Accuracy versus training-set size.""" | |
| 144 | + l = pd.read_csv(RES / "learning.csv") | |
| 145 | + fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.2)) | |
| 146 | + for model, color, mk in (("OLS quadratic (muni+quarter FE)", BLUE, "o"), | |
| 147 | + ("Gradient boosting", RED, "s")): | |
| 148 | + g = l[l["model"] == model].sort_values("n_train") | |
| 149 | + ax.plot(g["n_train"], g["mdape"], marker=mk, ms=4.5, color=color, | |
| 150 | + lw=1.2, mfc="white" if mk == "s" else color, label=model) | |
| 151 | + ax.set_xscale("log") | |
| 152 | + ax.set_xlabel("Training sales (log scale)") | |
| 153 | + ax.set_ylabel("Median absolute error (%)") | |
| 154 | + ax.legend(fontsize=8) | |
| 155 | + ygrid(ax) | |
| 156 | + _save(fig, "fig_learning.png") | |
| 157 | + | |
| 158 | + | |
| 159 | +# ---------------------------------------------------------------- fig 9 | |
| 160 | +def fig_segments(): | |
| 161 | + """MdAPE by market segment, OLS vs GB.""" | |
| 162 | + s = pd.read_csv(RES / "segments.csv") | |
| 163 | + order = [x for x in ["Class: single_family", "Class: condo", | |
| 164 | + "Class: plex", "Class: cottage", | |
| 165 | + "Muni < 1k sales", "Muni 1k–10k sales", | |
| 166 | + "Muni > 10k sales"] if x in set(s["segment"])] | |
| 167 | + labels = {"Class: single_family": "Single-family", "Class: condo": | |
| 168 | + "Condominium", "Class: plex": "Plex", "Class: cottage": | |
| 169 | + "Cottage", "Muni < 1k sales": "Muni $<$ 1k sales", | |
| 170 | + "Muni 1k–10k sales": "Muni 1k–10k sales", | |
| 171 | + "Muni > 10k sales": "Muni $>$ 10k sales"} | |
| 172 | + y = np.arange(len(order))[::-1].astype(float) | |
| 173 | + fig, ax = plt.subplots(figsize=(0.8 * TEXTWIDTH, 3.4)) | |
| 174 | + for model, color, mk, dy in (("OLS quadratic", BLUE, "o", 0.14), | |
| 175 | + ("Gradient boosting", RED, "s", -0.14)): | |
| 176 | + g = s[s["model"] == model].set_index("segment").loc[order] | |
| 177 | + ax.scatter(g["mdape"], y + dy, s=22, color=color, marker=mk, | |
| 178 | + facecolor="white" if mk == "s" else color, | |
| 179 | + edgecolor=color, label=model, zorder=3) | |
| 180 | + ax.set_yticks(y) | |
| 181 | + ax.set_yticklabels([labels[o] for o in order], fontsize=8) | |
| 182 | + ax.set_xlabel("Median absolute error (%)") | |
| 183 | + ax.legend(fontsize=8, loc="lower right") | |
| 184 | + ax.spines["left"].set_visible(False) | |
| 185 | + ax.tick_params(axis="y", length=0) | |
| 186 | + _save(fig, "fig_segments.png") | |
| 187 | + | |
| 188 | + | |
| 189 | +def main() -> None: | |
| 190 | + config.ensure_dirs() | |
| 191 | + apply_style() | |
| 192 | + fig_groups() | |
| 193 | + fig_random_vs_temporal() | |
| 194 | + fig_index() | |
| 195 | + fig_profiles() | |
| 196 | + fig_learning() | |
| 197 | + fig_segments() | |
| 198 | + made = sorted(p.name for p in OUT.glob("fig_*.png")) | |
| 199 | + print(f"{len(made)} figures written:", ", ".join(made)) | |
| 200 | + | |
| 201 | + | |
| 202 | +if __name__ == "__main__": | |
| 203 | + main() | |
added
scripts/05_make_tables.py
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +"""Step 05 — LaTeX tables. | |
| 4 | + | |
| 5 | +Converts the step-02/03 CSVs into booktabs tables under | |
| 6 | +``results/tables/``; the paper inputs these files directly. | |
| 7 | + | |
| 8 | +Usage: python scripts/05_make_tables.py | |
| 9 | +""" | |
| 10 | +import sys | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +import pandas as pd | |
| 14 | + | |
| 15 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 16 | + | |
| 17 | +from wp11 import config # noqa: E402 | |
| 18 | + | |
| 19 | +RES = config.REPRODUCED | |
| 20 | +TAB = config.TABLES | |
| 21 | + | |
| 22 | + | |
| 23 | +def _f(x, nd=3): | |
| 24 | + return f"{x:,.{nd}f}" | |
| 25 | + | |
| 26 | + | |
| 27 | +def _esc(s: str) -> str: | |
| 28 | + return (s.replace("–", "--").replace("~", r"$\sim$") | |
| 29 | + .replace("<", "$<$").replace(">", "$>$")) | |
| 30 | + | |
| 31 | + | |
| 32 | +def t_summary(): | |
| 33 | + s = pd.read_csv(RES / "summary_stats.csv") | |
| 34 | + lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule", | |
| 35 | + r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\", | |
| 36 | + r"\midrule"] | |
| 37 | + for _, r in s.iterrows(): | |
| 38 | + nd = 0 if r["mean"] > 1000 else 2 | |
| 39 | + label = (str(r["variable"]).replace("($)", "(\\$)") | |
| 40 | + .replace("(m2)", "(m$^2$)")) | |
| 41 | + lines.append( | |
| 42 | + f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & " | |
| 43 | + f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & " | |
| 44 | + f"{_f(r['p90'], nd)} \\\\") | |
| 45 | + lines += [r"\bottomrule", r"\end{tabular}"] | |
| 46 | + (TAB / "summary_stats.tex").write_text("\n".join(lines)) | |
| 47 | + | |
| 48 | + | |
| 49 | +def _race_table(fname_csv: str, fname_tex: str): | |
| 50 | + r = pd.read_csv(RES / fname_csv) | |
| 51 | + lines = [r"\begin{tabular}{lcccc c}", r"\toprule", | |
| 52 | + r"Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & " | |
| 53 | + r"$R^2_{\ln}$ & Fit (s) \\"] | |
| 54 | + for g in ["A. Functional form", "B. Time effects", | |
| 55 | + "C. Spatial controls", "D. Estimation method"]: | |
| 56 | + sub = r[r["group"] == g] | |
| 57 | + if sub.empty: | |
| 58 | + continue | |
| 59 | + lines.append(r"\midrule") | |
| 60 | + lines.append(r"\multicolumn{6}{l}{\itshape " + g + r"}\\") | |
| 61 | + best = sub["mdape"].min() | |
| 62 | + for _, x in sub.iterrows(): | |
| 63 | + name = _esc(str(x["name"])) | |
| 64 | + md = f"\\textbf{{{_f(x['mdape'], 1)}}}" \ | |
| 65 | + if x["mdape"] == best else _f(x["mdape"], 1) | |
| 66 | + lines.append( | |
| 67 | + f"\\quad {name} & {md} & {_f(x['mape'], 1)} & " | |
| 68 | + f"{_f(x['rmse_ln'])} & {_f(x['r2_ln'])} & " | |
| 69 | + f"{_f(x['seconds'], 0)} \\\\") | |
| 70 | + lines += [r"\bottomrule", r"\end{tabular}"] | |
| 71 | + (TAB / fname_tex).write_text("\n".join(lines)) | |
| 72 | + | |
| 73 | + | |
| 74 | +def t_generalization(): | |
| 75 | + a = pd.read_csv(RES / "horserace_random.csv") | |
| 76 | + b = pd.read_csv(RES / "horserace_temporal.csv") | |
| 77 | + m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t")) | |
| 78 | + m = m[m["name"].str.match(r"(A5|B4|C4|D)")] | |
| 79 | + lines = [r"\begin{tabular}{lcc c cc}", r"\toprule", | |
| 80 | + r" & \multicolumn{2}{c}{Random holdout} & &" | |
| 81 | + r" \multicolumn{2}{c}{Forward-in-time} \\", | |
| 82 | + r"\cmidrule{2-3}\cmidrule{5-6}", | |
| 83 | + r"Model & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & " | |
| 84 | + r"$R^2_{\ln}$ \\", r"\midrule"] | |
| 85 | + for _, x in m.iterrows(): | |
| 86 | + lines.append( | |
| 87 | + f"{_esc(str(x['name']))} & {_f(x['mdape_r'], 1)} & " | |
| 88 | + f"{_f(x['r2_ln_r'])} & & {_f(x['mdape_t'], 1)} & " | |
| 89 | + f"{_f(x['r2_ln_t'])} \\\\") | |
| 90 | + lines += [r"\bottomrule", r"\end{tabular}"] | |
| 91 | + (TAB / "generalization.tex").write_text("\n".join(lines)) | |
| 92 | + | |
| 93 | + | |
| 94 | +def t_boxcox(): | |
| 95 | + b = pd.read_csv(RES / "boxcox.csv") | |
| 96 | + lines = [r"\begin{tabular}{l" + "c" * len(b) + "}", r"\toprule", | |
| 97 | + r"$\lambda$ & " + " & ".join(_f(x, 2) for x in b["lambda"]) | |
| 98 | + + r" \\", r"\midrule", | |
| 99 | + r"Profile log-likelihood$^{\dagger}$ & " | |
| 100 | + + " & ".join(f"{(x - b['loglik'].max())/1000:,.1f}" | |
| 101 | + for x in b["loglik"]) + r" \\", | |
| 102 | + r"\bottomrule", r"\end{tabular}"] | |
| 103 | + (TAB / "boxcox.tex").write_text("\n".join(lines)) | |
| 104 | + | |
| 105 | + | |
| 106 | +def t_learning(): | |
| 107 | + l = pd.read_csv(RES / "learning.csv") | |
| 108 | + piv = l.pivot_table(index="n_train", columns="model", values="mdape") | |
| 109 | + cols = list(piv.columns) | |
| 110 | + lines = [r"\begin{tabular}{r" + "c" * len(cols) + "}", r"\toprule", | |
| 111 | + "Training sales & " + " & ".join(_esc(c) for c in cols) + r" \\", | |
| 112 | + r"\midrule"] | |
| 113 | + for n, row in piv.iterrows(): | |
| 114 | + lines.append(f"{n:,.0f} & " | |
| 115 | + + " & ".join(_f(v, 1) for v in row) + r" \\") | |
| 116 | + lines += [r"\bottomrule", r"\end{tabular}"] | |
| 117 | + (TAB / "learning.tex").write_text("\n".join(lines)) | |
| 118 | + | |
| 119 | + | |
| 120 | +def t_segments(): | |
| 121 | + s = pd.read_csv(RES / "segments.csv") | |
| 122 | + piv = s.pivot_table(index="segment", columns="model", values="mdape") | |
| 123 | + ns = s.groupby("segment")["n"].first() | |
| 124 | + lines = [r"\begin{tabular}{lrcc}", r"\toprule", | |
| 125 | + r"Segment & $n$ (test) & OLS quadratic & Gradient boosting \\", | |
| 126 | + r"\midrule"] | |
| 127 | + for seg, row in piv.iterrows(): | |
| 128 | + label = _esc(str(seg)).replace("Class: ", "").replace("_", " ") | |
| 129 | + lines.append(f"{label.capitalize()} & {ns[seg]:,.0f} & " | |
| 130 | + f"{_f(row['OLS quadratic'], 1)} & " | |
| 131 | + f"{_f(row['Gradient boosting'], 1)} \\\\") | |
| 132 | + lines += [r"\bottomrule", r"\end{tabular}"] | |
| 133 | + (TAB / "segments.tex").write_text("\n".join(lines)) | |
| 134 | + | |
| 135 | + | |
| 136 | +def main() -> None: | |
| 137 | + config.ensure_dirs() | |
| 138 | + t_summary() | |
| 139 | + _race_table("horserace_random.csv", "horserace_random.tex") | |
| 140 | + _race_table("horserace_temporal.csv", "horserace_temporal.tex") | |
| 141 | + t_generalization() | |
| 142 | + t_boxcox() | |
| 143 | + t_learning() | |
| 144 | + t_segments() | |
| 145 | + made = sorted(p.name for p in TAB.glob("*.tex")) | |
| 146 | + print(f"{len(made)} tables written:", ", ".join(made)) | |
| 147 | + | |
| 148 | + | |
| 149 | +if __name__ == "__main__": | |
| 150 | + main() | |
added
src/wp11/__init__.py
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""WP11 — Half a Million Prices, Twenty Models. | |
| 3 | + | |
| 4 | +A systematic assessment of hedonic specifications and estimation methods | |
| 5 | +for the Quebec housing market, 2021–2026: functional forms, time effects, | |
| 6 | +spatial controls, and machine-learning estimators, evaluated under random | |
| 7 | +and forward-in-time holdouts. | |
| 8 | +""" | |
| 9 | + | |
| 10 | +__version__ = "1.0" | |
| 11 | +__author__ = "Simon-Pierre Boucher" | |
| 12 | +__email__ = "contact@spboucher.ai" | |
added
src/wp11/config.py
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""Paths and global constants for the WP11 pipeline. | |
| 3 | + | |
| 4 | +All paths are relative to the repository root; override with ``WP11_ROOT``. | |
| 5 | +""" | |
| 6 | +import os | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +ROOT = Path(os.environ.get("WP11_ROOT", Path(__file__).resolve().parents[2])) | |
| 10 | + | |
| 11 | +RAW_PARQUET = ROOT / "data" / "raw" / "transactions_700k_avec_registre_foncier.parquet" | |
| 12 | +PROCESSED = ROOT / "data" / "processed" | |
| 13 | +ANALYSIS_PARQUET = PROCESSED / "analysis.parquet" | |
| 14 | +FIGURES = ROOT / "figures" | |
| 15 | +RESULTS = ROOT / "results" | |
| 16 | +REPRODUCED = RESULTS / "reproduced" | |
| 17 | +TABLES = RESULTS / "tables" | |
| 18 | + | |
| 19 | +# ---------------------------------------------------------------- sample | |
| 20 | +RESIDENTIAL_CUBF = {"1000", "1100", "1211", "1990"} | |
| 21 | +MATCH_MAX_DIST_M = 50.0 | |
| 22 | +MATCH_MIN_SCORE = 150.0 | |
| 23 | +PRICE_MIN = 50_000 | |
| 24 | +TRIM = (0.01, 0.99) # price and floor-area tails, within sale year | |
| 25 | +AGE_MAX = 150 | |
| 26 | +GRID_DEG = 0.01 # ~1.1 km spatial grid cells for high-dim FE | |
| 27 | +GRID5_DEG = 0.05 # ~5.5 km grid, mid-granularity spatial FE | |
| 28 | + | |
| 29 | +# ---------------------------------------------------------------- splits | |
| 30 | +SEED = 20260809 | |
| 31 | +TEST_SHARE = 0.20 # random holdout | |
| 32 | +TEMPORAL_CUTOFF = "2025-01-01" # train < cutoff, test >= cutoff | |
| 33 | + | |
| 34 | +# Box-Cox profile-likelihood grid | |
| 35 | +BOXCOX_GRID = [-0.50, -0.25, 0.0, 0.25, 0.50, 0.75, 1.0] | |
| 36 | + | |
| 37 | +# Learning-curve training sizes | |
| 38 | +LEARNING_SIZES = [10_000, 25_000, 50_000, 100_000, 200_000, 400_000] | |
| 39 | + | |
| 40 | +# Minimum predicted price when inverting level/Box-Cox models | |
| 41 | +PRED_FLOOR = 20_000.0 | |
| 42 | + | |
| 43 | + | |
| 44 | +def ensure_dirs() -> None: | |
| 45 | + """Create every output directory the pipeline writes to.""" | |
| 46 | + for p in (PROCESSED, FIGURES, REPRODUCED, TABLES): | |
| 47 | + p.mkdir(parents=True, exist_ok=True) | |
added
src/wp11/models.py
+283 −0
@@ -0,0 +1,283 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""The WP11 horse-race harness. | |
| 3 | + | |
| 4 | +Twenty hedonic models organised along four design axes: | |
| 5 | + | |
| 6 | + A. functional form — linear, semi-log, log-log, Box–Cox, quadratic, | |
| 7 | + splines (linear estimator, muni + quarter FE) | |
| 8 | + B. time effects — none / year / quarter / month FE (quadratic backbone) | |
| 9 | + C. spatial controls — none / municipality / ~1.1 km grid / neighbourhood | |
| 10 | + unit FE (quadratic backbone, quarter FE) | |
| 11 | + D. estimation method — OLS, ridge, random forest, gradient boosting | |
| 12 | + (with and without coordinates), spatial k-NN | |
| 13 | + comparables | |
| 14 | + | |
| 15 | +Every linear model is estimated as a sparse ridge with a vanishing penalty | |
| 16 | +(numerically OLS, but it (i) survives collinear dummies and (ii) predicts | |
| 17 | +unseen categories at the reference level via ``handle_unknown='ignore'``). | |
| 18 | +Every model is evaluated on identical train/test splits, and all metrics | |
| 19 | +are computed on *price levels* — log models are retransformed with Duan's | |
| 20 | +smearing factor, Box–Cox by analytic inversion — so that functional forms | |
| 21 | +compete on the same scoreboard. | |
| 22 | +""" | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import time | |
| 26 | + | |
| 27 | +import numpy as np | |
| 28 | +import pandas as pd | |
| 29 | +from sklearn.compose import ColumnTransformer | |
| 30 | +from sklearn.ensemble import (HistGradientBoostingRegressor, | |
| 31 | + RandomForestRegressor) | |
| 32 | +from sklearn.linear_model import Ridge, RidgeCV | |
| 33 | +from sklearn.neighbors import KNeighborsRegressor | |
| 34 | +from sklearn.pipeline import Pipeline | |
| 35 | +from sklearn.preprocessing import OneHotEncoder, SplineTransformer | |
| 36 | + | |
| 37 | +from . import config | |
| 38 | + | |
| 39 | +NUM_LEVELS = ["area", "lot", "has_lot", "age", "floors", "units"] | |
| 40 | +NUM_LOGS = ["ln_area", "ln_lot", "has_lot", "age", "floors", "units"] | |
| 41 | +NUM_QUAD = NUM_LEVELS + ["area2", "age2"] | |
| 42 | +CATS = ["prop_class", "link"] | |
| 43 | +ML_NUM = ["area", "lot", "has_lot", "age", "floors", "units", | |
| 44 | + "lat", "lng", "t"] | |
| 45 | + | |
| 46 | + | |
| 47 | +def add_derived(df: pd.DataFrame) -> pd.DataFrame: | |
| 48 | + """Quadratic terms, scaled to keep the design well conditioned.""" | |
| 49 | + out = df.copy() | |
| 50 | + out["area2"] = (out["area"] / 100.0) ** 2 | |
| 51 | + out["age2"] = (out["age"] / 10.0) ** 2 | |
| 52 | + return out | |
| 53 | + | |
| 54 | + | |
| 55 | +# ---------------------------------------------------------------- splits | |
| 56 | +def split(df: pd.DataFrame, scheme: str): | |
| 57 | + """Return (train, test) under the random or forward-in-time scheme.""" | |
| 58 | + if scheme == "random": | |
| 59 | + rng = np.random.default_rng(config.SEED) | |
| 60 | + mask = rng.random(len(df)) < config.TEST_SHARE | |
| 61 | + return df[~mask], df[mask] | |
| 62 | + if scheme == "temporal": | |
| 63 | + cut = pd.Timestamp(config.TEMPORAL_CUTOFF) | |
| 64 | + return df[df["sale_date"] < cut], df[df["sale_date"] >= cut] | |
| 65 | + raise ValueError(scheme) | |
| 66 | + | |
| 67 | + | |
| 68 | +# ---------------------------------------------------------------- pipelines | |
| 69 | +def _linear_pipeline(numeric: list[str], cats: list[str], | |
| 70 | + spline_cols: list[str] | None = None, | |
| 71 | + alpha: float = 1e-6) -> Pipeline: | |
| 72 | + transformers = [("cat", | |
| 73 | + OneHotEncoder(handle_unknown="ignore", drop="first"), | |
| 74 | + cats)] | |
| 75 | + if spline_cols: | |
| 76 | + transformers.append( | |
| 77 | + ("spl", SplineTransformer(degree=3, n_knots=6), spline_cols)) | |
| 78 | + passthrough = [c for c in numeric if c not in spline_cols] | |
| 79 | + else: | |
| 80 | + passthrough = numeric | |
| 81 | + if passthrough: | |
| 82 | + transformers.append(("num", "passthrough", passthrough)) | |
| 83 | + ct = ColumnTransformer(transformers, sparse_threshold=1.0) | |
| 84 | + return Pipeline([("ct", ct), | |
| 85 | + ("reg", Ridge(alpha=alpha, solver="sparse_cg"))]) | |
| 86 | + | |
| 87 | + | |
| 88 | +def _ml_frame(df: pd.DataFrame, geo: bool = True) -> pd.DataFrame: | |
| 89 | + cols = ML_NUM if geo else [c for c in ML_NUM if c not in ("lat", "lng")] | |
| 90 | + X = df[cols].copy() | |
| 91 | + for c in CATS: | |
| 92 | + X[c] = df[c].astype("category").cat.codes | |
| 93 | + return X | |
| 94 | + | |
| 95 | + | |
| 96 | +# ---------------------------------------------------------------- metrics | |
| 97 | +def _metrics(y_level: np.ndarray, pred_level: np.ndarray) -> dict: | |
| 98 | + pred_level = np.clip(pred_level, config.PRED_FLOOR, None) | |
| 99 | + ape = np.abs(pred_level - y_level) / y_level | |
| 100 | + ln_y, ln_p = np.log(y_level), np.log(pred_level) | |
| 101 | + err = ln_y - ln_p | |
| 102 | + return {"mdape": float(np.median(ape) * 100), | |
| 103 | + "mape": float(np.mean(ape) * 100), | |
| 104 | + "rmse_ln": float(np.sqrt(np.mean(err ** 2))), | |
| 105 | + "r2_ln": float(1 - np.sum(err ** 2) | |
| 106 | + / np.sum((ln_y - ln_y.mean()) ** 2))} | |
| 107 | + | |
| 108 | + | |
| 109 | +# ---------------------------------------------------------------- Box–Cox | |
| 110 | +def _boxcox(y: np.ndarray, lam: float) -> np.ndarray: | |
| 111 | + return np.log(y) if lam == 0 else (y ** lam - 1.0) / lam | |
| 112 | + | |
| 113 | + | |
| 114 | +def _inv_boxcox(z: np.ndarray, lam: float) -> np.ndarray: | |
| 115 | + if lam == 0: | |
| 116 | + return np.exp(z) | |
| 117 | + base = np.clip(lam * z + 1.0, 1e-6, None) | |
| 118 | + return base ** (1.0 / lam) | |
| 119 | + | |
| 120 | + | |
| 121 | +def fit_boxcox(train: pd.DataFrame, numeric: list[str], cats: list[str]): | |
| 122 | + """Profile-likelihood choice of λ on the training sample.""" | |
| 123 | + y = train["price"].to_numpy(float) | |
| 124 | + n = len(y) | |
| 125 | + sum_ln_y = np.log(y).sum() | |
| 126 | + rows = [] | |
| 127 | + for lam in config.BOXCOX_GRID: | |
| 128 | + pipe = _linear_pipeline(numeric, cats) | |
| 129 | + z = _boxcox(y, lam) | |
| 130 | + pipe.fit(train, z) | |
| 131 | + sse = float(np.sum((z - pipe.predict(train)) ** 2)) | |
| 132 | + ll = -0.5 * n * np.log(sse / n) + (lam - 1.0) * sum_ln_y | |
| 133 | + rows.append({"lambda": lam, "loglik": ll}) | |
| 134 | + prof = pd.DataFrame(rows) | |
| 135 | + lam_star = float(prof.loc[prof["loglik"].idxmax(), "lambda"]) | |
| 136 | + return lam_star, prof | |
| 137 | + | |
| 138 | + | |
| 139 | +# ---------------------------------------------------------------- registry | |
| 140 | +def model_registry() -> list[dict]: | |
| 141 | + """The twenty models. ``fe`` lists categorical FE columns appended to | |
| 142 | + the base dummies; ``numeric``/``spline`` define the design.""" | |
| 143 | + A = [ | |
| 144 | + dict(name="A1 Linear (levels)", group="A. Functional form", | |
| 145 | + kind="linear_level", numeric=NUM_LEVELS, fe=["muni", "quarter"]), | |
| 146 | + dict(name="A2 Semi-log", group="A. Functional form", | |
| 147 | + kind="linear_log", numeric=NUM_LEVELS, fe=["muni", "quarter"]), | |
| 148 | + dict(name="A3 Log-log", group="A. Functional form", | |
| 149 | + kind="linear_log", numeric=NUM_LOGS, fe=["muni", "quarter"]), | |
| 150 | + dict(name="A4 Box-Cox", group="A. Functional form", | |
| 151 | + kind="boxcox", numeric=NUM_LEVELS, fe=["muni", "quarter"]), | |
| 152 | + dict(name="A5 Semi-log + quadratics", group="A. Functional form", | |
| 153 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]), | |
| 154 | + dict(name="A6 Semi-log + splines", group="A. Functional form", | |
| 155 | + kind="linear_log", numeric=NUM_LEVELS, fe=["muni", "quarter"], | |
| 156 | + spline=["area", "age", "lot"]), | |
| 157 | + ] | |
| 158 | + B = [ | |
| 159 | + dict(name="B1 No time effects", group="B. Time effects", | |
| 160 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni"]), | |
| 161 | + dict(name="B2 Year FE", group="B. Time effects", | |
| 162 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "sale_year_c"]), | |
| 163 | + dict(name="B3 Quarter FE", group="B. Time effects", | |
| 164 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]), | |
| 165 | + dict(name="B4 Month FE", group="B. Time effects", | |
| 166 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "month"]), | |
| 167 | + ] | |
| 168 | + C = [ | |
| 169 | + dict(name="C1 No spatial controls", group="C. Spatial controls", | |
| 170 | + kind="linear_log", numeric=NUM_QUAD, fe=["quarter"]), | |
| 171 | + dict(name="C2 Municipality FE", group="C. Spatial controls", | |
| 172 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]), | |
| 173 | + dict(name="C3 Grid-cell FE (~5.5 km)", group="C. Spatial controls", | |
| 174 | + kind="linear_log", numeric=NUM_QUAD, fe=["grid5", "quarter"]), | |
| 175 | + dict(name="C4 Grid-cell FE (~1.1 km)", group="C. Spatial controls", | |
| 176 | + kind="linear_log", numeric=NUM_QUAD, fe=["grid", "quarter"]), | |
| 177 | + ] | |
| 178 | + D = [ | |
| 179 | + dict(name="D1 OLS (muni + month FE)", group="D. Estimation method", | |
| 180 | + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "month"]), | |
| 181 | + dict(name="D2 Ridge (cross-validated)", group="D. Estimation method", | |
| 182 | + kind="ridge", numeric=NUM_QUAD, fe=["muni", "month"]), | |
| 183 | + dict(name="D3 Random forest", group="D. Estimation method", | |
| 184 | + kind="rf"), | |
| 185 | + dict(name="D4 Gradient boosting", group="D. Estimation method", | |
| 186 | + kind="hgb"), | |
| 187 | + dict(name="D5 Gradient boosting, no coordinates", | |
| 188 | + group="D. Estimation method", kind="hgb_nogeo"), | |
| 189 | + dict(name="D6 Spatial k-NN comparables", group="D. Estimation method", | |
| 190 | + kind="knn"), | |
| 191 | + ] | |
| 192 | + return A + B + C + D | |
| 193 | + | |
| 194 | + | |
| 195 | +# ---------------------------------------------------------------- fit/eval | |
| 196 | +def fit_predict(spec: dict, train: pd.DataFrame, test: pd.DataFrame): | |
| 197 | + """Fit one registry entry, return level predictions on the test set.""" | |
| 198 | + kind = spec["kind"] | |
| 199 | + y_train = train["price"].to_numpy(float) | |
| 200 | + | |
| 201 | + if kind in ("linear_level", "linear_log", "boxcox", "ridge"): | |
| 202 | + cats = CATS + spec.get("fe", []) | |
| 203 | + pipe = _linear_pipeline(spec["numeric"], cats, | |
| 204 | + spline_cols=spec.get("spline")) | |
| 205 | + if kind == "linear_level": | |
| 206 | + pipe.fit(train, y_train) | |
| 207 | + return pipe.predict(test), pipe | |
| 208 | + if kind in ("linear_log", "ridge"): | |
| 209 | + if kind == "ridge": | |
| 210 | + from sklearn.preprocessing import StandardScaler | |
| 211 | + pipe.steps[-1:] = [ | |
| 212 | + ("sc", StandardScaler(with_mean=False)), | |
| 213 | + ("reg", RidgeCV(alphas=np.logspace(-6, 2, 17), | |
| 214 | + cv=3))] | |
| 215 | + z = np.log(y_train) | |
| 216 | + pipe.fit(train, z) | |
| 217 | + resid = z - pipe.predict(train) | |
| 218 | + smear = float(np.mean(np.exp(resid))) # Duan (1983) | |
| 219 | + return np.exp(pipe.predict(test)) * smear, pipe | |
| 220 | + lam, _ = fit_boxcox(train, spec["numeric"], cats) | |
| 221 | + pipe = _linear_pipeline(spec["numeric"], cats) | |
| 222 | + pipe.fit(train, _boxcox(y_train, lam)) | |
| 223 | + spec["lambda"] = lam | |
| 224 | + return _inv_boxcox(pipe.predict(test), lam), pipe | |
| 225 | + | |
| 226 | + if kind == "rf": | |
| 227 | + m = RandomForestRegressor(n_estimators=120, min_samples_leaf=5, | |
| 228 | + max_features=0.5, n_jobs=-1, | |
| 229 | + random_state=config.SEED) | |
| 230 | + m.fit(_ml_frame(train), np.log(y_train)) | |
| 231 | + return np.exp(m.predict(_ml_frame(test))), m | |
| 232 | + | |
| 233 | + if kind in ("hgb", "hgb_nogeo"): | |
| 234 | + geo = kind == "hgb" | |
| 235 | + m = HistGradientBoostingRegressor( | |
| 236 | + max_iter=600, learning_rate=0.08, max_leaf_nodes=63, | |
| 237 | + min_samples_leaf=20, l2_regularization=1e-2, | |
| 238 | + random_state=config.SEED) | |
| 239 | + m.fit(_ml_frame(train, geo), np.log(y_train)) | |
| 240 | + return np.exp(m.predict(_ml_frame(test, geo))), m | |
| 241 | + | |
| 242 | + if kind == "knn": | |
| 243 | + # price-per-m2 of the 10 nearest sold neighbours (lat/lng, km-scaled) | |
| 244 | + Xtr = train[["lat", "lng"]].to_numpy() * np.array([111.0, 78.0]) | |
| 245 | + Xte = test[["lat", "lng"]].to_numpy() * np.array([111.0, 78.0]) | |
| 246 | + m = KNeighborsRegressor(n_neighbors=10, weights="distance", n_jobs=-1) | |
| 247 | + m.fit(Xtr, np.log(y_train) - train["ln_area"].to_numpy()) | |
| 248 | + return np.exp(m.predict(Xte) + test["ln_area"].to_numpy()), m | |
| 249 | + | |
| 250 | + raise ValueError(kind) | |
| 251 | + | |
| 252 | + | |
| 253 | +def run_horserace(df: pd.DataFrame, scheme: str) -> pd.DataFrame: | |
| 254 | + """Evaluate the full registry under one split scheme. | |
| 255 | + | |
| 256 | + Under the forward-in-time split, test-period time categories (months, | |
| 257 | + quarters, years) were never seen in training. A one-hot encoder would | |
| 258 | + silently price them at the *baseline* period; the honest forecasting | |
| 259 | + rule for a static model is to freeze the price level at the last | |
| 260 | + period observed in training, so unseen time labels are remapped to the | |
| 261 | + latest training label before prediction. | |
| 262 | + """ | |
| 263 | + d = add_derived(df) | |
| 264 | + d["sale_year_c"] = d["sale_year"].astype(str) | |
| 265 | + train, test = split(d, scheme) | |
| 266 | + if scheme == "temporal": | |
| 267 | + test = test.copy() | |
| 268 | + for col in ("quarter", "month", "sale_year_c"): | |
| 269 | + last = train[col].max() | |
| 270 | + test.loc[~test[col].isin(train[col].unique()), col] = last | |
| 271 | + y_test = test["price"].to_numpy(float) | |
| 272 | + rows = [] | |
| 273 | + for spec in model_registry(): | |
| 274 | + t0 = time.time() | |
| 275 | + pred, _ = fit_predict(dict(spec), train, test) | |
| 276 | + met = _metrics(y_test, pred) | |
| 277 | + rows.append({"name": spec["name"], "group": spec["group"], | |
| 278 | + **met, "seconds": round(time.time() - t0, 1), | |
| 279 | + "n_train": len(train), "n_test": len(test)}) | |
| 280 | + print(f" [{scheme}] {spec['name']:<38} " | |
| 281 | + f"MdAPE={met['mdape']:5.1f}% R2_ln={met['r2_ln']:.3f} " | |
| 282 | + f"({rows[-1]['seconds']}s)") | |
| 283 | + return pd.DataFrame(rows) | |
added
src/wp11/plotstyle.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""Shared matplotlib style for all WP11 figures — print-journal calibre. | |
| 3 | + | |
| 4 | +Conventions (Journal of Finance house style): | |
| 5 | + - no titles inside figures (captions carry the message); multi-panel | |
| 6 | + figures use bold "Panel A." headers set flush left above each axes; | |
| 7 | + - Times-compatible serif text with STIX math, 8–9 pt; | |
| 8 | + - thin, recessive axes (0.6 pt), outward ticks, no top/right spines; | |
| 9 | + - one accent hue per figure; a second hue only for genuine polarity or a | |
| 10 | + second series, always doubled by a linestyle/marker difference; | |
| 11 | + - shaded confidence bands rather than cap-heavy error bars; | |
| 12 | + - direct labels instead of legend boxes wherever the geometry allows. | |
| 13 | + | |
| 14 | +The two data hues (#2e6da4, #c04848) pass the full colour-vision validation | |
| 15 | +suite (lightness band, chroma floor, CVD separation, contrast) on a white | |
| 16 | +surface; INK and GREY are reserved for marks-as-ink and reference lines. | |
| 17 | +""" | |
| 18 | +import matplotlib | |
| 19 | + | |
| 20 | +matplotlib.use("Agg") | |
| 21 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 22 | + | |
| 23 | +INK = "#1a1a1a" # primary marks (points, bars, text) | |
| 24 | +BLUE = "#2e6da4" # accent series / fitted lines | |
| 25 | +RED = "#c04848" # contrast series / polarity | |
| 26 | +GREY = "#8a8a8a" # reference lines | |
| 27 | +LIGHT = "#d9d9d9" # fills, bands | |
| 28 | +GRID = "#e3e3e3" # gridlines | |
| 29 | + | |
| 30 | +# Sequential blues for ordered series (light → dark, one hue) | |
| 31 | +BLUES = ["#c6d7e8", "#9dbcd8", "#74a1c8", "#4b86b8", "#2e6da4", "#1d4a75"] | |
| 32 | + | |
| 33 | +TEXTWIDTH = 6.3 # \textwidth in inches (1in margins, letter paper) | |
| 34 | + | |
| 35 | + | |
| 36 | +def apply_style() -> None: | |
| 37 | + plt.rcParams.update({ | |
| 38 | + "font.family": "serif", | |
| 39 | + "font.serif": ["Times New Roman", "Times", "STIXGeneral", "DejaVu Serif"], | |
| 40 | + "mathtext.fontset": "stix", | |
| 41 | + "font.size": 9, | |
| 42 | + "axes.labelsize": 9, | |
| 43 | + "xtick.labelsize": 8, | |
| 44 | + "ytick.labelsize": 8, | |
| 45 | + "legend.fontsize": 8, | |
| 46 | + "figure.dpi": 150, | |
| 47 | + "savefig.dpi": 300, | |
| 48 | + "axes.spines.top": False, | |
| 49 | + "axes.spines.right": False, | |
| 50 | + "axes.linewidth": 0.6, | |
| 51 | + "xtick.major.width": 0.6, | |
| 52 | + "ytick.major.width": 0.6, | |
| 53 | + "xtick.major.size": 3, | |
| 54 | + "ytick.major.size": 3, | |
| 55 | + "xtick.direction": "out", | |
| 56 | + "ytick.direction": "out", | |
| 57 | + "axes.grid": False, | |
| 58 | + "grid.color": GRID, | |
| 59 | + "grid.linewidth": 0.5, | |
| 60 | + "legend.frameon": False, | |
| 61 | + "lines.linewidth": 1.3, | |
| 62 | + "lines.markersize": 4.5, | |
| 63 | + "figure.constrained_layout.use": False, | |
| 64 | + }) | |
| 65 | + | |
| 66 | + | |
| 67 | +def panel_label(ax, text: str) -> None: | |
| 68 | + """Bold flush-left panel header above the axes (JoF style).""" | |
| 69 | + ax.set_title(text, loc="left", fontsize=9, fontweight="bold", pad=8) | |
| 70 | + | |
| 71 | + | |
| 72 | +def ygrid(ax) -> None: | |
| 73 | + """Recessive horizontal gridlines drawn beneath the data.""" | |
| 74 | + ax.grid(axis="y", linewidth=0.5, color=GRID) | |
| 75 | + ax.set_axisbelow(True) | |
added
src/wp11/sample.py
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"""Estimation-sample construction for WP11. | |
| 3 | + | |
| 4 | +Residential arm's-length sales (2021–2026) with a high-confidence roll | |
| 5 | +match and complete structural descriptors from the assessment roll: | |
| 6 | +floor area, lot area, building age, dwelling class, physical configuration | |
| 7 | +and coordinates. Writes ``data/processed/analysis.parquet``. | |
| 8 | +""" | |
| 9 | +import numpy as np | |
| 10 | +import pandas as pd | |
| 11 | + | |
| 12 | +from . import config | |
| 13 | + | |
| 14 | +LINK_MAP = {"1": "detached", "2": "semi_detached", "3": "row_end", | |
| 15 | + "4": "row", "5": "apartment_link"} | |
| 16 | + | |
| 17 | + | |
| 18 | +def _property_class(df: pd.DataFrame) -> pd.Series: | |
| 19 | + cls = pd.Series("other", index=df.index, dtype="object") | |
| 20 | + cls[df["role_cubf"] == "1100"] = "cottage" | |
| 21 | + cls[df["role_cubf"] == "1211"] = "mobile" | |
| 22 | + is_dwelling = df["role_cubf"] == "1000" | |
| 23 | + cls[is_dwelling & (df["propertyType"] == "condo")] = "condo" | |
| 24 | + cls[is_dwelling & (df["propertyType"] == "plex")] = "plex" | |
| 25 | + cls[is_dwelling & (df["propertyType"] == "unifamilial")] = "single_family" | |
| 26 | + untyped = is_dwelling & (cls == "other") | |
| 27 | + cls[untyped & (df["role_nb_logements"] == 1)] = "single_family" | |
| 28 | + cls[untyped & (df["role_nb_logements"].between(2, 5))] = "plex" | |
| 29 | + return cls | |
| 30 | + | |
| 31 | + | |
| 32 | +def build(raw: pd.DataFrame | None = None) -> pd.DataFrame: | |
| 33 | + df = raw if raw is not None else pd.read_parquet(config.RAW_PARQUET) | |
| 34 | + log = [("raw snapshot", len(df))] | |
| 35 | + | |
| 36 | + df = df[df["role_cubf"].isin(config.RESIDENTIAL_CUBF)] | |
| 37 | + log.append(("residential CUBF", len(df))) | |
| 38 | + | |
| 39 | + df = df[(df["match_dist_m"] <= config.MATCH_MAX_DIST_M) | |
| 40 | + & (df["match_score"] >= config.MATCH_MIN_SCORE)] | |
| 41 | + log.append(("high-confidence roll match", len(df))) | |
| 42 | + | |
| 43 | + df = df[(df["amount"] >= config.PRICE_MIN) | |
| 44 | + & (df["role_aire_etages_m2"] > 20) | |
| 45 | + & (df["role_aire_etages_m2"] < 2_000)] | |
| 46 | + log.append(("valid price and floor area", len(df))) | |
| 47 | + | |
| 48 | + df = df.copy() | |
| 49 | + df["sale_date"] = pd.to_datetime(df["date"]) | |
| 50 | + df["sale_year"] = df["tx_year"].astype(int) | |
| 51 | + df["month"] = df["sale_date"].dt.to_period("M").astype(str) | |
| 52 | + df["quarter"] = df["sale_date"].dt.to_period("Q").astype(str) | |
| 53 | + df["t"] = ((df["sale_date"] - pd.Timestamp("2021-01-01")).dt.days | |
| 54 | + / 30.44) # months since Jan 2021, continuous | |
| 55 | + | |
| 56 | + df["price"] = df["amount"].astype(float) | |
| 57 | + df["ln_price"] = np.log(df["price"]) | |
| 58 | + df["area"] = df["role_aire_etages_m2"].astype(float) | |
| 59 | + df["ln_area"] = np.log(df["area"]) | |
| 60 | + df["lot"] = df["role_superficie_terrain_m2"].fillna(0).clip(lower=0) | |
| 61 | + df.loc[df["lot"] > df["lot"].quantile(0.995), "lot"] = np.nan | |
| 62 | + df["lot"] = df["lot"].fillna(0) | |
| 63 | + df["has_lot"] = (df["lot"] > 0).astype(float) | |
| 64 | + df["ln_lot"] = np.log(df["lot"].where(df["lot"] > 0, 1.0)) | |
| 65 | + | |
| 66 | + year_built = pd.to_numeric(df["role_annee_construction"], errors="coerce") | |
| 67 | + df["age"] = (df["sale_year"] - year_built).clip(0, config.AGE_MAX) | |
| 68 | + df = df[df["age"].notna()] | |
| 69 | + log.append(("known building age", len(df))) | |
| 70 | + | |
| 71 | + df["floors"] = df["role_nb_etages"].clip(1, 6).fillna(1.0) | |
| 72 | + df["units"] = df["role_nb_logements"].clip(1, 12).fillna(1.0) | |
| 73 | + df["prop_class"] = _property_class(df) | |
| 74 | + df["link"] = df["role_lien_physique"].map(LINK_MAP).fillna("unknown") | |
| 75 | + df["muni"] = df["role_code_mun"] | |
| 76 | + df["grid"] = ((df["lat"] / config.GRID_DEG).round().astype(int).astype(str) | |
| 77 | + + "_" + (df["lng"] / config.GRID_DEG).round().astype(int).astype(str)) | |
| 78 | + df["grid5"] = ((df["lat"] / config.GRID5_DEG).round().astype(int).astype(str) | |
| 79 | + + "_" + (df["lng"] / config.GRID5_DEG).round().astype(int).astype(str)) | |
| 80 | + | |
| 81 | + lo, hi = config.TRIM | |
| 82 | + for col in ("price", "area"): | |
| 83 | + q = df.groupby("sale_year")[col].quantile([lo, hi]).unstack() | |
| 84 | + df = df.join(q.rename(columns={lo: "_qlo", hi: "_qhi"}), on="sale_year") | |
| 85 | + df = df[(df[col] >= df["_qlo"]) & (df[col] <= df["_qhi"])] | |
| 86 | + df = df.drop(columns=["_qlo", "_qhi"]) | |
| 87 | + log.append((f"price & area inside [{lo:.0%}, {hi:.0%}] of sale year", | |
| 88 | + len(df))) | |
| 89 | + | |
| 90 | + df.attrs["selection_log"] = log | |
| 91 | + keep = ["id", "sale_date", "sale_year", "month", "quarter", "t", | |
| 92 | + "price", "ln_price", "area", "ln_area", "lot", "ln_lot", "has_lot", | |
| 93 | + "age", "floors", "units", "prop_class", "link", | |
| 94 | + "muni", "grid", "grid5", "lat", "lng", "city", | |
| 95 | + "role_municipalite"] | |
| 96 | + return df[keep].reset_index(drop=True) | |
| 97 | + | |
| 98 | + | |
| 99 | +def build_and_save() -> pd.DataFrame: | |
| 100 | + config.ensure_dirs() | |
| 101 | + s = build() | |
| 102 | + for step, n in s.attrs["selection_log"]: | |
| 103 | + print(f" {n:>9,} after: {step}") | |
| 104 | + s.to_parquet(config.ANALYSIS_PARQUET, index=False) | |
| 105 | + return s | |
| 106 | + | |
| 107 | + | |
| 108 | +def load() -> pd.DataFrame: | |
| 109 | + if not config.ANALYSIS_PARQUET.exists(): | |
| 110 | + return build_and_save() | |
| 111 | + return pd.read_parquet(config.ANALYSIS_PARQUET) | |
| 112 | ||