policyengine-uk 2.45.4__py3-none-any.whl → 2.47.2__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.
Potentially problematic release.
This version of policyengine-uk might be problematic. Click here for more details.
- policyengine_uk/__init__.py +1 -0
- policyengine_uk/data/dataset_schema.py +6 -3
- policyengine_uk/data/economic_assumptions.py +1 -1
- policyengine_uk/dynamics/labour_supply.py +306 -0
- policyengine_uk/dynamics/participation.py +629 -0
- policyengine_uk/dynamics/progression.py +376 -0
- policyengine_uk/microsimulation.py +23 -1
- policyengine_uk/parameters/gov/dynamic/obr_labour_supply_assumptions.yaml +9 -0
- policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +270 -32
- policyengine_uk/simulation.py +184 -9
- policyengine_uk/tax_benefit_system.py +4 -1
- policyengine_uk/tests/microsimulation/reforms_config.yaml +7 -7
- policyengine_uk/tests/microsimulation/test_validity.py +2 -3
- policyengine_uk/tests/microsimulation/update_reform_impacts.py +104 -40
- policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml +8 -8
- policyengine_uk/utils/__init__.py +1 -0
- policyengine_uk/utils/compare.py +28 -0
- policyengine_uk/utils/solve_private_school_attendance_factor.py +4 -6
- policyengine_uk/variables/gov/dwp/additional_state_pension.py +1 -1
- policyengine_uk/variables/gov/dwp/basic_state_pension.py +1 -1
- policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py +11 -0
- policyengine_uk/variables/input/consumption/property/council_tax.py +0 -35
- policyengine_uk/variables/input/rent.py +0 -40
- {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/METADATA +5 -4
- {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/RECORD +27 -21
- {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/WHEEL +0 -0
- {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/licenses/LICENSE +0 -0
policyengine_uk/__init__.py
CHANGED
|
@@ -120,9 +120,12 @@ class UKSingleYearDataset:
|
|
|
120
120
|
if simulation.tax_benefit_system.variables[variable].entity.key
|
|
121
121
|
== entity
|
|
122
122
|
]
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
if len(input_variables) == 0:
|
|
124
|
+
entity_dfs[entity] = pd.DataFrame()
|
|
125
|
+
else:
|
|
126
|
+
entity_dfs[entity] = simulation.calculate_dataframe(
|
|
127
|
+
input_variables, period=fiscal_year
|
|
128
|
+
)
|
|
126
129
|
|
|
127
130
|
return UKSingleYearDataset(
|
|
128
131
|
person=entity_dfs["person"],
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Labour supply dynamics module for PolicyEngine UK.
|
|
2
|
+
|
|
3
|
+
This module coordinates labour supply responses by combining progression (intensive margin)
|
|
4
|
+
and participation (extensive margin) models. It implements the methodology from the OBR's
|
|
5
|
+
labour supply elasticity framework to estimate how changes in tax and benefit policies
|
|
6
|
+
affect employment and working hours.
|
|
7
|
+
|
|
8
|
+
Reference: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from policyengine_uk import Simulation
|
|
14
|
+
from microdf import MicroDataFrame
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .progression import (
|
|
19
|
+
calculate_derivative_change,
|
|
20
|
+
calculate_relative_income_change,
|
|
21
|
+
calculate_labour_substitution_elasticities,
|
|
22
|
+
calculate_labour_net_income_elasticities,
|
|
23
|
+
calculate_employment_income_change,
|
|
24
|
+
)
|
|
25
|
+
from .participation import (
|
|
26
|
+
apply_participation_responses,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def calculate_excluded_from_labour_supply_responses(
|
|
31
|
+
sim: Simulation, count_adults: int = 1
|
|
32
|
+
):
|
|
33
|
+
"""Calculate which individuals are excluded from labour supply responses.
|
|
34
|
+
|
|
35
|
+
Excludes self-employed, full-time students, aged 60+, and individuals
|
|
36
|
+
outside the specified adult range.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
sim: PolicyEngine simulation object
|
|
40
|
+
count_adults: Number of adults to include in calculations
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Boolean array indicating which individuals are excluded
|
|
44
|
+
"""
|
|
45
|
+
# Exclude self-employed, full-time students, aged 60+, and adult_index == (0, >= count_adults + 1)
|
|
46
|
+
employment_status = sim.calculate("employment_status")
|
|
47
|
+
self_employed = np.isin(
|
|
48
|
+
employment_status, ["FT_SELF_EMPLOYED", "PT_SELF_EMPLOYED"]
|
|
49
|
+
)
|
|
50
|
+
student = employment_status == "STUDENT"
|
|
51
|
+
age = sim.calculate("age")
|
|
52
|
+
age_60_plus = age >= 60
|
|
53
|
+
adult_index = sim.calculate("adult_index")
|
|
54
|
+
excluded = (
|
|
55
|
+
self_employed
|
|
56
|
+
| student
|
|
57
|
+
| age_60_plus
|
|
58
|
+
| (adult_index == 0)
|
|
59
|
+
| (adult_index >= count_adults + 1)
|
|
60
|
+
)
|
|
61
|
+
return excluded
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FTEImpacts(BaseModel):
|
|
65
|
+
"""Data model for FTE impacts of labour supply responses."""
|
|
66
|
+
|
|
67
|
+
substitution_response_ftes: float
|
|
68
|
+
"""FTE impact from substitution effects."""
|
|
69
|
+
income_response_ftes: float
|
|
70
|
+
"""FTE impact from income effects."""
|
|
71
|
+
total_response_ftes: float
|
|
72
|
+
"""Total FTE impact from both substitution and income effects."""
|
|
73
|
+
|
|
74
|
+
participation_response_employment: Optional[float] = None
|
|
75
|
+
"""FTE impact from participation responses, if available."""
|
|
76
|
+
participation_response_ftes: Optional[float] = None
|
|
77
|
+
"""FTE impact from participation responses, if available."""
|
|
78
|
+
|
|
79
|
+
ftes: Optional[float] = None
|
|
80
|
+
"""Total FTE impact across all responses."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LabourSupplyResponseData(BaseModel):
|
|
84
|
+
progression: pd.DataFrame
|
|
85
|
+
"""DataFrame containing intensive margin (progression) responses."""
|
|
86
|
+
participation: Optional[pd.DataFrame] = None
|
|
87
|
+
"""DataFrame containing extensive margin (participation) responses, if available."""
|
|
88
|
+
|
|
89
|
+
# Specific outputs for comparison with OBR outputs
|
|
90
|
+
fte_impacts: FTEImpacts
|
|
91
|
+
"""FTE impacts of labour supply responses, including both progression and participation."""
|
|
92
|
+
|
|
93
|
+
model_config = {
|
|
94
|
+
"arbitrary_types_allowed": True,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apply_labour_supply_responses(
|
|
99
|
+
sim: Simulation,
|
|
100
|
+
target_variable: str = "household_net_income",
|
|
101
|
+
input_variable: str = "employment_income",
|
|
102
|
+
year: int = 2025,
|
|
103
|
+
count_adults: int = 1,
|
|
104
|
+
delta: float = 1_000,
|
|
105
|
+
) -> pd.DataFrame:
|
|
106
|
+
"""Apply labour supply responses to simulation and return the response vector.
|
|
107
|
+
|
|
108
|
+
This is the main function for applying dynamic labour supply responses to
|
|
109
|
+
a PolicyEngine simulation. It coordinates both intensive margin (progression)
|
|
110
|
+
and extensive margin (participation) responses to policy changes.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
sim: PolicyEngine simulation object (should have baseline attribute)
|
|
114
|
+
target_variable: Variable that drives labour supply decisions
|
|
115
|
+
input_variable: Variable representing labour supply (typically employment_income)
|
|
116
|
+
year: Year for calculation
|
|
117
|
+
count_adults: Number of adults to calculate responses for
|
|
118
|
+
delta: Size of change for marginal rate calculation (£)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
DataFrame with labour supply response information
|
|
122
|
+
"""
|
|
123
|
+
follow_obr = sim.tax_benefit_system.parameters.gov.dynamic.obr_labour_supply_assumptions(
|
|
124
|
+
year
|
|
125
|
+
)
|
|
126
|
+
if (not follow_obr) or (sim.baseline is None):
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Apply intensive margin responses (progression model)
|
|
130
|
+
progression_responses = apply_progression_responses(
|
|
131
|
+
sim=sim,
|
|
132
|
+
target_variable=target_variable,
|
|
133
|
+
input_variable=input_variable,
|
|
134
|
+
year=year,
|
|
135
|
+
count_adults=count_adults,
|
|
136
|
+
delta=delta,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Apply extensive margin responses (participation model)
|
|
140
|
+
participation_responses = apply_participation_responses(sim=sim, year=year)
|
|
141
|
+
|
|
142
|
+
# Add FTE impacts to the response data
|
|
143
|
+
fte_impacts = FTEImpacts(
|
|
144
|
+
substitution_response_ftes=progression_responses[
|
|
145
|
+
"substitution_response_ftes"
|
|
146
|
+
].sum(),
|
|
147
|
+
income_response_ftes=progression_responses[
|
|
148
|
+
"income_response_ftes"
|
|
149
|
+
].sum(),
|
|
150
|
+
total_response_ftes=progression_responses["total_response_ftes"].sum(),
|
|
151
|
+
participation_response_employment=(
|
|
152
|
+
participation_responses["participation_change"].sum()
|
|
153
|
+
if participation_responses is not None
|
|
154
|
+
else None
|
|
155
|
+
),
|
|
156
|
+
participation_response_ftes=(
|
|
157
|
+
participation_responses["participation_change_ftes"].sum()
|
|
158
|
+
if participation_responses is not None
|
|
159
|
+
else None
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
fte_impacts.ftes = fte_impacts.total_response_ftes + (
|
|
164
|
+
fte_impacts.participation_response_ftes
|
|
165
|
+
if fte_impacts.participation_response_ftes is not None
|
|
166
|
+
else 0
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# For now, return only progression responses since participation is placeholder
|
|
170
|
+
# TODO: Combine progression and participation responses when participation model is implemented
|
|
171
|
+
return LabourSupplyResponseData(
|
|
172
|
+
progression=progression_responses,
|
|
173
|
+
participation=participation_responses,
|
|
174
|
+
fte_impacts=fte_impacts,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def apply_progression_responses(
|
|
179
|
+
sim: Simulation,
|
|
180
|
+
target_variable: str = "household_net_income",
|
|
181
|
+
input_variable: str = "employment_income",
|
|
182
|
+
year: int = 2025,
|
|
183
|
+
count_adults: int = 1,
|
|
184
|
+
delta: float = 1_000,
|
|
185
|
+
) -> pd.DataFrame:
|
|
186
|
+
"""Apply progression (intensive margin) labour supply responses.
|
|
187
|
+
|
|
188
|
+
This function handles the intensive margin of labour supply by calculating
|
|
189
|
+
how individuals adjust their working hours in response to policy changes.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
sim: PolicyEngine simulation object (should have baseline attribute)
|
|
193
|
+
target_variable: Variable that drives labour supply decisions
|
|
194
|
+
input_variable: Variable representing labour supply (typically employment_income)
|
|
195
|
+
year: Year for calculation
|
|
196
|
+
count_adults: Number of adults to calculate responses for
|
|
197
|
+
delta: Size of change for marginal rate calculation (£)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
DataFrame with progression response information
|
|
201
|
+
"""
|
|
202
|
+
# Calculate changes in marginal rates (drives substitution effects)
|
|
203
|
+
derivative_changes = calculate_derivative_change(
|
|
204
|
+
sim=sim,
|
|
205
|
+
target_variable=target_variable,
|
|
206
|
+
input_variable=input_variable,
|
|
207
|
+
year=year,
|
|
208
|
+
count_adults=count_adults,
|
|
209
|
+
delta=delta,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
derivative_changes = derivative_changes.rename(
|
|
213
|
+
columns={col: f"deriv_{col}" for col in derivative_changes.columns}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Add in actual implied wages
|
|
217
|
+
gross_wage = sim.calculate("employment_income", year) / sim.calculate(
|
|
218
|
+
"hours_worked", year
|
|
219
|
+
)
|
|
220
|
+
gross_wage = gross_wage.fillna(0).replace([np.inf, -np.inf], 0)
|
|
221
|
+
derivative_changes["wage_gross"] = gross_wage
|
|
222
|
+
derivative_changes["wage_baseline"] = (
|
|
223
|
+
gross_wage * derivative_changes["deriv_baseline"]
|
|
224
|
+
)
|
|
225
|
+
derivative_changes["wage_scenario"] = (
|
|
226
|
+
gross_wage * derivative_changes["deriv_scenario"]
|
|
227
|
+
)
|
|
228
|
+
derivative_changes["wage_rel_change"] = (
|
|
229
|
+
derivative_changes["wage_scenario"]
|
|
230
|
+
/ derivative_changes["wage_baseline"]
|
|
231
|
+
- 1
|
|
232
|
+
).replace([np.inf, -np.inf], 0)
|
|
233
|
+
derivative_changes["wage_abs_change"] = (
|
|
234
|
+
derivative_changes["wage_scenario"]
|
|
235
|
+
- derivative_changes["wage_baseline"]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Calculate changes in income levels (drives income effects)
|
|
239
|
+
income_changes = calculate_relative_income_change(
|
|
240
|
+
sim, target_variable, year
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
income_changes = income_changes.rename(
|
|
244
|
+
columns={col: f"income_{col}" for col in income_changes.columns}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
df = pd.concat([derivative_changes, income_changes], axis=1).fillna(0)
|
|
248
|
+
|
|
249
|
+
# Get elasticity parameters by demographic group
|
|
250
|
+
substitution_elasticities = calculate_labour_substitution_elasticities(sim)
|
|
251
|
+
income_elasticities = calculate_labour_net_income_elasticities(sim)
|
|
252
|
+
|
|
253
|
+
df["income_elasticity"] = income_elasticities
|
|
254
|
+
df["substitution_elasticity"] = substitution_elasticities
|
|
255
|
+
|
|
256
|
+
# Get baseline employment income levels
|
|
257
|
+
employment_income = sim.calculate(input_variable, year)
|
|
258
|
+
|
|
259
|
+
df["employment_income"] = employment_income
|
|
260
|
+
df["hours_per_week"] = sim.calculate("hours_worked", year) / 52
|
|
261
|
+
|
|
262
|
+
# Calculate total labour supply response
|
|
263
|
+
response_df = calculate_employment_income_change(
|
|
264
|
+
employment_income=employment_income,
|
|
265
|
+
derivative_changes=derivative_changes,
|
|
266
|
+
income_changes=income_changes,
|
|
267
|
+
substitution_elasticities=substitution_elasticities,
|
|
268
|
+
income_elasticities=income_elasticities,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
df = pd.concat([df, response_df], axis=1)
|
|
272
|
+
|
|
273
|
+
# Apply relative {substitution, income, total} changes to hours as well
|
|
274
|
+
# Apply relative changes to hours using the same factor for all response types
|
|
275
|
+
for response_type in [
|
|
276
|
+
"substitution_response",
|
|
277
|
+
"income_response",
|
|
278
|
+
"total_response",
|
|
279
|
+
]:
|
|
280
|
+
df[f"{response_type}_ftes"] = (
|
|
281
|
+
df[response_type]
|
|
282
|
+
/ df["employment_income"]
|
|
283
|
+
* df["hours_per_week"]
|
|
284
|
+
/ 37.5
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
excluded = calculate_excluded_from_labour_supply_responses(
|
|
288
|
+
sim, count_adults=count_adults
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
for col in df.columns:
|
|
292
|
+
df.loc[excluded, col] = 0
|
|
293
|
+
|
|
294
|
+
df["excluded"] = excluded
|
|
295
|
+
|
|
296
|
+
response = response_df["total_response"].values
|
|
297
|
+
|
|
298
|
+
# Apply the labour supply response to the simulation
|
|
299
|
+
sim.reset_calculations()
|
|
300
|
+
sim.set_input(input_variable, year, employment_income + response)
|
|
301
|
+
|
|
302
|
+
weight = sim.calculate("household_weight", year, map_to="person")
|
|
303
|
+
|
|
304
|
+
result = MicroDataFrame(df, weights=weight)
|
|
305
|
+
|
|
306
|
+
return result[~result.excluded].drop(columns=["excluded"])
|