diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 04b71efb9..0987cde7c 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -280,6 +280,30 @@ benefit_cap_reduction = sim.calculate("benefit_cap_reduction", 2026).mean() print(f"Benefit cap reduction after abolition: £{benefit_cap_reduction:.0f}") ``` +### Removing economic assumptions + +If you want to isolate policy effects from forecast-driven uprating, use the +`no_economic_assumptions` scenario. It applies before data load, so it freezes +both parameter uprating and any dataset extension that depends on forecast +growth rates. + +```python +from policyengine_uk import Simulation +from policyengine_uk.scenarios import no_economic_assumptions + +situation = { + "people": {"person": {"age": {2025: 40}, "employment_income": {2025: 30_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + +baseline = Simulation(situation=situation) +static = Simulation(situation=situation, scenario=no_economic_assumptions) + +print(float(baseline.calculate("employment_income", 2026)[0])) +print(float(static.calculate("employment_income", 2026)[0])) +``` + ## Advanced scenario techniques ### Time-varying parameters diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 1a4a957d4..87e2f9dd5 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -357,3 +357,31 @@ def reset_uprating( dataset.datasets[year].time_period = str(year) return dataset + + +def reset_growthfactor_uprating( + dataset: UKMultiYearDataset, +): + with open(Path(__file__).parent / "uprating_indices.yaml", "r") as f: + uprating = yaml.safe_load(f) + + growthfactor_uprated_variables = { + variable for variables in uprating.values() for variable in variables + } + growthfactor_uprated_variables.update({"council_tax", "rent"}) + + first_year = min(dataset.datasets.keys()) + base_year_dataset = dataset.datasets[first_year] + + for year in dataset.datasets: + if year == first_year: + continue + current_year_dataset = dataset.datasets[year] + for table_name in current_year_dataset.table_names: + base_table = getattr(base_year_dataset, table_name) + current_table = getattr(current_year_dataset, table_name) + for variable in growthfactor_uprated_variables: + if variable in current_table.columns and variable in base_table.columns: + current_table[variable] = base_table[variable].values + + return dataset diff --git a/policyengine_uk/scenarios/__init__.py b/policyengine_uk/scenarios/__init__.py index f6d755250..023c243f1 100644 --- a/policyengine_uk/scenarios/__init__.py +++ b/policyengine_uk/scenarios/__init__.py @@ -1,4 +1,5 @@ from .abolish_benefit_cap import abolish_benefit_cap +from .no_economic_assumptions import no_economic_assumptions from .pip_reform import reform_pip_phase_in from .reindex_benefit_cap import reindex_benefit_cap from .repeal_two_child_limit import repeal_two_child_limit diff --git a/policyengine_uk/scenarios/no_economic_assumptions.py b/policyengine_uk/scenarios/no_economic_assumptions.py new file mode 100644 index 000000000..55a60abbe --- /dev/null +++ b/policyengine_uk/scenarios/no_economic_assumptions.py @@ -0,0 +1,29 @@ +from policyengine_core.parameters import Parameter + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario + + +def remove_economic_assumptions(simulation: Simulation): + simulation.disable_economic_assumptions = True + simulation.tax_benefit_system.reset_parameters() + + cutoff = f"{simulation.default_input_period}-01-01" + yoy_growth = ( + simulation.tax_benefit_system.parameters.gov.economic_assumptions.yoy_growth + ) + + for parameter in yoy_growth.get_descendants(): + if not isinstance(parameter, Parameter): + continue + for value_at_instant in parameter.values_list: + if value_at_instant.instant_str >= cutoff: + value_at_instant.value = 0 + + simulation.tax_benefit_system.process_parameters() + + +no_economic_assumptions = Scenario( + simulation_modifier=remove_economic_assumptions, + applied_before_data_load=True, +) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 90260351e..9fa5a356c 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -24,6 +24,7 @@ from policyengine_uk.utils.scenario import Scenario from policyengine_uk.data.economic_assumptions import ( extend_single_year_dataset, + reset_growthfactor_uprating, ) from policyengine_uk.utils.dependencies import get_variable_dependencies from policyengine_uk.reforms import create_structural_reforms_from_parameters @@ -131,6 +132,7 @@ def __init__( self.max_spiral_loops: int = 10 self.memory_config = None self._data_storage_dir: Optional[str] = None + self.disable_economic_assumptions: bool = False self.branches: Dict[str, Simulation] = {} @@ -424,6 +426,10 @@ def build_from_multi_year_dataset(self, dataset: UKMultiYearDataset) -> None: Args: dataset: UKMultiYearDataset containing multiple years of data """ + if self.disable_economic_assumptions: + dataset = dataset.copy() + reset_growthfactor_uprating(dataset) + # Ensure enum columns are encoded and _enum_columns is populated so # that .person/.benunit/.household properties can decode back to strings. if not any(dataset[y]._enum_columns for y in dataset.years): diff --git a/policyengine_uk/tests/test_no_economic_assumptions.py b/policyengine_uk/tests/test_no_economic_assumptions.py new file mode 100644 index 000000000..93c3c3ff8 --- /dev/null +++ b/policyengine_uk/tests/test_no_economic_assumptions.py @@ -0,0 +1,98 @@ +import pandas as pd +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import ( + UKMultiYearDataset, + UKSingleYearDataset, +) +from policyengine_uk.scenarios import no_economic_assumptions + + +def test_no_economic_assumptions_freezes_growthfactor_uprating(): + situation = { + "people": { + "person": { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + baseline = Simulation(situation=situation) + reformed = Simulation(situation=situation, scenario=no_economic_assumptions) + + baseline_income_2025 = float(baseline.calculate("employment_income", 2025)[0]) + baseline_income_2026 = float(baseline.calculate("employment_income", 2026)[0]) + reformed_income_2025 = float(reformed.calculate("employment_income", 2025)[0]) + reformed_income_2026 = float(reformed.calculate("employment_income", 2026)[0]) + + assert baseline_income_2026 > baseline_income_2025 + assert reformed_income_2026 == pytest.approx(reformed_income_2025) + + baseline_fees = baseline.tax_benefit_system.parameters.gov.simulation.private_school_vat.private_school_fees + reformed_fees = reformed.tax_benefit_system.parameters.gov.simulation.private_school_vat.private_school_fees + assert float(baseline_fees("2026")) > float(baseline_fees("2025")) + assert float(reformed_fees("2026")) == pytest.approx(float(reformed_fees("2025"))) + + baseline_benefit_index = ( + baseline.tax_benefit_system.parameters.gov.benefit_uprating_cpi + ) + reformed_benefit_index = ( + reformed.tax_benefit_system.parameters.gov.benefit_uprating_cpi + ) + assert float(baseline_benefit_index("2026")) > float(baseline_benefit_index("2025")) + assert float(reformed_benefit_index("2026")) == pytest.approx( + float(reformed_benefit_index("2025")) + ) + + +def test_no_economic_assumptions_resets_multi_year_dataset_without_mutating_input(): + person_2025 = pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "employment_income": [30_000.0], + "age": [40], + } + ) + person_2026 = person_2025.copy() + person_2026["employment_income"] = [31_019.96875] + person_2026["age"] = [41] + + benunit = pd.DataFrame({"benunit_id": [1]}) + household = pd.DataFrame({"household_id": [1]}) + + dataset = UKMultiYearDataset( + datasets=[ + UKSingleYearDataset( + person=person_2025, + benunit=benunit.copy(), + household=household.copy(), + fiscal_year=2025, + ), + UKSingleYearDataset( + person=person_2026, + benunit=benunit.copy(), + household=household.copy(), + fiscal_year=2026, + ), + ] + ) + + baseline = Simulation(dataset=dataset) + reformed = Simulation(dataset=dataset, scenario=no_economic_assumptions) + + assert float(baseline.calculate("employment_income", 2026)[0]) == pytest.approx( + 31_019.96875 + ) + assert float(reformed.calculate("employment_income", 2026)[0]) == pytest.approx( + 30_000.0 + ) + assert int(reformed.calculate("age", 2026)[0]) == 41 + assert dataset[2026].person["employment_income"].iloc[0] == pytest.approx( + 31_019.96875 + )