PolicyEngine / PolicyEngine/policyengine-uk-data

Local-area UC calibration targets are positionally misaligned: every constituency and local authority is assigned another area's UC count

Open
#468 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Python
Stars
1
Forks
2
Avg merge
1d 14h
Merged PRs (30d)
4

Description

cc @vahid-ahmadi

Summary

The local-area Universal Credit targets are joined to their areas by row position against orderings they do not have. The parsed Stat-Xplore extracts carry area names but no codes, and are returned in the spreadsheet's own row order; the loss builders then assign them positionally to code files in a different order. The result:

  • Local authorities: 8 of 360 rows land on the right area. Birmingham is calibrated to West Oxfordshire's UC count (5,828 instead of ~175,600), Hackney to the Isles of Scilly's (70 instead of ~38,200), Glasgow to Moray's (6,728 instead of ~84,100).
  • Constituencies: 0 of 650 rows land on the right area, and the misordered vector — whose data is already on 2024 boundaries — is then pushed through the 2010→2024 boundary-change matrix a second time. The final target surface has correlation 0.118 with the true per-constituency values; only 44 of 632 GB constituencies are within 5% of truth (median error 36%).
  • The four uc_hh_*_children constituency columns are derived from the same totals and inherit the misalignment (verified: the splits sum back to the misaligned totals).

National totals are preserved (both parsers rescale to the national payment-distribution total), which is why nothing aggregate-level catches it.

Affected columns: uc_households at both grains + the 4 child-split columns — 5 of 21 constituency loss-matrix columns and 1 of 30 LA columns, in every release since the constituency UC targets landed (2025-10-21, 8fad6d5; LA 2025-11-07, c61798e; child splits 2026-02-19, bd90d28). Verified unfixed at current main (12a1e02, v1.56.16). No test references the alignment.

Mechanism

Local authorities. utils/uc_data.py::_parse_uc_la_households reads uc_la_households.xlsx row-by-row into {la_name, household_count} (GB rows in spreadsheet order, NI rows appended from the DfC ODS), rescales to the national total, and returns — no sort, no merge, no codes. targets/sources/local_uc.py::get_la_uc_targets returns uc_la_households.household_count with a docstring claiming it is "aligned to the same ordering as the LA age.csv"; the construction contains no step that could make that true. local_authorities/loss.py then does y["uc_households"] = get_la_uc_targets().values against local_authorities_2021.csv order. Positional name agreement: 8/360 (the name sets do not even fully coincide — 22 rows differ only by bilingual Welsh labels, but the orderings are unrelated).

Constituencies. _parse_uc_pc_households reads uc_pc_households.xlsx the same way. Its names are 2024 Westminster constituencies — all 650 match constituencies_2024.csv, and only 416 coincide with the 2010 vocabulary of age.csv, which defines the y-frame's row order. Positional agreement with the 2010 order: 0/650 (and 0/650 with constituencies_2024.csv order too — the spreadsheet is in Stat-Xplore's own row order, which matches nothing in the repo). The y frame, built on 2010 rows, is then transformed by boundary_changes/mapping_matrix.py — so PCON24-native data is boundary-mapped as if it were 2010 data, compounding the permutation.

Every other family in the same y frames is code-joined (age.csv carries codes in file order, the ONS/tenure/rent families merge on code/la_code); UC is the only column with no key.

Evidence

1. Same data, permuted — the name-join control. Joining the parsed UC tables to the code files by name (splitting bilingual Welsh labels) aligns 360/360 and 650/650. So joined, the values reproduce the published Stat-Xplore May-2025 statistics (independently extracted in PolicyEngine/chronicle) at the parser's uniform national rescale:

grain rows compared ratio (uk-data ÷ published) spread
local authority 350 0.8985 max deviation from uniform 0.46% (extract-revision noise)
constituency 632 0.8965 p5–p95: 0.8904–0.9018

2. Positional assignment — what the loss builder actually calibrates to:

area positional target that row actually is true value (by name)
Birmingham (E08000025) 5,828 West Oxfordshire 175,630
Hackney (E09000012) 70 Isles of Scilly 38,243
Glasgow City (S12000049) 6,728 Moray 84,123

3. End-to-end constituency surface (post boundary-mapping, versus published values at the same national scale): correlation 0.118; 44/632 areas within 5%; median |relative error| 0.36; max 5.6×.

What we checked before raising this

  • The parsers' deliberate national rescale (×~0.90) is not the discrepancy — it is uniform and survives the name join; the misassignment is orthogonal to it.
  • The residual ≤0.46% per-area spread against the Chronicle extraction is publication-revision noise between download vintages, not structure.
  • origin/main equals the pinned release (12a1e02); neither utils/uc_data.py nor targets/sources/local_uc.py nor the loss builders changed since — the defect is live in the current release.
  • No file under tests/ references uc_pc_households / uc_la_households / the UC target getters.

Possibly related

#452 (UC caseload ~40% below administrative counts in calibrated results). Not claimed as caused by this, but a per-area UC surface that is noise with respect to the household weights gives the solver no coherent gradient toward the UC totals it is simultaneously asked to hit — worth re-checking #452 after a fix.

Suggested fix

Both correct join keys already exist in storage: the constituency names are PCON24 and match constituencies_2024.csv 650/650; the LA names match local_authorities_2021.csv 360/360 after splitting the bilingual Welsh labels. Merge on those (or better, carry codes out of the parse), place the constituency UC column on 2024 rows after the boundary mapping rather than through it (its data is already PCON24), and add a regression test asserting a handful of known areas (e.g. Birmingham ≈ 176k) so a future re-extract cannot silently regress.

How this was found

Found while building the microcosm local target surface (microcosm#759): a compile-parity fixture extracted from this repo's loss builders at 12a1e028 was compared against the same Stat-Xplore publication ported independently as Chronicle facts; the UC family was the only one whose matched rows diverged by more than publication-vintage noise, and the divergence pattern (uniform ratio under a name join, garbage under the positional join) isolated the alignment.


Verification run 2026-08-26 against the policyengine-uk-data-12a1e028 checkout with its own parsers and storage files; comparison facts from the Chronicle UK bundle at 33ca98a (feed sha 4395a4e7…).

🤖 Generated with Claude Code

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with utils/uc_data.py, targets/sources/local_uc.py, local_authorities/loss.py, and boundary_changes/mapping_matrix.py to trace how parsed UC names and values enter each loss frame. Check the existing test layout, then add regression coverage for known local authorities and constituencies. Done means UC targets are name- or code-aligned, constituency data is not boundary-mapped twice, and the cited areas have expected values.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
data-engineering
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
56/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.