spb/wp10_uqo Public
UQO Working Paper No. 10 — The assessment gap in Quebec: vertical and horizontal inequity in municipal property assessment.
TeX 55.9%
Python 44%
1# Author: Simon-Pierre Boucher — contact@spboucher.ai2"""IAAO ratio-study statistics.34Implements the standard diagnostics of the IAAO (2013) *Standard on Ratio5Studies*: the median assessment ratio, the coefficient of dispersion (COD),6the price-related differential (PRD), and the coefficient of price-related7bias (PRB), together with nonparametric bootstrap confidence intervals.89Notation: r_i = AV_i / SP_i is the assessment ratio of sale i.10"""11from __future__ import annotations1213import numpy as np14import pandas as pd1516from . import config171819# ---------------------------------------------------------------- point stats20def 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)) / med242526def prd(av: np.ndarray, sp: np.ndarray) -> float:27 """Price-related differential: mean ratio ÷ sale-weighted mean ratio.2829 PRD > 1 indicates regressivity (low-priced properties assessed at higher30 ratios); the IAAO acceptable range is [0.98, 1.03].31 """32 r = av / sp33 return float(np.mean(r) / (np.sum(av) / np.sum(sp)))343536def prb(av: np.ndarray, sp: np.ndarray) -> tuple[float, float]:37 """Coefficient of price-related bias (IAAO 2013, Appendix on PRB).3839 Regress the proportional deviation of the ratio from its median on the40 log (base 2) of a value proxy that blends the sale price and the41 median-deflated assessment:4243 (r_i − med) / med = α + PRB · ln2( 0.5·SP_i + 0.5·AV_i/med ) + u_i4445 Returns (PRB, robust standard error). PRB = −0.03 means ratios fall by46 3% of the median with every doubling of value: regressive if PRB < 0.47 """48 r = av / sp49 med = np.median(r)50 y = (r - med) / med51 proxy = 0.5 * sp + 0.5 * av / med52 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 @ beta56 # HC1 robust standard error of the slope57 XtX_inv = np.linalg.inv(X.T @ X)58 meat = (X * (resid ** 2)[:, None]).T @ X59 k = len(y) / (len(y) - 2)60 se = float(np.sqrt(k * (XtX_inv @ meat @ XtX_inv)[1, 1]))61 return float(beta[1]), se626364# ---------------------------------------------------------------- bootstrap65def 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 / s76 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()}828384# ---------------------------------------------------------------- group table85def 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``.8889 Returns one row per group with n, median ratio, COD, PRD, PRB (and its90 SE); optionally percentile-bootstrap CIs (slow — reserve for headline91 rows).92 """93 rows = []94 for key, g in df.groupby(by):95 if len(g) < min_n:96 continue97 av = g["role_valeur_immeuble"].to_numpy(float)98 sp = g["amount"].to_numpy(float)99 r = av / sp100 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, hi108 rows.append(row)109 return pd.DataFrame(rows)110