Skip to content

Modernise OG-UK: new PolicyEngine API, 8-sector calibration, OBR-grounded TPI charts#63

Merged
nikhilwoodruff merged 30 commits intomainfrom
obr-grounded-tpi-charts
Mar 20, 2026
Merged

Modernise OG-UK: new PolicyEngine API, 8-sector calibration, OBR-grounded TPI charts#63
nikhilwoodruff merged 30 commits intomainfrom
obr-grounded-tpi-charts

Conversation

@nwoodruff-co
Copy link
Copy Markdown
Collaborator

@nwoodruff-co nwoodruff-co commented Mar 4, 2026

Summary

This PR modernises OG-UK end-to-end: new PolicyEngine API integration, UK macro calibration, 8-sector industry model, and OBR-grounded transition path charts.

Core modernisation

  • New PolicyEngine API: Clean functional interface (solve_steady_state, run_transition_path, map_to_real_world) replacing the old imperative runner — see oguk/api.py
  • UK macro calibration: Fiscal params calibrated to OBR Nov 2025 EFO; real-world mapping anchored to ONS GDP/expenditure data — see oguk/macro_params.py
  • Tax function estimation: Fixed two critical bugs; switched from DEP to GS functional form; added age-specific estimation option
  • Demographics: Use ogcore.demographics for UK population data, drop legacy demographics.py and setup.py
  • CI/docs fixes: Pin jupyter-book<2, fix lint, stub UN API token, modernise pyproject.toml

8-sector industry calibration (oguk/industry_params.py)

  • M=8 production industries mapped to SIC 2007 sections (Manufacturing last — capital good producer per OG-Core convention)
  • Capital shares (gamma): ONS GDP income approach, shrunk 40% toward aggregate mean (0.35) for solver stability
  • IO matrix: ONS Input-Output Supply and Use Tables 2022 (Table 2), aggregated from ~100 product groups into 8 sectors, row-normalised
  • TFP (Z): Solow residuals from ONS capital stocks (CAPSTK) and workforce jobs (JOBS02), GVA-weighted normalisation
  • CES elasticities (epsilon): Calibrated from Chirinko (2008) and Knoblach et al. (2020), but currently set to Cobb-Douglas (1.0) because OG-Core TPI diverges with heterogeneous epsilon. Raw values preserved in _EPSILON for when this is fixed upstream
  • c_min: Non-zero energy floor for subsistence consumption
  • Energy cost shares: For future energy price shock modelling

OBR-grounded TPI charts (scripts/tpi_charts_plotly.py)

  • Macro charts: 6-panel (GDP, Consumption, Investment, Government, Tax Revenue, Debt) from 2000-2030. Baseline = ONS outturn + OBR forecast projected forward. Reform = baseline x (1 + model % change). Single continuous baseline line, dashed reform from 2027
  • Sector charts: Per-industry % change summary (Output, Capital, Labour) + 8x3 panel with historical ONS outturn (GVA, capital stocks, workforce jobs) from 2000, projected forward using OBR GDP growth
  • No stitching artefacts: Both macro and sector charts use the same approach — model % changes applied to OBR-consistent baselines

TPI export (scripts/run_tpi_and_export.py)

  • Runs baseline + reform SS+TPI for 1pp basic rate increase from 2027
  • Exports to tpi_results.xlsx: 3 macro sheets + 12 sector sheets (baseline levels, reform levels, % changes for Y_m, K_m, L_m, p_m)

Key model results (1pp basic rate increase)

  • GDP impact: -0.3% by 2029 (consistent with OBR/HMRC ready reckoners)
  • Labour impact: ~60-80k FTE reduction (consistent with OBR NICs/threshold estimates)
  • Energy sector least affected (most capital-intensive); Public and Other goes positive (more tax revenue -> government spending)

Test plan

  • run_tpi_and_export.py produces tpi_results.xlsx with macro + sector sheets
  • tpi_charts_plotly.py produces tpi_charts.html with smooth baseline/reform lines
  • CI passes (lint, format, tests, docs build)
  • Manufacturing at index 7 (last sector) per OG-Core convention
  • All IO matrix rows sum to 1.0
  • Sector TFP normalised so GVA-weighted mean = 1.0

- Fix map_transition_to_real_world: use period-0 anchor scale factor so
  baseline levels carry the full growth path rather than collapsing to a
  flat constant each period
- Add scripts/run_tpi_and_export.py: runs SS+TPI for baseline and a 1pp
  basic rate reform, exports results to tpi_results.xlsx with optional
  pre-2026 ONS/OBR historical rows (light yellow) prepended to the
  baseline sheet
