Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/lsr-age-heterogeneity.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reintroduce age-specific labor supply response multipliers without changing the legacy scalar income elasticity path, and fix the labor-supply-response zero guard so nonzero primary or secondary substitution elasticities are not skipped.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
description: Multiplier applied to income elasticity for people at or above the age threshold.
values:
2020-01-01: 2.0
metadata:
unit: /1
label: income elasticity multiplier for people at or above the age threshold
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
description: Age at which the income elasticity multiplier begins to apply.
values:
2020-01-01: 65
metadata:
unit: year
label: age threshold for income elasticity multiplier
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ all:
metadata:
unit: /1
label: substitution elasticity of labor supply
age_threshold:
description: Age at which the substitution elasticity multiplier begins to apply.
values:
2020-01-01: 65
metadata:
unit: year
label: age threshold for substitution elasticity multiplier
age_multiplier_over_threshold:
description: Multiplier applied to substitution elasticities for people at or above the age threshold.
values:
2020-01-01: 2.0
metadata:
unit: /1
label: substitution elasticity multiplier for people at or above the age threshold
by_position_and_decile:
metadata:
label: by position and decile
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch

from policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response import (
labor_supply_behavioral_response,
)


def _parameters(*, income=0, substitution_all=0, substitution_secondary=0, primary_1=0):
return lambda period: SimpleNamespace(
gov=SimpleNamespace(
simulation=SimpleNamespace(
labor_supply_responses=SimpleNamespace(
elasticities=SimpleNamespace(
income=income,
substitution=SimpleNamespace(
all=substitution_all,
by_position_and_decile=SimpleNamespace(
secondary=substitution_secondary,
primary=SimpleNamespace(
**{
str(decile): primary_1 if decile == 1 else 0
for decile in range(1, 11)
}
),
),
),
)
)
)
)
)


def _person():
person = Mock()
person.simulation = Mock()
person.simulation.baseline = Mock()
person.simulation._lsr_calculating = False
return person


def test_guard_returns_zero_only_when_all_elasticities_are_zero():
result = labor_supply_behavioral_response.formula(_person(), 2026, _parameters())

assert result == 0


@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.calculate_substitution_lsr_effect",
return_value=500,
)
@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.calculate_income_lsr_effect",
return_value=0,
)
@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.get_behavioral_response_measurements",
return_value={"baseline_net_income": 1},
)
def test_guard_does_not_short_circuit_primary_decile_responses(
mock_measurements,
mock_income,
mock_substitution,
):
result = labor_supply_behavioral_response.formula(
_person(), 2026, _parameters(primary_1=0.2)
)

assert result == 500
mock_measurements.assert_called_once()
mock_income.assert_called_once()
mock_substitution.assert_called_once()


@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.calculate_substitution_lsr_effect",
return_value=250,
)
@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.calculate_income_lsr_effect",
return_value=0,
)
@patch(
"policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response.get_behavioral_response_measurements",
return_value={"baseline_net_income": 1},
)
def test_guard_does_not_short_circuit_secondary_responses(
mock_measurements,
mock_income,
mock_substitution,
):
result = labor_supply_behavioral_response.formula(
_person(), 2026, _parameters(substitution_secondary=0.15)
)

assert result == 250
mock_measurements.assert_called_once()
mock_income.assert_called_once()
mock_substitution.assert_called_once()
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,22 @@
output:
income_elasticity: 0

- name: Custom income elasticity value
- name: Custom income elasticity value under threshold
period: 2023
input:
age: 50
gov.simulation.labor_supply_responses.elasticities.income: -0.2
gov.simulation.labor_supply_responses.elasticities.income_age_threshold: 65
gov.simulation.labor_supply_responses.elasticities.income_age_multiplier_over_threshold: 2.0
output:
income_elasticity: -0.2

- name: Income elasticity for people at or above threshold applies multiplier
period: 2023
input:
age: 70
gov.simulation.labor_supply_responses.elasticities.income: -0.2
gov.simulation.labor_supply_responses.elasticities.income_age_threshold: 65
gov.simulation.labor_supply_responses.elasticities.income_age_multiplier_over_threshold: 2.0
output:
income_elasticity: -0.4
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
- name: Older secondary earner gets the age multiplier on the secondary elasticity
period: 2023
input:
people:
older_secondary:
age: 67
employment_income_before_lsr: 40_000
self_employment_income_before_lsr: 0
younger_primary:
age: 55
employment_income_before_lsr: 50_000
self_employment_income_before_lsr: 0
tax_units:
tax_unit:
members: [older_secondary, younger_primary]
spm_units:
spm_unit:
members: [older_secondary, younger_primary]
households:
household:
members: [older_secondary, younger_primary]
families:
family:
members: [older_secondary, younger_primary]
marital_units:
marital_unit:
members: [older_secondary, younger_primary]
gov.simulation.labor_supply_responses.elasticities.income: -0.04
gov.simulation.labor_supply_responses.elasticities.income_age_threshold: 65
gov.simulation.labor_supply_responses.elasticities.income_age_multiplier_over_threshold: 2.0
gov.simulation.labor_supply_responses.elasticities.substitution.all: 0
gov.simulation.labor_supply_responses.elasticities.substitution.age_threshold: 65
gov.simulation.labor_supply_responses.elasticities.substitution.age_multiplier_over_threshold: 2.0
gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.20
gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.secondary: 0.10
output:
income_elasticity:
- -0.08
- -0.04
substitution_elasticity:
- 0.20
- 0.20
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@
output:
substitution_elasticity: 0 # TODO: Debug why single person isn't getting primary earner elasticity

