SPB Git

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%
4.1 KB · 110 lines python
Raw Blame History
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