Skip to content

Draw each cloned household's Output Area from its own FRS region - #517

Merged
MaxGhenis merged 3 commits into
mainfrom
oa-region-constrained
Oct 7, 2026
Merged

MaxGhenis merged 3 commits into
mainfrom
oa-region-constrained

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

clone_and_assign gives each cloned household an Output Area sampled by population from anywhere in its country. It then writes that OA's region_code_oa, la_code_oa and constituency_code_oa onto the household and leaves region alone. An English household's OA could therefore sit in any English region, whatever region the FRS recorded.

Measured on the released enhanced FRS 2024-25 (aggregates only):

Release English households whose OA is in another region Share of English weight Median English LA: weight from other-region households
1.56.16 (policyengine.py bundle 6.2.1) 33,586 of 37,961 (88.5%) 86.0% 92.8%
1.57.4 (latest) 33,583 of 37,961 (88.5%) 86.1% 92.5%

Wales and Scotland were unaffected, because each is one region in the crosswalk. Northern Ireland has no crosswalk rows, so its 5,659 households get no OA, before and after this PR.

Who reads these columns

  • policyengine.py (origin/main 6a9c878):
    • ConstituencyImpact, LocalAuthorityImpact and the constituency/LA entries of build_uk_region_registry (RowFilterStrategy) all group household rows by constituency_code_oa / la_code_oa, weighted by household_weight.
    • compute_longwise_uk_geography_impacts does the grouping.
    • The UK model passes these columns through to output datasets.
  • policyengine-api-v2: the simulation executor's UK local-authority output (simulation_output_geographic.py) calls the same function on la_code_oa.
  • This repo:
    • write_long_geography_weights runs in create_datasets.py and writes local_geography_weights.csv.gz from these columns.
    • matrix_builder.py and publish_local_h5s.py read them, but nothing in the build calls either.
    • db/etl.py reads only the crosswalk.
    • Constituency and LA calibration (create_constituency_target_matrix, create_local_authority_target_matrix) use a country mask and never read the OA columns.
  • policyengine-uk: does not reference any of these columns, so no household's tax or benefit calculation depends on them.
  • microcosm: has its own sampler (assign_household_geography, constrain_to_region=True by default) and is unaffected.

So the bias lands in the area outputs: every English constituency's and LA's figure was built from an England-wide mix of households.

Fix

  • assign_random_geography takes household_regions (FRS region names or crosswalk region codes) and samples each household's OA, population-weighted, from its own region.
    • Wales and Scotland are one region each.
    • A missing or UNKNOWN region falls back to the country.
  • Constituency collision avoidance across clones draws from the same region.
    • No LA or constituency straddles a region, so every OA stays reachable.
    • The smallest region (North East) has 27 constituencies, more than the default 10 clones (release and PR builds set PE_UK_DATA_OA_CLONES=1).
  • It raises on a region with no OAs in the crosswalk, a region that contradicts the household's country, and an unrecognised region value.
  • A region that holds every OA of its country (Wales and Scotland in the crosswalk) shares the country's sampling stratum, so its households draw exactly as a country-only call. With one clone, every Welsh and Scottish household keeps exactly its released OA (checked on 1.57.4).
  • clone_and_assign passes household.region and derives each household's country with the same region normaliser the sampler uses (names, ONS codes, bytes, untidy text). An unrecognised region raises instead of silently counting as England.
  • Called without regions, the function draws exactly as before: seed 42 on the released households reproduces the released oa_code (checked on 1.57.4) and la_code_oa and constituency_code_oa (checked on 1.56.16) exactly.
  • Adds hypothesis to the dev extras for the property tests.

Invariants (tests/test_oa_region_assignment.py)

Hypothesis property tests over generated crosswalks (1-6 regions, zero-population OAs and strata, countries present or absent), households whose region arrives as a name, ONS code, bytes or untidy text, UNKNOWN regions in every country, 1-4 clones and any seed:

  1. Region: for every household and every clone, the OA's region code is the household's FRS region code. An UNKNOWN region stays in its country.
  2. Row integrity: every assigned LSOA, MSOA, LA, constituency and region is the crosswalk's own row for the assigned OA. While its stratum has people, no zero-population OA is drawn.
  3. Determinism: same inputs and seed give an identical assignment and an identical cloned household table.
  4. Weights:
    • clone_and_assign preserves total household weight and each source household's weight.
    • region is never rewritten.
    • A country the crosswalk does not cover gets no geography at all; a known region missing from a covered country raises.
  5. Differential: when every country is one region, the region path and the country path return identical arrays, including with UNKNOWN households shuffled among known ones.