- name: Primary earner at or above threshold gets substitution multiplier
period: 2023
input:
age: 70
employment_income_before_lsr: 50_000
self_employment_income_before_lsr: 0
gov.simulation.labor_supply_responses.elasticities.substitution.all: 0
gov.simulation.labor_supply_responses.elasticities.substitution.age_threshold: 65
gov.simulation.labor_supply_responses.elasticities.substitution.age_multiplier_over_threshold: 2.0
gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15
output:
substitution_elasticity: 0.30

- name: Negative total earnings should have zero elasticity
period: 2023
input:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,21 @@ class income_elasticity(Variable):
label = "income elasticity of labor supply"
unit = "/1"
definition_period = YEAR
adds = ["gov.simulation.labor_supply_responses.elasticities.income"]
reference = [
"https://www.cbo.gov/publication/43675",
"https://www.cbo.gov/publication/43676",
"https://academic.oup.com/restud/article-abstract/72/2/395/1558553",
]

def formula(person, period, parameters):
elasticities = parameters(
period
).gov.simulation.labor_supply_responses.elasticities
base_elasticity = elasticities.income
age = person("age", period.this_year)
age_multiplier = where(
age >= elasticities.income_age_threshold,
elasticities.income_age_multiplier_over_threshold,
1.0,
)
return base_elasticity * age_multiplier
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@ def formula(person, period, parameters):
simulation = person.simulation
if simulation.baseline is None:
return 0 # No reform, no impact
if p.elasticities.income == 0 and p.elasticities.substitution.all == 0:

substitution_elasticities = p.elasticities.substitution
by_position = getattr(substitution_elasticities, "by_position_and_decile", None)
primary_elasticities = (
getattr(by_position, "primary", None) if by_position else None
)
no_income_response = p.elasticities.income == 0
no_substitution_response = (
substitution_elasticities.all == 0
and getattr(by_position, "secondary", 0) == 0
and all(
getattr(primary_elasticities, str(decile), 0) == 0
for decile in range(1, 11)
)
)
if no_income_response and no_substitution_response:
return 0

# Guard against re-entry (prevents recursion when branches calculate variables)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ class substitution_elasticity(Variable):
label = "substitution elasticity of labor supply"
unit = "/1"
definition_period = YEAR
reference = [
"https://www.cbo.gov/publication/43675",
"https://www.cbo.gov/publication/43676",
"https://academic.oup.com/restud/article-abstract/72/2/395/1558553",
]

def formula(person, period, parameters):
p = parameters(
Expand Down Expand Up @@ -46,7 +51,7 @@ def formula(person, period, parameters):
max_earnings_in_unit = tax_unit.max(earnings)
is_primary_earner = earnings == max_earnings_in_unit

elasticities = np.zeros_like(earnings)
elasticities = np.zeros_like(earnings, dtype=float)

# Handle zero earnings first
zero_earnings = earnings == 0
Expand All @@ -57,18 +62,32 @@ def formula(person, period, parameters):

if np.any(non_zero_earnings):
# First assign primary earner elasticities by decile
decile_elasticities = [
p.by_position_and_decile.primary._children[str(i + 1)]
for i in range(10)
]
for i in range(10):
mask = (
non_zero_earnings & (earnings_decile == i + 1) & is_primary_earner
)
elasticities[mask] = decile_elasticities[i]
if np.any(mask):
decile_param = getattr(p.by_position_and_decile.primary, str(i + 1))
param_value = (
decile_param(period) if callable(decile_param) else decile_param
)
elasticities[mask] = param_value

# Then assign secondary earner elasticity where applicable
secondary_mask = non_zero_earnings & ~is_primary_earner
elasticities[secondary_mask] = p.by_position_and_decile.secondary
secondary_param = p.by_position_and_decile.secondary
secondary_value = (
secondary_param(period)
if callable(secondary_param)
else secondary_param
)
elasticities[secondary_mask] = secondary_value

return elasticities
age = person("age", period.this_year)
age_multiplier = where(
age >= p.age_threshold,
p.age_multiplier_over_threshold,
1.0,
)

return elasticities * age_multiplier
Loading