#!/usr/bin/env python3 """M1-LONG v2 corrections — RESEARCH ONLY. Preserves v1 outputs under data/m1/*. Writes NEW artifacts under data/m1/v2/. No production scoring. Does not touch L1 or score_authorized(M1). """ from __future__ import annotations import hashlib import json import math from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional import pandas as pd # Reuse v1 builder primitives without rewriting v1 outputs from build_m1_long_pilot import ( SAMPLE_FRESH, SAMPLE_REINT2, SAMPLE_REINT3, VALID_ATT, LABEL, SIGNED, load_micro, build_pairs, sha256_file, yyyymm_gap_months, MICRO_CSV, ROOT, ) OUT = ROOT / "data" / "m1" / "v2" ORIGIN_MAX = 201912 # authorized origin ≤2019 LEFT_CENSOR_ORIGIN = 201810 # earliest usable successful-pair origin in extract EXTRACT_MAX = 202312 def write_codebook_excerpt(path: Path) -> None: text = """# SCA codebook excerpt — IDPREV / DATEPR / IDPREV2 / DATEPR2 **Source (official SDA codebook):** https://sda.umsurvey.org/sca/Doc/sca0001.htm **Also listed in pilot plan:** https://sda.umsurvey.org/sca/Doc/scax01.htm **Fetched for M1-LONG v2:** 2026-09-09 (research pack; not a substitute for the live codebook page) --- ## IDPREV — ID IN PREVIOUS INTERVIEW | Field | Value | |-------|-------| | Label | ID IN PREVIOUS INTERVIEW | | Text | Previous ID | | Data type | numeric | | Record/columns | 1/256-266 | Links the current interview to the immediately prior interview via the prior interview's `ID`. ## DATEPR — DATE OF PREVIOUS INTERVIEW | Field | Value | |-------|-------| | Label | DATE OF PREVIOUS INTERVIEW | | Text | Previous Date | | Data type | numeric | | Record/columns | 1/27-32 | Calendar stamp (`YYYYMM`) of the previous interview. **Pair with `IDPREV`** — ID-only linkage is rejected by the M1-LONG protocol. ## IDPREV2 — ID IN FIRST INTERVIEW FOR 3RD INTERVIEW | Field | Value | |-------|-------| | Label | ID IN FIRST INTERVIEW FOR 3RD INTERVIEW | | Text | Previous ID | | Data type | numeric | | Record/columns | 1/267-277 | Shortcut from a third interview back to the **first** (fresh) interview's `ID`. ## DATEPR2 — DATE OF FIRST INTERVIEW FOR 3RD INTERVIEW | Field | Value | |-------|-------| | Label | DATE OF FIRST INTERVIEW FOR 3RD INTERVIEW | | Text | Previous Date | | Data type | numeric | | Record/columns | 1/33-38 | Calendar stamp of the first interview for a third-interview row. **Pair with `IDPREV2`**. --- ## Related design fields (same codebook page) ### SAMPLE — SAMPLE TYPE | Code | Label | |-----:|-------| | 1 | Landline RDD Interview | | 2 | Landline RDD Reinterview | | 3 | Cell RDD Interview | | 4 | Cell RDD Reinterview | | 5 | Cell RDD Second Reinterview | | 6 | Fresh ABS Web Interview | | 7 | First ABS Web Reinterview | | 8 | Second ABS Web Reinterview | ### METHOD — DATA COLLECTION METHOD | Code | Label | |-----:|-------| | 1 | Telephone (CATI) | | 2 | Web | | 3 | Telephone (paper and pencil) | | 4 | In-person | --- ## Blank IDPREV2 / DATEPR2 in this extract In `sca_micro_2015_2023_pilot.csv` (201501–202312; sha256 `8f747691…`): - `IDPREV2` non-null count = **0** - `DATEPR2` non-null count = **0** The fields exist in the SDA codebook and were selected in the customized subset, but the public export for this window returned them blank. Michigan's data-updates log notes `IDPREV2`/`DATEPR2`/`SAMPLE`/`METHOD` were added to SDA ~May 2025 and `DATEPR`/`DATEPR2` population work ~Jun 2025 (https://data.sca.isr.umich.edu/fetchdoc.php?docid=80741) — population of the shortcut fields in historical microdata may still be incomplete in the public extract used here. **Linkage used in v1/v2:** `(IDPREV, DATEPR)` two-step chain only: destination (interview 3) → mid (interview 2) → origin (interview 1). **REJECT** ID-only matching. When `IDPREV2`/`DATEPR2` are later populated, the protocol prefers the shortcut with chain-agreement QC. ## Provenance pointer See `data/raw/michigan_sca/microdata/PROVENANCE.md` for download URL, UTC timestamp, row count, and file sha256. """ path.write_text(text) def cohort_keys(df: pd.DataFrame) -> set[tuple[int, int]]: return {(int(r.ID), int(r.YYYYMM)) for r in df.itertuples(index=False)} def build_prior_sets(df: pd.DataFrame) -> dict[str, set[tuple[int, int]]]: """Map prior (ID,YYYYMM) referenced by reinterview rows.""" mid = df[df["SAMPLE"].isin(SAMPLE_REINT2)] dest = df[df["SAMPLE"].isin(SAMPLE_REINT3)] mid_priors: set[tuple[int, int]] = set() dest_priors: set[tuple[int, int]] = set() # dest → mid via IDPREV/DATEPR for r in mid.itertuples(index=False): if pd.isna(r.IDPREV) or pd.isna(r.DATEPR): continue mid_priors.add((int(r.IDPREV), int(r.DATEPR))) for r in dest.itertuples(index=False): if pd.isna(r.IDPREV) or pd.isna(r.DATEPR): continue dest_priors.add((int(r.IDPREV), int(r.DATEPR))) return {"mid_priors": mid_priors, "dest_to_mid": dest_priors} def attrition_at_risk( df: pd.DataFrame, pairs_df: pd.DataFrame, origin_min: int, origin_max: int, label: str, ) -> dict[str, Any]: """Genuine at-risk attrition with left/right censoring + SAMPLE design rules. N1: fresh SAMPLE∈{1,3,6}, valid PEXP, origin in [origin_min, origin_max] (left-censor early months; right-censor handled by origin_max choice) N2: of N1, (ID,YYYYMM) appears as IDPREV/DATEPR of SAMPLE∈{2,4,7} N3: of N1, appears as origin of a successful linked pair (valid PAGO, fresh origin, chain link) with gap in {11,12,13} preferred; also report any-gap Step hazards use correct denominators: h12=N2/N1, h23=N3/N2, overall=N3/N1. """ fresh = df[df["SAMPLE"].isin(SAMPLE_FRESH)].copy() n1_pool = fresh[ (fresh["YYYYMM"] >= origin_min) & (fresh["YYYYMM"] <= origin_max) & (fresh["PEXP"].isin(VALID_ATT)) ] # missingness among fresh in window (design-eligible before PEXP filter) fresh_window = fresh[ (fresh["YYYYMM"] >= origin_min) & (fresh["YYYYMM"] <= origin_max) ] n_fresh_window = int(len(fresh_window)) n_pexp_missing = int((~fresh_window["PEXP"].isin(VALID_ATT)).sum()) priors = build_prior_sets(df) n1_keys = cohort_keys(n1_pool) n2_keys = {k for k in n1_keys if k in priors["mid_priors"]} # N3 from pairs whose origin is in N1 window sub = pairs_df[ (pairs_df["origin_yyyymm"] >= origin_min) & (pairs_df["origin_yyyymm"] <= origin_max) ] n3_any = int(len(sub)) n3_gap = int(sub["gap_in_11_13"].sum()) if len(sub) else 0 n3_keys = set(zip(sub["origin_id"].astype(int), sub["origin_yyyymm"].astype(int))) # Among N2, how many reach N3 (step hazard denom = N2) n3_from_n2 = len(n2_keys & n3_keys) n1 = len(n1_keys) n2 = len(n2_keys) # SAMPLE composition in N1 sample_counts = { str(int(k)): int(v) for k, v in n1_pool["SAMPLE"].value_counts().sort_index().items() } # Right/left censor notes notes = [ f"Window label: {label}", f"Left-censor: origins < {LEFT_CENSOR_ORIGIN} excluded — SAMPLE=5 (cell 2nd reinterview) " f"first appears {int(df.loc[df['SAMPLE']==5,'YYYYMM'].min())}; ~12m design gap ⇒ earliest " f"usable origin {LEFT_CENSOR_ORIGIN}.", f"Right-censor rule: origin_max={origin_max} chosen so destination ~+12m is observable " f"in extract through {EXTRACT_MAX} (and/or matches authorized ≤2019 bound).", "N1 = fresh SAMPLE∈{1,3,6} with PEXP∈{1,3,5} in window.", "N2 = N1 keys appearing as (IDPREV,DATEPR) of SAMPLE∈{2,4,7}.", "N3 = successful IDPREV/DATEPR chain pairs with fresh origin + valid PAGO (this extract: IDPREV2 blank).", "Step hazards: h12=N2/N1, h23=N3_from_N2/N2, overall=N3/N1 — NOT crude pooled 4907/37153.", "This extract SAMPLE support: {2,3,4,5} only (no landline/ABS web codes populated).", ] def rate(num, den): return round(num / den, 6) if den else None return { "label": label, "origin_min_yyyymm": origin_min, "origin_max_yyyymm": origin_max, "N_fresh_in_window_any_pexp": n_fresh_window, "N_fresh_pexp_missing_or_invalid": n_pexp_missing, "N1_fresh_valid_pexp_at_risk": n1, "N2_reached_reint2": n2, "N3_linked_pairs_any_gap": n3_any, "N3_linked_pairs_gap_11_13": n3_gap, "N3_origins_in_N2": n3_from_n2, "hazard_N1_to_N2": rate(n2, n1), "hazard_N2_to_N3": rate(n3_from_n2, n2), "retention_N1_to_N3": rate(n3_any, n1), "attrition_N1_to_N2": rate(n1 - n2, n1), "attrition_N2_to_N3": rate(n2 - n3_from_n2, n2) if n2 else None, "attrition_N1_to_N3": rate(n1 - n3_any, n1), "sample_counts_N1": sample_counts, "crude_v1_ratio_DO_NOT_USE": { "N3_over_N1_pooled_extract": "4907/37153=0.1321", "why_invalid": "Pools left-censored pre-201810 fresh + right-tail origins lacking full N3 exposure; not a cohort attrition rate.", }, "notes": notes, } def miss_summary(pairs_df: pd.DataFrame) -> dict[str, Any]: primary = pairs_df[pairs_df["gap_in_11_13"] == 1] n = len(primary) if n == 0: return {"n": 0} err = primary["error_signed"] return { "n": int(n), "match_error0": int((err == 0).sum()), "match_share": round(float((err == 0).mean()), 4), "strong_neg_error_plus2": int((err == 2).sum()), "strong_neg_share": round(float((err == 2).mean()), 4), "total_neg_error_gt0": int((err > 0).sum()), "total_neg_share": round(float((err > 0).mean()), 4), "error_signed_counts": {str(int(k)): int(v) for k, v in err.value_counts().sort_index().items()}, "contingency": { f"{LABEL[int(e)]}→{LABEL[int(a)]}": int( ((primary["pexp"] == e) & (primary["pago"] == a)).sum() ) for e in (1, 3, 5) for a in (1, 3, 5) }, } def interval_stats(pairs_df: pd.DataFrame) -> dict[str, Any]: def leg(series): s = pd.Series(series) return { "n": int(len(s)), "min": int(s.min()) if len(s) else None, "p25": float(s.quantile(0.25)) if len(s) else None, "median": float(s.median()) if len(s) else None, "p75": float(s.quantile(0.75)) if len(s) else None, "max": int(s.max()) if len(s) else None, "distribution": {str(int(k)): int(v) for k, v in sorted(Counter(s).items())}, } g12 = [ yyyymm_gap_months(o, m) for o, m in zip(pairs_df["origin_yyyymm"], pairs_df["mid_yyyymm"]) if pd.notna(m) ] g23 = [ yyyymm_gap_months(m, d) for m, d in zip(pairs_df["mid_yyyymm"], pairs_df["dest_yyyymm"]) if pd.notna(m) ] g13 = list(pairs_df["gap_months"].astype(int)) in_tol = sum(1 for g in g13 if 11 <= g <= 13) return { "predeclared_1_to_3_tolerance_months": [11, 12, 13], "leg_1_to_2": leg(g12), "leg_2_to_3": leg(g23), "leg_1_to_3": leg(g13), "share_1_to_3_in_tolerance": round(in_tol / len(g13), 6) if g13 else None, "count_1_to_3_in_tolerance": in_tol, } def linked_vs_unlinked(df: pd.DataFrame, pairs_df: pd.DataFrame, origin_min, origin_max) -> dict[str, Any]: fresh = df[ df["SAMPLE"].isin(SAMPLE_FRESH) & df["PEXP"].isin(VALID_ATT) & (df["YYYYMM"] >= origin_min) & (df["YYYYMM"] <= origin_max) ].copy() linked_keys = set( zip(pairs_df["origin_id"].astype(int), pairs_df["origin_yyyymm"].astype(int)) ) fresh["_key"] = list(zip(fresh["ID"].astype(int), fresh["YYYYMM"].astype(int))) linked = fresh[fresh["_key"].isin(linked_keys)] unlinked = fresh[~fresh["_key"].isin(linked_keys)] def desc(sub, name): out = {"group": name, "n": int(len(sub))} if len(sub) == 0: return out age = pd.to_numeric(sub["AGE"], errors="coerce") out["age_mean"] = round(float(age.mean()), 3) if age.notna().any() else None out["age_median"] = round(float(age.median()), 3) if age.notna().any() else None for col in ("SEX", "EDUC", "REGION", "PEXP"): if col in sub.columns: out[f"{col}_counts"] = { str(int(k)) if pd.notna(k) else "NA": int(v) for k, v in sub[col].value_counts(dropna=False).items() } return out return { "policy": ( "Destination WT is a cross-section weight, NOT automatically longitudinal. " "Until panel/selection weights exist → unweighted linked-sample descriptives only." ), "linked_origins": desc(linked, "linked_origins"), "unlinked_fresh_valid_pexp": desc(unlinked, "unlinked_fresh_valid_pexp"), } def main() -> None: OUT.mkdir(parents=True, exist_ok=True) df = load_micro() pairs, link_meta = build_pairs(df) pairs_df = pd.DataFrame([p.__dict__ for p in pairs]) # --- 1) Authorized origin ≤2019 subset (preserve v1 file untouched) --- sub = pairs_df[pairs_df["origin_yyyymm"] <= ORIGIN_MAX].copy() # Also enforce left-censor documentation: all should already be ≥201810 assert sub["origin_yyyymm"].min() >= LEFT_CENSOR_ORIGIN pairs_path = OUT / "m1long_pairs_origin_le2019.csv" sub.to_csv(pairs_path, index=False) protocol = { "name": "m1long_pilot_stratumA_v2_origin_le2019", "research_only": True, "score_authorized": False, "preserves_v1": True, "v1_label": "pilot_v1_2015_2023_full_extract", "v1_pairs_path": "data/m1/m1long_pairs_pilot_stratumA.csv", "authorization_window": { "origin_yyyymm_max": ORIGIN_MAX, "origin_yyyymm_min_observed": int(sub["origin_yyyymm"].min()), "left_censor_earliest_usable_origin": LEFT_CENSOR_ORIGIN, "left_censor_reason": ( "SAMPLE=5 (Cell RDD Second Reinterview) first appears 201910 in this extract; " "with ~12-month 1→3 design spacing, earliest usable origin is 201810. " "Raw extract starts 201501 but pre-201810 fresh interviews are left-censored " "for completed 1→2→3 chains." ), "executed_extract": "201501-202312", "planned_vs_executed": ( "Review authorized 2015-2019 origin subset; executed pull was full 2015-2023. " "This v2 file is the authorized origin≤201912 subset of linked pairs." ), }, "linkage": { "reject_id_only": True, "primary_when_available": "(IDPREV2, DATEPR2) → (ID, YYYYMM)", "used_in_this_extract": "(IDPREV, DATEPR) chain 3→2→1", "IDPREV2_nonnull": int(df["IDPREV2"].notna().sum()), "DATEPR2_nonnull": int(df["DATEPR2"].notna().sum()), "destination_sample": sorted(SAMPLE_REINT3), "origin_sample_fresh": sorted(SAMPLE_FRESH), "valid_pexp_pago": sorted(VALID_ATT), "gap_primary_months": [11, 12, 13], }, "n_pairs": int(len(sub)), "n_pairs_gap_11_13": int(sub["gap_in_11_13"].sum()), "inputs": { "micro_csv": str(MICRO_CSV.relative_to(ROOT)), "micro_sha256": sha256_file(MICRO_CSV), }, "generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gates": { "research_only": True, "no_production_scoring": True, "score_authorized_M1": False, "L1_untouched": True, }, } protocol_path = OUT / "m1long_v2_origin_le2019_protocol.json" protocol_path.write_text(json.dumps(protocol, indent=2) + "\n") # --- 2) Attrition tables --- # Authorized ≤2019 at-risk (left-censored at 201810; right-censor N/A within extract for these origins) attr_le2019 = attrition_at_risk( df, pairs_df, LEFT_CENSOR_ORIGIN, ORIGIN_MAX, "authorized_origin_le2019_at_risk" ) # Full phone-core at-risk through origins that can complete N3 in extract (origin≤202212) attr_full = attrition_at_risk( df, pairs_df, LEFT_CENSOR_ORIGIN, 202212, "phone_core_at_risk_origin_201810_202212" ) # Diagnostic: show how bad the crude pooled ratio is attr_crude_window = attrition_at_risk( df, pairs_df, int(df["YYYYMM"].min()), int(df["YYYYMM"].max()), "DIAGNOSTIC_unrestricted_fresh_window_not_for_inference" ) attrition = { "title": "M1-LONG v2 genuine at-risk attrition", "research_only": True, "authorized_origin_le2019": attr_le2019, "phone_core_full_at_risk": attr_full, "diagnostic_unrestricted_NOT_attrition": attr_crude_window, "v1_crude_rejected": { "ratio": "4907/37153=13.2%", "status": "REJECTED_as_attrition", "replacement": "Use authorized_origin_le2019 and phone_core_full_at_risk step hazards", }, } attrition_path = OUT / "m1long_v2_attrition.json" attrition_path.write_text(json.dumps(attrition, indent=2) + "\n") # Human-readable attrition CSV tables rows = [] for block_name, block in [ ("authorized_le2019", attr_le2019), ("phone_core_201810_202212", attr_full), ]: rows.append( { "cohort": block_name, "origin_min": block["origin_min_yyyymm"], "origin_max": block["origin_max_yyyymm"], "N1": block["N1_fresh_valid_pexp_at_risk"], "N2": block["N2_reached_reint2"], "N3": block["N3_linked_pairs_gap_11_13"], "h12_N2_over_N1": block["hazard_N1_to_N2"], "h23_N3_over_N2": block["hazard_N2_to_N3"], "overall_N3_over_N1": block["retention_N1_to_N3"], "attrition_N1_to_N3": block["attrition_N1_to_N3"], } ) attrition_csv = OUT / "m1long_v2_attrition_table.csv" pd.DataFrame(rows).to_csv(attrition_csv, index=False) flow_rows = [] for block_name, block in [ ("authorized_le2019", attr_le2019), ("phone_core_201810_202212", attr_full), ]: flow_rows.extend( [ { "cohort": block_name, "step": "N1_fresh_valid_pexp", "n": block["N1_fresh_valid_pexp_at_risk"], "rate_vs_N1": 1.0, }, { "cohort": block_name, "step": "N2_reint2", "n": block["N2_reached_reint2"], "rate_vs_N1": block["hazard_N1_to_N2"], }, { "cohort": block_name, "step": "N3_linked_gap_11_13", "n": block["N3_linked_pairs_gap_11_13"], "rate_vs_N1": block["retention_N1_to_N3"], }, ] ) flow_csv = OUT / "m1long_v2_attrition_flow.csv" pd.DataFrame(flow_rows).to_csv(flow_csv, index=False) # --- 3) Codebook --- codebook_path = OUT / "codebook_idprev_excerpt.md" write_codebook_excerpt(codebook_path) # --- Diagnostics pack for ≤2019 --- pack = { "title": "M1-LONG pilot v2 FULL pack — response to Astra/ChatGPT 09ec3c7c", "status": "RESEARCH_ONLY_EVIDENCE_READY", "verdict": "CONDITIONAL", "preserves_v1": True, "score_authorized_M1": False, "review_comment_id": "09ec3c7c-2bea-4a0b-955e-71fad93fa3d9", "generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "item1_authorized_subset": { "n_pairs_origin_le2019": int(len(sub)), "origin_min": int(sub["origin_yyyymm"].min()), "origin_max": int(sub["origin_yyyymm"].max()), "dest_min": int(sub["dest_yyyymm"].min()), "dest_max": int(sub["dest_yyyymm"].max()), "left_censor_earliest_usable": LEFT_CENSOR_ORIGIN, "pairs_path": str(pairs_path.relative_to(ROOT)), "protocol_path": str(protocol_path.relative_to(ROOT)), "origin_year_counts": { str(int(k)): int(v) for k, v in sub["origin_yyyymm"].astype(int).floordiv(100).value_counts().sort_index().items() }, }, "item2_attrition": { "authorized_le2019": attr_le2019, "phone_core_full_at_risk": { k: attr_full[k] for k in ( "N1_fresh_valid_pexp_at_risk", "N2_reached_reint2", "N3_linked_pairs_gap_11_13", "hazard_N1_to_N2", "hazard_N2_to_N3", "retention_N1_to_N3", "attrition_N1_to_N3", ) }, }, "item3_codebook_and_provenance": { "codebook_excerpt": str(codebook_path.relative_to(ROOT)), "codebook_url": "https://sda.umsurvey.org/sca/Doc/sca0001.htm", "IDPREV2_nonnull": int(df["IDPREV2"].notna().sum()), "DATEPR2_nonnull": int(df["DATEPR2"].notna().sum()), "blank_IDPREV2_explanation": ( "Fields present in codebook and selected in SDA subset but entirely blank " "(nonnull=0) in this 201501-202312 public export; linkage used IDPREV/DATEPR chain." ), "provenance": "data/raw/michigan_sca/microdata/PROVENANCE.md", "link_qc": link_meta, }, "item4_intervals": interval_stats(sub), "item5_miss": miss_summary(sub), "item6_weights_linked_unlinked": linked_vs_unlinked( df, sub, LEFT_CENSOR_ORIGIN, ORIGIN_MAX ), "item7_cohort_mode": { "mode_pair_counts": { str(k): int(v) for k, v in sub["mode_pair"].value_counts().items() }, "origin_sample_counts": { str(int(k)): int(v) for k, v in sub["origin_sample"].value_counts().sort_index().items() }, "dest_sample_counts": { str(int(k)): int(v) for k, v in sub["dest_sample"].value_counts().sort_index().items() }, "origin_year_counts": { str(int(k)): int(v) for k, v in sub["origin_yyyymm"].astype(int).floordiv(100).value_counts().sort_index().items() }, "origin_month_counts": { str(int(k)): int(v) for k, v in sub["origin_yyyymm"].value_counts().sort_index().items() }, }, "item8_verdict": { "verdict": "CONDITIONAL", "request": "re-review of v2 pack; NOT requesting methodological PASS or population estimates", "gates": { "score_authorized_M1": False, "L1_untouched": True, "A2_M1D_remain_retired": True, "no_political_outcomes": True, "research_only": True, }, }, } pack_path = OUT / "m1long_v2_pack.json" pack_path.write_text(json.dumps(pack, indent=2) + "\n") # SHA256 everything in v2/ sums = [] for p in sorted(OUT.iterdir()): if p.is_file(): sums.append(f"{sha256_file(p)} {p.name}") sums_path = OUT / "SHA256SUMS.txt" # write after hashing others; then append self? Standard: hash all except SUMS, then write SUMS sums_path.write_text("\n".join(sums) + "\n") # re-hash including newly written files that weren't SUMS — already excluded SUMS during loop if created after # Rebuild sums excluding SHA256SUMS itself sums = [] for p in sorted(OUT.iterdir()): if p.is_file() and p.name != "SHA256SUMS.txt": sums.append(f"{sha256_file(p)} {p.name}") sums_path.write_text("\n".join(sums) + "\n") print( json.dumps( { "n_pairs_le2019": int(len(sub)), "attrition_le2019": { "N1": attr_le2019["N1_fresh_valid_pexp_at_risk"], "N2": attr_le2019["N2_reached_reint2"], "N3": attr_le2019["N3_linked_pairs_gap_11_13"], "h12": attr_le2019["hazard_N1_to_N2"], "h23": attr_le2019["hazard_N2_to_N3"], "overall": attr_le2019["retention_N1_to_N3"], }, "attrition_full_at_risk": { "N1": attr_full["N1_fresh_valid_pexp_at_risk"], "N2": attr_full["N2_reached_reint2"], "N3": attr_full["N3_linked_pairs_gap_11_13"], "h12": attr_full["hazard_N1_to_N2"], "h23": attr_full["hazard_N2_to_N3"], "overall": attr_full["retention_N1_to_N3"], }, "out_dir": str(OUT), }, indent=2, ) ) if __name__ == "__main__": main()