Skip to content

Match ONS age targets to the FRS top code of 80 - #604

Merged
MaxGhenis merged 2 commits into
mainfrom
fix/593-age-topcode
Oct 19, 2026
Merged

MaxGhenis merged 2 commits into
mainfrom
fix/593-age-topcode

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Part of #593

FRS ages are top-coded at 80. National calibration currently gives the entire 80+ sample an 80–84 target and also asks for two 85–90 columns with no records. This change emits ons/{female,male}_80_plus and ons/{region}_age_80_plus, with open-ended compute masks and complete ONS projection sums, including 90+ and the oldest grouped ages.

The data still has no records over 80, and this change only stops calibration chasing targets the data can't represent. It does not assign older ages or establish the recalibrated fiscal or distributional effect.

2025 target values (people)

Sex Old 80–84 Old 85–90 New 80+
female 1,064,043 766,765 2,164,978
male 853,423 524,918 1,543,695
Region Old 80–89 target New 80+ target
east 379,000 383,909.917
east_midlands 273,000 279,796.208
london 304,000 292,199.323
north_east 153,000 149,741.415
north_west 399,000 394,643.102
northern_ireland 93,000 95,719.000
scotland 297,000 290,656.000
south_east 559,000 569,679.705
south_west 388,000 399,727.616
wales 190,000 191,184.000
west_midlands 337,000 339,480.659
yorkshire_and_the_humber 302,000 300,471.035

National targets preserve the existing 2024-based ONS UK principal projection. They now include every age group from 80 through 110 and over, including the grouped 105–109 row. The regional top targets use directly reproducible 2022-based ONS sums: the aligned five-year-migration English subnational projection and principal country projections. This also refreshes the projection vintage and precision; lower regional bands remain unchanged. The old regional values cannot safely be interpreted as omitting 90+: e.g. East 2022 was 350,000, close to published 80+ 350,364 rather than 80–89 284,842. Their original extraction is unidentified. See tools/ons_age_targets/README.md.

Signal on the released 1.58.1 file

Measured at calibration year 2025 on released uk-data 1.58.1 enhanced_frs_2024_25.h5, SHA-256 056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784, with locked policyengine-uk 2.122.2 / core 3.32.13. One Microsimulation was created; only demographic band compute functions and household weights were evaluated, through the same _compute_column dispatch as create_target_matrix. Person records are the sum of each household count column; nonzero households count contributing matrix rows; weighted totals are column dot household_weight for each stated year. Counts and weighted totals only were output. No dataset was built or recalibrated.

Maximum age remains 80; 7,718 records are 80 and 0 are over 80. Both sex columns and the twelve regional columns separately partition those age-80 records. These are pre-recalibration column signals, not predicted recalibrated populations or fiscal effects.

New target (ons/ prefix) Person records Nonzero households People at 2024 weights People at 2025 weights
female_80_plus 4,054 4,050 2,557,341.635 2,575,754.517
male_80_plus 3,664 3,660 1,036,411.275 1,043,873.442
east_age_80_plus 614 514 369,221.371 371,879.767
east_midlands_age_80_plus 427 353 272,817.274 274,781.560
london_age_80_plus 435 385 301,125.011 303,293.110
north_east_age_80_plus 293 253 152,583.813 153,682.416
north_west_age_80_plus 609 525 390,397.299 393,208.165
northern_ireland_age_80_plus 754 628 91,494.240 92,153.000
scotland_age_80_plus 728 616 295,627.391 297,755.915
south_east_age_80_plus 1,142 913 534,126.231 537,971.943
south_west_age_80_plus 831 712 375,899.020 378,605.497
wales_age_80_plus 673 540 186,601.655 187,945.187
west_midlands_age_80_plus 460 376 333,183.400 335,582.325
yorkshire_and_the_humber_age_80_plus 752 604 290,676.204 292,769.074

Local calibration and integration

Local age CSVs contain single years 80–89 and 90+, but local_age.py emits only half-open age/0_10 … age/70_80. Age-80 people contribute zero to every local age-specific column. Their household weights still enter other local targets and the shared national demographic loss (dense local calibration uses summed local weights for that loss; the OA path stacks national targets). The new national 80+ constraint therefore applies during local calibration. Local age targets are unchanged; there is still no local 80+ age-specific constraint.

The old literal names were checked across code, tests, docs and gap lists. Saved notebook outputs remain historical. #595 is a separate unmerged branch: when combining it with this fix, remove its two ons/*_85_90 known-gap entries and change its zero-signal gap-count assertion from 22 to 20 (release count 19 to 17).

Validation

Validation at 29ca75420b885ba7cd0ee3819817a974841a5a99, on top of reviewed head 61f8f7ef:

  • UV_LINK_MODE=clone uv sync --frozen --all-extras with Python 3.13 and threads capped at four; production lock unchanged. pytest-xdist installed ephemerally for serial -n 0 runs.
  • Exact reviewed-head reproduction using the public-fixture plugin: PYTHONPATH="$PWD/.job:$PWD" pytest -n 0 -p offline_ons policyengine_uk_data/tests/test_national_age_bands.py at 61f8f7ef: 2 failed, 3 passed (open-ended-band subtraction and 32-vs-34 emission count). All original file contents were used and the patch restored afterward.
  • Same offline national suite at this head: 5 passed. The reader includes 80_plus, validates the terminal (80, None) band and bounded five-year bands, and compares the complete 2025 population exactly with the fixture (69,502,341).
  • All five targeted files, foreground serial pytest -n 0 -p offline_ons: 46 passed — test_national_age_bands.py 5; test_ons_demographics_targets.py 16; test_age_demographics.py 13; test_matrix_builder.py 10; test_calibrate_validation_targets.py 2. The requested matching-test search found the first two files; the other three preserve round 1 coverage.
  • uvx ruff format --check .: 266 files already formatted; git diff --check passes.
  • Fixture CRLF -> LF normalization preserves every one of its 214 rows and all values. Regional-weight comments now describe the shared household weights and summed local weights accurately.

The current continuation used only the committed public fixture and synthetic test data: no source/dataset downloads, dataset build or production calibration. Earlier regional extraction replay, released-file signals and main-versus-head checks remain documented in the round 1 hand-off and independent review; they were not repeated here. Independent review and merging remain with the hub and Max's d833 batch go. The rebuild-queue row was already installed by the hub; no build GO was requested or used.

Reproducer: policyengine_uk_data/tests/test_national_age_bands.py::test_national_gender_age_targets_are_emitted_once_per_band

axiom: n/a: calibration demographic targets only

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Code hand-off at 61f8f7ef00297a1344345b87e0f97e9cf5efe398 (Part of #593).

  • 41 targeted tests passed, serial foreground -n 0; all 14 new cases fail on
    main. uvx ruff format --check . passed (266 files). Public regional source
    extract reproduced byte for byte in the locked venv.
  • One released 1.58.1 demographic-column measurement, aggregates only: all 14
    new 80+ columns have signal and each sex/region partition covers 7,718 records.
    New 2025 targets are female 2,164,978 and male 1,543,695. The PR body contains
    every regional old/new value and each column's counts and weighted totals.
  • The data still has no records over 80. No dataset was built or recalibrated;
    local age targets remain unchanged. Regional values also refresh projection
    vintage/precision; legacy top values cannot be assumed to exclude 90+.
  • Hub: queue row installed (rebuild-queue row 18); review independently, and remove the two
    obsolete ons/*_85_90 gaps when integrating Fail the calibration build on targets it drops or cannot fit #595 (coverage count 22 to 20).
    Recalibrated effect and merge wait for Max's d833 batch go. No self review or
    merge performed.

The build job's push failed on GitHub TLS timeouts. The UK hub published the branch from the job's bundle (same commit) and opened this PR. The hub changed only the Reproducer: line, to a test node for the Fix evidence check. The job's hand-off, impact report and bundle are kept in the hub's job folder (jobs/fix-593-age-topcode/job-copy/).

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Addressed the independent review of 61f8f7ef in 29ca75420b885ba7cd0ee3819817a974841a5a99 (round 3; saved round 2 patch applied).

  1. P2 national-band regression: accept ..._80_plus names, require the terminal (80, None) band, validate bounded inclusive five-year bands without subtracting None, and compare the complete emitted population with the fixture exactly: 69,502,341, including both 80+ targets. At exact 61f8f7ef contents, the fixture-backed national suite reproduces 2 failed, 3 passed (None subtraction and 32 instead of 34 targets); with this patch, 5 passed.
  2. Inherited regional-weight comment: corrected the source comment and test-module explanation. Regional ten-year controls constrain the shared household weights, including summed local weights in dense local calibration; national five-year sex bands provide finer age resolution.
  3. Fixture hygiene: normalized CRLF to LF. Verified byte equality after newline normalization and exact equality of all 214 rows; population remains 69,502,341.

Commands (worktree locked venv on PATH; numerical threads <=4):

UV_LINK_MODE=clone uv sync --frozen --all-extras
# Copied review offline_ons.py into .job/ so its fixture path resolves here.
PYTHONPATH="$PWD/.job:$PWD" pytest -n 0 -p offline_ons policyengine_uk_data/tests/test_national_age_bands.py
PYTHONPATH="$PWD/.job:$PWD" pytest -n 0 -p offline_ons \
  policyengine_uk_data/tests/test_national_age_bands.py \
  policyengine_uk_data/tests/test_ons_demographics_targets.py \
  policyengine_uk_data/tests/test_age_demographics.py \
  policyengine_uk_data/tests/test_matrix_builder.py \
  policyengine_uk_data/tests/test_calibrate_validation_targets.py
uvx ruff format --check .
git diff --check

Five-file result: 46 passed (national 5; ONS targets 16; local-age 13; matrix builder 10; local-validation 2). Ruff: 266 files already formatted. Original-head reproduction and current-head logs/XML, progress and hand-off are under .job/.

Code hand-off: 29ca75420b885ba7cd0ee3819817a974841a5a99 pushed to fix/593-age-topcode, preserving the existing author/history; PR Validation and test-node Reproducer updated. Impact is regression coverage, accurate comments and fixture newline hygiene; this continuation changes no target values. No source/dataset downloads, dataset build, production calibration, self-review or merge. Hub independent review and Max's d833 batch go remain outstanding.

#595 integration: whichever of #595 (eb247c64) and #604 lands second must drop ons/female_85_90 and ons/male_85_90 from targets/known_gaps.yaml; update tests/test_target_coverage.py zero-signal count 22 -> 20 and released count 19 -> 17. These files are absent from #604's base/head and were not edited here.

MaxGhenis added a commit that referenced this pull request Oct 16, 2026
…test the matrix against main's

Answers Sol's review of #595 (F2, F3, F5, F6, F7).

- Duplicates (F2). build_target_matrix now reads every target the sources
  emit (registry.emitted_targets) and select_matrix_targets keeps the first
  of each name, national then region then country, exactly as before. Each
  target it drops gets the status `duplicate`, which fails the build unless
  known_gaps.yaml lists it. test_no_duplicate_names counts the raw emission,
  so it can fail. On the current registry 159 targets are dropped this way:
  156 HMRC SPI projections (#557) and 3 ONS copies (#605). They are listed;
  a name may be listed under two statuses, since the ten SPI £1m+ bands are
  both without signal and duplicated.
- Build kinds (F3). A known gap may set `builds: [testing]` or `[release]`;
  the build kind comes from TESTING, as in create_datasets. A testing-only
  gap may name fnmatch `patterns`, and one now covers every UC payment band
  in testing builds (#598), replacing the three SINGLE bands; release builds
  still fail on any band without signal but the three COUPLE_NO_CHILDREN
  ones. A pattern is stale only if it matches no target. Errors and
  non-finite values are never excused, whatever a gap says. Stale gaps and
  problems are also written, once each, to $GITHUB_STEP_SUMMARY.
- Status order (F5). The status is computed after the column enters the
  matrix, and a column whose status fails leaves it, so each offered target
  ends with exactly one status.
- Reasons (F6). The 85-90 age gaps are permanent while ages are top-coded
  at 80 (#604); no record can hold a postgraduate loan.
- A Hypothesis differential test checks that require_coverage=False builds
  main's matrix from the same sources, duplicates included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 16, 2026
MaxGhenis added a commit that referenced this pull request Oct 16, 2026
With #557 each SPI income band is emitted once, so its 156 duplicate entries
go; with #604 the ages are 80 and over, so the two ons/*_85_90 zero-signal
entries go. The shipped-file counts move from 19 to 17 without signal and
from 159 to 3 duplicates (the ONS three, #605). The ten SPI bands of £1m and
over keep their zero-signal entries: #557 gives them no signal.

On uk-data 1.58.1 at 2025 the coverage CLI reports 626 in the matrix, 3
duplicates, 15 with no 2025 value and 17 without signal, with no problems and
no stale gaps, as a release build and as a testing build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis pushed a commit that referenced this pull request Oct 18, 2026
@MaxGhenis
MaxGhenis merged commit 64d50f7 into main Oct 19, 2026
4 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