Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/fix-lsr-baseline-2268.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/fix-lsr-baseline-2268.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions docs/book/usage/dynamics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
98 changes: 98 additions & 0 deletions policyengine_uk/tests/test_capital_gains_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading