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%
5.9 KB · 134 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 03 — Vertical-inequity regressions, heterogeneity, horizontal4inequity and the implied tax shift.56Writes to ``results/reproduced/``:7  vertical.csv        Cheng OLS / Cheng FE / Clapp IV / Paglin–Fogarty8  quantile.csv        quantile-regression β(τ) on within-cell data9  heterogeneity.csv   Cheng-FE γ by property class, age, land share, year,10                      roll lag and municipality size11  binscatter.csv      within-cell mean ln ratio by price vigintile12  horizontal.csv      |deviation| regression (who gets noisy assessments)13  taxshift.csv        over/under-taxation by within-cell price decile14  robustness.csv      γ across sample and measurement variants1516Usage:  python scripts/03_estimate_regressions.py17"""18import sys19from pathlib import Path2021import numpy as np22import pandas as pd2324sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))2526from wp10 import config, models, sample  # noqa: E402272829def main() -> None:30    config.ensure_dirs()31    df = sample.load()32    out = config.REPRODUCED3334    # ------------------------------------------------------------ main table35    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")4546    # ------------------------------------------------------------ quantiles47    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()})5152    # ------------------------------------------------------------ binscatter53    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))6263    # ------------------------------------------------------------ heterogeneity64    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"] == y84                   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")8889    # ------------------------------------------------------------ horizontal90    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)9495    # ------------------------------------------------------------ tax shift96    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()})100101    # ------------------------------------------------------------ robustness102    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)130131132if __name__ == "__main__":133    main()134