Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/codex-congressional-district-outcome-pct.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add winner, loser, and no-change percentages to the congressional district impact output.
33 changes: 32 additions & 1 deletion src/policyengine/outputs/congressional_district_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 people who are winners, losers, or unchanged.
"""

model_config = ConfigDict(arbitrary_types_allowed=True)
Expand All @@ -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])
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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,
}
)
Expand Down
92 changes: 92 additions & 0 deletions tests/test_congressional_district_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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(
{
"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],
}
)

Expand All @@ -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():
Expand All @@ -67,13 +73,15 @@ 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(
{
"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],
}
)

Expand All @@ -90,13 +98,15 @@ 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(
{
"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],
}
)

Expand All @@ -113,13 +123,15 @@ 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(
{
"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],
}
)

Expand All @@ -129,3 +141,83 @@ 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


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
Loading