policyengine-uk 2.45.4__py3-none-any.whl → 2.47.3__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 (29) 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/scenario.py +37 -2
  19. policyengine_uk/utils/solve_private_school_attendance_factor.py +4 -6
  20. policyengine_uk/variables/gov/dwp/additional_state_pension.py +1 -1
  21. policyengine_uk/variables/gov/dwp/basic_state_pension.py +1 -1
  22. policyengine_uk/variables/gov/gov_tax.py +0 -1
  23. policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py +11 -0
  24. policyengine_uk/variables/input/consumption/property/council_tax.py +0 -35
  25. policyengine_uk/variables/input/rent.py +0 -40
  26. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/METADATA +5 -4
  27. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/RECORD +29 -23
  28. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/WHEEL +0 -0
  29. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,376 @@
1
+ """Labour supply progression (intensive margin) dynamics module.
2
+
3
+ This module handles the intensive margin of labour supply - how people adjust their
4
+ working hours in response to policy changes. It implements the elasticity-based
5
+ methodology from the OBR's labour supply framework.
6
+
7
+ Reference: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf
8
+ """
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from policyengine_uk import Simulation
13
+
14
+
15
+ def calculate_derivative(
16
+ sim: Simulation,
17
+ target_variable: str = "household_net_income",
18
+ input_variable: str = "employment_income",
19
+ year: int = 2025,
20
+ count_adults: int = 2,
21
+ delta: float = 1_000,
22
+ ) -> np.ndarray:
23
+ """Calculate the marginal rate of change of target variable with respect to input variable.
24
+
25
+ This function computes numerical derivatives by applying small changes to the input
26
+ variable and measuring the resulting change in the target variable. This is used
27
+ to estimate marginal tax rates and benefit withdrawal rates.
28
+
29
+ Args:
30
+ sim: PolicyEngine simulation object
31
+ target_variable: Variable to measure changes in (typically household_net_income)
32
+ input_variable: Variable to change (typically employment_income)
33
+ year: Year for calculation
34
+ count_adults: Number of adults to apply changes to
35
+ delta: Size of change to apply for derivative calculation (£)
36
+
37
+ Returns:
38
+ Array of marginal rates clipped between 0 and 1
39
+ """
40
+ # Get baseline values for input variable and identify adults
41
+ input_variable_values = sim.calculate(input_variable, year)
42
+ adult_index = sim.calculate("adult_index")
43
+ entity_key = sim.tax_benefit_system.variables[input_variable].entity.key
44
+
45
+ # Calculate baseline target values
46
+ original_target_values = sim.calculate(
47
+ target_variable, year, map_to=entity_key
48
+ )
49
+ new_target_values = original_target_values.copy()
50
+
51
+ # Apply delta change to each adult sequentially to calculate marginal effects
52
+ for i in range(count_adults):
53
+ gets_pay_rise = adult_index == i + 1
54
+ new_input_variable_values = input_variable_values.copy()
55
+ new_input_variable_values[gets_pay_rise] += delta
56
+ sim.reset_calculations()
57
+ sim.set_input(input_variable, year, new_input_variable_values)
58
+ new_target_values[gets_pay_rise] = sim.calculate(
59
+ target_variable, year, map_to=entity_key
60
+ )[gets_pay_rise]
61
+
62
+ # Calculate marginal rate as change in target per unit change in input
63
+ rel_marginal_wages = (new_target_values - original_target_values) / delta
64
+
65
+ # Set non-adult observations to NaN
66
+ rel_marginal_wages[
67
+ ~pd.Series(adult_index).isin(range(1, count_adults + 1))
68
+ ] = np.nan
69
+
70
+ # Clip to ensure rates are between 0 and 1 (0% to 100% retention)
71
+ return rel_marginal_wages.clip(0, 1)
72
+
73
+
74
+ def calculate_relative_income_change(
75
+ sim: Simulation,
76
+ target_variable: str = "household_net_income",
77
+ year: int = 2025,
78
+ ) -> pd.DataFrame:
79
+ """Calculate relative change in income between baseline and scenario.
80
+
81
+ This function compares the target variable values between the baseline
82
+ simulation and the reform scenario to measure the income effect of the policy.
83
+
84
+ Args:
85
+ sim: PolicyEngine simulation object (should have baseline attribute)
86
+ target_variable: Variable to measure changes in
87
+ year: Year for calculation
88
+
89
+ Returns:
90
+ DataFrame with baseline, scenario, relative change, and absolute change columns
91
+ """
92
+ # Get income values from baseline and reform scenarios
93
+ original_target_values = sim.baseline.calculate(
94
+ target_variable, year, map_to="person"
95
+ )
96
+ reformed_target_values = sim.calculate(
97
+ target_variable, year, map_to="person"
98
+ )
99
+
100
+ # Calculate relative change, handling division by zero
101
+ rel_change = (
102
+ reformed_target_values - original_target_values
103
+ ) / original_target_values
104
+ rel_change[original_target_values == 0] = np.nan
105
+
106
+ # Clip extreme values and fill NaN with 0
107
+ rel_changes = rel_change.clip(-1, 1).fillna(0)
108
+
109
+ return pd.DataFrame(
110
+ {
111
+ "baseline": original_target_values,
112
+ "scenario": reformed_target_values,
113
+ "rel_change": rel_changes,
114
+ "abs_change": reformed_target_values - original_target_values,
115
+ }
116
+ )
117
+
118
+
119
+ def calculate_derivative_change(
120
+ sim: Simulation,
121
+ target_variable: str = "household_net_income",
122
+ input_variable: str = "employment_income",
123
+ year: int = 2025,
124
+ count_adults: int = 1,
125
+ delta: float = 1_000,
126
+ ) -> pd.DataFrame:
127
+ """Calculate change in marginal rates between baseline and scenario.
128
+
129
+ This function computes how marginal tax rates or benefit withdrawal rates
130
+ change as a result of the policy reform, which drives substitution effects
131
+ in labour supply responses.
132
+
133
+ Args:
134
+ sim: PolicyEngine simulation object (should have baseline attribute)
135
+ target_variable: Variable to measure marginal rates for
136
+ input_variable: Variable to change for derivative calculation
137
+ year: Year for calculation
138
+ count_adults: Number of adults to calculate derivatives for
139
+ delta: Size of change for derivative calculation (£)
140
+
141
+ Returns:
142
+ DataFrame with baseline, scenario, relative change, and absolute change in derivatives
143
+ """
144
+ # Calculate marginal rates under baseline and reform scenarios
145
+ original_deriv = calculate_derivative(
146
+ sim=sim.baseline,
147
+ target_variable=target_variable,
148
+ input_variable=input_variable,
149
+ year=year,
150
+ count_adults=count_adults,
151
+ delta=delta,
152
+ )
153
+
154
+ reformed_deriv = calculate_derivative(
155
+ sim=sim,
156
+ target_variable=target_variable,
157
+ input_variable=input_variable,
158
+ year=year,
159
+ count_adults=count_adults,
160
+ delta=delta,
161
+ )
162
+
163
+ # Calculate relative and absolute changes in marginal rates
164
+ rel_change = reformed_deriv / original_deriv - 1
165
+ abs_change = reformed_deriv - original_deriv
166
+
167
+ # Clip extreme relative changes to avoid misleading results from small baseline derivatives
168
+ rel_change = rel_change.clip(-1, 1)
169
+
170
+ rel_change[rel_change == np.inf] = 0
171
+
172
+ return pd.DataFrame(
173
+ {
174
+ "baseline": original_deriv,
175
+ "scenario": reformed_deriv,
176
+ "rel_change": rel_change,
177
+ "abs_change": abs_change,
178
+ }
179
+ ).fillna(0)
180
+
181
+
182
+ def calculate_labour_substitution_elasticities(
183
+ sim: Simulation,
184
+ ) -> np.ndarray:
185
+ """Calculate labour supply substitution elasticities by demographic group.
186
+
187
+ Uses OBR elasticity estimates to assign substitution elasticities based on
188
+ gender, marital status, and presence/age of children. These elasticities
189
+ measure how labour supply responds to changes in marginal tax rates.
190
+
191
+ Reference: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf
192
+
193
+ Args:
194
+ sim: PolicyEngine simulation object
195
+
196
+ Returns:
197
+ Array of substitution elasticities for each person
198
+ """
199
+ # Get demographic characteristics for elasticity assignment
200
+ gender = sim.calculate("gender")
201
+ is_married = sim.calculate("is_married", map_to="person")
202
+ has_children = sim.calculate("benunit_count_children", map_to="person") > 0
203
+ youngest_child_age = sim.calculate("youngest_child_age", map_to="person")
204
+
205
+ # Initialize elasticity array
206
+ elasticities = np.zeros(gender.shape, dtype=float)
207
+
208
+ # Married or cohabiting women - higher elasticities, especially with young children
209
+ married_women = (gender == "FEMALE") & is_married
210
+ elasticities[married_women & ~has_children] = 0.14 # No children
211
+
212
+ # Elasticities vary significantly by youngest child's age
213
+ elasticities[married_women & has_children & (youngest_child_age <= 2)] = (
214
+ 0.301 # 0-2 years
215
+ )
216
+ elasticities[
217
+ married_women
218
+ & has_children
219
+ & (youngest_child_age >= 3)
220
+ & (youngest_child_age <= 4)
221
+ ] = 0.439 # 3-4 years (highest)
222
+ elasticities[
223
+ married_women
224
+ & has_children
225
+ & (youngest_child_age >= 5)
226
+ & (youngest_child_age <= 10)
227
+ ] = 0.173 # 5-10 years
228
+ elasticities[married_women & has_children & (youngest_child_age >= 11)] = (
229
+ 0.160 # 11+ years
230
+ )
231
+
232
+ # Lone parents - lower elasticities than married women, reflecting different constraints
233
+ lone_parents = (gender == "FEMALE") & ~is_married & has_children
234
+ elasticities[lone_parents & (youngest_child_age <= 4)] = 0.094 # 0-4 years
235
+ elasticities[
236
+ lone_parents & (youngest_child_age >= 5) & (youngest_child_age <= 10)
237
+ ] = 0.128 # 5-10 years
238
+ elasticities[
239
+ lone_parents & (youngest_child_age >= 11) & (youngest_child_age <= 18)
240
+ ] = 0.136 # 11-18 years
241
+
242
+ # Men (excluding lone fathers) - moderate, consistent elasticity
243
+ elasticities[(gender == "MALE") & ~(~is_married & has_children)] = 0.15
244
+
245
+ # Single women without children - same as men
246
+ elasticities[(gender == "FEMALE") & ~is_married & ~has_children] = 0.15
247
+
248
+ return elasticities
249
+
250
+
251
+ def calculate_labour_net_income_elasticities(
252
+ sim: Simulation,
253
+ ) -> np.ndarray:
254
+ """Calculate labour supply income elasticities by demographic group.
255
+
256
+ Uses OBR elasticity estimates to assign income elasticities based on
257
+ gender, marital status, and presence/age of children. These elasticities
258
+ measure how labour supply responds to changes in unearned income.
259
+
260
+ Reference: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf
261
+ Table A2 - Income elasticities
262
+
263
+ Args:
264
+ sim: PolicyEngine simulation object
265
+
266
+ Returns:
267
+ Array of income elasticities for each person (typically negative)
268
+ """
269
+ # Get demographic characteristics for elasticity assignment
270
+ gender = sim.calculate("gender")
271
+ is_married = sim.calculate("is_married", map_to="person")
272
+ has_children = sim.calculate("benunit_count_children", map_to="person") > 0
273
+ youngest_child_age = sim.calculate("youngest_child_age", map_to="person")
274
+
275
+ # Initialize elasticity array
276
+ elasticities = np.zeros(gender.shape, dtype=float)
277
+
278
+ # Married or cohabiting women - negative income elasticities (normal good)
279
+ married_women = (gender == "FEMALE") & is_married
280
+ elasticities[married_women & ~has_children] = (
281
+ 0.0 # No income effect without children
282
+ )
283
+
284
+ # Stronger negative income effects with younger children
285
+ elasticities[married_women & has_children & (youngest_child_age <= 2)] = (
286
+ -0.185
287
+ ) # 0-2 years
288
+ elasticities[
289
+ married_women
290
+ & has_children
291
+ & (youngest_child_age >= 3)
292
+ & (youngest_child_age <= 4)
293
+ ] = -0.173 # 3-4 years
294
+ elasticities[
295
+ married_women
296
+ & has_children
297
+ & (youngest_child_age >= 5)
298
+ & (youngest_child_age <= 10)
299
+ ] = -0.102 # 5-10 years
300
+ elasticities[married_women & has_children & (youngest_child_age >= 11)] = (
301
+ -0.063
302
+ ) # 11+ years
303
+
304
+ # Lone parents - smaller negative income effects than married women
305
+ lone_parents = (gender == "FEMALE") & ~is_married & has_children
306
+ elasticities[lone_parents & (youngest_child_age <= 4)] = (
307
+ -0.037
308
+ ) # 0-4 years
309
+ elasticities[
310
+ lone_parents & (youngest_child_age >= 5) & (youngest_child_age <= 10)
311
+ ] = -0.075 # 5-10 years
312
+ elasticities[
313
+ lone_parents & (youngest_child_age >= 11) & (youngest_child_age <= 18)
314
+ ] = -0.054 # 11-18 years
315
+
316
+ # Men (excluding lone fathers) - small negative income effect
317
+ elasticities[(gender == "MALE") & ~(~is_married & has_children)] = -0.05
318
+
319
+ # Single women without children - same as men
320
+ elasticities[(gender == "FEMALE") & ~is_married & ~has_children] = -0.05
321
+
322
+ return elasticities
323
+
324
+
325
+ def calculate_employment_income_change(
326
+ employment_income: np.ndarray,
327
+ derivative_changes: pd.DataFrame,
328
+ income_changes: pd.DataFrame,
329
+ substitution_elasticities: np.ndarray,
330
+ income_elasticities: np.ndarray,
331
+ ) -> np.ndarray:
332
+ """Calculate total labour supply response combining substitution and income effects.
333
+
334
+ This function implements the Slutsky equation decomposition of labour supply
335
+ responses into substitution and income effects. The total response is the
336
+ sum of these two components.
337
+
338
+ Args:
339
+ employment_income: Baseline employment income levels
340
+ derivative_changes: Changes in marginal rates (substitution effect driver)
341
+ income_changes: Changes in income levels (income effect driver)
342
+ substitution_elasticities: Elasticities for substitution effects
343
+ income_elasticities: Elasticities for income effects
344
+
345
+ Returns:
346
+ Array of employment income changes due to labour supply responses
347
+ """
348
+ # Calculate substitution effect: response to changes in marginal rates
349
+ substitution_response = (
350
+ employment_income
351
+ * derivative_changes["wage_rel_change"]
352
+ * substitution_elasticities
353
+ )
354
+
355
+ # Calculate income effect: response to changes in unearned income
356
+ income_response = (
357
+ employment_income
358
+ * income_changes["income_rel_change"]
359
+ * income_elasticities
360
+ )
361
+
362
+ # Total labour supply response is sum of substitution and income effects
363
+ total_response = substitution_response + income_response
364
+
365
+ # No response for people with zero employment income
366
+ total_response[employment_income == 0] = 0
367
+
368
+ df = pd.DataFrame(
369
+ {
370
+ "substitution_response": substitution_response,
371
+ "income_response": income_response,
372
+ "total_response": total_response,
373
+ }
374
+ )
375
+
376
+ return df.fillna(0)
@@ -47,7 +47,6 @@ class Microsimulation(Simulation):
47
47
  unweighted: bool = False,
