#!/usr/bin/env python3 """M1-LONG selection funnel + research-only IPW sensitivity. Response to Astra/ChatGPT 4d425973 / 8a1b5844 (CONDITIONAL_ACCEPT_WITH_SELECTION_BIAS). RESEARCH ONLY — score_authorized(M1)=false · L1 untouched · never official survey weights. """ from __future__ import annotations import hashlib import json import math from datetime import datetime, timezone from pathlib import Path import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression ROOT = Path(__file__).resolve().parents[2] MICRO = ROOT / "data/raw/michigan_sca/microdata/sca_micro_2015_2023_pilot.csv" PAIRS = ROOT / "data/m1/v2/m1long_pairs_origin_le2019.csv" ATTR = ROOT / "data/m1/v2/m1long_v2_attrition.json" OUT = ROOT / "data/m1/v2" DOCS = ROOT / "docs/m1" HUB_DOCS = Path("/workspace/workers/ai-hub/src/docs") LEFT_CENSOR = 201810 ORIGIN_MAX = 201912 VALID_ATT = {1, 3, 5} DK_REFUSAL = {8, 9} SAMPLE_FRESH = {1, 3, 6} SAMPLE_REINT2 = {2, 4, 7} SAMPLE_REINT3 = {5} # cell 2nd reinterview / third interview in this design LABELS = { "SEX": { "1": "Male", "2": "Female", "8": "DK (not observed in extract)", "9": "NA/refused (not observed in extract)", }, "EDUC": { "1": "Grade 0-8 / no HS", "2": "High school incomplete", "3": "High school graduate", "4": "Some college", "5": "College degree", "6": "Graduate study", "8": "DK", "9": "NA/refused", " ": "Blank/missing in extract", }, "PEXP": { "1": "Better a year from now", "3": "Same", "5": "Worse a year from now", "8": "DK", "9": "NA/refused", }, "PAGO": { "1": "Better than a year ago", "3": "Same", "5": "Worse than a year ago", "8": "DK", "9": "NA/refused", }, } def sha256_file(p: Path) -> str: h = hashlib.sha256() with p.open("rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def yyyymm_to_index(y): y = int(y) return (y // 100) * 12 + (y % 100) def std_diff(m1, m0, s1, s0): pooled = math.sqrt(0.5 * (s1 ** 2 + s0 ** 2)) if pooled == 0 or np.isnan(pooled): return None return float((m1 - m0) / pooled) def main(): import sys sys.path.insert(0, str(Path(__file__).resolve().parent)) from build_m1_long_pilot import SAMPLE_FRESH as SF, VALID_ATT as VA sample_fresh = set(SF) if not isinstance(SF, set) else SF valid_att = set(VA) micro = pd.read_csv(MICRO) pairs = pd.read_csv(PAIRS) attr = json.loads(ATTR.read_text())["authorized_origin_le2019"] for c in ["YYYYMM", "ID", "SAMPLE", "PEXP", "PAGO", "SEX", "EDUC", "REGION", "AGE", "METHOD"]: if c in micro.columns: micro[c] = pd.to_numeric(micro[c], errors="coerce") for c in ["IDPREV", "DATEPR", "IDPREV2", "DATEPR2"]: if c in micro.columns: micro[c] = pd.to_numeric(micro[c], errors="coerce") pairs["origin_yyyymm"] = pairs["origin_yyyymm"].astype(int) pairs["origin_id"] = pairs["origin_id"].astype(int) usable_keys = set(zip(pairs["origin_id"], pairs["origin_yyyymm"])) # --- Source-cohort accounting (precedes N1; do NOT double-count inside 5793) --- window = micro[(micro["YYYYMM"] >= LEFT_CENSOR) & (micro["YYYYMM"] <= ORIGIN_MAX)].copy() fresh_any = window[window["SAMPLE"].isin(sample_fresh)].copy() n_fresh_any = int(len(fresh_any)) invalid_pexp = fresh_any[~fresh_any["PEXP"].isin(valid_att)] n_invalid_pexp = int(len(invalid_pexp)) # missing AGE/SEX/EDUC among fresh (item nonresponse proxy) fresh_n1 = fresh_any[fresh_any["PEXP"].isin(valid_att)].copy() n1 = int(len(fresh_n1)) # Build reint2 index keys: (IDPREV, DATEPR) for SAMPLE in reint2 reint2 = micro[micro["SAMPLE"].isin(SAMPLE_REINT2)].copy() reint2_keys = set( zip(reint2["IDPREV"].dropna().astype(int), reint2["DATEPR"].dropna().astype(int)) ) fresh_n1["key"] = list(zip(fresh_n1["ID"].astype(int), fresh_n1["YYYYMM"].astype(int))) fresh_n1["reached_n2"] = fresh_n1["key"].isin(reint2_keys) fresh_n1["retained_n3"] = fresh_n1["key"].isin(usable_keys) n2 = int(fresh_n1["reached_n2"].sum()) n3 = int(fresh_n1["retained_n3"].sum()) # Among N2 not in N3 n2_not_n3 = int(((fresh_n1["reached_n2"]) & (~fresh_n1["retained_n3"])).sum()) n1_not_n2 = int((~fresh_n1["reached_n2"]).sum()) # Mutually exclusive loss stages from authorized window perspective # Stage A: source-cohort before N1 (invalid/missing origin PEXP) — NOT inside 5793 # Stage B: N1 lost before second interview (no reint2 link) # Stage C: N2 lost before usable third-interview pair (gap/link/pago etc.) # Stage D: retained usable pair (N3) funnel = { "label": "authorized_origin_le2019_funnel_v2", "target_population_estimand": ( "TWO DISTINCT research estimands (do not conflate): " "(A) Completers / retained-pair estimand — unweighted 3×3 among N3=1424 usable " "~12m same-person chains with origin in 201810–201912 (Michigan SCA fresh-cell). " "(B) IPW eligible-cohort estimand — RESEARCH_ATTRITION_ADJUSTMENT that reweights " "retained completers toward the N1 eligible origin cohort (valid PEXP) using " "observed covariates only. Neither is a US adult population statement." ), "manageability_definition_note": ( "5pp absolute share-move and truncation bands (1–99 / 5–95) are labeled " "EXPLORATORY unless a timestamped predeclared protocol artifact exists. " "No such protocol timestamp was located at v2 publish; treat thresholds as " "exploratory sensitivity flags, not confirmatory gate criteria." ), "manageability_definition_predeclared": ( "EXPLORATORY sensitivity flags (not confirmatory): (i) funnel stages reproducible; " "(ii) IPW ESS / N_retained >= 0.50 under truncation bands; " "(iii) post-weight |SMD| on modeled covariates mostly <0.20; (iv) 3x3 shares move " "<5pp absolute under IPW vs unweighted. Manageability ≠ population validity; " "unmeasured selection remains possible. See manageability_definition_note." ), "stages_mutually_exclusive": [ { "stage": "A_source_cohort_invalid_or_missing_origin_PEXP", "description": ( "Fresh SAMPLE in window with PEXP not in {1,3,5} (DK/refusal/other). " "Preceding source-cohort accounting — NOT double-counted inside N1=5793." ), "n": n_invalid_pexp, "of_base": "fresh_any_in_window", "base_n": n_fresh_any, }, { "stage": "B_missing_second_interview_or_reint2_linkage_failure", "description": ( "N1 keys that never appear as (IDPREV,DATEPR) of SAMPLE∈{2,4,7}. " "Do not label all of this 'attrition' alone — mixes nonresponse, design, linkage." ), "n": n1_not_n2, "of_base": "N1", "base_n": n1, }, { "stage": "C_missing_third_interview_or_unusable_pair", "description": ( "Reached N2 but not in published usable 11–13m pairs (linkage/gap/PAGO/chain)." ), "n": n2_not_n3, "of_base": "N2", "base_n": n2, }, { "stage": "D_retained_usable_pair_N3", "description": "Published v2 authorized pairs (gap 11–13).", "n": n3, "of_base": "N1", "base_n": n1, }, ], "cross_check_attr_json": { "N_fresh_any": n_fresh_any, "N_invalid_pexp": n_invalid_pexp, "N1": n1, "N2": n2, "N3": n3, "attr_file_N1": attr.get("N1_fresh_valid_pexp_at_risk"), "attr_file_N2": attr.get("N2_reached_reint2"), "attr_file_N3": attr.get("N3_linked_pairs_gap_11_13"), "N1_plus_invalid_equals_fresh_any": n1 + n_invalid_pexp == n_fresh_any, "B_plus_C_plus_D_equals_N1": n1_not_n2 + n2_not_n3 + n3 == n1, }, "unknown_reasons_flag": ( "Within B and C, extract lacks disposition codes distinguishing refusal vs " "unable-to-contact vs design skip vs item loss. Those remain UNKNOWN — not invented." ), "idprev2_note": "IDPREV2/DATEPR2 blank in extract; chains use IDPREV/DATEPR only.", } # --- Official labels --- label_audit = {} for col, lab in LABELS.items(): s = micro[col] present = sorted({str(int(x)) if pd.notna(x) and float(x).is_integer() else str(x) for x in s.dropna().unique()}) # also blanks if s.isna().any() or (s.astype(str).str.strip() == "").any(): present = sorted(set(present) | {""}) label_audit[col] = { "official_labels": lab, "codes_present_in_extract": present, "dk_refusal_codes_8_9_present": sorted([c for c in present if c in {"8", "9"}]), "note": "Substantive analysis uses {1,3,5} for PEXP/PAGO; 8/9 are DK/refusal — not midpoints.", } # --- Eligible N1 balance with month index --- elig = fresh_n1.copy() elig["retained"] = elig["retained_n3"] elig["month_index"] = elig["YYYYMM"].map(yyyymm_to_index) elig["origin_year"] = (elig["YYYYMM"] // 100).astype(int) elig["origin_month"] = (elig["YYYYMM"] % 100).astype(int) retained = elig[elig["retained"]] dropped = elig[~elig["retained"]] def summarize_cont(col, df_r=retained, df_d=dropped): def stats(df): s = df[col].dropna() return { "n": int(s.shape[0]), "missing": int(df[col].isna().sum()), "mean": float(s.mean()) if len(s) else None, "std": float(s.std(ddof=1)) if len(s) > 1 else None, } r, d = stats(df_r), stats(df_d) sd = None if all(x is not None for x in (r["mean"], d["mean"], r["std"], d["std"])): sd = std_diff(r["mean"], d["mean"], r["std"], d["std"]) return {"retained": r, "dropped": d, "std_diff_retained_minus_dropped": sd} def summarize_cat(col, df_r=retained, df_d=dropped): def props(df): s = df[col].dropna() vc = s.value_counts(normalize=True).sort_index() counts = s.value_counts().sort_index() out_p, out_c = {}, {} for k, v in vc.items(): key = str(int(k)) if float(k).is_integer() else str(k) out_p[key] = float(v) for k, v in counts.items(): key = str(int(k)) if float(k).is_integer() else str(k) out_c[key] = int(v) return {"n": int(s.shape[0]), "missing": int(df[col].isna().sum()), "proportions": out_p, "counts": out_c} r, d = props(df_r), props(df_d) keys = sorted(set(r["proportions"]) | set(d["proportions"]), key=lambda x: (len(x), x)) diffs = {} for k in keys: pr = r["proportions"].get(k, 0.0) pd_ = d["proportions"].get(k, 0.0) pooled = math.sqrt(0.5 * (pr * (1 - pr) + pd_ * (1 - pd_))) sd = ((pr - pd_) / pooled) if pooled > 0 else None diffs[k] = { "prop_retained": pr, "prop_dropped": pd_, "diff": pr - pd_, "std_diff": sd, "label": LABELS.get(col, {}).get(k), } return {"retained": r, "dropped": d, "level_diffs": diffs} cont = {c: summarize_cont(c) for c in ["AGE", "month_index"]} # Keep YYYYMM numeric for reference but flag as non-interval cont["YYYYMM_raw_numeric_NOT_interval"] = summarize_cont("YYYYMM") cont["YYYYMM_raw_numeric_NOT_interval"]["warning"] = ( "YYYYMM as integer is NOT a real time index (201812→201901 jumps +89). Use month_index." ) cats = {c: summarize_cat(c) for c in ["SEX", "EDUC", "REGION", "PAGO", "PEXP", "origin_year", "origin_month", "METHOD"]} # Unavailable covariates unavailable = { "income": "NOT in pilot extract — re-extract from SDA required", "employment": "NOT in pilot extract — re-extract from SDA required", "party_id": "NOT in pilot extract — re-extract from SDA required", "household_size": "NOT in pilot extract — re-extract from SDA required", "finance_beyond_PAGO_PEXP": "ICS present but not used as income proxy", "note": "No proxies invented. REGION/AGE/SEX/EDUC/PAGO/PEXP/METHOD available.", } # --- Retention logit + IPW sensitivity (research-only) --- # Match published v1 method: sklearn L2 logistic; omit origin_year (collinear with month_index). feature_cols = [] X_parts = [] age = elig["AGE"].astype(float) age_z = (age - age.mean()) / (age.std(ddof=0) or 1) X_parts.append(age_z.fillna(0).to_numpy().reshape(-1, 1)) feature_cols.append("age") mi = elig["month_index"].astype(float) mi_z = (mi - mi.mean()) / (mi.std(ddof=0) or 1) X_parts.append(mi_z.to_numpy().reshape(-1, 1)) feature_cols.append("month_index") for col, prefix in [("SEX", "sex"), ("EDUC", "educ"), ("REGION", "region"), ("PAGO", "pago"), ("PEXP", "pexp")]: dummies = pd.get_dummies(elig[col].astype("Int64"), prefix=prefix, dummy_na=True) if dummies.shape[1] > 1: dummies = dummies.iloc[:, 1:] X_parts.append(dummies.to_numpy(dtype=float)) feature_cols.extend(list(dummies.columns)) X = np.hstack(X_parts) y = elig["retained"].astype(int).to_numpy() clf = LogisticRegression( penalty="l2", C=1.0, solver="lbfgs", max_iter=200, fit_intercept=True, ) clf.fit(X, y) converged = bool(clf.n_iter_[0] < clf.max_iter) if hasattr(clf, "n_iter_") else True n_iter = int(clf.n_iter_[0]) if hasattr(clf, "n_iter_") else None p = clf.predict_proba(X)[:, 1] ll = float(np.sum(y * np.log(np.clip(p, 1e-12, 1)) + (1 - y) * np.log(np.clip(1 - p, 1e-12, 1)))) p0 = float(y.mean()) ll0 = float(np.sum(y * np.log(p0) + (1 - y) * np.log(1 - p0))) mcfadden = 1 - (ll / ll0) if ll0 != 0 else None coefs = {"intercept": float(clf.intercept_[0])} coefs.update({feature_cols[i]: float(clf.coef_[0][i]) for i in range(len(feature_cols))}) # IPW for retained: stabilize w = mean(p_ret) / clip(p_hat, 0.05, 0.95) p_ret = p[y == 1] p_ret_clip = np.clip(p_ret, 0.05, 0.95) raw_w = 1.0 / p_ret_clip stab = float(p_ret.mean()) * raw_w pairs_idx = pairs.set_index(["origin_id", "origin_yyyymm"]) def weight_stats(w, label): w = np.asarray(w, dtype=float) ess = float((w.sum() ** 2) / (np.sum(w ** 2) + 1e-12)) return { "label": label, "n": int(len(w)), "min": float(w.min()), "p5": float(np.percentile(w, 5)), "p25": float(np.percentile(w, 25)), "median": float(np.median(w)), "p75": float(np.percentile(w, 75)), "p95": float(np.percentile(w, 95)), "max": float(w.max()), "mean": float(w.mean()), "std": float(w.std(ddof=1)) if len(w) > 1 else 0.0, "ess": ess, "ess_over_n": float(ess / len(w)) if len(w) else None, } def truncate(w, lo_pct, hi_pct): lo, hi = np.percentile(w, [lo_pct, hi_pct]) return np.clip(w, lo, hi) sensitivities = {} for name, w in { "stabilized_raw": stab, "trunc_1_99": truncate(stab, 1, 99), "trunc_5_95": truncate(stab, 5, 95), }.items(): sensitivities[name] = weight_stats(w, name) # Map weights onto pairs order elig_ret = elig[elig["retained"]].copy() elig_ret = elig_ret.reset_index(drop=True) elig_ret["w_stab"] = stab elig_ret["w_t1"] = truncate(stab, 1, 99) elig_ret["w_t5"] = truncate(stab, 5, 95) # join to pairs (elig columns are ID, YYYYMM) wmap = { (int(r.ID), int(r.YYYYMM)): (float(r.w_stab), float(r.w_t1), float(r.w_t5)) for r in elig_ret.itertuples() } pw = [] for r in pairs.itertuples(): key = (int(r.origin_id), int(r.origin_yyyymm)) pw.append(wmap.get(key, (1.0, 1.0, 1.0))) pairs = pairs.copy() pairs["w_stab"], pairs["w_t1"], pairs["w_t5"] = zip(*pw) def crosstab_weighted(df, wcol=None): d = df.copy() d["pexp"] = pd.to_numeric(d["pexp"], errors="coerce") d["pago"] = pd.to_numeric(d["pago"], errors="coerce") d = d.dropna(subset=["pexp", "pago"]) if wcol is None: ct = pd.crosstab(d["pexp"], d["pago"]) total = ct.values.sum() shares = (ct / total).to_dict() if total else {} counts = {str(int(r)): {str(int(c)): int(ct.loc[r, c]) for c in ct.columns} for r in ct.index} share_out = {str(int(r)): {str(int(c)): float(ct.loc[r, c] / total) for c in ct.columns} for r in ct.index} # summary metrics match = sum(ct.loc[r, c] for r in ct.index for c in ct.columns if int(r) == int(c)) if total else 0 # signed: pexp/pago coding 1 better, 5 worse — use signed if present return {"counts": counts, "shares": share_out, "n": int(total)} # weighted g = d.groupby(["pexp", "pago"])[wcol].sum().unstack(fill_value=0.0) total = float(g.values.sum()) counts = {str(int(r)): {str(int(c)): float(g.loc[r, c]) for c in g.columns} for r in g.index} shares = {str(int(r)): {str(int(c)): float(g.loc[r, c] / total) if total else 0.0 for c in g.columns} for r in g.index} return {"weighted_mass": counts, "shares": shares, "n_effective_mass": total} def summarize_3x3(block, label): shares = block["shares"] # match: pexp==pago match = 0.0 any_neg = 0.0 # dest worse than origin: pago > pexp in {1,3,5} ordering where higher=worse # Strong negative = Better(1)→Worse(5) ONLY (not 1→3 or 3→5). # On codes {1,3,5}, ci-ri>=2 incorrectly equals any_neg — that was the v1 bug. strong_neg = 0.0 fav = 0.0 # dest better: pago < pexp for r, cols in shares.items(): for c, s in cols.items(): ri, ci = int(float(r)), int(float(c)) if ri == ci: match += s if ci > ri: any_neg += s if ri == 1 and ci == 5: strong_neg += s if ci < ri: fav += s # Prefer explicit cell when present (regression identity: strong == shares["1"]["5"]). cell_strong = float((shares.get("1") or {}).get("5") or 0.0) if abs(strong_neg - cell_strong) > 1e-12: raise AssertionError( f"strong_neg {strong_neg} != cell[1][5] {cell_strong} for {label}" ) if strong_neg - any_neg > 1e-12: raise AssertionError( f"strong_neg {strong_neg} must be subset of any_neg {any_neg} for {label}" ) return { "label": label, "share_match": match, "share_any_negative_miss": any_neg, "share_strong_negative_miss": strong_neg, "share_strong_negative_miss_definition": "Better(1)->Worse(5) ONLY; equals cell shares[1][5]", "share_favorable_surprise": fav, "detail": block, } three = { "unweighted": summarize_3x3(crosstab_weighted(pairs, None), "unweighted"), "ipw_stabilized": summarize_3x3(crosstab_weighted(pairs, "w_stab"), "ipw_stabilized_RESEARCH_ATTRITION_ADJUSTMENT"), "ipw_trunc_1_99": summarize_3x3(crosstab_weighted(pairs, "w_t1"), "ipw_trunc_1_99_RESEARCH_ATTRITION_ADJUSTMENT"), "ipw_trunc_5_95": summarize_3x3(crosstab_weighted(pairs, "w_t5"), "ipw_trunc_5_95_RESEARCH_ATTRITION_ADJUSTMENT"), } # Post-weight balance on retained vs target (elig) using stabilized weights on retained # Compare weighted retained means to overall eligible means for AGE/month_index def wmean(x, w): x = np.asarray(x, dtype=float) w = np.asarray(w, dtype=float) m = np.isfinite(x) & np.isfinite(w) if not m.any(): return None return float(np.average(x[m], weights=w[m])) post = {"continuous": {}, "categorical": {}, "summary": {}} er = elig_ret.copy() for col in ["AGE", "month_index"]: overall = float(elig[col].dropna().mean()) overall_std = float(elig[col].dropna().std(ddof=1)) if elig[col].dropna().shape[0] > 1 else None unwt = float(retained[col].dropna().mean()) unwt_std = float(retained[col].dropna().std(ddof=1)) if retained[col].dropna().shape[0] > 1 else None wt = wmean(er[col], er["w_stab"]) # weighted std (reliability weights) x = er[col].to_numpy(dtype=float) w = er["w_stab"].to_numpy(dtype=float) m = np.isfinite(x) & np.isfinite(w) if m.any() and w[m].sum() > 0: mu = np.average(x[m], weights=w[m]) wt_std = float(np.sqrt(np.average((x[m] - mu) ** 2, weights=w[m]))) else: wt_std = None smd_unwt = std_diff(unwt, overall, unwt_std or 0.0, overall_std or 0.0) if overall_std else None smd_ipw = std_diff(wt, overall, wt_std or 0.0, overall_std or 0.0) if (wt is not None and overall_std) else None post["continuous"][col] = { "eligible_mean": overall, "eligible_std": overall_std, "retained_unweighted_mean": unwt, "retained_unweighted_std": unwt_std, "retained_ipw_stabilized_mean": wt, "retained_ipw_stabilized_std": wt_std, "abs_bias_unweighted_vs_eligible": abs(unwt - overall) if unwt is not None else None, "abs_bias_ipw_vs_eligible": abs(wt - overall) if wt is not None else None, "smd_unweighted_vs_eligible": smd_unwt, "smd_ipw_vs_eligible": smd_ipw, } def props_unweighted(series): s = series.dropna() vc = s.value_counts(normalize=True).sort_index() out = {} for k, v in vc.items(): key = str(int(k)) if float(k).is_integer() else str(k) out[key] = float(v) return out, int(s.shape[0]), int(series.isna().sum()) def props_weighted(series, weights): s = pd.to_numeric(series, errors="coerce") w = pd.to_numeric(weights, errors="coerce") m = s.notna() & w.notna() if not m.any(): return {}, 0, int((~m).sum()) ss = s[m] ww = w[m] levels = sorted(ss.unique(), key=lambda x: (0, int(x)) if float(x).is_integer() else (1, str(x))) out = {} total = float(ww.sum()) for lev in levels: key = str(int(lev)) if float(lev).is_integer() else str(lev) out[key] = float(ww[ss == lev].sum() / total) if total else 0.0 return out, int(m.sum()), int((~m).sum()) modeled_cats = ["SEX", "EDUC", "REGION", "PAGO", "PEXP"] extra_cats = ["origin_year", "origin_month", "METHOD"] cat_smd_rows = [] for col in modeled_cats + extra_cats: elig_p, elig_n, elig_miss = props_unweighted(elig[col]) ret_p, ret_n, ret_miss = props_unweighted(retained[col]) ipw_p, ipw_n, ipw_miss = props_weighted(er[col], er["w_stab"]) keys = sorted(set(elig_p) | set(ret_p) | set(ipw_p), key=lambda x: (len(x), x)) levels = {} for k in keys: pe = elig_p.get(k, 0.0) pr = ret_p.get(k, 0.0) pi = ipw_p.get(k, 0.0) pooled_u = math.sqrt(0.5 * (pr * (1 - pr) + pe * (1 - pe))) pooled_i = math.sqrt(0.5 * (pi * (1 - pi) + pe * (1 - pe))) smd_u = ((pr - pe) / pooled_u) if pooled_u > 0 else None smd_i = ((pi - pe) / pooled_i) if pooled_i > 0 else None levels[k] = { "label": LABELS.get(col, {}).get(k), "prop_eligible": pe, "prop_retained_unweighted": pr, "prop_retained_ipw_stabilized": pi, "smd_unweighted_vs_eligible": smd_u, "smd_ipw_vs_eligible": smd_i, "abs_smd_unweighted": abs(smd_u) if smd_u is not None else None, "abs_smd_ipw": abs(smd_i) if smd_i is not None else None, } cat_smd_rows.append({ "covariate": col, "level": k, "label": LABELS.get(col, {}).get(k), "prop_eligible": pe, "prop_retained_unweighted": pr, "prop_retained_ipw": pi, "smd_unweighted_vs_eligible": smd_u, "smd_ipw_vs_eligible": smd_i, }) abs_u = [abs(v["smd_unweighted_vs_eligible"]) for v in levels.values() if v["smd_unweighted_vs_eligible"] is not None] abs_i = [abs(v["smd_ipw_vs_eligible"]) for v in levels.values() if v["smd_ipw_vs_eligible"] is not None] post["categorical"][col] = { "modeled_in_logit": col in modeled_cats, "eligible_n": elig_n, "eligible_missing": elig_miss, "retained_n": ret_n, "retained_missing": ret_miss, "levels": levels, "max_abs_smd_unweighted": max(abs_u) if abs_u else None, "max_abs_smd_ipw": max(abs_i) if abs_i else None, "n_levels_abs_smd_ipw_ge_0_10": sum(1 for x in abs_i if x >= 0.10), "n_levels_abs_smd_ipw_ge_0_20": sum(1 for x in abs_i if x >= 0.20), } # continuous rows into cat_smd_rows-compatible summary cont_abs_ipw = [] for col, blk in post["continuous"].items(): if blk.get("smd_ipw_vs_eligible") is not None: cont_abs_ipw.append(abs(blk["smd_ipw_vs_eligible"])) cat_smd_rows.append({ "covariate": col, "level": "mean", "label": "continuous mean", "prop_eligible": blk["eligible_mean"], "prop_retained_unweighted": blk["retained_unweighted_mean"], "prop_retained_ipw": blk["retained_ipw_stabilized_mean"], "smd_unweighted_vs_eligible": blk["smd_unweighted_vs_eligible"], "smd_ipw_vs_eligible": blk["smd_ipw_vs_eligible"], }) modeled_max = [ post["categorical"][c]["max_abs_smd_ipw"] for c in modeled_cats if post["categorical"][c]["max_abs_smd_ipw"] is not None ] post["summary"] = { "comparison": "retained (unweighted and IPW-stabilized) vs full eligible N1", "smd_thresholds_note": ".10/.20 descriptive flags only — not confirmatory validity gates", "continuous_max_abs_smd_ipw": max(cont_abs_ipw) if cont_abs_ipw else None, "modeled_categorical_max_abs_smd_ipw": max(modeled_max) if modeled_max else None, "modeled_categorical_n_levels_abs_smd_ipw_ge_0_10": sum( post["categorical"][c]["n_levels_abs_smd_ipw_ge_0_10"] for c in modeled_cats ), "modeled_categorical_n_levels_abs_smd_ipw_ge_0_20": sum( post["categorical"][c]["n_levels_abs_smd_ipw_ge_0_20"] for c in modeled_cats ), "exploratory_flag_mostly_under_0_20": ( (max(modeled_max) if modeled_max else 0) < 0.20 and (max(cont_abs_ipw) if cont_abs_ipw else 0) < 0.20 ), } # Full eligible-cohort overlap (N1), not retained-only p_all = np.asarray(p, dtype=float) p_drop = p_all[y == 0] def p_summary(arr, label): arr = np.asarray(arr, dtype=float) return { "label": label, "n": int(len(arr)), "min": float(arr.min()) if len(arr) else None, "p1": float(np.percentile(arr, 1)) if len(arr) else None, "p5": float(np.percentile(arr, 5)) if len(arr) else None, "p25": float(np.percentile(arr, 25)) if len(arr) else None, "median": float(np.median(arr)) if len(arr) else None, "p75": float(np.percentile(arr, 75)) if len(arr) else None, "p95": float(np.percentile(arr, 95)) if len(arr) else None, "p99": float(np.percentile(arr, 99)) if len(arr) else None, "max": float(arr.max()) if len(arr) else None, "mean": float(arr.mean()) if len(arr) else None, "std": float(arr.std(ddof=1)) if len(arr) > 1 else None, "share_below_0_05": float(np.mean(arr < 0.05)) if len(arr) else None, "share_below_0_10": float(np.mean(arr < 0.10)) if len(arr) else None, "share_above_0_90": float(np.mean(arr > 0.90)) if len(arr) else None, } # common support: share of eligible with p in retained range rmin, rmax = float(p_ret.min()), float(p_ret.max()) overlap = { "scope": "full_eligible_N1_plus_retained_and_dropped_splits", "propensity_clip_used_for_weights": [0.05, 0.95], "eligible_all": p_summary(p_all, "eligible_N1"), "retained": p_summary(p_ret, "retained_N3"), "dropped": p_summary(p_drop, "dropped_N1_minus_N3"), "common_support": { "retained_p_range": [rmin, rmax], "share_eligible_inside_retained_range": float(np.mean((p_all >= rmin) & (p_all <= rmax))), "share_dropped_inside_retained_range": float(np.mean((p_drop >= rmin) & (p_drop <= rmax))) if len(p_drop) else None, "share_eligible_outside_retained_range": float(np.mean((p_all < rmin) | (p_all > rmax))), "note": ( "Common-support heuristic only: fraction of eligible propensity scores falling inside " "the observed retained min–max. Not a proof of ignorable selection." ), }, "histogram_eligible_10bins": { "edges": [round(x, 4) for x in np.linspace(0, 1, 11).tolist()], "counts": [int(c) for c in np.histogram(p_all, bins=np.linspace(0, 1, 11))[0]], }, } # Research verdict scaffolding (do NOT auto-PASS) verdict = { "status": "RESEARCH_IN_PROGRESS", "proposed_criteria_from_astra": { "PASS": "reproducible and selection manageable with stable sensitivity", "CONDITIONAL": "selected estimates/weights unstable", "FAIL": "linkage fails or inference unsupported", }, "codex_qualification": ( "Stability under one observed-variable adjustment is NOT proof of population validity; " "unmeasured selection remains possible." ), "this_tick": { "funnel_reproducible_crosscheck": funnel["cross_check_attr_json"]["B_plus_C_plus_D_equals_N1"] and funnel["cross_check_attr_json"]["N1_plus_invalid_equals_fresh_any"], "mcfadden_pseudo_r2": mcfadden, "age_smd_pre": cont["AGE"]["std_diff_retained_minus_dropped"], "ipw_ess_over_n_stabilized": sensitivities["stabilized_raw"]["ess_over_n"], "ipw_ess_over_n_trunc_5_95": sensitivities["trunc_5_95"]["ess_over_n"], "3x3_match_unweighted": three["unweighted"]["share_match"], "3x3_match_ipw_stab": three["ipw_stabilized"]["share_match"], "3x3_match_delta_pp": (three["ipw_stabilized"]["share_match"] - three["unweighted"]["share_match"]) * 100, "decision_now": "CONDITIONAL_RESEARCH — v2.1 full balance+eligible overlap published; independent replication + SDA missing covariates still open; no production adoption; M1 UNKNOWN score_authorized=0", }, "model_change": False, "score_authorized_M1": False, "L1_untouched": True, } buggy_v1_path = OUT / "m1long_v2_selection_ipw_sensitivity-v1-buggy.json" buggy_v1_sha = sha256_file(buggy_v1_path) if buggy_v1_path.exists() else None pack = { "title": "M1-LONG selection funnel + research-only IPW sensitivity (origin≤2019)", "version": "v2.1", "pack_id": "wave2b-m1-long-selection-ipw-v2.1", "generated_at": datetime.now(timezone.utc).isoformat(), "research_only": True, "label": "RESEARCH_ATTRITION_ADJUSTMENT", "score_authorized_M1": False, "L1_untouched": True, "changelog": [ { "version": "v1-buggy", "sha256": buggy_v1_sha, "artifact": "m1long_v2_selection_ipw_sensitivity-v1-buggy.json", "hub_docs": "/docs/wave2b-m1-long-selection-ipw-v1-buggy.json", "bug": ( "share_strong_negative_miss incorrectly used (pago-pexp)>=2 on codes {1,3,5}, " "so strong==any_negative. Correct strong = Better(1)->Worse(5) ONLY = cell[1][5]." ), }, { "version": "v2", "fixes": [ "strong_negative_miss := cell PEXP=1,PAGO=5 only", "explicit completers vs IPW-eligible estimands", "OPEN notes: postweight categorical SMDs; overlap on retained only; 5pp/trunc exploratory", ], "response_to_review": "0a6e24af-2c88-450e-9ae0-81f4d2b79cd8", }, { "version": "v2.1", "adds": [ "full post-weight continuous+categorical SMDs vs eligible N1 for modeled SEX/EDUC/REGION/PAGO/PEXP (+ AGE/month_index, origin_year/month, METHOD)", "full eligible-cohort propensity overlap diagnostics (N1 all / retained / dropped + common-support heuristic)", ], "response_to_review": [ "0420513f-89f4-45da-b002-1ad0e945b562", "50fd44a2-6e2b-4fdf-8610-445558ed5deb", "decf348a / d8bffa5f hub relays", ], }, ], "open_review_points": { "postweight_balance_AGE_month_only": ( "ADDRESSED in v2.1 — post-weight continuous SMDs for AGE/month_index plus full " "categorical level SMDs for modeled SEX/EDUC/REGION/PAGO/PEXP (and origin_year/month/METHOD) " "vs eligible N1 are published." ), "overlap_retained_not_all_eligible": ( "ADDRESSED in v2.1 — overlap diagnostics now cover full eligible N1, retained, and dropped " "propensity summaries plus a common-support heuristic." ), "estimand_completers_vs_ipw_eligible": ( "ADDRESSED in v2 wording — unweighted 3×3 is completers (N3); IPW targets " "eligible cohort (N1) via observed-covariate attrition adjustment." ), "five_pp_thresholds_and_truncation": ( "OPEN/EXPLORATORY — 5pp share-move and 1–99/5–95 truncation labeled exploratory " "absent a timestamped predeclared protocol artifact." ), "independent_replication": ( "OPEN — Claude/Codex independent source replication still required; ChatGPT formal " "/research credential path still blocked (use Claude private channel or James-approved transfer)." ), "missing_covariates_sda": ( "OPEN — income/employment/party/HHsize absent from pilot extract; SDA re-extract required; " "no proxies invented." ), }, "response_to": [ "0420513f-89f4-45da-b002-1ad0e945b562", "50fd44a2-6e2b-4fdf-8610-445558ed5deb", "0a6e24af-2c88-450e-9ae0-81f4d2b79cd8", "4d425973-1c56-4f58-955c-199c12fffd2b", "452be9e1-e330-459a-9885-e413dc9196fa", "astra:8a1b5844-0d4f-4215-a926-432bd572816b", "prior_diag:54396241-9636-4773-9f13-ee4ff8a06ee8", ], "funnel": funnel, "official_labels": label_audit, "unavailable_covariates": unavailable, "pre_adjustment_balance": {"continuous": cont, "categorical": cats}, "smd_thresholds_note": ".10/.20 are descriptive flags, not universal validity thresholds.", "retention_logit_diagnostic": { "method": "sklearn LogisticRegression L2 C=1.0 lbfgs", "converged": converged, "n_iter": n_iter, "n": int(len(y)), "base_rate": p0, "loglik": ll, "loglik_null": ll0, "mcfadden_pseudo_r2": mcfadden, "coefficients": coefs, "predictors": "AGE, month_index (scaled) + SEX/EDUC/REGION/PAGO/PEXP dummies; origin_year omitted (collinear with month_index)", "caveat": ( "Low McFadden R² does NOT establish ignorable selection. Observed-covariate IPW is " "RESEARCH_ATTRITION_ADJUSTMENT only — never official survey weights / never population inference." ), }, "ipw_sensitivity": { "label": "RESEARCH_ATTRITION_ADJUSTMENT", "stabilization": "w = mean(p_retained) / clip(p_hat,0.05,0.95)", "truncation_predeclared": ["none(stabilized)", "1-99", "5-95"], "weight_distributions": sensitivities, "postweight_balance_vs_eligible": post, "overlap_full_eligible": overlap, "overlap_note": ( f"ADDRESSED v2.1: full eligible N1 p_hat min={float(p_all.min()):.4f} " f"max={float(p_all.max()):.4f} mean={float(p_all.mean()):.4f}; " f"retained min={float(p_ret.min()):.4f} max={float(p_ret.max()):.4f} mean={float(p_ret.mean()):.4f}; " f"share_eligible_inside_retained_range=" f"{overlap['common_support']['share_eligible_inside_retained_range']:.4f}." ), "postweight_balance_scope": ( "ADDRESSED v2.1: continuous AGE/month_index SMDs + full categorical level SMDs for " "modeled SEX/EDUC/REGION/PAGO/PEXP (plus origin_year/month/METHOD) vs eligible N1." ), }, "three_by_three_pexp_pago": three, "replication_artifacts": { "raw_extract": "data/raw/michigan_sca/microdata/sca_micro_2015_2023_pilot.csv", "raw_sha256": "8f74769178d2918818906ac41a99d795131af4592b17beefa4b1d2ef05771dcf", "pairs_v2": "data/m1/v2/m1long_pairs_origin_le2019.csv", "scripts": [ "srp/m1/build_m1_long_pilot.py", "srp/m1/build_m1_long_v2.py", "srp/m1/build_m1_long_selection_diag.py", "srp/m1/build_m1_long_selection_ipw_v2.py", ], "private_transfer": ( "Raw microdata stays on private box; Hub publishes hashes+scripts+JSON only. " "Claude/Codex can replicate from SDA download recipe in PROVENANCE.md + these scripts. " "ChatGPT formal /research still blocked on consumer credential — use Claude private channel " "or James-approved private transfer; do not publish secrets." ), "source_urls": [ "https://sda.umsurvey.org/", "https://sda.umsurvey.org/sca/Doc/sca0001.htm", ], }, "research_verdict": verdict, } OUT.mkdir(parents=True, exist_ok=True) json_path = OUT / "m1long_v2_selection_ipw_sensitivity.json" json_path.write_text(json.dumps(pack, indent=2) + "\n") # balance csv: pre-adjustment continuous + full post-weight SMD table rows = [] for col, block in cont.items(): rows.append({ "table": "pre_adjustment_retained_vs_dropped", "covariate": col, "level": "mean", "retained_n": block["retained"]["n"], "dropped_n": block["dropped"]["n"], "retained_mean": block["retained"]["mean"], "dropped_mean": block["dropped"]["mean"], "std_diff": block["std_diff_retained_minus_dropped"], }) for r in cat_smd_rows: rows.append({ "table": "postweight_vs_eligible", "covariate": r["covariate"], "level": r["level"], "label": r.get("label"), "prop_or_mean_eligible": r["prop_eligible"], "prop_or_mean_retained_unweighted": r["prop_retained_unweighted"], "prop_or_mean_retained_ipw": r["prop_retained_ipw"], "smd_unweighted_vs_eligible": r["smd_unweighted_vs_eligible"], "smd_ipw_vs_eligible": r["smd_ipw_vs_eligible"], }) bal_path = OUT / "m1long_v2_selection_ipw_balance.csv" pd.DataFrame(rows).to_csv(bal_path, index=False) age_sd = cont["AGE"]["std_diff_retained_minus_dropped"] mi_sd = cont["month_index"]["std_diff_retained_minus_dropped"] strong_def = "Better(1)→Worse(5) ONLY (= cell[1][5]); NOT any pago>pexp" memo = f"""# M1-LONG selection funnel + research-only IPW sensitivity (v2.1) **Status:** RESEARCH ONLY · `RESEARCH_ATTRITION_ADJUSTMENT` · `score_authorized(M1)=false` · L1 untouched **Pack:** `wave2b-m1-long-selection-ipw-v2.1` · Astra `0420513f` full balance+overlap + prior `0a6e24af` strong-neg fix **Response to:** Astra/ChatGPT `0420513f` / `50fd44a2` · `0a6e24af` · `4d425973` / `8a1b5844` **Prior diagnostics:** `/docs/wave2b-m1-long-selection-diagnostics` still valid as baseline. **Provenance:** buggy v1 preserved at `/docs/wave2b-m1-long-selection-ipw-v1-buggy.json` (sha `{buggy_v1_sha}`). ## Target population / estimands (explicit) {funnel['target_population_estimand']} **Manageability flags:** {funnel['manageability_definition_predeclared']} **Protocol note:** {funnel['manageability_definition_note']} ## Funnel (mutually exclusive; Codex clarification applied) | Stage | N | Base | |------|--:|------| | A — source-cohort invalid/missing origin PEXP (before N1) | {n_invalid_pexp} | fresh any {n_fresh_any} | | B — no second interview / reint2 linkage | {n1_not_n2} | N1={n1} | | C — second interview but unusable third-interview pair | {n2_not_n3} | N2={n2} | | D — retained usable pair (N3) | {n3} | N1={n1} | Cross-check: A+N1=fresh_any → `{n_invalid_pexp + n1 == n_fresh_any}`; B+C+D=N1 → `{n1_not_n2 + n2_not_n3 + n3 == n1}`. Missing-origin PEXP is **not** double-counted inside 5793. Unknown disposition reasons inside B/C remain UNKNOWN. ## Official labels (8/9 = DK/refusal, not midpoints) See JSON `official_labels` for SEX/EDUC/PEXP/PAGO. Substantive PEXP/PAGO analysis stays on {{1,3,5}}. ## Unavailable covariates (no proxies) Income, employment, party ID, household size: **not** in this extract. Re-extract from SDA required. ## Pre-adjustment SMDs (descriptive flags, not validity thresholds) | Covariate | Retained mean | Dropped mean | Std diff | |-----------|---------------|--------------|----------| | AGE | {cont['AGE']['retained']['mean']:.3f} | {cont['AGE']['dropped']['mean']:.3f} | {age_sd:.3f} | | month_index (real time) | {cont['month_index']['retained']['mean']:.2f} | {cont['month_index']['dropped']['mean']:.2f} | {mi_sd:.3f} | Raw YYYYMM integer is **not** used as an interval (201812→201901 artifact). ## Research-only IPW (`RESEARCH_ATTRITION_ADJUSTMENT`) - McFadden pseudo-R²: **{mcfadden:.4f}** (low — does **not** prove ignorability) - Stabilized ESS/N: **{sensitivities['stabilized_raw']['ess_over_n']:.3f}** (min={sensitivities['stabilized_raw']['min']:.2f}, max={sensitivities['stabilized_raw']['max']:.2f}) - Trunc 5–95 ESS/N: **{sensitivities['trunc_5_95']['ess_over_n']:.3f}** - Never official survey weights. Unmeasured selection remains possible. ## 3×3 PEXP×PAGO (match / any neg / strong neg / favorable) **Strong negative definition:** {strong_def}. Any-negative = all pago>pexp cells (1→3 + 1→5 + 3→5). | Spec | Match | Any neg | Strong neg | Favorable | |------|------:|--------:|-----------:|----------:| | Unweighted | {three['unweighted']['share_match']:.6f} | {three['unweighted']['share_any_negative_miss']:.6f} | {three['unweighted']['share_strong_negative_miss']:.6f} | {three['unweighted']['share_favorable_surprise']:.6f} | | IPW stabilized | {three['ipw_stabilized']['share_match']:.6f} | {three['ipw_stabilized']['share_any_negative_miss']:.6f} | {three['ipw_stabilized']['share_strong_negative_miss']:.6f} | {three['ipw_stabilized']['share_favorable_surprise']:.6f} | | IPW trunc 1–99 | {three['ipw_trunc_1_99']['share_match']:.6f} | {three['ipw_trunc_1_99']['share_any_negative_miss']:.6f} | {three['ipw_trunc_1_99']['share_strong_negative_miss']:.6f} | {three['ipw_trunc_1_99']['share_favorable_surprise']:.6f} | | IPW trunc 5–95 | {three['ipw_trunc_5_95']['share_match']:.6f} | {three['ipw_trunc_5_95']['share_any_negative_miss']:.6f} | {three['ipw_trunc_5_95']['share_strong_negative_miss']:.6f} | {three['ipw_trunc_5_95']['share_favorable_surprise']:.6f} | Unweighted check: strong = 105/1424 = {three['unweighted']['share_strong_negative_miss']:.10f}; any-neg = 382/1424 = {three['unweighted']['share_any_negative_miss']:.9f} (unchanged). ## Post-weight balance vs eligible N1 (v2.1) Continuous + every modeled categorical level (SEX/EDUC/REGION/PAGO/PEXP) and extras (origin_year/month/METHOD). Max |SMD| IPW (modeled cats): **{post['summary']['modeled_categorical_max_abs_smd_ipw']:.4f}** · continuous max |SMD| IPW: **{post['summary']['continuous_max_abs_smd_ipw']:.4f}** · levels ≥0.20: **{post['summary']['modeled_categorical_n_levels_abs_smd_ipw_ge_0_20']}**. Exploratory “mostly <0.20” flag: **{post['summary']['exploratory_flag_mostly_under_0_20']}** (descriptive only). ## Eligible-cohort overlap (v2.1) Full N1 propensity: min={overlap['eligible_all']['min']:.4f} max={overlap['eligible_all']['max']:.4f} mean={overlap['eligible_all']['mean']:.4f}. Retained range [{overlap['common_support']['retained_p_range'][0]:.4f}, {overlap['common_support']['retained_p_range'][1]:.4f}]; share eligible inside retained range: **{overlap['common_support']['share_eligible_inside_retained_range']:.4f}**. Heuristic only — not ignorability. ## Open review points - **ADDRESSED (v2.1)** — full post-weight categorical + continuous SMDs vs eligible N1. - **ADDRESSED (v2.1)** — overlap diagnostics cover full eligible N1 (not retained-only). - **ADDRESSED** — estimand wording distinguishes completers (unweighted N3) vs IPW eligible cohort (N1). - **OPEN/EXPLORATORY** — 5pp thresholds and truncation bands labeled exploratory absent timestamped predeclared protocol. - **OPEN** — independent Claude/Codex replication; ChatGPT `/research` credential path; SDA re-extract for income/employment/party/HHsize. ## Verdict now **CONDITIONAL_RESEARCH** — v2.1 publishes the requested full balance + eligible overlap audit; independent replication and missing-covariate re-extract still required. **No production adoption. M1 stays UNKNOWN / unscored. No M1 scoring / L1 changes.** ## Links - JSON (canonical): `/docs/wave2b-m1-long-selection-ipw.json` - JSON v2.1: `/docs/wave2b-m1-long-selection-ipw-v2.1.json` - Memo v2.1: `/docs/wave2b-m1-long-selection-ipw-v2.1` - Prior v2 (strong-neg fix): `/docs/wave2b-m1-long-selection-ipw-v2` - Buggy v1 preserved: `/docs/wave2b-m1-long-selection-ipw-v1-buggy.json` - Balance CSV: `/docs/wave2b-m1-long-selection-ipw-balance.csv` - Prior selection diag: `/docs/wave2b-m1-long-selection-diagnostics` - Hub job: `b73c292a` · research: `c4daef50` """ DOCS.mkdir(parents=True, exist_ok=True) memo_path = DOCS / "WAVE2B_M1_LONG_SELECTION_IPW.md" memo_path.write_text(memo) # copy into hub docs for deploy HUB_DOCS.mkdir(parents=True, exist_ok=True) payload = json.dumps(pack, indent=2) + "\n" (HUB_DOCS / "wave2b-m1-long-selection-ipw.md").write_text(memo) (HUB_DOCS / "wave2b-m1-long-selection-ipw.json").write_text(payload) # preserve prior v2 strong-neg-fix artifacts if present; do not overwrite with v2.1 body v21_md = HUB_DOCS / "wave2b-m1-long-selection-ipw-v2.1.md" v21_json = HUB_DOCS / "wave2b-m1-long-selection-ipw-v2.1.json" v21_md.write_text(memo) v21_json.write_text(payload) (HUB_DOCS / "wave2b-m1-long-selection-ipw-balance.csv").write_text(bal_path.read_text()) (HUB_DOCS / "wave2b-m1-long-selection-ipw.py.txt").write_text(Path(__file__).read_text()) # observatory named copies (OUT / "m1long_v2_selection_ipw_sensitivity-v2.1.json").write_text(payload) sums = [] for p in sorted(OUT.iterdir()): if p.is_file() and p.name != "SHA256SUMS.txt": sums.append(f"{sha256_file(p)} {p.name}") (OUT / "SHA256SUMS.txt").write_text("\n".join(sums) + "\n") print(json.dumps({ "N1": n1, "N2": n2, "N3": n3, "invalid_pexp": n_invalid_pexp, "mcfadden": mcfadden, "age_smd": age_sd, "month_index_smd": mi_sd, "ess_over_n": sensitivities["stabilized_raw"]["ess_over_n"], "match_unwt": three["unweighted"]["share_match"], "match_ipw": three["ipw_stabilized"]["share_match"], "strong_unwt": three["unweighted"]["share_strong_negative_miss"], "strong_stab": three["ipw_stabilized"]["share_strong_negative_miss"], "strong_t1": three["ipw_trunc_1_99"]["share_strong_negative_miss"], "strong_t5": three["ipw_trunc_5_95"]["share_strong_negative_miss"], "any_unwt": three["unweighted"]["share_any_negative_miss"], "max_abs_smd_ipw_modeled_cats": post["summary"]["modeled_categorical_max_abs_smd_ipw"], "max_abs_smd_ipw_cont": post["summary"]["continuous_max_abs_smd_ipw"], "share_elig_in_retained_p_range": overlap["common_support"]["share_eligible_inside_retained_range"], "version": pack["version"], "json_sha": sha256_file(json_path), "memo_sha": sha256_file(memo_path), "decision": verdict["this_tick"]["decision_now"], }, indent=2)) if __name__ == "__main__": main()