PolicyEngine / PolicyEngine/policyengine-uk
Align UK model/data pipeline with US patterns: reported-anchoring, second-stage imputation, and downstream bugs
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 50
- Forks
- 33
- Avg merge
- 20h 58m
- Merged PRs (30d)
- 14
Description
Summary
Surveying the UK model and enhanced FRS pipeline against what's been done in policyengine-us / policyengine-us-data over the last few months, several architectural gaps and specific bugs surface. Filing this as a tracking issue so fixes can land as separate, scoped PRs.
Architectural gaps vs US
1. _reported at runtime in would_claim_* formulas
US pattern (confirmed by reading policyengine_us_data/utils/takeup.py::assign_takeup_with_reported_anchors and call sites in cps.py): _reported columns are only consulted once, in the data-build pipeline, to anchor stochastic takeup flags (reported recipient → takeup=True with certainty; rest filled probabilistically to a target rate). At runtime, policyengine-us formulas only read the pre-computed takes_up_X_if_eligible flag.
UK pattern: some would_claim_* variables already match US — would_claim_uc, would_claim_pc are input-only with default_value = True, populated stochastically in policyengine-uk-data/datasets/frs.py. But several still compute from reported at runtime:
would_claim_housing_benefit:claims_all_entitled_benefits | (housing_benefit_reported > 0)would_claim_IS: same shapewould_claim_WTC: same shapewould_claim_CTC: same shape
Reform-friendliness is broken as a result. A reform expanding housing-benefit eligibility only reaches existing housing_benefit_reported > 0 claimants — non-claimants who newly qualify get nothing unless the user manually sets claims_all_entitled_benefits = True everywhere.
Fix: convert each to input-only default_value = True (matching would_claim_uc), populate stochastically in uk-data/datasets/frs.py with the existing takeup-rate pattern.
2. Takeup assignment ignores reported data
In policyengine_uk_data/datasets/frs.py:833-868, all takeup flags are set by pure random draw:
pe_benunit["would_claim_uc"] = generator.random(len(pe_benunit)) < universal_credit_rate
This ignores the fact that respondents who reported receiving UC clearly did take it up. That makes the distribution of simulated claimants less accurate than it could be — a respondent with universal_credit_reported > 0 should have would_claim_uc = True with certainty, with the remaining draws calibrated to hit the overall target rate.
Fix: port policyengine_us_data/utils/takeup.py::assign_takeup_with_reported_anchors into uk-data, apply it to each takeup variable where a reported column exists.
3. SPI-donor imputation only replaces 6 income vars; rest stay as FRS donor's
policyengine_uk_data/datasets/imputations/income.py::impute_income creates a zero-weight copy of 10k FRS rows and runs a QRF on it with:
PREDICTORS = ["age", "gender", "region"]
IMPUTATIONS = [
"employment_income",
"self_employment_income",
"savings_interest_income",
"dividend_income",
"private_pension_income",
"property_income",
]
It replaces only these six variables on the SPI donor side. Everything else (rent, wealth, pension contributions, consumption, gift_aid, tax-reported characteristics, benefits-reported flags) stays as whatever middle-income FRS donor happened to be sampled. So a synthetic SPI donor ending up with £2M imputed self-employment income still carries a typical-FRS-donor's savings balance, pension contribution, and zero gift_aid.
This is exactly the failure mode PolicyEngine/policyengine-us-data#589 ("QRF-impute CPS-only variables for PUF clone half", merged 2026-03-14) fixed in the US pipeline. The US equivalent now trains a second-stage QRF on CPS where predictors = demographics + newly-imputed income vars and outputs = the ~60 CPS-only variables; for PUF-clone prediction it substitutes the already-imputed PUF incomes as predictors so CPS-only variables come out consistent with imputed income.
Fix: add the analogous second-stage QRF in policyengine_uk_data/datasets/imputations/, training on FRS data with predictors = demographics + 6 income vars, outputs = the consequential FRS-only variables (see sublist under bug #6 below).
Specific bugs this gap has produced
4. Degenerate income composition above £1M total income
Current weighted bracket analysis on the enhanced FRS (1.50.3) at 2025:
| Bracket | Rows | employment_income share nonzero |
self_employment_income share nonzero |
dividend_income |
private_pension_income |
property_income |
|---|---|---|---|---|---|---|
| £200k+ | 558 | 85% | 19% | 37% | 13% | 32% |
| £500k+ | 30 | 40% | 73% | 33% | 33% | 20% |
| £1M+ | 10 | 0% | 100% | 0% | 0% | 0% |
Every £1M+ row has 100% of its imputed income as self-employment and zero everywhere else. That's the QRF memorising a narrow "sole trader with massive profits and blank everything else" pattern in the SPI tail donor set.
gift_aid is zero across the entire dataset — the variable is in SPI_RENAMES but not in IMPUTATIONS, so it never gets populated on SPI donors, and FRS doesn't capture it. Missing ~£1-1.5bn/yr of Gift Aid tax relief.
5. Benefit-aggregate misses against OBR (2025)
Quick scan of model vs external reference points on the fresh 1.50.3 build:
| variable | model | target | gap | notes |
|---|---|---|---|---|
income_support |
£1.07bn | ~£0.2bn | +£0.9bn | Legacy, near-fully migrated. Likely same reported > 0 pattern as tax credits. |
esa_contrib |
£5.56bn | ~£2.5bn | +£3.1bn | Large. Possibly reported-based takeup + no phase-out for migrated cases. |
attendance_allowance |
£8.76bn | ~£7.0bn | +£1.8bn | Moderate. Likely cap-rate or takeup issue. |
tax_free_childcare |
£0.41bn | ~£0.9bn | -£0.5bn | Understated by about half. |
state_pension |
£127.5bn (after #1618) | ~£140bn | -£12bn | Remaining gap after fixing state_pension_type; likely Additional State Pension / SERPS coverage. |
6. Cosmetic / small
policyengine_uk/parameters/gov/dwp/tax_credits/min_benefit.yaml:10hasunit: currency-USD. Should becurrency-GBP.benunit_weekly_hourshas label "Average weekly hours worked by adults in the benefit unit" but the formula isadds = ["weekly_hours"](sum, not average). Used once in one test.- Some
~bool_expressionpaths triggerDeprecationWarning: Bitwise inversion '~' on boolthat becomes an error in Python 3.16. Scanninggov_balance's dependency tree for offending Python-bool scalars would get ahead of that.
Candidate PR sequence (if we want to chew through this)
- Convert remaining
would_claim_*formulas to input-only. Two-repo PR pair: one inpolicyengine-uk(remove the formulas, keepdefault_value = True), one inpolicyengine-uk-data/datasets/frs.py(add stochastic assignment for each). Low risk; matches the pattern already applied to UC/PC. - Port
assign_takeup_with_reported_anchorsto uk-data and apply it to each takeup variable. Pure data-pipeline change. Catches reported=True → takeup=True for free and tightens per-variable calibration. - Add second-stage QRF for FRS-only variables on SPI donors. The biggest ticket but directly addresses the "£1M earners have zero-elsewhere" failure. Train QRF on FRS with predictors = demographics + 6 income vars, outputs = rent, mortgage_interest_repayment,
gift_aid,covenanted_payments,charitable_investment_gifts,other_deductions,employee_pension_contributions*,employer_pension_contributions,total_wealthcomponents,capital_allowances,deficiency_relief, and the full list of*_reportedbenefits (so high-income SPI donors don't carry a middle-income donor's HB/UC/WTC reports). - Add
gift_aidtoIMPUTATIONSinpolicyengine_uk_data/datasets/imputations/income.pyso the SPI donor side at least gets this one ~£1-1.5bn deduction stream. Independent of (3); can land immediately as a one-line change plus regression bound. - Residual-benefit-aggregate follow-ups. Separate small PRs for IS phase-out (analogous to the TC PR #1619), ESA contrib investigation, AA calibration, TFC understatement, Additional State Pension coverage. Each is ~20-100 lines plus a YAML test.
Happy to pick up whichever of these the team wants to see first.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading policyengine_uk_data/datasets/frs.py around lines 833-868 and policyengine_uk_data/datasets/imputations/income.py, then compare the referenced policyengine-us_data takeup utility. Treat the candidate PR sequence as separate scopes; done means one scoped fix has corresponding regression coverage and avoids bundling the architectural, imputation, and aggregate follow-ups.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- data-engineering
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100