Skip to content

Commit a1832dc

Browse files
Switch from per capita employment income to average earnings forecast (#1205)
* Switch from per capita employment income to average earnings forecast * Add changelog entry * Add reform tests * Add updateable reform impact tests * Add printout in tests * Remove duplication of microsim runs * Run these first so they always run if tests fails * Update verbosity * Remove backup file, also add printout of changes to growfactors * Format * Don't pass tests by definition! * Update tests * Try locking dependencies * Remove system arg * Use python 3.11 * Use different toml syntax * Remove ubuntu test * Add UV sync to main action --------- Co-authored-by: Nikhil Woodruff <nikhil.woodruff@outlook.com>
1 parent e42cb40 commit a1832dc

18 files changed

Lines changed: 2962 additions & 79 deletions

.github/workflows/code_changes.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ jobs:
4444
run: uv pip install -e .[dev] --system
4545
- name: Install policyengine
4646
run: uv pip install policyengine --system
47+
- name: UV sync
48+
run: uv sync
4749
- name: Run tests
4850
run: make test
4951
env:

.github/workflows/pr_code_changes.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
Test:
2424
strategy:
2525
matrix:
26-
os: [ubuntu-latest, macos-latest]
26+
os: [macos-latest]
2727
runs-on: ${{ matrix.os }}
2828
permissions:
2929
contents: "read"
@@ -37,7 +37,7 @@ jobs:
3737
- name: Set up Python
3838
uses: actions/setup-python@v2
3939
with:
40-
python-version: '3.12'
40+
python-version: '3.11'
4141
- uses: "google-github-actions/auth@v2"
4242
with:
4343
workload_identity_provider: "projects/322898545428/locations/global/workloadIdentityPools/policyengine-research-id-pool/providers/prod-github-provider"
@@ -47,9 +47,19 @@ jobs:
4747
run: uv pip install -e .[dev] --system
4848
- name: Install policyengine
4949
run: uv pip install policyengine --system
50+
- name: UV sync
51+
run: uv sync
5052
- name: Run tests
5153
run: make test
5254
env:
5355
HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
56+
- name: Update tests
57+
run: make update-tests
58+
env:
59+
HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
60+
- name: Save dataset
61+
run: make update-tests
62+
env:
63+
HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
5464
- name: Test documentation builds
5565
run: make documentation

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
all: install
2-
pip install wheel
3-
python setup.py sdist bdist_wheel
2+
pip install build
3+
python -m build
44

55
install:
66
pip install policyengine
@@ -14,7 +14,10 @@ format:
1414
test:
1515
policyengine-core test policyengine_uk/tests/policy -c policyengine_uk
1616
pytest policyengine_uk/tests/ -v
17+
18+
update-tests:
1719
python policyengine_uk/data/economic_assumptions.py
20+
python policyengine_uk/tests/microsimulation/update_reform_impacts.py
1821

1922
documentation:
2023
jb clean docs/book

changelog_entry.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- bump: minor
2+
changes:
3+
changed:
4+
- Earnings uprated with OBR average earnings rather than per-capita employment income.

policyengine_uk/data/economic_assumptions.py

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
END_YEAR = 2029
77

88

9-
def create_policyengine_uprating_factors_table():
9+
def create_policyengine_uprating_factors_table(print_diff=True):
1010
from policyengine_uk.system import system
1111

1212
df = pd.DataFrame()
@@ -24,7 +24,7 @@ def create_policyengine_uprating_factors_table():
2424
start_value = parameter(START_YEAR)
2525
for year in range(START_YEAR, END_YEAR + 1):
2626
variable_names.append(variable.name)
27-
years.append(year)
27+
years.append(str(year)) # Convert to string here
2828
growth = parameter(year) / start_value
2929
index_values.append(round(growth, 3))
3030

@@ -40,27 +40,121 @@ def create_policyengine_uprating_factors_table():
4040

4141
df_growth = df.copy()
4242
for year in range(END_YEAR, START_YEAR, -1):
43-
df_growth[year] = round(df_growth[year] / df_growth[year - 1] - 1, 3)
44-
df_growth[START_YEAR] = 0
43+
year_str = str(year)
44+
prev_year_str = str(year - 1)
45+
df_growth[year_str] = round(
46+
df_growth[year_str] / df_growth[prev_year_str] - 1, 3
47+
)
48+
df_growth[str(START_YEAR)] = 0
4549

4650
file_path = Path(__file__).parent / "uprating_growth_factors.csv"
51+
52+
# Read old CSV if it exists
53+
old_df = None
54+
if file_path.exists():
55+
old_df = pd.read_csv(file_path, index_col=0)
56+
# Ensure all columns are strings in old_df
57+
old_df.columns = old_df.columns.astype(str)
58+
59+
# Prepare new dataframe
4760
df_growth["Parameter"] = df.index.map(parameter_by_variable)
4861
df_growth = df_growth[
49-
["Parameter"] + [year for year in range(START_YEAR, END_YEAR + 1)]
62+
["Parameter"] + [str(year) for year in range(START_YEAR, END_YEAR + 1)]
5063
]
51-
df_growth.to_csv(file_path)
64+
65+
# Print diff if old CSV existed and print_diff is True
66+
if old_df is not None and print_diff:
67+
print_csv_diff(old_df, df_growth)
68+
# Save new CSV
69+
df_growth.to_csv(file_path)
70+
5271
return pd.read_csv(file_path)
5372

5473

74+
def print_csv_diff(old_df, new_df):
75+
"""Print differences between old and new dataframes."""
76+
print("\n" + "=" * 80)
77+
print("CSV diff report")
78+
print("=" * 80)
79+
80+
# Check for new rows
81+
new_rows = set(new_df.index) - set(old_df.index)
82+
if new_rows:
83+
print(f"\n✅ New rows added ({len(new_rows)}):")
84+
for row in sorted(new_rows):
85+
print(f" - {row}")
86+
87+
# Check for deleted rows
88+
deleted_rows = set(old_df.index) - set(new_df.index)
89+
if deleted_rows:
90+
print(f"\n❌ Rows deleted ({len(deleted_rows)}):")
91+
for row in sorted(deleted_rows):
92+
print(f" - {row}")
93+
94+
# Check for changed values
95+
common_rows = set(old_df.index) & set(new_df.index)
96+
common_cols = set(old_df.columns) & set(new_df.columns)
97+
98+
changes = []
99+
for row in common_rows:
100+
for col in common_cols:
101+
old_val = old_df.loc[row, col]
102+
new_val = new_df.loc[row, col]
103+
104+
# Handle NaN values
105+
if pd.isna(old_val) and pd.isna(new_val):
106+
continue
107+
elif pd.isna(old_val) or pd.isna(new_val):
108+
changes.append((row, col, old_val, new_val))
109+
elif old_val != new_val:
110+
changes.append((row, col, old_val, new_val))
111+
112+
if changes:
113+
print(f"\n🔄 Value changes ({len(changes)}):")
114+
print(
115+
f"{'Variable':<30} {'Column':<15} {'Old value':<15} {'New value':<15}"
116+
)
117+
print("-" * 75)
118+
for row, col, old_val, new_val in sorted(changes):
119+
old_str = str(old_val) if not pd.isna(old_val) else "NaN"
120+
new_str = str(new_val) if not pd.isna(new_val) else "NaN"
121+
print(f"{row:<30} {str(col):<15} {old_str:<15} {new_str:<15}")
122+
123+
# Check for new columns
124+
new_cols = set(new_df.columns) - set(old_df.columns)
125+
if new_cols:
126+
print(f"\n✅ New columns added ({len(new_cols)}):")
127+
for col in sorted(new_cols):
128+
print(f" - {col}")
129+
130+
# Check for deleted columns
131+
deleted_cols = set(old_df.columns) - set(new_df.columns)
132+
if deleted_cols:
133+
print(f"\n❌ Columns deleted ({len(deleted_cols)}):")
134+
for col in sorted(deleted_cols):
135+
print(f" - {col}")
136+
137+
if not (new_rows or deleted_rows or changes or new_cols or deleted_cols):
138+
print("\n✨ No changes detected - CSV is identical!")
139+
140+
print("\n" + "=" * 80 + "\n")
141+
142+
55143
def convert_yoy_growth_to_index(
56144
growth_factors: pd.DataFrame,
57145
):
58146
"""
59147
Convert year-on-year growth factors to an index.
60148
"""
61149
growth_factors = growth_factors.copy()
62-
index = growth_factors[growth_factors.columns[2]] * 0 + 1
63-
for year in growth_factors.columns[2:]:
150+
# Get the first year column (skip 'Variable' and 'Parameter' columns)
151+
year_columns = [
152+
col
153+
for col in growth_factors.columns
154+
if col not in ["Variable", "Parameter"]
155+
]
156+
index = growth_factors[year_columns[0]] * 0 + 1
157+
for year in year_columns:
64158
index *= 1 + growth_factors[year]
65159
growth_factors[year] = index
66160
return growth_factors
@@ -88,8 +182,11 @@ def apply_growth_factors(
88182
return dataset
89183

90184

91-
BASELINE_GROWFACTORS = create_policyengine_uprating_factors_table()
185+
BASELINE_GROWFACTORS = create_policyengine_uprating_factors_table(
186+
print_diff=False
187+
)
92188

93189

94190
if __name__ == "__main__":
95-
create_policyengine_uprating_factors_table()
191+
# Print diff when running as script
192+
create_policyengine_uprating_factors_table(print_diff=True)

policyengine_uk/data/uprating_growth_factors.csv

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ dla_m_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02
1919
dla_sc_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
2020
domestic_energy_consumption,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
2121
education_consumption,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
22-
employee_pension_contributions,gov.obr.per_capita.employment_income,0,0.072,0.074,0.062,0.05,0.033,0.02,0.019,0.023,0.026
23-
employer_pension_contributions,gov.obr.per_capita.employment_income,0,0.072,0.074,0.062,0.05,0.033,0.02,0.019,0.023,0.026
24-
employment_income,gov.obr.per_capita.employment_income,0,0.072,0.074,0.062,0.05,0.033,0.02,0.019,0.023,0.026
25-
employment_income_before_lsr,gov.obr.per_capita.employment_income,0,0.072,0.074,0.062,0.05,0.033,0.02,0.019,0.023,0.026
22+
employee_pension_contributions,gov.obr.average_earnings,0,0.058,0.065,0.068,0.047,0.038,0.021,0.021,0.023,0.025
23+
employer_pension_contributions,gov.obr.average_earnings,0,0.058,0.065,0.068,0.047,0.038,0.021,0.021,0.023,0.025
24+
employment_income,gov.obr.average_earnings,0,0.058,0.065,0.068,0.047,0.038,0.021,0.021,0.023,0.025
25+
employment_income_before_lsr,gov.obr.average_earnings,0,0.058,0.065,0.068,0.047,0.038,0.021,0.021,0.023,0.025
2626
esa_contrib_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
2727
esa_income_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
2828
food_and_non_alcoholic_beverages_consumption,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
29+
free_school_fruit_veg,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
30+
free_school_meals,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
2931
free_school_milk,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
3032
gross_financial_wealth,household.wealth.financial_assets,0,0.014,-0.108,0.004,0.03,0.032,0.034,0.034,0.036,0.0
3133
health_consumption,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
@@ -54,7 +56,7 @@ other_residential_property_value,household.wealth.financial_assets,0,0.014,-0.10
5456
owned_land,household.wealth.financial_assets,0,0.014,-0.108,0.004,0.03,0.032,0.034,0.034,0.036,0.0
5557
pension_credit_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
5658
pension_income,gov.obr.per_capita.gdp,0,0.125,0.092,0.05,0.038,0.028,0.028,0.031,0.033,0.033
57-
personal_pension_contributions,gov.obr.per_capita.employment_income,0,0.072,0.074,0.062,0.05,0.033,0.02,0.019,0.023,0.026
59+
personal_pension_contributions,gov.obr.average_earnings,0,0.058,0.065,0.068,0.047,0.038,0.021,0.021,0.023,0.025
5860
petrol_spending,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
5961
pip_dl_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
6062
pip_m_reported,gov.obr.consumer_price_index,0,0.039,0.101,0.057,0.023,0.032,0.02,0.02,0.02,0.02
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# PolicyEngine UK reform impact tests
2+
3+
This directory contains automated tests to monitor when model changes affect the fiscal impacts of policy reforms. It helps ensure that updates to the PolicyEngine UK model don't unexpectedly change reform outcomes.
4+
5+
## Overview
6+
7+
The testing framework consists of three main components:
8+
9+
1. **reforms_config.yaml** - Configuration file containing reform definitions and their expected impacts
10+
2. **test_reform_impacts.py** - Pytest test suite that verifies reforms produce expected impacts
11+
3. **update_reform_impacts.py** - Script to update expected impacts when model changes are intentional
12+
13+
## Quick start
14+
15+
### Running tests
16+
17+
To check if current model produces expected reform impacts:
18+
19+
```bash
20+
pytest test_reform_impacts.py
21+
```
22+
23+
To run with more detail:
24+
25+
```bash
26+
pytest test_reform_impacts.py -v
27+
```
28+
29+
### Updating expected impacts
30+
31+
When model changes are intentional and you need to update the baseline expectations:
32+
33+
```bash
34+
# Preview changes without updating
35+
python update_reform_impacts.py --dry-run
36+
37+
# Update the configuration file
38+
python update_reform_impacts.py
39+
40+
# Update with detailed output
41+
python update_reform_impacts.py --verbose
42+
```
43+
44+
## Configuration file structure
45+
46+
The `reforms_config.yaml` file defines each reform with:
47+
48+
```yaml
49+
reforms:
50+
- name: "Description of the reform"
51+
expected_impact: 10.5 # Fiscal impact in billions
52+
parameters:
53+
"gov.some.parameter": 0.25
54+
"gov.another.parameter": 1000
55+
```
56+
57+
## Workflow
58+
59+
### Regular testing
60+
61+
1. Run `pytest policyengine_uk/tests/microsimulation/test_reform_impacts.py` as part of your CI/CD pipeline
62+
2. Tests will fail if any reform impact differs by more than £0.1 billion from expected
63+
3. This catches unintended consequences of model changes
64+
65+
### After intentional model changes
66+
67+
1. Run `python policyengine_uk/tests/microsimulation/update_reform_impacts.py --dry-run` to preview impact changes
68+
2. Review the changes to ensure they're expected
69+
3. Run `python policyengine_uk/tests/microsimulation/update_reform_impacts.py` to update the configuration
70+
4. Commit both the model changes and updated `reforms_config.yaml`
71+
72+
### Adding new reforms
73+
74+
1. Add a new entry to `reforms_config.yaml`:
75+
```yaml
76+
- name: "Your new reform description"
77+
expected_impact: 0.0 # Initial placeholder
78+
parameters:
79+
"gov.parameter.to.change": new_value
80+
```
81+
82+
2. Run `python policyengine_uk/tests/microsimulation/update_reform_impacts.py` to calculate the actual impact
83+
3. Run `pytest policyengine_uk/tests/microsimulation/test_reform_impacts.py` to verify the test passes
84+
85+
## Test tolerance
86+
87+
Tests allow for a tolerance of £0.1 billion in fiscal impacts. This accounts for:
88+
- Minor numerical differences in calculations
89+
- Small variations from microsimulation sampling
90+
91+
If you need to adjust this tolerance, modify the assertion in `test_reform_impacts.py`.
92+
93+
## Troubleshooting
94+
95+
### Tests failing after model update
96+
97+
1. Check if the changes are expected by running the update script with `--dry-run`
98+
2. If changes are expected, update the configuration file
99+
3. If changes are unexpected, investigate the model modifications
100+
101+
### Import errors
102+
103+
Ensure you have the required dependencies:
104+
```bash
105+
pip install policyengine-uk pytest pyyaml
106+
```
107+
108+
### Configuration file not found
109+
110+
The tests expect `reforms_config.yaml` to be in the same directory as the test file. You can specify a different path:
111+
112+
```bash
113+
python update_reform_impacts.py --config /path/to/reforms_config.yaml
114+
```
115+
116+
## Example reforms
117+
118+
The configuration includes various UK tax and benefit reforms:
119+
- Income tax rate changes (basic, higher, additional rates)
120+
- Personal allowance adjustments
121+
- National Insurance contribution rates
122+
- VAT rate changes
123+
- Universal Credit taper rate modifications
124+
- Child benefit amount changes
125+
126+
Each reform's fiscal impact is measured as the change in government balance (in billions) for the year 2029.

0 commit comments

Comments
 (0)