Source document

pipeline_miami/verify/residual_checks.py

Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.

"""Checks on the residual-z output (pipeline_miami/analysis/residual_z.py):

  R1  Independent refit: for every fitted outcome × year, re-derive the
      sampling N of every cell from the served October membership under the
      outcomes.yaml n_rule (own parser), re-derive eligibility (complete
      covariates, N ≥ min_n, year not excluded), require the eligible set to
      equal the engine's, then rebuild the design matrix and solve the
      weighted normal equations (X'WX)b = X'Wy with np.linalg.solve — a
      different solver from the engine's lstsq — and reproduce z to within
      1e-6 with the standardisation written out here independently:
        z = r / sqrt(tau² + se_i²), tau² = max(0, s²·n/(n−k) − Σw·se²/Σw).
      Before the 2026-09-16 review R1 copied N and the eligible set from the
      engine's CSV, so it could not catch a wrong n_rule; now it can.
  R2  Served = committed: every residual_z in miami_school_year_metrics equals
      the value in data/cities/miami/reference/residual_z.csv, and no served
      z exists for a cell absent from the CSV.
  R3  Sanity gates (from the reference scaffold's checks.py): every eligible z
      finite; flagged share below 50%; at least 30 eligible cells per fit.

Writes docs/qa_reports/miami/__residual_checks.{json,md}.
Run:  python -m pipeline_miami.verify.residual_checks
"""
from __future__ import annotations

import hashlib
import json
import re

import numpy as np
import pandas as pd

from pipeline_miami.analysis import residual_z as engine
from pipeline_miami.verify import common

GRADES = ["PK", "KG", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]


def own_n(frame: pd.DataFrame, key: str, rule: str) -> pd.Series:
    """Sampling N under the outcomes.yaml n_rule, parsed here rather than by the engine."""
    if rule == "denominator":
        return frame[f"den__{key}"] if f"den__{key}" in frame.columns else pd.Series(np.nan, index=frame.index)
    if rule == "enrollment":
        return frame["enrollment_oct"]
    m = re.fullmatch(r"grades:([^;]+)(?:;share:([0-9.]+))?", rule)
    if not m:
        raise SystemExit(f"R1: cannot parse n_rule {rule}")
    grades: list[str] = []
    for part in m.group(1).split(","):
        if "-" in part:
            a, b = part.split("-")
            grades += [str(g) for g in range(int(a), int(b) + 1)]
        else:
            grades.append(part)
    share = float(m.group(2)) if m.group(2) else 1.0
    cols = [f"enrollment_oct_g{g}" for g in grades if f"enrollment_oct_g{g}" in frame.columns]
    return frame[cols].sum(axis=1, min_count=1) * share


def independent_z(e: pd.DataFrame, key: str, cov_cfg, scale: str, direction: str) -> np.ndarray:
    # Design: intercept, numeric (log where configured), drop-first dummies in sorted column order.
    parts = [np.ones(len(e))]
    for c in cov_cfg["numeric"]:
        x = e[c["key"]].to_numpy(dtype=float)
        parts.append(np.log(np.maximum(x, 1.0)) if c.get("transform") == "log" else x)
    for c in cov_cfg["categorical"]:
        d = pd.get_dummies(e[c["key"]].astype("category"), prefix=c["key"], drop_first=True)
        for col in sorted(d.columns):
            parts.append(d[col].to_numpy(dtype=float))
    X = np.column_stack(parts)
    y = e[key].to_numpy(dtype=float)
    w = e["n_own"].to_numpy(dtype=float)
    n_rows, k = X.shape
    # Solve the normal equations on centred, unit-scaled columns (the fitted
    # values are invariant to that reparameterisation); squaring an
    # unscaled design with log-enrollment next to percentages loses ~5
    # digits, which is a property of this solver, not of the engine.
    mu = X[:, 1:].mean(axis=0)
    sd = X[:, 1:].std(axis=0)
    sd = np.where(sd > 0, sd, 1.0)
    Xs = np.column_stack([np.ones(len(X)), (X[:, 1:] - mu) / sd])
    XtW = Xs.T * w
    beta = np.linalg.solve(XtW @ Xs, XtW @ y)
    resid = y - Xs @ beta
    s2 = np.sum(w * resid ** 2) / np.sum(w) * n_rows / (n_rows - k)
    n = w
    # scale here is the outcome's sampling model (outcomes.yaml sampling_error, else scale): percent → binomial on
    # N students, rate_per_100 → Poisson on the count, none → no per-cell noise term (attendance, a share of
    # student-days; review 2026-09-16b N11).
    if scale == "none":
        se = np.zeros_like(y, dtype=float)
    elif scale == "percent":
        p = np.clip(np.clip(y / 100.0, 0, 1), 0.5 / n, 1 - 0.5 / n)
        se = 100.0 * np.sqrt(p * (1 - p) / n)
    else:
        lam = np.maximum(y, 0.5 * 100.0 / n)
        se = np.sqrt(100.0 * lam / n)
    tau2 = max(0.0, s2 - np.sum(w * se ** 2) / np.sum(w))
    z = resid / np.sqrt(tau2 + se ** 2)
    return -z if direction == "LOWER_BETTER" else z


