policyengine 3.1.15__py3-none-any.whl → 3.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,22 @@ from policyengine.outputs.decile_impact import (
8
8
  DecileImpact,
9
9
  calculate_decile_impacts,
10
10
  )
11
+ from policyengine.outputs.inequality import (
12
+ UK_INEQUALITY_INCOME_VARIABLE,
13
+ US_INEQUALITY_INCOME_VARIABLE,
14
+ Inequality,
15
+ calculate_uk_inequality,
16
+ calculate_us_inequality,
17
+ )
18
+ from policyengine.outputs.poverty import (
19
+ UK_POVERTY_VARIABLES,
20
+ US_POVERTY_VARIABLES,
21
+ Poverty,
22
+ UKPovertyType,
23
+ USPovertyType,
24
+ calculate_uk_poverty_rates,
25
+ calculate_us_poverty_rates,
26
+ )
11
27
 
12
28
  __all__ = [
13
29
  "Output",
@@ -18,4 +34,16 @@ __all__ = [
18
34
  "ChangeAggregateType",
19
35
  "DecileImpact",
20
36
  "calculate_decile_impacts",
37
+ "Poverty",
38
+ "UKPovertyType",
39
+ "USPovertyType",
40
+ "UK_POVERTY_VARIABLES",
41
+ "US_POVERTY_VARIABLES",
42
+ "calculate_uk_poverty_rates",
43
+ "calculate_us_poverty_rates",
44
+ "Inequality",
45
+ "UK_INEQUALITY_INCOME_VARIABLE",
46
+ "US_INEQUALITY_INCOME_VARIABLE",
47
+ "calculate_uk_inequality",
48
+ "calculate_us_inequality",
21
49
  ]
@@ -0,0 +1,276 @@
1
+ """Inequality analysis output types."""
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from pydantic import ConfigDict
8
+
9
+ from policyengine.core import Output, Simulation
10
+
11
+
12
+ def _gini(values: np.ndarray, weights: np.ndarray) -> float:
13
+ """Calculate weighted Gini coefficient.
14
+
15
+ Args:
16
+ values: Array of income values
17
+ weights: Array of weights
18
+
19
+ Returns:
20
+ Gini coefficient between 0 (perfect equality) and 1 (perfect inequality)
21
+ """
22
+ # Handle edge cases
23
+ if len(values) == 0 or weights.sum() == 0:
24
+ return 0.0
25
+
26
+ # Sort by values
27
+ sorted_indices = np.argsort(values)
28
+ sorted_values = values[sorted_indices]
29
+ sorted_weights = weights[sorted_indices]
30
+
31
+ # Cumulative weights and weighted values
32
+ cumulative_weights = np.cumsum(sorted_weights)
33
+ total_weight = cumulative_weights[-1]
34
+ cumulative_weighted_values = np.cumsum(sorted_values * sorted_weights)
35
+ total_weighted_value = cumulative_weighted_values[-1]
36
+
37
+ if total_weighted_value == 0:
38
+ return 0.0
39
+
40
+ # Calculate Gini using the area formula
41
+ # Gini = 1 - 2 * (area under Lorenz curve)
42
+ lorenz_curve = cumulative_weighted_values / total_weighted_value
43
+ weight_fractions = sorted_weights / total_weight
44
+
45
+ # Area under Lorenz curve using trapezoidal rule
46
+ area = np.sum(weight_fractions * (lorenz_curve - weight_fractions / 2))
47
+
48
+ return float(1 - 2 * area)
49
+
50
+
51
+ class Inequality(Output):
52
+ """Single inequality measure result - represents one database row.
53
+
54
+ This is a single-simulation output type that calculates inequality
55
+ metrics for a given income variable, optionally filtered by
56
+ demographic variables.
57
+ """
58
+
59
+ model_config = ConfigDict(arbitrary_types_allowed=True)
60
+
61
+ simulation: Simulation
62
+ income_variable: str
63
+ entity: str = "household"
64
+
65
+ # Optional demographic filters
66
+ filter_variable: str | None = None
67
+ filter_variable_eq: Any | None = None
68
+ filter_variable_leq: Any | None = None
69
+ filter_variable_geq: Any | None = None
70
+
71
+ # Results populated by run()
72
+ gini: float | None = None
73
+ top_10_share: float | None = None
74
+ top_1_share: float | None = None
75
+ bottom_50_share: float | None = None
76
+
77
+ def run(self):
78
+ """Calculate inequality metrics."""
79
+ # Get income variable info
80
+ income_var_obj = (
81
+ self.simulation.tax_benefit_model_version.get_variable(
82
+ self.income_variable
83
+ )
84
+ )
85
+
86
+ # Get target entity data
87
+ target_entity = self.entity
88
+ data = getattr(self.simulation.output_dataset.data, target_entity)
89
+
90
+ # Map income variable to target entity if needed
91
+ if income_var_obj.entity != target_entity:
92
+ mapped = self.simulation.output_dataset.data.map_to_entity(
93
+ income_var_obj.entity,
94
+ target_entity,
95
+ columns=[self.income_variable],
96
+ )
97
+ income_series = mapped[self.income_variable]
98
+ else:
99
+ income_series = data[self.income_variable]
100
+
101
+ # Get weights
102
+ weight_col = f"{target_entity}_weight"
103
+ if weight_col in data.columns:
104
+ weights = data[weight_col]
105
+ else:
106
+ weights = pd.Series(np.ones(len(income_series)))
107
+
108
+ # Apply demographic filter if specified
109
+ if self.filter_variable is not None:
110
+ filter_var_obj = (
111
+ self.simulation.tax_benefit_model_version.get_variable(
112
+ self.filter_variable
113
+ )
114
+ )
115
+
116
+ if filter_var_obj.entity != target_entity:
117
+ filter_mapped = (
118
+ self.simulation.output_dataset.data.map_to_entity(
119
+ filter_var_obj.entity,
120
+ target_entity,
121
+ columns=[self.filter_variable],
122
+ )
123
+ )
124
+ filter_series = filter_mapped[self.filter_variable]
125
+ else:
126
+ filter_series = data[self.filter_variable]
127
+
128
+ # Build filter mask
129
+ mask = filter_series.notna()
130
+ if self.filter_variable_eq is not None:
131
+ mask &= filter_series == self.filter_variable_eq
132
+ if self.filter_variable_leq is not None:
133
+ mask &= filter_series <= self.filter_variable_leq
134
+ if self.filter_variable_geq is not None:
135
+ mask &= filter_series >= self.filter_variable_geq
136
+
137
+ # Apply mask
138
+ income_series = income_series[mask]
139
+ weights = weights[mask]
140
+
141
+ # Convert to numpy arrays
142
+ values = np.array(income_series)
143
+ weights_arr = np.array(weights)
144
+
145
+ # Remove NaN values
146
+ valid_mask = ~np.isnan(values) & ~np.isnan(weights_arr)
147
+ values = values[valid_mask]
148
+ weights_arr = weights_arr[valid_mask]
149
+
150
+ # Calculate Gini coefficient
151
+ self.gini = _gini(values, weights_arr)
152
+
153
+ # Calculate income shares
154
+ if len(values) > 0 and weights_arr.sum() > 0:
155
+ total_income = np.sum(values * weights_arr)
156
+
157
+ if total_income > 0:
158
+ # Sort by income
159
+ sorted_indices = np.argsort(values)
160
+ sorted_values = values[sorted_indices]
161
+ sorted_weights = weights_arr[sorted_indices]
162
+
163
+ # Cumulative weight fractions
164
+ cumulative_weights = np.cumsum(sorted_weights)
165
+ total_weight = cumulative_weights[-1]
166
+ weight_fractions = cumulative_weights / total_weight
167
+
168
+ # Top 10% share
169
+ top_10_mask = weight_fractions > 0.9
170
+ self.top_10_share = float(
171
+ np.sum(
172
+ sorted_values[top_10_mask]
173
+ * sorted_weights[top_10_mask]
174
+ )
175
+ / total_income
176
+ )
177
+
178
+ # Top 1% share
179
+ top_1_mask = weight_fractions > 0.99
180
+ self.top_1_share = float(
181
+ np.sum(
182
+ sorted_values[top_1_mask] * sorted_weights[top_1_mask]
183
+ )
184
+ / total_income
185
+ )
186
+
187
+ # Bottom 50% share
188
+ bottom_50_mask = weight_fractions <= 0.5
189
+ self.bottom_50_share = float(
190
+ np.sum(
191
+ sorted_values[bottom_50_mask]
192
+ * sorted_weights[bottom_50_mask]
193
+ )
194
+ / total_income
195
+ )
196
+ else:
197
+ self.top_10_share = 0.0
198
+ self.top_1_share = 0.0
199
+ self.bottom_50_share = 0.0
200
+ else:
201
+ self.top_10_share = 0.0
202
+ self.top_1_share = 0.0
203
+ self.bottom_50_share = 0.0
204
+
205
+
206
+ # Default income variables for each country
207
+ UK_INEQUALITY_INCOME_VARIABLE = "equiv_hbai_household_net_income"
208
+ US_INEQUALITY_INCOME_VARIABLE = "household_net_income"
209
+
210
+
211
+ def calculate_uk_inequality(
212
+ simulation: Simulation,
213
+ income_variable: str = UK_INEQUALITY_INCOME_VARIABLE,
214
+ filter_variable: str | None = None,
215
+ filter_variable_eq: Any | None = None,
216
+ filter_variable_leq: Any | None = None,
217
+ filter_variable_geq: Any | None = None,
218
+ ) -> Inequality:
219
+ """Calculate inequality metrics for a UK simulation.
220
+
221
+ Args:
222
+ simulation: The simulation to analyse
223
+ income_variable: Income variable to use (default: equiv_hbai_household_net_income)
224
+ filter_variable: Optional variable to filter by
225
+ filter_variable_eq: Filter for exact match
226
+ filter_variable_leq: Filter for less than or equal
227
+ filter_variable_geq: Filter for greater than or equal
228
+
229
+ Returns:
230
+ Inequality object with Gini and income share metrics
231
+ """
232
+ inequality = Inequality(
233
+ simulation=simulation,
234
+ income_variable=income_variable,
235
+ entity="household",
236
+ filter_variable=filter_variable,
237
+ filter_variable_eq=filter_variable_eq,
238
+ filter_variable_leq=filter_variable_leq,
239
+ filter_variable_geq=filter_variable_geq,
240
+ )
241
+ inequality.run()
242
+ return inequality
243
+
244
+
245
+ def calculate_us_inequality(
246
+ simulation: Simulation,
247
+ income_variable: str = US_INEQUALITY_INCOME_VARIABLE,
248
+ filter_variable: str | None = None,
249
+ filter_variable_eq: Any | None = None,
250
+ filter_variable_leq: Any | None = None,
251
+ filter_variable_geq: Any | None = None,
252
+ ) -> Inequality:
253
+ """Calculate inequality metrics for a US simulation.
254
+
255
+ Args:
256
+ simulation: The simulation to analyse
257
+ income_variable: Income variable to use (default: household_net_income)
258
+ filter_variable: Optional variable to filter by
259
+ filter_variable_eq: Filter for exact match
260
+ filter_variable_leq: Filter for less than or equal
261
+ filter_variable_geq: Filter for greater than or equal
262
+
263
+ Returns:
264
+ Inequality object with Gini and income share metrics
265
+ """
266
+ inequality = Inequality(
267
+ simulation=simulation,
268
+ income_variable=income_variable,
269
+ entity="household",
270
+ filter_variable=filter_variable,
271
+ filter_variable_eq=filter_variable_eq,
272
+ filter_variable_leq=filter_variable_leq,
273
+ filter_variable_geq=filter_variable_geq,
274
+ )
275
+ inequality.run()
276
+ return inequality
@@ -0,0 +1,238 @@
1
+ """Poverty analysis output types."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+ from pydantic import ConfigDict
8
+
9
+ from policyengine.core import Output, OutputCollection, Simulation
10
+
11
+
12
+ class UKPovertyType(str, Enum):
13
+ """UK poverty measure types."""
14
+
15
+ ABSOLUTE_BHC = "absolute_bhc"
16
+ ABSOLUTE_AHC = "absolute_ahc"
17
+ RELATIVE_BHC = "relative_bhc"
18
+ RELATIVE_AHC = "relative_ahc"
19
+
20
+
21
+ class USPovertyType(str, Enum):
22
+ """US poverty measure types."""
23
+
24
+ SPM = "spm"
25
+ SPM_DEEP = "spm_deep"
26
+
27
+
28
+ # Mapping from poverty type to variable name
29
+ UK_POVERTY_VARIABLES = {
30
+ UKPovertyType.ABSOLUTE_BHC: "in_poverty_bhc",
31
+ UKPovertyType.ABSOLUTE_AHC: "in_poverty_ahc",
32
+ UKPovertyType.RELATIVE_BHC: "in_relative_poverty_bhc",
33
+ UKPovertyType.RELATIVE_AHC: "in_relative_poverty_ahc",
34
+ }
35
+
36
+ US_POVERTY_VARIABLES = {
37
+ USPovertyType.SPM: "spm_unit_is_in_spm_poverty",
38
+ USPovertyType.SPM_DEEP: "spm_unit_is_in_deep_spm_poverty",
39
+ }
40
+
41
+
42
+ class Poverty(Output):
43
+ """Single poverty measure result - represents one database row.
44
+
45
+ This is a single-simulation output type that calculates poverty
46
+ headcount and rate for a given poverty measure, optionally filtered
47
+ by demographic variables.
48
+ """
49
+
50
+ model_config = ConfigDict(arbitrary_types_allowed=True)
51
+
52
+ simulation: Simulation
53
+ poverty_variable: str
54
+ entity: str = "person"
55
+
56
+ # Optional demographic filters
57
+ filter_variable: str | None = None
58
+ filter_variable_eq: Any | None = None
59
+ filter_variable_leq: Any | None = None
60
+ filter_variable_geq: Any | None = None
61
+
62
+ # Results populated by run()
63
+ headcount: float | None = None
64
+ total_population: float | None = None
65
+ rate: float | None = None
66
+
67
+ def run(self):
68
+ """Calculate poverty headcount and rate."""
69
+ # Get poverty variable info
70
+ poverty_var_obj = (
71
+ self.simulation.tax_benefit_model_version.get_variable(
72
+ self.poverty_variable
73
+ )
74
+ )
75
+
76
+ # Get target entity data
77
+ target_entity = self.entity
78
+ data = getattr(self.simulation.output_dataset.data, target_entity)
79
+
80
+ # Map poverty variable to target entity if needed
81
+ if poverty_var_obj.entity != target_entity:
82
+ mapped = self.simulation.output_dataset.data.map_to_entity(
83
+ poverty_var_obj.entity,
84
+ target_entity,
85
+ columns=[self.poverty_variable],
86
+ )
87
+ poverty_series = mapped[self.poverty_variable]
88
+ else:
89
+ poverty_series = data[self.poverty_variable]
90
+
91
+ # Apply demographic filter if specified
92
+ if self.filter_variable is not None:
93
+ filter_var_obj = (
94
+ self.simulation.tax_benefit_model_version.get_variable(
95
+ self.filter_variable
96
+ )
97
+ )
98
+
99
+ if filter_var_obj.entity != target_entity:
100
+ filter_mapped = (
101
+ self.simulation.output_dataset.data.map_to_entity(
102
+ filter_var_obj.entity,
103
+ target_entity,
104
+ columns=[self.filter_variable],
105
+ )
106
+ )
107
+ filter_series = filter_mapped[self.filter_variable]
108
+ else:
109
+ filter_series = data[self.filter_variable]
110
+
111
+ # Build filter mask
112
+ mask = filter_series.notna()
113
+ if self.filter_variable_eq is not None:
114
+ mask &= filter_series == self.filter_variable_eq
115
+ if self.filter_variable_leq is not None:
116
+ mask &= filter_series <= self.filter_variable_leq
117
+ if self.filter_variable_geq is not None:
118
+ mask &= filter_series >= self.filter_variable_geq
119
+
120
+ # Apply mask
121
+ poverty_series = poverty_series[mask]
122
+
123
+ # Calculate results using weighted counts
124
+ self.headcount = float((poverty_series == True).sum()) # noqa: E712
125
+ self.total_population = float(poverty_series.count())
126
+ self.rate = (
127
+ self.headcount / self.total_population
128
+ if self.total_population > 0
129
+ else 0.0
130
+ )
131
+
132
+
133
+ def calculate_uk_poverty_rates(
134
+ simulation: Simulation,
135
+ filter_variable: str | None = None,
136
+ filter_variable_eq: Any | None = None,
137
+ filter_variable_leq: Any | None = None,
138
+ filter_variable_geq: Any | None = None,
139
+ ) -> OutputCollection[Poverty]:
140
+ """Calculate all UK poverty rates for a simulation.
141
+
142
+ Args:
143
+ simulation: The simulation to analyse
144
+ filter_variable: Optional variable to filter by (e.g., "is_child")
145
+ filter_variable_eq: Filter for exact match
146
+ filter_variable_leq: Filter for less than or equal
147
+ filter_variable_geq: Filter for greater than or equal
148
+
149
+ Returns:
150
+ OutputCollection containing Poverty objects for each UK poverty type
151
+ """
152
+ results = []
153
+
154
+ for poverty_variable in UK_POVERTY_VARIABLES.values():
155
+ poverty = Poverty(
156
+ simulation=simulation,
157
+ poverty_variable=poverty_variable,
158
+ entity="person",
159
+ filter_variable=filter_variable,
160
+ filter_variable_eq=filter_variable_eq,
161
+ filter_variable_leq=filter_variable_leq,
162
+ filter_variable_geq=filter_variable_geq,
163
+ )
164
+ poverty.run()
165
+ results.append(poverty)
166
+
167
+ df = pd.DataFrame(
168
+ [
169
+ {
170
+ "simulation_id": r.simulation.id,
171
+ "poverty_variable": r.poverty_variable,
172
+ "filter_variable": r.filter_variable,
173
+ "filter_variable_eq": r.filter_variable_eq,
174
+ "filter_variable_leq": r.filter_variable_leq,
175
+ "filter_variable_geq": r.filter_variable_geq,
176
+ "headcount": r.headcount,
177
+ "total_population": r.total_population,
178
+ "rate": r.rate,
179
+ }
180
+ for r in results
181
+ ]
182
+ )
183
+
184
+ return OutputCollection(outputs=results, dataframe=df)
185
+
186
+
187
+ def calculate_us_poverty_rates(
188
+ simulation: Simulation,
189
+ filter_variable: str | None = None,
190
+ filter_variable_eq: Any | None = None,
191
+ filter_variable_leq: Any | None = None,
192
+ filter_variable_geq: Any | None = None,
193
+ ) -> OutputCollection[Poverty]:
194
+ """Calculate all US poverty rates for a simulation.
195
+
196
+ Args:
197
+ simulation: The simulation to analyse
198
+ filter_variable: Optional variable to filter by (e.g., "is_child")
199
+ filter_variable_eq: Filter for exact match
200
+ filter_variable_leq: Filter for less than or equal
201
+ filter_variable_geq: Filter for greater than or equal
202
+
203
+ Returns:
204
+ OutputCollection containing Poverty objects for each US poverty type
205
+ """
206
+ results = []
207
+
208
+ for poverty_variable in US_POVERTY_VARIABLES.values():
209
+ poverty = Poverty(
210
+ simulation=simulation,
211
+ poverty_variable=poverty_variable,
212
+ entity="person",
213
+ filter_variable=filter_variable,
214
+ filter_variable_eq=filter_variable_eq,
215
+ filter_variable_leq=filter_variable_leq,
216
+ filter_variable_geq=filter_variable_geq,
217
+ )
218
+ poverty.run()
219
+ results.append(poverty)
220
+
221
+ df = pd.DataFrame(
222
+ [
223
+ {
224
+ "simulation_id": r.simulation.id,
225
+ "poverty_variable": r.poverty_variable,
226
+ "filter_variable": r.filter_variable,
227
+ "filter_variable_eq": r.filter_variable_eq,
228
+ "filter_variable_leq": r.filter_variable_leq,
229
+ "filter_variable_geq": r.filter_variable_geq,
230
+ "headcount": r.headcount,
231
+ "total_population": r.total_population,
232
+ "rate": r.rate,
233
+ }
234
+ for r in results
235
+ ]
236
+ )
237
+
238
+ return OutputCollection(outputs=results, dataframe=df)
@@ -14,6 +14,14 @@ from policyengine.outputs.decile_impact import (
14
14
  DecileImpact,
15
15
  calculate_decile_impacts,
16
16
  )
17
+ from policyengine.outputs.inequality import (
18
+ Inequality,
19
+ calculate_uk_inequality,
20
+ )
21
+ from policyengine.outputs.poverty import (
22
+ Poverty,
23
+ calculate_uk_poverty_rates,
24
+ )
17
25
 
18
26
  from .datasets import PolicyEngineUKDataset, UKYearData
19
27
  from .model import uk_latest
@@ -175,6 +183,10 @@ class PolicyReformAnalysis(BaseModel):
175
183
 
176
184
  decile_impacts: OutputCollection[DecileImpact]
177
185
  programme_statistics: OutputCollection[ProgrammeStatistics]
186
+ baseline_poverty: OutputCollection[Poverty]
187
+ reform_poverty: OutputCollection[Poverty]
188
+ baseline_inequality: Inequality
189
+ reform_inequality: Inequality
178
190
 
179
191
 
180
192
  def economic_impact_analysis(
@@ -262,7 +274,19 @@ def economic_impact_analysis(
262
274
  outputs=programme_statistics, dataframe=programme_df
263
275
  )
264
276
 
277
+ # Calculate poverty rates for both simulations
278
+ baseline_poverty = calculate_uk_poverty_rates(baseline_simulation)
279
+ reform_poverty = calculate_uk_poverty_rates(reform_simulation)
280
+
281
+ # Calculate inequality for both simulations
282
+ baseline_inequality = calculate_uk_inequality(baseline_simulation)
283
+ reform_inequality = calculate_uk_inequality(reform_simulation)
284
+
265
285
  return PolicyReformAnalysis(
266
286
  decile_impacts=decile_impacts,
267
287
  programme_statistics=programme_collection,
288
+ baseline_poverty=baseline_poverty,
289
+ reform_poverty=reform_poverty,
290
+ baseline_inequality=baseline_inequality,
291
+ reform_inequality=reform_inequality,
268
292
  )
@@ -13,6 +13,10 @@ from policyengine.core import (
13
13
  TaxBenefitModelVersion,
14
14
  Variable,
15
15
  )
16
+ from policyengine.utils.parameter_labels import (
17
+ build_scale_lookup,
18
+ generate_label_for_parameter,
19
+ )
16
20
 
17
21
  from .datasets import PolicyEngineUKDataset, UKYearData
18
22
 
@@ -146,17 +150,21 @@ class PolicyEngineUKLatest(TaxBenefitModelVersion):
146
150
 
147
151
  from policyengine_core.parameters import Parameter as CoreParameter
148
152
 
153
+ scale_lookup = build_scale_lookup(system)
154
+
149
155
  for param_node in system.parameters.get_descendants():
150
156
  if isinstance(param_node, CoreParameter):
151
157
  parameter = Parameter(
152
158
  id=self.id + "-" + param_node.name,
153
159
  name=param_node.name,
154
- label=param_node.metadata.get("label", param_node.name),
160
+ label=generate_label_for_parameter(
161
+ param_node, system, scale_lookup
162
+ ),
155
163
  tax_benefit_model_version=self,
156
164
  description=param_node.description,
157
165
  data_type=type(param_node(2025)),
158
166
  unit=param_node.metadata.get("unit"),
159
- _core_param=param_node, # Store for lazy value loading
167
+ _core_param=param_node,
160
168
  )
161
169
  self.add_parameter(parameter)
162
170
 
@@ -14,6 +14,14 @@ from policyengine.outputs.decile_impact import (
14
14
  DecileImpact,
15
15
  calculate_decile_impacts,
16
16
  )
17
+ from policyengine.outputs.inequality import (
18
+ Inequality,
19
+ calculate_us_inequality,
20
+ )
21
+ from policyengine.outputs.poverty import (
22
+ Poverty,
23
+ calculate_us_poverty_rates,
24
+ )
17
25
 
18
26
  from .datasets import PolicyEngineUSDataset, USYearData
19
27
  from .model import us_latest
@@ -193,6 +201,10 @@ class PolicyReformAnalysis(BaseModel):
193
201
 
194
202
  decile_impacts: OutputCollection[DecileImpact]
195
203
  program_statistics: OutputCollection[ProgramStatistics]
204
+ baseline_poverty: OutputCollection[Poverty]
205
+ reform_poverty: OutputCollection[Poverty]
206
+ baseline_inequality: Inequality
207
+ reform_inequality: Inequality
196
208
 
197
209
 
198
210
  def economic_impact_analysis(
@@ -283,6 +295,19 @@ def economic_impact_analysis(
283
295
  outputs=program_statistics, dataframe=program_df
284
296
  )
285
297
 
298
+ # Calculate poverty rates for both simulations
299
+ baseline_poverty = calculate_us_poverty_rates(baseline_simulation)
300
+ reform_poverty = calculate_us_poverty_rates(reform_simulation)
301
+
302
+ # Calculate inequality for both simulations
303
+ baseline_inequality = calculate_us_inequality(baseline_simulation)
304
+ reform_inequality = calculate_us_inequality(reform_simulation)
305
+
286
306
  return PolicyReformAnalysis(
287
- decile_impacts=decile_impacts, program_statistics=program_collection
307
+ decile_impacts=decile_impacts,
308
+ program_statistics=program_collection,
309
+ baseline_poverty=baseline_poverty,
310
+ reform_poverty=reform_poverty,
311
+ baseline_inequality=baseline_inequality,
312
+ reform_inequality=reform_inequality,
288
313
  )
@@ -13,6 +13,10 @@ from policyengine.core import (
13
13
  TaxBenefitModelVersion,
14
14
  Variable,
15
15
  )
16
+ from policyengine.utils.parameter_labels import (
17
+ build_scale_lookup,
18
+ generate_label_for_parameter,
19
+ )
16
20
 
17
21
  from .datasets import PolicyEngineUSDataset, USYearData
18
22
 
@@ -55,6 +59,8 @@ class PolicyEngineUSLatest(TaxBenefitModelVersion):
55
59
  "person_weight",
56
60
  # Demographics
57
61
  "age",
62
+ "is_child",
63
+ "is_adult",
58
64
  # Income
59
65
  "employment_income",
60
66
  # Benefits
@@ -77,6 +83,9 @@ class PolicyEngineUSLatest(TaxBenefitModelVersion):
77
83
  "snap",
78
84
  "tanf",
79
85
  "spm_unit_net_income",
86
+ # Poverty measures
87
+ "spm_unit_is_in_spm_poverty",
88
+ "spm_unit_is_in_deep_spm_poverty",
80
89
  ],
81
90
  "tax_unit": [
82
91
  "tax_unit_id",
@@ -134,17 +143,21 @@ class PolicyEngineUSLatest(TaxBenefitModelVersion):
134
143
 
135
144
  from policyengine_core.parameters import Parameter as CoreParameter
136
145
 
146
+ scale_lookup = build_scale_lookup(system)
147
+
137
148
  for param_node in system.parameters.get_descendants():
138
149
  if isinstance(param_node, CoreParameter):
139
150
  parameter = Parameter(
140
151
  id=self.id + "-" + param_node.name,
141
152
  name=param_node.name,
142
- label=param_node.metadata.get("label"),
153
+ label=generate_label_for_parameter(
154
+ param_node, system, scale_lookup
155
+ ),
143
156
  tax_benefit_model_version=self,
144
157
  description=param_node.description,
145
158
  data_type=type(param_node(2025)),
146
159
  unit=param_node.metadata.get("unit"),
147
- _core_param=param_node, # Store for lazy value loading
160
+ _core_param=param_node,
148
161
  )
149
162
  self.add_parameter(parameter)
150
163
 
@@ -1,3 +1,7 @@
1
1
  from .dates import parse_safe_date as parse_safe_date
2
+ from .parameter_labels import build_scale_lookup as build_scale_lookup
3
+ from .parameter_labels import (
4
+ generate_label_for_parameter as generate_label_for_parameter,
5
+ )
2
6
  from .plotting import COLORS as COLORS
3
7
  from .plotting import format_fig as format_fig
@@ -0,0 +1,213 @@
1
+ """Utilities for generating human-readable labels for tax-benefit parameters."""
2
+
3
+ import re
4
+
5
+
6
+ def generate_label_for_parameter(param_node, system, scale_lookup):
7
+ """
8
+ Generate a label for a parameter that doesn't have one.
9
+
10
+ For breakdown parameters: Uses parent label + enum value
11
+ For bracket parameters: Uses scale label + bracket info
12
+
13
+ Args:
14
+ param_node: The CoreParameter object
15
+ system: The tax-benefit system (has variables and parameters)
16
+ scale_lookup: Dict mapping scale names to ParameterScale objects
17
+
18
+ Returns:
19
+ str or None: Generated label, or None if cannot generate
20
+ """
21
+ if param_node.metadata.get("label"):
22
+ return param_node.metadata.get("label")
23
+
24
+ param_name = param_node.name
25
+
26
+ if "[" in param_name:
27
+ return _generate_bracket_label(param_name, scale_lookup)
28
+
29
+ # Check for breakdown - either direct child or nested
30
+ breakdown_parent = _find_breakdown_parent(param_node)
31
+ if breakdown_parent:
32
+ return _generate_breakdown_label(param_node, system, breakdown_parent)
33
+
34
+ return None
35
+
36
+
37
+ def _find_breakdown_parent(param_node):
38
+ """
39
+ Walk up the tree to find the nearest ancestor with breakdown metadata.
40
+
41
+ Args:
42
+ param_node: The CoreParameter object
43
+
44
+ Returns:
45
+ The breakdown parent node, or None if not found
46
+ """
47
+ current = param_node.parent
48
+ while current:
49
+ if current.metadata.get("breakdown"):
50
+ return current
51
+ current = getattr(current, "parent", None)
52
+ return None
53
+
54
+
55
+ def _generate_breakdown_label(param_node, system, breakdown_parent=None):
56
+ """
57
+ Generate label for a breakdown parameter using enum values.
58
+
59
+ Handles both single-level and nested breakdowns by walking up to the
60
+ breakdown parent and collecting all dimension values.
61
+
62
+ Args:
63
+ param_node: The CoreParameter object
64
+ system: The tax-benefit system
65
+ breakdown_parent: The ancestor node with breakdown metadata (optional)
66
+
67
+ Returns:
68
+ str or None: Generated label, or None if cannot generate
69
+ """
70
+ # Find breakdown parent if not provided
71
+ if breakdown_parent is None:
72
+ breakdown_parent = _find_breakdown_parent(param_node)
73
+ if not breakdown_parent:
74
+ return None
75
+
76
+ parent_label = breakdown_parent.metadata.get("label")
77
+ if not parent_label:
78
+ return None
79
+
80
+ breakdown_vars = breakdown_parent.metadata.get("breakdown", [])
81
+ breakdown_labels = breakdown_parent.metadata.get("breakdown_labels", [])
82
+
83
+ # Collect dimension values from breakdown parent to param_node
84
+ dimension_values = _collect_dimension_values(
85
+ param_node, breakdown_parent
86
+ )
87
+
88
+ if not dimension_values:
89
+ return None
90
+
91
+ # Generate labels for each dimension
92
+ formatted_parts = []
93
+ for i, (dim_key, dim_value) in enumerate(dimension_values):
94
+ var_name = breakdown_vars[i] if i < len(breakdown_vars) else None
95
+ dim_label = breakdown_labels[i] if i < len(breakdown_labels) else None
96
+
97
+ formatted_value = _format_dimension_value(
98
+ dim_value, var_name, dim_label, system
99
+ )
100
+ formatted_parts.append(formatted_value)
101
+
102
+ return f"{parent_label} ({', '.join(formatted_parts)})"
103
+
104
+
105
+ def _collect_dimension_values(param_node, breakdown_parent):
106
+ """
107
+ Collect dimension keys and values from breakdown parent to param_node.
108
+
109
+ Args:
110
+ param_node: The CoreParameter object
111
+ breakdown_parent: The ancestor node with breakdown metadata
112
+
113
+ Returns:
114
+ list of (dimension_key, value) tuples, ordered from parent to child
115
+ """
116
+ # Build path from param_node up to breakdown_parent
117
+ path = []
118
+ current = param_node
119
+ while current and current != breakdown_parent:
120
+ path.append(current)
121
+ current = getattr(current, "parent", None)
122
+
123
+ # Reverse to get parent-to-child order
124
+ path.reverse()
125
+
126
+ # Extract dimension values
127
+ dimension_values = []
128
+ for i, node in enumerate(path):
129
+ key = node.name.split(".")[-1]
130
+ dimension_values.append((i, key))
131
+
132
+ return dimension_values
133
+
134
+
135
+ def _format_dimension_value(value, var_name, dim_label, system):
136
+ """
137
+ Format a single dimension value with semantic label if available.
138
+
139
+ Args:
140
+ value: The raw dimension value (e.g., "SINGLE", "1", "CA")
141
+ var_name: The breakdown variable name (e.g., "filing_status", "range(1, 9)")
142
+ dim_label: The human-readable label for this dimension (e.g., "Household size")
143
+ system: The tax-benefit system
144
+
145
+ Returns:
146
+ str: Formatted dimension value
147
+ """
148
+ # First, try to get enum display value
149
+ if var_name and isinstance(var_name, str) and not var_name.startswith("range(") and not var_name.startswith("list("):
150
+ var = system.variables.get(var_name)
151
+ if var and hasattr(var, "possible_values") and var.possible_values:
152
+ try:
153
+ enum_value = var.possible_values[value].value
154
+ return str(enum_value)
155
+ except (KeyError, AttributeError):
156
+ pass
157
+
158
+ # For range() dimensions or when no enum found, use breakdown_label if available
159
+ if dim_label:
160
+ return f"{dim_label} {value}"
161
+
162
+ return value
163
+
164
+
165
+ def _generate_bracket_label(param_name, scale_lookup):
166
+ """Generate label for a bracket parameter."""
167
+ match = re.match(r"^(.+)\[(\d+)\]\.(\w+)$", param_name)
168
+ if not match:
169
+ return None
170
+
171
+ scale_name = match.group(1)
172
+ bracket_index = int(match.group(2))
173
+ field_name = match.group(3)
174
+
175
+ scale = scale_lookup.get(scale_name)
176
+ if not scale:
177
+ return None
178
+
179
+ scale_label = scale.metadata.get("label")
180
+ scale_type = scale.metadata.get("type", "")
181
+
182
+ if not scale_label:
183
+ return None
184
+
185
+ bracket_num = bracket_index + 1
186
+
187
+ if scale_type in ("marginal_rate", "marginal_amount"):
188
+ bracket_desc = f"bracket {bracket_num}"
189
+ elif scale_type == "single_amount":
190
+ bracket_desc = f"tier {bracket_num}"
191
+ else:
192
+ bracket_desc = f"bracket {bracket_num}"
193
+
194
+ return f"{scale_label} ({bracket_desc} {field_name})"
195
+
196
+
197
+ def build_scale_lookup(system):
198
+ """
199
+ Build a lookup dict mapping scale names to ParameterScale objects.
200
+
201
+ Args:
202
+ system: The tax-benefit system
203
+
204
+ Returns:
205
+ dict: Mapping of scale name -> ParameterScale object
206
+ """
207
+ from policyengine_core.parameters import ParameterScale
208
+
209
+ return {
210
+ p.name: p
211
+ for p in system.parameters.get_descendants()
212
+ if isinstance(p, ParameterScale)
213
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: policyengine
3
- Version: 3.1.15
3
+ Version: 3.1.16
4
4
  Summary: A package to conduct policy analysis using PolicyEngine tax-benefit models.
5
5
  Author-email: PolicyEngine <hello@policyengine.org>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -670,15 +670,15 @@ Description-Content-Type: text/markdown
670
670
  License-File: LICENSE
671
671
  Requires-Dist: pydantic>=2.0.0
672
672
  Requires-Dist: pandas>=2.0.0
673
- Requires-Dist: microdf_python
673
+ Requires-Dist: microdf_python>=1.2.1
674
674
  Requires-Dist: plotly>=5.0.0
675
675
  Requires-Dist: requests>=2.31.0
676
676
  Requires-Dist: psutil>=5.9.0
677
677
  Provides-Extra: uk
678
- Requires-Dist: policyengine_core>=3.10; extra == "uk"
678
+ Requires-Dist: policyengine_core>=3.23.6; extra == "uk"
679
679
  Requires-Dist: policyengine-uk>=2.51.0; extra == "uk"
680
680
  Provides-Extra: us
681
- Requires-Dist: policyengine_core>=3.10; extra == "us"
681
+ Requires-Dist: policyengine_core>=3.23.6; extra == "us"
682
682
  Requires-Dist: policyengine-us>=1.213.1; extra == "us"
683
683
  Provides-Extra: dev
684
684
  Requires-Dist: black; extra == "dev"
@@ -691,7 +691,7 @@ Requires-Dist: itables; extra == "dev"
691
691
  Requires-Dist: build; extra == "dev"
692
692
  Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
693
693
  Requires-Dist: ruff>=0.5.0; extra == "dev"
694
- Requires-Dist: policyengine_core>=3.10; extra == "dev"
694
+ Requires-Dist: policyengine_core>=3.23.6; extra == "dev"
695
695
  Requires-Dist: policyengine-uk>=2.51.0; extra == "dev"
696
696
  Requires-Dist: policyengine-us>=1.213.1; extra == "dev"
697
697
  Dynamic: license-file
@@ -1,5 +1,5 @@
1
1
  policyengine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- policyengine/__pycache__/__init__.cpython-313.pyc,sha256=MPybDS-iHQgTbwpR0cZQ_5__Xexio_9dqsdZDdfQBtQ,175
2
+ policyengine/__pycache__/__init__.cpython-313.pyc,sha256=pXlfNHK9qaW6hIe40yAYhaHxkBtl6c_uvAIzt-n6hzM,175
3
3
  policyengine/core/__init__.py,sha256=KBVhkqzkvjWLDDwk96vquQKL63ZFuLen5AzBOBnO9pg,912
4
4
  policyengine/core/cache.py,sha256=DcVVFaCt7k9PmqwlhXoNDMtJ8sF4neYP1uRqWik5QYg,1812
5
5
  policyengine/core/dataset.py,sha256=iJr9-J6w11uMRYy3EEJO9Gveku1m71AA1yzeo-0SiCs,16094
@@ -13,28 +13,31 @@ policyengine/core/simulation.py,sha256=h6QbFt3uEvyfRXRVbSFBlrOd6Ze03OeZkwX9oElmO
13
13
  policyengine/core/tax_benefit_model.py,sha256=2Yc1RlQrUG7djDMZbJOQH4Ns86_lOnLeISCGR4-9zMo,176
14
14
  policyengine/core/tax_benefit_model_version.py,sha256=iVzEKWzQxoPVicwxcqo9Fy8PfVX07faBvyL9NhVIjuU,3212
15
15
  policyengine/core/variable.py,sha256=AjSImORlRkh05xhYxyeT6GFMOfViRzYg0qRQAIj-mxo,350
16
- policyengine/outputs/__init__.py,sha256=IJUmLP0Og41VrwiqhJF-a9-3fIb4nlXpS7uFuVCINIs,515
16
+ policyengine/outputs/__init__.py,sha256=fcqkl1iK4lMpkdS0OBj3wWGAd1zZjc6IiJ-nrXy9VU8,1254
17
17
  policyengine/outputs/aggregate.py,sha256=exI-U04OF5kVf2BBYV6sf8VldIWnT_IzxgkBs5wtnCw,4846
18
18
  policyengine/outputs/change_aggregate.py,sha256=tK4K87YlByKikqFaB7OHyh1SqAuGtUnLL7cSF_EhrOs,7373
19
19
  policyengine/outputs/decile_impact.py,sha256=f8nR3pea8_qDuQ-M6kaKnVKxbGnfL0IzpRfFTdi7TqA,5522
20
+ policyengine/outputs/inequality.py,sha256=W_yc9Ibeavx7KA3reJTFArK3fR1kf_YFV0jAaC121w0,9356
21
+ policyengine/outputs/poverty.py,sha256=h8dHj-S8YeEQ6CXqmWje3gEz30H8jgE-gmXQ0NoJTUU,7866
20
22
  policyengine/tax_benefit_models/uk.py,sha256=HzAG_dORmsj1NJ9pd9WrqwgZPe9DUDrZ1wV5LuVCKAg,950
21
23
  policyengine/tax_benefit_models/us.py,sha256=G51dAmHo8NJLb2mnbne6iO5eNaatCGUd_2unvawwF84,946
22
24
  policyengine/tax_benefit_models/uk/__init__.py,sha256=StjVt4mV0n2QxlM_2oCp_OqHJu7eyWNbdPndezC7ve0,1294
23
- policyengine/tax_benefit_models/uk/analysis.py,sha256=iw34SERGdjCO4GSnimOZwXRnzSV7nutoilkVxHaI5WM,8627
25
+ policyengine/tax_benefit_models/uk/analysis.py,sha256=uPQt2EI2y6obibLfZfV-fHuN-FVzvUVUgeZY9kKSB5E,9527
24
26
  policyengine/tax_benefit_models/uk/datasets.py,sha256=N8pMrlhQFec_cbgvVf5HE2owU14VF1i8-ZUwZYBSeio,9043
25
- policyengine/tax_benefit_models/uk/model.py,sha256=woVnq5-HRt3EzRqvHr9TFMhWD06CHxc1H0zlo_LqEJ4,8796
27
+ policyengine/tax_benefit_models/uk/model.py,sha256=8byY9n8rEWA2DxG3uq7N3SogZWvl_o9JqKcL0xfQ6fk,8984
26
28
  policyengine/tax_benefit_models/uk/outputs.py,sha256=2mYLwQW4QNvrOHtHfm_ACqE9gbmuLxvcCyldRU46s0o,3543
27
29
  policyengine/tax_benefit_models/us/__init__.py,sha256=0RtqCl01j-Z_T4i9LITBSePegO97gZ4IIYqt-nsv2O0,1290
28
- policyengine/tax_benefit_models/us/analysis.py,sha256=y-M4QAUyp44-Y9fbkKQ6KMbS9qS9eHju3D5QG3iJHf8,9435
30
+ policyengine/tax_benefit_models/us/analysis.py,sha256=qJ9pZjyEY1xS7HCpT5-AETdPTtd-k7hWePxJz-NpXDE,10344
29
31
  policyengine/tax_benefit_models/us/datasets.py,sha256=OWqiYK8TWwdYP2qgUNIv6nIpqN5FVtyd8aYkVMUkAno,14757
30
- policyengine/tax_benefit_models/us/model.py,sha256=p5s8LHyGqkkVoJU1_7CGnwY8-KgXo_-3RSPuG9NO7Ww,15572
32
+ policyengine/tax_benefit_models/us/model.py,sha256=t8YPeiEzOskzSnEIwCSZFPvdxFubtLTAUPkTEcG_JN8,15945
31
33
  policyengine/tax_benefit_models/us/outputs.py,sha256=GT8Eur8DfB9cPQRbSljEl9RpKSNHW80Fq_CBXCybvIU,3519
32
- policyengine/utils/__init__.py,sha256=1X-VYAWLyB9A0YRHwsGWrqQHns1WfeZ7ISC6DMU5myM,140
34
+ policyengine/utils/__init__.py,sha256=qq9ElvVnZtmM0CAjbkJV_QFBHz3bAjOSCTJGqx29F0c,311
33
35
  policyengine/utils/dates.py,sha256=HnAqyl8S8EOYp8ibsnMTmECYoDWCSqwL-7A2_qKgxSc,1510
36
+ policyengine/utils/parameter_labels.py,sha256=_QTCTOjOdaW-pPVOsYMn7VyN-75QTD6IILfH-6oAd7U,6549
34
37
  policyengine/utils/parametric_reforms.py,sha256=4P3U39-4pYTU4BN6JjgmVLUkCkBhRfZJ6UIWTlsjyQE,1155
35
38
  policyengine/utils/plotting.py,sha256=ZAzTWz38vIaW0c3Nt4Un1kfrNoXLyHCDd1pEJIlsRg4,5335
36
- policyengine-3.1.15.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
37
- policyengine-3.1.15.dist-info/METADATA,sha256=FkFNAnnQvw5prYSAd-CZeNxAroQ7p1C7vF8Re1q2fwo,45919
38
- policyengine-3.1.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
39
- policyengine-3.1.15.dist-info/top_level.txt,sha256=_23UPobfkneHQkpJ0e0OmDJfhCUfoXj_F2sTckCGOH4,13
40
- policyengine-3.1.15.dist-info/RECORD,,
39
+ policyengine-3.1.16.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
40
+ policyengine-3.1.16.dist-info/METADATA,sha256=rmdahoK--THkMMXjckeYhFpagrIk6w_oANMFgTE9rts,45932
41
+ policyengine-3.1.16.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
42
+ policyengine-3.1.16.dist-info/top_level.txt,sha256=_23UPobfkneHQkpJ0e0OmDJfhCUfoXj_F2sTckCGOH4,13
43
+ policyengine-3.1.16.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5