- Add scripts/tpi_charts_plotly.py: single-page 2×3 chart showing five
  variables as % of GDP and GDP in £bn; baseline = OBR Nov 2025 EFO
  outturn + forecast; reform = OBR baseline × TPI % change from model;
  dashed reform line only, grey dotted boundary at outturn/forecast split
Extend chart baseline back to 2000-01 by fetching ONS YBHA/NPQS/ABJQ
calendar-year series and converting to fiscal years via the existing
_fy_from_calendar() helper. OBR table 1.4/1.2 data takes priority from
2008-09 onwards; ONS rows are only prepended for earlier years.

Also extend hardcoded _fy_tax_revenue() back to 2000-01 using HMRC
outturn data, and update x-axis range to use HIST_START_FY constant.
- Recalibrate adjustment_factor_for_cit_receipts from 0.309 → 1.1785 to
  match UK CT/GDP ~3.3% (was underestimating at 0.8% using US default)
- Add run_ct_and_export.py: SS+TPI for baseline (cit=0.27) and reform
  (cit=0.28), exports 3-sheet xlsx with historical actuals + model output
- Add ct_charts_plotly.py: 6-panel interactive Plotly charts mapping model
  % changes onto OBR outturn/forecast baseline
- Add ct_tpi_results.xlsx and rendered HTML charts
- Add ct_reexport.py (interim correction script, now superseded)

Dynamic GE estimate: CT +1pp raises ~£2.3-2.6bn/yr tax revenue vs HMRC
static RR of £3.6-4.0bn, with ~£2bn/yr investment drag and £0.5-0.8bn
GDP cost in OBR window.
Previously, marginal tax rates were computed by comparing
total_income - income_tax before and after a £1 perturbation.
This missed NI, UC taper, and benefit interactions — all of
which affect the true net income change faced by a household.

Switch to hbai_household_net_income (the HBAI poverty measure
of disposable income), broadcast from household to person level
via map_to_entity(..., how="project"). This captures the full
tax-benefit wedge including NI, UC, and other means-tested
interactions, giving accurate ETR and MTR estimates for the
Gouveia-Strauss tax function estimation.

Also remove stale dead-code lines (unused BW variable, a
redundant frac_payroll assignment, and two inline comments)
and fix years[i] formatting in run_oguk.py to handle
fiscal-year string labels.
- Remove scripts/run_ct_and_export.py, ct_reexport.py, ct_charts_plotly.py,
  ct_charts_changes.html, ct_tpi_results.xlsx, tpi_results.xlsx (all CT +1pp
  experiment artifacts and binary outputs)
- Fix F841: remove unused cons_m/inv_m/gov_m ONS fetches and derived _bn vars
  in map_transition_to_real_world (oguk/api.py) — only gdp_bn is used now
- Fix F541: remove spurious f-prefix from Plotly hovertemplate strings
  (scripts/tpi_charts_plotly.py)
@nwoodruff-co nwoodruff-co requested a review from jdebacker March 10, 2026 11:53
…m script

- run_transition_path() now accepts param_overrides dict for structural
  parameter shocks (Z, cit_rate, etc.) without needing a PolicyEngine Policy
- New scripts/productivity_headroom.py: estimates fiscal headroom from
  +0.1pp productivity growth via Z (TFP level) shock
- Updated SKILL.md with param_overrides docs and g_y_annual warning
Splits the UK economy into 8 production industries calibrated from ONS
Blue Book / Supply and Use Table data: Energy (B,D), Manufacturing (C),
Construction (F), Trade & Transport (G,H,I), Info & Finance (J,K),
Real Estate (L), Business Services (M,N), Public & Other (A,E,O-U).

Key additions:
- oguk/industry_params.py: sector definitions, GVA shares, capital shares
  (gamma), energy cost shares, c_min (subsistence consumption), and
  get_industry_params() returning OG-Core-ready parameters
- SS solver switched to Levenberg-Marquardt (lm) for M>1 convergence
- Identity I-O matrix (full inter-industry flows cause instability)
- Capital shares shrunk toward mean for stability (raw real estate 0.92)
- Sort imports in __init__.py per isort rules (I001)
- Rename `I` to `NUM_CONSUMPTION_GOODS` in industry_params.py (E741)
Copy link
Copy Markdown
Member

@jdebacker jdebacker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I'd like to see changed in this PR before merge: make manufacturing the 8th industry in the multi-sector calibration.

@vahid-ahmadi
Copy link
Copy Markdown
Collaborator