def main() -> None:
    import psycopg

    common.ensure_qa_dir()
    cov_cfg, out_cfg, comp = engine.load_config()
    packet = json.loads(engine.OUT_JSON.read_text())
    config_hash = hashlib.sha256("".join((engine.CONFIG_DIR / name).read_text() for name in
                                      ("covariates.yaml", "outcomes.yaml", "comparability.yaml")).encode()).hexdigest()[:12]
    packet_config_matches = packet.get("meta", {}).get("config_sha256_12") == config_hash
    csv = pd.read_csv(engine.OUT_CSV, dtype={"school_number": str, "year": str, "metric_key": str})
    with psycopg.connect(common.served_db_dsn()) as conn:
        conn.read_only = True
        frame, _, _ = engine.fetch_frame(conn, cov_cfg, list(out_cfg["outcomes"].keys()))
        served = pd.DataFrame(conn.execute("SELECT school_number, year, metric_key, residual_z::float8, outlier_flag FROM miami_school_year_metrics WHERE residual_z IS NOT NULL AND subgroup='ALL'").fetchall(),
                              columns=["school_number", "year", "metric_key", "z_served", "flag_served"])
    num_keys = [c["key"] for c in cov_cfg["numeric"]]
    cat_keys = [c["key"] for c in cov_cfg["categorical"]]
    min_n_default = out_cfg["defaults"]["min_n"]
    # R1 — independent N, eligibility and refit per outcome × year
    r1 = []
    for key, ocfg in out_cfg["outcomes"].items():
        if key not in frame.columns:
            continue
        excluded = set(str(y) for y in comp["regimes"][ocfg["comparability_regime"]].get("excluded_years", []))
        min_n = ocfg.get("min_n", min_n_default)
        f = frame[frame[key].notna()].copy()
        f["n_own"] = own_n(f, key, ocfg["n_rule"])
        f["eligible_own"] = f[num_keys + cat_keys].notna().all(axis=1) & (f.n_own >= min_n) & ~f.year.isin(excluded)
        sub = csv[(csv.metric_key == key) & (csv.eligible)]
        for year, g in sub.groupby("year"):
            own = f[(f.year == year) & f.eligible_own]
            own_set, csv_set = set(own.school), set(g.school_number)
            e = own.set_index("school").loc[sorted(own_set & csv_set)].reset_index()
            gi = g.set_index("school_number").loc[e.school]
            n_diff = float(np.nanmax(np.abs(e.n_own.to_numpy() - gi.n.to_numpy()))) if len(e) else float("nan")
            if len(e) >= 30 and own_set == csv_set:
                z_ind = independent_z(e, key, cov_cfg, ocfg.get("sampling_error", ocfg["scale"]), ocfg["direction"])
                independent_finite = bool(np.isfinite(z_ind).all())
                max_dz = float(np.max(np.abs(z_ind - gi.z.to_numpy())))
            else:
                independent_finite = False
                max_dz = float("nan")
            r1.append({"metric_key": key, "year": year, "n": int(len(g)), "eligible_set_equal": own_set == csv_set,
                       "own_only": int(len(own_set - csv_set)), "csv_only": int(len(csv_set - own_set)), "max_abs_dn": n_diff, "max_abs_dz": max_dz,
                       "independent_z_all_finite": independent_finite,
                       "pass": bool(independent_finite and own_set == csv_set and n_diff < 1e-9 and max_dz < 1e-6)})
    # R2 — served equals committed
    m = csv[csv.eligible].merge(served, on=["school_number", "year", "metric_key"], how="outer", indicator=True)
    both = m[m["_merge"] == "both"]
    r2 = {"csv_eligible": int(csv.eligible.sum()), "served_cells": int(len(served)), "matched": int(len(both)),
          "csv_only": int((m["_merge"] == "left_only").sum()), "served_only": int((m["_merge"] == "right_only").sum()),
          "all_z_finite": bool(len(both) and np.isfinite(both.z).all() and np.isfinite(both.z_served).all()),
          "max_abs_dz": float(np.max(np.abs(both.z - both.z_served))) if len(both) else None,
          "flag_mismatches": int((both.flag != both.flag_served).sum()) if len(both) else None}
    r2["pass"] = bool(r2["all_z_finite"] and r2["csv_only"] == 0 and r2["served_only"] == 0 and r2["max_abs_dz"] < 1e-6 and r2["flag_mismatches"] == 0)
    # R3 — sanity gates
    el = csv[csv.eligible]
    per_fit = el.groupby(["metric_key", "year"]).agg(n=("z", "size"), finite=("z", lambda s: bool(np.isfinite(s).all())), flagged=("flag", lambda s: float((s != "none").mean())))
    r3 = {"all_z_finite": bool(per_fit.finite.all()), "max_flagged_share": float(per_fit.flagged.max()), "min_eligible_per_fit": int(per_fit.n.min()),
          "pass": bool(per_fit.finite.all() and per_fit.flagged.max() < 0.5 and per_fit.n.min() >= 30)}
    verdict = "PASS" if packet_config_matches and r1 and all(r["pass"] for r in r1) and r2["pass"] and r3["pass"] else "REVIEW"
    out = {"verdict": verdict, "config_sha256_12": config_hash,
           "packet_generated_at": packet.get("meta", {}).get("generated_at"),
           "packet_config_matches": packet_config_matches,
           "R1_independent_refit": {"fits": len(r1), "all_pass": all(r["pass"] for r in r1), "max_abs_dz": max(r["max_abs_dz"] for r in r1) if r1 else None,
                                    "max_abs_dn": max(r["max_abs_dn"] for r in r1) if r1 else None, "eligible_sets_equal": all(r["eligible_set_equal"] for r in r1), "rows": r1},
           "R2_served_equals_committed": r2, "R3_sanity": r3}
    (common.QA_REPORTS_DIR / "__residual_checks.json").write_text(json.dumps(out, indent=2, default=str))
    md = [f"# Residual-z checks\n", f"Verdict: **{verdict}**\n",
          f"- Packet generation: {out['packet_generated_at']}; config {config_hash}; packet matches current config = {packet_config_matches}",
          f"- R1 independent N + eligibility + refit (normal equations vs lstsq): {len(r1)} outcome-years, eligible sets equal = {out['R1_independent_refit']['eligible_sets_equal']}, max |ΔN| = {out['R1_independent_refit']['max_abs_dn']:.2e}, max |Δz| = {out['R1_independent_refit']['max_abs_dz']:.2e}, all pass = {out['R1_independent_refit']['all_pass']}",
          f"- R2 served = committed: {r2['matched']} cells matched, {r2['csv_only']} CSV-only, {r2['served_only']} served-only, max |Δz| = {r2['max_abs_dz']}, flag mismatches = {r2['flag_mismatches']} → {'PASS' if r2['pass'] else 'REVIEW'}",
          f"- R3 sanity: all z finite = {r3['all_z_finite']}, max flagged share = {r3['max_flagged_share']:.3f}, min eligible per fit = {r3['min_eligible_per_fit']} → {'PASS' if r3['pass'] else 'REVIEW'}",
          "", "| outcome | year | n | eligible set equal | max abs dN | max abs dz |", "|---|---|---:|---|---:|---:|"]
    md += [f"| `{r['metric_key']}` | {r['year']} | {r['n']} | {r['eligible_set_equal']} | {r['max_abs_dn']:.1e} | {r['max_abs_dz']:.2e} |" for r in r1]
    (common.QA_REPORTS_DIR / "__residual_checks.md").write_text("\n".join(md))
    print(json.dumps({k: v for k, v in out.items() if k != "R1_independent_refit"} | {"R1": {k: v for k, v in out["R1_independent_refit"].items() if k != "rows"}}, indent=1, default=str))


if __name__ == "__main__":
    main()