Example tests cover:

  • population weighting within a region (a 1:3 OA pair splits 0.75 ± 0.012 over 20,000 draws, whatever the size of the next region);
  • the unchanged country-only path;
  • UNKNOWN/empty/None/NaN regions;
  • names, codes and bytes giving identical draws;
  • every error path;
  • collision avoidance within a 12-constituency region at 10 clones;
  • termination when a region has one constituency;
  • uniform draws in a zero-population region;
  • the review's counterexample (an UNKNOWN and a London household where London is England's only region);
  • untidy region values through clone_and_assign.

Checks on the real crosswalk:

  • the region code map against known LAs (Hartlepool, Manchester, Leeds, Nottingham, Birmingham, Cambridge, Westminster, Brighton and Hove, Bristol, Cardiff, Glasgow);
  • LAs and constituencies nesting in regions;
  • every region having at least 10 constituencies;
  • 10-clone assignment with all-distinct constituencies.

test_built_dataset_oa_region_matches_frs_region asserts invariant 1 on the enhanced FRS that CI builds: exact for known regions, English for households with no region below the country. It ran and passed in this PR's Test job at f8b21d3.

Mutation check (head c842656): all seven mutants are killed. Each row names the first failing test.

Mutant First failing test
clone_and_assign stops passing region (the original bug) clone_and_assign property
Sampler ignores the region assignment property
Collision avoidance removed within-region collision example
Zero-population stratum always picks its first OA zero-population uniform example
Sole-region stratum not shared with the country differential property
Strict country lookup in clone_and_assign clone_and_assign property
UNKNOWN read as London assignment property

Effect on outputs

Method

  • Runs: policyengine-uk 2.102.3 on the certified enhanced FRS 2024-25 @1.56.16, year 2026. There are three real runs: the baseline, personal allowance +£1,000, and UC standard allowance +10%.
  • Grouping: area outputs come from policyengine.py's own compute_longwise_uk_geography_impacts (6a9c878).
  • Before and after: "Before" is the released OA columns, or the country-only draw for other seeds. "After" is the region-constrained draw on the same households.
  • What stays fixed: household incomes are the same in both, so aggregates are unchanged (£9.40bn and £3.13bn). Only which area each household is counted in moves.
  • Head: computed with the f8b21d3 sampler. c842656 draws identically on the households of both releases (seeds 42, 0 and 7 checked), so the results hold for the current head.

Households counted in each region's LAs vs that region's own households

Baseline mean household net income, £/year, released seed:

Region Own households Before After
North East 49,357 72,527 49,357
London 70,263 57,617 70,263
Yorkshire 52,381 49,165 52,381
East of England 63,265 60,562 63,265

After the fix, the households in a region's LAs are exactly that region's households, in every draw.

Expected area outputs, mean over 200 draws (English areas)

Reform England-wide Own-region range Before: each region's LAs After
Personal allowance +£1,000 £298.5 £275.0 to £333.0 £296.5 to £300.4 = own region
UC standard allowance +10% £98.1 £70.5 to £142.6 £97.5 to £101.8 = own region

Correlation of each area's 200-draw mean average household income change with its region's own figure:

LAs (309) Constituencies (543)
Personal allowance +£1,000, before / after −0.01 / 0.72 0.06 / 0.71
UC standard allowance +10%, before / after −0.05 / 0.90 0.03 / 0.91

External check (released seed)

Each LA's modelled mean household net income was correlated with its ONS net income before housing costs (FYE 2020, the mean of its MSOA estimates; the file the LA calibration targets use). Across 317 LAs the correlation rises from 0.04 to 0.23.

What this does not fix

The FRS records nothing finer than region, so after this PR an area's expected figure is its region's figure, not its own.

Release builds give each household one OA (PE_UK_DATA_OA_CLONES=1), so an LA has a median of about 100 sample households and a constituency about 70. A single area's figure moves by about £77 to £115 (standard deviation across draws) for these reforms. That is larger than the gaps between regions.

This PR removes the systematic error, not that noise. Comparing these outputs with the area-calibrated weight matrices this repo still builds is a separate follow-up.