Just wanted to flag these points on the multi-sector calibration:

  1. With io_matrix = np.eye(8), there are no inter-industry flows. An energy price shock only hits households directly; manufacturing doesn't get more expensive because it uses energy as input. The supply-chain channel is absent. ONS publishes Supply and Use Tables (1997–2023) with the inter-industry flows needed for a real 8x8 coefficients matrix.

  2. Cobb-Douglas everywhere. OG-Core already supports sector-specific CES (p.epsilon[m]), but the PR sets epsilon=1.0 for all 8 sectors. This means capital-labor substitution is identical across energy and business services, which isn't realistic. Rough literature values: Energy ~0.5, Manufacturing ~0.8, Construction ~0.7, Services ~1.1–1.3, Real Estate ~0.4 (Chirinko 2008, Knoblach et al. 2020).

  3. Uniform TFP. With Z=1 everywhere, all cross-sector output differences come from capital shares and factor quantities alone. A sector with high productivity (finance) looks the same as one with low productivity (agriculture). Factor prices don't reflect true sectoral productivity differences. Sector-level Z can be backed out as a Solow residual from ONS data: Z_m = GVA_m / (K_m^γ * L_m^(1-γ)) using ONS capital stocks by industry and workforce jobs by SIC.

@jdebacker
Copy link
Copy Markdown
Member

@vahid-ahmadi Thanks for flagging -- very good points to keep in mind.

On your first point, about supply chains, that's right - other than the capital good, no outputs are used by inputs in other industries. This is a limitation from OG-Core. I think it should be updated when we build out trade in goods other than the capital good.

vahid-ahmadi and others added 3 commits March 17, 2026 17:29
…O matrix

Replace uniform Cobb-Douglas (epsilon=1), flat TFP (Z=1), and identity
IO matrix with sector-specific values grounded in ONS data and the
capital-labour substitution literature (Chirinko 2008, Knoblach et al. 2020).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Shrink gamma 40% toward aggregate mean (0.35) inside get_industry_params()
  for solver stability; raw ONS values preserved in _GAMMA
- Set epsilon to Cobb-Douglas (1.0) for all sectors: OG-Core TPI produces
  NaN with any heterogeneous CES elasticities (even 80% shrunk toward 1.0);
  raw literature values preserved in _EPSILON for future use
- Solow-residual TFP (Z) and full IO matrix remain heterogeneous
- Remove c_min from api.py strip list (no longer returned by get_industry_params)
- Fix tpi_charts_plotly.py: align baseline/reform by fiscal year before
  computing % changes (baseline xlsx has 10 historical rows that reform lacks,
  causing row-index misalignment and spurious jumps)
- Improve chart readability: horizontal x-axis labels, show every 5th year,
  rename TME to Gov. Consumption
- Reduce Dask workers in run_tpi_and_export.py from os.cpu_count() to 2
  with 4GB memory limit for stability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vahid-ahmadi
Copy link
Copy Markdown
Collaborator

vahid-ahmadi commented Mar 19, 2026

Addressing sector heterogeneity concerns

1. IO matrix — Fully addressed ✅

Replaced np.eye(8) with a full 8×8 IO matrix sourced from ONS Input-Output Supply and Use Tables 2022 (Table 2: "Use of products by industry at basic prices"). Raw ~100 product-by-industry flows were aggregated into the 8-sector classification and row-normalised. The matrix is diagonal-dominant (each good primarily from its own industry) with off-diagonal entries capturing cross-industry supply chains (e.g. energy inputs to manufacturing, business services to finance).

2. CES elasticities (epsilon) — Partially addressed ⚠️

Raw literature values from Chirinko (2008) and Knoblach et al. (2020) are calibrated and preserved in _EPSILON:

  • Energy: 0.50, Manufacturing: 0.80, Construction: 0.70, Trade & Transport: 1.00, Info & Finance: 1.20, Real Estate: 0.40, Business Services: 1.30, Public & Other: 0.90

However, epsilon is currently set to 1.0 (Cobb-Douglas) for all sectors in get_industry_params(). The reason: OG-Core's TPI solver produces NaN at iteration 1 with any heterogeneous epsilon — even when shrunk 80% toward 1.0 (range 0.88–1.06). The SS solver handles heterogeneous epsilon fine; only TPI breaks.

Next step: Investigate the root cause inside OG-Core's TPI.run_TPI() — specifically trace where NaN first appears in the inner loop when p.epsilon varies across sectors. This is likely a numerical issue in how TPI computes CES production function derivatives or initial guesses along the transition path. Once identified and fixed upstream, we can enable the calibrated epsilon values by changing one line in get_industry_params().

3. TFP (Z) — Fully addressed ✅

Sector-level TFP is computed as Solow residuals exactly as suggested:

