diff --git a/changelog.d/lsr-age-heterogeneity.changed.md b/changelog.d/lsr-age-heterogeneity.changed.md new file mode 100644 index 00000000000..fd51877dd93 --- /dev/null +++ b/changelog.d/lsr-age-heterogeneity.changed.md @@ -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. diff --git a/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_multiplier_over_threshold.yaml b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_multiplier_over_threshold.yaml new file mode 100644 index 00000000000..d56562dfca8 --- /dev/null +++ b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_multiplier_over_threshold.yaml @@ -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 diff --git a/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_threshold.yaml b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_threshold.yaml new file mode 100644 index 00000000000..94247b44c13 --- /dev/null +++ b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/income_age_threshold.yaml @@ -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 diff --git a/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/substitution.yaml b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/substitution.yaml index 8c23f799913..df1ea4504f0 100644 --- a/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/substitution.yaml +++ b/policyengine_us/parameters/gov/simulation/labor_supply_responses/elasticities/substitution.yaml @@ -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 diff --git a/policyengine_us/tests/core/test_labor_supply_behavioral_response_guard.py b/policyengine_us/tests/core/test_labor_supply_behavioral_response_guard.py new file mode 100644 index 00000000000..066d124c1aa --- /dev/null +++ b/policyengine_us/tests/core/test_labor_supply_behavioral_response_guard.py @@ -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() diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/income_elasticity.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/income_elasticity.yaml index 30005906d99..3f87d744195 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/income_elasticity.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/income_elasticity.yaml @@ -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 diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/integration.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/integration.yaml new file mode 100644 index 00000000000..54536efb0f1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/integration.yaml @@ -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 diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml index 925bc4b7f42..918617de4df 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml @@ -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: diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/income_elasticity.py b/policyengine_us/variables/gov/simulation/labor_supply_response/income_elasticity.py index 477e6e12e4f..aa35ec18151 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/income_elasticity.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/income_elasticity.py @@ -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 diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/labor_supply_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/labor_supply_behavioral_response.py index 464baf92768..e75ce8f2801 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/labor_supply_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/labor_supply_behavioral_response.py @@ -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) diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py index 951c4433c84..deab52b5cbf 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py @@ -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( @@ -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 @@ -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