Review

  • Round 1 (independent Opus review of f8b21d3): REQUEST CHANGES, with three findings and a surviving mutant, all addressed in c842656.
    1. Equivalent country and region pools drew in a different order when known and UNKNOWN households were mixed. Fixed by sharing the stratum.
    2. The built-dataset test would have rejected a legitimate UNKNOWN fallback. Fixed.
    3. clone_and_assign and the sampler read untidy region values differently. Fixed with one normaliser.
    4. Nothing tested uniform draws in a zero-population region, so a mutant survived. Now tested.
  • Round 1 also confirmed:
    • The no-region path matches origin/main exactly: 80 cases on the committed crosswalk and 100 on a synthetic one.
    • The calibration path never reads the OA columns.
  • Round 2 (independent GPT-6.1 Sol review of c842656): APPROVE WITH NITS, with two documentation nits fixed in 1c5b20c (docs only).
    1. A country, not a region, the crosswalk does not cover gets no geography.
    2. 10 clones is the default, not what release builds use.
  • Round 2 also confirmed:
    • The targeted tests pass (61 passed, 2 skipped).
    • The built-dataset region check passed in CI at c842656.
  • Round 3 (delta review of the docs-only 1c5b20c): APPROVE. It checked the clone-count text against create_datasets.py, both workflows and the crosswalk.

Release

Merging to main runs push.yaml, which builds, uploads and tags a data release. This PR waits for the batched uk-data release rather than merging on its own.

axiom: n/a: data-pipeline geography, no policy rule changes.

🤖 Generated with Claude Code

clone_and_assign sampled a household's OA by population from anywhere in
its country, then wrote the OA's region, LA and constituency onto the
household without touching `region`. In release 1.57.4, 88.5% of English
households (86% of English weight) carry an OA in a different region from
the one the FRS recorded, and the median English LA draws 93% of its
weight from households surveyed in another region.

policyengine.py groups households by `constituency_code_oa` / `la_code_oa`
for its constituency and local-authority impacts and for area-scoped
simulations, so every English area was reporting an England-wide mix.

assign_random_geography now takes the households' regions and samples
within the region (Wales and Scotland are one region each; a household
with no region below the country falls back to its country). Collision
avoidance draws from the same region. A region with no OAs in the
crosswalk, a region that contradicts the country, or an unrecognised
region value raises. Called without regions it draws exactly as before:
seed 42 reproduces the released `oa_code` column.

Adds Hypothesis to the dev extras for the property tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 3, 2026 08:06
From the independent review of f8b21d3:

- A region that holds every OA of its country (Wales, Scotland) now shares
  the country's stratum, so mixed known and UNKNOWN households draw exactly
  as a country-only call. Before, the two strata consumed the RNG in a
  different household order. With one clone, Welsh and Scottish households
  keep exactly their released OAs.
- clone_and_assign derives each household's country with the sampler's own
  region normaliser, so bytes, ONS codes and untidy text no longer give a
  contradictory country (an unrecognised region now raises instead of
  silently counting as England).
- The built-dataset test checks known regions exactly and households with
  no region below the country against England, instead of failing them.
- New tests: uniform draws in a zero-population region (the mutant that
  survived), the review's mixed UNKNOWN/London counterexample, and untidy
  regions through clone_and_assign. The property strategies now generate
  UNKNOWN in every country, regions as names/codes/bytes/untidy text, and
  countries the crosswalk does not cover.
- Docs: create_datasets.py exports the long geography weights; it does not
  call publish_local_h5s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review nit: create_datasets.py defaults to 10 clones (2 with TESTING=1),
and PE_UK_DATA_OA_CLONES overrides it; push.yaml and pull_request.yaml set
it to 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 4, 2026 10:43
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hand-off to the UK hub (owning session fba13616)

  • Head: 1c5b20c, out of draft, MERGEABLE.
  • Reviews:
    1. Round 1 (Opus, f8b21d3): REQUEST CHANGES. All four points were fixed in c842656.
    2. Round 2 (GPT-6.1 Sol, c842656): APPROVE WITH NITS. The two documentation nits were fixed in 1c5b20c.
    3. Round 3 (delta review, 1c5b20c, docs only): APPROVE.
  • CI: at c842656, every check passed, including the Test job's built-dataset region check. At 1c5b20c, 4 checks pass and Test is running.
  • Merge: lands only in the batched uk-data data release, on Max's go. It does not merge on its own.
  • Dependencies: none. Like several other batch PRs, it adds hypothesis to the dev extras; whichever lands second relocks uv.lock.
  • Follow-ups: two, already split out of this PR.
    • The longwise constituency and LA outputs still carry about £100 of noise per area per draw. Compare them with the calibrated weight matrices.
    • The BRMA draw is independent of the OA draw.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Queued in release PR #544 for the 10/8 uk-data batch. It lands only on Max's go (d833).

@MaxGhenis
MaxGhenis merged commit 1aed341 into main Oct 7, 2026
5 checks passed
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.

1 participant