Z_m = GVA_m / (K_m^γ_m × L_m^(1 − γ_m))

Using ONS capital stocks by industry (CAPSTK, 2022 net capital stock) and workforce jobs by SIC (JOBS02, 2022 Q4), normalised so the GVA-weighted mean equals 1.0. Raw (unshrunk) gamma values are used for the Solow residual calculation to ensure data consistency.

image

- Add Y_m, K_m, L_m, p_m (T×M) sector arrays to TransitionPathResult
  and extract them from OG-Core TPI output
- Uncomment c_min in industry_params (required when I=8, was crashing)
- Export 12 new sector sheets in tpi_results.xlsx: baseline levels,
  reform levels, and % changes for output, capital, labour, and prices
- Add sector real-world charts to tpi_charts_plotly.py: ONS outturn
  from 2000 projected forward using OBR GDP growth, with reform line
  computed as baseline × (1 + model % change) — same approach as the
  macro charts, no stitching artefacts
- Add sector % change summary charts (1×3 subplot)
- Regenerated tpi_results.xlsx and tpi_charts.html with full sector data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vahid-ahmadi
Copy link
Copy Markdown
Collaborator

vahid-ahmadi commented Mar 19, 2026

Update: Per-Industry TPI Impacts + Historical Outturn Charts

This latest push adds sector-level transition path results and real-world anchored charts for the 8-sector model.

What's new

Per-industry TPI export (run_tpi_and_export.py):

  • Extracts Y_m, K_m, L_m, p_m (output, capital, labour, prices by sector) from OG-Core's TPI solve
  • Exports 12 new sheets in tpi_results.xlsx: baseline levels, reform levels, and % changes for each variable across all 8 sectors

Sector charts with historical outturn (tpi_charts_plotly.py):

  • ONS outturn (GVA by SIC section, capital stocks, workforce jobs) from 2000 onwards
  • Projected forward using OBR nominal GDP growth — same approach as the macro charts (no stitching artefacts)
  • Reform line = baseline × (1 + model % change from TPI)
  • Sector % change summary charts (Output, Capital, Labour side by side)

Key findings from the 1pp basic rate increase

  • Energy least affected (most capital-intensive, less sensitive to labour income tax)
  • Public & Other goes positive: more tax revenue → more government spending
  • Private sectors cluster together because epsilon is Cobb-Douglas (1.0) for all — heterogeneous CES would differentiate them more but breaks OG-Core TPI
  • Total labour impact ~60-80k FTE, consistent with OBR's own estimates for similar-magnitude tax changes (e.g. NICs cuts ~50k FTE per pp, threshold freeze ~130k FTE). Most is intensive margin (hours) not job losses.
image image

Limitations

  • Epsilon homogeneity: OG-Core TPI cannot converge with heterogeneous CES elasticities. This limits cross-sector differentiation in reform impacts.
  • Labour supply magnitude: The Frisch elasticity in OG-Core may be calibrated higher than UK-specific estimates. Direction is correct; magnitudes need validation against UK labour supply literature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
vahid-ahmadi and others added 5 commits March 19, 2026 16:31
OG-Core assumes the Mth industry produces the capital good.
Manufacturing is the natural choice for the UK.

Reorder all sector arrays: SECTOR_NAMES, gamma, epsilon,
capital stock, workforce jobs, IO matrix (rows + columns),
energy cost shares, c_min, and historical outturn data in
tpi_charts_plotly.py.

Addresses @jdebacker review comment on PR #63.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These are generated output files that shouldn't be in the repo.
Added to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vahid-ahmadi vahid-ahmadi changed the title Add OBR-grounded TPI charts and fix transition path mapping Modernise OG-UK: new PolicyEngine API, 8-sector calibration, OBR-grounded TPI charts Mar 19, 2026
@rickecon
Copy link
Copy Markdown
Member

@nikhilwoodruff and @vahid-ahmadi (cc @jdebacker). This PR looks great. I just reviewed and approved it. The only recommendation I have is that we update the version in pyproject.toml and /oguk/__init__.py to 0.3.1, with a corresponding update to the CHANGELOG.md. This is a significant calibration update with some updated Python scripts.

Per @rickecon's review: update version to 0.3.1 in pyproject.toml and
oguk/__init__.py, and add CHANGELOG entry documenting the 8-sector
multi-industry calibration additions in this PR.
@nikhilwoodruff
Copy link
Copy Markdown
Collaborator

Thanks- merging!

@nikhilwoodruff nikhilwoodruff merged commit 195e209 into main Mar 20, 2026
3 checks passed
@rickecon rickecon deleted the obr-grounded-tpi-charts branch March 20, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants