Modernise OG-UK: UK calibration, GS tax functions, real-world mapping#62
Modernise OG-UK: UK calibration, GS tax functions, real-world mapping#62nikhilwoodruff wants to merge 21 commits intomainfrom
Conversation
…erface - Replace setup.py/environment.yml with pyproject.toml (uv/hatch) - Create clean pydantic-based API in oguk/api.py: - CalibrationResult and SteadyStateResult models - calibrate(start_year, years, policy) function - solve_steady_state(start_year, policy, max_iter) function - Support Policy objects for reform scenarios - Remove pandas_datareader dependency (hardcode UK infant mortality) - Update example to use new API - Simplify tests to use new calibrate() function
rho from calibrate() was 1D (S,) but OG-Core expects 2D (T+S, S). Tile at source in calibrate() rather than in each consumer. Also handle SS outputs that are 1D arrays rather than scalars. Co-Authored-By: Claude <noreply@anthropic.com>
Add scripts/refresh_calibration.py which scrapes ONS, Bank of England, and GOV.UK for UK-specific fiscal and economic parameters. Key changes: - Debt ratio from ONS (0.93 vs old 0.78), gov spending shares from ONS - Corporation tax 25%, employer NICs 15%, effective VAT 10%, IHT 8% - Retirement age 66, productivity growth 1% (OBR), real rate 1.75% (BoE) - Remove 9 US-specific Social Security parameters (AIME/PIA) - Fix rho, r_gov_scale, r_gov_shift, replacement_rate_adjust formats - solve_steady_state() and run_oguk.py now load UK defaults JSON - Steady state converges with positive government spending Co-Authored-By: Claude <noreply@anthropic.com>
…finitions The DEP (12-parameter) tax function was producing wildly unstable results for reform comparisons — a 1p basic rate change produced ~20% output swings due to the optimizer landing in different local minima. Root causes: - OG-Core's tax_data_sample drops observations with capital income < £5, removing ~75% of UK microdata (most people have no capital income) - The DEP functional form is over-parameterised and poorly identified for UK data, producing flat ~6% ETRs vs actual 8-36% progressive rates - Only savings_interest_income was used as capital income (mean £148), missing dividends (£1,057), pensions (£2,915), and property (£403) Fixes: - Switch from DEP to GS (Gouveia-Strauss) tax function type, which has 3 parameters and correctly captures UK progressive tax rates - Use global optimisation (differential evolution) for deterministic, stable parameter estimation - Broaden capital income to include dividends, private pensions, property, and savings interest - Add self-employment income to labour income - Preserve zero-capital-income observations with small random floor instead of dropping them - Custom data cleaning that retains ~57k of 92k obs (vs 21k with OG-Core) GS ETR comparison for 1p basic rate reform now shows correct direction (+0.4-0.6pp) and magnitude across the income distribution. Co-Authored-By: Claude <noreply@anthropic.com>
1. PolicyEngine Policy combination bug: when combining a reform Policy (with parameter_values) and a perturbation Policy (with simulation_modifier), the parameter_values were silently dropped. This meant reform MTRs were computed under baseline tax rates, producing near-zero MTR data. Fix: apply reform parameters directly inside the simulation_modifier using the TBS parameter tree. 2. GS MTR estimation instability: separately estimating MTRx/MTRy parameters with differential_evolution produced wildly different results for nearly identical data (phi0 jumping from 0.65 to 0.04 for a 1pp tax change). Fix: use ETR parameters for all three (ETR, MTRx, MTRy). This works because the GS MTR formula is the analytical derivative of the GS ETR — same 3 params give mathematically consistent rates. Before: 1pp basic rate rise showed ~11.5% output change. After: 1pp basic rate rise shows -0.12% output, +0.93% revenue. Co-Authored-By: Claude <noreply@anthropic.com>
New map_to_real_world() function scales model-unit SS changes to actual UK aggregates from ONS. Uses GDP-anchored scaling (single scale factor from real GDP / model GDP) rather than per-variable percentage changes, which avoids blow-up when model variables like G are near zero. Co-Authored-By: Claude <noreply@anthropic.com>
The model was using US Social Security replacement rates (90%/32%/15% PIA brackets), producing pension outlays of 15.9% of GDP. Combined with alpha_T=0.15 (total social protection), mandatory spending exceeded revenue and G was forced negative. Fix: set replacement_rate_adjust=0.35 to match UK state pension spending (~5.5% of GDP) and alpha_T=0.04 for non-pension transfers only (UC, housing benefit, disability). G is now +18.5% of GDP, consistent with the alpha_G=0.209 calibration from ONS. Co-Authored-By: Claude <noreply@anthropic.com>
Recalibrate tax and fiscal parameters so model revenue/GDP matches OBR forecasts (~40% of GDP). Add wealth tax proxy for council tax/SDLT/CGT, align capital depreciation with economic depreciation, set steady-state debt ratio to 95%. Replace five legacy CI workflows with single uv-based Python 3.13 workflow using ruff for linting and formatting. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
|
Hey @jdebacker - I think this moves the repo to a good place for solving the steady state. Think a bit more work is needed on TPI. I've also added the HUGGING_FACE_TOKEN to the repo secrets |
…up instructions Co-Authored-By: Claude <noreply@anthropic.com>
Add age_specific parameter to solve_steady_state(), calibrate(), and run_transition_path() with three modes: - "pooled": single GS function for all ages (default, unchanged) - "brackets": 4 age-group functions (20-35, 36-50, 51-66, 67-100) aligned to UK state pension age - "each": separate function per individual age (80 estimations) The run script now accepts the mode as a second argument: uv run python examples/run_oguk.py ss brackets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Added
Usage: # Pooled (default, same as before):
uv run python examples/run_oguk.py
# Brackets:
uv run python examples/run_oguk.py ss brackets
# Each individual age:
uv run python examples/run_oguk.py ss eachTested brackets vs pooled for the 1pp basic rate reform — results show same direction but brackets capture a larger GDP impact (-0.135% vs -0.080%) and lower revenue gain (+0.746% vs +0.942%), reflecting different behavioural responses across age groups. The retirement bracket (67-100) has distinctly different GS params from working-age groups, confirming age variation matters. |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
oguk/demographics.py
Outdated
There was a problem hiding this comment.
@nikhilwoodruff @vahid-ahmadi If you all are ok using the UN World Population Prospects data, we can remove this module and just use ogcore.demographics.
There was a problem hiding this comment.
Done — agreed, this is the cleaner approach. Removed oguk/demographics.py and now calling ogcore.demographics.get_pop_objs directly with country_id="826" (UK). Also fixed a shape mismatch: the old local module returned rho as 1D, but ogcore returns it as (T+S, S) directly, so the downstream tile logic has been removed.
There was a problem hiding this comment.
Why is the environment file being removed?
There was a problem hiding this comment.
Restored. The new environment.yml is a lean conda env that delegates to pip install oguk[dev], keeping it in sync with pyproject.toml without duplicating the full dependency list.
There was a problem hiding this comment.
Let's keep the CI workflows that check and deploy the docs to keep them up to date.
There was a problem hiding this comment.
Restored both deploy_docs.yml and docs_check.yml, updated to use uv instead of conda/pip.
There was a problem hiding this comment.
I like having the environment file. If not this, do we need a uv.lock file for the workflow implicit in this PR?
There was a problem hiding this comment.
Addressed — environment.yml is back. uv.lock already exists in the repo; the CI workflows use uv sync directly (which picks up the lockfile), while the conda env installs via pip for users who prefer Anaconda.
There was a problem hiding this comment.
Where does the interaction with PolicyEngine-UK happen if this script is removed?
There was a problem hiding this comment.
Restored oguk/get_micro_data.py. The PolicyEngine interaction now goes through the new policyengine package API (not the legacy policyengine_uk package), via oguk.api._get_micro_data. The module exposes the same get_data() public interface as before so downstream code is unaffected.
There was a problem hiding this comment.
It was in api.py but have moved the reference to here
There was a problem hiding this comment.
The name used for the module with this functionality in other country calibrations is macro_params.py (see ZAF example). I'm ok if you want to use a different naming convention. But something more specific than sources should be used.
There was a problem hiding this comment.
Renamed to macro_params.py — good call, that's a much clearer name. All imports updated.
- Remove oguk/demographics.py; use ogcore.demographics.get_pop_objs with country_id="826" (UK) so we pull UN World Population Prospects data via the standard ogcore pathway - Fix rho shape: ogcore returns (T+S, S) directly; remove the old reshape+tile that assumed a 1D array from the local module - Update calibrate.py to import from ogcore.demographics - Remove setup.py (superseded by pyproject.toml)
…docs build - ruff format oguk/get_micro_data.py - ogcore.demographics requires final_data_year > initial_data_year; use max(years, 2) to ensure this holds when years=1 - docs workflows: use uv pip install (not uv run pip install) so jb is installed into the venv and accessible via uv run
- Write empty un_api_token.txt before tests so ogcore.demographics doesn't call input() under pytest's output capture (which raises OSError) - docs workflows: use plain pip install + jb (not uv run) since jupyter-book is installed into the setup-python env, not the uv venv
- Use python -m jupyter_book instead of jb to avoid PATH issues in CI - Restrict push trigger to main branch only, so CI doesn't run twice on every PR (once for the push event, once for pull_request)
…rance TPI was failing with RC_error ≈ 0.109, entirely concentrated at the last period (t=T-1). Periods 0–158 all have RC errors < 8e-5. OG-Core's fiscal closure uses a different formula at t=T-1 that creates this boundary discontinuity. Also auto-calibrate alpha_G from the SS solution (G_ss/Y_ss) so that TPI government spending is consistent with the steady state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@nwoodruff-co TPI now runs to completion. Two issues were found:
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Summary
Overhauls OG-UK to use properly sourced UK parameters and a clean functional API. Supersedes #61 (moved from fork to fix CI secrets).
UK macro calibration: replaces US defaults with ONS/OBR/GOV.UK data (debt ratio, government spending, tax rates, state pension age, etc.). Fiscal parameters calibrated to OBR November 2025 EFO: steady-state debt at 95% of GDP, revenue/GDP ~39%, fiscal adjustment from period 4 matching UK fiscal rules. Tax instruments include wealth tax proxy (council tax, stamp duty, CGT), effective indirect taxes (VAT + excise), and corporation tax inclusive of business rates. Includes
oguk/sources.pyfor live ONS/BoE fetching with hardcoded fallbacks.GS tax functions: switches from DEP to Gouveia-Strauss, estimates ETR only and reuses params for MTR (the GS MTR is the analytical derivative of ETR). Fixes two critical bugs: PolicyEngine
Policy.__add__silently droppingparameter_valueswhensimulation_modifierpresent, and GS MTR estimation instability from separate optimisation.Real-world mapping:
map_to_real_world(baseline, reform)anchors model-unit SS changes to actual UK aggregates from ONS using GDP-scaled £bn changes.CI modernisation: replaces five legacy workflows (conda, Python 3.9, black, 3-OS matrix) with a single uv-based workflow on Python 3.13 using ruff for linting and formatting.
Steady-state results for 1pp basic rate rise (20% to 21%):
Interest rate moves from 5.09% to 5.13%.
Test plan
uv run python examples/run_oguk.pyproduces baseline + reform SS with real-world £bn tablefrom oguk import calibrate, solve_steady_state, map_to_real_worldimports cleanly