From 577d15aa9d5c4453862127038e1f09cdc8c42585 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 23:25:00 -0400 Subject: [PATCH 1/3] Add congressional district outcome percentages --- .../outputs/congressional_district_impact.py | 33 ++++++++- tests/test_congressional_district_impact.py | 67 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/policyengine/outputs/congressional_district_impact.py b/src/policyengine/outputs/congressional_district_impact.py index 1f7f524f..205ffc75 100644 --- a/src/policyengine/outputs/congressional_district_impact.py +++ b/src/policyengine/outputs/congressional_district_impact.py @@ -16,7 +16,8 @@ class CongressionalDistrictImpact(Output): Groups households by congressional_district_geoid (integer SSDD format where SS = state FIPS, DD = district number) and computes weighted - average and relative household income changes per district. + average and relative household income changes per district, plus the + district-level shares of winners, losers, and unchanged households. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -36,6 +37,11 @@ def run(self) -> None: baseline_income = baseline_hh["household_net_income"].values reform_income = reform_hh["household_net_income"].values weights = baseline_hh["household_weight"].values + household_count_people = ( + baseline_hh["household_count_people"].values + if "household_count_people" in baseline_hh.columns + else np.ones_like(weights) + ) # Only include valid geoids (positive integers) unique_geoids = np.unique(geoids[geoids > 0]) @@ -50,6 +56,7 @@ def run(self) -> None: b_inc = baseline_income[mask] r_inc = reform_income[mask] + people_weights = household_count_people[mask] * w weighted_baseline = float((b_inc * w).sum()) weighted_reform = float((r_inc * w).sum()) @@ -60,6 +67,27 @@ def run(self) -> None: if weighted_baseline != 0 else 0.0 ) + capped_baseline = np.maximum(b_inc, 1.0) + income_change = (r_inc - b_inc) / capped_baseline + people_total = float(people_weights.sum()) + + if people_total == 0: + winner_percentage = 0.0 + loser_percentage = 0.0 + no_change_percentage = 1.0 + else: + winner_percentage = float( + people_weights[income_change > 1e-3].sum() / people_total + ) + loser_percentage = float( + people_weights[income_change <= -1e-3].sum() / people_total + ) + no_change_percentage = float( + people_weights[ + (income_change > -1e-3) & (income_change <= 1e-3) + ].sum() + / people_total + ) geoid_int = int(geoid) state_fips = geoid_int // 100 @@ -72,6 +100,9 @@ def run(self) -> None: "district_number": district_number, "average_household_income_change": float(avg_change), "relative_household_income_change": float(rel_change), + "winner_percentage": winner_percentage, + "loser_percentage": loser_percentage, + "no_change_percentage": no_change_percentage, "population": total_weight, } ) diff --git a/tests/test_congressional_district_impact.py b/tests/test_congressional_district_impact.py index 576e539c..021fd3c5 100644 --- a/tests/test_congressional_district_impact.py +++ b/tests/test_congressional_district_impact.py @@ -29,6 +29,7 @@ def test_basic_district_grouping(): "congressional_district_geoid": [101, 101, 202], "household_net_income": [50000.0, 60000.0, 40000.0], "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [2.0, 2.0, 2.0], } ) reform = _make_sim( @@ -36,6 +37,7 @@ def test_basic_district_grouping(): "congressional_district_geoid": [101, 101, 202], "household_net_income": [52000.0, 62000.0, 42000.0], "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [2.0, 2.0, 2.0], } ) @@ -52,12 +54,16 @@ def test_basic_district_grouping(): assert d101["district_number"] == 1 assert abs(d101["average_household_income_change"] - 2000.0) < 1e-6 assert d101["population"] == 2.0 + assert d101["winner_percentage"] == 1.0 + assert d101["loser_percentage"] == 0.0 + assert d101["no_change_percentage"] == 0.0 # District 202: baseline = 40000, reform = 42000, change = 2000 d202 = by_geoid[202] assert d202["state_fips"] == 2 assert d202["district_number"] == 2 assert abs(d202["average_household_income_change"] - 2000.0) < 1e-6 + assert d202["winner_percentage"] == 1.0 def test_negative_geoid_excluded(): @@ -67,6 +73,7 @@ def test_negative_geoid_excluded(): "congressional_district_geoid": [-1, 0, 101], "household_net_income": [50000.0, 50000.0, 50000.0], "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 1.0, 1.0], } ) reform = _make_sim( @@ -74,6 +81,7 @@ def test_negative_geoid_excluded(): "congressional_district_geoid": [-1, 0, 101], "household_net_income": [55000.0, 55000.0, 55000.0], "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 1.0, 1.0], } ) @@ -90,6 +98,7 @@ def test_zero_weight_district_skipped(): "congressional_district_geoid": [101, 202], "household_net_income": [50000.0, 50000.0], "household_weight": [1.0, 0.0], + "household_count_people": [1.0, 1.0], } ) reform = _make_sim( @@ -97,6 +106,7 @@ def test_zero_weight_district_skipped(): "congressional_district_geoid": [101, 202], "household_net_income": [55000.0, 55000.0], "household_weight": [1.0, 0.0], + "household_count_people": [1.0, 1.0], } ) @@ -113,6 +123,7 @@ def test_weighted_average_change(): "congressional_district_geoid": [101, 101], "household_net_income": [40000.0, 80000.0], "household_weight": [3.0, 1.0], + "household_count_people": [1.0, 1.0], } ) reform = _make_sim( @@ -120,6 +131,7 @@ def test_weighted_average_change(): "congressional_district_geoid": [101, 101], "household_net_income": [42000.0, 82000.0], "household_weight": [3.0, 1.0], + "household_count_people": [1.0, 1.0], } ) @@ -129,3 +141,58 @@ def test_weighted_average_change(): # Weighted avg change: (3*2000 + 1*2000) / 4 = 2000 assert abs(d["average_household_income_change"] - 2000.0) < 1e-6 assert d["population"] == 4.0 + assert d["winner_percentage"] == 1.0 + + +def test_district_winner_loser_percentages_are_people_weighted(): + """Winner and loser shares should use household_count_people * weight.""" + baseline = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1000.0, 1000.0, 1000.0], + "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 3.0, 2.0], + } + ) + reform = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1100.0, 900.0, 1000.0], + "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 3.0, 2.0], + } + ) + + impact = compute_us_congressional_district_impacts(baseline, reform) + + district = impact.district_results[0] + assert district["winner_percentage"] == 1 / 6 + assert district["loser_percentage"] == 3 / 6 + assert district["no_change_percentage"] == 2 / 6 + + +def test_district_small_changes_use_no_change_threshold(): + """Changes within +/-0.1% should be counted as no change.""" + baseline = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1000.0, 1000.0, 1000.0], + "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 1.0, 1.0], + } + ) + reform = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1001.0, 999.5, 990.0], + "household_weight": [1.0, 1.0, 1.0], + "household_count_people": [1.0, 1.0, 1.0], + } + ) + + impact = compute_us_congressional_district_impacts(baseline, reform) + + district = impact.district_results[0] + assert district["winner_percentage"] == 0.0 + assert district["loser_percentage"] == 1 / 3 + assert district["no_change_percentage"] == 2 / 3 From 3a61ea13cb58181d3a304f4ed768fa9827fe6994 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 23:26:38 -0400 Subject: [PATCH 2/3] Add changelog fragment --- changelog.d/codex-congressional-district-outcome-pct.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/codex-congressional-district-outcome-pct.added diff --git a/changelog.d/codex-congressional-district-outcome-pct.added b/changelog.d/codex-congressional-district-outcome-pct.added new file mode 100644 index 00000000..258441b1 --- /dev/null +++ b/changelog.d/codex-congressional-district-outcome-pct.added @@ -0,0 +1 @@ +Add winner, loser, and no-change percentages to the congressional district impact output. From 98ab5aaca35e0f12aabdd3962fec830cbd3a8343 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 23:42:50 -0400 Subject: [PATCH 3/3] Tighten district outcome coverage --- .../outputs/congressional_district_impact.py | 2 +- tests/test_congressional_district_impact.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/policyengine/outputs/congressional_district_impact.py b/src/policyengine/outputs/congressional_district_impact.py index 205ffc75..d8162a6d 100644 --- a/src/policyengine/outputs/congressional_district_impact.py +++ b/src/policyengine/outputs/congressional_district_impact.py @@ -17,7 +17,7 @@ class CongressionalDistrictImpact(Output): Groups households by congressional_district_geoid (integer SSDD format where SS = state FIPS, DD = district number) and computes weighted average and relative household income changes per district, plus the - district-level shares of winners, losers, and unchanged households. + district-level shares of people who are winners, losers, or unchanged. """ model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/test_congressional_district_impact.py b/tests/test_congressional_district_impact.py index 021fd3c5..46d425d2 100644 --- a/tests/test_congressional_district_impact.py +++ b/tests/test_congressional_district_impact.py @@ -196,3 +196,28 @@ def test_district_small_changes_use_no_change_threshold(): assert district["winner_percentage"] == 0.0 assert district["loser_percentage"] == 1 / 3 assert district["no_change_percentage"] == 2 / 3 + + +def test_district_outcome_percentages_fall_back_to_household_weights(): + """Missing household_count_people should default to household-weighted shares.""" + baseline = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1000.0, 1000.0, 1000.0], + "household_weight": [1.0, 2.0, 1.0], + } + ) + reform = _make_sim( + { + "congressional_district_geoid": [101, 101, 101], + "household_net_income": [1100.0, 900.0, 1000.0], + "household_weight": [1.0, 2.0, 1.0], + } + ) + + impact = compute_us_congressional_district_impacts(baseline, reform) + + district = impact.district_results[0] + assert district["winner_percentage"] == 1 / 4 + assert district["loser_percentage"] == 2 / 4 + assert district["no_change_percentage"] == 1 / 4