Skip to content

Commit fa986de

Browse files
authored
Merge pull request #377 from PolicyEngine/codex/cliff-impact
[codex] Add opt-in cliff impact outputs
2 parents 98229cf + 592040a commit fa986de

10 files changed

Lines changed: 537 additions & 19 deletions

File tree

changelog.d/cliff-impact.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added opt-in macro cliff impact outputs for US and UK reform analyses.

src/policyengine/outputs/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
ChangeAggregate,
55
ChangeAggregateType,
66
)
7+
from policyengine.outputs.cliff_impact import (
8+
CliffImpact,
9+
CliffImpactInSimulation,
10+
calculate_cliff_impact,
11+
configure_cliff_impact_variables,
12+
)
713
from policyengine.outputs.congressional_district_impact import (
814
CongressionalDistrictImpact,
915
compute_us_congressional_district_impacts,
@@ -76,6 +82,10 @@
7682
"AggregateType",
7783
"ChangeAggregate",
7884
"ChangeAggregateType",
85+
"CliffImpact",
86+
"CliffImpactInSimulation",
87+
"calculate_cliff_impact",
88+
"configure_cliff_impact_variables",
7989
"DecileImpact",
8090
"calculate_decile_impacts",
8191
"ProgramStatistics",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Legacy-compatible tax-benefit cliff macro output."""
2+
3+
from __future__ import annotations
4+
5+
from pydantic import BaseModel
6+
7+
from policyengine.core import Output, Simulation
8+
from policyengine.outputs.aggregate import (
9+
get_aggregate_variable,
10+
get_output_entity_data,
11+
require_output_column,
12+
)
13+
from policyengine.outputs.extra_variables import add_extra_variables
14+
15+
CLIFF_IMPACT_VARIABLES = ("cliff_gap", "is_on_cliff", "is_adult")
16+
17+
18+
class CliffImpactInSimulation(BaseModel):
19+
cliff_gap: float
20+
cliff_share: float
21+
22+
23+
class CliffImpact(Output):
24+
baseline: CliffImpactInSimulation
25+
reform: CliffImpactInSimulation
26+
27+
28+
def _cliff_variables_by_entity(
29+
simulation: Simulation,
30+
) -> dict[str, list[str]]:
31+
variables_by_entity: dict[str, list[str]] = {}
32+
for variable_name in CLIFF_IMPACT_VARIABLES:
33+
variable = get_aggregate_variable(
34+
simulation,
35+
variable_name,
36+
"CliffImpact.extra_variables",
37+
)
38+
variables_by_entity.setdefault(variable.entity, []).append(variable_name)
39+
return variables_by_entity
40+
41+
42+
def configure_cliff_impact_variables(*simulations: Simulation) -> None:
43+
"""Materialize cliff columns only for analyses that request them."""
44+
for simulation in simulations:
45+
add_extra_variables(
46+
simulation,
47+
_cliff_variables_by_entity(simulation),
48+
)
49+
50+
51+
def _sum_output_variable(
52+
simulation: Simulation,
53+
variable_name: str,
54+
) -> float:
55+
context = f"CliffImpact.{variable_name}"
56+
variable = get_aggregate_variable(simulation, variable_name, context)
57+
data = get_output_entity_data(simulation, variable.entity, context)
58+
require_output_column(
59+
data,
60+
variable_name,
61+
variable.entity,
62+
simulation,
63+
context,
64+
)
65+
return float(data[variable_name].sum())
66+
67+
68+
def _calculate_cliff_impact_in_simulation(
69+
simulation: Simulation,
70+
) -> CliffImpactInSimulation:
71+
cliff_gap = _sum_output_variable(simulation, "cliff_gap")
72+
people_on_cliffs = _sum_output_variable(simulation, "is_on_cliff")
73+
adults = _sum_output_variable(simulation, "is_adult")
74+
75+
return CliffImpactInSimulation(
76+
cliff_gap=cliff_gap,
77+
cliff_share=float(people_on_cliffs / adults),
78+
)
79+
80+
81+
def calculate_cliff_impact(
82+
baseline_simulation: Simulation,
83+
reform_simulation: Simulation,
84+
) -> CliffImpact:
85+
"""Calculate legacy macro cliff output from materialized simulations."""
86+
return CliffImpact(
87+
baseline=_calculate_cliff_impact_in_simulation(baseline_simulation),
88+
reform=_calculate_cliff_impact_in_simulation(reform_simulation),
89+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Helpers for conditionally materialized output variables."""
2+
3+
from __future__ import annotations
4+
5+
from policyengine.core import Simulation
6+
7+
8+
def add_extra_variables(
9+
simulation: Simulation,
10+
variables_by_entity: dict[str, list[str]],
11+
) -> None:
12+
"""Append extra output variables without dropping caller-supplied extras."""
13+
extra_variables = {
14+
entity: list(variables)
15+
for entity, variables in (simulation.extra_variables or {}).items()
16+
}
17+
for entity, variables in variables_by_entity.items():
18+
existing = extra_variables.setdefault(entity, [])
19+
for variable in variables:
20+
if variable not in existing:
21+
existing.append(variable)
22+
simulation.extra_variables = extra_variables

src/policyengine/outputs/labor_supply_response.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
get_output_entity_data,
1515
require_output_column,
1616
)
17+
from policyengine.outputs.extra_variables import add_extra_variables
1718

1819
CountryCode = Literal["us", "uk"]
1920
DecileValues = dict[int, float]
@@ -162,22 +163,6 @@ def _active_lsr_variables(country_code: CountryCode) -> dict[str, list[str]]:
162163
)
163164

164165

