diff --git a/changelog.d/fix-lsr-baseline-2268.added.md b/changelog.d/fix-lsr-baseline-2268.added.md new file mode 100644 index 0000000000..0e14841a7d --- /dev/null +++ b/changelog.d/fix-lsr-baseline-2268.added.md @@ -0,0 +1 @@ +- Added `policyengine_uk.utils.measurement_branches`: `measurement_branch`, which measures a simulation's results at set inputs in a branch with every behavioural response switched off, and `held_behavioural_responses` and `hold_behavioural_responses`, which hold the responses fixed in a branch that steps an input to measure a marginal rate. diff --git a/changelog.d/fix-lsr-baseline-2268.fixed.md b/changelog.d/fix-lsr-baseline-2268.fixed.md new file mode 100644 index 0000000000..77704d8a92 --- /dev/null +++ b/changelog.d/fix-lsr-baseline-2268.fixed.md @@ -0,0 +1 @@ +- Measured the elasticity-based labour supply responses against the reform's baseline simulation rather than a copy of the reform, so a reform that changes marginal rates or net income now gets a response when `gov.simulation.labour_supply_responses` elasticities are set. The labour supply and capital gains measurements now start from what their simulation was given, not what it had already calculated, and hold every behavioural response at zero, so neither response depends on what was calculated first or includes the other. Marginal tax rates (on earnings, on employer cost and on capital gains) hold every behavioural response at the simulation's values in the branches where they step an input, instead of measuring the responses again there. A branch that shares its simulation's rules, such as the Pension Credit branch of the UC non-dependant exemption, takes the simulation's labour supply response instead of measuring it again, and a labour supply term whose elasticity is zero is zero without a measurement. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index d82a30084b..a501ec990f 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -157,6 +157,63 @@ print(f"Difference: {(dynamics.fte_impacts.ftes - 94_000):+,.0f}") Small differences from OBR estimates can arise from data vintage, population projections, or minor methodological variations. +## Elasticity-based responses + +Separately from `apply_dynamics`, a reform can set +`gov.simulation.labour_supply_responses.substitution_elasticity` and +`gov.simulation.labour_supply_responses.income_elasticity`. Each person's +`employment_income_behavioral_response` is then their employment income before +responses times the substitution elasticity times `relative_wage_change`, plus +the income elasticity times `relative_income_change`. Both changes compare the +reform with its baseline (`simulation.baseline`), each measured with every +behavioural response switched off and at the reform's earnings and capital +gains before responses: `relative_wage_change` is the relative change in one minus +the marginal tax rate, and `relative_income_change` the relative change in +household net income. The changes are capped at +`gov.simulation.labour_supply_responses.bounds`. The response is part of +`employment_income`, so it feeds into every result that depends on it. + +Two things follow from measuring against `simulation.baseline`: + +- **The baseline is current law.** `simulation.baseline` is built without the + reform, whatever policy a comparison treats as its baseline. When two + simulations are compared and the elasticities are set on the reform side + only, the reform's response also answers the comparison's baseline policy. + For a single earner on £30,000 in 2026, the response to a 22% basic rate is + about -£187, and to that rate plus a £13,000 personal allowance about -£193. + Compared with the 22% rate costed without responses, the second reform shows + the whole -£193, not the -£6 or so the allowance adds. Setting the + elasticities on both sides of the comparison isolates the reform's own + response. +- **Only earnings and gains are held.** Both measurements take the reform's + earnings and capital gains before responses. Any other input the reform's + scenario changes (a data modifier, say, or removing economic assumptions) + keeps each simulation's own value, so the difference counts as policy in + `relative_income_change`. + +```python +from policyengine_uk import Simulation + +situation = { + "people": {"person": {"age": {2025: 30}, "employment_income": {2025: 40_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} +reformed = Simulation( + situation=situation, + reform={ + "gov.hmrc.income_tax.rates.uk[0].rate": {"2025": 0.30}, + "gov.simulation.labour_supply_responses.substitution_elasticity": { + "2025": 0.25 + }, + "gov.simulation.labour_supply_responses.income_elasticity": {"2025": -0.05}, + }, +) +# The marginal rate rises from 28% to 38%, so the wage after tax falls by 5/36, +# and net income falls by £2,743 of £32,145: a response of about -£1,218. +print(reformed.calculate("employment_income_behavioral_response", 2025)) +``` + ## Technical implementation details The dynamics calculation proceeds through several steps: diff --git a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py index 374ae085ce..67c14752b1 100644 --- a/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py +++ b/policyengine_uk/tests/code_health/test_cached_arrays_not_written_in_place.py @@ -562,8 +562,12 @@ def test_branching_runs_with_read_only_cache(read_only_cache, case): ) for year in YEARS: sim.calculate("household_net_income", year) + # The measurement branches are not kept; the responses show they ran. if case == "labour_supply_responses": - assert "lsr_measurement" in sim.branches + # The higher personal allowance raises both earners' net income and + # leaves their marginal rates alone, so with a negative income + # elasticity both work less. + response = sim.calculate("employment_income_behavioral_response", 2026) + assert (response[:2] < 0).all() else: - # The measurement branches are deleted; the response shows they ran. assert sim.calculate("capital_gains_behavioural_response", 2026)[0] < 0 diff --git a/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py b/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py index a3d3db9000..f3e5fa6180 100644 --- a/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py +++ b/policyengine_uk/tests/code_health/test_labour_supply_parameters_are_read.py @@ -173,12 +173,13 @@ def labour_supply_run(): _add_unread_control(simulation) with record_parameter_reads() as reads: - formula_sim.calculate("employment_income_behavioral_response", YEAR) + response = formula_sim.calculate("employment_income_behavioral_response", YEAR) dynamics = dynamics_sim.apply_dynamics(YEAR) - # Both paths ran in full: the formula built its measurement branch, which - # it does only after its early returns, and the dynamics returned results. - assert "lsr_measurement" in formula_sim.branches + # Both paths ran in full: the formula measured a response, which it does + # only after its early returns (its measurement branches are not kept), + # and the dynamics returned results. + assert (np.asarray(response) != 0).any() assert dynamics is not None return simulations, {canonicalize_lsr_parameter_path(read) for read in reads} diff --git a/policyengine_uk/tests/test_capital_gains_responses.py b/policyengine_uk/tests/test_capital_gains_responses.py index b95eaaba71..d977d73486 100644 --- a/policyengine_uk/tests/test_capital_gains_responses.py +++ b/policyengine_uk/tests/test_capital_gains_responses.py @@ -209,6 +209,104 @@ def test_pre_created_measurement_branch_cannot_poison_the_system(): assert after is before +# Gains near the top of the basic rate band, where the marginal rate depends on +# the level of gains. Inputs are for the year before, so YEAR is uprated. +BAND_EDGE_SITUATION = { + "people": { + "person": { + "age": {YEAR - 1: 45, YEAR: 46}, + "employment_income": {YEAR - 1: 30_000}, + "capital_gains": {YEAR - 1: 22_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} +RESPONSE_PARTS = ( + "capital_gains_behavioural_response", + "capital_gains_badr_behavioural_response", + "relative_capital_gains_retention_rate_change", + "relative_capital_gains_mtr_change", +) + + +def response_parts(sim) -> dict: + return { + variable: sim.calculate(variable, YEAR).values.copy() + for variable in RESPONSE_PARTS + } + + +def test_response_is_the_same_on_recalculation_near_a_band_edge(): + """The reform's measurement branch started from the gains the reform had + already calculated, after the response, so recalculating measured the rate + at lower gains: -4,766.83 became +1,191.71 (found with issue #2268). The + branch now measures from inputs alone.""" + sim = Microsimulation( + situation=BAND_EDGE_SITUATION, + scenario=Scenario( + parameter_changes={ + **EQUALISED_RATES, + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 1.0}, + } + ), + ) + sim.calculate("household_net_income", YEAR) + first = response_parts(sim) + for variable in RESPONSE_PARTS: + sim.delete_arrays(variable, YEAR) + second = response_parts(sim) + + assert first["capital_gains_behavioural_response"][0] < 0 + for variable in RESPONSE_PARTS: + assert second[variable] == pytest.approx(first[variable]), variable + + +def test_response_does_not_depend_on_calculating_the_baseline_first(): + """Without economic assumptions the reform's gains stay at their 2025 + level while the baseline's grow. The baseline's measurement branch started + from the gains the baseline had calculated, so calculating the baseline + first measured its rate at its own gains: the response went from 709.68 to + 1,157.90 (found with issue #2268).""" + from policyengine_uk.scenarios.no_economic_assumptions import ( + remove_economic_assumptions, + ) + + def modify(simulation): + remove_economic_assumptions(simulation) + parameters = simulation.tax_benefit_system.parameters + for name, value in { + **{name: rates[str(YEAR)] for name, rates in EQUALISED_RATES.items()}, + "gov.simulation.capital_gains_responses.elasticity": 1.0, + }.items(): + parameters.get_child(name).update(period="year:2000:100", value=value) + simulation.tax_benefit_system.reset_parameter_caches() + + def build(): + return Microsimulation( + situation=BAND_EDGE_SITUATION, + scenario=Scenario( + simulation_modifier=modify, applied_before_data_load=True + ), + ) + + alone = build() + reform_gains = alone.calculate("capital_gains_before_response", YEAR).values + # The baseline is a Simulation, which returns arrays. + baseline_gains = alone.baseline.calculate("capital_gains_before_response", YEAR) + assert reform_gains[0] < baseline_gains[0] + expected = response_parts(alone) + + baseline_first = build() + baseline_first.baseline.calculate("household_net_income", YEAR) + baseline_first.baseline.calculate("marginal_tax_rate_on_capital_gains", YEAR) + parts = response_parts(baseline_first) + + assert expected["capital_gains_behavioural_response"][0] != 0 + for variable in RESPONSE_PARTS: + assert parts[variable] == pytest.approx(expected[variable]), variable + + def test_two_gainers_in_one_household_respond_symmetrically(): """Equal gainers get equal responses; the second adult is not dropped.""" situation = { diff --git a/policyengine_uk/tests/test_labour_supply_response_baseline.py b/policyengine_uk/tests/test_labour_supply_response_baseline.py new file mode 100644 index 0000000000..08af6605c5 --- /dev/null +++ b/policyengine_uk/tests/test_labour_supply_response_baseline.py @@ -0,0 +1,497 @@ +"""Elasticity-based labour supply responses against the reform's baseline. + +employment_income_behavioral_response, relative_wage_change and +relative_income_change compare the reform with its baseline, each measured +with labour supply responses neutralised and at the reform's earnings and gains +before responses. The baseline is ``simulation.baseline``, a separately built +simulation. These variables used to take a branch of the reform simulation +named ``baseline``, which carries the reform's parameters, so every response +was zero (issue #2268). + +Each simulation below holds single-person households, one per earner. For any +earnings and elasticities: + +1. With no policy change (a scenario that sets only the elasticities), the + relative wage change, the relative income change and the response are zero. +2. A rise in the basic rate, for an earner whose £1,000 marginal-rate step + stays inside the basic rate band, lowers the relative wage by + (rate rise) / (1 - baseline marginal rate) and the relative income by the + income tax rise over baseline net income. The substitution term is then + negative and the income term positive, as positive substitution and + negative income elasticities imply, and the response is their sum. +3. Zero elasticities give a zero response. +4. Scaling both elasticities by k scales the response by k and leaves the + relative changes unchanged. +5. Calculating the response leaves the baseline's own results, branches and + tax-benefit system unchanged, and keeps the response live in the reform. +6. The response does not depend on the order of calculation: calculating the + baseline first, recalculating after deleting the response, and calculating + periods in any order give the same values. +7. Each behavioural response is measured from the position before any + response, so setting the capital gains elasticity leaves the labour supply + response unchanged, and setting the labour supply elasticities leaves the + capital gains response unchanged. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.scenarios.no_economic_assumptions import ( + remove_economic_assumptions, +) +from policyengine_uk.utils.capital_gains import RESPONSE_VARIABLES +from policyengine_uk.utils.measurement_branches import BEHAVIOURAL_RESPONSES + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +RESPONSE = "employment_income_behavioral_response" +WAGE_CHANGE = "relative_wage_change" +INCOME_CHANGE = "relative_income_change" +SUBSTITUTION_TERM = "substitution_elasticity_lsr" +INCOME_TERM = "income_elasticity_lsr" +BASIC_RATE = "gov.hmrc.income_tax.rates.uk[0].rate" +SUBSTITUTION_ELASTICITY = ( + "gov.simulation.labour_supply_responses.substitution_elasticity" +) +INCOME_ELASTICITY = "gov.simulation.labour_supply_responses.income_elasticity" + +# 2025-26: a 20% basic rate on income above the £12,570 personal allowance, +# and 8% employee National Insurance between £12,570 and £50,270. +BASELINE_BASIC_RATE = 0.20 +PERSONAL_ALLOWANCE = 12_570 +EMPLOYEE_NI_RATE = 0.08 +BASELINE_MARGINAL_RATE = BASELINE_BASIC_RATE + EMPLOYEE_NI_RATE + +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +# Earnings whose £1,000 marginal-rate step stays above the personal allowance +# and below the higher rate threshold (£50,270). +BASIC_RATE_EARNINGS = st.integers(13_000, 49_000) +ANY_EARNINGS = st.integers(0, 200_000) +SUBSTITUTION_ELASTICITIES = st.floats(0.01, 0.5) +INCOME_ELASTICITIES = st.floats(-0.3, -0.01) + + +def earners(earnings, years=(YEAR,)): + """One single-person household per earner, aged 30 throughout.""" + people = { + f"earner_{i}": { + "age": {year: 30 for year in years}, + "employment_income": {years[0]: amount}, + } + for i, amount in enumerate(earnings) + } + return { + "people": people, + "benunits": {f"{name}_benunit": {"members": [name]} for name in people}, + "households": {f"{name}_household": {"members": [name]} for name in people}, + } + + +def changes(substitution, income, basic_rate=None, years=(YEAR,)): + parameter_changes = { + SUBSTITUTION_ELASTICITY: {str(year): substitution for year in years}, + INCOME_ELASTICITY: {str(year): income for year in years}, + } + if basic_rate is not None: + parameter_changes[BASIC_RATE] = {str(year): basic_rate for year in years} + return parameter_changes + + +def simulate(earnings, substitution, income, basic_rate=None, years=(YEAR,)): + return Simulation( + situation=earners(earnings, years), + scenario=Scenario( + parameter_changes=changes(substitution, income, basic_rate, years) + ), + ) + + +def values(simulation, variable, period=YEAR): + return np.asarray(simulation.calculate(variable, period), dtype=float) + + +def response_parts(simulation, period=YEAR): + """The response first, as the relative changes require.""" + return { + variable: values(simulation, variable, period) + for variable in ( + RESPONSE, + WAGE_CHANGE, + INCOME_CHANGE, + SUBSTITUTION_TERM, + INCOME_TERM, + ) + } + + +def test_reform_raising_the_basic_rate_responds_by_hand_calculated_amounts(): + """The case in issue #2268, checked by hand. + + £40,000 of earnings in 2025-26. Baseline income tax is 20% of £27,430 + (£40,000 less the £12,570 personal allowance), £5,486; at a 30% basic + rate it is £8,229, £2,743 more. Baseline net income is £40,000 less that + tax, employee NI of 8% of £27,430 (£2,194.40), the £174.50 TV licence and + the model's £0.045 of expected stamp duty: £32,145.055. + + The marginal rate rises from 28% to 38%, so the wage after tax falls from + 0.72 to 0.62 of pay: a relative change of -0.10 / 0.72 = -5/36. Net + income falls by 2,743 / 32,145.055. With elasticities of 0.25 and -0.05 + the response is 40,000 x (0.25 x -5/36 + -0.05 x -2,743 / 32,145.055) + = -1,388.89 + 170.66 = -£1,218.23. + """ + sim = simulate([40_000], substitution=0.25, income=-0.05, basic_rate=0.30) + parts = response_parts(sim) + + wage_change = -0.10 / 0.72 + income_change = -2_743 / 32_145.055 + substitution_term = 40_000 * 0.25 * wage_change + income_term = 40_000 * -0.05 * income_change + + assert parts[WAGE_CHANGE] == pytest.approx([wage_change], rel=1e-5) + assert parts[INCOME_CHANGE] == pytest.approx([income_change], rel=1e-5) + assert parts[SUBSTITUTION_TERM] == pytest.approx([-1_388.889], abs=0.01) + assert parts[INCOME_TERM] == pytest.approx([170.664], abs=0.01) + assert parts[RESPONSE] == pytest.approx([substitution_term + income_term], abs=0.01) + assert parts[RESPONSE] == pytest.approx([-1_218.225], abs=0.01) + # The response reaches employment income, and through it income tax. + earnings = 40_000 + parts[RESPONSE][0] + assert values(sim, "employment_income") == pytest.approx([earnings], abs=0.01) + assert values(sim, "income_tax") == pytest.approx( + [0.30 * (earnings - PERSONAL_ALLOWANCE)], abs=0.01 + ) + # The baseline keeps its tax. + assert values(sim.baseline, "income_tax") == pytest.approx([5_486]) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, +) +def test_no_policy_change_gives_no_response(earnings, substitution, income): + """Invariant 1: elasticities alone change nothing to respond to.""" + parts = response_parts(simulate(earnings, substitution, income)) + + for variable, array in parts.items(): + np.testing.assert_array_equal(array, 0, err_msg=variable) + + +def test_simulation_without_a_scenario_gives_no_response(): + """Invariant 1 where the baseline is the simulation's own clone. + + A simulation built without a scenario keeps a clone of itself as its + baseline. Setting the elasticities on it afterwards changes no policy the + baseline measures differently. + """ + sim = Simulation(situation=earners([20_000, 40_000, 80_000])) + sim.apply_parameter_changes(changes(0.25, -0.05)) + + parts = response_parts(sim) + + assert ( + sim.tax_benefit_system.parameters( + str(YEAR) + ).gov.simulation.labour_supply_responses.substitution_elasticity + == 0.25 + ) + for variable, array in parts.items(): + np.testing.assert_array_equal(array, 0, err_msg=variable) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(BASIC_RATE_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.22, 0.25, 0.30, 0.40]), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, +) +def test_basic_rate_rise_gives_responses_of_the_implied_sign( + earnings, basic_rate, substitution, income +): + """Invariant 2: a basic rate rise lowers the wage and income after tax.""" + sim = simulate(earnings, substitution, income, basic_rate) + parts = response_parts(sim) + earnings = np.array(earnings, dtype=float) + + rise = basic_rate - BASELINE_BASIC_RATE + expected_wage_change = -rise / (1 - BASELINE_MARGINAL_RATE) + np.testing.assert_allclose(parts[WAGE_CHANGE], expected_wage_change, rtol=1e-4) + baseline_net_income = values(sim.baseline, "household_net_income") + tax_rise = rise * (earnings - PERSONAL_ALLOWANCE) + np.testing.assert_allclose( + parts[INCOME_CHANGE], -tax_rise / baseline_net_income, rtol=1e-4 + ) + + assert (parts[SUBSTITUTION_TERM] < 0).all() + assert (parts[INCOME_TERM] > 0).all() + np.testing.assert_allclose( + parts[SUBSTITUTION_TERM], + earnings * substitution * parts[WAGE_CHANGE], + rtol=1e-5, + ) + np.testing.assert_allclose( + parts[INCOME_TERM], earnings * income * parts[INCOME_CHANGE], rtol=1e-5 + ) + np.testing.assert_allclose( + parts[RESPONSE], + parts[SUBSTITUTION_TERM] + parts[INCOME_TERM], + rtol=1e-5, + atol=0.01, + ) + + +@PROPERTY_SETTINGS +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.15, 0.25, 0.30]), +) +def test_zero_elasticities_give_no_response(earnings, basic_rate): + """Invariant 3.""" + sim = simulate(earnings, 0, 0, basic_rate) + + np.testing.assert_array_equal(values(sim, RESPONSE), 0) + np.testing.assert_allclose(values(sim, "employment_income"), earnings, rtol=1e-6) + + +# Each example builds two reforms, each with its baseline. +@settings(PROPERTY_SETTINGS, max_examples=4) +@given( + earnings=st.lists(ANY_EARNINGS, min_size=1, max_size=4), + basic_rate=st.sampled_from([0.15, 0.25, 0.30]), + substitution=SUBSTITUTION_ELASTICITIES, + income=INCOME_ELASTICITIES, + factor=st.sampled_from([0.5, 2.0, 3.0]), +) +def test_response_scales_with_the_elasticities( + earnings, basic_rate, substitution, income, factor +): + """Invariant 4: the relative changes do not depend on the elasticities.""" + base = response_parts(simulate(earnings, substitution, income, basic_rate)) + scaled = response_parts( + simulate(earnings, factor * substitution, factor * income, basic_rate) + ) + + np.testing.assert_array_equal(scaled[WAGE_CHANGE], base[WAGE_CHANGE]) + np.testing.assert_array_equal(scaled[INCOME_CHANGE], base[INCOME_CHANGE]) + # The response is stored as float32. + np.testing.assert_allclose( + scaled[RESPONSE], factor * base[RESPONSE], rtol=1e-5, atol=0.01 + ) + + +def test_response_leaves_the_baseline_unchanged(): + """Invariant 5.""" + sim = simulate([18_000, 40_000, 75_000], 0.25, -0.05, basic_rate=0.30) + baseline = sim.baseline + measured = ( + "household_net_income", + "income_tax", + "marginal_tax_rate", + "employment_income", + RESPONSE, + ) + before = {variable: values(baseline, variable) for variable in measured} + branches = set(baseline.branches) + baseline_response = baseline.tax_benefit_system.variables[RESPONSE] + reform_response = sim.tax_benefit_system.variables[RESPONSE] + + response = values(sim, RESPONSE) + # Basic rate taxpayers face a higher marginal rate and lower income, and + # work less. The higher rate taxpayer's marginal rate is unchanged, so + # only the fall in income acts, and with a negative income elasticity + # they work more. + assert (response[:2] < 0).all() + assert response[2] > 0 + + for variable in measured: + np.testing.assert_array_equal( + values(baseline, variable), before[variable], err_msg=variable + ) + assert set(baseline.branches) == branches + assert baseline.tax_benefit_system.variables[RESPONSE] is baseline_response + assert sim.tax_benefit_system.variables[RESPONSE] is reform_response + assert not reform_response.is_neutralized + # The measurement branches are not kept. + assert "lsr_measurement" not in sim.branches + assert "baseline_lsr_measurement" not in baseline.branches + + +def remove_growth_and_raise_the_basic_rate(simulation): + remove_economic_assumptions(simulation) + parameters = simulation.tax_benefit_system.parameters + for name, value in ( + (SUBSTITUTION_ELASTICITY, 0.25), + (INCOME_ELASTICITY, -0.05), + (BASIC_RATE, 0.30), + ): + parameters.get_child(name).update(period="year:2000:100", value=value) + simulation.tax_benefit_system.reset_parameter_caches() + + +def test_response_does_not_depend_on_calculating_the_baseline_first(): + """Invariant 6, where the reform's data differ from the baseline's. + + Without economic assumptions the reform's 2026 earnings stay at their + 2025 level, while the baseline's grow. A measurement branch of the + baseline started from what the baseline had calculated at its own + earnings, and read those results in place of results at the reform's: the + response was -1,218.20 calculated alone and -1,164.16 with the baseline + calculated first. + """ + + def build(): + return Simulation( + situation=earners([40_000], years=(2025, 2026)), + scenario=Scenario( + simulation_modifier=remove_growth_and_raise_the_basic_rate, + applied_before_data_load=True, + ), + ) + + alone = build() + assert values(alone, "employment_income_before_lsr", 2026) == pytest.approx( + [40_000] + ) + assert values(alone.baseline, "employment_income_before_lsr", 2026)[0] > 40_000 + expected = response_parts(alone, 2026) + + baseline_first = build() + values(baseline_first.baseline, "household_net_income", 2026) + values(baseline_first.baseline, "marginal_tax_rate", 2026) + parts = response_parts(baseline_first, 2026) + + assert expected[RESPONSE][0] < 0 + for variable, array in expected.items(): + np.testing.assert_array_equal(parts[variable], array, err_msg=variable) + + +def test_recalculation_gives_the_same_response(): + """Invariant 6 on recalculation. + + The reform's measurement branch started from what the reform had + calculated, including household net income after the response, so + deleting the response and calculating it again gave -1,171.23 instead of + -1,218.23. + """ + sim = simulate([40_000], 0.25, -0.05, basic_rate=0.30) + values(sim, "household_net_income") + first = response_parts(sim) + for variable in first: + sim.delete_arrays(variable, YEAR) + second = response_parts(sim) + sim.reset_calculations() + third = response_parts(sim) + + for variable, array in first.items(): + np.testing.assert_array_equal(second[variable], array, err_msg=variable) + np.testing.assert_array_equal(third[variable], array, err_msg=variable) + + +def test_periods_give_the_same_response_in_any_order(): + """Invariant 6 across periods, against one period per simulation.""" + years = (2025, 2026, 2027) + + def build(): + return simulate([25_000, 40_000], 0.25, -0.05, basic_rate=0.30, years=years) + + alone = {year: values(build(), RESPONSE, year) for year in years} + forwards, backwards = build(), build() + in_order = {year: values(forwards, RESPONSE, year) for year in years} + reversed_order = { + year: values(backwards, RESPONSE, year) for year in reversed(years) + } + + for year in years: + assert (alone[year] < 0).all() + np.testing.assert_array_equal(in_order[year], alone[year]) + np.testing.assert_array_equal(reversed_order[year], alone[year]) + + +CAPITAL_GAINS_RESPONSE = "capital_gains_behavioural_response" +# Capital gains tax charged at income tax rates, with a realisation response. +CAPITAL_GAINS_REFORM = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} +CAPITAL_GAINS_ELASTICITY = "gov.simulation.capital_gains_responses.elasticity" + + +def test_each_response_is_measured_before_the_other(): + """Invariant 7. + + When only each response's own variable was neutralised in its + measurement, the capital gains marginal rate, measured by stepping gains + up in branches, recomputed the labour supply response there against a + baseline held at gains before the step. The spurious change in net + income moved the capital gains rate: on a 16-person dataset the capital + gains response went from -150,569 to -554,679 when the labour supply + elasticities were set. + """ + people = { + name: { + "age": {YEAR: 45}, + "employment_income": {YEAR: earnings}, + "capital_gains": {YEAR: gains}, + } + for name, earnings, gains in ( + ("basic_rate_earner", 25_000, 10_000), + ("crossing_the_band", 45_000, 40_000), + ("higher_rate_earner", 90_000, 150_000), + ) + } + situation = { + "people": people, + "benunits": {f"{name}_benunit": {"members": [name]} for name in people}, + "households": {f"{name}_household": {"members": [name]} for name in people}, + } + + def responses(capital_gains_elasticity, substitution, income): + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + **CAPITAL_GAINS_REFORM, + CAPITAL_GAINS_ELASTICITY: {str(YEAR): capital_gains_elasticity}, + **changes(substitution, income), + } + ), + ) + return values(sim, RESPONSE), values(sim, CAPITAL_GAINS_RESPONSE) + + labour_both, gains_both = responses(1.0, 0.25, -0.05) + labour_only, no_gains_response = responses(0.0, 0.25, -0.05) + no_labour_response, gains_only = responses(1.0, 0.0, 0.0) + + np.testing.assert_array_equal(no_gains_response, 0) + np.testing.assert_array_equal(no_labour_response, 0) + assert (gains_only < 0).all() + assert (labour_only != 0).any() + np.testing.assert_array_equal(gains_both, gains_only) + np.testing.assert_array_equal(labour_both, labour_only) + + +def test_measurements_neutralise_every_behavioural_response(): + """A response missing from BEHAVIOURAL_RESPONSES would be recomputed + inside the other responses' measurements.""" + variables = Simulation(situation=earners([0])).tax_benefit_system.variables + responses = { + name + for name in variables + if name.endswith(("behavioural_response", "behavioral_response")) + } + + assert responses == set(BEHAVIOURAL_RESPONSES) + assert set(RESPONSE_VARIABLES) <= responses diff --git a/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py new file mode 100644 index 0000000000..2d2c7ebd91 --- /dev/null +++ b/policyengine_uk/tests/test_marginal_rates_hold_behavioural_responses.py @@ -0,0 +1,137 @@ +"""Marginal rates hold the behavioural responses at the simulation's values. + +marginal_tax_rate, marginal_tax_rate_wrt_employer_cost and +marginal_tax_rate_on_capital_gains step an input in a branch of the +simulation, which shares its tax-benefit system, and recalculate every +variable with a formula there. A response recalculated in that branch was +measured again: at the stepped input, against a baseline without the step, +and with the step itself undone inside the measurement's own marginal rate +branches, which recalculate the stepped variable from its formula. The rate is +the change in net income at fixed behaviour, so it must equal the rate of a +simulation without the response whose inputs sit where the response put them. + +For the labour supply response, two things now keep these branches from +measuring it again: the hold, and response_source, which gives a branch that +shares its parent's rules the parent's response. So the labour supply cases +fail only with both removed, and the capital gains cases isolate the hold in +these variables. test_measurement_branches.py isolates the hold for every +response with a branch that has its own copy of the rules. + +Every simulation here sets the capital gains response explicitly, so the +tests hold whatever its default is. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +LABOUR_SUPPLY_RESPONSE = "employment_income_behavioral_response" +CAPITAL_GAINS_RESPONSE = "capital_gains_behavioural_response" +ELASTICITIES = { + "gov.simulation.labour_supply_responses.substitution_elasticity": {str(YEAR): 0.25}, + "gov.simulation.labour_supply_responses.income_elasticity": {str(YEAR): -0.05}, +} +BASIC_RATE_RISE = {"gov.hmrc.income_tax.rates.uk[0].rate": {str(YEAR): 0.30}} +# Employers bear half of employer NI, and pass it on to prices: the price +# incidence reads baseline_employer_cost, which reads the labour supply +# response directly. +EMPLOYER_NI_INCIDENCE = { + "gov.contrib.policyengine.employer_ni.employee_incidence": {str(YEAR): 0.5} +} +CAPITAL_GAINS_REFORM = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} +CAPITAL_GAINS_RESPONSES = "gov.simulation.capital_gains_responses" +STATIC_CAPITAL_GAINS = { + f"{CAPITAL_GAINS_RESPONSES}.elasticity": {str(YEAR): 0}, + f"{CAPITAL_GAINS_RESPONSES}.mtr_elasticity": {str(YEAR): 0}, +} +CAPITAL_GAINS_ELASTICITY = { + f"{CAPITAL_GAINS_RESPONSES}.elasticity": {str(YEAR): 1.0}, + f"{CAPITAL_GAINS_RESPONSES}.mtr_elasticity": {str(YEAR): 0}, +} + + +def situation(earnings, gains=0.0, food=0.0): + person = {"age": {YEAR: 45}, "employment_income": {YEAR: earnings}} + if gains: + person["capital_gains"] = {YEAR: gains} + household = {"members": ["person"]} + if food: + household["food_and_non_alcoholic_beverages_consumption"] = {YEAR: food} + return { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": household}, + } + + +def value(simulation, variable): + return float(np.asarray(simulation.calculate(variable, YEAR), dtype=float)[0]) + + +@pytest.mark.parametrize( + "variable, gains, food, changes", + [ + # Gains of £22,000 on £40,000 of earnings straddle the basic rate + # limit. Measured again at £1,000 more gains, the response reached the + # wage change bound (-£9,921 against -£1,406), and the capital gains + # rate came out at 502% instead of 24%. + ("marginal_tax_rate_on_capital_gains", 22_000, 0, {}), + # With employer NI price incidence on, the pay rise branches measured + # the response again at the reform's earnings after the response plus + # the step: 94.2% instead of 95.5%, and 94.9% instead of 96.1%. + ("marginal_tax_rate", 0, 10_000, EMPLOYER_NI_INCIDENCE), + ("marginal_tax_rate_wrt_employer_cost", 0, 10_000, EMPLOYER_NI_INCIDENCE), + ], +) +def test_marginal_rates_hold_the_labour_supply_response(variable, gains, food, changes): + reform = {**BASIC_RATE_RISE, **STATIC_CAPITAL_GAINS, **changes} + responding = Simulation( + situation=situation(40_000, gains, food), + scenario=Scenario(parameter_changes={**reform, **ELASTICITIES}), + ) + response = value(responding, LABOUR_SUPPLY_RESPONSE) + held = Simulation( + situation=situation(40_000 + response, gains, food), + scenario=Scenario(parameter_changes=reform), + ) + + assert response < 0 + assert value(responding, variable) == pytest.approx(value(held, variable), abs=1e-4) + + +@pytest.mark.parametrize("gains", [13_000, 22_000]) +def test_marginal_tax_rate_holds_the_capital_gains_response(gains): + """A pay rise moves gains near the basic rate limit into the higher rate. + + The pay rise branch measured the realisation response again at the higher + earnings, and the capital gains marginal rate inside that measurement + recalculated earnings without the pay rise: the marginal tax rate came out + at 830% (gains of £13,000) and 1,164% (£22,000), against 28% and 48%. + """ + responding = Simulation( + situation=situation(40_000, gains), + scenario=Scenario( + parameter_changes={**CAPITAL_GAINS_REFORM, **CAPITAL_GAINS_ELASTICITY} + ), + ) + response = value(responding, CAPITAL_GAINS_RESPONSE) + held = Simulation( + situation=situation(40_000, gains + response), + scenario=Scenario( + parameter_changes={**CAPITAL_GAINS_REFORM, **STATIC_CAPITAL_GAINS} + ), + ) + + assert response < 0 + assert value(responding, "marginal_tax_rate") == pytest.approx( + value(held, "marginal_tax_rate"), abs=1e-4 + ) diff --git a/policyengine_uk/tests/test_measurement_branches.py b/policyengine_uk/tests/test_measurement_branches.py new file mode 100644 index 0000000000..c3d2a8c1ea --- /dev/null +++ b/policyengine_uk/tests/test_measurement_branches.py @@ -0,0 +1,231 @@ +"""A measurement branch measures from inputs alone. + +A branch starts from every value its simulation holds. A measurement branch +drops what the simulation calculated, so a result the simulation calculated +with a behavioural response is recalculated without it, at the branch's +inputs, rather than read from the simulation. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.utils.measurement_branches import ( + BEHAVIOURAL_RESPONSES, + held_behavioural_responses, + hold_behavioural_responses, + measurement_branch, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +ELASTICITIES = { + "gov.simulation.labour_supply_responses.substitution_elasticity": {str(YEAR): 0.25}, + "gov.simulation.labour_supply_responses.income_elasticity": {str(YEAR): -0.05}, +} +BASIC_RATE_RISE = {"gov.hmrc.income_tax.rates.uk[0].rate": {str(YEAR): 0.30}} +STATIC_CAPITAL_GAINS = { + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 0}, + "gov.simulation.capital_gains_responses.mtr_elasticity": {str(YEAR): 0}, +} +SITUATION = { + "people": {"person": {"age": {YEAR: 45}, "employment_income": {YEAR: 40_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + + +def value(simulation, variable): + return float(np.asarray(simulation.calculate(variable, YEAR), dtype=float)[0]) + + +def test_a_measurement_branch_drops_what_its_simulation_calculated(): + reform = {**BASIC_RATE_RISE, **STATIC_CAPITAL_GAINS} + responding = Simulation( + situation=SITUATION, + scenario=Scenario(parameter_changes={**reform, **ELASTICITIES}), + ) + assert value(responding, "employment_income_behavioral_response") < 0 + with_response = value(responding, "household_net_income") + + branch = measurement_branch(responding, "probe", YEAR, {}) + + without_response = Simulation( + situation=SITUATION, scenario=Scenario(parameter_changes=reform) + ) + measured = value(branch, "household_net_income") + assert measured == value(without_response, "household_net_income") + assert measured != with_response + # The branch is not kept, and the simulation's own results are untouched. + assert "probe" not in responding.branches + assert value(responding, "household_net_income") == with_response + + +def test_a_branch_with_its_parents_rules_takes_the_parents_response(monkeypatch): + """The Pension Credit branch of the UC non-dependant exemption fixes the + UC housing element at zero. It changes inputs, not policy, so it takes the + simulation's labour supply response instead of measuring the whole + population again with that element held on the reform's side only.""" + from policyengine_uk.utils import labour_supply_responses + + measured_from = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + if name == "lsr_measurement": + measured_from.append(simulation.branch_name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + members = ["earner", "partner", "older_child", "younger_child"] + renting_family = { + "people": { + "earner": {"age": {YEAR: 35}, "employment_income": {YEAR: 22_000}}, + "partner": {"age": {YEAR: 33}}, + "older_child": {"age": {YEAR: 6}}, + "younger_child": {"age": {YEAR: 3}}, + }, + "benunits": {"benunit": {"members": members}}, + "households": { + "household": { + "members": members, + "rent": {YEAR: 10_800}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + } + }, + } + responding = Simulation( + situation=renting_family, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **ELASTICITIES, + } + ), + ) + + assert value(responding, "universal_credit") > 0 + responding.calculate("household_net_income", YEAR) + + assert value(responding, "employment_income_behavioral_response") < 0 + assert measured_from == ["default"] + + +@pytest.mark.parametrize( + "elasticities, terms_measured", + [ + ({}, []), + # Only the income term is nonzero, so only it reads the measurement. + ( + { + "gov.simulation.labour_supply_responses.income_elasticity": { + str(YEAR): -0.05 + } + }, + ["relative_income_change"], + ), + ], +) +def test_a_term_with_a_zero_elasticity_measures_nothing( + monkeypatch, elasticities, terms_measured +): + """policyengine.py asks for both terms on every UK simulation. A term + whose elasticity is zero is zero, so it measures nothing.""" + from policyengine_uk.utils import labour_supply_responses + + opened = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + opened.append(name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + simulation = Simulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **elasticities, + } + ), + ) + + terms = { + "income_elasticity_lsr": "relative_income_change", + "substitution_elasticity_lsr": "relative_wage_change", + } + # The terms require the response first (policyengine-core 3.32 enforces + # it). With no elasticities the response returns early, but the terms are + # still asked for afterwards. + simulation.calculate("employment_income_behavioral_response", YEAR) + for term in terms: + simulation.calculate(term, YEAR) + calculated = { + change + for term, change in terms.items() + if simulation.get_holder(change).get_known_periods() + } + + assert sorted(calculated) == terms_measured + if not terms_measured: + assert opened == [] + + +def test_holding_the_responses_keeps_a_branch_from_measuring_them_again(monkeypatch): + """The marginal rate variables hold every response in the branches where + they step an input. A branch that shares the simulation's rules would take + the labour supply response through response_source anyway, so this uses a + branch with its own copy of the rules, which isolates the hold: emptied as + the marginal rate variables empty theirs, it measures the response again + unless the responses are held.""" + from policyengine_uk.utils import labour_supply_responses + + measured_from = [] + measure = labour_supply_responses.measurement_branch + + def recording(simulation, name, period, inputs): + if name == "lsr_measurement": + measured_from.append(simulation.branch_name) + return measure(simulation, name, period, inputs) + + monkeypatch.setattr(labour_supply_responses, "measurement_branch", recording) + responding = Simulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes={ + **BASIC_RATE_RISE, + **STATIC_CAPITAL_GAINS, + **ELASTICITIES, + } + ), + ) + response = value(responding, "employment_income_behavioral_response") + held = held_behavioural_responses(responding.person, YEAR) + assert set(held) == set(BEHAVIOURAL_RESPONSES) + + def emptied_branch(name): + branch = responding.get_branch(name, clone_system=True) + variables = responding.tax_benefit_system.variables + for variable, data in variables.items(): + if ( + variable not in responding.input_variables + and not data.is_input_variable() + ): + branch.delete_arrays(variable) + return branch + + measured_from.clear() + unheld = emptied_branch("unheld") + assert value(unheld, "employment_income_behavioral_response") == response + assert measured_from == ["unheld"] + + measured_from.clear() + branch = emptied_branch("held") + hold_behavioural_responses(branch, YEAR, held) + assert value(branch, "employment_income_behavioral_response") == response + assert measured_from == [] diff --git a/policyengine_uk/utils/capital_gains.py b/policyengine_uk/utils/capital_gains.py index 0ce4fcbf7c..f9bfc89d17 100644 --- a/policyengine_uk/utils/capital_gains.py +++ b/policyengine_uk/utils/capital_gains.py @@ -5,6 +5,8 @@ from policyengine_core.simulations import Simulation +from policyengine_uk.utils.measurement_branches import measurement_branch + # The response, and the part of it on gains qualifying for Business Asset # Disposal Relief, which capital_gains_tax reads to keep those gains apart. @@ -22,29 +24,19 @@ def measure_mtr( ) -> np.ndarray: """Measure the capital gains MTR in a simulation, holding gains fixed. - The branch clones the tax-benefit system because it neutralises the - behavioural response variable, which would otherwise recurse back into - this measurement. Cloning keeps that neutralisation off the simulation - being measured. + The measurement branch neutralises the behavioural responses and measures + from inputs alone, so the rate is taken at ``gains`` and at earnings + before any labour supply response, even where the simulation has already + calculated gains after a response, or at gains of its own (see + ``measurement_branch``). """ - # get_branch returns an existing branch of the requested name without - # honouring clone_system, and neutralising on a shared system would - # permanently disable the response variable for the caller. Take a name - # nothing else holds, then require the clone before touching it. - while branch_name in simulation.branches: - branch_name += "_" - branch = simulation.get_branch(branch_name, clone_system=True) - if branch.tax_benefit_system is simulation.tax_benefit_system: - raise RuntimeError( - "Capital gains MTR measurement requires a cloned tax-benefit " - "system; refusing to neutralise on the simulation's own." - ) - for variable in RESPONSE_VARIABLES: - branch.tax_benefit_system.neutralize_variable(variable) - branch.set_input("capital_gains_before_response", period, gains) - mtr = branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) - del simulation.branches[branch_name] - return mtr + branch = measurement_branch( + simulation, + branch_name, + period, + {"capital_gains_before_response": gains}, + ) + return branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) def measure_capital_gains_mtrs(person, period) -> tuple[np.ndarray, np.ndarray]: diff --git a/policyengine_uk/utils/labour_supply_responses.py b/policyengine_uk/utils/labour_supply_responses.py new file mode 100644 index 0000000000..07157407d2 --- /dev/null +++ b/policyengine_uk/utils/labour_supply_responses.py @@ -0,0 +1,68 @@ +"""Branches that measure a reform and its baseline with labour supply held +fixed, for the elasticity-based labour supply responses.""" + +from contextlib import contextmanager + +from policyengine_uk.utils.measurement_branches import measurement_branch + +# Set on both branches to the values of the simulation whose response is being +# measured, so that the two differ in policy alone. +HELD_INPUTS = ("employment_income_before_lsr", "capital_gains_before_response") + +# The pair of branches of each measurement under way, by simulation and period. +# The income and substitution terms of one response read the same pair. +_open_measurements = {} + + +def response_source(simulation): + """The simulation whose labour supply response ``simulation`` takes. + + A branch that shares its parent's tax-benefit system changes inputs, not + policy: a helper branch that fixes one input to break a cycle (the Pension + Credit branch of the UC non-dependant exemption, say), or a marginal + rate's step. The response answers the policy, so labour supply there is + where the parent puts it. Measured again, the response would compare the + branch's inputs with a baseline that does not have them, and repeat a + measurement of the whole population. + """ + parent = getattr(simulation, "parent_branch", None) + while ( + parent is not None + and parent.tax_benefit_system is simulation.tax_benefit_system + ): + simulation, parent = parent, getattr(parent, "parent_branch", None) + return simulation + + +@contextmanager +def measurement_branches(person, period): + """Yield the branches that measure the reform and the baseline with labour + supply held fixed, as (reform, baseline). + + Simulations hold their baseline as a separately constructed simulation + (``simulation.baseline``) rather than a branch named ``baseline``, so the + baseline is measured there. A branch of the reform simulation carries + reform parameters, and reports no change in net income or marginal rates + however large the reform. + + The pair lasts as long as the outermost use: the response opens it, and + the relative income and wage changes it then asks for read the same pair. + """ + simulation = person.simulation + key = (id(simulation), period) + if key in _open_measurements: + yield _open_measurements[key] + return + inputs = {variable: person(variable, period) for variable in HELD_INPUTS} + branches = tuple( + measurement_branch(sim, name, period, inputs) + for sim, name in ( + (simulation, "lsr_measurement"), + (simulation.baseline, "baseline_lsr_measurement"), + ) + ) + _open_measurements[key] = branches + try: + yield branches + finally: + del _open_measurements[key] diff --git a/policyengine_uk/utils/measurement_branches.py b/policyengine_uk/utils/measurement_branches.py new file mode 100644 index 0000000000..bd6c901cd1 --- /dev/null +++ b/policyengine_uk/utils/measurement_branches.py @@ -0,0 +1,120 @@ +"""Branches that measure a simulation's results at inputs set on the branch. + +Behavioural responses compare a reform with its baseline at the same inputs +before the response: the labour supply responses at the reform's earnings and +gains, and the capital gains response at the reform's gains. Each measurement +runs in a branch of the reform simulation or of its baseline, with every +behavioural response neutralised and those inputs set. +""" + +from typing import Dict + +import numpy as np + +# Every behavioural response. Each is measured from the position before any +# response, so one response's measurement does not include another response, +# and does not recompute it inside its own branches: the labour supply +# measurement takes gains before their realisation response, and the capital +# gains measurement takes earnings before the labour supply response. +BEHAVIOURAL_RESPONSES = ( + "employment_income_behavioral_response", + "capital_gains_behavioural_response", + "capital_gains_badr_behavioural_response", +) + + +def drop_calculated_values(simulation) -> None: + """Delete the values a simulation has calculated, keeping its inputs. + + Inputs are variables without a formula, variables the simulation was built + from, and variables set since through set_input, as a scenario's + simulation modifier does to formula variables. Only variables the + simulation holds values for are visited. + """ + supplied = set(getattr(simulation, "input_variables", None) or ()) + supplied.update( + name for name, _, _ in getattr(simulation, "_user_input_keys", None) or () + ) + variables = simulation.tax_benefit_system.variables + held = { + name + for population in simulation.populations.values() + for name in getattr(population, "_holders", {}) + } + for name in held: + variable = variables.get(name) + if variable is None or name in supplied or variable.is_input_variable(): + continue + simulation.delete_arrays(name) + + +def held_behavioural_responses(person, period) -> dict: + """The behavioural responses a simulation calculated, to hold fixed in a + branch that steps one of its inputs to measure a marginal rate. + + The branch shares the simulation's tax-benefit system. A response + recalculated there would be measured again: at the stepped input, against + a baseline without the step, and with the step undone inside the + measurement, whose own marginal rate branches recalculate every variable + with a formula. A marginal rate is the change at fixed behaviour, so the + branch reads the simulation's responses instead. Responses neutralised in + the simulation (as in a measurement branch) are not held. + """ + variables = person.simulation.tax_benefit_system.variables + return { + response: person(response, period) + for response in BEHAVIOURAL_RESPONSES + if not variables[response].is_neutralized + } + + +def hold_behavioural_responses(branch, period, held: dict) -> None: + """Set the responses :func:`held_behavioural_responses` returned on a + branch, so the branch reads them instead of measuring them again.""" + for response, values in held.items(): + branch.set_input(response, period, values) + + +def measurement_branch( + simulation, + name: str, + period, + inputs: Dict[str, np.ndarray], +): + """A branch of ``simulation`` with every behavioural response neutralised + and ``inputs`` set for ``period``. + + The branch clones the tax-benefit system, so neutralising the responses + there keeps them live in the simulation, and no response recurses back + into a measurement. + + A branch starts from every value its simulation holds, and would read + those in place of values at the inputs set here: results the simulation + calculated at its own inputs before the response (a baseline's, if its + data differ from the reform's), or results that include a response it + calculated earlier. So the branch drops what the simulation calculated and + measures from inputs alone. Its results then do not depend on what either + simulation calculated before. + + Only the caller holds the branch: it is not kept in ``simulation.branches``, + so the next measurement starts afresh. + """ + # get_branch returns an existing branch of the requested name without + # honouring clone_system, and neutralising on a shared system would + # permanently disable the response for the simulation. Take a name nothing + # else holds, then require the clone before touching it. + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name, clone_system=True) + del simulation.branches[name] + if branch.tax_benefit_system is simulation.tax_benefit_system: + raise RuntimeError( + "A measurement branch requires a cloned tax-benefit system; " + "refusing to neutralise on the simulation's own." + ) + for variable in BEHAVIOURAL_RESPONSES: + branch.tax_benefit_system.neutralize_variable(variable) + drop_calculated_values(branch) + for variable, value in inputs.items(): + branch.set_input(variable, period, value) + return branch diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py index 51b10488ad..2fa7250e52 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * -from policyengine_uk.utils.capital_gains import RESPONSE_VARIABLES +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) from policyengine_uk.utils.marginal_rates import marginal_rate_step @@ -28,6 +31,10 @@ def formula(person, period, parameters): gains = person("capital_gains", period) step = marginal_rate_step(gains, minimum=1_000) adult_index_values = person("adult_index_cg", period) + # Hold every behavioural response at this simulation's values, so each + # branch reads its schedule split, and its earnings, instead of + # measuring the responses again at the stepped gains. + held_responses = held_behavioural_responses(person, period) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index @@ -38,12 +45,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) - # Hold the realisation response at this simulation's values, so the - # branch reads its schedule split instead of measuring it again. - for response in RESPONSE_VARIABLES: - response_variable = simulation.tax_benefit_system.variables[response] - if not response_variable.is_neutralized: - alt_simulation.set_input(response, period, person(response, period)) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input("capital_gains", period, gains + mask * step) alt_person = alt_simulation.person # Float32 rounds the higher gains, so the rise actually stored can diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py index e9becafc4d..543be92411 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/employment_income_behavioral_response.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import ( + measurement_branches, + response_source, +) class employment_income_behavioral_response(Variable): @@ -15,33 +19,22 @@ def formula(person, period, parameters): return 0 # No reform, no impact if lsr.income_elasticity == 0 and lsr.substitution_elasticity == 0: return 0 - - measurement_branch = simulation.get_branch( - "lsr_measurement", clone_system=True - ) # A branch without LSRs - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement", clone_system=True - ) # Already created by default + # A branch with its parent's rules takes the parent's response. + source = response_source(simulation) + if source is not simulation: + return source.person("employment_income_behavioral_response", period) # (system with LSRs) <- (system without LSRs used to calculate LSRs) # | # * -(baseline system without LSRs used to calculate LSRs) - for branch in [measurement_branch, baseline_branch]: - branch.tax_benefit_system.neutralize_variable( - "employment_income_behavioral_response" - ) - branch.set_input( - "employment_income_before_lsr", + # Both terms measure on the one pair of branches opened here. + with measurement_branches(person, period): + return add( + person, period, - person("employment_income_before_lsr", period), + [ + "income_elasticity_lsr", + "substitution_elasticity_lsr", + ], ) - - return add( - person, - period, - [ - "income_elasticity_lsr", - "substitution_elasticity_lsr", - ], - ) diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py index feccb02afc..8eab8e9001 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/income_elasticity_lsr.py @@ -11,6 +11,9 @@ class income_elasticity_lsr(Variable): def formula(person, period, parameters): lsr = parameters(period).gov.simulation.labour_supply_responses + if lsr.income_elasticity == 0: + # Zero without measuring relative_income_change, which needs a measurement. + return 0 employment_income = max_(person("employment_income_before_lsr", period), 0) income_change = person("relative_income_change", period) diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py index b7a8d463e9..63737159fd 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_income_change.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import measurement_branches class relative_income_change(Variable): @@ -10,20 +11,18 @@ class relative_income_change(Variable): requires_computation_after = "employment_income_behavioral_response" def formula(person, period, parameters): - simulation = person.simulation - measurement_branch = simulation.get_branch("lsr_measurement") - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - measurement_person = measurement_branch.populations["person"] - baseline_person = baseline_branch.populations["person"] - baseline_net_income = baseline_person.household("household_net_income", period) - net_income = measurement_person.household("household_net_income", period) + if person.simulation.baseline is None: + return 0 # No reform, no change + with measurement_branches(person, period) as ( + measurement_branch, + baseline_branch, + ): + measurement_person = measurement_branch.populations["person"] + baseline_person = baseline_branch.populations["person"] + baseline_net_income = baseline_person.household( + "household_net_income", period + ) + net_income = measurement_person.household("household_net_income", period) income_change_bound = parameters( period ).gov.simulation.labour_supply_responses.bounds.income_change diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py index 872c5d4fb1..4d12d853cc 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/relative_wage_change.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.labour_supply_responses import measurement_branches class relative_wage_change(Variable): @@ -10,21 +11,17 @@ class relative_wage_change(Variable): requires_computation_after = "employment_income_behavioral_response" def formula(person, period, parameters): - simulation = person.simulation - measurement_branch = simulation.get_branch("lsr_measurement") - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_lsr_measurement" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - measurement_person = measurement_branch.populations["person"] - baseline_person = baseline_branch.populations["person"] - baseline_mtr = baseline_person("marginal_tax_rate", period) + if person.simulation.baseline is None: + return 0 # No reform, no change + with measurement_branches(person, period) as ( + measurement_branch, + baseline_branch, + ): + measurement_person = measurement_branch.populations["person"] + baseline_person = baseline_branch.populations["person"] + baseline_mtr = baseline_person("marginal_tax_rate", period) + mtr = measurement_person("marginal_tax_rate", period) baseline_wage = 1 - baseline_mtr - mtr = measurement_person("marginal_tax_rate", period) wage_rate = 1 - mtr # _c suffix for "clipped" baseline_wage_c = np.where(baseline_wage == 0, 0.01, baseline_wage) diff --git a/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py b/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py index a8631bc942..106c9bf3b6 100644 --- a/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py +++ b/policyengine_uk/variables/gov/simulation/labour_supply_response/substitution_elasticity_lsr.py @@ -11,6 +11,9 @@ class substitution_elasticity_lsr(Variable): def formula(person, period, parameters): lsr = parameters(period).gov.simulation.labour_supply_responses + if lsr.substitution_elasticity == 0: + # Zero without measuring relative_wage_change, which needs a measurement. + return 0 employment_income = max_(person("employment_income_before_lsr", period), 0) wage_change = person("relative_wage_change", period) diff --git a/policyengine_uk/variables/household/marginal_tax_rate.py b/policyengine_uk/variables/household/marginal_tax_rate.py index ce512aeabd..768867cc1b 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate.py +++ b/policyengine_uk/variables/household/marginal_tax_rate.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable from policyengine_uk.utils.marginal_rates import marginal_rate_step +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) class marginal_tax_rate(Variable): @@ -25,6 +29,9 @@ def formula(person, period, parameters): employment_income = person("employment_income", period) step = marginal_rate_step(employment_income, p.marginal_tax_rate_delta) adult_count = p.marginal_tax_rate_adults + # The pay rise is not a reform: each branch holds the behavioural + # responses at this simulation's values. + held_responses = held_behavioural_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch(f"adult_{adult_index}_pay_rise") mask = adult_index_values == adult_index @@ -35,6 +42,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input( "employment_income", period, diff --git a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py index 6adf0f0e60..d5d7709e6d 100644 --- a/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py +++ b/policyengine_uk/variables/household/marginal_tax_rate_wrt_employer_cost.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * from policyengine_core.variables import Variable from policyengine_uk.utils.marginal_rates import marginal_rate_step +from policyengine_uk.utils.measurement_branches import ( + held_behavioural_responses, + hold_behavioural_responses, +) class marginal_tax_rate_wrt_employer_cost(Variable): @@ -29,6 +33,9 @@ def formula(person, period, parameters): adult_count = p.marginal_tax_rate_adults baseline_employer_cost = person("employer_cost", period) baseline_net = person.household("household_net_income", period) + # The pay rise is not a reform: each branch holds the behavioural + # responses at this simulation's values. + held_responses = held_behavioural_responses(person, period) for adult_index in range(1, 1 + adult_count): alt_simulation = simulation.get_branch( f"adult_{adult_index}_employer_cost_mtr" @@ -41,6 +48,7 @@ def formula(person, period, parameters): and not variable_data.is_input_variable() ): alt_simulation.delete_arrays(variable) + hold_behavioural_responses(alt_simulation, period, held_responses) alt_simulation.set_input( "employment_income", period,