Skip to content

Commit 8785dc7

Browse files
authored
Merge pull request #1565 from PolicyEngine/codex/fix-1135
Clip negative earnings in labor supply responses
2 parents 9093cc7 + 2e76c02 commit 8785dc7

5 files changed

Lines changed: 91 additions & 6 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Prevent labor supply response formulas and progression dynamics from
2+
flipping sign when baseline employment income is negative.

policyengine_uk/dynamics/progression.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,23 +347,25 @@ def calculate_employment_income_change(
347347
Returns:
348348
Array of employment income changes due to labour supply responses
349349
"""
350+
earnings_base = np.maximum(employment_income, 0)
351+
350352
# Calculate substitution effect: response to changes in marginal rates
351353
substitution_response = (
352-
employment_income
354+
earnings_base
353355
* derivative_changes["wage_rel_change"]
354356
* substitution_elasticities
355357
)
356358

357359
# Calculate income effect: response to changes in unearned income
358360
income_response = (
359-
employment_income * income_changes["income_rel_change"] * income_elasticities
361+
earnings_base * income_changes["income_rel_change"] * income_elasticities
360362
)
361363

362364
# Total labour supply response is sum of substitution and income effects
363365
total_response = substitution_response + income_response
364366

365-
# No response for people with zero employment income
366-
total_response[employment_income == 0] = 0
367+
# No response for people with non-positive employment income
368+
total_response[earnings_base == 0] = 0
367369

368370
df = pd.DataFrame(
369371
{
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from types import SimpleNamespace
2+
3+
import numpy as np
4+
import pandas as pd
5+
6+
from policyengine_uk.dynamics.progression import (
7+
calculate_employment_income_change,
8+
)
9+
from policyengine_uk.variables.gov.simulation.labor_supply_response.income_elasticity_lsr import (
10+
income_elasticity_lsr,
11+
)
12+
from policyengine_uk.variables.gov.simulation.labor_supply_response.substitution_elasticity_lsr import (
13+
substitution_elasticity_lsr,
14+
)
15+
16+
17+
class FakePerson:
18+
def __init__(self, values):
19+
self.values = values
20+
21+
def __call__(self, variable, period):
22+
return np.array(self.values[variable])
23+
24+
25+
class FakeParameters:
26+
def __init__(self, income_elasticity=0.1, substitution_elasticity=0.2):
27+
self.gov = SimpleNamespace(
28+
simulation=SimpleNamespace(
29+
labor_supply_responses=SimpleNamespace(
30+
income_elasticity=income_elasticity,
31+
substitution_elasticity=substitution_elasticity,
32+
)
33+
)
34+
)
35+
36+
def __call__(self, period):
37+
return self
38+
39+
40+
def test_income_elasticity_lsr_clips_negative_earnings():
41+
person = FakePerson(
42+
{
43+
"employment_income_before_lsr": [-1_000, 0, 1_000],
44+
"relative_income_change": [0.1, 0.1, 0.1],
45+
}
46+
)
47+
48+
result = income_elasticity_lsr.formula(
49+
person, 2025, FakeParameters(income_elasticity=0.1)
50+
)
51+
52+
assert np.allclose(result, np.array([0.0, 0.0, 10.0]))
53+
54+
55+
def test_substitution_elasticity_lsr_clips_negative_earnings():
56+
person = FakePerson(
57+
{
58+
"employment_income_before_lsr": [-1_000, 0, 1_000],
59+
"relative_wage_change": [0.2, 0.2, 0.2],
60+
}
61+
)
62+
63+
result = substitution_elasticity_lsr.formula(
64+
person, 2025, FakeParameters(substitution_elasticity=0.2)
65+
)
66+
67+
assert np.allclose(result, np.array([0.0, 0.0, 40.0]))
68+
69+
70+
def test_progression_labor_supply_response_clips_negative_earnings():
71+
result = calculate_employment_income_change(
72+
employment_income=np.array([-1_000.0, 0.0, 1_000.0]),
73+
derivative_changes=pd.DataFrame({"wage_rel_change": [0.1, 0.1, 0.1]}),
74+
income_changes=pd.DataFrame({"income_rel_change": [0.2, 0.2, 0.2]}),
75+
substitution_elasticities=np.array([0.15, 0.15, 0.15]),
76+
income_elasticities=np.array([-0.05, -0.05, -0.05]),
77+
)
78+
79+
assert np.allclose(result["substitution_response"], [0.0, 0.0, 15.0])
80+
assert np.allclose(result["income_response"], [0.0, 0.0, -10.0])
81+
assert np.allclose(result["total_response"], [0.0, 0.0, 5.0])

policyengine_uk/variables/gov/simulation/labor_supply_response/income_elasticity_lsr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class income_elasticity_lsr(Variable):
1111

1212
def formula(person, period, parameters):
1313
lsr = parameters(period).gov.simulation.labor_supply_responses
14-
employment_income = person("employment_income_before_lsr", period)
14+
employment_income = max_(person("employment_income_before_lsr", period), 0)
1515
income_change = person("relative_income_change", period)
1616

1717
return employment_income * income_change * lsr.income_elasticity

policyengine_uk/variables/gov/simulation/labor_supply_response/substitution_elasticity_lsr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class substitution_elasticity_lsr(Variable):
1111

1212
def formula(person, period, parameters):
1313
lsr = parameters(period).gov.simulation.labor_supply_responses
14-
employment_income = person("employment_income_before_lsr", period)
14+
employment_income = max_(person("employment_income_before_lsr", period), 0)
1515
wage_change = person("relative_wage_change", period)
1616

1717
return employment_income * wage_change * lsr.substitution_elasticity

0 commit comments

Comments
 (0)