165-
def _add_extra_variables(
166-
simulation: Simulation,
167-
variables_by_entity: dict[str, list[str]],
168-
) -> None:
169-
extra_variables = {
170-
entity: list(variables)
171-
for entity, variables in (simulation.extra_variables or {}).items()
172-
}
173-
for entity, variables in variables_by_entity.items():
174-
existing = extra_variables.setdefault(entity, [])
175-
for variable in variables:
176-
if variable not in existing:
177-
existing.append(variable)
178-
simulation.extra_variables = extra_variables
179-
180-
181166
def configure_labor_supply_response_variables(
182167
baseline_simulation: Simulation,
183168
reform_simulation: Simulation,
@@ -193,8 +178,8 @@ def configure_labor_supply_response_variables(
193178
return False
194179

195180
active_variables = _active_lsr_variables(country_code)
196-
_add_extra_variables(baseline_simulation, active_variables)
197-
_add_extra_variables(reform_simulation, active_variables)
181+
add_extra_variables(baseline_simulation, active_variables)
182+
add_extra_variables(reform_simulation, active_variables)
198183
return True
199184

200185

src/policyengine/tax_benefit_models/uk/analysis.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111

1212
from policyengine.core import OutputCollection, Simulation
1313
from policyengine.outputs import (
14+
CliffImpact,
1415
LaborSupplyResponse,
1516
ProgramStatistics,
17+
calculate_cliff_impact,
1618
calculate_labor_supply_response,
19+
configure_cliff_impact_variables,
1720
configure_labor_supply_response_variables,
1821
)
1922
from policyengine.outputs.decile_impact import (
@@ -71,6 +74,7 @@ class PolicyReformAnalysis(BaseModel):
7174
baseline_inequality: Inequality
7275
reform_inequality: Inequality
7376
labor_supply_response: LaborSupplyResponse
77+
cliff_impact: CliffImpact | None = None
7478

7579

7680
def _format_missing_program_variables(missing_variables: set[str]) -> str | None:
@@ -141,13 +145,16 @@ def _validate_program_statistics_config(
141145
def economic_impact_analysis(
142146
baseline_simulation: Simulation,
143147
reform_simulation: Simulation,
148+
include_cliff_impacts: bool = False,
144149
) -> PolicyReformAnalysis:
145150
"""Perform comprehensive analysis of a UK policy reform."""
146151
configure_labor_supply_response_variables(
147152
baseline_simulation,
148153
reform_simulation,
149154
country_code="uk",
150155
)
156+
if include_cliff_impacts:
157+
configure_cliff_impact_variables(baseline_simulation, reform_simulation)
151158
_validate_program_statistics_config(baseline_simulation, reform_simulation)
152159

153160
baseline_simulation.ensure()
@@ -224,6 +231,11 @@ def economic_impact_analysis(
224231
reform_simulation,
225232
country_code="uk",
226233
)
234+
cliff_impact = (
235+
calculate_cliff_impact(baseline_simulation, reform_simulation)
236+
if include_cliff_impacts
237+
else None
238+
)
227239

228240
return PolicyReformAnalysis(
229241
decile_impacts=decile_impacts,
@@ -235,4 +247,5 @@ def economic_impact_analysis(
235247
baseline_inequality=baseline_inequality,
236248
reform_inequality=reform_inequality,
237249
labor_supply_response=labor_supply_response,
250+
cliff_impact=cliff_impact,
238251
)

src/policyengine/tax_benefit_models/us/analysis.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313

1414
from policyengine.core import OutputCollection, Simulation
1515
from policyengine.outputs import (
16+
CliffImpact,
1617
LaborSupplyResponse,
1718
ProgramStatistics,
19+
calculate_cliff_impact,
1820
calculate_labor_supply_response,
21+
configure_cliff_impact_variables,
1922
configure_labor_supply_response_variables,
2023
)
2124
from policyengine.outputs.decile_impact import (
@@ -63,6 +66,7 @@ class PolicyReformAnalysis(BaseModel):
6366
baseline_inequality: Inequality
6467
reform_inequality: Inequality
6568
labor_supply_response: LaborSupplyResponse
69+
cliff_impact: CliffImpact | None = None
6670

6771

6872
def _format_missing_program_variables(missing_variables: set[str]) -> str | None:
@@ -134,6 +138,7 @@ def economic_impact_analysis(
134138
baseline_simulation: Simulation,
135139
reform_simulation: Simulation,
136140
inequality_preset: Union[USInequalityPreset, str] = USInequalityPreset.STANDARD,
141+
include_cliff_impacts: bool = False,
137142
) -> PolicyReformAnalysis:
138143
"""Perform comprehensive analysis of a US policy reform.
139144
@@ -151,6 +156,8 @@ def economic_impact_analysis(
151156
reform_simulation,
152157
country_code="us",
153158
)
159+
if include_cliff_impacts:
160+
configure_cliff_impact_variables(baseline_simulation, reform_simulation)
154161
_validate_program_statistics_config(baseline_simulation, reform_simulation)
155162

156163
baseline_simulation.ensure()
@@ -218,6 +225,11 @@ def economic_impact_analysis(
218225
reform_simulation,
219226
country_code="us",
220227
)
228+
cliff_impact = (
229+
calculate_cliff_impact(baseline_simulation, reform_simulation)
230+
if include_cliff_impacts
231+
else None
232+
)
221233

222234
return PolicyReformAnalysis(
223235
decile_impacts=decile_impacts,
@@ -227,4 +239,5 @@ def economic_impact_analysis(
227239
baseline_inequality=baseline_inequality,
228240
reform_inequality=reform_inequality,
229241
labor_supply_response=labor_supply_response,
242+
cliff_impact=cliff_impact,
230243
)

0 commit comments

Comments
 (0)