Skip to content

Commit af51b6b

Browse files
anth-volkclaude
andcommitted
feat: Fix economic_impact_analysis with correct programs, budget summary, and poverty demographics
Fixes #258 - Add BudgetSummaryItem output and compute_budget_summary() for per-variable budget analysis - Add CountryConfig strategy pattern (US_CONFIG, UK_CONFIG) replacing country conditionals - Add compute_decile_impacts() accepting already-run simulations - Add compute_program_statistics() shared function for both US and UK - Add PolicyReformAnalysis unified result container - Fix US program names and entities to match API (employee_payroll_tax, ssi on spm_unit) - Add intra-decile impacts, poverty by demographics (age/gender/race) to analysis - Fix StopIteration crashes in Aggregate/ChangeAggregate with clear ValueError messages - Add model_rebuild() for ProgramStatistics/ProgrammeStatistics and BudgetSummaryItem - Update outputs __init__.py exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f01f12 commit af51b6b

12 files changed

Lines changed: 616 additions & 207 deletions

File tree

src/policyengine/outputs/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from policyengine.core import Output, OutputCollection
22
from policyengine.outputs.aggregate import Aggregate, AggregateType
3+
from policyengine.outputs.budget_summary import (
4+
BudgetSummaryItem,
5+
compute_budget_summary,
6+
)
37
from policyengine.outputs.change_aggregate import (
48
ChangeAggregate,
59
ChangeAggregateType,
@@ -12,9 +16,11 @@
1216
ConstituencyImpact,
1317
compute_uk_constituency_impacts,
1418
)
19+
from policyengine.outputs.country_config import UK_CONFIG, US_CONFIG, CountryConfig
1520
from policyengine.outputs.decile_impact import (
1621
DecileImpact,
1722
calculate_decile_impacts,
23+
compute_decile_impacts,
1824
)
1925
from policyengine.outputs.inequality import (
2026
UK_INEQUALITY_INCOME_VARIABLE,
@@ -31,6 +37,7 @@
3137
LocalAuthorityImpact,
3238
compute_uk_local_authority_impacts,
3339
)
40+
from policyengine.outputs.policy_reform_analysis import PolicyReformAnalysis
3441
from policyengine.outputs.poverty import (
3542
AGE_GROUPS,
3643
GENDER_GROUPS,
@@ -48,6 +55,7 @@
4855
calculate_us_poverty_by_race,
4956
calculate_us_poverty_rates,
5057
)
58+
from policyengine.outputs.program_statistics import compute_program_statistics
5159

5260
__all__ = [
5361
"Output",
@@ -86,4 +94,12 @@
8694
"compute_uk_constituency_impacts",
8795
"LocalAuthorityImpact",
8896
"compute_uk_local_authority_impacts",
97+
"BudgetSummaryItem",
98+
"compute_budget_summary",
99+
"compute_decile_impacts",
100+
"compute_program_statistics",
101+
"PolicyReformAnalysis",
102+
"CountryConfig",
103+
"US_CONFIG",
104+
"UK_CONFIG",
89105
]

src/policyengine/outputs/aggregate.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,15 @@ def run(self):
4747

4848
# Get variable object
4949
var_obj = next(
50-
v
51-
for v in self.simulation.tax_benefit_model_version.variables
52-
if v.name == self.variable
50+
(
51+
v
52+
for v in self.simulation.tax_benefit_model_version.variables
53+
if v.name == self.variable
54+
),
55+
None,
5356
)
57+
if var_obj is None:
58+
raise ValueError(f"Variable '{self.variable}' not found in model")
5459

5560
# Get the target entity data
5661
target_entity = self.entity or var_obj.entity
@@ -68,10 +73,17 @@ def run(self):
6873
# Apply filters
6974
if self.filter_variable is not None:
7075
filter_var_obj = next(
71-
v
72-
for v in self.simulation.tax_benefit_model_version.variables
73-
if v.name == self.filter_variable
76+
(
77+
v
78+
for v in self.simulation.tax_benefit_model_version.variables
79+
if v.name == self.filter_variable
80+
),
81+
None,
7482
)
83+
if filter_var_obj is None:
84+
raise ValueError(
85+
f"Filter variable '{self.filter_variable}' not found in model"
86+
)
7587

