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%
14.5 KB · 347 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 04 — Publication figures (print-journal calibre).45Reads the analysis sample and the step-02/03 outputs, writes ten PNG figures6to ``figures/``. No titles are drawn inside single-panel figures — captions7in the manuscript carry the message; multi-panel figures use bold8"Panel A/B" headers. One accent hue per figure, doubled by linestyle or9marker so nothing is encoded by colour alone.1011Usage:  python scripts/04_make_figures.py12"""13import sys14from pathlib import Path1516import numpy as np17import pandas as pd1819sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))2021from wp10 import config, sample  # noqa: E40222from wp10.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH,  # noqa: E40223                            apply_style, panel_label, ygrid)2425import matplotlib.pyplot as plt  # noqa: E40226import matplotlib.dates as mdates  # noqa: E4022728OUT = config.FIGURES29RES = config.REPRODUCED303132def _save(fig, name):33    fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02)34    plt.close(fig)353637# ---------------------------------------------------------------- fig 138def 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))4142    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")5657    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")707172# ---------------------------------------------------------------- fig 273def 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")929394# ---------------------------------------------------------------- fig 395def 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            continue108        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")120121122# ---------------------------------------------------------------- fig 4123def 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_RANGE129    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")145146147# ---------------------------------------------------------------- fig 5148def 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")172173174# ---------------------------------------------------------------- fig 6175def 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")197198199# ---------------------------------------------------------------- fig 7200def 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 = 0217    for name, keys in panels:218        headers.append((y, name))219        y -= 1220        for k in keys:221            if k in h.index:222                rows.append((y, k, h.loc[k]))223                ypos.append(y)224                y -= 1225        seps.append(y + 0.5)226        y -= 0.6227    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")245246247# ---------------------------------------------------------------- fig 8248def 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.2260        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")268269270# ---------------------------------------------------------------- fig 9271def 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))275276    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")289290    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")300301302# ---------------------------------------------------------------- fig 10303def 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")325326327def 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))343344345if __name__ == "__main__":346    main()347