model 06 — 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.
From a few lines of Python to a full UK transition path.
OG-UK is open source, and every step below that carries logic ships as
a runnable file under /olg/code/
on this site. Pick a step, run it on its own, swap the reform. Full context lives in
PSL's OG-UK repository.
score_reform with model="og",
dynamic_reform_impact) return install/CLI instructions
instead of results. Locally, until
OG-UK#68
lands, oguk pins policyengine-uk==2.88.0 and
needs its own environment:
pe-macro og-score --reform '...' --json > og.json
there, then
pe-macro dynamic-score --reform '...' --og-payload og.json
in the main one. Both need a HUGGING_FACE_TOKEN with
access to the gated UK microdata. What the pin means for results is on
the Validation tab.
Install
A Python package (3.11+); the recommended setup uses
uv. The PolicyEngine
enhanced-FRS microdata downloads on first run and is gated: you need
a HUGGING_FACE_TOKEN with read access to
policyengine/policyengine-uk-data.
runnable file: 01_install.sh.
prefer conda?
conda env create -f environment.yml && conda activate oguk-dev && pip install -e .
works too.
git clone https://github.com/PSLmodels/OG-UK.git
cd OG-UK
uv sync
export HUGGING_FACE_TOKEN=hf_your_token_hereDefine a reform
Reforms use the PolicyEngine API: pick a parameter from the UK tax-and-benefit rule book, give it a new value and a start date. Anything PolicyEngine can represent — rates, thresholds, allowance tapers, new benefits — flows straight through. To simulate a different reform, swap the parameter path and value; the rest of the pipeline does not change.
runnable file: 02_reform.py —
every later script imports REFORM from it.
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.
Z (TFP) for productivity shocks — never
g_y_annual, which is the balanced-growth normalisation
the model detrends by, not a productivity lever.
from datetime import datetime
from policyengine.core import ParameterValue, Policy
from policyengine.tax_benefit_models.uk import uk_latest
# Reform: raise the basic rate of income tax from 20% to 21%
basic_rate = uk_latest.get_parameter("gov.hmrc.income_tax.rates.uk[0].rate")
REFORM = Policy(
name="Basic rate 21%",
parameter_values=[
ParameterValue(
parameter=basic_rate,
value=0.21,
start_date=datetime(2026, 1, 1),
)
],
)# 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)Solve the long-run steady state
The fastest way to see what a reform does.
solve_steady_state finds the long-run equilibrium of the
UK economy under a given policy: the prices, quantities and tax
revenues that emerge once the economy has fully adjusted. Run it once
for the baseline, once for the reform; the difference is the answer.
The output shown is illustrative — the format matches 03_steady_state.py, but the numbers depend on the calibration date and your data release. On the fast configuration, budget ~17 minutes per steady state.
The main configuration dials and their trade-offs:
solve_steady_state()
long-run answer, ~17 min per solve
run_transition_path()
full 60-year path, multiple hours for baseline + reform
"pooled"
one tax function for all ages (fastest, most stable)
"brackets"
one per age bracket (4 groups, split at state pension age)
"each"
one per single year of age (80 functions, slowest)
False
one production sector
True
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.
from oguk import solve_steady_state, map_to_real_world
from og_dashboard.reform import REFORM
baseline = solve_steady_state(start_year=2026)
reform = solve_steady_state(start_year=2026, policy=REFORM)
impact = map_to_real_world(baseline, reform)
print(f"GDP: £{impact.gdp:,.1f}bn ({impact.gdp_pct:+.3f}%)")
print(f"Tax revenue: £{impact.tax_revenue:,.1f}bn ({impact.tax_revenue_pct:+.3f}%)")
print(f"Investment: £{impact.investment:,.1f}bn ({impact.investment_pct:+.3f}%)")
print(f"Interest: {impact.r_baseline:.2%} -> {impact.r_reform:.2%}")Solving baseline steady state (age_specific='pooled', 1-sector)...
Solving reform steady state (age_specific='pooled', 1-sector)...
Steady state impact (£bn, current prices)
============================================================
Variable Baseline Reform Change %
------------------------------------------------------------
GDP 2853.8 2852.6 -1.2 -0.043%
Consumption 1819.4 1817.2 -2.2 -0.121%
Investment 469.1 466.2 -2.9 -0.612%
Government 564.4 565.5 +1.1 +0.198%
Tax revenue 1036.4 1051.7 +15.3 +1.473%
Debt 2691.4 2691.0 -0.4 -0.014%
Interest rate: 3.84% -> 3.87%Run the year-by-year transition path
The steady state tells you where the economy ends up. The transition path tells you how it gets there — year by year, 60 periods by default (configurable). This is what produced the reform paths in the Showcase tab. The transition costs more compute — the model solves every cohort's lifetime under rational expectations — so OG-UK uses Dask to parallelise across CPU cores.
runnable file: 04_transition.py.
transition paths are available through the oguk API but
are not wired into the PolicyEngine Macro CLI yet —
the CLI is steady-state only (see step 7).
from dask.distributed import Client
from oguk import run_transition_path, map_transition_to_real_world
from og_dashboard.reform import REFORM
client = Client(n_workers=2, threads_per_worker=1, memory_limit="2GB")
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)
# First ten years of GDP and tax-revenue impacts
for i in range(10):
print(
f"{impact.years[i]} "
f"ΔGDP {impact.gdp_change[i]:+6.2f} "
f"ΔRevenue {impact.tax_revenue_change[i]:+6.2f}"
)From abstract units to pounds
OG-UK solves in dimensionless model units. To translate them into
figures a policymaker can read, 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, falling back to cached values if ONS is unreachable. The transition-path variant returns NumPy arrays indexed by year.
impact = map_transition_to_real_world(base_tp, reform_tp)
impact.years # fiscal-year strings: ["2026-27", ..., "2085-86"]
impact.gdp # reform GDP path (£bn, per year)
impact.gdp_change # £bn change vs baseline, per year
impact.tax_revenue_change
impact.consumption_change
impact.investment_change
impact.government_change
impact.debt_change
# Interest-rate paths live on the TPI results themselves
base_tp.r, reform_tp.r # baseline / reform r(t)Bring in the eight industry sectors
Pass multi_sector=True and the same call returns the
breakdown across the eight UK industry sectors (energy,
manufacturing, construction, trade & transport, info &
finance, real estate, business services, public & other) —
sector-level output, capital and labour alongside the macro
aggregates, and the basis for the industry charts in the
Showcase tab.
runnable file: 06_multi_sector.py.
base_tp, reform_tp = run_transition_path(
start_year=2026,
policy=REFORM,
client=client,
multi_sector=True, # 8-sector CES production
)Where to go next
Every step above ships as a file you can download and run on its own:
- 01_install.sh — install and token setup.
- 02_reform.py — the shared
REFORM; edit the parameter, value or start date here and every other script picks it up. - 03_steady_state.py — fastest, prints a one-line £bn impact summary.
- 04_transition.py — heavier, produces the full year-by-year path.
- 05_map_to_gbp.py — the £bn-mapping field reference.
- 06_multi_sector.py — the 8-sector calibration behind the Showcase industry views.
Through this suite, two CLI 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). For more variations, the upstream
OG-UK examples
directory carries additional pipelines
(run_oguk_fast_tpi.py, run_oguk_fast_sector.py,
plot.py). Full API reference and theory documentation:
pslmodels.github.io/OG-UK.