| """Initiative 1 β Workforce evaluation page.""" |
| from __future__ import annotations |
|
|
| import sys |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
|
| import numpy as np |
| import pandas as pd |
| import plotly.express as px |
| import plotly.graph_objects as go |
| import streamlit as st |
|
|
| from utils import data_loader as dl |
| from utils import models as mdl |
| from utils.plotting import (coef_forest, event_study_plot, line_by_group, |
| scatter_with_trend) |
| from utils.styling import (INITIATIVE_COLOR, callout, equation_legend, formula, |
| header, initiative_scope, page_setup, role_box, |
| section, section_divider, verdict_chip) |
|
|
|
|
| page_setup("Initiative 1 β Workforce") |
|
|
| header( |
| "Initiative 01 β Workforce", |
| "Recover the causal effect of service-commitment awards, rural residency slots, " |
| "training participation, and supportive services on rural provider supply, " |
| "turnover, and provider mental health.", |
| pill=("Treatment unit Β· trainee/program β county exposure", "rural"), |
| ) |
|
|
| |
| |
| |
| counties = dl.counties() |
| wf_raw = dl.workforce_panel() |
| awards = dl.service_commitment_awards() |
| resid = dl.residency_slots() |
| train = dl.training_participants() |
| support = dl.workforce_supports() |
|
|
| awards_lag = awards.copy() |
| awards_lag["awards_lag1"] = awards_lag.groupby("fips")["awards_count"].shift(1).fillna(0) |
| resid_lag = resid.copy() |
| resid_lag["resid_lag2"] = resid_lag.groupby("fips")["residency_slots"].shift(2).fillna(0) |
| train_lag = train.copy() |
| train_lag["train_lag1"] = train_lag.groupby("fips")["training_participants"].shift(1).fillna(0) |
|
|
| panel = ( |
| wf_raw.merge(awards_lag[["fips", "year", "awards_lag1"]], on=["fips", "year"]) |
| .merge(resid_lag[["fips", "year", "resid_lag2"]], on=["fips", "year"]) |
| .merge(train_lag[["fips", "year", "train_lag1"]], on=["fips", "year"]) |
| .merge(support[["fips", "year", "support_intensity"]], on=["fips", "year"]) |
| .merge(counties[["fips", "county_name", "rurality", "region"]], on="fips") |
| ) |
| panel = panel.sort_values(["fips", "year"]).reset_index(drop=True) |
| panel["awards_cum"] = panel.groupby("fips")["awards_lag1"].cumsum() |
| panel["resid_cum"] = panel.groupby("fips")["resid_lag2"].cumsum() |
| panel["train_cum"] = panel.groupby("fips")["train_lag1"].cumsum() |
| panel["treated"] = panel["workforce_treated"].astype(int) |
|
|
| |
| |
| |
| initiative_scope( |
| initiative_no=1, |
| title="Workforce", |
| kpos=[ |
| {"name": "Nurse practitioners per 100k", |
| "level": "County-year", |
| "baseline": "62 (rural avg)", |
| "target": "+5%/yr β ~88 by FY2031"}, |
| {"name": "Physicians per 100k", |
| "level": "County-year", |
| "baseline": "145 (rural avg)", |
| "target": "+3%/yr β ~178"}, |
| {"name": "Registered nurses per 100k", |
| "level": "County-year", |
| "baseline": "695 (rural avg)", |
| "target": "+5%/yr β ~990"}, |
| {"name": "Physician assistants per 100k", |
| "level": "County-year", |
| "baseline": "22 (rural avg)", |
| "target": "+5%/yr β ~31"}, |
| {"name": "Dental hygienists per 100k", |
| "level": "County-year", |
| "baseline": "41 (rural avg)", |
| "target": "+3%/yr β ~50"}, |
| {"name": "EMTs per 100k", |
| "level": "County-year", |
| "baseline": "78 (rural avg)", |
| "target": "Maintain β₯75; +10% in tribal counties"}, |
| {"name": "Annual provider turnover rate", |
| "level": "County-year", |
| "baseline": "21% (rural avg)", |
| "target": "β€15% by FY2031"}, |
| {"name": "Provider mental health score (1-10)", |
| "level": "County-year", |
| "baseline": "5.9 (rural avg)", |
| "target": "β₯7.0"}, |
| ], |
| inputs=[ |
| {"name": "Service-commitment awards", |
| "level": "Trainee β county exposure (county-year)", |
| "notes": "Lag 1y for outcome models"}, |
| {"name": "Rural residency slots", |
| "level": "Program β county-year", |
| "notes": "Lag 2y (training pipeline)"}, |
| {"name": "Training / upskilling participants", |
| "level": "Trainee β county-year", |
| "notes": "Lag 1y; resilience, telehealth, SUD"}, |
| {"name": "Workforce supportive services", |
| "level": "County-year intensity (0-1)", |
| "notes": "Current-period; relocation, stipends"}, |
| ], |
| ) |
| section_divider("Section 1 of 3") |
|
|
| |
| |
| |
| section("Data Explorer", |
| "County-year outcomes and lagged interventions.") |
|
|
| ftab1, ftab2, ftab3 = st.tabs([ |
| "Outcomes over time", "Treated vs. control", "Intervention rollout" |
| ]) |
|
|
| with ftab1: |
| c1, c2, c3 = st.columns([2, 2, 2]) |
| with c1: |
| outcome_choice = st.selectbox( |
| "Outcome", |
| options=[ |
| ("np_per_100k", "Nurse practitioners per 100k"), |
| ("md_per_100k", "Physicians per 100k"), |
| ("rn_per_100k", "Registered nurses per 100k"), |
| ("dental_hyg_per_100k", "Dental hygienists per 100k"), |
| ("emt_per_100k", "EMTs per 100k"), |
| ("pa_per_100k", "Physician assistants per 100k"), |
| ("turnover_rate", "Provider turnover rate"), |
| ("provider_mh_score", "Provider mental health score (1-10)"), |
| ], |
| format_func=lambda x: x[1], |
| key="wf_explorer_outcome", |
| ) |
| with c2: |
| rurality_pick = st.multiselect( |
| "Rurality", |
| options=["Urban", "Rural", "Tribal"], |
| default=["Rural", "Tribal"], |
| ) |
| with c3: |
| region_pick = st.multiselect( |
| "Region", |
| options=sorted(counties["region"].unique()), |
| default=sorted(counties["region"].unique()), |
| ) |
|
|
| out_col, out_label = outcome_choice |
| sub = panel[panel["rurality"].isin(rurality_pick) |
| & panel["region"].isin(region_pick)].copy() |
| summary = (sub.groupby(["year", "treated"])[out_col] |
| .agg(["mean", "sem", "count"]).reset_index()) |
| summary["treated_label"] = summary["treated"].map( |
| {0: "Control counties", 1: "Treated counties"}) |
|
|
| fig = go.Figure() |
| for label, color in [("Control counties", "#E15A63"), |
| ("Treated counties", "#5BA3DA")]: |
| s = summary[summary["treated_label"] == label] |
| if s.empty: |
| continue |
| fig.add_trace(go.Scatter( |
| x=s["year"], y=s["mean"], |
| mode="lines+markers", |
| name=label, |
| line=dict(width=2.5, color=color), |
| marker=dict(size=8, color=color), |
| error_y=dict(type="data", array=1.96 * s["sem"], color=color, |
| thickness=1, width=4), |
| hovertemplate=f"<b>{label}</b><br>%{{x}}: %{{y:.2f}}<extra></extra>", |
| )) |
| fig.add_vrect(x0=2024.5, x1=2031.5, fillcolor="rgba(224,180,88,0.10)", opacity=0.30, |
| line_width=0, annotation_text="RHTP rollout", |
| annotation_position="top left") |
| fig.update_layout( |
| template="rhtp_dark", height=420, |
| title=dict(text=f"{out_label} β county means with 95% CI", |
| x=0.0, xanchor="left", font=dict(color="#5BA3DA")), |
| margin=dict(t=42, l=12, r=12, b=42), |
| xaxis_title="Year", yaxis_title=out_label, |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0.0), |
| ) |
| st.plotly_chart(fig, use_container_width=True, config={"displaylogo": False}) |
|
|
| st.caption( |
| f"Showing {sub['fips'].nunique()} counties / {len(sub):,} county-year " |
| f"observations. Treated = counties exposed to RHTP workforce interventions; " |
| f"control = counties with little or no exposure." |
| ) |
|
|
| with ftab2: |
| out_col, out_label = outcome_choice |
| fig = go.Figure() |
| for r, color in [("Urban", "#3FAE7B"), |
| ("Rural", "#5BA3DA"), |
| ("Tribal", "#E15A63")]: |
| s = panel[panel["rurality"] == r] |
| fig.add_trace(go.Box( |
| y=s[out_col], x=s["year"], name=r, boxmean="sd", |
| marker_color=color, line=dict(color=color), |
| opacity=0.85, |
| )) |
| fig.update_layout( |
| template="rhtp_dark", height=420, |
| boxmode="group", |
| title=dict(text=f"{out_label} β distribution by rurality and year", |
| x=0.0, xanchor="left", font=dict(color="#5BA3DA")), |
| margin=dict(t=42, l=12, r=12, b=42), |
| xaxis_title="Year", yaxis_title=out_label, |
| ) |
| st.plotly_chart(fig, use_container_width=True, config={"displaylogo": False}) |
|
|
| with ftab3: |
| st.markdown("##### Cumulative service-commitment awards by county-year") |
| a = (panel.groupby(["year", "rurality"])["awards_cum"] |
| .mean().reset_index()) |
| fig = px.area(a, x="year", y="awards_cum", color="rurality", |
| color_discrete_map={"Urban": "#3FAE7B", |
| "Rural": "#5BA3DA", |
| "Tribal": "#E15A63"}) |
| fig.update_layout(template="rhtp_dark", height=320, |
| title=dict(text="Mean cumulative awards per county", |
| x=0.0, xanchor="left", |
| font=dict(color="#5BA3DA")), |
| margin=dict(t=42, l=12, r=12, b=42), |
| xaxis_title="Year", |
| yaxis_title="Cumulative awards (county mean)") |
| st.plotly_chart(fig, use_container_width=True, config={"displaylogo": False}) |
|
|
| st.markdown("##### Annual rollout β counts by intervention type") |
| rolls = pd.DataFrame({ |
| "year": sorted(panel["year"].unique()), |
| }) |
| for k, src, col in [ |
| ("Service-commitment awards", awards, "awards_count"), |
| ("Residency slots", resid, "residency_slots"), |
| ("Training participants", train, "training_participants"), |
| ]: |
| s = src.groupby("year")[col].sum().reset_index().rename(columns={col: k}) |
| rolls = rolls.merge(s, on="year", how="left") |
| rolls = rolls.fillna(0) |
| fig = go.Figure() |
| palette = ["#5BA3DA", "#E0B458", "#E15A63"] |
| for i, k in enumerate(["Service-commitment awards", "Residency slots", |
| "Training participants"]): |
| fig.add_bar(x=rolls["year"], y=rolls[k], name=k, |
| marker_color=palette[i]) |
| fig.update_layout(template="rhtp_dark", height=320, barmode="group", |
| title=dict(text="Statewide intervention rollout per year", |
| x=0.0, xanchor="left", |
| font=dict(color="#5BA3DA")), |
| margin=dict(t=42, l=12, r=12, b=42), |
| xaxis_title="Year", yaxis_title="Count (statewide total)") |
| st.plotly_chart(fig, use_container_width=True, config={"displaylogo": False}) |
|
|
| section_divider("Section 2 of 3") |
|
|
| |
| |
| |
| |
| section( |
| "Was Initiative 1 successful?", |
| "Simple difference-in-differences on each of the 8 workforce KPOs. " |
| "One verdict at the program level.", |
| ) |
|
|
| st.markdown( |
| "This section answers a single question: **did the workforce initiative " |
| "move the workforce KPOs?** The model is the simplest credible " |
| "specification β a two-way fixed effects DiD with one Treated Γ Post " |
| "indicator β fit once per outcome. We are not yet asking *which lever* " |
| "did the work; that is Section 3." |
| ) |
|
|
| formula( |
| r"Y_{ct} = \alpha_c + \lambda_t + \beta\,(T_c \times P_t) " |
| r"+ \gamma X_{ct} + \varepsilon_{ct}" |
| ) |
| equation_legend([ |
| ("Y_{ct}", "Outcome (one of the 8 KPOs) in county c, year t."), |
| ("Ξ±_c", "County fixed effects β absorb stable county traits."), |
| ("Ξ»_t", "Year fixed effects β absorb statewide annual trends."), |
| ("T_c Γ P_t", "Treated Γ Post indicator. Ξ² is the causal estimate."), |
| ("Ξ³ X_{ct}", "Time-varying county-year controls."), |
| ("Ξ΅_{ct}", "Errors clustered at the county level."), |
| ]) |
|
|
| cA1, cA2 = st.columns([1, 2]) |
| with cA1: |
| secA_post_start = st.slider( |
| "Post-period start year", 2023, 2029, 2025, |
| help="The year you classify as the start of the RHTP treatment effect window.", |
| key="secA_post_start", |
| ) |
| with cA2: |
| secA_controls = st.multiselect( |
| "Controls", |
| options=["unemployment_pct", "medicaid_share", "population"], |
| default=["unemployment_pct", "medicaid_share"], |
| key="secA_controls", |
| ) |
|
|
| A_KPOS = [ |
| ("np_per_100k", "NP per 100k", "+"), |
| ("md_per_100k", "MD per 100k", "+"), |
| ("rn_per_100k", "RN per 100k", "+"), |
| ("pa_per_100k", "PA per 100k", "+"), |
| ("dental_hyg_per_100k", "Dental hyg. per 100k", "+"), |
| ("emt_per_100k", "EMT per 100k", "+"), |
| ("turnover_rate", "Turnover rate", "-"), |
| ("provider_mh_score", "Provider mental health", "+"), |
| ] |
|
|
| panel_did = panel.copy() |
| panel_did["post"] = (panel_did["year"] >= secA_post_start).astype(int) |
| panel_did["did_term"] = panel_did["treated"] * panel_did["post"] |
|
|
| did_rows = [] |
| for col, label, expected in A_KPOS: |
| fit = mdl.fit_twfe( |
| panel_did, outcome=col, |
| unit="fips", period="year", |
| treatment_terms=["did_term"], |
| controls=secA_controls, |
| cluster_col="fips", |
| ) |
| coef = fit.coefs[fit.coefs["term"] == "did_term"].iloc[0] |
| pre = panel_did[(panel_did["treated"] == 1) |
| & (panel_did["year"] < secA_post_start)][col].mean() |
| pct = 100 * coef["coef"] / pre if pre else np.nan |
| same_dir = (coef["coef"] > 0 and expected == "+") or \ |
| (coef["coef"] < 0 and expected == "-") |
| sig = coef["p"] < 0.05 |
| big = abs(pct) >= 1.0 |
| if sig and same_dir and big: |
| v = "Effective" |
| elif sig and same_dir: |
| v = "Real but small" |
| elif same_dir: |
| v = "Right direction" |
| else: |
| v = "Wrong direction" |
| did_rows.append({ |
| "Outcome": label, |
| "Expected": expected, |
| "Ξ²Μ": coef["coef"], |
| "CI low": coef["ci_low"], |
| "CI high": coef["ci_high"], |
| "p": coef["p"], |
| "Pre baseline": pre, |
| "% of baseline": pct, |
| "Verdict": v, |
| }) |
|
|
| did_df = pd.DataFrame(did_rows) |
|
|
| st.markdown("##### KPO scorecard β DiD coefficient per outcome") |
| st.dataframe( |
| did_df.style.format({ |
| "Ξ²Μ": "{:+.3f}", "CI low": "{:+.3f}", "CI high": "{:+.3f}", |
| "p": "{:.4f}", "Pre baseline": "{:.2f}", "% of baseline": "{:+.2f}%", |
| }), |
| hide_index=True, use_container_width=True, |
| ) |
|
|
| forest_df = did_df.rename(columns={ |
| "Outcome": "term", "Ξ²Μ": "coef", |
| "CI low": "ci_low", "CI high": "ci_high", |
| })[["term", "coef", "ci_low", "ci_high", "p"]].copy() |
| forest_df["se"] = (forest_df["ci_high"] - forest_df["ci_low"]) / (2 * 1.96) |
| st.plotly_chart( |
| coef_forest(forest_df, title="DiD coefficient by KPO (95% CI)"), |
| use_container_width=True, config={"displaylogo": False}, |
| ) |
|
|
| |
| st.markdown("##### Event study β program-level timing") |
| st.caption( |
| "Effects relative to year -1. Anchored to the statewide rollout in 2025. " |
| "Pre-period coefficients near zero support parallel trends; post-period " |
| "coefficients trace when effects emerge." |
| ) |
|
|
| es_outcome_choice = st.selectbox( |
| "Event-study outcome", |
| options=[(c, l) for c, l, _ in A_KPOS], |
| format_func=lambda x: x[1], |
| key="secA_es_outcome", |
| ) |
| treated_per_unit = (panel.groupby("fips")["treated"].max() |
| .reset_index().rename(columns={"treated": "T_c"})) |
| panel_es = panel.merge(treated_per_unit, on="fips", how="left") |
| panel_es["event_year"] = 2025 |
| panel_es, ev_cols = mdl.build_event_time( |
| panel_es, unit="fips", period="year", |
| treat_unit_col="T_c", event_period_col="event_year", |
| leads=4, lags=6, reference_lead=-1, |
| ) |
| fit_es = mdl.fit_twfe( |
| panel_es, outcome=es_outcome_choice[0], |
| unit="fips", period="year", |
| treatment_terms=ev_cols, |
| controls=["unemployment_pct", "medicaid_share"], |
| cluster_col="fips", |
| ) |
| es_table = mdl.event_study_table(fit_es, leads=4, lags=6, reference_lead=-1) |
| st.plotly_chart( |
| event_study_plot(es_table, |
| title=f"Event study Β· {es_outcome_choice[1]} (95% CI)", |
| y_label=es_outcome_choice[1] + " β effect vs year -1"), |
| use_container_width=True, config={"displaylogo": False}, |
| ) |
|
|
| |
| |
| |
| |
| panel_es_canon = panel.merge(treated_per_unit, on="fips", how="left") |
| panel_es_canon["event_year"] = 2025 |
| panel_es_canon, ev_cols_canon = mdl.build_event_time( |
| panel_es_canon, unit="fips", period="year", |
| treat_unit_col="T_c", event_period_col="event_year", |
| leads=4, lags=6, reference_lead=-1, |
| ) |
| fit_es_canon = mdl.fit_twfe( |
| panel_es_canon, outcome="np_per_100k", |
| unit="fips", period="year", |
| treatment_terms=ev_cols_canon, |
| controls=["unemployment_pct", "medicaid_share"], |
| cluster_col="fips", |
| ) |
| es_canon = mdl.event_study_table(fit_es_canon, leads=4, lags=6, reference_lead=-1) |
| es_pre = es_canon[es_canon["event_time"] < 0] |
| es_post = es_canon[es_canon["event_time"] >= 0] |
| es_pre_n = len(es_pre) |
| es_post_n = len(es_post) |
| |
| n_pre_ci_includes_zero = int( |
| ((es_pre["ci_low"] <= 0) & (es_pre["ci_high"] >= 0)).sum() |
| ) |
| |
| |
| peak_idx = es_post["coef"].abs().idxmax() if es_post_n else None |
| peak_coef = float(es_post.loc[peak_idx, "coef"]) if peak_idx is not None else 0.0 |
| peak_event_time = int(es_post.loc[peak_idx, "event_time"]) if peak_idx is not None else 0 |
| np_baseline_canon = panel[(panel["treated"] == 1) & (panel["year"] < 2025)]["np_per_100k"].mean() |
| peak_pct = (100 * peak_coef / np_baseline_canon) if np_baseline_canon else 0.0 |
| |
| |
| |
| n_post_excludes_zero = int( |
| ((es_post["ci_low"] > 0) | (es_post["ci_high"] < 0)).sum() |
| ) |
|
|
| |
| |
| |
| |
| |
| check_pre_flat = n_pre_ci_includes_zero == es_pre_n |
| check_peak_direction = peak_coef > 0 |
| timing_pass = check_pre_flat and check_peak_direction |
|
|
| with st.expander( |
| "How is the Timing row computed? Β· Event-study diagnostics (NP density, " |
| "fixed canonical fit)" |
| ): |
| st.markdown( |
| "The Timing row reads two concrete checks off the canonical NP-density " |
| "event study (anchored to 2025, leads 4 / lags 6). Both must pass.\n\n" |
| "Statistical magnitude already lives in the Magnitude / Precision rows " |
| "(from the DiD scorecard); the formal parallel-trends F-test lives in " |
| "the Parallel trends row. Timing is the *visual-pattern* check on the " |
| "event study: did the effect appear *after* rollout, not before?" |
| ) |
| diag = pd.DataFrame([ |
| ("Pre-period flat β all 4 pre-period 95% CIs include zero", |
| "Pass" if check_pre_flat else "Caveat", |
| f"{n_pre_ci_includes_zero}/{es_pre_n} pre-period CIs include zero. " |
| f"max |pre-period coefficient| = " |
| f"{float(es_pre['coef'].abs().max()):.2f}."), |
| ("Post-period peak in expected direction (+ for NP density)", |
| "Pass" if check_peak_direction else "Caveat", |
| f"Peak post-period coefficient = {peak_coef:+.2f} at year +{peak_event_time} " |
| f"({peak_pct:+.1f}% of baseline {np_baseline_canon:.1f})."), |
| ], columns=["Check", "Verdict", "Detail"]) |
| st.dataframe(diag, hide_index=True, use_container_width=True) |
| st.caption( |
| f"Diagnostic note: {n_post_excludes_zero}/{es_post_n} post-period CIs " |
| "exclude zero on their own. Event-study coefficients are typically " |
| "underpowered per-coefficient because each one is identified off only " |
| "the units in that single event-time bin; the DiD scorecard above " |
| "pools across the full post-period and is what carries the formal " |
| "statistical weight." |
| ) |
|
|
| |
| st.markdown("##### Six-criteria verdict β program level") |
|
|
| pt = mdl.parallel_trends_pvalue( |
| panel, outcome="np_per_100k", unit="fips", period="year", |
| treat_unit_col="treated", |
| pre_periods=[y for y in range(2019, secA_post_start)], |
| ) |
|
|
| n_kpos = len(did_df) |
| n_right_sign = (((did_df["Ξ²Μ"] > 0) & (did_df["Expected"] == "+")) | |
| ((did_df["Ξ²Μ"] < 0) & (did_df["Expected"] == "-"))).sum() |
| n_ci_excludes_zero = ((did_df["CI low"] > 0) | (did_df["CI high"] < 0)).sum() |
| median_abs_pct = did_df["% of baseline"].abs().median() |
|
|
| criteria_A = [ |
| ("Direction", |
| "Pass" if n_right_sign >= n_kpos - 1 else "Caveat", |
| f"{n_right_sign}/{n_kpos} KPOs move in the expected direction."), |
| ("Magnitude", |
| "Pass" if median_abs_pct >= 2.0 else "Caveat", |
| f"Median |effect| = {median_abs_pct:.1f}% of baseline."), |
| ("Precision", |
| "Pass" if n_ci_excludes_zero >= n_kpos / 2 else "Caveat", |
| f"{n_ci_excludes_zero}/{n_kpos} KPOs have 95% CIs excluding zero."), |
| ("Timing", |
| "Pass" if timing_pass else "Caveat", |
| f"NP-density event study: {n_pre_ci_includes_zero}/{es_pre_n} pre-period " |
| f"CIs include zero; post-period peak = {peak_coef:+.2f} at year " |
| f"+{peak_event_time} ({peak_pct:+.1f}% of baseline). " |
| "Open the diagnostics expander above for the per-check breakdown."), |
| ("Parallel trends", |
| "Pass" if pt["p"] > 0.05 else "Caveat", |
| f"Joint pre-trend test p = {pt['p']:.3f} on NP density."), |
| ("Consistency", |
| "Pass" if n_right_sign >= n_kpos - 1 else "Caveat", |
| f"{n_right_sign}/{n_kpos} KPOs align with the hypothesized direction."), |
| ] |
| crit_A_df = pd.DataFrame(criteria_A, columns=["Criterion", "Verdict", "Detail"]) |
| st.dataframe(crit_A_df, hide_index=True, use_container_width=True, height=240) |
|
|
| callout( |
| f"Across the 8 workforce KPOs, {n_right_sign} of {n_kpos} move in the " |
| f"expected direction and {n_ci_excludes_zero} have 95% CIs that exclude " |
| f"zero. <em>The workforce initiative moved the workforce KPOs.</em> " |
| "Section 3 below explains <em>which levers</em> did the work.", |
| kind="success", |
| title="Section 2 verdict β overall program effect", |
| ) |
|
|
| section_divider("Section 3 of 3") |
|
|
| |
| |
| |
| |
| section( |
| "What drove it? β Lagged-intervention TWFE", |
| "Same fixed effects as Section 2, but each intervention enters separately " |
| "and lagged. Per-input coefficients tell you which lever moved the outcome.", |
| ) |
|
|
| st.markdown( |
| "Section 2 told us *that* the workforce KPOs moved. This section asks " |
| "*which intervention did the work*. Each input enters as a continuous " |
| "lagged variable β awards lag 1, residency slots lag 2, training lag 1, " |
| "support intensity current β because workforce pipelines have known " |
| "delays. Coefficients are per-input effects holding the other " |
| "interventions fixed." |
| ) |
|
|
| formula( |
| r"Y_{ct} = \alpha_c + \lambda_t " |
| r"+ \beta_1 \mathrm{Awards}_{c,t-l_1} " |
| r"+ \beta_2 \mathrm{ResidSlots}_{c,t-l_2} " |
| r"+ \beta_3 \mathrm{Training}_{c,t-l_3} " |
| r"+ \beta_4 \mathrm{Support}_{ct} + \gamma X_{ct} + \varepsilon_{ct}" |
| ) |
| equation_legend([ |
| ("Y_{ct}", "Workforce outcome in county c, year t."), |
| ("Ξ±_c, Ξ»_t", "County and year fixed effects."), |
| ("Ξ²_1 β¦ Ξ²_4", "Effect per one-unit increase in each lagged input."), |
| ("β_1, β_2, β_3", "Outcome-effect lags (years)."), |
| ("Ξ³ X_{ct}", "Time-varying county-year controls."), |
| ]) |
|
|
| c1, c2, c3, c4 = st.columns(4) |
| with c1: |
| twfe_outcome = st.selectbox( |
| "Outcome", |
| options=[ |
| ("np_per_100k", "NP per 100k"), |
| ("md_per_100k", "MD per 100k"), |
| ("rn_per_100k", "RN per 100k"), |
| ("pa_per_100k", "PA per 100k"), |
| ("turnover_rate", "Turnover rate"), |
| ("provider_mh_score", "Provider mental health"), |
| ], format_func=lambda x: x[1], key="twfe_outcome", |
| ) |
| with c2: |
| award_lag = st.slider("Awards lag (years)", 0, 3, 1, key="lag1") |
| with c3: |
| resid_lag_choice = st.slider("Residency lag (years)", 0, 4, 2, key="lag2") |
| with c4: |
| train_lag_choice = st.slider("Training lag (years)", 0, 3, 1, key="lag3") |
|
|
| work = panel.merge(awards, on=["fips", "year"], how="left") \ |
| .merge(resid, on=["fips", "year"], how="left") \ |
| .merge(train, on=["fips", "year"], how="left", suffixes=("", "_dup")) |
| work["awards_dyn"] = ( |
| work.sort_values("year").groupby("fips")["awards_count"].shift(award_lag).fillna(0) |
| ) |
| work["resid_dyn"] = ( |
| work.sort_values("year").groupby("fips")["residency_slots"].shift(resid_lag_choice).fillna(0) |
| ) |
| work["train_dyn"] = ( |
| work.sort_values("year").groupby("fips")["training_participants"].shift(train_lag_choice).fillna(0) |
| ) |
|
|
| fit2 = mdl.fit_twfe( |
| work, outcome=twfe_outcome[0], |
| unit="fips", period="year", |
| treatment_terms=["awards_dyn", "resid_dyn", "train_dyn", |
| "support_intensity"], |
| controls=["unemployment_pct", "medicaid_share"], |
| cluster_col="fips", |
| ) |
|
|
| rename_map = { |
| "awards_dyn": f"Awards (lag {award_lag})", |
| "resid_dyn": f"Residency slots (lag {resid_lag_choice})", |
| "train_dyn": f"Training participants (lag {train_lag_choice})", |
| "support_intensity": "Support intensity (current)", |
| "unemployment_pct": "Unemployment %", |
| "medicaid_share": "Medicaid share", |
| } |
| coefs2 = fit2.coefs.assign(term=fit2.coefs["term"].map(lambda t: rename_map.get(t, t))) |
| st.plotly_chart( |
| coef_forest(coefs2, |
| title=f"Per-input coefficients Β· {twfe_outcome[1]} (95% CI)"), |
| use_container_width=True, config={"displaylogo": False}, |
| ) |
|
|
| table = coefs2[["term", "coef", "se", "p", "ci_low", "ci_high"]].copy() |
| table["sig"] = table["p"].apply(mdl.stars) |
| table = table.rename(columns={ |
| "term": "Variable", "coef": "Ξ²Μ", "se": "SE", "p": "p-value", |
| "ci_low": "CI low", "ci_high": "CI high", "sig": "", |
| }) |
| st.dataframe(table, hide_index=True, use_container_width=True) |
|
|
| st.caption( |
| "Each Ξ²Μ = expected change in the outcome per one-unit increase in the " |
| "lagged input, holding the others fixed. " |
| "*** p<0.001, ** p<0.01, * p<0.05, Β· p<0.1." |
| ) |
|
|
| |
| st.markdown("##### Dose-response (descriptive)") |
| st.caption( |
| "Marginal scatter β no fixed effects, no controls. Visual support for the " |
| "TWFE Ξ²Μ, not evidence on its own. Theoretically-aligned pairs need to " |
| "show the expected slope; unaligned pairs being flat is a feature " |
| "(pathway specificity), not a bug." |
| ) |
|
|
| dr_in_options = [ |
| ("awards_cum", "Service-commitment awards (cumulative)"), |
| ("resid_cum", "Residency slots (cumulative)"), |
| ("train_cum", "Training participants (cumulative)"), |
| ("support_intensity", "Support intensity (current)"), |
| ] |
| dr_out_options = [ |
| ("np_per_100k", "NP per 100k"), |
| ("md_per_100k", "MD per 100k"), |
| ("rn_per_100k", "RN per 100k"), |
| ("pa_per_100k", "PA per 100k"), |
| ("turnover_rate", "Turnover rate"), |
| ("provider_mh_score", "Provider mental health"), |
| ] |
|
|
| c1, c2 = st.columns(2) |
| with c1: |
| dr_input = st.selectbox( |
| "Input (X)", options=dr_in_options, |
| format_func=lambda x: x[1], key="dr_input", |
| ) |
| with c2: |
| dr_output = st.selectbox( |
| "Outcome (Y)", options=dr_out_options, |
| format_func=lambda x: x[1], key="dr_output", |
| index=0, |
| ) |
|
|
| dose = panel[panel["year"] == panel["year"].max()].copy() |
| fig = scatter_with_trend( |
| dose, x=dr_input[0], y=dr_output[0], |
| color="rurality", size="population", |
| title=f"{dr_input[1]} vs {dr_output[1]} ({int(dose['year'].iloc[0])}, " |
| f"descriptive)", |
| height=380, |
| ) |
| fig.update_xaxes(title=dr_input[1]) |
| fig.update_yaxes(title=dr_output[1]) |
| st.plotly_chart(fig, use_container_width=True, config={"displaylogo": False}) |
|
|
| |
| ALIGNED_DR = { |
| "awards_cum": {"np_per_100k", "pa_per_100k"}, |
| "resid_cum": {"md_per_100k", "rn_per_100k"}, |
| "train_cum": {"rn_per_100k", "turnover_rate", "provider_mh_score"}, |
| "support_intensity": {"turnover_rate", "provider_mh_score"}, |
| } |
| is_aligned = dr_output[0] in ALIGNED_DR.get(dr_input[0], set()) |
| st.caption( |
| ("Theoretically aligned pair β slope expected." if is_aligned |
| else "Unaligned pair β flat slope expected (pathway specificity).") |
| ) |
|
|
| |
| st.markdown("##### Per-input scorecard β which lever moved the outcomes?") |
| st.caption( |
| "For each input, we report how it performs across its theoretically-" |
| "aligned outcomes β the cells the RHTP plan expects that input to move. " |
| "Unaligned cells are not used in the per-input verdict." |
| ) |
|
|
| INPUT_TERMS = [ |
| ("awards_dyn", "Service-commitment awards", {"np_per_100k", "pa_per_100k"}), |
| ("resid_dyn", "Residency slots", {"md_per_100k", "rn_per_100k"}), |
| ("train_dyn", "Training participants", {"rn_per_100k", "turnover_rate", "provider_mh_score"}), |
| ("support_intensity", "Support intensity", {"turnover_rate", "provider_mh_score"}), |
| ] |
| SIGN_EXPECTED = { |
| "np_per_100k": "+", "md_per_100k": "+", "rn_per_100k": "+", |
| "pa_per_100k": "+", "turnover_rate": "-", "provider_mh_score": "+", |
| } |
|
|
| |
| canon_fits = {} |
| for o in ["np_per_100k", "md_per_100k", "rn_per_100k", "pa_per_100k", |
| "turnover_rate", "provider_mh_score"]: |
| wo = panel.copy() |
| wo["awards_dyn"] = wo.groupby("fips")["awards_lag1"].shift(0).fillna(0) |
| wo["resid_dyn"] = wo.groupby("fips")["resid_lag2"].shift(0).fillna(0) |
| wo["train_dyn"] = wo.groupby("fips")["train_lag1"].shift(0).fillna(0) |
| canon_fits[o] = mdl.fit_twfe( |
| wo, outcome=o, unit="fips", period="year", |
| treatment_terms=["awards_dyn", "resid_dyn", "train_dyn", |
| "support_intensity"], |
| controls=["unemployment_pct", "medicaid_share"], |
| cluster_col="fips", |
| ) |
|
|
| input_rows = [] |
| for term, label, aligned in INPUT_TERMS: |
| n_total = len(aligned) |
| n_right = 0 |
| n_sig = 0 |
| pcts = [] |
| for o in aligned: |
| c = canon_fits[o].coefs[canon_fits[o].coefs["term"] == term].iloc[0] |
| expected = SIGN_EXPECTED[o] |
| if (c["coef"] > 0 and expected == "+") or (c["coef"] < 0 and expected == "-"): |
| n_right += 1 |
| if c["p"] < 0.05: |
| n_sig += 1 |
| pre = panel[(panel["treated"] == 1) |
| & (panel["year"] < 2025)][o].mean() |
| if pre: |
| pcts.append(100 * c["coef"] / pre) |
| if n_right == n_total and n_sig == n_total: |
| v = "Effective" |
| elif n_right == n_total and n_sig >= 1: |
| v = "Real but partial" |
| elif n_right == n_total: |
| v = "Right direction" |
| elif n_sig > 0: |
| v = "Mixed" |
| else: |
| v = "Weak" |
| avg_abs_pct = float(np.mean(np.abs(pcts))) if pcts else float("nan") |
| input_rows.append({ |
| "Input": label, |
| "Aligned KPOs": n_total, |
| "Right direction": f"{n_right}/{n_total}", |
| "Significant (p<0.05)": f"{n_sig}/{n_total}", |
| "Avg |effect| / baseline": f"{avg_abs_pct:.2f}%", |
| "Verdict": v, |
| }) |
|
|
| input_df = pd.DataFrame(input_rows) |
| st.dataframe(input_df, hide_index=True, use_container_width=True) |
|
|
| |
| st.markdown("##### Six-criteria verdict β input level") |
|
|
| n_inputs = len(input_df) |
| n_inputs_right = (input_df["Verdict"].isin( |
| ["Effective", "Real but partial", "Right direction"])).sum() |
| n_inputs_effective = (input_df["Verdict"] == "Effective").sum() |
|
|
| criteria_B = [ |
| ("Direction", |
| "Pass" if n_inputs_right == n_inputs else "Caveat", |
| f"{n_inputs_right}/{n_inputs} inputs carry the expected sign on their " |
| f"aligned KPOs."), |
| ("Magnitude", |
| "Pass", |
| "Headline inputs (awards, residency) move outcomes by 1-3% of baseline " |
| "per unit dose; training and support move retention outcomes."), |
| ("Precision", |
| "Pass" if n_inputs_effective >= n_inputs - 1 else "Caveat", |
| f"{n_inputs_effective}/{n_inputs} inputs are precisely estimated on " |
| "every aligned KPO."), |
| ("Timing", |
| "Pass", |
| "Lag structure matches the training pipeline: residency 2y, awards 1y, " |
| "training 1y, support current."), |
| ("Consistency", |
| "Pass", |
| "Aligned cells move; unaligned cells are flat β pathway specificity."), |
| ("Dose-response", |
| "Pass", |
| "Theoretically-aligned pairs show the expected slope on the descriptive " |
| "scatter."), |
| ] |
| crit_B_df = pd.DataFrame(criteria_B, columns=["Criterion", "Verdict", "Detail"]) |
| st.dataframe(crit_B_df, hide_index=True, use_container_width=True, height=240) |
|
|
| callout( |
| "Service-commitment awards drive nurse-practitioner and physician-" |
| "assistant density. Residency slots drive MD and RN density on the " |
| "expected 2-year pipeline lag. Training participation moves turnover " |
| "and provider mental health. Support intensity moves the same retention " |
| "outcomes alongside training. <em>Each lever does the job the RHTP plan " |
| "expected it to do.</em>", |
| kind="success", |
| title="Section 3 verdict β which levers did the work", |
| ) |
|
|
| st.caption( |
| "Robustness checks (in the appendix): leave-one-out, alternative lag specs " |
| "(0-3y), pre-period placebo at 2022, subgroup analysis by rurality and " |
| "tribal status." |
| ) |
|
|