48
48
  ):
49
49
  tracer: SimpleTracer = self.tracer
50
-
51
50
  result = super().calculate(
52
51
  variable_name, period, map_to=map_to, decode_enums=decode_enums
53
52
  )
@@ -81,3 +80,26 @@ class Microsimulation(Simulation):
81
80
  return values
82
81
  weights = self.get_weights(variable_names[0], period, map_to=map_to)
83
82
  return MicroDataFrame(values, weights=weights)
83
+
84
+ def compare(
85
+ self,
86
+ other: "Simulation",
87
+ variables: list[str] = None,
88
+ period: str = None,
89
+ change_only: bool = False,
90
+ ):
91
+ """Compare two simulations for a specific variable list.
92
+
93
+ Args:
94
+ other: Another Simulation instance to compare against
95
+ variables: List of variable names to compare. If None, compares all variables.
96
+
97
+ Returns:
98
+ DataFrame with comparison results
99
+ """
100
+ df = super().compare(
101
+ other, variables=variables, period=period, change_only=change_only
102
+ )
103
+ return MicroDataFrame(
104
+ df, weights=self.get_weights(variables[0], period)
105
+ )
@@ -0,0 +1,9 @@
1
+ description: Follow OBR labour supply response assumptions to tax-benefit reforms.
2
+ values:
3
+ 0001-01-01: true
4
+ metadata:
5
+ unit: bool
6
+ label: Follow OBR labour supply response assumptions
7
+ reference:
8
+ - title: OBR assumptions
9
+ href: https://obr.uk/docs/dlm_uploads/NICS-Cut-Impact-on-Labour-Supply-Note.pdf