7688
if filter_var_obj.entity != target_entity:
7789
filter_mapped = self.simulation.output_dataset.data.map_to_entity(
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Budget summary output — totals for key budget variables under baseline and reform."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pandas as pd
8+
from pydantic import ConfigDict
9+
10+
from policyengine.core import Output, OutputCollection
11+
from policyengine.outputs.aggregate import Aggregate, AggregateType
12+
13+
if TYPE_CHECKING:
14+
from policyengine.core.simulation import Simulation
15+
16+
17+
class BudgetSummaryItem(Output):
18+
"""One row of the budget summary — totals for a single variable."""
19+
20+
model_config = ConfigDict(arbitrary_types_allowed=True)
21+
22+
baseline_simulation: Simulation
23+
reform_simulation: Simulation
24+
variable_name: str
25+
entity: str
26+
27+
# Results populated by run()
28+
baseline_total: float | None = None
29+
reform_total: float | None = None
30+
change: float | None = None
31+
32+
def run(self):
33+
baseline_agg = Aggregate(
34+
simulation=self.baseline_simulation,
35+
variable=self.variable_name,
36+
aggregate_type=AggregateType.SUM,
37+
entity=self.entity,
38+
)
39+
baseline_agg.run()
40+
41+
reform_agg = Aggregate(
42+
simulation=self.reform_simulation,
43+
variable=self.variable_name,
44+
aggregate_type=AggregateType.SUM,
45+
entity=self.entity,
46+
)
47+
reform_agg.run()
48+
49+
self.baseline_total = float(baseline_agg.result)
50+
self.reform_total = float(reform_agg.result)
51+
self.change = self.reform_total - self.baseline_total
52+
53+
54+
def compute_budget_summary(
55+
baseline_simulation: Simulation,
56+
reform_simulation: Simulation,
57+
variables: dict[str, str],
58+
) -> OutputCollection[BudgetSummaryItem]:
59+
"""Compute budget totals for each variable under baseline and reform.
60+
61+
Args:
62+
baseline_simulation: Already-run baseline simulation.
63+
reform_simulation: Already-run reform simulation.
64+
variables: Mapping of variable name to entity,
65+
e.g. ``{"household_tax": "household"}``.
66+
67+
Returns:
68+
OutputCollection of BudgetSummaryItem objects with a DataFrame.
69+
"""
70+
results: list[BudgetSummaryItem] = []
71+
for var_name, entity in variables.items():
72+
item = BudgetSummaryItem(
73+
baseline_simulation=baseline_simulation,
74+
reform_simulation=reform_simulation,
75+
variable_name=var_name,
76+
entity=entity,
77+
)
78+
item.run()
79+
results.append(item)
80+
81+
df = pd.DataFrame(
82+
[
83+
{
84+
"variable_name": r.variable_name,
85+
"entity": r.entity,
86+
"baseline_total": r.baseline_total,
87+
"reform_total": r.reform_total,
88+
"change": r.change,
89+
}
90+
for r in results
91+
]
92+
)
93+
94+
return OutputCollection(outputs=results, dataframe=df)

src/policyengine/outputs/change_aggregate.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,15 @@ def run(self):
5959

6060
# Get variable object
6161
var_obj = next(
62-
v
63-
for v in self.baseline_simulation.tax_benefit_model_version.variables
64-
if v.name == self.variable
62+
(
63+
v
64+
for v in self.baseline_simulation.tax_benefit_model_version.variables
65+
if v.name == self.variable
66+
),
67+
None,
6568
)
69+
if var_obj is None:
70+
raise ValueError(f"Variable '{self.variable}' not found in model")
6671

6772
# Get the target entity data
6873
target_entity = self.entity or var_obj.entity
@@ -123,10 +128,17 @@ def run(self):
123128
# Apply filter_variable filters
124129
if self.filter_variable is not None:
125130
filter_var_obj = next(
126-
v
127-
for v in self.baseline_simulation.tax_benefit_model_version.variables
128-
if v.name == self.filter_variable
131+
(
132+
v
133+
for v in self.baseline_simulation.tax_benefit_model_version.variables
134+
if v.name == self.filter_variable
135+
),
136+
None,
129137
)
138+
if filter_var_obj is None:
139+
raise ValueError(
140+
f"Filter variable '{self.filter_variable}' not found in model"
141+
)
130142

131143
if filter_var_obj.entity != target_entity:
132144
filter_mapped = (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Country configuration strategy — holds all country-specific parameters."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
7+
8+
@dataclass(frozen=True)
9+
class CountryConfig:
10+
"""All country-specific parameters needed by compute functions.
11+
12+
Individual compute functions read the fields they need from this
13+
config rather than accepting a ``country_id`` string and branching.
14+
"""
15+
16+
country_id: str
17+
income_variable: str
18+
programs: dict[str, dict] = field(default_factory=dict)
19+
budget_variables: dict[str, str] = field(default_factory=dict)
20+
poverty_variables: dict[str, str] = field(default_factory=dict)
21+
poverty_entity: str = "person"
22+
poverty_breakdowns: tuple[str, ...] = ()
23+
inequality_income_variable: str | None = None
24+
inequality_entity: str = "household"
25+
26+
27+
US_CONFIG = CountryConfig(
28+
country_id="us",
29+
income_variable="household_net_income",
30+
programs={
31+
"income_tax": {"entity": "tax_unit", "is_tax": True},
32+
"employee_payroll_tax": {"entity": "person", "is_tax": True},
33+
"snap": {"entity": "spm_unit", "is_tax": False},
34+
"tanf": {"entity": "spm_unit", "is_tax": False},
35+
"ssi": {"entity": "spm_unit", "is_tax": False},
36+
"social_security": {"entity": "person", "is_tax": False},
37+
},
38+
budget_variables={
39+
"household_tax": "household",
40+
"household_benefits": "household",
41+
"household_net_income": "household",
42+
"household_state_income_tax": "tax_unit",
43+
},
44+
poverty_variables={
45+
"spm": "spm_unit_is_in_spm_poverty",
46+
"spm_deep": "spm_unit_is_in_deep_spm_poverty",
47+
},
48+
poverty_entity="person",
49+
poverty_breakdowns=("age", "gender", "race"),
50+
inequality_income_variable="household_net_income",
51+
inequality_entity="household",
52+
)
53+
54+
UK_CONFIG = CountryConfig(
55+
country_id="uk",
56+
income_variable="equiv_hbai_household_net_income",
57+
programs={
58+
"income_tax": {"entity": "person", "is_tax": True},
59+
"national_insurance": {"entity": "person", "is_tax": True},
60+
"vat": {"entity": "household", "is_tax": True},
61+
"council_tax": {"entity": "household", "is_tax": True},
62+
"universal_credit": {"entity": "person", "is_tax": False},
63+
"child_benefit": {"entity": "person", "is_tax": False},
64+
"pension_credit": {"entity": "person", "is_tax": False},
65+
"income_support": {"entity": "person", "is_tax": False},
66+
"working_tax_credit": {"entity": "person", "is_tax": False},
67+
"child_tax_credit": {"entity": "person", "is_tax": False},
68+
},
69+
budget_variables={
70+
"household_tax": "household",
71+
"household_benefits": "household",
72+
"household_net_income": "household",
73+
},
74+
poverty_variables={
75+
"absolute_bhc": "in_poverty_bhc",
76+
"absolute_ahc": "in_poverty_ahc",
77+
"relative_bhc": "in_relative_poverty_bhc",
78+
"relative_ahc": "in_relative_poverty_ahc",
79+
},
80+
poverty_entity="person",
81+
poverty_breakdowns=("age", "gender"),
82+
inequality_income_variable="equiv_hbai_household_net_income",
83+
inequality_entity="household",
84+
)

src/policyengine/outputs/decile_impact.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,63 @@ def run(self):
9696
self.count_no_change = float((absolute_change[mask] == 0).sum())
9797

9898

99+
def compute_decile_impacts(
100+
baseline_simulation: Simulation,
101+
reform_simulation: Simulation,
102+
income_variable: str = "equiv_hbai_household_net_income",
103+
entity: str | None = None,
104+
quantiles: int = 10,
105+
) -> OutputCollection[DecileImpact]:
106+
"""Calculate decile-by-decile impact using already-run simulations.
107+
108+
Unlike ``calculate_decile_impacts`` this does **not** create new
109+
Simulation objects — it works directly with the provided ones.
110+
111+
Args:
112+
baseline_simulation: Already-run baseline simulation.
113+
reform_simulation: Already-run reform simulation.
114+
income_variable: Variable to measure income changes.
115+
entity: Entity to aggregate on (default: variable's entity).
116+
quantiles: Number of quantiles (default 10 for deciles).
117+
118+
Returns:
119+
OutputCollection of DecileImpact objects with a DataFrame.
120+
"""
121+
results = []
122+
for decile in range(1, quantiles + 1):
123+
impact = DecileImpact(
124+
baseline_simulation=baseline_simulation,
125+
reform_simulation=reform_simulation,
126+
income_variable=income_variable,
127+
entity=entity,
128+
decile=decile,
129+
quantiles=quantiles,
130+
)
131+
impact.run()
132+
results.append(impact)
133+
134+
df = pd.DataFrame(
135+
[
136+
{
137+
"baseline_simulation_id": r.baseline_simulation.id,
138+
"reform_simulation_id": r.reform_simulation.id,
139+
"income_variable": r.income_variable,
140+
"decile": r.decile,
141+
"baseline_mean": r.baseline_mean,
142+
"reform_mean": r.reform_mean,
143+
"absolute_change": r.absolute_change,
144+
"relative_change": r.relative_change,
145+
"count_better_off": r.count_better_off,
146+
"count_worse_off": r.count_worse_off,
147+
"count_no_change": r.count_no_change,
148+
}
149+
for r in results
150+
]
151+
)
152+
153+
return OutputCollection(outputs=results, dataframe=df)
154+
155+
99156
def calculate_decile_impacts(
100157
dataset: Dataset,
101158
tax_benefit_model_version: TaxBenefitModelVersion,

0 commit comments

Comments
 (0)