Skip to content

Elasticity-based labour supply responses compare a reform with a copy of itself #2268

Open

Description

@MaxGhenis

What happens

The elasticity-based labour supply response variables compare a reform with a copy of itself, so they return zero for a reform that changes marginal tax rates.

Reproduction

On main (2.125.2, policyengine-core 3.32.27):

import numpy as np
from policyengine_uk import Simulation

situation = {
    "people": {"person": {"age": {2025: 30}, "employment_income": {2025: 40_000}, "hours_worked": {2025: 1800}}},
    "benunits": {"benunit": {"members": ["person"]}},
    "households": {"household": {"members": ["person"]}},
}
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},
}
sim = Simulation(situation=situation, reform=reform)
rate = lambda s: float(s.tax_benefit_system.parameters("2025").gov.hmrc.income_tax.rates.uk.rates[0])
print(rate(sim), rate(sim.baseline), rate(sim.get_branch("baseline")))  # 0.3 0.2 0.3
print("baseline" in sim.branches, sim.get_branch("baseline") is sim.baseline)  # False False

sim = Simulation(situation=situation, reform=reform)
print(sim.calculate("income_tax", 2025), sim.baseline.calculate("income_tax", 2025))  # [8229.] [5486.]
for variable in ("relative_wage_change", "relative_income_change", "employment_income_behavioral_response"):
    print(variable, sim.calculate(variable, 2025))  # [0.] each

Income tax rises from £5,486 to £8,229 and the basic rate from 20% to 30%, yet the relative wage change, the relative income change and the response are all zero.

Cause, read in the code

relative_wage_change, relative_income_change and employment_income_behavioral_response (policyengine_uk/variables/gov/simulation/labour_supply_response/) take the baseline from simulation.get_branch("baseline"); the comment says it is "Already created by default". That is policyengine-core's convention, where a reform simulation registers its baseline as the branch baseline. policyengine_uk.Simulation.__init__ does not: it keeps the baseline as a separate simulation in simulation.baseline and leaves simulation.branches empty. So get_branch("baseline") makes a new branch, which is a clone of the reform simulation with the reform's parameters.

policyengine_uk/dynamics/ (the OBR labour supply responses behind apply_dynamics) reads sim.baseline directly and is not affected.

Suggested fix

Take the baseline from simulation.baseline (for example simulation.baseline.get_branch("baseline_lsr_measurement", ...)), and add tests with a reform that changes a marginal rate and expects a non-zero response of the right sign. No test covers a non-zero case today. State the change in aggregates for reforms that set these elasticities.

Found while fixing #2254; #2265 does not touch this code.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions