Source document

scripts/analysis/bakeoff.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.

#!/usr/bin/env python
"""Clustering/decile bake-off for NYC school peer groups & proclivity deciles.

Ten approaches, two families (cluster/peer-group + decile/need-index), scored on a
common predictive-validity battery against three outcomes (ELA/Math proficiency,
4-yr graduation rate, chronic absenteeism).

Unifying metric: leave-one-out "expected-outcome R^2". Each method yields an
EXPECTED outcome per school formed WITHOUT that school's own outcome, so cluster,
peer-set, and index methods are all scored on the same "how well does this
representation predict actual outcomes" scale.

Also: within-group homogeneity (clusters), temporal out-of-sample (fit year T ->
predict T+1), and residual ("punching above expectation") year-over-year stability.

Read-only on a cached CSV; writes results CSVs to scripts/analysis/data/.
"""
import warnings
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import NearestNeighbors
from sklearn.cluster import KMeans, AgglomerativeClustering
from sklearn.mixture import GaussianMixture
from sklearn.decomposition import PCA
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold

warnings.filterwarnings("ignore")
RNG = 42
DATA = "scripts/analysis/data"

DEMOG = ["pct_black", "pct_hispanic", "pct_white", "pct_asian",
         "pct_ell", "pct_swd", "pct_econ_dis"]
GROUP_SIZE = 40  # target peer-group size, matches production K

# ----------------------------------------------------------------------------- data
def load_panel():
    df = pd.read_csv(f"{DATA}/school_year_panel.csv")
    df = df[df["include_in_default_comparisons"] == True].copy()
    # academic proficiency = mean of ELA & Math % proficient (0-100)
    df["prof"] = df[["ela_all_proficiency", "math_all_proficiency"]].mean(axis=1)
    df["grad"] = df["graduation_rate_4yr"]
    df["absent"] = df["chronic_absenteeism_rate"]
    # grade-band-median impute for econ_dis (only column with material missingness)
    for c in DEMOG:
        df[c] = df.groupby(["year", "grade_band"])[c].transform(
            lambda s: s.fillna(s.median())
        )
        df[c] = df[c].fillna(df.groupby("year")[c].transform("median"))
    df = df.dropna(subset=DEMOG)
    return df

# outcome -> (column, higher_is_better, eligible grade bands)
OUTCOMES = {
    "proficiency": ("prof", True, {"ES", "MS", "K8"}),
    "graduation": ("grad", True, {"HS"}),
    "absenteeism": ("absent", False, {"ES", "MS", "K8", "HS", "K12"}),
}

def r2(actual, expected):
    a = np.asarray(actual, float); e = np.asarray(expected, float)
    m = np.isfinite(a) & np.isfinite(e)
    if m.sum() < 10: return np.nan
    a, e = a[m], e[m]
    ss_res = np.sum((a - e) ** 2)
    ss_tot = np.sum((a - a.mean()) ** 2)
    return 1 - ss_res / ss_tot if ss_tot > 0 else np.nan

def zscaled(df, cols, scaler=None):
    if scaler is None:
        scaler = StandardScaler().fit(df[cols].values)
    return scaler.transform(df[cols].values), scaler

# ----------------------------------------------------------------------------- helpers
def loo_group_mean(labels, y):
    """Leave-one-out mean outcome within each label. y: array; labels: array."""
    y = np.asarray(y, float)
    out = np.full(len(y), np.nan)
    lab = pd.Series(labels)
    for g, idx in lab.groupby(lab).groups.items():
        idx = np.array(list(idx))
        yi = y[idx]
        valid = np.isfinite(yi)
        if valid.sum() <= 1:
            continue
        tot = np.nansum(yi); cnt = valid.sum()
        for j in idx:
            if np.isfinite(y[j]):
                out[j] = (tot - y[j]) / (cnt - 1)
            else:
                out[j] = tot / cnt
    return out

def ntile(series, n=10):
    """1..n decile labels by rank (ascending)."""
    r = series.rank(method="first")
    return np.minimum(n, (np.floor((r - 1) / len(series) * n) + 1)).astype(int)

# ----------------------------------------------------------------------------- methods
# Each method: fit on a per-band feature frame, return integer assignment labels
# (partition) OR a continuous index. Peer (k-NN) methods return None labels and
# are handled specially. The harness turns labels/index/peers into expected-outcome.

def k_for(n):
    return max(2, int(round(n / GROUP_SIZE)))

def assign_kmeans(X, n):
    return KMeans(n_clusters=k_for(n), random_state=RNG, n_init=10).fit_predict(X)

def assign_gmm(X, n):
    k = k_for(n)
    gm = GaussianMixture(n_components=k, random_state=RNG, covariance_type="diag",
                         reg_covar=1e-4, max_iter=200).fit(X)
    return gm.predict(X)

def assign_ward(X, n):
    return AgglomerativeClustering(n_clusters=k_for(n), linkage="ward").fit_predict(X)

# index methods (continuous "need" score; higher = more challenged)
PROCLIVITY3 = ["pct_econ_dis", "pct_ell", "pct_swd"]  # current production feature set

def index_meanpctrank(df):  # current proclivity: econ_dis, ell, swd (3 feat, no race)
    return df[PROCLIVITY3].rank(pct=True).mean(axis=1)

def index_meanpctrank7(df):  # same method, all 7 features (adds race/ethnicity)
    return df[DEMOG].rank(pct=True).mean(axis=1)

def index_pca3(df):  # PCA method on the 3 non-race proclivity features
    X, _ = zscaled(df, PROCLIVITY3)
    pc = PCA(n_components=1, random_state=RNG).fit(X)
    comp = pc.transform(X)[:, 0]
    if np.corrcoef(comp, df["pct_econ_dis"].values)[0, 1] < 0:
        comp = -comp
    return pd.Series(comp, index=df.index)

def index_pca(df):  # PCA on all 7 features (incl race/ethnicity)
    X, _ = zscaled(df, DEMOG)
    pc = PCA(n_components=1, random_state=RNG).fit(X)
    comp = pc.transform(X)[:, 0]
    # orient so higher = more challenged (positively correlated with econ_dis)
    if np.corrcoef(comp, df["pct_econ_dis"].values)[0, 1] < 0:
        comp = -comp
    return pd.Series(comp, index=df.index)

def index_eni(df):  # official-style weighted composite: econ need primary + ELL
    return 0.7 * df["pct_econ_dis"] + 0.3 * df["pct_ell"]

# ----------------------------------------------------------------------------- in-sample expected
def expected_partition(df, assign_fn, y):
    """Cluster within each grade band; LOO group-mean expected outcome."""
    exp = pd.Series(np.nan, index=df.index)
    lab_full = pd.Series("", index=df.index)
    for band, g in df.groupby("grade_band"):
        if len(g) < GROUP_SIZE:  # too few to cluster into ~40s -> single group
            labels = np.zeros(len(g), int)
        else:
            X, _ = zscaled(g, DEMOG)
            labels = assign_fn(X, len(g))
        lab = pd.Series([f"{band}:{l}" for l in labels], index=g.index)
        lab_full.loc[g.index] = lab
        exp.loc[g.index] = loo_group_mean(lab.values, g[y].values)
    return exp, lab_full

def expected_knn(df, y, feat_cols, feat_scaler_cols=None):
    """k-NN ego-group within band; mean outcome of K nearest peers (excl self)."""
    exp = pd.Series(np.nan, index=df.index)
    lab = pd.Series("", index=df.index)  # not a partition; left blank
    cols = feat_cols
    for band, g in df.groupby("grade_band"):
        gy = g[y].values
        have = np.isfinite(gy)
        if have.sum() <= GROUP_SIZE:
            continue
        Xall, scaler = zscaled(g, cols)
        cand = g[have]
        Xc = scaler.transform(cand[cols].values)
        k = min(GROUP_SIZE, len(cand) - 1)
        nn = NearestNeighbors(n_neighbors=k + 1).fit(Xc)
        _, ind = nn.kneighbors(Xall)
        cy = cand[y].values
        cand_pos = {pos: i for i, pos in enumerate(np.where(have)[0])}
        for row, gi in enumerate(g.index):
            self_cand = cand_pos.get(row, None)
            neigh = [p for p in ind[row] if p != self_cand][:k]
            exp.loc[gi] = np.nanmean(cy[neigh])
    return exp, lab

def expected_index(df, index_fn, y, n_dec=10):
    """Index -> decile bins; LOO decile-mean expected (the product 'decile' use)."""
    idx = index_fn(df)
    dec = pd.Series(ntile(idx, n_dec), index=df.index)
    exp = pd.Series(loo_group_mean(dec.values, df[y].values), index=df.index)
    return exp, dec, idx

def expected_regression(df, y, cols, kfolds=5):
    """Supervised: k-fold CV predictions of outcome from features. Expected = pred."""
    sub = df.dropna(subset=[y])
    if len(sub) < 50:
        return pd.Series(np.nan, index=df.index)
    X, _ = zscaled(sub, cols)
    yv = sub[y].values
    exp = pd.Series(np.nan, index=df.index)
    kf = KFold(n_splits=kfolds, shuffle=True, random_state=RNG)
    pred = np.full(len(sub), np.nan)
    for tr, te in kf.split(X):
        lr = LinearRegression().fit(X[tr], yv[tr])
        pred[te] = lr.predict(X[te])
    exp.loc[sub.index] = pred
    return exp

# ----------------------------------------------------------------------------- out-of-sample
def oos_partition(train, test, assign_fn, y):
    """Fit clusters per band on train; assign test to nearest centroid; train group-mean."""
    exp = pd.Series(np.nan, index=test.index)
    for band, gtr in train.groupby("grade_band"):
        gte = test[test["grade_band"] == band]
        if len(gte) == 0:
            continue
        if len(gtr) < GROUP_SIZE:
            exp.loc[gte.index] = gtr[y].mean()
            continue
        Xtr, scaler = zscaled(gtr, DEMOG)
        labels = assign_fn(Xtr, len(gtr))
        means = pd.Series(gtr[y].values).groupby(labels).mean()
        # centroids
        cents = np.vstack([Xtr[labels == l].mean(axis=0) for l in np.unique(labels)])
        clab = np.unique(labels)
        Xte = scaler.transform(gte[DEMOG].values)
        nn = NearestNeighbors(n_neighbors=1).fit(cents)
        _, ci = nn.kneighbors(Xte)
        exp.loc[gte.index] = [means.get(clab[c[0]], np.nan) for c in ci]
    return exp

def oos_knn(train, test, y, cols):
    exp = pd.Series(np.nan, index=test.index)
    for band, gtr in train.groupby("grade_band"):
        gte = test[test["grade_band"] == band]
        cand = gtr.dropna(subset=[y])
        if len(gte) == 0 or len(cand) <= GROUP_SIZE:
            continue
        Xc, scaler = zscaled(cand, cols)
        Xte = scaler.transform(gte[cols].values)
        k = min(GROUP_SIZE, len(cand))
        nn = NearestNeighbors(n_neighbors=k).fit(Xc)
        _, ind = nn.kneighbors(Xte)
        cy = cand[y].values
        exp.loc[gte.index] = [np.nanmean(cy[row]) for row in ind]
    return exp

def oos_index(train, test, index_fn, y, n_dec=10):
    itr = index_fn(train); ite = index_fn(test)
    # decile thresholds from train
    qs = np.quantile(itr, np.linspace(0, 1, n_dec + 1)[1:-1])
    dtr = np.digitize(itr, qs)
    dte = np.digitize(ite, qs)
    means = pd.Series(train[y].values).groupby(dtr).mean()
    return pd.Series([means.get(d, np.nan) for d in dte], index=test.index)

def oos_regression(train, test, y, cols):
    sub = train.dropna(subset=[y])
    if len(sub) < 50:
        return pd.Series(np.nan, index=test.index)
    Xtr, scaler = zscaled(sub, cols)
    lr = LinearRegression().fit(Xtr, sub[y].values)
    Xte = scaler.transform(test[cols].values)
    return pd.Series(lr.predict(Xte), index=test.index)

# ----------------------------------------------------------------------------- registry
# Methods 5 & 10b use prior-year outcome -> feature set includes 'prior'.
PARTITION_METHODS = {
    "02_kmeans": assign_kmeans,
    "03_gmm": assign_gmm,
    "04_ward": assign_ward,
}
INDEX_METHODS = {
    "07_meanpctrank": index_meanpctrank,
    "07b_meanpctrank7": index_meanpctrank7,
    "08_pca": index_pca,
    "08b_pca3": index_pca3,
    "09_eni": index_eni,
}

def build_prior(df, ycol):
    """Add prior-year (T-1) outcome per school as a feature column 'prior'."""
    years = sorted(df["year"].unique())
    prev = {years[i]: years[i - 1] for i in range(1, len(years))}
    key = df.set_index(["dbn", "year"])[ycol]
    def lookup(r):
        py = prev.get(r["year"])
        if py is None: return np.nan
        try: return key.loc[(r["dbn"], py)]
        except KeyError: return np.nan
    return df.apply(lookup, axis=1)