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.

Files changed (27) hide show
  1. policyengine_uk/__init__.py +1 -0
  2. policyengine_uk/data/dataset_schema.py +6 -3
  3. policyengine_uk/data/economic_assumptions.py +1 -1
  4. policyengine_uk/dynamics/labour_supply.py +306 -0
  5. policyengine_uk/dynamics/participation.py +629 -0
  6. policyengine_uk/dynamics/progression.py +376 -0
  7. policyengine_uk/microsimulation.py +23 -1
  8. policyengine_uk/parameters/gov/dynamic/obr_labour_supply_assumptions.yaml +9 -0
  9. policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +270 -32
  10. policyengine_uk/simulation.py +184 -9
  11. policyengine_uk/tax_benefit_system.py +4 -1
  12. policyengine_uk/tests/microsimulation/reforms_config.yaml +7 -7
  13. policyengine_uk/tests/microsimulation/test_validity.py +2 -3
  14. policyengine_uk/tests/microsimulation/update_reform_impacts.py +104 -40
  15. policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml +8 -8
  16. policyengine_uk/utils/__init__.py +1 -0
  17. policyengine_uk/utils/compare.py +28 -0
  18. policyengine_uk/utils/solve_private_school_attendance_factor.py +4 -6
  19. policyengine_uk/variables/gov/dwp/additional_state_pension.py +1 -1
  20. policyengine_uk/variables/gov/dwp/basic_state_pension.py +1 -1
  21. policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py +11 -0
  22. policyengine_uk/variables/input/consumption/property/council_tax.py +0 -35
  23. policyengine_uk/variables/input/rent.py +0 -40
  24. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/METADATA +5 -4
  25. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/RECORD +27 -21
  26. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/WHEEL +0 -0
  27. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.2.dist-info}/licenses/LICENSE +0 -0
@@ -12,6 +12,7 @@ from policyengine_uk.system import (
12
12
  )
13
13
  from pathlib import Path
14
14
  import os
15
+ from .model_api import *
15
16
  from policyengine_core.taxbenefitsystems import TaxBenefitSystem
16
17
 
17
18
  REPO = Path(__file__).parent
@@ -120,9 +120,12 @@ class UKSingleYearDataset:
120
120
  if simulation.tax_benefit_system.variables[variable].entity.key
121
121
  == entity
122
122
  ]
123
- entity_dfs[entity] = simulation.calculate_dataframe(
124
- input_variables, period=fiscal_year
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"],
@@ -9,7 +9,7 @@ import logging
9
9
 
10
10
  def extend_single_year_dataset(
11
11
  dataset: UKSingleYearDataset,
12
- end_year: int = 2029,
12
+ end_year: int = 2030,
13
13
  ) -> UKMultiYearDataset:
14
14
  # Extend years and uprate
15
15
  start_year = int(dataset.time_period)
@@ -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"])