SPB Git

spb/wp10_uqo

Public

UQO Working Paper No. 10 — The assessment gap in Quebec: vertical and horizontal inequity in municipal property assessment.

WP10 — The Assessment Gap in Quebec: full research project

End-to-end pipeline (sample, IAAO diagnostics, Cheng FE / Clapp IV /
quantile / heterogeneity / horizontal / tax shift), 10 journal-calibre
figures, 10 LaTeX tables, 29-page paper with literature review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 9, 2026) Browse files

65 changed files +3,864 −0

65 changed files
Unified Split
added .gitignore +20 −0
@@ -0,0 +1,20 @@
1+# Raw data snapshot — 100 MB parquet, not redistributed
2+data/raw/*.parquet
3+data/processed/*.parquet
4+
5+# Python
6+__pycache__/
7+*.pyc
8+
9+# LaTeX build artifacts (paper/main.pdf IS committed)
10+paper/*.aux
11+paper/*.log
12+paper/*.out
13+paper/*.fls
14+paper/*.fdb_latexmk
15+paper/*.bbl
16+paper/*.blg
17+paper/*.synctex.gz
18+
19+# macOS
20+.DS_Store
added README.md +200 −0
@@ -0,0 +1,200 @@
1+<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2+# ⚖️ The Assessment Gap in Quebec
3+
4+**UQO Working Paper No. 10** — *Vertical and Horizontal Inequity in Municipal
5+Property Valuation: Evidence from 522,769 Sales Matched to the Assessment Roll*
6+
7+[![Paper](https://img.shields.io/badge/paper-PDF%20(29%20p.)-b31b1b?logo=latex&logoColor=white)](paper/main.pdf)
8+[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](requirements.txt)
9+[![Data](https://img.shields.io/badge/data-745%2C119%20matched%20sales%20·%20parquet-4051b5)](data/raw/README.md)
10+[![Estimation](https://img.shields.io/badge/estimation-statsmodels%20·%20linearmodels%20(AbsorbingLS%20%2B%20IV2SLS)-4051b5)](src/wp10/models.py)
11+[![Reproducible](https://img.shields.io/badge/reproducible-end--to--end%20pipeline-2e7d32)](scripts/)
12+[![Institution](https://img.shields.io/badge/UQO-D%C3%A9pt.%20des%20sciences%20administratives-16365c)](https://uqo.ca)
13+[![Author](https://img.shields.io/badge/author-Simon--Pierre%20Boucher-16365c)](mailto:contact@spboucher.ai)
14+[![Contact](https://img.shields.io/badge/contact-contact%40spboucher.ai-a02020?logo=maildotru&logoColor=white)](mailto:contact@spboucher.ai)
15+
16+> **TL;DR** — Matching 522,769 Quebec residential sales (2021–2026) to the triennial
17+> assessment rolls that tax them, this paper delivers the **first province-wide audit
18+> of property-assessment equity in Canada**. Within the same municipality × roll ×
19+> year, the elasticity of the assessment ratio w.r.t. price is **−0.34** (Cheng FE)
20+> and still **−0.08** under Clapp's measurement-error-robust IV. **99% of
21+> municipalities** fail the IAAO uniformity standard, **95%** fail its vertical-equity
22+> standard — Montréal is the lone progressive large market. The median dwelling in the
23+> bottom local price decile pays **~65% more property tax** than uniform assessment
24+> would imply; the top decile pays ~5% less.
25+
26+---
27+
28+## 📖 Table of contents
29+
30+1. [What this paper does](#-what-this-paper-does)
31+2. [Headline results](#-headline-results)
32+3. [Repository layout](#-repository-layout)
33+4. [Quick start](#-quick-start)
34+5. [The pipeline, step by step](#-the-pipeline-step-by-step)
35+6. [Data](#-data)
36+7. [Methodology](#-methodology)
37+8. [Figures & tables inventory](#-figures--tables-inventory)
38+9. [Limitations](#-limitations)
39+10. [Citation](#-citation)
40+11. [Author & contact](#-author--contact)
41+
42+---
43+
44+## 🎯 What this paper does
45+
46+Quebec taxes every dwelling in proportion to its **assessed value**, redrawn on a
47+triennial roll whose values must, by statute (art. 46 LFM), reflect market conditions
48+at a single reference date. If assessments are *regressive* — cheap homes overvalued
49+relative to expensive ones — the effective tax rate falls with wealth, silently.
50+This project measures that gap at province scale:
51+
52+```
53+ln AV_i = α_c(i) + β · ln SP_i + ε_i (c = municipality × roll × sale-year)
54+```
55+
56+- **γ = β − 1** is the elasticity of the assessment ratio w.r.t. price
57+ (γ < 0 ⇒ regressive), identified **within** 2,884 market-timing cells;
58+- the **Clapp (1990) rank IV** purges the attenuation bias that pushes naïve
59+ ratio studies toward spurious regressivity;
60+- the full **IAAO ratio-study battery** (median ratio, COD, PRD, PRB) with
61+ bootstrap CIs is computed for every municipality with ≥ 100 usable sales;
62+- quantile profiles, subgroup elasticities, horizontal-dispersion regressions and
63+ an exact **intra-municipal tax-shift** calculation complete the audit.
64+
65+## 🏆 Headline results
66+
67+| Object | Estimate |
68+|---|---|
69+| Cheng elasticity, pooled OLS | β = 0.812 (0.029) |
70+| Cheng elasticity, cell FE | **β = 0.656 (0.029)** → γ = −0.344 |
71+| Clapp rank-IV (error-robust) | **β = 0.917 (0.021)** → γ = −0.083 |
72+| Quantile profile β(τ), τ = .10 → .90 | 0.87 → **0.49** (failure concentrated at the top) |
73+| Province-wide PRB (2021 / 2024) | −0.104 / −0.002 (staleness mechanism) |
74+| Municipalities with PRB < 0 | **99%** (95% below IAAO band) |
75+| Median municipality COD | 26 (IAAO ceiling: 15) |
76+| Excess tax, bottom ↔ top local decile | **+65% ↔ −5%** (median) |
77+| The exception | Montréal: PRB **+0.07**, progressive in all 6 years |
78+
79+Sub-group anatomy: condos γ = −0.12 vs single-family −0.45 / plex −0.49;
80+land share > 0.4 → −0.50; age > 60 y → −0.43; large markets (>10k sales) −0.27.
81+
82+## 🗂 Repository layout
83+
84+```
85+wp10_uqo/
86+├── data/
87+│ ├── raw/ # matched sales–roll snapshot (see data/raw/README.md)
88+│ └── processed/ # analysis.parquet (built by step 01)
89+├── src/wp10/ # reusable package
90+│ ├── config.py # paths, thresholds, constants
91+│ ├── sample.py # sample construction & variable definitions
92+│ ├── iaao.py # COD / PRD / PRB + bootstrap CIs
93+│ ├── models.py # Cheng FE, Clapp IV, quantiles, horizontal, tax shift
94+│ └── plotstyle.py # journal-calibre matplotlib style (validated palette)
95+├── scripts/ # the pipeline, in order
96+│ ├── 01_build_sample.py
97+│ ├── 02_iaao_stats.py
98+│ ├── 03_estimate_regressions.py
99+│ ├── 04_make_figures.py
100+│ └── 05_make_tables.py
101+├── results/
102+│ ├── reproduced/ # every CSV the scripts emit
103+│ └── tables/ # LaTeX tables input by the paper
104+├── figures/ # 10 publication figures (PNG, 300 dpi)
105+└── paper/ # main.tex + sections/ + references.bib → main.pdf
106+```
107+
108+## 🚀 Quick start
109+
110+```bash
111+cd wp10_uqo
112+python3 -m pip install -r requirements.txt
113+
114+# place the parquet snapshot in data/raw/ (see data/raw/README.md), then:
115+python3 scripts/01_build_sample.py # 745,119 → 522,769 sales
116+python3 scripts/02_iaao_stats.py # IAAO diagnostics + bootstrap
117+python3 scripts/03_estimate_regressions.py # FE / IV / quantile / heterogeneity / tax shift
118+python3 scripts/04_make_figures.py # 10 figures
119+python3 scripts/05_make_tables.py # 10 LaTeX tables
120+
121+cd paper && make # compile main.pdf (latexmk)
122+```
123+
124+Total runtime ≈ 5 minutes on an Apple-silicon laptop; peak RAM ≈ 6 GB.
125+
126+## 🗃 Data
127+
128+745,119 residential-market transactions (Jan 2021 – Jul 2026) matched at the
129+**parcel level** to the assessment roll in force at the sale date (median match
130+distance 0.6 m; every retained sale reproduces the roll value exactly). From the
131+roll: total/land/building assessed values, lot & floor areas, year built, unit
132+count, CUBF use code, roll vintage and the statutory market-condition date.
133+Sample cascade (details in the paper, §4): residential CUBF → match quality →
134+valid AV/SP → 1/99% ratio trim within roll vintage → cells ≥ 20 sales
135+⇒ **522,769 sales, 625 municipalities, 2,884 cells**.
136+
137+## 🔬 Methodology
138+
139+1. **IAAO diagnostics** (Standard on Ratio Studies, 2013) within
140+ municipality × sale-year blocks, aggregated by median — COD, PRD, PRB with
141+ percentile-bootstrap CIs (`src/wp10/iaao.py`).
142+2. **Cheng (1974) log-log regression** with 2,884 absorbed fixed effects
143+ (linearmodels `AbsorbingLS`), SEs clustered on 625 municipalities.
144+3. **Clapp (1990) rank IV** — Z ∈ {−1,0,+1} from within-cell rank agreement of
145+ ln AV and ln SP, 2SLS on demeaned data (`IV2SLS`) — the conservative bound.
146+4. **Quantile regressions** on within-cell demeaned data (τ = .10….90).
147+5. **Heterogeneity**: separate FE estimates by class, age, land share, roll lag,
148+ municipality size, sale year.
149+6. **Horizontal inequity**: |ln r − cell median| regressed on characteristics.
150+7. **Tax shift**: r / cell-median − 1 by within-cell price decile — exact %
151+ over/under-payment under Quebec's exemption-free ad valorem rule.
152+
153+## 🖼 Figures & tables inventory
154+
155+| Figure | Content |
156+|---|---|
157+| `fig_ratio_dist` | ratio distribution + by roll lag (Panels A/B) |
158+| `fig_time` | median ratio by month × roll vintage (staleness drift) |
159+| `fig_binscatter` | within-cell ln ratio vs ln price, 20 bins — the core fact |
160+| `fig_quantile` | β(τ) profile with FE/IV benchmarks |
161+| `fig_heterogeneity` | forest plot of subgroup γ |
162+| `fig_prb_muni`, `fig_cod` | municipal PRB / COD distributions vs IAAO bands |
163+| `fig_map` | province map of municipal PRB (Montréal exception) |
164+| `fig_taxshift` | excess tax burden by local price decile |
165+| `fig_robustness` | γ_FE vs γ_IV across 10 sample variants |
166+
167+Tables (`results/tables/*.tex`): summary stats, IAAO province-wide, ten largest
168+markets, vertical regressions, quantiles, heterogeneity, horizontal, tax shift,
169+robustness, Paglin–Fogarty.
170+
171+## ⚠️ Limitations
172+
173+- Sales < $50k absent from the source registry; residual non-arm's-length
174+ transfers may survive screens (bottom-decile shift = upper bound).
175+- 2021–2026 is an unusually turbulent market — levels statistics are
176+ period-specific (the within-market elasticity is not).
177+- No appeals, renovations, or owner demographics observed.
178+- Montréal's progressivity may partly reflect borough-level composition.
179+
180+## 📝 Citation
181+
182+```bibtex
183+@techreport{boucher2026assessmentgap,
184+ author = {Boucher, Simon-Pierre},
185+ title = {The Assessment Gap in Quebec: Vertical and Horizontal Inequity
186+ in Municipal Property Valuation},
187+ institution = {Universit\'e du Qu\'ebec en Outaouais,
188+ D\'epartement des sciences administratives},
189+ type = {Working Paper},
190+ number = {10},
191+ year = {2026},
192+ month = {August}
193+}
194+```
195+
196+## 👤 Author & contact
197+
198+**Simon-Pierre Boucher** — Département des sciences administratives,
199+Université du Québec en Outaouais (UQO), Gatineau, QC.
200+📧 [contact@spboucher.ai](mailto:contact@spboucher.ai)
added data/raw/README.md +18 −0
@@ -0,0 +1,18 @@
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 (rôle d'évaluation foncière)
7+in force at the sale date.
8+
9+| Group | Key columns |
10+|---|---|
11+| Transaction | `id`, `date`, `amount`, `street`, `zipCode`, `city`, `lat`, `lng`, `propertyType`, `ownerType`, `tx_year` |
12+| Roll identity | `role_id_provinc`, `role_matricule`, `role_code_mun`, `role_municipalite`, `role_anrole` (roll vintage), `role_date_cond_marche` (statutory market-condition reference date) |
13+| Roll values | `role_valeur_immeuble` (total AV), `role_valeur_terrain` (land), `role_valeur_batiment` (building), `role_valeur_role_anterieur`, `previousValue`, `totalArValue` |
14+| Roll structure | `role_cubf` (+ `role_cubf_libelle`), `role_superficie_terrain_m2`, `role_aire_etages_m2`, `role_annee_construction`, `role_nb_logements`, `role_nb_etages`, `role_lien_physique`, `role_genre_construction`, `role_unite_voisinage` |
15+| Match quality | `match_dist_m` (median 0.6 m), `match_score` (max 220), `match_valeur_exacte` |
16+
17+The file is **not redistributed** with the repository (100 MB; see
18+`.gitignore`). Place it in this directory before running the pipeline.
added figures/fig_binscatter.png +0 −0

Binary file not shown.

added figures/fig_cod.png +0 −0

Binary file not shown.

added figures/fig_heterogeneity.png +0 −0

Binary file not shown.

added figures/fig_map.png +0 −0

Binary file not shown.

added figures/fig_prb_muni.png +0 −0

Binary file not shown.

added figures/fig_quantile.png +0 −0

Binary file not shown.

added figures/fig_ratio_dist.png +0 −0

Binary file not shown.

added figures/fig_robustness.png +0 −0

Binary file not shown.

added figures/fig_taxshift.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 +124 −0
@@ -0,0 +1,124 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+% UQO Working Paper No. 10
4+% The Assessment Gap in Quebec: Vertical and Horizontal Inequity in
5+% Municipal Property Valuation
6+%
7+% Build: latexmk -pdf main.tex (or `make` in this directory)
8+% Figures are read from ../figures/, tables from ../results/tables/.
9+% ============================================================================
10+\documentclass[12pt,letterpaper]{article}
11+
12+% ---------------------------------------------------------------- encoding
13+\usepackage[utf8]{inputenc}
14+\usepackage[T1]{fontenc}
15+\usepackage[english]{babel}
16+
17+% ---------------------------------------------------------------- layout
18+\usepackage[letterpaper,margin=1in]{geometry}
19+\usepackage{setspace}
20+\onehalfspacing
21+
22+% ---------------------------------------------------------------- typography
23+\usepackage{mathptmx}
24+\usepackage{microtype}
25+
26+% ---------------------------------------------------------------- math
27+\usepackage{amsmath,amssymb,amsthm}
28+
29+% ---------------------------------------------------------------- tables
30+\usepackage{booktabs}
31+\usepackage{threeparttable}
32+\usepackage{makecell}
33+
34+% ---------------------------------------------------------------- figures
35+\usepackage{graphicx}
36+\usepackage{subcaption}
37+\graphicspath{{../figures/}{./}}
38+
39+% ---------------------------------------------------------------- captions
40+\usepackage[font=small,labelfont=bf,labelsep=period,justification=justified,singlelinecheck=false]{caption}
41+
42+% ---------------------------------------------------------------- colours & links
43+\usepackage[dvipsnames]{xcolor}
44+\usepackage[colorlinks=true,linkcolor=NavyBlue,citecolor=NavyBlue,urlcolor=NavyBlue,breaklinks=true]{hyperref}
45+
46+% ---------------------------------------------------------------- bibliography
47+\usepackage[authoryear,round,semicolon]{natbib}
48+\bibliographystyle{aer}
49+
50+% ---------------------------------------------------------------- headings & lists
51+\usepackage{titlesec}
52+\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{}
53+\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{}
54+\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{}
55+\usepackage{fancyhdr}
56+\pagestyle{fancy}\fancyhf{}
57+\renewcommand{\headrulewidth}{0pt}
58+\fancyfoot[C]{\thepage}
59+\usepackage{enumitem}
60+\setlist{nosep,leftmargin=*}
61+
62+% ============================================================================
63+% METADATA
64+% ============================================================================
65+\newcommand{\WPnumber}{10}
66+\newcommand{\WPtitle}{The Assessment Gap in Quebec}
67+\newcommand{\WPsubtitle}{Vertical and Horizontal Inequity in Municipal Property
68+ Valuation: Evidence from 522{,}769 Sales Matched to the Assessment Roll}
69+\newcommand{\WPdate}{August 2026}
70+\newcommand{\WPversion}{1.0}
71+\newcommand{\WPkeywords}{Property tax, Assessment, Vertical equity,
72+ Regressivity, Ratio study, Quebec}
73+\newcommand{\WPjel}{H71, H22, R51, R31}
74+
75+\newcommand{\WPauthor}{Simon-Pierre Boucher}
76+\newcommand{\WPaffiliation}{D\'epartement des sciences administratives\\
77+ Universit\'e du Qu\'ebec en Outaouais}
78+\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca}
79+\newcommand{\WPaddress}{Gatineau -- Pavillon Alexandre-Tach\'e\\
80+ 283, boulevard Alexandre-Tach\'e\\ Gatineau, Qu\'ebec, Canada J9A 1L8}
81+
82+\newcommand{\WPabstract}{%
83+Municipal property taxes are levied on assessed values, so any systematic
84+relationship between assessment accuracy and market value silently
85+redistributes the tax burden. Matching 522{,}769 residential transactions
86+from 2021--2026 to the triennial assessment rolls of 625 Quebec
87+municipalities at the parcel level, we provide the first province-wide
88+evaluation of vertical and horizontal equity in Canadian property assessment.
89+Assessment ratios decline steeply with price: within
90+municipality~$\times$~roll~$\times$~sale-year markets, the elasticity of the
91+assessment ratio with respect to the sale price is $-0.34$, and a rank-based
92+instrumental-variable estimator in the spirit of \mbox{Clapp (1990)}---which
93+purges the attenuation bias that pushes least-squares tests toward spurious
94+regressivity---still yields $-0.08$ ($t\approx4$). IAAO ratio-study
95+diagnostics place 95\% of municipalities below the acceptable price-related
96+bias band and 99\% above the COD uniformity ceiling; Montr\'eal is the only
97+large market whose assessments are progressive. Regressivity is strongest for
98+single-family homes, older buildings, properties with a high assessed land
99+share, and smaller municipalities, and it doubles in years when market prices
100+decouple from the rolls' reference dates, pointing to infrequent revaluation
101+as a first-order mechanism. The implied intra-municipal tax shift is large:
102+the median dwelling in the bottom within-market price decile pays roughly
103+65\% more property tax than it would under uniform assessment, while the top
104+decile pays about 5\% less.}
105+
106+\begin{document}
107+
108+\input{sections/titlepage}
109+
110+\setcounter{page}{1}
111+\input{sections/introduction}
112+\input{sections/literature}
113+\input{sections/institutional}
114+\input{sections/data}
115+\input{sections/methodology}
116+\input{sections/results}
117+\input{sections/robustness}
118+\input{sections/discussion}
119+\input{sections/conclusion}
120+
121+\newpage
122+\bibliography{references}
123+
124+\end{document}
added paper/references.bib +402 −0
@@ -0,0 +1,402 @@
1+@comment{ Author: Simon-Pierre Boucher (contact at spboucher.ai) -- Bibliography for UQO Working Paper No 10 }
2+
3+@article{paglin1972equity,
4+ author = {Paglin, Morton and Fogarty, Michael},
5+ title = {Equity and the Property Tax: A New Conceptual Focus},
6+ journal = {National Tax Journal},
7+ year = {1972},
8+ volume = {25},
9+ number = {4},
10+ pages = {557--565}
11+}
12+
13+@article{cheng1974property,
14+ author = {Cheng, Pao Lun},
15+ title = {Property Taxation, Assessment Performance, and Its Measurement},
16+ journal = {Public Finance},
17+ year = {1974},
18+ volume = {29},
19+ number = {3},
20+ pages = {268--284}
21+}
22+
23+@article{kochin1982vertical,
24+ author = {Kochin, Levis A. and Parks, Richard W.},
25+ title = {Vertical Equity in Real Estate Assessment: A Fair Appraisal},
26+ journal = {Economic Inquiry},
27+ year = {1982},
28+ volume = {20},
29+ number = {4},
30+ pages = {511--532}
31+}
32+
33+@article{bell1984administrative,
34+ author = {Bell, Earl J.},
35+ title = {Administrative Inequity and Property Assessment: The Case for the Traditional Approach},
36+ journal = {Property Tax Journal},
37+ year = {1984},
38+ volume = {3},
39+ number = {2},
40+ pages = {123--131}
41+}
42+
43+@article{kennedy1984unfair,
44+ author = {Kennedy, Peter},
45+ title = {On an Unfair Appraisal of Vertical Equity in Real Estate Assessment},
46+ journal = {Economic Inquiry},
47+ year = {1984},
48+ volume = {22},
49+ number = {2},
50+ pages = {287--290}
51+}
52+
53+@article{sunderman1990testing,
54+ author = {Sunderman, Mark A. and Birch, John W. and Cannaday, Roger E. and Hamilton, Thomas W.},
55+ title = {Testing for Vertical Inequity in Property Tax Systems},
56+ journal = {Journal of Real Estate Research},
57+ year = {1990},
58+ volume = {5},
59+ number = {3},
60+ pages = {319--334}
61+}
62+
63+@article{clapp1990new,
64+ author = {Clapp, John M.},
65+ title = {A New Test for Equitable Real Estate Tax Assessment},
66+ journal = {Journal of Real Estate Finance and Economics},
67+ year = {1990},
68+ volume = {3},
69+ number = {3},
70+ pages = {233--249}
71+}
72+
73+@article{sirmans1995vertical,
74+ author = {Sirmans, G. Stacy and Diskin, Barry A. and Friday, H. Swint},
75+ title = {Vertical Inequity in the Taxation of Real Property},
76+ journal = {National Tax Journal},
77+ year = {1995},
78+ volume = {48},
79+ number = {1},
80+ pages = {71--84}
81+}
82+
83+@article{allen2002measuring,
84+ author = {Allen, Marcus T. and Dare, William H.},
85+ title = {Identifying Determinants of Horizontal Property Tax Inequity: Evidence from Florida},
86+ journal = {Journal of Real Estate Research},
87+ year = {2002},
88+ volume = {24},
89+ number = {2},
90+ pages = {153--164}
91+}
92+
93+@article{sirmans2008vertical,
94+ author = {Sirmans, G. Stacy and Gatzlaff, Dean H. and Macpherson, David A.},
95+ title = {Horizontal and Vertical Inequity in Real Property Taxation},
96+ journal = {Journal of Real Estate Literature},
97+ year = {2008},
98+ volume = {16},
99+ number = {2},
100+ pages = {167--180}
101+}
102+
103+@article{mcmillen2008thin,
104+ author = {McMillen, Daniel P. and Weber, Rachel N.},
105+ title = {Thin Markets and Property Tax Inequities: A Multinomial Logit Approach},
106+ journal = {National Tax Journal},
107+ year = {2008},
108+ volume = {61},
109+ number = {4},
110+ pages = {653--671}
111+}
112+
113+@article{mcmillen2011assessment,
114+ author = {McMillen, Daniel P.},
115+ title = {Assessment Regressivity: A Tale of Two {Illinois} Counties},
116+ journal = {Land Lines},
117+ year = {2011},
118+ volume = {23},
119+ number = {1},
120+ pages = {9--15}
121+}
122+
123+@article{hodge2017assessment,
124+ author = {Hodge, Timothy R. and McMillen, Daniel P. and Sands, Gary and Skidmore, Mark},
125+ title = {Assessment Inequity in a Declining Housing Market: The Case of {Detroit}},
126+ journal = {Real Estate Economics},
127+ year = {2017},
128+ volume = {45},
129+ number = {2},
130+ pages = {237--258}
131+}
132+
133+@article{mcmillen2020assessment,
134+ author = {McMillen, Daniel and Singh, Ruchi},
135+ title = {Assessment Regressivity and Property Taxation},
136+ journal = {Journal of Real Estate Finance and Economics},
137+ year = {2020},
138+ volume = {60},
139+ number = {1},
140+ pages = {155--169}
141+}
142+
143+@article{avenancio2022assessment,
144+ author = {Avenancio-Le{\'o}n, Carlos F. and Howard, Troup},
145+ title = {The Assessment Gap: Racial Inequalities in Property Taxation},
146+ journal = {Quarterly Journal of Economics},
147+ year = {2022},
148+ volume = {137},
149+ number = {3},
150+ pages = {1383--1434}
151+}
152+
153+@techreport{berry2021reassessing,
154+ author = {Berry, Christopher R.},
155+ title = {Reassessing the Property Tax},
156+ institution = {University of Chicago, Harris School of Public Policy},
157+ type = {Working Paper},
158+ year = {2021}
159+}
160+
161+@techreport{amornsiripanitch2022residential,
162+ author = {Amornsiripanitch, Natee},
163+ title = {Why Are Residential Property Tax Rates Regressive?},
164+ institution = {Federal Reserve Bank of Philadelphia},
165+ type = {Working Paper},
166+ number = {22-02},
167+ year = {2022}
168+}
169+
170+@article{mcmillen2022allocative,
171+ author = {McMillen, Daniel and Singh, Ruchi},
172+ title = {Measures of Vertical Inequality in Assessments},
173+ journal = {Journal of Real Estate Finance and Economics},
174+ year = {2022},
175+ volume = {online first},
176+ pages = {1--29}
177+}
178+
179+@book{iaao2013standard,
180+ author = {{International Association of Assessing Officers}},
181+ title = {Standard on Ratio Studies},
182+ publisher = {IAAO},
183+ address = {Kansas City, MO},
184+ year = {2013}
185+}
186+
187+@book{eckert1990property,
188+ author = {Eckert, Joseph K. and Gloudemans, Robert J. and Almy, Richard R.},
189+ title = {Property Appraisal and Assessment Administration},
190+ publisher = {International Association of Assessing Officers},
191+ address = {Chicago},
192+ year = {1990}
193+}
194+
195+@book{gloudemans2011fundamentals,
196+ author = {Gloudemans, Robert and Almy, Richard},
197+ title = {Fundamentals of Mass Appraisal},
198+ publisher = {International Association of Assessing Officers},
199+ address = {Kansas City, MO},
200+ year = {2011}
201+}
202+
203+@article{oates1969effects,
204+ author = {Oates, Wallace E.},
205+ title = {The Effects of Property Taxes and Local Public Spending on Property Values: An Empirical Study of Tax Capitalization and the {Tiebout} Hypothesis},
206+ journal = {Journal of Political Economy},
207+ year = {1969},
208+ volume = {77},
209+ number = {6},
210+ pages = {957--971}
211+}
212+
213+@article{oates2016local,
214+ author = {Oates, Wallace E. and Fischel, William A.},
215+ title = {Are Local Property Taxes Regressive, Progressive, or What?},
216+ journal = {National Tax Journal},
217+ year = {2016},
218+ volume = {69},
219+ number = {2},
220+ pages = {415--434}
221+}
222+
223+@article{rosen1974hedonic,
224+ author = {Rosen, Sherwin},
225+ title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition},
226+ journal = {Journal of Political Economy},
227+ year = {1974},
228+ volume = {82},
229+ number = {1},
230+ pages = {34--55}
231+}
232+
233+@incollection{malpezzi2003hedonic,
234+ author = {Malpezzi, Stephen},
235+ title = {Hedonic Pricing Models: A Selective and Applied Review},
236+ booktitle = {Housing Economics and Public Policy},
237+ editor = {O'Sullivan, Tony and Gibb, Kenneth},
238+ publisher = {Blackwell},
239+ address = {Oxford},
240+ year = {2003},
241+ pages = {67--89}
242+}
243+
244+@article{carbonnier2024property,
245+ author = {Carbonnier, Cl{\'e}ment},
246+ title = {Property Tax Regressivity, the Case of {Qu{\'e}bec}},
247+ journal = {Public Finance Review},
248+ year = {2024},
249+ volume = {52},
250+ number = {2},
251+ pages = {155--181}
252+}
253+
254+@techreport{slack2014political,
255+ author = {Slack, Enid and Bird, Richard M.},
256+ title = {The Political Economy of Property Tax Reform},
257+ institution = {OECD},
258+ type = {OECD Working Papers on Fiscal Federalism},
259+ number = {18},
260+ year = {2014}
261+}
262+
263+@book{youngman2016good,
264+ author = {Youngman, Joan},
265+ title = {A Good Tax: Legal and Policy Issues for the Property Tax in the {United States}},
266+ publisher = {Lincoln Institute of Land Policy},
267+ address = {Cambridge, MA},
268+ year = {2016}
269+}
270+
271+@techreport{found2017property,
272+ author = {Found, Adam and Tomlinson, Peter},
273+ title = {Business Tax Burdens in {Canada's} Major Cities: The 2017 Report Card},
274+ institution = {C.D. Howe Institute},
275+ type = {E-Brief},
276+ year = {2017}
277+}
278+
279+@techreport{mpac2022vertical,
280+ author = {{Municipal Property Assessment Corporation}},
281+ title = {Vertical Equity Review of Residential Assessed Values},
282+ institution = {MPAC},
283+ address = {Toronto},
284+ type = {Technical Report},
285+ year = {2022}
286+}
287+
288+@article{goolsby1997assessment,
289+ author = {Goolsby, William C.},
290+ title = {Assessment Error in the Valuation of Owner-Occupied Housing},
291+ journal = {Journal of Real Estate Research},
292+ year = {1997},
293+ volume = {13},
294+ number = {1},
295+ pages = {33--45}
296+}
297+
298+@article{quintos2020prb,
299+ author = {Quintos, Carmela},
300+ title = {A Gradient Boosting Approach to Understanding Vertical and Horizontal Inequity in Property Assessments},
301+ journal = {Journal of Property Tax Assessment \& Administration},
302+ year = {2020},
303+ volume = {17},
304+ number = {2},
305+ pages = {1--28}
306+}
307+
308+@article{ross2012assessor,
309+ author = {Ross, Justin M.},
310+ title = {Interjurisdictional Determinants of Property Assessment Regressivity},
311+ journal = {Land Economics},
312+ year = {2012},
313+ volume = {88},
314+ number = {1},
315+ pages = {28--42}
316+}
317+
318+@article{plummer2014evidence,
319+ author = {Plummer, Elizabeth},
320+ title = {The Effects of Property Tax Protests on the Assessment Uniformity of Residential Properties},
321+ journal = {Real Estate Economics},
322+ year = {2014},
323+ volume = {42},
324+ number = {4},
325+ pages = {900--937}
326+}
327+
328+@article{clapp1990methods,
329+ author = {Clapp, John M.},
330+ title = {A Methodology for Constructing Vacant Land Price Indices},
331+ journal = {Real Estate Economics},
332+ year = {1990},
333+ volume = {18},
334+ number = {3},
335+ pages = {274--293}
336+}
337+
338+@misc{lfm2026,
339+ author = {{Gouvernement du Qu\'ebec}},
340+ title = {Loi sur la fiscalit\'e municipale, {RLRQ}, c.\ {F-2.1}},
341+ howpublished = {L\'egisQu\'ebec},
342+ year = {2026},
343+ note = {Articles 42--46 (triennial rolls and market-condition reference date)}
344+}
345+
346+@misc{mamh2024manuel,
347+ author = {{Minist\`ere des Affaires municipales et de l'Habitation}},
348+ title = {Manuel d'\'evaluation fonci\`ere du {Qu\'ebec}},
349+ howpublished = {Gouvernement du Qu\'ebec},
350+ address = {Qu\'ebec},
351+ year = {2024}
352+}
353+
354+@article{desrosiers2000hedonic,
355+ author = {Des Rosiers, Fran{\c c}ois and Th{\'e}riault, Marius and Villeneuve, Paul-Y.},
356+ title = {Sorting Out Access and Neighbourhood Factors in Hedonic Price Modelling},
357+ journal = {Journal of Property Investment \& Finance},
358+ year = {2000},
359+ volume = {18},
360+ number = {3},
361+ pages = {291--315}
362+}
363+
364+@article{bostic2007land,
365+ author = {Bostic, Raphael W. and Longhofer, Stanley D. and Redfearn, Christian L.},
366+ title = {Land Leverage: Decomposing Home Price Dynamics},
367+ journal = {Real Estate Economics},
368+ year = {2007},
369+ volume = {35},
370+ number = {2},
371+ pages = {183--208}
372+}
373+
374+@article{ihlanfeldt2023appraisal,
375+ author = {Ihlanfeldt, Keith and Rodgers, Luke P.},
376+ title = {Homestead Exemptions, Heterogeneous Assessment, and Property Tax Progressivity},
377+ journal = {National Tax Journal},
378+ year = {2022},
379+ volume = {75},
380+ number = {1},
381+ pages = {7--31}
382+}
383+
384+@article{baar1981property,
385+ author = {Baar, Kenneth K.},
386+ title = {Property Tax Assessment Discrimination Against Low-Income Neighborhoods},
387+ journal = {The Urban Lawyer},
388+ year = {1981},
389+ volume = {13},
390+ number = {3},
391+ pages = {333--406}
392+}
393+
394+@article{engle1975deassessment,
395+ author = {Engle, Robert F.},
396+ title = {De Facto Discrimination in Residential Assessments: {Boston}},
397+ journal = {National Tax Journal},
398+ year = {1975},
399+ volume = {28},
400+ number = {4},
401+ pages = {445--451}
402+}
added paper/sections/conclusion.tex +37 −0
@@ -0,0 +1,37 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Conclusion}
4+\label{sec:concl}
5+
6+Matching more than half a million residential sales to the assessment
7+rolls that tax them, this paper delivers the first province-wide audit of
8+assessment equity in Canada. The verdict is unambiguous. Quebec's
9+property-tax base is systematically regressive: within the same
10+municipality, roll and year, the assessment ratio falls by 34\% per
11+log-point of price in the descriptive fixed-effects estimate, and by 8\%
12+under the most conservative measurement-error correction available.
13+Ninety-five percent of municipalities fail the IAAO vertical-equity
14+standard, ninety-nine percent fail its uniformity standard, and the median
15+dwelling in the bottom local price decile pays roughly two thirds more
16+property tax than uniform assessment would imply, while the top decile
17+pays less. The inequity concentrates where mass appraisal is hardest ---
18+old houses, plexes, land-heavy and luxury properties, thin markets ---
19+doubles when triennial rolls fall behind a booming market, and disappears
20+almost nowhere except Montr\'eal.
21+
22+These findings reframe the presumption that centralized, professionally
23+regulated assessment systems are immune to the pathologies documented in
24+the fragmented U.S. system. Quebec has uniform statutes, certified
25+evaluators and statutory market-vintage synchronization --- and produces
26+regressivity of U.S. magnitude anyway. The binding constraint is not
27+governance but valuation technology and revaluation frequency, which is
28+in fact encouraging: cycle length, pooled assessment services and
29+vertical audit standards are policy variables, and inexpensive ones
30+relative to the redistribution at stake.
31+
32+The matched sale--roll architecture built here supports a research agenda
33+beyond this audit: linking assessment gaps to neighbourhood
34+socio-demographics, exploiting the discrete arrival of new rolls to study
35+price anchoring, and using the land--building decomposition to measure
36+land-value dynamics at the parcel level. We pursue these in companion
37+work.
added paper/sections/data.tex +101 −0
@@ -0,0 +1,101 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Data and sample construction}
4+\label{sec:data}
5+
6+\subsection{Sources and matching}
7+
8+We start from a compiled registry of 745{,}119 residential-market
9+transactions recorded in Quebec between January 2021 and July 2026, each
10+carrying the sale price, sale date, civic address, geographic coordinates,
11+and a listing-derived property type. Each transaction has been matched at
12+the parcel level to the municipal assessment roll in force on the sale date.
13+The match keys on geographic proximity between the transaction's coordinates
14+and the roll unit's coordinates and is validated against the roll's recorded
15+value; a composite score (maximum 220) summarizes the quality of the
16+address, distance and value agreement. Matches are extremely tight: the
17+median distance between the transaction and the matched roll unit is
18+0.6~metres, and every observation retained in the estimation sample
19+reproduces the roll's assessed value exactly.
20+
21+From the roll we observe the taxable value of the property
22+($AV$, \texttt{valeur immeuble}), its land and building components, lot
23+area, total floor area, year of construction, number of dwelling units, the
24+standardized use code (CUBF), the roll vintage, and the statutory
25+market-condition reference date discussed in Section~\ref{sec:inst}.
26+
27+\subsection{Sample restrictions}
28+
29+Table~\ref{tab:sumstats} describes the estimation sample; the selection
30+cascade is as follows. We keep sales of residential use codes ---
31+dwellings (CUBF 1000), cottages (1100), mobile homes (1211) and other
32+residential (1990) --- which removes vacant land, commercial property and
33+construction-in-progress (672{,}276 sales remain). We require a
34+high-confidence roll match (distance $\le 50$~m and score $\ge 150$;
35+568{,}711 sales), a positive assessed value and a price of at least
36+\$50{,}000 --- the floor of the source registry, which also screens out
37+most non-arm's-length transfers. Assessment ratios $r_i = AV_i/SP_i$ are
38+trimmed at the 1st and 99th percentiles \emph{within each roll vintage}, so
39+that the mechanical drift of ratio levels across vintages is not trimmed
40+asymmetrically (557{,}325 sales). Finally, since all estimates compare
41+sales within a municipality $\times$ roll $\times$ sale-year block
42+(``cell''), we require at least 20 sales per cell. The estimation sample
43+contains \textbf{522{,}769 sales} in 625 municipalities and 2{,}884 cells:
44+345{,}053 single-family homes, 81{,}693 plexes (2--5 units), 81{,}461
45+condominiums, 9{,}287 cottages and 4{,}701 mobile homes.
46+
47+\begin{table}[t]
48+\centering
49+\begin{threeparttable}
50+\caption{Summary statistics, estimation sample}
51+\label{tab:sumstats}
52+\small
53+\input{../results/tables/summary_stats}
54+\begin{tablenotes}[flushleft]\footnotesize
55+\item \textit{Notes:} 522{,}769 residential sales, January 2021 -- July
56+2026, matched at the parcel level to the assessment roll in force at the
57+sale date. The assessment ratio is assessed value divided by sale price.
58+Roll lag is the number of months between the roll's statutory
59+market-condition reference date (July 1) and the sale date. The assessed
60+land share is the roll's land value divided by total assessed value.
61+\end{tablenotes}
62+\end{threeparttable}
63+\end{table}
64+
65+\subsection{The raw pattern}
66+
67+The median assessment ratio is 0.78: the typical dwelling sells for about
68+28\% more than its rolled value, the expected imprint of a rising market on
69+back-dated rolls. Figure~\ref{fig:ratiodist} shows the distribution and its
70+decomposition by roll lag: sales occurring within two years of the
71+reference date centre near 0.87, while sales more than four years out
72+centre near 0.64 --- the mechanical staleness gradient that our fixed
73+effects absorb. Figure~\ref{fig:time} traces the same mechanics in
74+calendar time: each roll vintage enters near parity with the market it was
75+referenced on, then drifts down as prices rise, and the 2022--2023 rate
76+shock is visible as a flattening of the drift. Everything that follows
77+nets out this timing structure and asks a sharper question: \emph{within} a
78+given municipality, roll, and year, do cheap and expensive homes face the
79+same ratio?
80+
81+\begin{figure}[t]
82+\centering
83+\includegraphics[width=\textwidth]{fig_ratio_dist.png}
84+\caption{Assessment ratios. Panel A: distribution of $AV/SP$ across the
85+estimation sample; the median is 0.78 and the dashed line marks parity.
86+Panel B: kernel of the same distribution split by the number of months
87+between the roll's market-condition reference date and the sale; older
88+rolls sit systematically further below parity.}
89+\label{fig:ratiodist}
90+\end{figure}
91+
92+\begin{figure}[t]
93+\centering
94+\includegraphics[width=\textwidth]{fig_time.png}
95+\caption{Median assessment ratio by sale month and roll vintage. Each line
96+follows sales assessed under one triennial vintage (labelled by entry
97+year); monthly medians with fewer than 100 sales are suppressed. Vintages
98+enter near their reference-date market level and drift down as prices
99+rise; the flattening after 2022 reflects the interest-rate correction.}
100+\label{fig:time}
101+\end{figure}
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{Mechanisms}
7+
8+The evidence assembles into a coherent mechanism story with three layers.
9+
10+\paragraph{1. Hedonic difficulty, not assessor idiosyncrasy.} The
11+heterogeneity profile tracks the difficulty of the valuation problem with
12+almost embarrassing fidelity: condominiums (homogeneous, comparable-rich)
13+are nearly equitable; old houses, plexes, cottages and high-land-share
14+properties (heterogeneous, comparable-poor, unanchored by construction
15+costs) are severely regressive. This is the pattern predicted if assessors
16+run reasonable hedonic models whose unexplained component is largest ---
17+and most value-correlated --- where the housing bundle is hardest to price
18+\citep{amornsiripanitch2022residential, gloudemans2011fundamentals,
19+bostic2007land}. Regression toward the mean in any imperfect valuation
20+model over-values the cheap tail and under-values the expensive tail;
21+our quantile profile, with proportionality holding at the bottom and
22+collapsing at the top ($\beta(0.90) = 0.49$), shows Quebec's failure is
23+concentrated where comparables are thinnest.
24+
25+\paragraph{2. Staleness amplifies the gap.} The doubling of the
26+province-wide PRB in 2021--2022 shows that triennial rolls interact
27+perversely with fast markets: when prices move 30--40\% between reference
28+dates, the cross-sectional dispersion of appreciation --- which is itself
29+correlated with price segment --- loads directly into measured inequity.
30+Jurisdictions cannot control housing cycles, but they control revaluation
31+frequency; annual-cycle systems mechanically cap this channel
32+\citep{berry2021reassessing, ross2012assessor}.
33+
34+\paragraph{3. Scale helps: the Montr\'eal exception.} Montr\'eal ---
35+the largest sales sample, a dedicated in-house assessment service, dense
36+comparable markets in every segment --- is the only large jurisdiction
37+with progressive assessments, in all six years. Mid-sized and small
38+municipalities, many relying on contracted regional evaluators with
39+limited modelling capacity, are uniformly regressive. The thin-market
40+mechanism of \citet{mcmillen2008thin} operates within a single provincial
41+rulebook: identical statutes, divergent outcomes, sorted by market depth
42+and assessment resources. We note one caveat: Montr\'eal's measured
43+progressivity may partly reflect within-city composition (its cell is the
44+whole city, pooling boroughs), and borough-level analysis is a natural
45+extension.
46+
47+\subsection{Policy implications}
48+
49+Three levers follow directly from the diagnosis, in increasing order of
50+ambition. First, \textbf{shorten the cycle}: moving from triennial to
51+annual (or indexed) rolls would eliminate the staleness component that
52+dominated 2021--2022; several U.S. states and British Columbia already
53+operate annual cycles. Second, \textbf{pool assessment capacity}: the
54+regressivity gradient by municipality size argues for regional or
55+provincial mass-appraisal services --- scale is the cheapest known
56+technology for assessment quality. Third, \textbf{audit vertically, not
57+just horizontally}: Quebec's oversight regime monitors median ratios and
58+CODs but sets no explicit PRB requirement; adding the IAAO vertical
59+standard to the ministry's audit criteria, with published
60+municipality-level statistics like those in this paper, would make the
61+equity dimension visible and contestable. Because low-priced-segment
62+owners appeal least \citep{avenancio2022assessment, plummer2014evidence},
63+supply-side correction --- better models --- will do more than
64+demand-side remedies.
65+
66+\subsection{Limitations}
67+
68+Four limitations bound the interpretation. First, sale prices below
69+\$50{,}000 are absent from the source registry and some residual
70+non-arm's-length transfers may survive our screens; the robustness
71+battery (dropping sub-\$100k sales, tightening trims) shows the
72+conclusions survive, but the bottom-decile tax-shift magnitude should be
73+read as an upper bound on that decile. Second, our data cover 2021--2026,
74+an unusually turbulent market; the within-market elasticity is stable
75+across the window, but levels statistics from calmer periods would differ.
76+Third, we observe no appeals, renovations between assessment and sale, or
77+conditions of sale; some of what we call horizontal noise is genuine
78+unobserved quality change. Fourth, without owner demographics we cannot
79+speak to the distributional incidence across income or racial groups as
80+\citet{avenancio2022assessment} do --- linking these assessments to census
81+tract characteristics is the obvious next step.
added paper/sections/institutional.tex +63 −0
@@ -0,0 +1,63 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Institutional setting: property assessment in Quebec}
4+\label{sec:inst}
5+
6+Quebec's municipal fiscal regime is governed by the \textit{Loi sur la
7+fiscalit\'e municipale} (LFM) and operationalized by the province's
8+assessment manual, the \textit{Manuel d'\'evaluation fonci\`ere du
9+Qu\'ebec} \citep{lfm2026, mamh2024manuel}. Four features matter for the
10+measurement design of this paper.
11+
12+\paragraph{Triennial rolls.} Every municipality's assessment roll is
13+redrawn on a fixed three-year cycle by a municipal body or a contracted
14+private firm under the responsibility of a chartered evaluator. The roll
15+lists, for each assessment unit, the taxable value of the property and its
16+decomposition into land and building components, along with the structural
17+descriptors used in valuation (lot area, floor area, year of construction,
18+number of dwelling units, physical configuration, and a standardized use
19+code, the CUBF).
20+
21+\paragraph{A statutory market-condition date.} Article 46 of the LFM
22+requires that the values entered on a roll reflect the state of the market
23+\emph{eighteen months before} the roll takes effect: a roll entering force
24+on January~1 of year $t$ must value every property as of July~1 of year
25+$t-2$. This single reference date is printed on the roll itself and is
26+common to every property in the municipality. Two implications follow.
27+First, in a rising market the \emph{level} of assessment ratios drifts
28+mechanically below one as sales occur further from the reference date ---
29+between 18 and 54 months elapse between the reference date and a sale, 36
30+months at the median in our sample. This drift is a feature of the system,
31+not an inequity, and our empirical design absorbs it entirely with
32+municipality~$\times$~roll~$\times$~sale-year fixed effects. Second, and
33+crucially, \emph{within} such a block every property shares the same market
34+vintage, so cross-sectional patterns in ratios cannot be attributed to
35+timing.
36+
37+\paragraph{Uniform taxation of the rolled value.} The municipal levy on a
38+residential property is the rolled taxable value multiplied by the
39+municipal rate (plus school taxes levied on the same base). There are no
40+assessment-growth caps, homestead exemptions, or acquisition-value rules of
41+the Californian or Floridian type: relative taxable values within a
42+municipality equal relative assessed values. This makes the mapping from
43+assessment error to tax-burden redistribution exact --- a property assessed
44+10\% above the jurisdiction's median ratio pays 10\% more tax than uniform
45+assessment would imply --- and motivates the tax-shift calculation of
46+Section~\ref{sec:results}.
47+
48+\paragraph{Oversight and appeal.} The Ministry of Municipal Affairs (MAMH)
49+prescribes methods and audits rolls; owners may request an administrative
50+review and appeal to the Tribunal administratif du Qu\'ebec. Appeal rates
51+for residential property are low. Unlike several U.S. jurisdictions
52+studied in the literature \citep{plummer2014evidence,
53+avenancio2022assessment}, Quebec's appeal system plays a minor quantitative
54+role for the housing stock at large, which makes the valuation model
55+itself --- rather than post-assessment litigation --- the natural locus of
56+any inequity we measure.
57+
58+Taken together, these institutions imply that Quebec should be a
59+\emph{best-case} environment for assessment equity: uniform provincial
60+methodology, professional certification, statutory synchronization of
61+market vintage, and a tax that consumes the rolled value without
62+exemption-driven distortions. The magnitude of the inequity we document
63+below should be read against this backdrop.
added paper/sections/introduction.tex +127 −0
@@ -0,0 +1,127 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Introduction}
4+\label{sec:intro}
5+
6+The property tax is the fiscal backbone of Canadian local government: it
7+finances roughly two thirds of municipal budgets and is, by statute, an
8+\emph{ad valorem} tax --- every dwelling in a municipality is meant to be
9+taxed in strict proportion to its market value. That proportionality rests
10+entirely on the quality of the assessment roll. If assessors systematically
11+overvalue inexpensive homes relative to expensive ones, the legal tax rate
12+becomes a fiction: the effective tax rate falls with wealth, and the
13+resulting redistribution --- from modest neighbourhoods toward affluent ones
14+--- occurs silently, without any legislative decision, embedded in a
15+technical document that few taxpayers ever contest. A rapidly growing
16+literature documents exactly this pattern in the United States, where
17+assessment regressivity has been measured at national scale
18+\citep{berry2021reassessing, avenancio2022assessment,
19+amornsiripanitch2022residential}. Whether the same pathology afflicts
20+Canadian assessment systems --- centralized, professionally supervised, and
21+widely presumed to be of high quality --- is essentially unknown.
22+
23+This paper provides the first province-wide answer for Quebec. We assemble
24+522{,}769 residential transactions recorded between January 2021 and July
25+2026 and match each sale, at the parcel level, to the triennial municipal
26+assessment roll in force on the day of the sale. The match is performed on
27+geographic coordinates and validated against the roll's recorded value; the
28+median match distance is under one metre. The resulting file couples every
29+sale price with the exact taxable value that generated the owner's property
30+tax bill, together with the roll's decomposition of that value into land and
31+building components, structural descriptors (lot area, floor area, year
32+built, number of units), and the statutory market-condition reference date
33+of the roll. The data span 625 municipalities --- from Montr\'eal
34+(79{,}581 usable sales) to towns with barely a hundred --- and a housing
35+cycle of unusual amplitude: the post-pandemic boom, the 2022--2023 interest
36+rate shock, and the subsequent recovery.
37+
38+Quebec's institutional design makes it an unusually clean laboratory for
39+equity measurement. Every municipality's roll is redone on a fixed
40+three-year cycle, and by law (art.~46, \textit{Loi sur la fiscalit\'e
41+municipale}) the values entered on a roll must reflect market conditions
42+eighteen months before the roll takes effect --- a single, known reference
43+date shared by every property in the municipality
44+\citep{lfm2026, mamh2024manuel}. Within a municipality $\times$ roll
45+$\times$ sale-year block, therefore, every assessment embodies the same
46+market vintage, and any systematic relationship between assessment ratios
47+and prices inside such a block is inequity, not timing. Our empirical design
48+exploits exactly this structure: all regression estimates absorb 2{,}884
49+municipality~$\times$~roll~$\times$~year fixed effects, so that vertical
50+inequity is identified purely from comparisons between cheap and expensive
51+dwellings facing the same assessor, the same roll, and the same market
52+moment.
53+
54+Three findings emerge. First, assessment regressivity in Quebec is
55+pervasive, large, and statistically unambiguous. The elasticity of the
56+assessment ratio with respect to the sale price --- zero under proportional
57+assessment --- is $-0.34$ (s.e.\ $0.03$) in the fixed-effects log-log
58+regression. Because sale prices measure market value with noise, part of
59+that estimate reflects mechanical attenuation \citep{kochin1982vertical,
60+clapp1990new}; a rank-based instrumental-variables estimator in the spirit
61+of \citet{clapp1990new} that purges this bias still yields $-0.08$
62+(s.e.\ $0.02$). Under the IAAO ratio-study standards used by assessment
63+authorities across North America \citep{iaao2013standard}, 95\% of Quebec
64+municipalities fall outside the acceptable band for price-related bias and
65+99\% exceed the uniformity (COD) ceiling. Strikingly, Montr\'eal --- the
66+province's largest and best-resourced assessment jurisdiction --- is the
67+\emph{only} major market whose assessments are progressive.
68+
69+Second, the inequity has a clear anatomy. Regressivity is roughly four times
70+stronger for single-family homes and plexes than for condominiums, whose
71+quasi-homogeneous units are easy to mass-appraise; it rises steeply with
72+building age and with the assessed land share of the property, consistent
73+with the difficulty of valuing land and depreciated structures
74+\citep{bostic2007land, gloudemans2011fundamentals}; and it is worse in small
75+municipalities, echoing the thin-market mechanism of \citet{mcmillen2008thin}.
76+The quantile profile is equally telling: the log-log slope
77+falls from $0.87$ at the first decile of the conditional value distribution
78+to $0.49$ at the ninth, so the failure of proportionality is concentrated at
79+the top --- expensive homes are not merely under-assessed, they are
80+under-assessed at an accelerating rate.
81+
82+Third, the stakes are material. Within a taxing jurisdiction the levy is
83+proportional to assessed value, so a property assessed above the
84+jurisdiction median ratio pays exactly that percentage more tax than uniform
85+assessment would imply. The median dwelling in the bottom within-market
86+price decile pays roughly 65\% more property tax than under uniform
87+assessment; the median dwelling in the top decile pays about 5\% less. Put
88+differently, the effective tax schedule that Quebec's assessment machinery
89+delivers is regressive enough to undo, within each municipality, a
90+substantial share of whatever progressivity the rest of the fiscal system
91+achieves --- a finding that complements \citet{carbonnier2024property}, who
92+documents the regressivity of Quebec's property tax relative to income using
93+survey data, but who could not observe the assessment channel isolated here.
94+
95+We also show \emph{when} the gap opens. Assessment ratios drift mechanically
96+between reference dates, and the province-wide price-related bias doubles in
97+2021--2022, precisely when the post-pandemic boom pulled market prices away
98+from rolls anchored in 2018--2020 conditions --- direct evidence that
99+infrequent revaluation is a first-order driver of measured inequity, as
100+conjectured in the U.S. literature \citep{berry2021reassessing,
101+ross2012assessor}. Because the diagnosis singles out stale rolls, thin
102+markets, and hard-to-value property types --- rather than assessor
103+discretion alone --- it maps directly into policy: shorter revaluation
104+cycles, pooled assessment services for small municipalities, and targeted
105+review of high-land-share and older properties.
106+
107+Beyond the Canadian evidence gap, the paper makes two methodological
108+contributions to the ratio-study literature. It is, to our knowledge, the
109+first large-scale equity study to exploit a statutory single-date reference
110+regime to separate timing drift from genuine vertical inequity by design
111+rather than by econometric correction; and it implements the full modern
112+test battery --- IAAO diagnostics with bootstrap inference
113+\citep{iaao2013standard}, the classical regression tests
114+\citep{paglin1972equity, cheng1974property}, measurement-error-robust IV
115+\citep{clapp1990new}, and quantile profiles \citep{mcmillen2020assessment}
116+--- on more than half a million sales, allowing precise subgroup estimates
117+that smaller samples cannot support.
118+
119+The remainder of the paper proceeds as follows. Section~\ref{sec:lit}
120+positions the paper in the ratio-study and property-tax-equity literatures.
121+Section~\ref{sec:inst} describes Quebec's assessment institutions.
122+Section~\ref{sec:data} presents the data and the matched sample.
123+Section~\ref{sec:method} lays out the diagnostics and econometric
124+specifications. Section~\ref{sec:results} reports the results,
125+Section~\ref{sec:robust} the robustness battery, Section~\ref{sec:disc}
126+discusses mechanisms and policy implications, and Section~\ref{sec:concl}
127+concludes.
added paper/sections/literature.tex +100 −0
@@ -0,0 +1,100 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Related literature}
4+\label{sec:lit}
5+
6+\subsection{Measuring vertical inequity: from ratio studies to modern tests}
7+
8+The empirical study of assessment equity is nearly as old as the modern
9+property tax. Its workhorse is the \emph{sales ratio study}: for each sale
10+$i$, compute the ratio $r_i = AV_i/SP_i$ of assessed value to sale price and
11+ask whether $r_i$ is uniform across the value distribution. The
12+practitioner's canon --- codified in the IAAO \textit{Standard on Ratio
13+Studies} \citep{iaao2013standard} and the mass-appraisal manuals of
14+\citet{eckert1990property} and \citet{gloudemans2011fundamentals} ---
15+summarizes a jurisdiction with the median ratio, the coefficient of
16+dispersion (COD) for horizontal uniformity, and the price-related
17+differential (PRD) or, since 2013, the coefficient of price-related bias
18+(PRB) for vertical equity. These diagnostics discipline assessment practice
19+across North America, and we report all of them, with bootstrap confidence
20+intervals, for every Quebec municipality with sufficient sales.
21+
22+The academic literature grew out of dissatisfaction with those summary
23+measures. \citet{paglin1972equity} proposed regressing assessed value on
24+sale price in levels and reading regressivity off a positive intercept;
25+\citet{cheng1974property} moved the test to logarithms, where the slope
26+$\beta$ has the clean interpretation of an elasticity and $\beta<1$ signals
27+regressivity. Subsequent work refined the specification menu ---
28+\citet{bell1984administrative} and \citet{sunderman1990testing} allowed
29+splines and structural breaks, and \citet{sirmans1995vertical} surveyed the
30+proliferating test battery. A decisive critique came from
31+\citet{kochin1982vertical} and \citet{kennedy1984unfair}: because the sale
32+price is itself a noisy measure of market value, the log-log slope is
33+attenuated toward zero even under perfectly proportional assessment ---
34+ratio studies are biased \emph{toward} finding regressivity.
35+\citet{clapp1990new} answered with a rank-based instrumental-variables
36+estimator whose coarse instrument is nearly orthogonal to transitory price
37+noise; \citet{goolsby1997assessment} and \citet{mcmillen2020assessment}
38+document how severely the correction can matter, and
39+\citet{quintos2020prb} extends the toolkit with machine-learning
40+diagnostics. Our design follows this lesson to the letter: we report the
41+na\"ive estimates, the Clapp IV, and quantile profiles
42+\citep{mcmillen2020assessment} side by side, and treat the IV as the lower
43+bound on true regressivity.
44+
45+\subsection{Evidence: pervasive regressivity, mostly documented in the U.S.}
46+
47+Substantively, the modern consensus is that assessment regressivity is the
48+rule rather than the exception. \citet{berry2021reassessing} computes ratio
49+statistics for essentially every U.S. county and finds that the
50+lowest-decile home within a jurisdiction faces an effective tax rate roughly
51+double that of the top decile; \citet{avenancio2022assessment} show in the
52+\textit{QJE} that the same machinery generates a 10--13\% assessment gap
53+against Black and Hispanic homeowners, driven partly by unpriced
54+neighbourhood attributes and partly by differential appeals;
55+\citet{amornsiripanitch2022residential} attributes much of the gradient to
56+assessors' inability to capture unobserved quality that scales with price.
57+Earlier strands documented the same pattern city by city ---
58+\citet{engle1975deassessment} in Boston, \citet{baar1981property} across
59+low-income neighbourhoods, \citet{mcmillen2011assessment} and
60+\citet{mcmillen2008thin} in Illinois, where thin markets and infrequent
61+reassessment emerge as structural drivers, \citet{hodge2017assessment} in
62+collapsing Detroit, and \citet{plummer2014evidence} and
63+\citet{ihlanfeldt2023appraisal} on the role of appeals and exemptions.
64+\citet{ross2012assessor} shows regressivity responds to the institutional
65+incentives of assessors themselves. The mechanism menu that emerges ---
66+stale valuations, hard-to-value heterogeneous properties, thin markets,
67+asymmetric appeals --- guides our heterogeneity analysis directly.
68+
69+\subsection{Canada: a conspicuous gap}
70+
71+For Canada the cupboard is nearly bare. Ontario's assessment corporation
72+publishes an internal vertical-equity review of its own values
73+\citep{mpac2022vertical}, and \citet{found2017property} compare tax
74+\emph{rates} across cities, but neither measures sale-level equity.
75+For Quebec, \citet{carbonnier2024property} establishes with survey microdata
76+that property tax payments are steeply regressive relative to
77+\emph{income}; the assessment channel --- whether the tax base itself is
78+mismeasured against \emph{market value} --- has never been examined at
79+scale, for Quebec or for any Canadian province. This is the gap the present
80+paper fills. The Canadian case is of independent interest precisely because
81+its institutions differ from the fragmented U.S. county system on the
82+dimensions the U.S. literature blames: Quebec assessment is governed by a
83+uniform provincial statute and manual \citep{lfm2026, mamh2024manuel},
84+performed by certified professional evaluators, and synchronized to a
85+statutory market-condition date. Finding large regressivity \emph{despite}
86+this apparatus sharpens the interpretation considerably: the problem is not
87+lax governance but the intrinsic difficulty of mass appraisal, compounded by
88+triennial staleness.
89+
90+\subsection{Hedonic foundations}
91+
92+Finally, the paper connects to the hedonic tradition. Mass appraisal is
93+applied hedonics \citep{rosen1974hedonic, malpezzi2003hedonic}: the
94+assessor's model prices a bundle of structural and locational attributes,
95+and equity failures are hedonic specification failures --- unpriced
96+land-share gradients \citep{bostic2007land}, depreciation curvature, or
97+neighbourhood effects that Quebec practitioners have long modelled in the
98+Quebec City market \citep{desrosiers2000hedonic}. Reading our subgroup
99+estimates through this lens turns an audit into a diagnosis: the assessment
100+gap is largest exactly where the hedonic problem is hardest.
added paper/sections/methodology.tex +108 −0
@@ -0,0 +1,108 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Methodology}
4+\label{sec:method}
5+
6+Let $AV_i$ denote the assessed value of property $i$ on the roll in force
7+at its sale, $SP_i$ its sale price, and $r_i = AV_i/SP_i$ the assessment
8+ratio. Under perfectly proportional (vertically equitable) assessment,
9+$\mathbb{E}[r_i \mid SP_i]$ is constant. All tests below ask, in different
10+ways, whether $r_i$ instead declines with value.
11+
12+\subsection{IAAO ratio-study diagnostics}
13+
14+For any group of sales we report the four statistics of the IAAO
15+\textit{Standard on Ratio Studies} \citep{iaao2013standard}. The
16+\emph{median ratio} measures the assessment level. The \emph{coefficient of
17+dispersion},
18+$\mathrm{COD} = 100 \cdot \operatorname{mean}\!\left(|r_i -
19+\tilde r|\right) / \tilde r$ with $\tilde r$ the median ratio, measures
20+horizontal uniformity; the standard deems residential CODs above 15
21+unacceptable. The \emph{price-related differential},
22+$\mathrm{PRD} = \bar r \,/\, (\sum_i AV_i / \sum_i SP_i)$, compares the
23+unweighted and value-weighted mean ratios; PRD~$>1.03$ indicates
24+regressivity. Because the PRD is sensitive to outliers, the standard's
25+preferred vertical measure is the \emph{coefficient of price-related bias}
26+(PRB), the slope $b$ in
27+\begin{equation}
28+\frac{r_i - \tilde r}{\tilde r}
29+ \;=\; a + b \cdot \log_2\!\Big(\tfrac{1}{2}SP_i +
30+ \tfrac{1}{2}\,AV_i/\tilde r\Big) + u_i ,
31+\label{eq:prb}
32+\end{equation}
33+which measures the proportional change in the ratio per doubling of value;
34+the acceptable band is $[-0.05, 0.05]$. We compute percentile-bootstrap
35+confidence intervals for all four statistics. Because ratio levels drift
36+with roll staleness, municipality-level diagnostics are computed within
37+municipality~$\times$~sale-year blocks --- inside which a single roll is in
38+force --- and aggregated across years by the median.
39+
40+\subsection{Regression tests with market-timing fixed effects}
41+
42+Our workhorse is the log-log specification of \citet{cheng1974property},
43+\begin{equation}
44+\ln AV_i \;=\; \alpha_{c(i)} + \beta \, \ln SP_i + \varepsilon_i ,
45+\label{eq:cheng}
46+\end{equation}
47+where $c(i)$ indexes the municipality~$\times$~roll~$\times$~sale-year cell
48+of sale $i$ and the $\alpha_c$ are 2{,}884 absorbed fixed effects.
49+Proportionality implies $\beta = 1$; we report
50+$\gamma \equiv \beta - 1$, the elasticity of the assessment \emph{ratio}
51+with respect to price ($\gamma<0$: regressive). The fixed effects guarantee
52+that $\gamma$ is identified only from comparisons of dwellings assessed by
53+the same authority, on the same roll vintage, and sold in the same year ---
54+purging the staleness drift, municipal composition, and aggregate market
55+movements in one stroke. For completeness we also report the pooled
56+regression without fixed effects and the levels test of
57+\citet{paglin1972equity}. Inference is clustered at the municipality level
58+throughout (625 clusters).
59+
60+\subsection{Measurement error and the Clapp instrument}
61+
62+Sale prices measure market value with idiosyncratic noise --- bilateral
63+bargaining, unobserved conditions of sale --- so OLS on
64+equation~\eqref{eq:cheng} suffers attenuation bias: $\hat\beta < 1$ even
65+under proportional assessment \citep{kochin1982vertical, kennedy1984unfair}.
66+Following \citet{clapp1990new}, we instrument $\ln SP_i$ with a coarse rank
67+variable $Z_i \in \{-1, 0, +1\}$ that flags sales in the bottom or top
68+third of \emph{both} the within-cell $\ln AV$ and $\ln SP$ distributions.
69+Because $Z_i$ retains only ordinal information agreed on by both measures of
70+value, it is (nearly) orthogonal to the transitory component of either, and
71+the resulting two-stage least-squares estimate of $\beta$ is consistent
72+under classical measurement error. The demeaned-within-cell implementation
73+preserves the fixed-effects structure. Throughout the paper we treat
74+$\gamma_{\text{IV}}$ as a conservative lower bound on regressivity and
75+$\gamma_{\text{FE}}$ as the descriptive upper bound; the truth lies between.
76+
77+\subsection{Quantile profile and heterogeneity}
78+
79+Averaging can hide where proportionality fails. We estimate
80+equation~\eqref{eq:cheng} by quantile regression on within-cell demeaned
81+variables at $\tau \in \{0.10, 0.25, 0.50, 0.75, 0.90\}$
82+\citep{mcmillen2020assessment}, tracing $\beta(\tau)$ across the
83+conditional distribution of assessed values. We then re-estimate the
84+fixed-effects model on subsamples --- property class, building-age bands,
85+assessed-land-share bands, roll-lag bands, municipality size, and sale year
86+--- keeping only cells that retain at least 20 sales, to locate the
87+inequity where the mass-appraisal problem is hardest.
88+
89+\subsection{Horizontal inequity}
90+
91+Vertical tests concern the \emph{mean} of $r_i$ given value; horizontal
92+equity concerns its \emph{dispersion} among comparable properties
93+\citep{allen2002measuring, sirmans2008vertical}. Beyond the COD, we ask
94+who receives noisy assessments: we regress the absolute deviation of a
95+sale's log ratio from its cell median,
96+$|\ln r_i - \operatorname{med}_{c(i)} \ln r|$, on property characteristics
97+(age, land share, property-class indicators) with cell fixed effects.
98+
99+\subsection{The implied tax shift}
100+
101+Because Quebec taxes the rolled value without exemptions or caps
102+(Section~\ref{sec:inst}), a property whose ratio exceeds its jurisdiction's
103+median by $x\%$ pays exactly $x\%$ more tax than uniform assessment would
104+imply. For each sale we compute the relative assessment error
105+$e_i = r_i / \operatorname{med}_{c(i)}(r) - 1$ and average it by
106+within-cell sale-price decile. This translates the econometrics into the
107+policy-relevant object: the percentage over- or under-payment of property
108+tax by position in the local price distribution.
added paper/sections/results.tex +298 −0
@@ -0,0 +1,298 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Results}
4+\label{sec:results}
5+
6+\subsection{Province-wide diagnostics}
7+
8+Table~\ref{tab:iaao} reports the IAAO statistics for the full sample and by
9+sale year. Three facts stand out. First, uniformity is poor everywhere: the
10+province-wide COD is 28.5, and even within municipality-year blocks the
11+median municipality posts a COD of 26 --- nearly twice the IAAO ceiling of
12+15 for residential property; 99\% of municipalities exceed it
13+(Figure~\ref{fig:cod}). Second, every vertical measure points the same way:
14+the PRD is 1.072 (acceptable range 0.98--1.03) and the PRB is $-0.029$ with
15+a bootstrap confidence interval far from zero --- ratios fall by about 3\%
16+of the median with every doubling of value. Third, the time pattern is
17+diagnostic: the PRB collapses to $-0.104$ and $-0.117$ in 2021 and 2022 ---
18+when the boom pulled prices away from rolls referenced on 2018--2020
19+markets --- and shrinks to $-0.002$ in 2024 before widening again. Vertical
20+inequity is not a fixed institutional constant; it breathes with the gap
21+between the market and the roll's vintage.
22+
23+\begin{table}[t]
24+\centering
25+\begin{threeparttable}
26+\caption{IAAO ratio-study diagnostics, province-wide}
27+\label{tab:iaao}
28+\small
29+\input{../results/tables/iaao}
30+\begin{tablenotes}[flushleft]\footnotesize
31+\item \textit{Notes:} Median assessment ratio, coefficient of dispersion
32+(COD), price-related differential (PRD) and coefficient of price-related
33+bias (PRB), computed on the estimation sample; 95\% percentile-bootstrap
34+confidence intervals (200 replications) in brackets. IAAO acceptable
35+ranges: COD $\le 15$ (single-family residential), PRD $\in [0.98, 1.03]$,
36+PRB $\in [-0.05, 0.05]$.
37+\end{tablenotes}
38+\end{threeparttable}
39+\end{table}
40+
41+\begin{figure}[t]
42+\centering
43+\includegraphics[width=\textwidth]{fig_cod.png}
44+\caption{Horizontal uniformity. Panel A: distribution of municipality-level
45+CODs (median across sale-year blocks); the IAAO ceiling for residential
46+property is 15. Panel B: COD against the number of sales in the
47+municipality; dispersion is worst in the thinnest markets.}
48+\label{fig:cod}
49+\end{figure}
50+
51+Table~\ref{tab:cities} disaggregates the ten largest markets.
52+Regressivity is significant in nine of them --- Sherbrooke ($-0.39$),
53+Trois-Rivi\`eres ($-0.32$) and Terrebonne ($-0.29$) are the worst --- in
54+every single sale year. The exception is Montr\'eal, whose PRB of $+0.07$
55+makes it the lone \emph{progressive} large jurisdiction in the province, in
56+all six years. We return to this contrast in Section~\ref{sec:disc}.
57+Figure~\ref{fig:map} maps the municipal estimates: regressive (red)
58+municipalities carpet the province, with the Montr\'eal agglomeration a
59+solitary blue island, and Figure~\ref{fig:prbmuni} shows the distribution:
60+99\% of the 261 municipalities with at least 100 usable sales have a
61+negative PRB, and 95\% fall below the IAAO band.
62+
63+\begin{table}[t]
64+\centering
65+\begin{threeparttable}
66+\caption{Assessment equity in the ten largest markets}
67+\label{tab:cities}
68+\small
69+\input{../results/tables/iaao_cities}
70+\begin{tablenotes}[flushleft]\footnotesize
71+\item \textit{Notes:} Statistics computed within municipality
72+$\times$ sale-year blocks (a single roll in force per block) and
73+aggregated across years by the median; $n$ is the total number of sales.
74+The last column is the share of the six sale years in which the
75+municipality's PRB is negative.
76+\end{tablenotes}
77+\end{threeparttable}
78+\end{table}
79+
80+\begin{figure}[t]
81+\centering
82+\includegraphics[width=0.72\textwidth]{fig_prb_muni.png}
83+\caption{Distribution of municipality-level PRB across the 261
84+municipalities with at least 100 usable sales. The shaded band is the IAAO
85+acceptable range $[-0.05, 0.05]$.}
86+\label{fig:prbmuni}
87+\end{figure}
88+
89+\begin{figure}[t]
90+\centering
91+\includegraphics[width=\textwidth]{fig_map.png}
92+\caption{The geography of vertical inequity. Municipality-level PRB
93+(median across sale-year blocks); bubble area is proportional to the
94+square root of the number of sales. Montr\'eal is the only large
95+progressive jurisdiction.}
96+\label{fig:map}
97+\end{figure}
98+
99+\subsection{Regression estimates}
100+
101+Table~\ref{tab:vertical} presents the econometric core.
102+Figure~\ref{fig:binscatter} shows the raw material: within
103+municipality~$\times$~roll~$\times$~year cells, mean log assessment ratios
104+fall monotonically --- and remarkably linearly through the central 90\% of
105+the distribution --- as log prices rise. The pooled \citet{cheng1974property}
106+elasticity is $\beta = 0.812$ (s.e.\ 0.029); absorbing the 2{,}884 cell
107+fixed effects \emph{steepens} it to $\beta = 0.656$, i.e.\
108+$\gamma_{\text{FE}} = -0.344$: within the same market and vintage, a home
109+priced 10\% above its neighbours is assessed only about 6.6\% higher, so
110+its ratio is 3.4\% lower. The levels test of \citet{paglin1972equity}
111+agrees: the intercept is \$31{,}729 (s.e.\ \$5{,}378) --- inexpensive
112+properties carry a fixed assessment premium.
113+
114+\begin{table}[t]
115+\centering
116+\begin{threeparttable}
117+\caption{Vertical-inequity regressions}
118+\label{tab:vertical}
119+\small
120+\input{../results/tables/vertical}
121+\begin{tablenotes}[flushleft]\footnotesize
122+\item \textit{Notes:} Dependent variable: $\ln AV$. Column (1): pooled
123+OLS. Column (2): absorbing least squares with 2{,}884 municipality
124+$\times$ roll $\times$ sale-year fixed effects. Column (3): within-cell
125+2SLS using the \citet{clapp1990new} rank instrument
126+$Z \in \{-1,0,+1\}$. Standard errors clustered by municipality (625
127+clusters) in parentheses. Stars test $H_0\!: \beta = 1$ (proportional
128+assessment); *** $p<0.01$.
129+\end{tablenotes}
130+\end{threeparttable}
131+\end{table}
132+
133+\begin{figure}[t]
134+\centering
135+\includegraphics[width=0.72\textwidth]{fig_binscatter.png}
136+\caption{Vertical inequity within markets. Mean of the within-cell demeaned
137+log assessment ratio by vigintile of the within-cell demeaned log sale
138+price; whiskers are 95\% confidence intervals (they are smaller than the
139+markers). The fitted line is the sale-weighted least-squares slope.}
140+\label{fig:binscatter}
141+\end{figure}
142+
143+How much of this is measurement-error artefact? The \citet{clapp1990new}
144+instrument delivers $\beta_{\text{IV}} = 0.917$ (s.e.\ 0.021):
145+attenuation indeed accounts for the majority of the na\"ive gap, exactly
146+as the critique of \citet{kochin1982vertical} predicts --- but the
147+corrected elasticity remains four standard errors below one. Genuine
148+regressivity of roughly $-0.08$ survives the harshest correction in the
149+literature. An 8\% ratio penalty per log-point of value is economically
150+large: across the interquartile price range within a typical market
151+(roughly 0.8 log points), it generates a 6--7\% differential in effective
152+tax rates from vertical inequity alone, before adding the much larger
153+dispersion documented below.
154+
155+\subsection{Where proportionality fails: the quantile profile}
156+
157+Figure~\ref{fig:quantile} plots $\beta(\tau)$. The profile is strikingly
158+asymmetric: $\beta$ is 0.87 at $\tau=0.10$ and 0.86 at $\tau=0.25$, then
159+falls to 0.70 at $\tau=0.75$ and 0.49 at $\tau=0.90$
160+(Table~\ref{tab:quantile} in the robustness section reports the
161+coefficients). Assessment tracks the market tolerably well through the
162+middle of the distribution and loses contact at the top: the most expensive
163+homes within each market are under-assessed at an accelerating rate. This
164+is the signature of hedonic truncation --- mass-appraisal models built on
165+mid-market comparables extrapolate poorly into thin luxury segments
166+\citep{amornsiripanitch2022residential, gloudemans2011fundamentals}.
167+
168+\begin{figure}[t]
169+\centering
170+\includegraphics[width=0.72\textwidth]{fig_quantile.png}
171+\caption{Quantile profile of the Cheng elasticity. Quantile regressions of
172+within-cell demeaned $\ln AV$ on demeaned $\ln SP$; the shaded band is the
173+pointwise 95\% confidence interval. Reference lines mark proportionality
174+($\beta=1$) and the FE and IV point estimates.}
175+\label{fig:quantile}
176+\end{figure}
177+
178+\subsection{The anatomy of regressivity}
179+
180+Figure~\ref{fig:het} and Table~\ref{tab:het} dissect
181+$\gamma_{\text{FE}}$ by subsample, and the pattern reads like a map of
182+mass-appraisal difficulty:
183+
184+\begin{itemize}
185+\item \textbf{Property class.} Condominiums --- homogeneous units with
186+abundant comparables --- show $\gamma = -0.12$, single-family homes
187+$-0.45$ and plexes $-0.49$. The assessor's problem, not the assessor's
188+jurisdiction, drives the gradient.
189+\item \textbf{Building age.} Regressivity deepens from $-0.30$ (under 20
190+years) to $-0.43$ (over 60): depreciation and renovation heterogeneity are
191+hard to observe from the roll.
192+\item \textbf{Land share.} The starkest gradient: $-0.26$ where land is
193+under 20\% of assessed value versus $-0.50$ where it exceeds 40\%.
194+Land is the component without construction-cost anchoring
195+\citep{bostic2007land}, and properties whose value is mostly land are
196+assessed worst.
197+\item \textbf{Municipality size.} Large markets ($>$10k sales) do
198+better ($-0.27$) than mid-sized ones ($-0.41$), consistent with
199+\citet{mcmillen2008thin}.
200+\item \textbf{Stability.} Estimates are remarkably stable across sale
201+years ($-0.32$ to $-0.38$) and roll lags --- unlike the \emph{level}
202+statistics, the within-market elasticity is a structural feature of the
203+valuation technology, not of the cycle.
204+\end{itemize}
205+
206+\begin{figure}[p]
207+\centering
208+\includegraphics[width=0.85\textwidth]{fig_heterogeneity.png}
209+\caption{Heterogeneity of the within-market elasticity
210+$\gamma$. Each point is a separate fixed-effects estimate of
211+equation~\eqref{eq:cheng} on the indicated subsample; whiskers are 95\%
212+confidence intervals with municipality-clustered standard errors.}
213+\label{fig:het}
214+\end{figure}
215+
216+\begin{table}[p]
217+\centering
218+\begin{threeparttable}
219+\caption{Heterogeneity of vertical inequity}
220+\label{tab:het}
221+\small
222+\input{../results/tables/heterogeneity}
223+\begin{tablenotes}[flushleft]\footnotesize
224+\item \textit{Notes:} Cheng fixed-effects estimates of
225+$\gamma = \beta - 1$ by subsample; cells with fewer than 20 sales after
226+masking are dropped. Municipality-clustered standard errors in
227+parentheses. *, **, *** denote significance at 10\%, 5\%, 1\%.
228+\end{tablenotes}
229+\end{threeparttable}
230+\end{table}
231+
232+\subsection{Horizontal inequity}
233+
234+Table~\ref{tab:horizontal} reports the dispersion regression. The mean
235+absolute deviation of a sale's log ratio from its cell median is 0.188 ---
236+the typical Quebec dwelling is assessed 19\% away from its market's norm,
237+an enormous horizontal lottery consistent with the COD evidence. The
238+deviation rises by 0.75~log points per decade of building age and by
239+5.2~points for cottages, and falls by 5.5~points for condominiums.
240+Old, idiosyncratic, land-heavy properties receive not only biased but
241+\emph{noisy} valuations.
242+
243+\begin{table}[t]
244+\centering
245+\begin{threeparttable}
246+\caption{Horizontal inequity: who receives noisy assessments?}
247+\label{tab:horizontal}
248+\small
249+\input{../results/tables/horizontal}
250+\begin{tablenotes}[flushleft]\footnotesize
251+\item \textit{Notes:} Dependent variable:
252+$|\ln r_i - \operatorname{med}_{c(i)} \ln r|$. Absorbing least squares
253+with cell fixed effects; municipality-clustered standard errors.
254+Omitted class: single-family. *, **, *** denote significance at 10\%,
255+5\%, 1\%.
256+\end{tablenotes}
257+\end{threeparttable}
258+\end{table}
259+
260+\subsection{The implied tax shift}
261+
262+Figure~\ref{fig:taxshift} and Table~\ref{tab:taxshift} translate the
263+estimates into tax dollars. Within the median market, the median dwelling
264+in the bottom price decile is assessed 65\% above the local norm --- and
265+therefore pays 65\% more property tax than uniform assessment would imply.
266+The overpayment falls to $+7\%$ in the second decile and crosses zero at
267+the median; the top decile underpays by about 5\%. The redistribution is
268+strongly convex: the burden of assessment error is overwhelmingly
269+concentrated on the cheapest tenth of the housing stock --- precisely the
270+segment where owners have the least capacity to appeal and the highest
271+housing-cost burdens. The magnitudes rival those documented for the U.S.
272+by \citet{berry2021reassessing}, in a system with none of the U.S.'s
273+institutional fragmentation.
274+
275+\begin{figure}[t]
276+\centering
277+\includegraphics[width=0.85\textwidth]{fig_taxshift.png}
278+\caption{The implied property-tax shift. Median relative assessment error
279+$e_i = r_i/\operatorname{med}_{c(i)}(r) - 1$ by within-market sale-price
280+decile. Because the levy is proportional to the rolled value, $e_i$ equals
281+the percentage over- or under-payment relative to uniform assessment.}
282+\label{fig:taxshift}
283+\end{figure}
284+
285+\begin{table}[t]
286+\centering
287+\begin{threeparttable}
288+\caption{Excess tax burden by within-market price decile}
289+\label{tab:taxshift}
290+\small
291+\input{../results/tables/taxshift}
292+\begin{tablenotes}[flushleft]\footnotesize
293+\item \textit{Notes:} Relative assessment error by within-cell sale-price
294+decile. The mean is sensitive to extreme low-price sales; the median is
295+the preferred summary. Standard errors of the mean in parentheses.
296+\end{tablenotes}
297+\end{threeparttable}
298+\end{table}
added paper/sections/robustness.tex +86 −0
@@ -0,0 +1,86 @@
1+% Author: Simon-Pierre Boucher — contact@spboucher.ai
2+% ============================================================================
3+\section{Robustness}
4+\label{sec:robust}
5+
6+Table~\ref{tab:robust} and Figure~\ref{fig:robust} re-estimate the two
7+headline elasticities --- $\gamma_{\text{FE}}$ and the Clapp
8+$\gamma_{\text{IV}}$ --- across ten sample and measurement variants;
9+Table~\ref{tab:quantile} reports the quantile coefficients underlying
10+Figure~\ref{fig:quantile}.
11+
12+\begin{table}[t]
13+\centering
14+\begin{threeparttable}
15+\caption{Robustness of the vertical-inequity elasticities}
16+\label{tab:robust}
17+\small
18+\input{../results/tables/robustness}
19+\begin{tablenotes}[flushleft]\footnotesize
20+\item \textit{Notes:} Each row re-estimates the Cheng fixed-effects and
21+Clapp IV regressions on the indicated variant; cells with fewer than 20
22+sales after restriction are dropped. Municipality-clustered standard
23+errors in parentheses. *, **, *** denote significance at 10\%, 5\%, 1\%
24+against $\gamma = 0$.
25+\end{tablenotes}
26+\end{threeparttable}
27+\end{table}
28+
29+\begin{figure}[t]
30+\centering
31+\includegraphics[width=0.85\textwidth]{fig_robustness.png}
32+\caption{Stability of $\gamma$ across sample variants. Filled circles:
33+fixed-effects estimates; open squares: Clapp IV. Whiskers are 95\%
34+confidence intervals, municipality-clustered.}
35+\label{fig:robust}
36+\end{figure}
37+
38+\begin{table}[t]
39+\centering
40+\begin{threeparttable}
41+\caption{Quantile-regression coefficients}
42+\label{tab:quantile}
43+\small
44+\input{../results/tables/quantile}
45+\begin{tablenotes}[flushleft]\footnotesize
46+\item \textit{Notes:} Quantile regressions of within-cell demeaned
47+$\ln AV$ on demeaned $\ln SP$, estimated on a seeded 250{,}000-sale
48+subsample. Stars test $H_0\!:\beta(\tau)=1$.
49+\end{tablenotes}
50+\end{threeparttable}
51+\end{table}
52+
53+\paragraph{Match quality.} Requiring the maximum matcher score of 220
54+(210{,}820 sales) or a match distance under 10~metres changes
55+$\gamma_{\text{FE}}$ to $-0.41$ and $-0.36$ respectively, and
56+$\gamma_{\text{IV}}$ to $-0.10$ and $-0.09$: if anything, the cleanest
57+matches show \emph{more} regressivity, ruling out mismatch noise as the
58+source.
59+
60+\paragraph{Extreme sales.} Dropping all sales under \$100{,}000 --- the
61+segment most likely to harbour residual non-arm's-length transfers ---
62+attenuates the FE estimate modestly ($-0.30$) and the IV to $-0.05$, both
63+still overwhelmingly significant. Tightening the ratio trim to the 5th--95th
64+percentiles, which mechanically compresses dispersion, cuts
65+$\gamma_{\text{FE}}$ to $-0.15$ and $\gamma_{\text{IV}}$ to $-0.04$; the
66+qualitative conclusion is unchanged, and the ordering
67+$|\gamma_{\text{IV}}| < |\gamma_{\text{FE}}|$ is preserved in every
68+variant, as the measurement-error logic requires.
69+
70+\paragraph{Composition.} Single-family homes alone give
71+$\gamma_{\text{FE}} = -0.45$; condominiums alone $-0.12$ (with an IV
72+estimate indistinguishable from zero), confirming that the aggregate
73+result is not an artefact of pooling heterogeneous property classes ---
74+each class is regressive or neutral on its own, none is progressive.
75+
76+\paragraph{Time and market depth.} Splitting the window into 2021--2023
77+and 2024--2026 yields $-0.35$ and $-0.34$ (FE); restricting to
78+municipalities with at least 300 sales, or to cells with at least 50,
79+moves the estimates by less than 0.005. The phenomenon is stable across
80+the largest housing-cycle swing in recent Canadian history.
81+
82+\paragraph{Inference.} With 625 municipality clusters, the
83+cluster-robust $t$-statistics on $\gamma_{\text{FE}}$ exceed 11 in every
84+variant; the IV first stage is enormous (the rank instrument correlates
85+with within-cell log price at $F$ far above conventional thresholds), so
86+weak-instrument concerns do not arise.
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 +8 −0
@@ -0,0 +1,8 @@
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+statsmodels==0.14.6
7+linearmodels==7.0
8+matplotlib==3.10.9
added results/reproduced/binscatter.csv +21 −0
@@ -0,0 +1,21 @@
1+bin,x,y,se,n
2+0,-1.1299388141576843,0.7401279154992733,0.003196992348982775,26139
3+1,-0.6850118092956418,0.2574425293076139,0.0025583543636468675,26138
4+2,-0.5073601868074681,0.10436219388765414,0.0020528587799829565,26153
5+3,-0.39073743914550024,0.047860490092793556,0.001736111026990563,26124
6+4,-0.30065659167407555,0.016570011132268175,0.0015548062368614822,26139
7+5,-0.22425213523591567,-0.002513336220497968,0.0014291012118165137,26156
8+6,-0.15838142150231835,-0.020241310853057217,0.0013378404347182862,26124
9+7,-0.09992669391808244,-0.03963672880796096,0.0012165744895958942,26148
10+8,-0.04710053458833093,-0.053894099650792227,0.001160689913664137,26200
11+9,0.002122479477476558,-0.06362384340772381,0.0011248344303339988,26066
12+10,0.049307323000753994,-0.07312651316133213,0.0010811167525577004,26137
13+11,0.09626979736571095,-0.08352363160551252,0.001065184297317105,26142
14+12,0.14419727346672664,-0.08918200392095645,0.0010663579136722928,26136
15+13,0.19538855584502757,-0.09595623188857955,0.0010518993779298034,26136
16+14,0.2517919431123114,-0.09939639689803642,0.0010652961471458247,26160
17+15,0.31579361306351245,-0.10044775245396857,0.0010607621841663896,26122
18+16,0.3909496467065449,-0.1001064460067215,0.0010745180988978066,26136
19+17,0.48726172323242867,-0.10229496211595199,0.0010849459604205768,26136
20+18,0.6279334261827585,-0.10814069396200333,0.0010932289509538405,26138
21+19,0.9827359705440116,-0.13437611742305566,0.0011936815957342471,26139
added results/reproduced/heterogeneity.csv +23 −0
@@ -0,0 +1,23 @@
1+group,gamma,se,n
2+Single-family,-0.4537825487653664,0.017669914962190263,337855
3+Condominium,-0.12464520941899382,0.01518233161943941,77976
4+Plex (2–5 units),-0.48721753704516557,0.054393819641049175,69745
5+Cottage,-0.28835281011784275,0.026012993411055806,2114
6+Age < 20 y,-0.29681001316776945,0.04138996753677022,117898
7+Age 20–60 y,-0.3838945768600194,0.03428322120554528,227857
8+Age > 60 y,-0.4348917536559145,0.027878597780328678,108049
9+Land share < 0.2,-0.26159665555927447,0.04182768275315659,119718
10+Land share 0.2–0.4,-0.37212541838852264,0.03209301087096023,236923
11+Land share > 0.4,-0.5032586691495551,0.030343186599237057,125714
12+Roll lag < 24 m,-0.33521036537225046,0.027518136511668685,86001
13+Roll lag 24–48 m,-0.3383010361647635,0.03253068680640818,327031
14+Roll lag > 48 m,-0.36120558524361845,0.026496805098668315,91367
15+Muni < 1k sales,-0.37853163229754105,0.007225289628016334,125139
16+Muni 1k–10k sales,-0.40556881854947346,0.013683713072741294,185111
17+Muni > 10k sales,-0.26678422681660763,0.04292003298652767,212519
18+Sales 2021,-0.31582561216354454,0.02741379492805952,115221
19+Sales 2022,-0.37986236880270274,0.03017363968613998,90627
20+Sales 2023,-0.365252633545463,0.03376287524528814,74110
21+Sales 2024,-0.33272608196080145,0.03235286456948262,89204
22+Sales 2025,-0.33508012882403526,0.026198875018876387,102026
23+Sales 2026,-0.3494522195906299,0.029905254010043902,51581
added results/reproduced/horizontal.csv +6 −0
@@ -0,0 +1,6 @@
1+,coef,se,tstat
2+age_dec,0.007468884136129831,0.0012336871606707124,6.0541151551494305
3+land_share,0.0030967138766707955,0.02979602772694477,0.10393042673505146
4+is_condo,-0.055121433754366414,0.0073939373971805885,-7.4549500209976065
5+is_plex,0.02472595832635656,0.015081161360430577,1.6395261436053368
6+is_cottage,0.0520317625932596,0.007306467785143703,7.121329228201921
added results/reproduced/horizontal_meta.csv +4 −0
@@ -0,0 +1,4 @@
1+,0
2+n,495092.0
3+r2,0.04549344925077059
4+mean_dep,0.18776662312587053
added results/reproduced/iaao_cities.csv +11 −0
@@ -0,0 +1,11 @@
1+muni,n,n_years,median_ratio,cod,prd,prb,share_years_prb_neg
2+Gatineau,21270,6,0.7984077215662176,16.931554716735143,1.0495027784666382,-0.13658868222945214,1.0
3+Laval,23569,6,0.7292941176470589,20.99004120848114,1.07591200087497,-0.1677198430090882,1.0
4+Longueuil,15343,6,0.6993231963838167,22.936569556574902,1.073902247304333,-0.12759857852649836,1.0
5+Lévis,10622,6,0.8274403990009007,22.318404178238602,1.0892991972930153,-0.23689185802043072,1.0
6+Montréal,79581,6,0.8922306974762114,24.895070443807846,1.0571629439855559,0.06966324444056746,0.0
7+Québec,39927,6,0.8088761174968071,22.021089492553433,1.0711624215910516,-0.04798363355653862,1.0
8+Saguenay,10794,6,0.7872989417989418,19.073245445985677,1.0638779683601711,-0.18854120739412214,1.0
9+Sherbrooke,11413,6,0.7435681818181819,30.520246620851758,1.1372039979494142,-0.3872307527051138,1.0
10+Terrebonne,8061,6,0.7949895317396622,23.75303561732275,1.0961290692775711,-0.28961986412143703,1.0
11+Trois-Rivières,9070,6,0.7291707983633229,29.363374404233365,1.1225282017869498,-0.3166532132665827,1.0
added results/reproduced/iaao_muni.csv +262 −0
@@ -0,0 +1,262 @@
1+muni,n,n_years,median_ratio,cod,prd,prb,share_years_prb_neg,lat,lng,name
2+01023,539,5,0.6673333333333333,50.91474528551908,1.2248771217060996,-0.34468825127789776,1.0,47.373297415,-61.892867091,Les Îles-de-la-Madeleine
3+02028,193,3,0.7335620300751879,24.924431206109645,1.0698721233673953,-0.18103197862957446,1.0,48.348539266,-64.682155177,Chandler
4+03005,631,6,0.8679785511938487,32.09747197510151,1.1090280708711022,-0.18068585618165026,1.0,48.837329995,-64.464568935,Gaspé
5+04037,400,5,0.6966666666666667,24.657677166061557,1.044320194185855,-0.029292963930198,0.8,49.125795114,-66.48063584,Sainte-Anne-des-Monts
6+07047,154,2,0.8250403510712454,18.715867104102017,1.0429833315137729,-0.05918136199537266,1.0,48.4661869825,-67.4294386585,Amqui
7+08053,924,6,0.7849542717864635,22.961756490553256,1.0676455781752932,-0.12514845360658441,1.0,48.844493941500005,-67.523459035,Matane
8+09077,416,5,0.7596562499999999,21.291435789055335,1.0548283361050281,-0.12982997825324094,1.0,48.587353906,-68.191464128,Mont-Joli
9+10043,2952,6,0.7817020537241743,32.027913818017566,1.1328015504123914,-0.3029817279665089,1.0,48.436574925,-68.5190762055,Rimouski
10+12015,119,2,0.772315153471027,15.183800703538104,1.0416125937656937,-0.058507880001827606,1.0,47.773246705999995,-69.4710944155,Saint-Antonin
11+12072,1128,6,0.7966340986130649,14.969808769491877,1.0265360685354072,-0.06693134453658334,1.0,47.8276978835,-69.5320477905,Rivière-du-Loup
12+13073,143,2,0.7872480720728201,19.48365920891809,1.0294657084213812,-0.02696053619823202,0.5,47.679574698,-68.879280856,Témiscouata-sur-le-Lac
13+15013,626,6,0.7636936026936028,23.977174989511976,1.084659312941719,-0.13630189728350917,0.6666666666666666,47.653839902499996,-70.154518171,La Malbaie
14+16013,238,3,0.8511296296296296,32.56224604586135,1.0957467754441717,-0.1674954492469384,1.0,47.4433681525,-70.51061298799999,Baie-Saint-Paul
15+18050,669,6,0.815526698145736,19.519300312098004,1.048588309170369,-0.0816733383768248,1.0,46.977625381,-70.557680462,Montmagny
16+19062,226,4,0.8106281573263143,27.439661292313254,1.1094424684068356,-0.4294672773533285,0.75,46.63032661,-70.97501990949999,Saint-Anselme
17+19068,301,4,0.8489275767899632,26.548869666269344,1.107642649780156,-0.29915439960145335,1.0,46.690995181,-71.07396918399999,Saint-Henri
18+19105,101,2,0.847860831323592,23.395844030649442,1.1136408119218577,-0.2104739818721205,1.0,46.830723019000004,-71.00647754799999,Beaumont
19+21010,483,5,0.776255707762557,25.54827289959927,1.0746565664467242,-0.007935727616828097,0.6,47.088081377500004,-70.88400634300001,Saint-Ferréol-les-Neiges
20+21025,414,5,0.7632876712328767,19.1893209073912,1.0676453235538235,-0.042170544608549236,0.8,47.0531188095,-70.8950832745,Beaupré
21+21035,283,4,0.8476034069736491,22.82023013038416,1.08235603365272,-0.23215142029214486,1.0,46.976472478000005,-71.0231973965,Château-Richer
22+21045,709,6,0.8284982561670373,17.98839469618786,1.067921620148053,-0.08884275574512873,1.0,46.901759032,-71.143656871,Boischatel
23+22005,860,6,0.7824572474617508,25.34594286142555,1.1054442494360623,-0.2780885837540912,1.0,46.8442399275,-71.6147577,Sainte-Catherine-de-la-Jacques-Cartier
24+22010,111,2,0.7879374902272098,22.278392800147888,1.0942267327131154,-0.14510322400523507,1.0,46.880712103,-71.6133648635,Fossambault-sur-le-Lac
25+22020,561,6,0.7876398038915275,27.994845862418558,1.13734252635859,-0.4505269529039112,1.0,46.883512871,-71.532064949,Shannon
26+22035,931,6,0.7901038482590206,24.17844235537976,1.0956374310640464,-0.19103461724047766,1.0,47.008459672,-71.375813659,Stoneham-et-Tewkesbury
27+22040,718,6,0.7630942346287174,28.260839719893976,1.1238613719535708,-0.20672851511595283,1.0,46.9485322015,-71.300988286,Lac-Beauport
28+22045,904,6,0.7989815294450779,19.48589386774982,1.0674612417675986,-0.2483627463824416,1.0,46.996448249,-71.2022620465,Sainte-Brigitte-de-Laval
29+23027,39927,6,0.8088761174968071,22.021089492553433,1.0711624215910516,-0.04798363355653862,1.0,46.845432242,-71.284341623,Québec
30+23057,1256,6,0.7958226723756803,20.83415657438064,1.0839279319598174,-0.32141718627637206,1.0,46.8058055155,-71.3587655525,L'Ancienne-Lorette
31+23072,1494,6,0.7916239054702947,18.91738691312515,1.0844010250630294,-0.1522959320199167,1.0,46.741946145,-71.392724151,Saint-Augustin-de-Desmaures
32+25213,10622,6,0.8274403990009007,22.318404178238602,1.0892991972930153,-0.23689185802043072,1.0,46.734125024,-71.21804311950001,Lévis
33+26030,817,6,0.8015482334259627,24.138578405030643,1.0968411674968217,-0.2225915996902068,1.0,46.446288901,-71.019009516,Sainte-Marie
34+26070,433,5,0.8462264150943396,33.3912910416355,1.118002113482546,-0.3059912138982086,1.0,46.587031214,-71.222876646,Saint-Lambert-de-Lauzon
35+27028,239,4,0.8086742424242425,29.11413633851084,1.123900641050577,-0.35346427459067,1.0,46.2083331485,-70.7789057075,Beauceville
36+27043,221,4,0.7670741607157514,26.12427786850234,1.1054264617213942,-0.1822426203555372,1.0,46.307911006,-70.8754787755,Saint-Joseph-de-Beauce
37+28020,118,2,0.7477937325513451,36.432113509221125,1.1462049821936644,-0.21638507574156152,1.0,46.212335225000004,-70.483038276,Saint-Prosper
38+28053,264,4,0.7347851427131247,17.35436494841649,1.0454657317728653,-0.03620096106684201,1.0,46.397102893500005,-70.5098076245,Lac-Etchemin
39+29073,2196,6,0.8083449413489736,25.755695026295424,1.0770891060813792,-0.10798779415583887,0.8333333333333334,46.1141325115,-70.66347340850001,Saint-Georges
40+30030,367,5,0.7629411764705882,23.40617277896169,1.0725742458683925,-0.056711080582011186,0.8,45.580061028,-70.88744873600001,Lac-Mégantic
41+31084,2083,6,0.7679633740288568,23.200578245790716,1.0632822378115123,-0.07864610355357327,1.0,46.100656922,-71.298757211,Thetford Mines
42+32033,381,5,0.7161185770750988,25.36432789859908,1.0724563014146264,-0.22461012647627657,1.0,46.168170629,-71.880109919,Princeville
43+32043,133,2,0.7078918774437928,21.547516555339485,1.0467423122026374,-0.05363017912735923,1.0,46.220329115,-71.77564931,Plessisville
44+33035,118,2,0.813110300081103,24.25198091954615,1.1075171862309936,-0.4105833065487351,1.0,46.510338575,-71.363735332,Saint-Gilles
45+33045,365,5,0.7725760286225403,24.777323647820566,1.093959945902275,-0.27699719905934855,1.0,46.558726373,-71.439719591,Saint-Agapit
46+33090,801,6,0.7834754939542901,21.722636341184472,1.079801411095684,-0.18195529622856327,1.0,46.610267401,-71.504652032,Saint-Apollinaire
47+34007,275,4,0.8409123376623376,23.602702704062967,1.100676310204747,-0.2278918738548532,1.0,46.7100307405,-71.585762454,Neuville
48+34017,870,6,0.7965175280806989,23.617933054488034,1.0936190199222806,-0.3870384304969796,1.0,46.7532657545,-71.6892814165,Pont-Rouge
49+34025,625,6,0.8213156424581005,22.5154051048244,1.0804485119666485,-0.1853439391006324,1.0,46.674770253,-71.722938844,Donnacona
50+34030,127,2,0.8109785171936221,26.431353685573384,1.1088532854538589,-0.2728652908822181,1.0,46.6753289075,-71.76630738899999,Cap-Santé
51+34048,200,3,0.8914040114613181,22.4419217687073,1.0777283157443607,-0.16351424193035438,1.0,46.707453458,-71.887872363,Portneuf
52+34128,1058,6,0.7943707552421599,33.892653129910514,1.1411084126189521,-0.16653534972200065,1.0,46.887098,-71.817427818,Saint-Raymond
53+36033,4203,6,0.7286444444444444,25.881872445103877,1.0841432224933971,-0.11015824114165505,1.0,46.573502159,-72.735323152,Shawinigan
54+37067,9070,6,0.7291707983633229,29.363374404233365,1.1225282017869498,-0.3166532132665827,1.0,46.3598810855,-72.573001212,Trois-Rivières
55+37235,379,6,0.698388888888889,34.58912731014522,1.1494863386313716,-0.19023003788389053,1.0,46.473336473,-72.681230831,Notre-Dame-du-Mont-Carmel
56+38010,953,6,0.7378636526665802,29.34676894055913,1.1041531520581835,-0.11951993425484558,1.0,46.331892837,-72.436542647,Bécancour
57+39062,3397,6,0.7725079365079366,21.167131268805907,1.0590575297473819,-0.11969422015713542,1.0,46.056750684,-71.955100058,Victoriaville
58+40043,645,6,0.7140458839406207,25.050406547460213,1.081643635149071,-0.22814897980200044,1.0,45.774533753,-71.932177531,Val-des-Sources
59+40047,236,4,0.6974422000860849,33.23499525532314,1.1015789067458372,-0.11661627086349244,1.0,45.785007603500006,-72.01360457550001,Danville
60+41038,185,3,0.6448275862068965,38.606331978071005,1.1660730742063186,-0.3968719817353335,1.0,45.3844633695,-71.6295519155,Cookshire-Eaton
61+41060,196,3,0.8599224137931034,35.53421652858063,1.1691374253389413,-0.4351925732778529,1.0,45.485394948,-71.6580871,East Angus
62+41098,195,3,0.6753623188405797,31.53311390998279,1.1320286579703374,-0.17149384178633773,1.0,45.69994358,-71.445326867,Weedon
63+42025,362,5,0.7549885680965192,35.203169940963946,1.1763525360553093,-0.24823982708549983,1.0,45.450530426,-72.090137863,Saint-Denis-de-Brompton
64+42088,339,5,0.7247393919144671,25.079939475478415,1.088690267817179,-0.2944486676543964,1.0,45.572104515,-72.000885705,Windsor
65+42098,120,2,0.7598296152643589,28.60356720524078,1.0901799648126935,-0.18915439032617962,1.0,45.661565373,-72.13979631699999,Richmond
66+43027,11413,6,0.7435681818181819,30.520246620851758,1.1372039979494142,-0.3872307527051138,1.0,45.392633295,-71.930715774,Sherbrooke
67+44037,630,6,0.7636818947432714,39.103320928672524,1.174166223587914,-0.5171929791954604,1.0,45.13080419,-71.80705083449999,Coaticook
68+45030,135,2,0.7746368795305014,24.602025504275613,1.0656614832850893,-0.07064281825699523,1.0,45.0788365575,-72.3409277395,Potton
69+45072,2576,6,0.6996702637889688,28.74585155682162,1.1231887982499333,-0.16961190181816477,1.0,45.269992867,-72.151298396,Magog
70+45085,176,3,0.8217012726054923,29.60658953091504,1.1136327841600513,-0.05055215467062311,0.6666666666666666,45.254333104,-72.261598675,Austin
71+45093,193,3,0.5888311688311688,37.122686831974555,1.160332247800896,-0.2906102901008709,1.0,45.313381840999995,-72.300395944,Eastman
72+45115,679,6,0.6063801904310379,29.630617868711955,1.1389419969522057,-0.12787910511482986,1.0,45.316923081,-72.167720393,Orford
73+46058,440,6,0.6716763687600644,35.0399114099244,1.1644503220221871,-0.3021844843769021,1.0,45.105737911,-72.6047236695,Sutton
74+46075,507,5,0.6399901315789474,27.55162268404918,1.1477159758154432,-0.1968557522728025,1.0,45.238537717,-72.533189645,Lac-Brome
75+46078,988,6,0.7235671402106036,25.82041311803009,1.1175358334345749,-0.20549848425065131,1.0,45.3095708915,-72.6607766245,Bromont
76+46080,996,6,0.7869125951584968,26.198853324684954,1.1147771543579075,-0.3610004408751044,1.0,45.207637269,-72.74552933449999,Cowansville
77+46112,860,6,0.7050468809797168,31.438957313985355,1.1301858043558206,-0.3655193836711927,1.0,45.2805323405,-72.97625546500001,Farnham
78+47017,4901,6,0.7294086689011172,29.52259191115126,1.1118951327046869,-0.31021184964998916,1.0,45.400759665,-72.731849819,Granby
79+47025,431,5,0.7389595410628019,30.393345512426347,1.1426260017062118,-0.6243582754165377,0.8,45.340766011,-72.523795612,Waterloo
80+47035,551,6,0.6814398233345602,38.47509613172598,1.2034380183091464,-0.36554592129527963,0.8333333333333334,45.363765456,-72.617788786,Shefford
81+47047,362,5,0.7858909678137878,42.382459905281635,1.2008030800169927,-0.3865934883575038,1.0,45.472375239,-72.66004383949999,Roxton Pond
82+48028,559,6,0.7543902258274068,28.018807118374035,1.1091013787799113,-0.21701741650534076,1.0,45.647339981,-72.567065213,Acton Vale
83+49048,181,3,0.7025,30.409774665224678,1.1497028833165948,-0.2823453496491139,0.6666666666666666,45.839364424,-72.56928357550001,Saint-Germain-de-Grantham
84+49058,5438,6,0.780937265257802,28.156173910893166,1.0985192856127424,-0.15805926106005436,1.0,45.8636461665,-72.488506331,Drummondville
85+49070,240,4,0.7331632399064072,28.26332419308384,1.0959426599044875,-0.17573180014727086,0.75,45.9286980065,-72.42569711600001,Saint-Cyrille-de-Wendover
86+50072,536,6,0.7499419147961521,23.653935663973613,1.072662630300501,-0.11256109410431805,0.8333333333333334,46.234372841500004,-72.606647624,Nicolet
87+51015,428,5,0.7371850087593252,36.04550143376593,1.1358027356965468,-0.22142935485853846,1.0,46.254489091,-72.945032821,Louiseville
88+51065,343,5,0.7208805031446541,34.97788003353477,1.1447463757537515,-0.1445638307342712,0.8,46.4667905365,-73.147176442,Saint-Alexis-des-Monts
89+51085,246,4,0.7110915228807202,36.60035597094894,1.1535195526106898,-0.27891311631728755,1.0,46.499025471,-72.826011591,Saint-Boniface
90+51090,176,3,0.6595238095238095,46.32063307271163,1.2066301454514747,-0.2955329419528453,1.0,46.438600727,-72.7760082695,Saint-Étienne-des-Grès
91+52007,1351,6,0.7811588002873564,18.604861636030577,1.0581659181587162,-0.25745548213469116,1.0,45.883310054,-73.289400883,Lavaltrie
92+52017,454,6,0.7424664099602105,27.458078813029132,1.106305536658132,-0.23829756434408023,1.0,45.9622651545,-73.2234576355,Lanoraie
93+52035,200,3,0.7861176470588235,22.655549017167036,1.091352261310293,-0.25046959637460336,1.0,46.083254857,-73.180119373,Berthierville
94+52080,119,2,0.6955672547533602,28.708856460143494,1.0711018490504467,-0.0813118223932662,1.0,46.295695435,-73.38626004,Saint-Gabriel
95+52085,177,3,0.727027027027027,27.88324615835368,1.0925763371789967,-0.14670307401976548,1.0,46.299322264,-73.400681704,Saint-Gabriel-de-Brandon
96+52095,259,4,0.8312473389237706,30.540380247851083,1.1357915288335463,-0.14562631914040758,0.75,46.3822765345,-73.372126772,Mandeville
97+53040,125,2,0.7212600732600732,20.859605403839247,1.0397414336249162,0.0195953827442501,0.5,45.89315123,-73.159513486,Saint-Roch-de-Richelieu
98+53052,2900,6,0.7163174603174602,22.015700026909695,1.069732650385139,-0.15586730118239517,1.0,46.025134898999994,-73.120790133,Sorel-Tracy
99+54008,509,6,0.7409696934629468,36.175329889110756,1.1485863416499411,-0.31318822759192966,1.0,45.506691818,-72.89942077,Saint-Pie
100+54048,3188,6,0.7215852526947326,29.983735016934077,1.120637745311388,-0.4548109525949386,1.0,45.624822898000005,-72.94903571949999,Saint-Hyacinthe
101+55023,436,6,0.7155175586947871,35.10632381575468,1.1429899815511533,-0.5190053063839385,1.0,45.4122608645,-73.0083647985,Saint-Césaire
102+55048,988,6,0.6665826990500734,27.68549680670662,1.115740440522321,-0.3310324780296,1.0,45.431773133,-73.15662568,Marieville
103+55057,210,3,0.7058823529411765,39.22349855731835,1.1791480122856313,-0.5927454024538161,1.0,45.437899329,-73.245698844,Richelieu
104+55065,250,4,0.7067612524461839,32.49284470613207,1.132452062518392,-0.3582422420168257,1.0,45.4843284675,-73.2594419175,Saint-Mathias-sur-Richelieu
105+56005,182,3,0.6952126233702937,25.981522684659765,1.0532080535284765,-0.004549080929276983,0.6666666666666666,45.075641823,-73.152962375,Venise-en-Québec
106+56023,100,2,0.6628845451458486,32.81492586289752,1.1049183792145993,-0.159649344387989,1.0,45.078495171,-73.3724631485,Lacolle
107+56083,6860,6,0.6857290061587995,26.58305679732141,1.1033053033841163,-0.27318761992146323,1.0,45.3173844665,-73.26839254449999,Saint-Jean-sur-Richelieu
108+57005,2466,6,0.7958329542147105,27.67591821449598,1.1284734909133656,-0.30965970135070986,1.0,45.4386584325,-73.29915464550001,Chambly
109+57010,1010,6,0.6948232586712788,26.000694608760995,1.1148493785861135,-0.2039855201907288,1.0,45.458347274,-73.33319411650001,Carignan
110+57020,1203,6,0.662749373433584,28.651959329107765,1.1198338888029058,-0.36229490745193826,1.0,45.524009139,-73.289582455,Saint-Basile-le-Grand
111+57025,348,5,0.665,26.685732093244475,1.1124067946453324,-0.6607363263019816,1.0,45.54996469,-73.23327609750001,McMasterville
112+57030,744,6,0.6713241217798596,22.627379704392297,1.09090788751882,-0.3634820536670958,1.0,45.539227893,-73.2053586795,Otterburn Park
113+57035,1502,6,0.7662368421052632,23.948325462191995,1.0928237725547036,-0.15912137452024816,1.0,45.5703179335,-73.18333605250001,Mont-Saint-Hilaire
114+57040,2016,6,0.765013455787344,27.043523508117854,1.11590548883787,-0.5387275185042903,1.0,45.5766494225,-73.21203484899999,Beloeil
115+58007,4943,6,0.7298304235121789,18.157822443347143,1.0517755883825637,-0.07511871550042507,1.0,45.456964004,-73.460176368,Brossard
116+58012,1479,6,0.764075235109718,19.23169014322753,1.0562801725449358,-0.015803048035999905,0.8333333333333334,45.49607166,-73.502201617,Saint-Lambert
117+58033,2818,6,0.7214520247728389,28.282212165129522,1.1241439694492887,-0.30088808757487173,1.0,45.5948568775,-73.43997271149999,Boucherville
118+58037,1868,6,0.7155095152983274,23.403401825301806,1.0981337097937294,-0.2023309864341323,1.0,45.526278876999996,-73.343779198,Saint-Bruno-de-Montarville
119+58227,15343,6,0.6993231963838167,22.936569556574902,1.073902247304333,-0.12759857852649836,1.0,45.511886497,-73.455769263,Longueuil
120+59010,2186,6,0.6892368654082559,27.101368542651706,1.1305064886095173,-0.4208004484202777,1.0,45.58665754,-73.34013155,Sainte-Julie
121+59015,969,5,0.6941578947368421,27.55841878174844,1.1062269197523142,-0.2654275934202815,1.0,45.641254737,-73.301101941,Saint-Amable
122+59020,1445,6,0.699660685296846,24.833384416142934,1.1064585476041848,-0.3238212478103721,1.0,45.680047018,-73.42792562,Varennes
123+59025,316,5,0.714789419619928,37.91237662771402,1.1764206667175272,-0.6104876127849174,1.0,45.7716982595,-73.359030094,Verchères
124+59035,797,5,0.6441,20.7123316545552,1.0731133733528522,-0.20195773491992464,1.0,45.85806846,-73.228782724,Contrecoeur
125+60005,358,5,0.7413559322033898,16.885494403406323,1.0479257422809722,-0.19259103274095157,0.8,45.726849001,-73.489765653,Charlemagne
126+60013,6713,6,0.6672872479706728,21.0598366386061,1.074717623319002,-0.14803103612872476,1.0,45.763412589,-73.452409152,Repentigny
127+60028,1816,6,0.6641461896639159,26.47252266986331,1.098006926018187,-0.37232052536630766,1.0,45.847046872,-73.42668152600001,L'Assomption
128+60037,725,6,0.8081229328680933,26.03219665844953,1.1007082495328808,-0.4117621700398755,1.0,45.850995196,-73.493655426,L'Épiphanie
129+61005,560,6,0.7581785130423317,25.33968913013652,1.0920848292142917,-0.2066034333520754,1.0,45.993637825,-73.44000416099999,Saint-Paul
130+61013,171,3,0.6333333333333333,34.26526659043471,1.1392978456036058,-0.3231291053094608,1.0,45.966378601,-73.476801969,Crabtree
131+61025,1239,6,0.7232373306724722,21.78002201339599,1.0773392447696346,-0.21467207149062822,1.0,46.019966328,-73.442065366,Joliette
132+61030,714,6,0.753252407704655,19.795712908840187,1.0674690275569825,-0.2114305898722327,0.8333333333333334,46.0511858175,-73.43218235,Notre-Dame-des-Prairies
133+61035,718,6,0.7409494773519164,20.1414097732401,1.046712724191733,-0.12750349507855074,1.0,46.048532085,-73.46625851499999,Saint-Charles-Borromée
134+61040,116,2,0.8286152882205513,33.12170191273214,1.1315083813730664,-0.3073266401447655,1.0,46.0796626785,-73.5277993025,Saint-Ambroise-de-Kildare
135+61050,119,2,0.6857176850171405,27.477878878609587,1.074415485857116,-0.17338003774365124,1.0,46.135610829,-73.52854585,Sainte-Mélanie
136+62007,548,6,0.7623781687762731,25.150478859407567,1.0750411207032586,-0.14234524882818494,0.8333333333333334,46.1652538545,-73.438296302,Saint-Félix-de-Valois
137+62015,491,5,0.7808888888888889,30.881862719342106,1.113263005777294,-0.155376216739658,0.8,46.254256657,-73.536897228,Saint-Jean-de-Matha
138+62020,112,2,0.7460389512337418,30.265828679987003,1.0932190159392952,-0.07945162393825822,1.0,46.193276397,-73.61716932,Sainte-Béatrix
139+62037,1364,6,0.7565606494746896,27.04102518019009,1.1077998636864796,-0.16603887711981763,1.0,46.048327736999994,-73.725588486,Rawdon
140+62047,916,6,0.6686273865669214,34.70941173536347,1.1448167828128435,-0.19856673586099222,1.0,46.109469743000005,-73.895015133,Chertsey
141+62060,794,6,0.7427972147972148,33.904336974441236,1.1834926479685908,-0.21682129664326572,1.0,46.318582148000004,-74.213703653,Saint-Donat
142+62065,420,5,0.6465903582009157,34.457159167787715,1.1454066195583492,-0.20381444347154196,1.0,46.2776111765,-73.7758384495,Saint-Côme
143+62070,108,2,0.6529564054139125,36.33391748449871,1.1417382804528984,-0.11266234441125024,0.5,46.321132874499995,-73.639100665,Sainte-Émélie-de-l'Énergie
144+62075,386,5,0.7098152254249814,29.668639557181635,1.1299303238462528,-0.2023320820761078,1.0,46.328845049,-73.518669413,Saint-Damien
145+63013,263,4,0.7297487480112332,30.749630320275678,1.135104477252584,-0.6872060215266782,1.0,45.950303911,-73.56989595,Saint-Jacques
146+63035,259,4,0.7150263554216867,25.025814834852063,1.115795536117923,-0.4967946545942795,1.0,45.857606134,-73.596808363,Saint-Roch-de-l'Achigan
147+63048,2657,6,0.6787482807072664,32.399731567075136,1.1426349702029932,-0.4409546828425126,1.0,45.829154267,-73.756059204,Saint-Lin--Laurentides
148+63055,990,6,0.6991879004344631,34.772906014843656,1.1179805996735728,-0.12841835399662452,1.0,45.9496960915,-73.8518049635,Saint-Calixte
149+63060,1376,6,0.6786177255124288,29.932403168703473,1.1084628215703884,-0.18243715273619399,1.0,45.9692136175,-73.721131893,Sainte-Julienne
150+64008,8061,6,0.7949895317396622,23.75303561732275,1.0961290692775711,-0.28961986412143703,1.0,45.712940538,-73.677957948,Terrebonne
151+64015,4084,6,0.6733830040040366,24.907510224848203,1.0966100878654021,-0.2790068811456091,1.0,45.742800969,-73.620258493,Mascouche
152+65005,23569,6,0.7292941176470589,20.99004120848114,1.07591200087497,-0.1677198430090882,1.0,45.573336747,-73.753870945,Laval
153+66007,122,2,0.7185924821990395,24.77281784551642,1.1040070044998445,-0.33961014858943006,1.0,45.631109163,-73.502026454,Montréal-Est
154+66023,79581,6,0.8922306974762114,24.895070443807846,1.0571629439855559,0.06966324444056746,0.0,45.52859923,-73.587846245,Montréal
155+66032,1001,6,0.9577432898937377,19.517732793938418,1.0650199449333386,-0.06690574065852856,1.0,45.484173923,-73.60049964,Westmount
156+66058,1337,6,0.9308821671736927,13.371841924509315,1.0295750134873236,-0.03141725569656499,1.0,45.474598268,-73.662658253,Côte-Saint-Luc
157+66062,119,2,0.828157191637994,15.169213801325093,1.0390621459475182,-0.08018877234711512,1.0,45.48090571,-73.646078423,Hampstead
158+66072,919,6,0.9712154346775798,18.437910632001554,1.0586410740869279,-0.03509377230935708,0.8333333333333334,45.515282794,-73.646702467,Mont-Royal
159+66087,960,6,0.9231119095396276,17.57375988436484,1.0422460191530596,-0.021726145155715075,0.6666666666666666,45.44579454,-73.7450680235,Dorval
160+66097,1595,6,0.9147700349809617,16.01327842926532,1.0495921971184083,-0.13170475816618712,1.0,45.452228282,-73.811135622,Pointe-Claire
161+66102,912,6,0.9157315185059659,15.222291389905148,1.03620196824576,-0.0937722845882564,1.0,45.44809616249999,-73.863656111,Kirkland
162+66107,1099,6,0.9318441349917743,17.594256192232862,1.0390910992766764,-0.06141225217710209,0.8333333333333334,45.43256399,-73.866690441,Beaconsfield
163+66142,1997,6,0.9090923582911031,17.722469897676014,1.0475652915979667,-0.11857595425804798,1.0,45.488650831,-73.825879925,Dollard-des-Ormeaux
164+67010,712,6,0.684262379422226,25.33975426489651,1.101002924179236,-0.448853524468586,1.0,45.3677048265,-73.480672417,Saint-Philippe
165+67015,1622,6,0.8212459879530496,22.693926604495797,1.1042615346863807,-0.2621491447773391,1.0,45.4090403705,-73.4802975005,La Prairie
166+67020,1736,6,0.680335891313185,22.419102830071658,1.076173224217308,-0.12050021573808359,1.0,45.383528165,-73.513485181,Candiac
167+67025,522,6,0.7268149330713496,25.35998209068471,1.0964745751820337,-0.539674897123865,1.0,45.37836885,-73.5442486825,Delson
168+67030,1110,6,0.7798735661126965,22.248866183009056,1.096618639052464,-0.6889060659985939,1.0,45.3966073255,-73.56369906500001,Sainte-Catherine
169+67035,1820,6,0.8063260280719035,19.269590412382986,1.080966359291669,-0.5027860383185769,1.0,45.377947198,-73.580077421,Saint-Constant
170+67045,1127,6,0.6681714781145649,24.917527317624618,1.1097996485766655,-0.48606177990172883,1.0,45.314420404,-73.741056666,Mercier
171+67050,3054,6,0.6863984355752648,23.963031662323864,1.0937004161347055,-0.4718981001477939,1.0,45.359037404,-73.736001287,Châteauguay
172+68030,251,4,0.7097808837589821,30.663358396736708,1.1155094173546272,-0.5579209556372569,1.0,45.188883749,-73.401096402,Napierville
173+68055,589,6,0.6838214033382906,26.415322729836625,1.078747858436056,-0.15287365173213993,0.8333333333333334,45.262373942,-73.616414923,Saint-Rémi
174+69037,106,2,0.6881955099377458,37.92110737415667,1.1730260422804393,-0.4670013713958576,1.0,45.119982944,-73.992491204,Ormstown
175+69070,193,3,0.6907325174825175,29.694453762884823,1.1121747313343102,-0.11765626471433004,1.0,45.116286833000004,-74.38926475950001,Saint-Anicet
176+70012,309,5,0.7320459236326109,30.384012277045837,1.1280580748468252,-0.3575072591273072,1.0,45.2573235345,-73.79560172449999,Sainte-Martine
177+70022,1075,6,0.7597574024047884,29.870884614022984,1.1144127568575457,-0.4358589181857655,1.0,45.312101516,-73.873625864,Beauharnois
178+70052,3472,6,0.7403786669685437,31.742876750423854,1.129265554756235,-0.39542016635342087,1.0,45.2621602895,-74.12986425700001,Salaberry-de-Valleyfield
179+71025,924,6,0.7541040226866018,34.38138604039217,1.1456583338332207,-0.4523567408963357,1.0,45.246927821,-74.246740692,Saint-Zotique
180+71033,509,6,0.6692114261414426,25.803009197034513,1.080165640307659,-0.3618141624281369,0.8333333333333334,45.269131059,-74.226509361,Les Coteaux
181+71040,471,6,0.6972763087740437,35.31534275886618,1.1650689893927204,-0.43853385299879444,1.0,45.296572945,-74.175489643,Coteau-du-Lac
182+71050,524,6,0.811130446717522,24.906489020547767,1.0853439175884678,-0.15171924992358465,0.8333333333333334,45.3276066585,-74.0569555055,Les Cèdres
183+71060,718,6,0.6973422430553382,24.315276991934034,1.0998484312304608,-0.23499118611460362,0.8333333333333334,45.3895503655,-73.952990984,L'Île-Perrot
184+71065,723,6,0.691639751552795,21.134183691410264,1.0900101947935883,-0.2552472905767234,1.0,45.38125767,-73.923685459,Notre-Dame-de-l'Île-Perrot
185+71070,958,6,0.6918983367809608,22.63951634518149,1.0818047696747568,-0.3296866908207592,1.0,45.378101038,-73.9810040255,Pincourt
186+71083,2616,6,0.6987913699204021,20.423287853352477,1.0679629167870859,-0.201423129509599,1.0,45.391017535,-74.03721731499999,Vaudreuil-Dorion
187+71100,440,5,0.657512202010254,21.139092496444366,1.0567610887847834,-0.06951265776759467,0.8,45.454409926,-74.148156067,Hudson
188+71105,1501,6,0.6584680311149621,25.484719956369524,1.103918642345454,-0.3099676452402449,1.0,45.40426853,-74.147789979,Saint-Lazare
189+71133,611,6,0.6784057357430919,24.228624394914526,1.0924866258655075,-0.1834992400459184,1.0,45.478344449,-74.301244119,Rigaud
190+72005,2980,6,0.6602678170113345,27.26128613086194,1.1090164884002613,-0.39811780722269663,1.0,45.573503094,-73.9003547245,Saint-Eustache
191+72010,1093,6,0.6784331943504811,29.31978303844349,1.131091676981932,-0.49115172208957975,1.0,45.543286433,-73.903958594,Deux-Montagnes
192+72015,1568,6,0.6834799296337758,27.718407825988223,1.1143667823617305,-0.26218667134366386,1.0,45.53085263,-73.940682486,Sainte-Marthe-sur-le-Lac
193+72020,589,6,0.742374040707374,29.13344857812509,1.117375821640409,-0.21563592347582894,1.0,45.497248842,-73.97750307,Pointe-Calumet
194+72025,379,5,0.7158108589230086,36.251999901980206,1.1408136801835462,-0.18925690542877868,1.0,45.5231855765,-73.9802705435,Saint-Joseph-du-Lac
195+72032,196,3,0.7348350197615494,29.765900135180456,1.1095989251399265,-0.4030715202592383,1.0,45.47335282,-74.081503438,Oka
196+73005,1667,6,0.7697670591362542,25.07291047173648,1.1057264849673165,-0.3921155583951153,1.0,45.61848801,-73.841808155,Boisbriand
197+73010,1766,6,0.7849619311211817,21.364045543447695,1.078015806539247,-0.16574586723421708,1.0,45.6438193675,-73.83684939700001,Sainte-Thérèse
198+73015,4302,6,0.6642971636742129,23.040896754254227,1.0974052681194044,-0.18976729044074891,1.0,45.674802691,-73.8721633665,Blainville
199+73020,947,6,0.7715263047359024,23.556732862555528,1.104648618295947,-0.29614415506310915,1.0,45.643460388,-73.794777382,Rosemère
200+73025,749,6,0.6429262075370085,23.614168966233816,1.0945989327222034,-0.29535573454640485,1.0,45.670854774,-73.784177923,Lorraine
201+73030,827,6,0.6672291272123052,21.611011856728346,1.0847893348004582,-0.24500182019431868,1.0,45.673537822,-73.764234148,Bois-des-Filion
202+73035,1134,6,0.659490376984127,29.599130473734494,1.1208319661428983,-0.5384539363937264,1.0,45.7659122305,-73.81296885399999,Sainte-Anne-des-Plaines
203+74005,4618,6,0.7749613542187931,25.169913073493312,1.107774565693448,-0.40041493495052627,1.0,45.673421485999995,-73.98505922449999,Mirabel
204+75005,1621,6,0.6562752525252525,30.650915931084207,1.133435577808017,-0.28971919531263324,1.0,45.746477502,-74.112937545,Saint-Colomban
205+75017,6616,6,0.7517893023113612,23.091842242510324,1.0834015969188,-0.24785057684142733,1.0,45.789913221,-74.0082415655,Saint-Jérôme
206+75028,1832,6,0.673219696969697,34.519633166289346,1.1589579575042572,-0.4521593731818938,1.0,45.832600118,-73.9533649835,Sainte-Sophie
207+75040,1292,6,0.6564135708058492,28.883399359732728,1.1250150841218194,-0.2923979410997817,1.0,45.860940030500004,-74.0554606795,Prévost
208+75045,1478,6,0.7645713506986852,30.123502040027475,1.126414204841707,-0.1552297611761352,1.0,45.89739243,-73.98773667149999,Saint-Hippolyte
209+76020,1178,6,0.7033883848863581,25.657210202235234,1.0943487239078933,-0.18994258509577283,1.0,45.651124736,-74.3435378865,Lachute
210+76025,240,4,0.6969195176954546,32.67202626752153,1.1293551629367897,-0.17129339765566512,1.0,45.7657740385,-74.2481922565,Gore
211+76043,681,6,0.7906597930839002,33.62984625478003,1.1317947395194912,-0.3000186607193363,1.0,45.677822306,-74.406445365,Brownsburg-Chatham
212+76052,112,2,0.6842583441149015,28.727733669937138,1.0979075842302088,-0.10610838611500388,1.0,45.649283199,-74.647290242,Grenville-sur-la-Rouge
213+77012,577,6,0.767416063397733,31.70987258356473,1.1178739679916119,-0.2180822273799095,0.8333333333333334,46.048795423,-74.076073038,Sainte-Marguerite-du-Lac-Masson
214+77022,1813,6,0.7736937921287237,26.104447343065164,1.0829474852619216,-0.15219298535856018,1.0,45.953708666,-74.139983317,Sainte-Adèle
215+77030,630,6,0.674526627218935,23.9838858690541,1.0680071983146502,-0.06951841047914054,1.0,45.892511213,-74.1345699675,Piedmont
216+77035,421,5,0.6726760563380282,32.707153156198636,1.1487149068078237,-0.20639664687018483,0.8,45.859360428,-74.119898636,Sainte-Anne-des-Lacs
217+77043,1679,6,0.6642491108705737,23.70281702578965,1.0734527565770051,-0.1685823132373225,1.0,45.885168363,-74.173904996,Saint-Sauveur
218+77050,615,6,0.6425609382325694,32.995697452241316,1.111440447284858,-0.20400064585818517,1.0,45.90287985,-74.238219198,Morin-Heights
219+77060,176,3,0.6742105263157895,48.283982620796436,1.2368582258892007,-0.25484431235044575,1.0,45.8555580205,-74.467661531,Wentworth-Nord
220+77065,786,6,0.7760721311475409,31.111832805489417,1.1296188471049702,-0.08404023050555207,1.0,45.962066259,-74.345879549,Saint-Adolphe-d'Howard
221+78005,260,4,0.788936974789916,29.460609073697377,1.105329878424848,-0.19843515170141063,1.0,46.002425485,-74.1954476,Val-Morin
222+78010,564,6,0.7682692371448463,30.02851642067145,1.1025223014890941,-0.12295037894100631,1.0,46.027993698,-74.209720426,Val-David
223+78032,1015,6,0.6855593767141084,25.791843718209538,1.0749411280220096,-0.11449565917287258,1.0,46.046276118,-74.281797912,Sainte-Agathe-des-Monts
224+78047,413,5,0.7135,33.7908170202975,1.1300176688632169,-0.21575811886767085,0.8,46.114378271,-74.47546422100001,Mont-Blanc
225+78095,171,3,0.8208829530699313,28.132199595941593,1.126098556497308,-0.13958716637973806,1.0,46.16770375,-74.470425115,Lac-Supérieur
226+78102,1089,6,0.7311117614269789,30.794479048645194,1.1204382252400635,-0.12836311075483878,1.0,46.132329575,-74.593895639,Mont-Tremblant
227+78120,196,3,0.762378947368421,42.255755825822746,1.2141461619843812,-0.2992448689549128,1.0,46.275642615,-74.734005549,Labelle
228+79005,317,5,0.7562162162162163,30.136734693961536,1.1003274393901796,-0.06869114861633299,1.0,46.100329843500006,-75.6227275185,Notre-Dame-du-Laus
229+79030,127,2,0.6468191065144411,33.48178439024826,1.093039579050811,-0.10737375244102947,1.0,46.398916586,-75.02818566100001,Nominingue
230+79037,295,4,0.6846636546184739,35.025329593330426,1.1400383441487119,-0.1695172539305697,1.0,46.444012066,-74.891305735,Rivière-Rouge
231+79088,1068,6,0.7740341365461847,24.302939834888655,1.069661991560468,-0.1465788154360099,1.0,46.552159219,-75.5027332425,Mont-Laurier
232+79097,104,2,0.7422861778744132,26.572593593059842,1.1068266452312072,-0.09448456343410147,1.0,46.701718909,-75.451640396,Ferme-Neuve
233+80027,143,2,0.6577200331373031,34.64365343512506,1.1540631770509941,-0.2563220112686172,1.0,45.7288633915,-75.056881545,Saint-André-Avellin
234+80050,104,1,0.7304779411764706,20.060720680119704,1.0551305741351824,-0.22257162986162263,1.0,45.602108631,-75.243203534,Thurso
235+81017,21270,6,0.7984077215662176,16.931554716735143,1.0495027784666382,-0.13658868222945214,1.0,45.478547612499995,-75.713496176,Gatineau
236+82005,368,5,0.7394618996027447,15.758329630938155,1.022595285772932,-0.011565422335264312,0.6,45.607380499,-75.448113435,L'Ange-Gardien
237+82015,1321,6,0.7915990277134624,20.436939014421327,1.0558473384677736,-0.10941019083278125,1.0,45.633673901,-75.645517707,Val-des-Monts
238+82020,877,6,0.6638305562902231,19.66144762913082,1.068141505374912,-0.15680720575821785,1.0,45.554269142,-75.760626051,Cantley
239+82025,533,5,0.7365128205128205,20.109427070252394,1.0711324084906955,-0.13228455957137367,0.6,45.507582942,-75.80418832,Chelsea
240+82030,278,4,0.7453869047619048,24.653691366080988,1.0795465837628575,-0.10148238374645359,1.0,45.5065481265,-76.0379189075,Pontiac
241+82035,715,6,0.6650178866263071,29.44183235849443,1.1144147254778574,-0.16212541696934157,1.0,45.656126335,-75.99370325,La Pêche
242+83032,184,3,0.7973958333333333,34.92667040526475,1.1703174468795343,-0.07965466822893477,1.0,46.092894060000006,-76.047773002,Gracefield
243+83065,218,3,0.7012959191727839,28.805661670739624,1.1015631923144458,-0.26803741432214934,1.0,46.3787703725,-75.974956507,Maniwaki
244+86042,2724,6,0.8366583424300523,15.06147804754267,1.0368678256129944,-0.08939705321208859,1.0,48.234782821500005,-79.02640637,Rouyn-Noranda
245+87090,438,5,0.7851063829787234,20.116819736961197,1.045655945310056,-0.09997817214443672,1.0,48.796851013,-79.203499652,La Sarre
246+88057,218,2,0.811819632749137,18.0851627067117,1.0643777382946966,-0.2559327944639028,1.0,48.5760654345,-78.113034247,Amos
247+89008,2324,6,0.8126685967539155,28.21645553056692,1.1161070235181083,-0.32663299095099524,1.0,48.102099980999995,-77.810123723,Val-d'Or
248+89015,116,2,0.8094558101472995,20.431714403736038,1.072786429093131,-0.23917163934784105,1.0,48.140422102,-78.122193154,Malartic
249+90012,824,6,0.749237588389274,25.46135544821528,1.0756222255829053,-0.1487413121568984,1.0,47.437275442499995,-72.779832883,La Tuque
250+91025,664,6,0.8137683672076632,19.119137295400005,1.0635366819847192,-0.14286063685850484,0.8333333333333334,48.5130966975,-72.229779914,Roberval
251+91042,749,6,0.8105882352941176,24.026771569803017,1.0853518553934969,-0.14987364945513987,1.0,48.654435423,-72.443420482,Saint-Félicien
252+92022,1068,6,0.7948982699805068,18.574330434764217,1.056085669382861,-0.05639164920521421,1.0,48.880969938,-72.2172604135,Dolbeau-Mistassini
253+93012,238,4,0.8150918603012337,25.382670531701578,1.1079238343881064,-0.12237404789260117,1.0,48.422557618,-71.87132776850001,Métabetchouan--Lac-à-la-Croix
254+93042,2286,6,0.8025041313443663,29.775957583854023,1.1264432678832426,-0.33707942496195986,1.0,48.548642577,-71.64940249899999,Alma
255+94068,10794,6,0.7872989417989418,19.073245445985677,1.0638779683601711,-0.18854120739412214,1.0,48.413850183,-71.114843926,Saguenay
256+94240,433,5,0.7548717948717949,26.19976837183734,1.1064419574799171,-0.25623669173628566,1.0,48.531486353000005,-71.08104856349999,Saint-Honoré
257+94245,204,3,0.6753375474083438,31.77265312960662,1.1134962112514815,-0.19640112322516234,1.0,48.636804908,-71.096657085,Saint-David-de-Falardeau
258+94255,115,2,0.8416867842601852,19.91364075604485,1.0634758075346165,-0.17522623594205916,1.0,48.560310212,-71.322823794,Saint-Ambroise
259+96020,1363,6,0.8078230923665707,17.345307803691497,1.0486315729197053,-0.15390315960400958,1.0,49.204002921,-68.190065087,Baie-Comeau
260+97007,1757,6,0.8232628501201955,31.766836500102194,1.1429833751673635,-0.2706148488213218,1.0,50.217120775,-66.379328936,Sept-Îles
261+97022,310,4,0.8620232267511514,26.468917141651318,1.075295425288986,-0.09639715621572498,1.0,50.023658767,-66.879255019,Port-Cartier
262+99025,678,6,0.8681148247978436,24.873041560245397,1.08653566843947,-0.19736064134913345,1.0,49.913814422499996,-74.375484754,Chibougamau
added results/reproduced/iaao_overall.csv +8 −0
@@ -0,0 +1,8 @@
1+group,n,median_ratio,cod,prd,prb,prb_se,median_lo,median_hi,cod_lo,cod_hi,prd_lo,prd_hi,prb_lo,prb_hi
2+All sales 2021–2026,522769,0.7842696629213484,28.453606677566754,1.0720526265902222,-0.029231043995398567,0.001011694100494542,0.7836525,0.7849235679214402,28.32931767404846,28.565805731993105,1.0708980043366874,1.0730233208372144,-0.0311809979240684,-0.027240556459155452
3+2021,115221,0.75875,26.56881340997751,1.0977977446595462,-0.10439995446335718,0.0018780611602475469,0.7573905876731963,0.76,26.351010556052213,26.81244648128587,1.096089395370703,1.0995245107667422,-0.10784763266408848,-0.10129939282929173
4+2022,90627,0.6996330275229358,30.166845460558804,1.113909455968154,-0.11645566662177718,0.002565006647926422,0.6986641148325359,0.7010965205683608,29.860881164700064,30.500688556680952,1.1115376567937396,1.1168128266197954,-0.12086406687016825,-0.11072838334214413
5+2023,74110,0.7678797912903873,30.158018383876275,1.0669797271549857,-0.014624967340268172,0.0029669111162585514,0.766664374101264,0.7692849755881734,29.831543249220744,30.421183921307005,1.064154926668826,1.0692306393998607,-0.01975682064425937,-0.008456585563497683
6+2024,89204,0.7652173913043478,29.51630268694212,1.0608402697893429,-0.0022461064763566636,0.002801065827976032,0.7639511533088846,0.7668753472222222,29.231808427730112,29.784991266712524,1.0588161557648539,1.0631172201778432,-0.007092623264666497,0.003072440446322985
7+2025,102026,0.8505050505050505,25.459880383502032,1.07018259382875,-0.038888882788478116,0.0026313803244376664,0.8495232422704633,0.8514549465240642,25.15070947846792,25.72947026310519,1.0682152399072007,1.072481009411764,-0.04395325683380448,-0.034422778196270945
8+2026,51581,0.8571428571428571,25.489813580325944,1.067871694912302,-0.030491883244112023,0.0038787622967144447,0.8553654037886341,0.8584553928095873,25.057401870485677,25.86570274574058,1.0649438720501567,1.0709611050077859,-0.038324942561289106,-0.024024217864788658
added results/reproduced/paglin_fogarty.csv +8 −0
@@ -0,0 +1,8 @@
1+,0
2+estimator,Paglin–Fogarty (levels)
3+intercept,31729.109668061967
4+intercept_se,5377.612928331011
5+slope,0.750538044579637
6+slope_se,0.0234789080468955
7+n,522769
8+r2,0.765529044655609
added results/reproduced/quantile.csv +6 −0
@@ -0,0 +1,6 @@
1+tau,beta,se,gamma,n
2+0.1,0.8710259920099666,0.0011617888737700704,-0.12897400799003345,250000
3+0.25,0.8576278086355942,0.0008188248060121729,-0.14237219136440582,250000
4+0.5,0.8158799571081065,0.0008596249662814258,-0.18412004289189354,250000
5+0.75,0.6977863205885624,0.001810213015287808,-0.3022136794114376,250000
6+0.9,0.493980443120313,0.004939830233012593,-0.506019556879687,250000
added results/reproduced/robustness.csv +12 −0
@@ -0,0 +1,12 @@
1+variant,gamma_fe,se_fe,gamma_iv,se_iv,n
2+Baseline,-0.3441977186584467,0.029440415405950358,-0.08342441688397817,0.02111681371535637,522769
3+Condominiums only,-0.12464520941899382,0.01518233161943941,0.05839388234158127,0.016310586343762663,77976
4+Excluding sales < $100k,-0.2954021582133831,0.02620799864834414,-0.04761800229908353,0.017049185795619447,507024
5+Match score = 220 (max),-0.41484792659187864,0.039909750823735164,-0.09508044808955152,0.029034367201517614,210820
6+Match distance <= 10 m,-0.35544067100844223,0.03044054381932686,-0.0865157304085471,0.0217104651732468,481476
7+Single-family only,-0.4537825487653664,0.017669914962190263,-0.14637837847309776,0.009674772614708964,337855
8+Ratio trim 5/95,-0.14708370575153007,0.022273388305055954,-0.04156000317201847,0.01559802433181914,465318
9+Sales 2021-2023,-0.34991990346605073,0.029902278574860372,-0.10123470423960601,0.02502546688053367,279958
10+Sales 2024-2026,-0.3371827571771222,0.028989933590428545,-0.06223166153460358,0.01639409832734001,242811
11+Munis >= 300 sales,-0.3409164404961804,0.032623407694126444,-0.07722611352301967,0.022313481326094283,474004
12+Cells >= 50 sales,-0.33992010068755585,0.03216956767872976,-0.07761468372097924,0.02222025306569112,476001
added results/reproduced/sample_counts.csv +10 −0
@@ -0,0 +1,10 @@
1+,0
2+n_sales,522769
3+n_munis,625
4+n_cells,2884
5+n_condo,81461
6+n_cottage,9287
7+n_mobile,4701
8+n_other,574
9+n_plex,81693
10+n_single_family,345053
added results/reproduced/summary_stats.csv +9 −0
@@ -0,0 +1,9 @@
1+variable,n,mean,sd,p10,p50,p90
2+Sale price ($),522769,462131.02316128154,331668.8849792319,175000.0,400000.0,787500.0
3+Assessed value ($),522769,378576.0241311172,284509.3908358621,155000.0,309400.0,664500.0
4+Assessment ratio AV/SP,522769,0.8782215447418829,0.4287885762103099,0.5793890014020876,0.7842696629213484,1.137561073733976
5+Roll lag (months),522769,37.06170101351715,11.313660102159131,22.470433639947437,36.16951379763469,50.65703022339027
6+Assessed land share,519330,0.30981189756147676,0.14934088670854148,0.12636418307358985,0.2959155356170383,0.5106060606060606
7+Building age (years),498458,43.62546694004309,30.107767330611793,10.0,39.0,79.0
8+Lot area (m2),522475,1406.4376602899658,10905.966380926626,111.71,555.8,2257.6
9+Floor area (m2),515958,137.64187220665247,84.87818498574318,75.9,112.0,225.9
added results/reproduced/taxshift.csv +11 −0
@@ -0,0 +1,11 @@
1+decile,n,mean_rel,median_rel,se
2+1,51007,1.0172224507626328,0.6488477482764501,0.00510136426265092
3+2,52458,0.2415117210138423,0.0698547780946529,0.002333270560075986
4+3,51976,0.11189928535329506,0.028146310809547326,0.0016347999883110573
5+4,52385,0.06314511674551167,0.00854447439353101,0.0013607139199222748
6+5,52879,0.030137573946786966,-0.008096278617191643,0.0011409831482484887
7+6,51767,0.008282058967129717,-0.022904260192395776,0.0010397072479158753
8+7,52236,-0.007197592292916026,-0.03155116674680325,0.0009408008322386948
9+8,52364,-0.018627874530360817,-0.03821138759371412,0.0008698417304787702
10+9,52123,-0.022000383569130387,-0.03809452119114054,0.0008369636786756912
11+10,53574,-0.03607074517324711,-0.04728024331485037,0.0008015863097273057
added results/reproduced/vertical.csv +4 −0
@@ -0,0 +1,4 @@
1+estimator,beta,se,gamma,n,r2
2+Cheng OLS (pooled),0.8119241820644745,0.029259867729858405,-0.18807581793552552,522769,0.7053864314995516
3+Cheng FE (cell),0.6558022813415533,0.029440415405950358,-0.3441977186584467,522769,0.7956390786292254
4+Clapp IV (rank instrument),0.9165755831160218,0.02111681371535637,-0.08342441688397817,522769,0.4855697563551059
added results/tables/heterogeneity.tex +39 −0
@@ -0,0 +1,39 @@
1+\begin{tabular}{lccr}
2+\toprule
3+Subsample & $\gamma$ & Std.\ error & $n$ \\
4+\midrule
5+\multicolumn{4}{l}{\itshape Property class}\\
6+\quad Single-family & -0.454*** & (0.018) & 337,855 \\
7+\quad Condominium & -0.125*** & (0.015) & 77,976 \\
8+\quad Plex (2--5 units) & -0.487*** & (0.054) & 69,745 \\
9+\quad Cottage & -0.288*** & (0.026) & 2,114 \\
10+\midrule
11+\multicolumn{4}{l}{\itshape Building age}\\
12+\quad Age $<$ 20 y & -0.297*** & (0.041) & 117,898 \\
13+\quad Age 20--60 y & -0.384*** & (0.034) & 227,857 \\
14+\quad Age $>$ 60 y & -0.435*** & (0.028) & 108,049 \\
15+\midrule
16+\multicolumn{4}{l}{\itshape Assessed land share}\\
17+\quad Land share $<$ 0.2 & -0.262*** & (0.042) & 119,718 \\
18+\quad Land share 0.2--0.4 & -0.372*** & (0.032) & 236,923 \\
19+\quad Land share $>$ 0.4 & -0.503*** & (0.030) & 125,714 \\
20+\midrule
21+\multicolumn{4}{l}{\itshape Roll lag at sale}\\
22+\quad Roll lag $<$ 24 m & -0.335*** & (0.028) & 86,001 \\
23+\quad Roll lag 24--48 m & -0.338*** & (0.033) & 327,031 \\
24+\quad Roll lag $>$ 48 m & -0.361*** & (0.026) & 91,367 \\
25+\midrule
26+\multicolumn{4}{l}{\itshape Municipality size}\\
27+\quad Muni $<$ 1k sales & -0.379*** & (0.007) & 125,139 \\
28+\quad Muni 1k--10k sales & -0.406*** & (0.014) & 185,111 \\
29+\quad Muni $>$ 10k sales & -0.267*** & (0.043) & 212,519 \\
30+\midrule
31+\multicolumn{4}{l}{\itshape Sale year}\\
32+\quad Sales 2021 & -0.316*** & (0.027) & 115,221 \\
33+\quad Sales 2022 & -0.380*** & (0.030) & 90,627 \\
34+\quad Sales 2023 & -0.365*** & (0.034) & 74,110 \\
35+\quad Sales 2024 & -0.333*** & (0.032) & 89,204 \\
36+\quad Sales 2025 & -0.335*** & (0.026) & 102,026 \\
37+\quad Sales 2026 & -0.349*** & (0.030) & 51,581 \\
38+\bottomrule
39+\end{tabular}
\ No newline at end of file
added results/tables/horizontal.tex +14 −0
@@ -0,0 +1,14 @@
1+\begin{tabular}{lcc}
2+\toprule
3+ & Coefficient & Std.\ error \\
4+\midrule
5+Building age (decades) & +0.0075*** & (0.0012) \\
6+Assessed land share & +0.0031 & (0.0298) \\
7+Condominium & -0.0551*** & (0.0074) \\
8+Plex (2--5 units) & +0.0247 & (0.0151) \\
9+Cottage & +0.0520*** & (0.0073) \\
10+\midrule
11+Mean of dependent variable & \multicolumn{2}{c}{0.188} \\
12+Observations & \multicolumn{2}{c}{495,092} \\
13+\bottomrule
14+\end{tabular}
\ No newline at end of file
added results/tables/iaao.tex +20 −0
@@ -0,0 +1,20 @@
1+\begin{tabular}{lrcccc}
2+\toprule
3+Sample & $n$ & Median ratio & COD & PRD & PRB \\
4+\midrule
5+All sales 2021–2026 & 522,769 & 0.784 & 28.5 & 1.072 & -0.0292 \\
6+ & & \footnotesize[0.784, 0.785] & \footnotesize[28.3, 28.6] & \footnotesize[1.071, 1.073] & \footnotesize[-0.0312, -0.0272] \\
7+2021 & 115,221 & 0.759 & 26.6 & 1.098 & -0.1044 \\
8+ & & \footnotesize[0.757, 0.760] & \footnotesize[26.4, 26.8] & \footnotesize[1.096, 1.100] & \footnotesize[-0.1078, -0.1013] \\
9+2022 & 90,627 & 0.700 & 30.2 & 1.114 & -0.1165 \\
10+ & & \footnotesize[0.699, 0.701] & \footnotesize[29.9, 30.5] & \footnotesize[1.112, 1.117] & \footnotesize[-0.1209, -0.1107] \\
11+2023 & 74,110 & 0.768 & 30.2 & 1.067 & -0.0146 \\
12+ & & \footnotesize[0.767, 0.769] & \footnotesize[29.8, 30.4] & \footnotesize[1.064, 1.069] & \footnotesize[-0.0198, -0.0085] \\
13+2024 & 89,204 & 0.765 & 29.5 & 1.061 & -0.0022 \\
14+ & & \footnotesize[0.764, 0.767] & \footnotesize[29.2, 29.8] & \footnotesize[1.059, 1.063] & \footnotesize[-0.0071, 0.0031] \\
15+2025 & 102,026 & 0.851 & 25.5 & 1.070 & -0.0389 \\
16+ & & \footnotesize[0.850, 0.851] & \footnotesize[25.2, 25.7] & \footnotesize[1.068, 1.072] & \footnotesize[-0.0440, -0.0344] \\
17+2026 & 51,581 & 0.857 & 25.5 & 1.068 & -0.0305 \\
18+ & & \footnotesize[0.855, 0.858] & \footnotesize[25.1, 25.9] & \footnotesize[1.065, 1.071] & \footnotesize[-0.0383, -0.0240] \\
19+\bottomrule
20+\end{tabular}
\ No newline at end of file
added results/tables/iaao_cities.tex +16 −0
@@ -0,0 +1,16 @@
1+\begin{tabular}{lrccccc}
2+\toprule
3+Municipality & $n$ & Median ratio & COD & PRD & PRB & \makecell{Share of years\\PRB $<$ 0} \\
4+\midrule
5+Montréal & 79,581 & 0.892 & 24.9 & 1.057 & 0.0697 & 0.00 \\
6+Québec & 39,927 & 0.809 & 22.0 & 1.071 & -0.0480 & 1.00 \\
7+Laval & 23,569 & 0.729 & 21.0 & 1.076 & -0.1677 & 1.00 \\
8+Gatineau & 21,270 & 0.798 & 16.9 & 1.050 & -0.1366 & 1.00 \\
9+Longueuil & 15,343 & 0.699 & 22.9 & 1.074 & -0.1276 & 1.00 \\
10+Sherbrooke & 11,413 & 0.744 & 30.5 & 1.137 & -0.3872 & 1.00 \\
11+Saguenay & 10,794 & 0.787 & 19.1 & 1.064 & -0.1885 & 1.00 \\
12+Lévis & 10,622 & 0.827 & 22.3 & 1.089 & -0.2369 & 1.00 \\
13+Trois-Rivières & 9,070 & 0.729 & 29.4 & 1.123 & -0.3167 & 1.00 \\
14+Terrebonne & 8,061 & 0.795 & 23.8 & 1.096 & -0.2896 & 1.00 \\
15+\bottomrule
16+\end{tabular}
\ No newline at end of file
added results/tables/paglin_fogarty.tex +9 −0
@@ -0,0 +1,9 @@
1+\begin{tabular}{lcc}
2+\toprule
3+ & Coefficient & Std.\ error \\
4+\midrule
5+Intercept ($\$$) & 31,729 & 5,378 \\
6+Slope on sale price & 0.751 & 0.023 \\
7+Observations & \multicolumn{2}{c}{522,769} \\
8+\bottomrule
9+\end{tabular}
\ No newline at end of file
added results/tables/quantile.tex +8 −0
@@ -0,0 +1,8 @@
1+\begin{tabular}{lccccc}
2+\toprule
3+Quantile $\tau$ & 0.10 & 0.25 & 0.50 & 0.75 & 0.90 \\
4+\midrule
5+$\beta(\tau)$ & 0.871*** & 0.858*** & 0.816*** & 0.698*** & 0.494*** \\
6+ & (0.0012) & (0.0008) & (0.0009) & (0.0018) & (0.0049) \\
7+\bottomrule
8+\end{tabular}
\ No newline at end of file
added results/tables/robustness.tex +17 −0
@@ -0,0 +1,17 @@
1+\begin{tabular}{lccccr}
2+\toprule
3+Sample variant & $\gamma_{\text{FE}}$ & Std.\ err. & $\gamma_{\text{IV}}$ & Std.\ err. & $n$ \\
4+\midrule
5+Baseline & -0.344*** & (0.029) & -0.083*** & (0.021) & 522,769 \\
6+Condominiums only & -0.125*** & (0.015) & +0.058*** & (0.016) & 77,976 \\
7+Excluding sales < \$100k & -0.295*** & (0.026) & -0.048*** & (0.017) & 507,024 \\
8+Match score = 220 (max) & -0.415*** & (0.040) & -0.095*** & (0.029) & 210,820 \\
9+Match distance $\le$ 10 m & -0.355*** & (0.030) & -0.087*** & (0.022) & 481,476 \\
10+Single-family only & -0.454*** & (0.018) & -0.146*** & (0.010) & 337,855 \\
11+Ratio trim 5/95 & -0.147*** & (0.022) & -0.042*** & (0.016) & 465,318 \\
12+Sales 2021-2023 & -0.350*** & (0.030) & -0.101*** & (0.025) & 279,958 \\
13+Sales 2024-2026 & -0.337*** & (0.029) & -0.062*** & (0.016) & 242,811 \\
14+Munis $\ge$ 300 sales & -0.341*** & (0.033) & -0.077*** & (0.022) & 474,004 \\
15+Cells $\ge$ 50 sales & -0.340*** & (0.032) & -0.078*** & (0.022) & 476,001 \\
16+\bottomrule
17+\end{tabular}
\ No newline at end of file
added results/tables/summary_stats.tex +14 −0
@@ -0,0 +1,14 @@
1+\begin{tabular}{lrrrrrr}
2+\toprule
3+Variable & $n$ & Mean & SD & P10 & Median & P90 \\
4+\midrule
5+Sale price (\$) & 522,769 & 462,131 & 331,669 & 175,000 & 400,000 & 787,500 \\
6+Assessed value (\$) & 522,769 & 378,576 & 284,509 & 155,000 & 309,400 & 664,500 \\
7+Assessment ratio AV/SP & 522,769 & 0.878 & 0.429 & 0.579 & 0.784 & 1.138 \\
8+Roll lag (months) & 522,769 & 37.1 & 11.3 & 22.5 & 36.2 & 50.7 \\
9+Assessed land share & 519,330 & 0.310 & 0.149 & 0.126 & 0.296 & 0.511 \\
10+Building age (years) & 498,458 & 43.6 & 30.1 & 10.0 & 39.0 & 79.0 \\
11+Lot area (m$^2$) & 522,475 & 1,406 & 10,906 & 112 & 556 & 2,258 \\
12+Floor area (m$^2$) & 515,958 & 137.6 & 84.9 & 75.9 & 112.0 & 225.9 \\
13+\bottomrule
14+\end{tabular}
\ No newline at end of file
added results/tables/taxshift.tex +16 −0
@@ -0,0 +1,16 @@
1+\begin{tabular}{lcccc}
2+\toprule
3+Price decile & $n$ & \makecell{Mean excess\\burden (\%)} & \makecell{Median excess\\burden (\%)} & Std.\ error \\
4+\midrule
5+1 & 51,007 & +101.7 & +64.9 & (0.51) \\
6+2 & 52,458 & +24.2 & +7.0 & (0.23) \\
7+3 & 51,976 & +11.2 & +2.8 & (0.16) \\
8+4 & 52,385 & +6.3 & +0.9 & (0.14) \\
9+5 & 52,879 & +3.0 & -0.8 & (0.11) \\
10+6 & 51,767 & +0.8 & -2.3 & (0.10) \\
11+7 & 52,236 & -0.7 & -3.2 & (0.09) \\
12+8 & 52,364 & -1.9 & -3.8 & (0.09) \\
13+9 & 52,123 & -2.2 & -3.8 & (0.08) \\
14+10 & 53,574 & -3.6 & -4.7 & (0.08) \\
15+\bottomrule
16+\end{tabular}
\ No newline at end of file
added results/tables/vertical.tex +15 −0
@@ -0,0 +1,15 @@
1+\begin{tabular}{lccc}
2+\toprule
3+ & (1) & (2) & (3) \\
4+ & Cheng OLS & Cheng FE & Clapp IV \\
5+\midrule
6+$\beta$ (ln sale price) & 0.812*** & 0.656*** & 0.917*** \\
7+ & (0.029) & (0.029) & (0.021) \\
8+$\gamma = \beta - 1$ & -0.188 & -0.344 & -0.083 \\
9+\midrule
10+Cell fixed effects & No & Yes & Yes \\
11+Error-in-price robust & No & No & Yes \\
12+Observations & 522,769 & 522,769 & 522,769 \\
13+$R^2$ & 0.705 & 0.796 & 0.486 \\
14+\bottomrule
15+\end{tabular}
\ No newline at end of file
added scripts/01_build_sample.py +31 −0
@@ -0,0 +1,31 @@
1+#!/usr/bin/env python3
2+# Author: Simon-Pierre Boucher — contact@spboucher.ai
3+"""Step 01 — Build the estimation sample.
4+
5+Reads the raw matched transaction–roll snapshot
6+(``data/raw/transactions_700k_avec_registre_foncier.parquet``), keeps
7+residential arm's-length sales with a high-confidence roll match, constructs
8+the assessment ratio and derived regressors, applies the trims, and writes
9+``data/processed/analysis.parquet``.
10+
11+Usage: python scripts/01_build_sample.py
12+"""
13+import sys
14+from pathlib import Path
15+
16+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
17+
18+from wp10 import sample # noqa: E402
19+
20+
21+def main() -> None:
22+ s = sample.build_and_save()
23+ print(f"\nEstimation sample written: {len(s):,} sales")
24+ print(f" municipalities : {s['muni'].nunique():,}")
25+ print(f" cells : {s['cell'].nunique():,}")
26+ print(f" classes : {s.groupby('prop_class').size().to_dict()}")
27+ print(f" median ratio : {s['ratio'].median():.3f}")
28+
29+
30+if __name__ == "__main__":
31+ main()
added scripts/02_iaao_stats.py +113 −0
@@ -0,0 +1,113 @@
1+#!/usr/bin/env python3
2+# Author: Simon-Pierre Boucher — contact@spboucher.ai
3+"""Step 02 — Descriptive statistics and IAAO ratio-study diagnostics.
4+
5+Writes to ``results/reproduced/``:
6+ summary_stats.csv sample descriptives used in Table 1
7+ iaao_overall.csv province-wide median ratio / COD / PRD / PRB with
8+ bootstrap CIs, overall and by sale year
9+ iaao_cities.csv the ten largest markets
10+ iaao_muni.csv every municipality with ≥ 100 sales (maps + histograms)
11+
12+Usage: python scripts/02_iaao_stats.py
13+"""
14+import sys
15+from pathlib import Path
16+
17+import numpy as np
18+import pandas as pd
19+
20+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
21+
22+from wp10 import config, iaao, sample # noqa: E402
23+
24+
25+def main() -> None:
26+ config.ensure_dirs()
27+ df = sample.load()
28+ out = config.REPRODUCED
29+
30+ # ------------------------------------------------------------ Table 1
31+ desc_vars = {
32+ "amount": "Sale price ($)",
33+ "role_valeur_immeuble": "Assessed value ($)",
34+ "ratio": "Assessment ratio AV/SP",
35+ "lag_months": "Roll lag (months)",
36+ "land_share": "Assessed land share",
37+ "age": "Building age (years)",
38+ "role_superficie_terrain_m2": "Lot area (m2)",
39+ "role_aire_etages_m2": "Floor area (m2)",
40+ }
41+ rows = []
42+ for var, label in desc_vars.items():
43+ s = df[var].dropna()
44+ rows.append({"variable": label, "n": len(s), "mean": s.mean(),
45+ "sd": s.std(), "p10": s.quantile(.10), "p50": s.median(),
46+ "p90": s.quantile(.90)})
47+ pd.DataFrame(rows).to_csv(out / "summary_stats.csv", index=False)
48+
49+ counts = {"n_sales": len(df), "n_munis": df["muni"].nunique(),
50+ "n_cells": df["cell"].nunique()}
51+ for k, v in df.groupby("prop_class").size().items():
52+ counts[f"n_{k}"] = int(v)
53+ pd.Series(counts).to_csv(out / "sample_counts.csv")
54+
55+ # ------------------------------------------------------------ overall + by year
56+ blocks = [("All sales 2021–2026", df)]
57+ blocks += [(str(y), g) for y, g in df.groupby("sale_year")]
58+ rows = []
59+ for label, g in blocks:
60+ av = g["role_valeur_immeuble"].to_numpy(float)
61+ sp = g["amount"].to_numpy(float)
62+ r = av / sp
63+ b, se = iaao.prb(av, sp)
64+ row = {"group": label, "n": len(g),
65+ "median_ratio": float(np.median(r)), "cod": iaao.cod(r),
66+ "prd": iaao.prd(av, sp), "prb": b, "prb_se": se}
67+ cis = iaao.bootstrap_ci(av, sp, n_boot=200)
68+ for stat, (lo, hi) in cis.items():
69+ row[f"{stat}_lo"], row[f"{stat}_hi"] = lo, hi
70+ rows.append(row)
71+ print(f" {label:<22} n={row['n']:>8,} med={row['median_ratio']:.3f} "
72+ f"COD={row['cod']:.1f} PRD={row['prd']:.3f} PRB={row['prb']:+.4f}")
73+ pd.DataFrame(rows).to_csv(out / "iaao_overall.csv", index=False)
74+
75+ # ---------------------------------------------------------------------
76+ # Municipality-level statistics. Within a municipality × sale-year block
77+ # a single roll is in force, so the roll lag is (nearly) constant and the
78+ # COD/PRB are not inflated by market-time drift. Annual blocks are then
79+ # aggregated to one row per municipality (median across years, total n).
80+ def annual_then_aggregate(data: pd.DataFrame, min_n: int) -> pd.DataFrame:
81+ blocks = iaao.group_metrics(data, ["muni", "sale_year"], min_n=min_n)
82+ blocks["muni"] = blocks["group"].str.rsplit("_", n=1).str[0]
83+ agg = (blocks.groupby("muni")
84+ .agg(n=("n", "sum"), n_years=("n", "size"),
85+ median_ratio=("median_ratio", "median"),
86+ cod=("cod", "median"), prd=("prd", "median"),
87+ prb=("prb", "median"))
88+ .reset_index())
89+ share_neg = (blocks.assign(neg=blocks["prb"] < 0)
90+ .groupby("muni")["neg"].mean().rename("share_years_prb_neg"))
91+ return agg.merge(share_neg, on="muni")
92+
93+ # ------------------------------------------------------------ ten largest markets
94+ big = df[df["role_municipalite"].isin(config.BIG_CITIES)].copy()
95+ big["muni"] = big["role_municipalite"] # aggregate by display name
96+ tab = annual_then_aggregate(big, min_n=200)
97+ tab.to_csv(out / "iaao_cities.csv", index=False)
98+
99+ # ------------------------------------------------------------ every muni ≥ 100 sales
100+ tab = annual_then_aggregate(df, min_n=50)
101+ tab = tab[tab["n"] >= config.MUNI_MIN_SALES]
102+ coords = df.groupby("muni")[["lat", "lng"]].median()
103+ names = df.groupby("muni")["role_municipalite"].first()
104+ tab = tab.merge(coords, left_on="muni", right_index=True)
105+ tab = tab.merge(names.rename("name"), left_on="muni", right_index=True)
106+ tab.to_csv(out / "iaao_muni.csv", index=False)
107+ print(f"\nMunicipality-level metrics: {len(tab)} municipalities "
108+ f"(median within-year COD {tab['cod'].median():.1f}, "
109+ f"share PRB<0: {(tab['prb'] < 0).mean():.1%})")
110+
111+
112+if __name__ == "__main__":
113+ main()
added scripts/03_estimate_regressions.py +133 −0
@@ -0,0 +1,133 @@
1+#!/usr/bin/env python3
2+# Author: Simon-Pierre Boucher — contact@spboucher.ai
3+"""Step 03 — Vertical-inequity regressions, heterogeneity, horizontal
4+inequity and the implied tax shift.
5+
6+Writes to ``results/reproduced/``:
7+ vertical.csv Cheng OLS / Cheng FE / Clapp IV / Paglin–Fogarty
8+ quantile.csv quantile-regression β(τ) on within-cell data
9+ heterogeneity.csv Cheng-FE γ by property class, age, land share, year,
10+ roll lag and municipality size
11+ binscatter.csv within-cell mean ln ratio by price vigintile
12+ horizontal.csv |deviation| regression (who gets noisy assessments)
13+ taxshift.csv over/under-taxation by within-cell price decile
14+ robustness.csv γ across sample and measurement variants
15+
16+Usage: python scripts/03_estimate_regressions.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 wp10 import config, models, sample # noqa: E402
27+
28+
29+def main() -> None:
30+ config.ensure_dirs()
31+ df = sample.load()
32+ out = config.REPRODUCED
33+
34+ # ------------------------------------------------------------ main table
35+ print("Vertical-inequity estimators")
36+ res = [models.cheng_pooled(df), models.cheng_fe(df), models.clapp_iv(df)]
37+ for r in res:
38+ print(f" {r['estimator']:<28} beta={r['beta']:.4f} ({r['se']:.4f}) "
39+ f"gamma={r['gamma']:+.4f} n={r['n']:,}")
40+ pf = models.paglin_fogarty(df)
41+ print(f" {pf['estimator']:<28} a={pf['intercept']:,.0f} "
42+ f"({pf['intercept_se']:,.0f}) b={pf['slope']:.4f}")
43+ pd.DataFrame(res).to_csv(out / "vertical.csv", index=False)
44+ pd.Series(pf).to_csv(out / "paglin_fogarty.csv")
45+
46+ # ------------------------------------------------------------ quantiles
47+ qt = models.quantile_betas(df)
48+ qt.to_csv(out / "quantile.csv", index=False)
49+ print("Quantile betas:", {f"{r.tau:.2f}": round(r.beta, 3)
50+ for r in qt.itertuples()})
51+
52+ # ------------------------------------------------------------ binscatter
53+ d = df.copy()
54+ d["lnr_w"] = d["ln_ratio"] - d.groupby("cell")["ln_ratio"].transform("mean")
55+ d["lnp_w"] = d["ln_price"] - d.groupby("cell")["ln_price"].transform("mean")
56+ d["bin"] = pd.qcut(d["lnp_w"], 20, labels=False)
57+ (d.groupby("bin")
58+ .agg(x=("lnp_w", "mean"), y=("lnr_w", "mean"),
59+ se=("lnr_w", lambda s: s.std() / np.sqrt(len(s))), n=("lnr_w", "size"))
60+ .reset_index()
61+ .to_csv(out / "binscatter.csv", index=False))
62+
63+ # ------------------------------------------------------------ heterogeneity
64+ muni_sales = df.groupby("muni")["muni"].transform("size")
65+ groups = {
66+ "Single-family": df["prop_class"] == "single_family",
67+ "Condominium": df["prop_class"] == "condo",
68+ "Plex (2–5 units)": df["prop_class"] == "plex",
69+ "Cottage": df["prop_class"] == "cottage",
70+ "Age < 20 y": df["age"] < 20,
71+ "Age 20–60 y": df["age"].between(20, 60),
72+ "Age > 60 y": df["age"] > 60,
73+ "Land share < 0.2": df["land_share"] < 0.2,
74+ "Land share 0.2–0.4": df["land_share"].between(0.2, 0.4),
75+ "Land share > 0.4": df["land_share"] > 0.4,
76+ "Roll lag < 24 m": df["lag_months"] < 24,
77+ "Roll lag 24–48 m": df["lag_months"].between(24, 48),
78+ "Roll lag > 48 m": df["lag_months"] > 48,
79+ "Muni < 1k sales": muni_sales < 1_000,
80+ "Muni 1k–10k sales": muni_sales.between(1_000, 10_000),
81+ "Muni > 10k sales": muni_sales > 10_000,
82+ }
83+ groups.update({f"Sales {y}": df["sale_year"] == y
84+ for y in sorted(df["sale_year"].unique())})
85+ het = models.gamma_by_group(df, groups)
86+ het.to_csv(out / "heterogeneity.csv", index=False)
87+ print(f"Heterogeneity: {len(het)} subgroups estimated")
88+
89+ # ------------------------------------------------------------ horizontal
90+ tab, meta = models.horizontal_dispersion(df)
91+ tab.to_csv(out / "horizontal.csv")
92+ pd.Series(meta).to_csv(out / "horizontal_meta.csv")
93+ print("Horizontal-dispersion regression:", meta)
94+
95+ # ------------------------------------------------------------ tax shift
96+ ts = models.tax_shift(df)
97+ ts.to_csv(out / "taxshift.csv", index=False)
98+ print("Tax shift by decile (mean %):",
99+ {int(r.decile): f"{r.mean_rel:+.1%}" for r in ts.itertuples()})
100+
101+ # ------------------------------------------------------------ robustness
102+ variants = {
103+ "Baseline": df,
104+ "Condominiums only": df[df["prop_class"] == "condo"],
105+ "Excluding sales < $100k": df[df["amount"] >= 100_000],
106+ "Match score = 220 (max)": df[df["match_score"] >= 219.9],
107+ "Match distance <= 10 m": df[df["match_dist_m"] <= 10],
108+ "Single-family only": df[df["prop_class"] == "single_family"],
109+ "Ratio trim 5/95": df[df["ratio"].between(
110+ df["ratio"].quantile(.05), df["ratio"].quantile(.95))],
111+ "Sales 2021-2023": df[df["sale_year"] <= 2023],
112+ "Sales 2024-2026": df[df["sale_year"] >= 2024],
113+ "Munis >= 300 sales": df[df.groupby("muni")["muni"]
114+ .transform("size") >= 300],
115+ "Cells >= 50 sales": df[df.groupby("cell")["cell"]
116+ .transform("size") >= 50],
117+ }
118+ rows = []
119+ for label, sub in variants.items():
120+ counts = sub.groupby("cell")["cell"].transform("size")
121+ sub = sub[counts >= config.CELL_MIN_OBS]
122+ est = models.cheng_fe(sub)
123+ iv = models.clapp_iv(sub)
124+ rows.append({"variant": label, "gamma_fe": est["gamma"],
125+ "se_fe": est["se"], "gamma_iv": iv["gamma"],
126+ "se_iv": iv["se"], "n": est["n"]})
127+ print(f" {label:<26} gamma_FE={est['gamma']:+.4f} "
128+ f"gamma_IV={iv['gamma']:+.4f} n={est['n']:,}")
129+ pd.DataFrame(rows).to_csv(out / "robustness.csv", index=False)
130+
131+
132+if __name__ == "__main__":
133+ main()
added scripts/04_make_figures.py +346 −0
@@ -0,0 +1,346 @@
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 analysis sample and the step-02/03 outputs, writes ten PNG figures
6+to ``figures/``. No titles are drawn inside single-panel figures — captions
7+in the manuscript carry the message; multi-panel figures use bold
8+"Panel A/B" headers. One accent hue per figure, doubled by linestyle or
9+marker so nothing is encoded by colour alone.
10+
11+Usage: python scripts/04_make_figures.py
12+"""
13+import sys
14+from pathlib import Path
15+
16+import numpy as np
17+import pandas as pd
18+
19+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
20+
21+from wp10 import config, sample # noqa: E402
22+from wp10.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH, # noqa: E402
23+ apply_style, panel_label, ygrid)
24+
25+import matplotlib.pyplot as plt # noqa: E402
26+import matplotlib.dates as mdates # noqa: E402
27+
28+OUT = config.FIGURES
29+RES = config.REPRODUCED
30+
31+
32+def _save(fig, name):
33+ fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02)
34+ plt.close(fig)
35+
36+
37+# ---------------------------------------------------------------- fig 1
38+def fig_ratio_dist(df):
39+ """Panel A: ratio histogram. Panel B: densities by roll lag."""
40+ fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 2.9))
41+
42+ ax = axes[0]
43+ ax.hist(df["ratio"], bins=120, range=(0, 2.5), color=LIGHT,
44+ edgecolor=INK, linewidth=0.25)
45+ med = df["ratio"].median()
46+ ax.axvline(med, color=INK, lw=0.9)
47+ ax.axvline(1.0, color=GREY, lw=0.8, ls=(0, (4, 3)))
48+ ax.text(med - 0.05, ax.get_ylim()[1] * 0.97, f"median = {med:.2f}",
49+ ha="right", va="top", fontsize=8)
50+ ax.text(1.04, ax.get_ylim()[1] * 0.75, "AV = SP", fontsize=8, color=GREY)
51+ ax.set_xlabel("Assessment ratio $AV/SP$")
52+ ax.set_ylabel("Sales")
53+ ax.set_yticks([])
54+ ax.spines["left"].set_visible(False)
55+ panel_label(ax, "Panel A. All sales")
56+
57+ ax = axes[1]
58+ specs = [("Roll lag $<$ 24 m", df["lag_months"] < 24, BLUES[2], "-"),
59+ ("24–48 m", df["lag_months"].between(24, 48), BLUES[4], (0, (5, 2))),
60+ ("$>$ 48 m", df["lag_months"] > 48, BLUES[5], (0, (1, 1.2)))]
61+ for label, m, color, ls in specs:
62+ ax.hist(df.loc[m, "ratio"], bins=120, range=(0, 2.5), density=True,
63+ histtype="step", lw=1.2, color=color, ls=ls, label=label)
64+ ax.set_xlabel("Assessment ratio $AV/SP$")
65+ ax.set_ylabel("Density")
66+ ax.legend(loc="upper right", handlelength=2.4)
67+ panel_label(ax, "Panel B. By roll lag at sale")
68+ fig.subplots_adjust(wspace=0.25)
69+ _save(fig, "fig_ratio_dist.png")
70+
71+
72+# ---------------------------------------------------------------- fig 2
73+def fig_binscatter():
74+ """Within-cell binned scatter of ln ratio on ln price — the core fact."""
75+ b = pd.read_csv(RES / "binscatter.csv")
76+ fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.3))
77+ ax.axhline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
78+ slope = np.polyfit(b["x"], b["y"], 1, w=b["n"])[0]
79+ icept = np.average(b["y"] - slope * b["x"], weights=b["n"])
80+ xs = np.linspace(b["x"].min(), b["x"].max(), 50)
81+ ax.plot(xs, slope * xs + icept, color=BLUE, lw=1.2, zorder=2)
82+ ax.errorbar(b["x"], b["y"], yerr=1.96 * b["se"], fmt="o", ms=4,
83+ mfc=INK, mec=INK, ecolor=INK, elinewidth=0.6, capsize=0,
84+ lw=0, zorder=3)
85+ ax.annotate(f"slope = ${slope:.3f}$", xy=(0.55, slope * 0.55 + icept),
86+ xytext=(0.32, 0.12), fontsize=8.5, color=BLUE,
87+ arrowprops=dict(arrowstyle="-", color=BLUE, lw=0.6,
88+ shrinkA=2, shrinkB=2))
89+ ax.set_xlabel("ln sale price (demeaned within municipality × roll × year)")
90+ ax.set_ylabel("ln assessment ratio (demeaned)")
91+ _save(fig, "fig_binscatter.png")
92+
93+
94+# ---------------------------------------------------------------- fig 3
95+def fig_time(df):
96+ """Median ratio by sale month; sequential blues by roll vintage,
97+ each segment labelled directly (no legend)."""
98+ d = df.copy()
99+ d["month"] = d["sale_date"].dt.to_period("M").dt.to_timestamp()
100+ fig, ax = plt.subplots(figsize=(TEXTWIDTH, 2.9))
101+ rolls = sorted(d["roll"].unique())
102+ for i, roll in enumerate(rolls):
103+ gg = d[d["roll"] == roll].groupby("month")["ratio"]
104+ g = gg.median()[gg.size() >= 100] # drop thin months (spurious spikes)
105+ g = g[g.index.notna()].sort_index()
106+ if len(g) < 3:
107+ continue
108+ color = BLUES[min(i, len(BLUES) - 1)]
109+ ax.plot(g.index, g.values, lw=1.3, color=color)
110+ ax.annotate(roll, xy=(g.index[-1], g.values[-1]),
111+ xytext=(3, 0), textcoords="offset points",
112+ fontsize=7.5, color=color, va="center")
113+ ax.axhline(1.0, color=GREY, lw=0.7, ls=(0, (4, 3)))
114+ ax.text(pd.Timestamp("2021-02-01"), 1.012, "parity", fontsize=7.5,
115+ color=GREY)
116+ ax.set_ylabel("Median assessment ratio")
117+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
118+ ygrid(ax)
119+ _save(fig, "fig_time.png")
120+
121+
122+# ---------------------------------------------------------------- fig 4
123+def fig_prb_muni():
124+ """Distribution of municipality-level PRB."""
125+ m = pd.read_csv(RES / "iaao_muni.csv")
126+ fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.1))
127+ ax.hist(m["prb"], bins=40, color=LIGHT, edgecolor=INK, linewidth=0.3)
128+ lo, hi = config.IAAO_PRB_RANGE
129+ ax.axvspan(lo, hi, color="0.92", zorder=0)
130+ ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
131+ med = m["prb"].median()
132+ ax.axvline(med, color=RED, lw=1.0)
133+ ymax = ax.get_ylim()[1]
134+ ax.text(med - 0.008, ymax * 0.97, f"median = {med:.2f}", ha="right",
135+ va="top", fontsize=8, color=RED)
136+ ax.text((lo + hi) / 2, ymax * 0.55, "IAAO\nband", ha="center",
137+ fontsize=7.5, color="0.35")
138+ share = (m["prb"] < 0).mean()
139+ ax.text(0.02, 0.97, f"{share:.0%} of municipalities\nhave PRB $<$ 0",
140+ transform=ax.transAxes, fontsize=8, va="top")
141+ ax.set_xlabel("Municipality-level PRB (median across sale years)")
142+ ax.set_ylabel("Municipalities")
143+ ygrid(ax)
144+ _save(fig, "fig_prb_muni.png")
145+
146+
147+# ---------------------------------------------------------------- fig 5
148+def fig_map():
149+ """Municipal PRB across the province (diverging hue, neutral midpoint)."""
150+ m = pd.read_csv(RES / "iaao_muni.csv")
151+ fig, ax = plt.subplots(figsize=(TEXTWIDTH, 4.4))
152+ v = m["prb"].clip(-0.30, 0.10)
153+ sc = ax.scatter(m["lng"], m["lat"], c=v, s=np.sqrt(m["n"]) * 0.9,
154+ cmap="RdBu", vmin=-0.30, vmax=0.30,
155+ edgecolor=INK, linewidth=0.25, alpha=0.9)
156+ cb = fig.colorbar(sc, ax=ax, shrink=0.75, pad=0.02)
157+ cb.set_label("PRB (negative = regressive)", fontsize=8)
158+ cb.ax.tick_params(labelsize=7.5)
159+ cb.outline.set_linewidth(0.5)
160+ offsets = {"Montréal": (6, -14), "Québec": (8, 4), "Gatineau": (-8, -14),
161+ "Sherbrooke": (8, -10), "Saguenay": (8, 4)}
162+ for _, r in m.nlargest(12, "n").iterrows():
163+ if r["name"] in offsets:
164+ ax.annotate(r["name"], (r["lng"], r["lat"]),
165+ xytext=offsets[r["name"]], textcoords="offset points",
166+ fontsize=7.5)
167+ ax.set_xlabel("Longitude")
168+ ax.set_ylabel("Latitude")
169+ ax.set_xlim(-80, -63)
170+ ax.set_ylim(44.9, 49.6)
171+ _save(fig, "fig_map.png")
172+
173+
174+# ---------------------------------------------------------------- fig 6
175+def fig_quantile():
176+ """β(τ) with a shaded 95% band; FE/IV benchmarks labelled directly."""
177+ q = pd.read_csv(RES / "quantile.csv")
178+ v = pd.read_csv(RES / "vertical.csv")
179+ fe = v.loc[v["estimator"].str.startswith("Cheng FE"), "beta"].iloc[0]
180+ iv = v.loc[v["estimator"].str.startswith("Clapp"), "beta"].iloc[0]
181+ fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.3))
182+ ax.fill_between(q["tau"], q["beta"] - 1.96 * q["se"],
183+ q["beta"] + 1.96 * q["se"], color=BLUE, alpha=0.18, lw=0)
184+ ax.plot(q["tau"], q["beta"], "o-", color=BLUE, ms=4, lw=1.3)
185+ x1, x0 = q["tau"].max(), q["tau"].min()
186+ for yv, lab, ls, xa, ha in [
187+ (1.0, r"$\beta = 1$ (proportional)", (0, (4, 3)), x1, "right"),
188+ (iv, f"Clapp IV ({iv:.2f})", (0, (1, 1.2)), x1, "right"),
189+ (fe, f"Cheng FE ({fe:.2f})", (0, (6, 2)), x0, "left")]:
190+ ax.axhline(yv, color=GREY, lw=0.8, ls=ls)
191+ ax.annotate(lab, xy=(xa, yv), xytext=(0, 3), textcoords="offset points",
192+ ha=ha, fontsize=7.5, color="0.35")
193+ ax.set_xlabel(r"Quantile $\tau$ of the conditional $\ln AV$ distribution")
194+ ax.set_ylabel(r"$\beta(\tau)$")
195+ ax.set_xticks(q["tau"])
196+ _save(fig, "fig_quantile.png")
197+
198+
199+# ---------------------------------------------------------------- fig 7
200+def fig_heterogeneity():
201+ """Forest plot of Cheng-FE γ by subgroup, with panel groupings."""
202+ h = pd.read_csv(RES / "heterogeneity.csv").set_index("group")
203+ panels = [
204+ ("Property class", ["Single-family", "Condominium", "Plex (2–5 units)",
205+ "Cottage"]),
206+ ("Building age", ["Age < 20 y", "Age 20–60 y", "Age > 60 y"]),
207+ ("Assessed land share", ["Land share < 0.2", "Land share 0.2–0.4",
208+ "Land share > 0.4"]),
209+ ("Roll lag at sale", ["Roll lag < 24 m", "Roll lag 24–48 m",
210+ "Roll lag > 48 m"]),
211+ ("Municipality size", ["Muni < 1k sales", "Muni 1k–10k sales",
212+ "Muni > 10k sales"]),
213+ ("Sale year", [f"Sales {y}" for y in range(2021, 2027)]),
214+ ]
215+ rows, ypos, headers, seps = [], [], [], []
216+ y = 0
217+ for name, keys in panels:
218+ headers.append((y, name))
219+ y -= 1
220+ for k in keys:
221+ if k in h.index:
222+ rows.append((y, k, h.loc[k]))
223+ ypos.append(y)
224+ y -= 1
225+ seps.append(y + 0.5)
226+ y -= 0.6
227+ fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 5.6))
228+ for yy, k, r in rows:
229+ ax.errorbar(r["gamma"], yy, xerr=1.96 * r["se"], fmt="o", ms=3.8,
230+ mfc=INK, mec=INK, ecolor=INK, elinewidth=0.8, capsize=1.5)
231+ ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
232+ for yy, name in headers:
233+ ax.text(-0.72, yy, name, fontsize=8.5, fontweight="bold", va="center")
234+ labels = {yy: k.replace("<", "$<$").replace(">", "$>$")
235+ for yy, k, _ in rows}
236+ ax.set_yticks(list(labels.keys()))
237+ ax.set_yticklabels(labels.values(), fontsize=8)
238+ ax.set_ylim(y + 0.4, 1.0)
239+ ax.set_xlim(-0.72, 0.12)
240+ ax.set_xlabel(r"$\gamma$ = elasticity of the assessment ratio with respect"
241+ " to price (negative = regressive)")
242+ ax.spines["left"].set_visible(False)
243+ ax.tick_params(axis="y", length=0)
244+ _save(fig, "fig_heterogeneity.png")
245+
246+
247+# ---------------------------------------------------------------- fig 8
248+def fig_taxshift():
249+ """Median excess tax burden by within-market price decile (polarity)."""
250+ t = pd.read_csv(RES / "taxshift.csv")
251+ fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.1))
252+ vals = 100 * t["median_rel"]
253+ colors = [RED if v > 0 else BLUE for v in vals]
254+ ax.bar(t["decile"], vals, width=0.72, color=colors, edgecolor=INK,
255+ linewidth=0.4)
256+ ax.axhline(0, color=INK, lw=0.7)
257+ for d, v in zip(t["decile"], vals):
258+ va = "bottom" if v > 0 else "top"
259+ off = 1.2 if v > 0 else -1.2
260+ ax.text(d, v + off, f"{v:+.1f}", ha="center", va=va, fontsize=7.5)
261+ ax.text(2.5, 45, "over-taxed", fontsize=8, color=RED, ha="center")
262+ ax.text(8.5, 14, "under-taxed", fontsize=8, color=BLUE, ha="center")
263+ ax.set_xticks(t["decile"])
264+ ax.set_xlabel("Within-market sale-price decile")
265+ ax.set_ylabel("Median excess tax burden (%)")
266+ ax.set_ylim(min(vals) - 8, max(vals) + 9)
267+ _save(fig, "fig_taxshift.png")
268+
269+
270+# ---------------------------------------------------------------- fig 9
271+def fig_cod():
272+ """Panel A: COD distribution. Panel B: COD vs market size."""
273+ m = pd.read_csv(RES / "iaao_muni.csv")
274+ fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 2.9))
275+
276+ ax = axes[0]
277+ ax.hist(m["cod"], bins=40, color=LIGHT, edgecolor=INK, linewidth=0.3)
278+ ax.axvline(config.IAAO_COD_MAX_SF, color=RED, lw=1.0)
279+ ymax = ax.get_ylim()[1]
280+ ax.text(config.IAAO_COD_MAX_SF + 0.6, ymax * 0.95,
281+ f"IAAO ceiling ({config.IAAO_COD_MAX_SF:.0f})", fontsize=7.5,
282+ color=RED, va="top")
283+ med = m["cod"].median()
284+ ax.axvline(med, color=INK, lw=0.9, ls=(0, (5, 2)))
285+ ax.text(med + 0.6, ymax * 0.72, f"median = {med:.1f}", fontsize=7.5)
286+ ax.set_xlabel("Municipality COD (median across sale years)")
287+ ax.set_ylabel("Municipalities")
288+ panel_label(ax, "Panel A. Distribution")
289+
290+ ax = axes[1]
291+ ax.scatter(m["n"], m["cod"], s=7, facecolor="none", edgecolor=INK,
292+ linewidth=0.5, alpha=0.65)
293+ ax.axhline(config.IAAO_COD_MAX_SF, color=RED, lw=0.9)
294+ ax.set_xscale("log")
295+ ax.set_xlabel("Sales in municipality, 2021–2026 (log scale)")
296+ ax.set_ylabel("COD")
297+ panel_label(ax, "Panel B. COD and market size")
298+ fig.subplots_adjust(wspace=0.25)
299+ _save(fig, "fig_cod.png")
300+
301+
302+# ---------------------------------------------------------------- fig 10
303+def fig_robustness():
304+ """γ across sample variants — FE (filled circles) vs IV (open squares)."""
305+ r = pd.read_csv(RES / "robustness.csv")
306+ fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.6))
307+ y = np.arange(len(r))[::-1].astype(float)
308+ ax.errorbar(r["gamma_fe"], y + 0.16, xerr=1.96 * r["se_fe"], fmt="o",
309+ ms=4, mfc=BLUE, mec=BLUE, ecolor=BLUE, elinewidth=0.8,
310+ capsize=1.5, lw=0, label="Cheng FE")
311+ ax.errorbar(r["gamma_iv"], y - 0.16, xerr=1.96 * r["se_iv"], fmt="s",
312+ ms=4, mfc="white", mec=RED, ecolor=RED, elinewidth=0.8,
313+ capsize=1.5, lw=0, label="Clapp IV")
314+ ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
315+ ax.set_yticks(y)
316+ labels = [str(v).replace("<=", "$\\leq$").replace(">=", "$\\geq$")
317+ .replace("<", "$<$").replace("$100k", "\\$100k")
318+ for v in r["variant"]]
319+ ax.set_yticklabels(labels, fontsize=8)
320+ ax.set_xlabel(r"$\gamma$ (negative = regressive)")
321+ ax.legend(loc="lower left", markerscale=1.1)
322+ ax.spines["left"].set_visible(False)
323+ ax.tick_params(axis="y", length=0)
324+ _save(fig, "fig_robustness.png")
325+
326+
327+def main() -> None:
328+ config.ensure_dirs()
329+ apply_style()
330+ df = sample.load()
331+ fig_ratio_dist(df)
332+ fig_binscatter()
333+ fig_time(df)
334+ fig_prb_muni()
335+ fig_map()
336+ fig_quantile()
337+ fig_heterogeneity()
338+ fig_taxshift()
339+ fig_cod()
340+ fig_robustness()
341+ made = sorted(p.name for p in OUT.glob("fig_*.png"))
342+ print(f"{len(made)} figures written:", ", ".join(made))
343+
344+
345+if __name__ == "__main__":
346+ main()
added scripts/05_make_tables.py +241 −0
@@ -0,0 +1,241 @@
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 CSV outputs into booktabs tables under
6+``results/tables/``. The paper inputs these files directly, so every number
7+in the manuscript is machine-generated.
8+
9+Usage: python scripts/05_make_tables.py
10+"""
11+import sys
12+from pathlib import Path
13+
14+import pandas as pd
15+
16+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
17+
18+from wp10 import config # noqa: E402
19+
20+RES = config.REPRODUCED
21+TAB = config.TABLES
22+
23+
24+def _stars(coef: float, se: float) -> str:
25+ t = abs(coef / se) if se > 0 else 0
26+ return "***" if t > 2.576 else "**" if t > 1.96 else "*" if t > 1.645 else ""
27+
28+
29+def _f(x, nd=3):
30+ return f"{x:,.{nd}f}"
31+
32+
33+def t_summary():
34+ s = pd.read_csv(RES / "summary_stats.csv")
35+ lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule",
36+ r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\",
37+ r"\midrule"]
38+ for _, r in s.iterrows():
39+ big = r["mean"] > 1000
40+ nd = 0 if big else 3 if r["mean"] < 10 else 1
41+ label = (str(r["variable"]).replace("($)", "(\\$)")
42+ .replace("(m2)", "(m$^2$)"))
43+ lines.append(
44+ f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & "
45+ f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & "
46+ f"{_f(r['p90'], nd)} \\\\")
47+ lines += [r"\bottomrule", r"\end{tabular}"]
48+ (TAB / "summary_stats.tex").write_text("\n".join(lines))
49+
50+
51+def t_iaao():
52+ d = pd.read_csv(RES / "iaao_overall.csv")
53+ lines = [r"\begin{tabular}{lrcccc}", r"\toprule",
54+ r"Sample & $n$ & Median ratio & COD & PRD & PRB \\",
55+ r"\midrule"]
56+ for _, r in d.iterrows():
57+ lines.append(
58+ f"{r['group']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & "
59+ f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} \\\\")
60+ lines.append(
61+ f" & & \\footnotesize[{_f(r['median_lo'])}, {_f(r['median_hi'])}]"
62+ f" & \\footnotesize[{_f(r['cod_lo'], 1)}, {_f(r['cod_hi'], 1)}]"
63+ f" & \\footnotesize[{_f(r['prd_lo'])}, {_f(r['prd_hi'])}]"
64+ f" & \\footnotesize[{_f(r['prb_lo'], 4)}, {_f(r['prb_hi'], 4)}] \\\\")
65+ lines += [r"\bottomrule", r"\end{tabular}"]
66+ (TAB / "iaao.tex").write_text("\n".join(lines))
67+
68+
69+def t_cities():
70+ d = pd.read_csv(RES / "iaao_cities.csv").sort_values("n", ascending=False)
71+ lines = [r"\begin{tabular}{lrccccc}", r"\toprule",
72+ r"Municipality & $n$ & Median ratio & COD & PRD & PRB & "
73+ r"\makecell{Share of years\\PRB $<$ 0} \\",
74+ r"\midrule"]
75+ for _, r in d.iterrows():
76+ lines.append(
77+ f"{r['muni']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & "
78+ f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} & "
79+ f"{_f(r['share_years_prb_neg'], 2)} \\\\")
80+ lines += [r"\bottomrule", r"\end{tabular}"]
81+ (TAB / "iaao_cities.tex").write_text("\n".join(lines))
82+
83+
84+def t_vertical():
85+ v = pd.read_csv(RES / "vertical.csv")
86+ pf = pd.read_csv(RES / "paglin_fogarty.csv", index_col=0).squeeze()
87+ pf = {k: (v if k == "estimator" else float(v)) for k, v in pf.items()}
88+ q = pd.read_csv(RES / "quantile.csv")
89+
90+ def row(label, vals):
91+ return label + " & " + " & ".join(vals) + r" \\"
92+
93+ cols = list(v["estimator"])
94+ lines = [r"\begin{tabular}{l" + "c" * len(cols) + "}", r"\toprule",
95+ row("", [f"({i+1})" for i in range(len(cols))]),
96+ row("", [c.replace(" (pooled)", "").replace(" (cell)", "")
97+ .replace(" (rank instrument)", "") for c in cols]),
98+ r"\midrule",
99+ row(r"$\beta$ (ln sale price)",
100+ [f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}"
101+ for _, r in v.iterrows()]),
102+ row("", [f"({_f(r['se'])})" for _, r in v.iterrows()]),
103+ row(r"$\gamma = \beta - 1$",
104+ [f"{r['gamma']:+.3f}" for _, r in v.iterrows()]),
105+ r"\midrule",
106+ row("Cell fixed effects",
107+ ["No", "Yes", "Yes"]),
108+ row("Error-in-price robust", ["No", "No", "Yes"]),
109+ row("Observations", [f"{r['n']:,.0f}" for _, r in v.iterrows()]),
110+ row("$R^2$", [f"{_f(r['r2'])}" for _, r in v.iterrows()]),
111+ r"\bottomrule", r"\end{tabular}"]
112+ (TAB / "vertical.tex").write_text("\n".join(lines))
113+ # stars in this table test H0: beta = 1 (proportionality), noted in caption
114+
115+ lines = [r"\begin{tabular}{lcc}", r"\toprule",
116+ r" & Coefficient & Std.\ error \\", r"\midrule",
117+ f"Intercept ($\\$$) & {pf['intercept']:,.0f} & "
118+ f"{pf['intercept_se']:,.0f} \\\\",
119+ f"Slope on sale price & {_f(pf['slope'])} & "
120+ f"{_f(pf['slope_se'])} \\\\",
121+ f"Observations & \\multicolumn{{2}}{{c}}{{{pf['n']:,.0f}}} \\\\",
122+ r"\bottomrule", r"\end{tabular}"]
123+ (TAB / "paglin_fogarty.tex").write_text("\n".join(lines))
124+
125+ lines = [r"\begin{tabular}{lccccc}", r"\toprule",
126+ "Quantile $\\tau$ & " + " & ".join(f"{t:.2f}" for t in q["tau"])
127+ + r" \\", r"\midrule",
128+ r"$\beta(\tau)$ & " + " & ".join(
129+ f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}"
130+ for _, r in q.iterrows()) + r" \\",
131+ " & " + " & ".join(f"({_f(r['se'], 4)})" for _, r in q.iterrows())
132+ + r" \\",
133+ r"\bottomrule", r"\end{tabular}"]
134+ (TAB / "quantile.tex").write_text("\n".join(lines))
135+
136+
137+def t_heterogeneity():
138+ h = pd.read_csv(RES / "heterogeneity.csv")
139+ panels = {
140+ "Property class": ["Single-family", "Condominium", "Plex (2–5 units)",
141+ "Cottage"],
142+ "Building age": ["Age < 20 y", "Age 20–60 y", "Age > 60 y"],
143+ "Assessed land share": ["Land share < 0.2", "Land share 0.2–0.4",
144+ "Land share > 0.4"],
145+ "Roll lag at sale": ["Roll lag < 24 m", "Roll lag 24–48 m",
146+ "Roll lag > 48 m"],
147+ "Municipality size": ["Muni < 1k sales", "Muni 1k–10k sales",
148+ "Muni > 10k sales"],
149+ "Sale year": [f"Sales {y}" for y in range(2021, 2027)],
150+ }
151+ lines = [r"\begin{tabular}{lccr}", r"\toprule",
152+ r"Subsample & $\gamma$ & Std.\ error & $n$ \\"]
153+ for panel, keys in panels.items():
154+ lines.append(r"\midrule")
155+ lines.append(r"\multicolumn{4}{l}{\itshape " + panel + r"}\\")
156+ for k in keys:
157+ r = h[h["group"] == k]
158+ if r.empty:
159+ continue
160+ r = r.iloc[0]
161+ label = (k.replace("<", "$<$").replace(">", "$>$")
162+ .replace("–", "--"))
163+ lines.append(
164+ f"\\quad {label} & {r['gamma']:+.3f}"
165+ f"{_stars(r['gamma'], r['se'])} & ({_f(r['se'])}) & "
166+ f"{r['n']:,.0f} \\\\")
167+ lines += [r"\bottomrule", r"\end{tabular}"]
168+ (TAB / "heterogeneity.tex").write_text("\n".join(lines))
169+
170+
171+def t_taxshift():
172+ t = pd.read_csv(RES / "taxshift.csv")
173+ lines = [r"\begin{tabular}{lcccc}", r"\toprule",
174+ r"Price decile & $n$ & \makecell{Mean excess\\burden (\%)} & "
175+ r"\makecell{Median excess\\burden (\%)} & Std.\ error \\",
176+ r"\midrule"]
177+ for _, r in t.iterrows():
178+ lines.append(
179+ f"{int(r['decile'])} & {r['n']:,.0f} & "
180+ f"{100 * r['mean_rel']:+.1f} & {100 * r['median_rel']:+.1f} & "
181+ f"({100 * r['se']:.2f}) \\\\")
182+ lines += [r"\bottomrule", r"\end{tabular}"]
183+ (TAB / "taxshift.tex").write_text("\n".join(lines))
184+
185+
186+def t_horizontal():
187+ h = pd.read_csv(RES / "horizontal.csv", index_col=0)
188+ meta = pd.read_csv(RES / "horizontal_meta.csv", index_col=0).squeeze()
189+ labels = {"age_dec": "Building age (decades)",
190+ "land_share": "Assessed land share",
191+ "is_condo": "Condominium",
192+ "is_plex": "Plex (2--5 units)",
193+ "is_cottage": "Cottage"}
194+ lines = [r"\begin{tabular}{lcc}", r"\toprule",
195+ r" & Coefficient & Std.\ error \\", r"\midrule"]
196+ for k, lab in labels.items():
197+ r = h.loc[k]
198+ lines.append(f"{lab} & {r['coef']:+.4f}{_stars(r['coef'], r['se'])} & "
199+ f"({_f(r['se'], 4)}) \\\\")
200+ lines += [r"\midrule",
201+ f"Mean of dependent variable & \\multicolumn{{2}}{{c}}"
202+ f"{{{float(meta['mean_dep']):.3f}}} \\\\",
203+ f"Observations & \\multicolumn{{2}}{{c}}{{{float(meta['n']):,.0f}}} \\\\",
204+ r"\bottomrule", r"\end{tabular}"]
205+ (TAB / "horizontal.tex").write_text("\n".join(lines))
206+
207+
208+def t_robustness():
209+ r = pd.read_csv(RES / "robustness.csv")
210+ lines = [r"\begin{tabular}{lccccr}", r"\toprule",
211+ r"Sample variant & $\gamma_{\text{FE}}$ & Std.\ err. & "
212+ r"$\gamma_{\text{IV}}$ & Std.\ err. & $n$ \\",
213+ r"\midrule"]
214+ for _, x in r.iterrows():
215+ label = (str(x["variant"]).replace("<=", "$\\le$")
216+ .replace(">=", "$\\ge$").replace("$100k", "\\$100k"))
217+ lines.append(
218+ f"{label} & {x['gamma_fe']:+.3f}{_stars(x['gamma_fe'], x['se_fe'])}"
219+ f" & ({_f(x['se_fe'])}) & "
220+ f"{x['gamma_iv']:+.3f}{_stars(x['gamma_iv'], x['se_iv'])} & "
221+ f"({_f(x['se_iv'])}) & {x['n']:,.0f} \\\\")
222+ lines += [r"\bottomrule", r"\end{tabular}"]
223+ (TAB / "robustness.tex").write_text("\n".join(lines))
224+
225+
226+def main() -> None:
227+ config.ensure_dirs()
228+ t_summary()
229+ t_iaao()
230+ t_cities()
231+ t_vertical()
232+ t_heterogeneity()
233+ t_taxshift()
234+ t_horizontal()
235+ t_robustness()
236+ made = sorted(p.name for p in TAB.glob("*.tex"))
237+ print(f"{len(made)} tables written:", ", ".join(made))
238+
239+
240+if __name__ == "__main__":
241+ main()
added src/wp10/__init__.py +11 −0
@@ -0,0 +1,11 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""WP10 — The Assessment Gap in Quebec.
3+
4+Vertical and horizontal inequity in municipal property valuation, estimated
5+from 600k+ residential transactions matched to the provincial assessment roll
6+(rôle d'évaluation foncière), 2021–2026.
7+"""
8+
9+__version__ = "1.0"
10+__author__ = "Simon-Pierre Boucher"
11+__email__ = "contact@spboucher.ai"
added src/wp10/config.py +54 −0
@@ -0,0 +1,54 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""Paths and global constants for the WP10 pipeline.
3+
4+All paths are relative to the repository root, so the pipeline runs on any
5+machine after cloning. Override the root with the ``WP10_ROOT`` environment
6+variable if the scripts are launched from elsewhere.
7+"""
8+import os
9+from pathlib import Path
10+
11+ROOT = Path(os.environ.get("WP10_ROOT", Path(__file__).resolve().parents[2]))
12+
13+RAW_PARQUET = ROOT / "data" / "raw" / "transactions_700k_avec_registre_foncier.parquet"
14+PROCESSED = ROOT / "data" / "processed"
15+ANALYSIS_PARQUET = PROCESSED / "analysis.parquet"
16+FIGURES = ROOT / "figures"
17+RESULTS = ROOT / "results"
18+REPRODUCED = RESULTS / "reproduced"
19+TABLES = RESULTS / "tables"
20+
21+# ---------------------------------------------------------------- sample
22+# Residential CUBF use codes kept in the estimation sample
23+# 1000 Logement | 1100 Chalet ou maison de villégiature | 1211 Maison mobile
24+# 1990 Autres immeubles résidentiels
25+RESIDENTIAL_CUBF = {"1000", "1100", "1211", "1990"}
26+
27+MATCH_MAX_DIST_M = 50.0 # roll–transaction geocoding distance ceiling
28+MATCH_MIN_SCORE = 150.0 # matcher confidence floor (max attainable: 220)
29+
30+PRICE_MIN = 50_000 # source feed already truncates below 50k
31+RATIO_TRIM = (0.01, 0.99) # symmetric trim on the assessment ratio, by roll vintage
32+
33+CELL_MIN_OBS = 20 # minimum sales per municipality×roll×year cell (FE cells)
34+MUNI_MIN_SALES = 100 # minimum sales for municipality-level IAAO statistics
35+
36+# ---------------------------------------------------------------- inference
37+SEED_BOOT = 20260809 # bootstrap seed (IAAO confidence intervals)
38+N_BOOT = 500 # bootstrap replications
39+QUANTILES = [0.10, 0.25, 0.50, 0.75, 0.90] # quantile-regression grid
40+
41+# IAAO (2013) acceptable ranges, quoted in the paper's tables
42+IAAO_COD_MAX_SF = 15.0 # single-family, heterogeneous areas
43+IAAO_PRD_RANGE = (0.98, 1.03)
44+IAAO_PRB_RANGE = (-0.05, 0.05)
45+
46+# Ten largest markets reported individually in the IAAO table
47+BIG_CITIES = ["Montréal", "Québec", "Laval", "Gatineau", "Longueuil",
48+ "Lévis", "Sherbrooke", "Saguenay", "Trois-Rivières", "Terrebonne"]
49+
50+
51+def ensure_dirs() -> None:
52+ """Create every output directory the pipeline writes to."""
53+ for p in (PROCESSED, FIGURES, REPRODUCED, TABLES):
54+ p.mkdir(parents=True, exist_ok=True)
added src/wp10/iaao.py +109 −0
@@ -0,0 +1,109 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""IAAO ratio-study statistics.
3+
4+Implements the standard diagnostics of the IAAO (2013) *Standard on Ratio
5+Studies*: the median assessment ratio, the coefficient of dispersion (COD),
6+the price-related differential (PRD), and the coefficient of price-related
7+bias (PRB), together with nonparametric bootstrap confidence intervals.
8+
9+Notation: r_i = AV_i / SP_i is the assessment ratio of sale i.
10+"""
11+from __future__ import annotations
12+
13+import numpy as np
14+import pandas as pd
15+
16+from . import config
17+
18+
19+# ---------------------------------------------------------------- point stats
20+def cod(ratio: np.ndarray) -> float:
21+ """Coefficient of dispersion: 100 × mean |r − med| / med (horizontal equity)."""
22+ med = np.median(ratio)
23+ return 100.0 * np.mean(np.abs(ratio - med)) / med
24+
25+
26+def prd(av: np.ndarray, sp: np.ndarray) -> float:
27+ """Price-related differential: mean ratio ÷ sale-weighted mean ratio.
28+
29+ PRD > 1 indicates regressivity (low-priced properties assessed at higher
30+ ratios); the IAAO acceptable range is [0.98, 1.03].
31+ """
32+ r = av / sp
33+ return float(np.mean(r) / (np.sum(av) / np.sum(sp)))
34+
35+
36+def prb(av: np.ndarray, sp: np.ndarray) -> tuple[float, float]:
37+ """Coefficient of price-related bias (IAAO 2013, Appendix on PRB).
38+
39+ Regress the proportional deviation of the ratio from its median on the
40+ log (base 2) of a value proxy that blends the sale price and the
41+ median-deflated assessment:
42+
43+ (r_i − med) / med = α + PRB · ln2( 0.5·SP_i + 0.5·AV_i/med ) + u_i
44+
45+ Returns (PRB, robust standard error). PRB = −0.03 means ratios fall by
46+ 3% of the median with every doubling of value: regressive if PRB < 0.
47+ """
48+ r = av / sp
49+ med = np.median(r)
50+ y = (r - med) / med
51+ proxy = 0.5 * sp + 0.5 * av / med
52+ x = np.log2(proxy)
53+ X = np.column_stack([np.ones_like(x), x])
54+ beta, *_ = np.linalg.lstsq(X, y, rcond=None)
55+ resid = y - X @ beta
56+ # HC1 robust standard error of the slope
57+ XtX_inv = np.linalg.inv(X.T @ X)
58+ meat = (X * (resid ** 2)[:, None]).T @ X
59+ k = len(y) / (len(y) - 2)
60+ se = float(np.sqrt(k * (XtX_inv @ meat @ XtX_inv)[1, 1]))
61+ return float(beta[1]), se
62+
63+
64+# ---------------------------------------------------------------- bootstrap
65+def bootstrap_ci(av: np.ndarray, sp: np.ndarray,
66+ n_boot: int = config.N_BOOT,
67+ seed: int = config.SEED_BOOT) -> dict:
68+ """Percentile bootstrap 95% CIs for the median ratio, COD, PRD and PRB."""
69+ rng = np.random.default_rng(seed)
70+ n = len(av)
71+ stats = {"median": [], "cod": [], "prd": [], "prb": []}
72+ for _ in range(n_boot):
73+ idx = rng.integers(0, n, n)
74+ a, s = av[idx], sp[idx]
75+ r = a / s
76+ stats["median"].append(np.median(r))
77+ stats["cod"].append(cod(r))
78+ stats["prd"].append(prd(a, s))
79+ stats["prb"].append(prb(a, s)[0])
80+ return {k: (float(np.percentile(v, 2.5)), float(np.percentile(v, 97.5)))
81+ for k, v in stats.items()}
82+
83+
84+# ---------------------------------------------------------------- group table
85+def group_metrics(df: pd.DataFrame, by: str | list[str],
86+ min_n: int = 50, ci: bool = False) -> pd.DataFrame:
87+ """IAAO statistics computed within each group of ``by``.
88+
89+ Returns one row per group with n, median ratio, COD, PRD, PRB (and its
90+ SE); optionally percentile-bootstrap CIs (slow — reserve for headline
91+ rows).
92+ """
93+ rows = []
94+ for key, g in df.groupby(by):
95+ if len(g) < min_n:
96+ continue
97+ av = g["role_valeur_immeuble"].to_numpy(float)
98+ sp = g["amount"].to_numpy(float)
99+ r = av / sp
100+ b, se = prb(av, sp)
101+ row = {"group": key if isinstance(key, str) else "_".join(map(str, key)),
102+ "n": len(g), "median_ratio": float(np.median(r)),
103+ "cod": cod(r), "prd": prd(av, sp), "prb": b, "prb_se": se}
104+ if ci:
105+ cis = bootstrap_ci(av, sp)
106+ for stat, (lo, hi) in cis.items():
107+ row[f"{stat}_lo"], row[f"{stat}_hi"] = lo, hi
108+ rows.append(row)
109+ return pd.DataFrame(rows)
added src/wp10/models.py +202 −0
@@ -0,0 +1,202 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""Econometric estimators for WP10.
3+
4+Every vertical-inequity estimator ultimately measures how the assessment
5+ratio r = AV/SP moves with market value. The workhorse is the log-log
6+(Cheng 1974) regression
7+
8+ ln AV_i = α_c(i) + β · ln SP_i + ε_i ,
9+
10+where α_c(i) is a municipality × roll vintage × sale-year fixed effect that
11+absorbs the mechanical drift of ratios between triennial reference dates.
12+β < 1 ⇒ regressive assessment. We report γ ≡ β − 1, the elasticity of the
13+assessment *ratio* with respect to price (γ < 0 ⇒ regressive), estimated by
14+
15+ - pooled OLS (no fixed effects),
16+ - absorbing least squares with cell fixed effects,
17+ - Clapp's (1990) rank-based IV, which purges the attenuation/mean-reversion
18+ bias that pushes OLS toward spurious regressivity when sale prices carry
19+ idiosyncratic noise,
20+ - quantile regression across the conditional distribution.
21+
22+Inference is clustered by municipality throughout.
23+"""
24+from __future__ import annotations
25+
26+import numpy as np
27+import pandas as pd
28+import statsmodels.api as sm
29+from linearmodels.iv import IV2SLS
30+from linearmodels.iv.absorbing import AbsorbingLS
31+
32+from . import config
33+
34+
35+# ---------------------------------------------------------------- helpers
36+def _demean(df: pd.DataFrame, cols: list[str], by: str = "cell") -> pd.DataFrame:
37+ """Within-transform ``cols`` inside groups of ``by`` (suffix ``_w``)."""
38+ out = df.copy()
39+ for c in cols:
40+ out[c + "_w"] = df[c] - df.groupby(by)[c].transform("mean")
41+ return out
42+
43+
44+def _cluster_ols(y: np.ndarray, X: np.ndarray, clusters: np.ndarray,
45+ dof_adjust: int = 0):
46+ """OLS with CRVE (cluster-robust) inference via statsmodels."""
47+ model = sm.OLS(y, X)
48+ return model.fit(cov_type="cluster", cov_kwds={"groups": clusters})
49+
50+
51+# ---------------------------------------------------------------- Cheng / FE
52+def cheng_pooled(df: pd.DataFrame) -> dict:
53+ """Pooled Cheng regression: ln AV on ln SP, no fixed effects."""
54+ X = sm.add_constant(df["ln_price"].to_numpy())
55+ res = _cluster_ols(df["ln_av"].to_numpy(), X, df["muni"].to_numpy())
56+ return {"estimator": "Cheng OLS (pooled)", "beta": res.params[1],
57+ "se": res.bse[1], "gamma": res.params[1] - 1.0,
58+ "n": int(res.nobs), "r2": res.rsquared}
59+
60+
61+def cheng_fe(df: pd.DataFrame, extra_controls: list[str] | None = None) -> dict:
62+ """Cheng regression absorbing municipality × roll × sale-year cells."""
63+ dep = df["ln_av"]
64+ exog_cols = ["ln_price"] + (extra_controls or [])
65+ exog = df[exog_cols]
66+ absorb = pd.DataFrame({"cell": df["cell"].astype("category")})
67+ mod = AbsorbingLS(dep, exog, absorb=absorb)
68+ res = mod.fit(cov_type="clustered", clusters=df["muni"].astype("category"))
69+ return {"estimator": "Cheng FE (cell)", "beta": float(res.params["ln_price"]),
70+ "se": float(res.std_errors["ln_price"]),
71+ "gamma": float(res.params["ln_price"]) - 1.0,
72+ "n": int(res.nobs), "r2": float(res.rsquared)}
73+
74+
75+def paglin_fogarty(df: pd.DataFrame) -> dict:
76+ """Levels regression AV = a + b·SP (Paglin & Fogarty 1972).
77+
78+ A positive intercept with b below the overall assessment level indicates
79+ regressivity in levels.
80+ """
81+ X = sm.add_constant(df["amount"].to_numpy(float))
82+ res = _cluster_ols(df["role_valeur_immeuble"].to_numpy(float), X,
83+ df["muni"].to_numpy())
84+ return {"estimator": "Paglin–Fogarty (levels)",
85+ "intercept": res.params[0], "intercept_se": res.bse[0],
86+ "slope": res.params[1], "slope_se": res.bse[1],
87+ "n": int(res.nobs), "r2": res.rsquared}
88+
89+
90+# ---------------------------------------------------------------- Clapp IV
91+def clapp_iv(df: pd.DataFrame) -> dict:
92+ """Clapp (1990) rank-based IV on within-cell demeaned data.
93+
94+ The instrument z ∈ {−1, 0, +1} flags sales in the bottom/top third of
95+ *both* the ln AV and ln SP within-cell distributions. Because z carries
96+ only coarse rank information, it is (near-)orthogonal to the transitory
97+ component of either variable, undoing the attenuation that biases OLS
98+ toward regressivity (β̂ < β) when prices are noisy.
99+ """
100+ d = _demean(df, ["ln_av", "ln_price"])
101+ g_av = d.groupby("cell")["ln_av"].rank(pct=True)
102+ g_sp = d.groupby("cell")["ln_price"].rank(pct=True)
103+ z = np.zeros(len(d))
104+ z[(g_av <= 1 / 3) & (g_sp <= 1 / 3)] = -1.0
105+ z[(g_av > 2 / 3) & (g_sp > 2 / 3)] = 1.0
106+ d["z"] = z
107+ res = IV2SLS(d["ln_av_w"], None, d[["ln_price_w"]], d[["z"]]).fit(
108+ cov_type="clustered", clusters=d["muni"].astype("category"))
109+ beta = float(res.params["ln_price_w"])
110+ return {"estimator": "Clapp IV (rank instrument)", "beta": beta,
111+ "se": float(res.std_errors["ln_price_w"]), "gamma": beta - 1.0,
112+ "n": int(res.nobs), "r2": float(res.rsquared)}
113+
114+
115+# ---------------------------------------------------------------- quantiles
116+def quantile_betas(df: pd.DataFrame, taus: list[float] = config.QUANTILES,
117+ max_n: int = 250_000, seed: int = config.SEED_BOOT) -> pd.DataFrame:
118+ """Quantile regressions of demeaned ln AV on demeaned ln SP.
119+
120+ Estimated on a seeded random subsample (IRLS on the full 600k sample is
121+ needlessly slow; the subsample SEs are already microscopic).
122+ """
123+ d = _demean(df, ["ln_av", "ln_price"])
124+ if len(d) > max_n:
125+ d = d.sample(max_n, random_state=seed)
126+ X = sm.add_constant(d["ln_price_w"].to_numpy())
127+ y = d["ln_av_w"].to_numpy()
128+ rows = []
129+ for tau in taus:
130+ r = sm.QuantReg(y, X).fit(q=tau, max_iter=2000)
131+ rows.append({"tau": tau, "beta": r.params[1], "se": r.bse[1],
132+ "gamma": r.params[1] - 1.0, "n": len(d)})
133+ return pd.DataFrame(rows)
134+
135+
136+# ---------------------------------------------------------------- subgroups
137+def gamma_by_group(df: pd.DataFrame, groups: dict[str, pd.Series]) -> pd.DataFrame:
138+ """Cheng-FE γ estimated separately on each labelled subsample.
139+
140+ ``groups`` maps a label to a boolean mask over ``df``. Subsamples keep
141+ only cells that retain ≥ CELL_MIN_OBS sales after masking.
142+ """
143+ rows = []
144+ for label, mask in groups.items():
145+ sub = df[mask]
146+ counts = sub.groupby("cell")["cell"].transform("size")
147+ sub = sub[counts >= config.CELL_MIN_OBS]
148+ if len(sub) < 2_000:
149+ continue
150+ est = cheng_fe(sub)
151+ rows.append({"group": label, "gamma": est["gamma"], "se": est["se"],
152+ "n": est["n"]})
153+ return pd.DataFrame(rows)
154+
155+
156+# ---------------------------------------------------------------- horizontal
157+def horizontal_dispersion(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
158+ """Horizontal inequity: who gets the noisiest assessments?
159+
160+ Regresses the absolute log deviation of a sale's ratio from its cell
161+ median, |ln r_i − med_c ln r|, on property characteristics with cell
162+ fixed effects. Positive coefficients = less uniform assessment.
163+ Returns (coefficient table, fit metadata).
164+ """
165+ d = df.copy()
166+ d["abs_dev"] = (d["ln_ratio"]
167+ - d.groupby("cell")["ln_ratio"].transform("median")).abs()
168+ d["age_dec"] = d["age"] / 10.0
169+ d["is_condo"] = (d["prop_class"] == "condo").astype(float)
170+ d["is_plex"] = (d["prop_class"] == "plex").astype(float)
171+ d["is_cottage"] = (d["prop_class"] == "cottage").astype(float)
172+ d = d.dropna(subset=["abs_dev", "age_dec", "land_share"])
173+ exog_cols = ["age_dec", "land_share", "is_condo", "is_plex", "is_cottage"]
174+ mod = AbsorbingLS(d["abs_dev"], d[exog_cols],
175+ absorb=pd.DataFrame({"cell": d["cell"].astype("category")}))
176+ res = mod.fit(cov_type="clustered", clusters=d["muni"].astype("category"))
177+ tab = pd.DataFrame({"coef": res.params, "se": res.std_errors,
178+ "tstat": res.tstats})
179+ return tab, {"n": int(res.nobs), "r2": float(res.rsquared),
180+ "mean_dep": float(d["abs_dev"].mean())}
181+
182+
183+# ---------------------------------------------------------------- tax shift
184+def tax_shift(df: pd.DataFrame, n_bins: int = 10) -> pd.DataFrame:
185+ """Implied property-tax shift from differential assessment.
186+
187+ Within a taxing cell the levy is proportional to AV, so a property whose
188+ ratio exceeds the cell median by x% pays x% more tax than under uniform
189+ assessment. We compute rel_i = r_i / med_c(r) − 1 and average it by
190+ within-cell sale-price decile.
191+ """
192+ d = df.copy()
193+ d["rel"] = d["ratio"] / d.groupby("cell")["ratio"].transform("median") - 1.0
194+ d["decile"] = (d.groupby("cell")["amount"]
195+ .rank(pct=True)
196+ .mul(n_bins).add(1 - 1e-9).astype(int).clip(1, n_bins))
197+ out = (d.groupby("decile")
198+ .agg(n=("rel", "size"), mean_rel=("rel", "mean"),
199+ median_rel=("rel", "median"),
200+ se=("rel", lambda s: s.std() / np.sqrt(len(s))))
201+ .reset_index())
202+ return out
added src/wp10/plotstyle.py +75 −0
@@ -0,0 +1,75 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""Shared matplotlib style for all WP10 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/wp10/sample.py +128 −0
@@ -0,0 +1,128 @@
1+# Author: Simon-Pierre Boucher — contact@spboucher.ai
2+"""Estimation-sample construction for WP10.
3+
4+Reads the raw matched transaction–roll snapshot, restricts to residential
5+arm's-length sales with a high-confidence roll match, builds the assessment
6+ratio and every derived regressor, applies the trims, and writes
7+``data/processed/analysis.parquet``.
8+
9+Key variable definitions
10+------------------------
11+ratio AV / SP where AV = ``role_valeur_immeuble`` (total assessed value
12+ on the roll in force at the sale date) and SP = ``amount``.
13+ln_ratio log(ratio).
14+cell municipality × roll vintage × sale year — the market-timing cell
15+ inside which assessed values share a common reference date, so
16+ the roll lag is constant and ratio comparisons are clean.
17+lag_months months elapsed between the roll's market-condition reference
18+ date (July 1, by statute) and the sale date.
19+land_share assessed land value / total assessed value.
20+age sale year minus year built (from the roll where available).
21+"""
22+import numpy as np
23+import pandas as pd
24+
25+from . import config
26+
27+
28+def _property_class(df: pd.DataFrame) -> pd.Series:
29+ """Harmonised dwelling class from the listing type, CUBF code and unit count."""
30+ cls = pd.Series("other", index=df.index, dtype="object")
31+ cls[df["role_cubf"] == "1100"] = "cottage"
32+ cls[df["role_cubf"] == "1211"] = "mobile"
33+ is_dwelling = df["role_cubf"] == "1000"
34+ cls[is_dwelling & (df["propertyType"] == "condo")] = "condo"
35+ cls[is_dwelling & (df["propertyType"] == "plex")] = "plex"
36+ cls[is_dwelling & (df["propertyType"] == "unifamilial")] = "single_family"
37+ # dwellings the feed left untyped: use the roll's unit count
38+ untyped = is_dwelling & (cls == "other")
39+ cls[untyped & (df["role_nb_logements"] == 1)] = "single_family"
40+ cls[untyped & (df["role_nb_logements"].between(2, 5))] = "plex"
41+ return cls
42+
43+
44+def build(raw: pd.DataFrame | None = None) -> pd.DataFrame:
45+ """Apply every sample restriction and return the analysis DataFrame."""
46+ df = raw if raw is not None else pd.read_parquet(config.RAW_PARQUET)
47+ n0 = len(df)
48+ log = [("raw snapshot", n0)]
49+
50+ # -------------------------------------------------- residential use codes
51+ df = df[df["role_cubf"].isin(config.RESIDENTIAL_CUBF)]
52+ log.append(("residential CUBF (1000/1100/1211/1990)", len(df)))
53+
54+ # -------------------------------------------------- match confidence
55+ df = df[(df["match_dist_m"] <= config.MATCH_MAX_DIST_M)
56+ & (df["match_score"] >= config.MATCH_MIN_SCORE)]
57+ log.append((f"match dist ≤ {config.MATCH_MAX_DIST_M:.0f} m & score ≥ "
58+ f"{config.MATCH_MIN_SCORE:.0f}", len(df)))
59+
60+ # -------------------------------------------------- valid AV and SP
61+ df = df[(df["amount"] >= config.PRICE_MIN)
62+ & (df["role_valeur_immeuble"] > 5_000)
63+ & df["role_date_cond_marche"].notna()]
64+ log.append(("positive AV, SP ≥ 50k, dated roll", len(df)))
65+
66+ # -------------------------------------------------- derived variables
67+ df = df.copy()
68+ df["sale_date"] = pd.to_datetime(df["date"])
69+ df["sale_year"] = df["tx_year"].astype(int)
70+ df["ratio"] = df["role_valeur_immeuble"] / df["amount"]
71+ df["ln_ratio"] = np.log(df["ratio"])
72+ df["ln_price"] = np.log(df["amount"].astype(float))
73+ df["ln_av"] = np.log(df["role_valeur_immeuble"])
74+ df["lag_months"] = ((df["sale_date"] - df["role_date_cond_marche"]).dt.days
75+ / 30.44)
76+ df["land_share"] = (df["role_valeur_terrain"]
77+ / df["role_valeur_immeuble"]).clip(0, 1)
78+ df["age"] = (df["sale_year"]
79+ - pd.to_numeric(df["role_annee_construction"], errors="coerce"))
80+ df.loc[(df["age"] < 0) | (df["age"] > 300), "age"] = np.nan
81+ df["prop_class"] = _property_class(df)
82+ df["muni"] = df["role_code_mun"]
83+ df["roll"] = df["role_anrole"]
84+ df["cell"] = (df["muni"] + "_" + df["roll"] + "_"
85+ + df["sale_year"].astype(str))
86+
87+ # -------------------------------------------------- ratio trim (by roll vintage,
88+ # so the mechanical drift of ratios across reference dates is not trimmed away)
89+ lo, hi = config.RATIO_TRIM
90+ q = df.groupby("roll")["ratio"].quantile([lo, hi]).unstack()
91+ df = df.join(q.rename(columns={lo: "_qlo", hi: "_qhi"}), on="roll")
92+ df = df[(df["ratio"] >= df["_qlo"]) & (df["ratio"] <= df["_qhi"])]
93+ df = df.drop(columns=["_qlo", "_qhi"])
94+ log.append((f"ratio inside [{lo:.0%}, {hi:.0%}] of its roll vintage", len(df)))
95+
96+ # -------------------------------------------------- market-timing cells
97+ counts = df.groupby("cell")["cell"].transform("size")
98+ df = df[counts >= config.CELL_MIN_OBS]
99+ log.append((f"cells (muni × roll × year) with ≥ {config.CELL_MIN_OBS} sales",
100+ len(df)))
101+
102+ df.attrs["selection_log"] = log
103+ keep = ["id", "sale_date", "sale_year", "amount", "ln_price",
104+ "role_valeur_immeuble", "role_valeur_terrain", "role_valeur_batiment",
105+ "totalArValue", "previousValue", "ln_av", "ratio", "ln_ratio",
106+ "lag_months", "land_share", "age", "prop_class",
107+ "muni", "roll", "cell", "city", "role_municipalite",
108+ "lat", "lng", "role_superficie_terrain_m2", "role_aire_etages_m2",
109+ "role_nb_logements", "match_dist_m", "match_score",
110+ "match_valeur_exacte", "ownerType", "role_cubf"]
111+ return df[keep].reset_index(drop=True)
112+
113+
114+def build_and_save() -> pd.DataFrame:
115+ """Build the sample, print the selection log, persist to parquet."""
116+ config.ensure_dirs()
117+ s = build()
118+ for step, n in s.attrs["selection_log"]:
119+ print(f" {n:>9,} after: {step}")
120+ s.to_parquet(config.ANALYSIS_PARQUET, index=False)
121+ return s
122+
123+
124+def load() -> pd.DataFrame:
125+ """Load the processed analysis sample (build it first if missing)."""
126+ if not config.ANALYSIS_PARQUET.exists():
127+ return build_and_save()
128+ return pd.read_parquet(config.ANALYSIS_PARQUET)
129
Menu

Theme