Models / PSL OLG

model 04 — overlapping generations · psl-og · UK · local

Model long-run behavioural change.

Estimate how reforms affect work, saving, investment, and public finances over decades, for the UK.

01 — what it is

Households age, the economy clears.

The model tracks 80 annual cohorts — households aged 20 to 99, entering at 20 and facing age-specific mortality. Each cohort chooses labour supply and saving over its remaining lifetime; firms rent that capital and labour; the government taxes, spends and issues debt. Two solves are on offer: the steady state ("where does the economy settle under this policy?") and the transition path — a multi-decade, year-by-year solve of how it gets there, which is what a budget profile needs.

It is the suite's long-run structural member. Where the OBR emulator answers "what does this do to the next twelve quarters of the official forecast?", this one answers "where does the economy end up, and why?" — with the behavioural mechanism visible rather than estimated.

02 — how it works

Calibrated to the UK, taxed by statute.

The UK calibration is anchored to official data: depreciation from ONS capital-stocks data (6.5%/yr), potential-output growth from the OBR EFO (1.1%/yr), the discount factor matched to the ONS household saving ratio, the world interest rate to Bank of England gilt-yield data, and a Frisch labour-supply elasticity of 0.35–0.4 (Blundell, MaCurdy & Meghir). Demographics — population by age, mortality, immigration — come from UN data. Outputs map back to current-price £bn against live ONS series, falling back to cached values if ONS is unreachable.

Taxes are not stylized wedges. OG-UK runs PolicyEngine-UK on household microdata under baseline and reform, computes marginal rates by perturbation — add £1 of employment (or dividend) income per adult, rerun, read the change in net income — and fits three-parameter Gouveia–Strauss schedules to the effective-rate microdata for each year of a three-year budget window. The marginal-rate schedules are the analytical derivatives of the same fitted parameters, so ETR and MTRs are consistent by construction, and the model sees the reform's true shape across the income and age distribution.

The main OLG configuration dials and their trade-offs
dialchoicestrade-off
mode solve_steady_state() · run_transition_path() long-run answer, >17 min per solve · full 60-year path, multiple hours for baseline + reform
age_specific "pooled" · "brackets" · "each" one tax function for all ages (fastest, most stable) · one per age bracket (4 groups, split at state pension age) · one per single year of age (80 functions, slowest)
multi_sector False · True one production sector · eight industries calibrated from ONS Blue Book supply-and-use tables by SIC section; needed for sector-level questions like energy price shocks

start with pooled + single-sector and scale up only once the fast run answers your question. non-converging solve? raise max_iter (default 250), fall back to pooled, and check whether the reform is extreme — OLG models can diverge on very large shocks.

03 — how to run it

Clone, sync, score.

A Python package (3.11+); the recommended setup uses uv. The PolicyEngine enhanced-FRS microdata is downloaded on first run and is gated: you need a HUGGING_FACE_TOKEN with read access to policyengine/policyengine-uk-data.

git clone https://github.com/PSLmodels/OG-UK.git
cd OG-UK
uv sync
export HUGGING_FACE_TOKEN=hf_your_token_here
uv run python examples/run_oguk.py

the example script solves baseline and reform (a 1pp basic-rate rise) and prints £bn impacts — budget half an hour. prefer conda? conda env create -f environment.yml && conda activate oguk-dev && pip install -e . works too.

The whole scoring loop in one program — raising the basic rate of income tax from 20% to 21%:

from datetime import datetime
from policyengine.core import ParameterValue, Policy
from policyengine.tax_benefit_models.uk import uk_latest
from oguk import solve_steady_state, map_to_real_world

# 1. build a reform from PolicyEngine parameters
param = uk_latest.get_parameter("gov.hmrc.income_tax.rates.uk[0].rate")
reform = Policy(
    name="Basic rate 21%",
    parameter_values=[
        ParameterValue(parameter=param, value=0.21,
                       start_date=datetime(2026, 1, 1))
    ],
)

# 2. solve baseline and reform steady states — ~17 min each
baseline  = solve_steady_state(start_year=2026)          # no policy = baseline
reform_ss = solve_steady_state(start_year=2026, policy=reform)

# 3. map model units to real-world £bn
impact = map_to_real_world(baseline, reform_ss)

# 4. read the results
print(f"GDP change: {impact.gdp_change:+.1f}bn ({impact.gdp_pct:+.3f}%)")
print(f"Tax revenue change: {impact.tax_revenue_change:+.1f}bn")
print(f"Interest rate: {impact.r_baseline:.2%} → {impact.r_reform:.2%}")

Stack several ParameterValues in one Policy to score a package. Common parameter paths: gov.hmrc.income_tax.rates.uk[0..2].rate (basic 20%, higher 40%, additional 45%), gov.hmrc.income_tax.allowances.personal_allowance.amount (£12,570), and gov.hmrc.national_insurance.class_1.rates.employee.main / .higher.

Shocks that are not tax-and-benefit statute go through param_overrides, a dict of OG-Core parameters applied on top of everything else. Corporation tax lives here, not in PolicyEngine — it is a structural parameter of the macro model:

# corporation tax cut to 25%
ss = solve_steady_state(param_overrides={"cit_rate": [[0.25]]})

# productivity: +0.4% TFP level shock
ss = solve_steady_state(param_overrides={"Z": [[1.004]]})

# statutory reform, structural shock, and finer tax functions in one run
ss = solve_steady_state(policy=reform,
                        param_overrides={"cit_rate": [[0.25]]},
                        age_specific="brackets", multi_sector=True)
Use Z (TFP) for productivity shocks — never g_y_annual, which is the balanced-growth normalisation the model detrends by, not a productivity lever.

The transition path answers "what happens each year on the way there" — decade by decade, baseline and reform in one call, on Dask workers:

from dask.distributed import Client
from oguk import run_transition_path, map_transition_to_real_world

client = Client(n_workers=4, threads_per_worker=1)
base_tp, reform_tp = run_transition_path(start_year=2026, policy=reform,
                                         client=client)
client.close()

impact = map_transition_to_real_world(base_tp, reform_tp)
# impact.years            → ["2026-27", "2027-28", ...]
# impact.gdp_change[t]    → £bn change from baseline in year t
# same arrays for consumption, investment, revenue, debt

transition paths are available through the oguk API but are not wired into the PolicyEngine Macro CLI yet — the CLI is steady-state only. Two commands are wired: pe-macro og-baseline and pe-macro og-score --reform '{"gov.hmrc.income_tax.rates.uk[0].rate": 0.21}', both on the fast configuration (pooled tax functions, single sector, steady state).

04 — what it can answer

Everything maps back to £bn.

map_to_real_world() returns a MacroImpact carrying, for each aggregate, the reform level, the change from baseline, and the percent change — all current-price £bn — plus baseline and reform interest rates:

  • .gdp / .gdp_change / .gdp_pct
  • .consumption, .investment, .government — same triple each
  • .tax_revenue, .debt — same triple each
  • .r_baseline, .r_reform — steady-state interest rates

The £bn mapping is GDP-anchored: one scale factor (real-world GDP ÷ model GDP) converts model-unit changes, with levels anchored to live ONS series (GDP, consumption, investment, government, debt ratio) and HMRC total receipts.

It cannot give you a quarterly path, a costing on the OBR's forecast basis, or a distributional table. Those are the OBR emulator and PolicyEngine respectively. And it should not be read as a forecast of anything — see below.
05 — how far to trust it

No ground truth. Here is what can be checked instead.

There is no published counterpart to replicate, so there is no headline accuracy number, and any page that gave you one would be misleading you. What exists instead is three weaker but honest checks, set out in full in the working paper:

What each calibration check establishes
checkwhat it establishes
Calibration targets vs official UK aggregates The imposed and targeted quantities match by construction — that is anchoring, not validation, and the paper's tables label which is which. The over-identifying checks are the emergent quantities, the capital–output ratio and the steady-state interest rate, for which no published reconciliation yet exists.
Parameters vs OG-Core / OG-USA defaults Deployed choices are set against framework defaults and the published De Backer–Evans–Phillips tax-function estimates, including their fit statistics.
Against the OBR's own UK OLG model OBR Working Paper No. 22 (2025) is the closest available external check, and its headline experiment is the same 1pp basic-rate change used as the running example here. For the rise, WP 22 reports GDP per person −0.1%, hours −0.2%, productivity +0.1% through composition, and just over £8bn in real income tax (~0.3% of GDP) against HMRC's ~£6–8bn static yield. A systematic side-by-side comparison is on the roadmap, not done.

Read point estimates as model-consistent magnitudes and signs, not forecasts: two defensible OLG models can disagree materially on the long-run effect of the same reform. Note also that WP 22 solves by value-function iteration in under two minutes where this model uses Newton root-finding plus time-path iteration at ~17 minutes — a difference in method, not in ambition. Wider suite evidence: validation.

06 — limits

Experimental-grade wiring, stated plainly.

Known limits of the overlapping-generations model
limitdetail
Version pin The integration installs oguk from GitHub (0.3.2), which pins policyengine-uk==2.88.0. Versions ≥ 2.89 renamed the microdata dataset keys (enhanced_frs_2023_24_<year>populace_uk_*), so mixing them fails with a KeyError at calibration time. The adapter traps this and raises an actionable error. Upstream has moved on; lifting the pin is roadmap work, and until it lands the OLG member scores against a slightly older statute vintage than the microsimulation member. A declared inconsistency, not a hidden one.
Gated data Calibration needs the private enhanced-FRS microdata, so a HUGGING_FACE_TOKEN with access is a hard prerequisite.
Thin CI CI cannot exercise a seventeen-minute solve on every commit, so regressions in solve behaviour are caught by scheduled runs and by hand, not continuously.
Steady-state-only in the CLI Because the CLI exposes steady-state solves only (section 03), the model cannot yet speak to fiscal-year profiles through the suite — the long-run destination is available through it, the path there is not.
Ability profiles not UK-estimated Lifetime-ability profiles are inherited rather than re-estimated on UK microdata. On the roadmap.
Want to see the model before running it? The OG-UK dashboard is a live explainer with a worked +1pp basic-rate simulation, a scrollytelling methodology walk-through, runnable code for every step, and a side-by-side with the OBR's UK OLG model.