Skip to content

UK AI-exposure analysis stack: exposure crosswalk, occupation targets, exposure imputation, and ESRI-style AI shock scenarios - #325

Open
vahid-ahmadi wants to merge 14 commits into
mainfrom
feature/uk-ai-exposure-analysis
Open

UK AI-exposure analysis stack: exposure crosswalk, occupation targets, exposure imputation, and ESRI-style AI shock scenarios#325
vahid-ahmadi wants to merge 14 commits into
mainfrom
feature/uk-ai-exposure-analysis

Conversation

@vahid-ahmadi

@vahid-ahmadi vahid-ahmadi commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the six building blocks for a UK analysis of generative AI's fiscal and distributional impact by age/seniority (ESRI JR16 replication for the UK, extended along the age dimension), all under populace.build.uk_runtime:

1. AI-exposure crosswalk (ai_exposure.py + packaged CSVs)

  • All 412 SOC2020 unit groups scored on six measures: c_aioe (primary, Felten AIOE × Pizzinelli complementarity adjustment), complementarity (θ), dsit_aioe, dsit_llm, eloundou_beta, felten_aioe.
  • θ reconstructed from public O*NET 27.3 (CC BY 4.0) per the IMF WP/23/216 recipe; validated against the paper's published min/max anchor occupations (reproduced exactly, uniform +0.035 vintage offset documented).
  • UK-native DSIT Nov-2023 Annex 1 scores (OGL v3) mapped SOC2010→SOC2020 via the ONS coding index; US measures chained US-SOC2018→SOC2010→ISCO-08→SOC2020 (BLS + ONS), mapping_quality + fan-out recorded per row (31 direct / 380 chained / 1 parent-imputed).
  • exposure_for_soc() with 4→3→2-digit fallback; exposure_for_major_group() (1-digit, ASHE-2025-employment-weighted) for microdata that carries only FRS major-group SOC.
  • Reproducibility: ai_exposure_sources.py pins source URLs and ships build_* functions for the θ reconstruction (published min/max anchors asserted), the DSIT SOC2010→SOC2020 and US-measure chains, the exposure table, and the ASHE-weighted major-group aggregation (the last regenerated the packaged major-group CSV from packaged data). One step is still open: the checked-in c_aioe composition does not reproduce the shipped c_aioe column from the shipped components (see review thread) — the shipped scores themselves remain anchored to the paper's published extremes.

2. Occupation calibration targets (occupation_targets.py + packaged CSVs)

  • ASHE Table 14 (2025 provisional, SOC2020 4-digit): 340 employment targets (suppressed cells skipped-and-reported).
  • APS via Nomis (Jan–Dec 2025): 26 sub-major occupation targets + 6 age-band targets on the scenario age scheme (16-24/25-34/35-44/45-54/55-64/65+, derived from the NM_170_1 five-year bands and reconciling with T01 where the schemes coincide). Occupation×age cross-tab is not published; margins only, documented.
  • Returns TargetSets (entity=person) with full provenance; bad rows skipped-and-reported, never silently dropped.

3. Exposure imputation stage (exposure_imputation.py)

  • QRF is numeric-only (targets coerced to float, quantile interpolation), so the stage imputes the numeric exposure score, never occupation codes — with observed FRS soc_major_group as the lead predictor (refine-within-group), a warned blind fallback, and a zero-model baseline (exposure_from_major_group).
  • Ships as a plan.Stage whose consumes includes soc_major_group, so runs are blocked before the FRS occupation merge.
  • Donor contract documents the UKDS EUL 5-quarter longitudinal LFS (4-digit SOC2020) column mapping and EUL constraints; donor microdata is never committed.

4. AI shock scenarios (ai_shock_scenarios.py)

  • ESRI JR16 mechanics: employment shock per eq 3.4 (group quotas ∝ EMP×C-AIOE, weighted random within-group selection, summaries averaged over 50 seeded draws); wage shock per eq 3.5 (distributed by θ, not exposure; uniform fallback warns); capital shock parameterised as return 1.005%→1.405% (≈+39.8%) on interest+dividends, rent excluded.
  • Presets: central (7% displacement, +2.6% wages — ESRI JR16's adoption of Briggs & Kodnani 2023, fn.3 §3.2), low (~1%, Acemoglu 2025, Economic Policy 40(121), employment-only per JR16 fn.8), high (13%, Brynjolfsson, Chandar & Chen 2025 — a cohort-specific relative decline, cited with that caveat) — documented as overridable literature anchors. JR16's own robustness grid (1–10% × +1–5%, +0.4pp capital always on) ships in the runner.
  • Extension beyond ESRI: youth_displacement_multiplier (default 1.0 = pure replication) tilts within-group displacement toward 16–24 while preserving eq-3.4 group totals; KLEIN_TEESELINK_YOUTH_MULTIPLIER (5.8/4.5 ≈ 1.29) and a central_youth_tilted preset calibrate it to the UK junior-role evidence. All summaries resolve by age band; employees-only scope (mirroring ESRI) is stated, and the employment summary reports excluded_self_employed_weighted.

5. FRS occupation merge (frs_occupation.py)

  • A plan.Stage producing observed soc_major_group on the person table from the licensed UKDA FRS adult.tab (SOC2020, major groups coded 1000–9000; UKDS EUL microdata, path supplied by the caller, never committed). Slots immediately before exposure_imputation_stage, whose primary path consumes it.
  • Joins on the PolicyEngine-UK single-year composite person_id = SERNUM * 1000 + PERSON (the upstream data build's assignment, cited precisely in the module docstring) — verified 1:1 against the same-vintage PE-UK H5: 28,590/28,590 adults on FRS 2023-24 and 27,714/27,714 on FRS 2024-25.
  • Loud-failure contract: duplicated composites raise; a zero-match join (ID-convention mismatch) raises rather than attaching an all-NaN column. Children and adults with missing/invalid SOC2020 stay NaN, with a count report.
  • Single-year datasets only: enhanced FRS builds clone households onto offset IDs downstream, so the observed-SOC join targets the single-year dataset; propagating soc_major_group through the clone step is an upstream follow-up (the imputation stage covers enhanced datasets meanwhile).

6. End-to-end shock runner (ai_shock_runner.py)

  • dataset → exposure (QRF draws, or the zero-model crosswalk baseline from observed soc_major_group) → presets and/or the JR16 robustness grid → PolicyEngine-UK write-back (employment_income, savings_interest_income, dividend_income, and employment_status=UNEMPLOYED where the input is accepted) → Exchequer cost, poverty (BHC/AHC), and Gini deltas — overall, by baseline income decile (the JR16 replication view), and by age band (the extension).
  • Everything summary-mathematical (weighted Gini, decile assignment, deltas, grid construction, JSON/CSV writers) is engine-free and unit-tested; only the two run functions touch policyengine_uk (lazy import behind the uk extra).
  • ESRI's displaced-workers-as-long-term-unemployed treatment is only partially expressible — PE-UK carries no unemployment-duration input, so contributory-benefit exhaustion is a documented scope limitation, recorded per run via employment_status_applied.

Relation to "Who bears the AI shock?" (SSRN 7174479)

This PR is the reusable populace infrastructure behind the working paper "Who bears the AI shock? The distributional and fiscal incidence in the UK" (SSRN 7174479). The study repo, PolicyEngine/uk-ai-study, packages the major-group crosswalk derived here (its README's method table cites this PR) and follows the same JR16 scenario architecture against the FRS 2024-25 single-year dataset.

Tests

85 tests across the six modules' packages/populace-build/tests/test_*.py files, plus two opt-in tests: an engine-backed runner check and a real-data join check (POPULACE_UK_FRS_ADULT_TAB + POPULACE_UK_FRS_H5) that rebuilds IDs from the raw adult.tab and requires every row to appear in the same-vintage PE-UK H5 — the upstream-drift class that self-consistent synthetic fixtures cannot catch. Full workspace suite and ruff check clean. Packaged data files are all <50 KB, open-licensed (OGL v3 / MIT / CC BY 4.0), with sources cited in module docstrings and derivations checked in (ai_exposure_sources.py).

Follow-ups (tracked, not in this PR)

  • Enhanced-FRS propagation: carrying observed soc_major_group through the household-cloning step (or exporting a clone→original ID mapping) so the observed-SOC path also covers enhanced datasets — an upstream data-build change.
  • Placement: whether the exposure/scenario/runner layer later moves to a research sub-namespace is a maintainer call from review; occupation_targets, frs_occupation, and exposure_imputation couple to the calibrate/plan machinery and belong in uk_runtime either way.

References

The design replicates and extends the methodology of these papers:

  • Doorley, K., O'Connor, S., O'Shea, R. & Tuda, D. (2026), Artificial intelligence and income inequality in Ireland, ESRI/Dept. of Finance Jointly-published Report No. 16 — the method being replicated for the UK: C-AIOE exposure -> employment/wage/capital shock scenarios (eq 3.4/3.5) -> tax-benefit microsimulation (SWITCH). PDF
  • Klein Teeselink, B. (2025), Generative AI and Labor Market Outcomes: Evidence from the United Kingdom, SSRN 5516798 — UK evidence that GenAI-exposed firms cut employment (-4.5%), concentrated in junior roles (-5.8%); anchors the displacement-scenario calibration and the youth multiplier. SSRN
  • Hosseini, S.M. & Lichtinger, G. (2026), Generative AI as Seniority-Biased Technological Change: Evidence from U.S. Resume and Job Posting Data — motivates the youth_displacement_multiplier extension: GenAI adoption reduces junior employment (~9% after six quarters) while senior employment is unaffected. PDF / SSRN 5425555

🤖 Generated with Claude Code

vahid-ahmadi and others added 9 commits July 6, 2026 15:48
…E/APS

Declare person-entity employment-count TargetSets for UK calibration from
two ONS source families, parsed from tidy CSVs committed under
populace/build/uk/data (all under 25 KB):

- ASHE Table 14.7a (Annual pay - Gross), occupation by four-digit SOC2020,
  2025 provisional edition released 23 October 2025: employee-job counts
  per unit group (340 targets; median annual pay carried for provenance
  only, since a median is not a sum constraint).
- Annual Population Survey via Nomis dataset NM_17_1, Jan 2025-Dec 2025,
  United Kingdom: employment by SOC2020 sub-major group (table T09b, 26
  targets) and, separately, in-employment counts by age band (table T01,
  6 disjoint bands). The APS publishes no occupation-by-age
  cross-tabulation, so the two margins are declared as independent target
  families.

Each builder returns the TargetSet plus a skipped-row report: suppressed
ASHE cells, non-unit-group codes, duplicates, and unparseable counts are
skipped and reported, never silently dropped; structural problems
(missing columns, empty files, all rows skipped) raise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New populace.build.uk_runtime.ai_shock_scenarios implements the
scenario-microsimulation method of ESRI JR16 (Doorley et al. 2026,
Chapter 3) on a pandas person table carrying ai_exposure:

- Employment shock (eq 3.4): TotalJobLoss allocated across occupation
  groups by employment-weighted C-AIOE, random within-group selection,
  summaries averaged over n_draws seeded draws (default 50). Displaced
  persons get employment_income=0, pre_shock_employment_income and an
  ai_displaced flag for downstream long-term-unemployed treatment.
- Wage shock (eq 3.5): aggregate wage change distributed by the separate
  ai_complementarity score (uniform fallback with a warning), weighted
  mean uplift equal to the scenario parameter.
- Capital shock: interest/dividend income scaled by
  capital_return_increase / base_capital_return (ESRI 0.004/0.01005,
  ~+39.8%); rental income excluded.

Presets central/low/high anchor Briggs & Kodnani (7% job loss, +2.6%
wages), Acemoglu (~1% job loss) and a 13% high variant; all documented
as literature placeholders for the analyst to override.

Extension beyond ESRI: all summaries resolved by age band (16-24 ...
65+) and an optional youth_displacement_multiplier tilting within-group
selection toward 16-24 year olds while preserving the eq 3.4 group
totals (default 1.0 = ESRI's age-neutral selection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New populace.build.uk_runtime.exposure_imputation module: a QRF-based
stage that attaches a numeric AI-exposure score to the person table.

Design: the stack's canonical imputer (RegimeGatedQRF) is numeric-only —
targets are coerced to float64 and draws linearly interpolate quantile
predictions — so occupation codes are never imputed. The FRS observes
SOC 2020 at major-group level (adult.tab SOC2020, merged upstream), so
the primary path conditions on the true soc_major_group and refines
exposure within it from shared covariates; omitting the group is a
documented blind fallback that emits a UserWarning. A zero-model
baseline (exposure_from_major_group: employment-weighted crosswalk mean
per major group) brackets the model from below for robustness checks.

- fit_exposure_imputer / impute_exposure / attach_exposure, plus an
  exposure_imputation_stage factory declaring the plan Stage with its
  LFS/APS DonorSpec
- documented DONOR CONTRACT: LFS/APS-derived frame with SOC->exposure
  pre-joined (lazy reference to the ai_exposure crosswalk module on its
  own branch); UKDS-licensed microdata never committed
- tests on synthetic frames: draws stay in donor support, typed design
  weights shift the imputed distribution, the planted education signal
  survives, and major-group conditioning beats the blind path on MAE

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ship a pre-built crosswalk attaching occupation-level AI-exposure
measures to every SOC 2020 unit group (412 rows), plus an ASHE-
employment-weighted major-group table, with lookup helpers in
populace.build.uk_runtime.ai_exposure:

- c_aioe: complementarity-adjusted AIOE per Pizzinelli et al. (2023,
  IMF WP/23/216) as applied to UK SOC by Williamson et al. (2025);
  theta reconstructed from the published O*NET recipe (O*NET 27.3)
  and validated against the paper's published min/max occupations.
- dsit_aioe / dsit_llm: DSIT 'Impact of AI on UK jobs and training'
  (Nov 2023) Annex 1 scores on UK SOC 2010, mapped to SOC 2020 via
  the ONS coding index.
- eloundou_beta (GPTs-are-GPTs, MIT) and felten_aioe (AIOE-Data),
  chained US SOC 2018 -> US SOC 2010 -> ISCO-08 -> SOC 2020 via BLS
  and ONS crosswalks with unweighted means, flagged in
  mapping_quality (direct/chained/imputed-from-parent).

exposure_for_soc falls back 4-digit -> 3-digit -> 2-digit and warns
on unknown codes; exposure_for_major_group serves the 1-digit table
for microdata that only carries SOC 2020 major groups (FRS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-ai-exposure-analysis

# Conflicts:
#	packages/populace-build/src/populace/build/uk_runtime/__init__.py
…-analysis

# Conflicts:
#	packages/populace-build/src/populace/build/uk_runtime/__init__.py
The behavioral contract (test_spec_only_country_packages) requires country
packages (populace/build/uk) to contain only .json/.jsonld spec files.
Relocate the ASHE/APS tidy CSVs to uk_runtime/occupation_targets_data/,
mirroring the ai_exposure_data/ pattern, and update
packaged_occupation_csv_path accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi requested a review from MaxGhenis July 6, 2026 15:30

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Strong work — clean module boundaries, provenance in every docstring, and behavioral tests that assert the actual equations (eq 3.4 group quotas, eq 3.5 θ-graded uplift, the +39.8% capital ratio) rather than just importing. The donor-weight test and the primary-vs-blind conditioning test are exactly the contracts DESIGN.md was written around. CI is green, it's mergeable, and it's additive with no downstream consumers yet, so nothing here blocks. A few methodology and provenance notes (agent-assisted review; data spot-checks reconciled — APS bands sum to the 16+ total, mapping_quality is 380/31/1, ASHE yields exactly 340 targets):

  1. Crosswalk reproducibility. Only the output CSVs are committed; the ONET θ reconstruction and the US-SOC→ISCO→SOC2020 chaining live outside the repo. That's a documented choice, but the θ reconstruction is elaborate enough — and DESIGN.md's "artifacts carry their environment" pointed enough — that the builder (or a linked script) should be committed the way geography_sources ships its build_ functions. A follow-up issue is fine.

  2. Two age-band schemes. The APS age targets use 16-19/20-24/25-34/35-49/50-64/65+; the scenario summaries use 16-24/25-34/35-44/45-54/55-64/65+. 16-24 reconciles to the first two APS bands, but 35-49/50-64 don't decompose into the scenario bands, so a driver can't calibrate age employment to APS and report shock incidence on the same bands above 35. Worth settling before the analysis driver lands.

  3. Wage anchor attribution. The 7% displacement checks out as Goldman's (Briggs & Kodnani) 6-7% base case. I couldn't find the +2.6% wage figure in their work — their headlines are +15% productivity / +7% GDP — so please confirm where +2.6% comes from (ESRI's own calibration?) and cite that source in the module docstring and PRESETS comment. Low stakes since presets are labeled overridable anchors, but the citation should corroborate the value.

  4. Self-employed sit outside every shock (the employed mask is employment_income > 0, and self-employment income isn't in the capital columns either). That mirrors ESRI, but state it as a scope limitation in any writeup.

  5. Major-group coding seam: exposure_for_major_group wants 1-9 but the FRS side documents 1000-9000. It warns on unmatched rather than failing silently, but the integration step has to map codes or it gets all-NaN.

One unverified item, flagged as such rather than as a problem: the θ min/max anchor occupations vs IMF WP/23/216 (their site 403s). Happy to approve once the +2.6% citation is confirmed or corrected.

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — provenance and conventions are excellent; two maintainer calls before merge (scope placement + rebase). Not approving yet on those grounds, not on any correctness defect.

Exposure-crosswalk provenance is exemplary. Every measure in uk_soc2020_ai_exposure.csv (all 412 SOC2020 unit groups) is attributed to a specific, real, correctly-characterized source:

  • felten_aioe — Felten, Raj & Seamans (2021) standardised AIOE (AIOE-Data/AIOE GitHub).
  • eloundou_beta — Eloundou et al. (2023) "GPTs are GPTs" human-annotated beta (openai/GPTs-are-GPTs, MIT).
  • dsit_aioe/dsit_llm — DSIT/DfE "The impact of AI on UK jobs and training" (Nov 2023), Annex 1, the official UK SOC2010 mapping of the Felten scores.
  • c_aioe (primary) — complementarity-adjusted AIOE per Pizzinelli et al. (2023, IMF WP/23/216) and the Williamson et al. (2025, J Labour Market Res 59:30) UK/Ireland application.
  • The US-SOC-2018 → SOC-2010 (BLS Nov 2017) → ISCO-08 (BLS 2012) → SOC-2020 (ONS SOC2020 Vol 2 coding index) chain is fully documented, with many-to-many links flagged chained/direct/imputed-from-parent in mapping_quality.

The one reconstructed measure, complementarity_theta, is handled with real rigor and full disclosure: theta isn't openly published at occupation level (IMF/IGEES request-only), so it's rebuilt from the WP/23/216 recipe on ONET 27.3 and validated to reproduce the paper's published min/max occupations (US SOC 51-9031 / 29-1022) with a documented ~+0.03–0.04 vintage offset. No licensing issue — it derives from CC BY 4.0 ONET plus the published method, not the request-only data itself. Worth a note to swap in the exact IMF/IGEES theta if it ever becomes available.

Occupation-target provenance is equally clean. ASHE Table 14.7a → employment_jobs per SOC2020 4-digit (median pay carried for diagnostics only — correctly not a sum target); APS via Nomis NM_17_1, table T09b (sub-major occupation) and T01 (age band) declared as independent margins because the APS publishes no occupation×age cross-tab. CSV schemas match the docstrings and the magnitudes are right (16+ employment 33.3m ≈ UK total; SOC 11 2.62m). Bad rows are skipped-with-reason, structural problems raise.

Conventions match the UK build. exposure_imputation uses a proper DonorSpec (LFS Five-Quarter Longitudinal EUL) with a documented donor contract and fit_weighted predictors; occupation_targets compiles a populace.calibrate.TargetSet. Consistent with the DonorSpec/Stage + TargetSet patterns the rest of the UK stack uses.

Neutrality on the scenarios is right. ai_shock_scenarios is framed explicitly as scenario analysis, not causal/predictive estimation, implementing ESRI JR16 (Doorley et al. 2026) eqs 3.4/3.5 with parameters presented as overridable literature anchors (Briggs & Kodnani 2023 / Goldman central, Acemoglu 2025 low). It sets employment_income=0 + ai_displaced=True and leaves benefit/unemployment modelling to the downstream PE-UK run — clean separation. The by-age-band resolution and youth_displacement_multiplier are a documented, principled extension of ESRI's random within-group selection.

Two things for a maintainer to decide before merge:

  1. Scope/placement. This is ~+3980 lines of research-analysis infrastructure (an exposure crosswalk + shock-scenario engine) landing in populace-build/uk_runtime. occupation_targets unambiguously belongs (population calibration). The exposure crosswalk + scenario engine are an analysis layer riding on the microdata — reasonable to host here, but big enough that whether it lives in core populace-build vs. a separate research module is a call for the maintainers, not something to wave through in a routine review.
  2. Rebase. CONFLICTING/DIRTY — a large branch touching uk_runtime/__init__.py; it needs a rebase onto current main and the conflict resolution won't be purely mechanical. CI was green on the last push (test + wheels, 3.13/3.14).

Minor: the exposure major-group aggregation weights by ASHE Table 14 (2021 provisional) while the calibration targets use ASHE 2025 — different vintages for different purposes, defensible but worth aligning to the latest release; and the shipped CSVs are tidy derivations whose raw→tidy step lives outside the repo, so a checked-in derivation script would make the crosswalk fully reproducible from primary sources.

Not merging (review-only per request).

vahid-ahmadi and others added 2 commits July 10, 2026 11:39
…s, builders, and study pipeline

Review fixes (PR #325):
- Correct preset citations: +2.6% wages is ESRI JR16's reading of Briggs &
  Kodnani (2023) — the median wage-change estimate of their surveyed studies
  (JR16 fn.3, §3.2) — not a Goldman headline; low re-cited to Acemoglu (2025,
  Economic Policy 40(121)); high 13% re-anchored to Brynjolfsson, Chandar &
  Chen (2025) "Canaries in the Coal Mine?" with a cohort-specificity caveat
  and JR16's own 10% grid ceiling documented (grid: 1-10% x +1-5%, +0.4pp
  capital always on, per Cazzaniga/Pizzinelli et al. 2024).
- Reconcile APS age targets onto the scenario reporting bands
  (16-24/25-34/35-44/45-54/55-64/65+) via Nomis NM_170_1 five-year bands
  (T01 does not publish the scheme); sums reconcile to the 16+ total within
  publication rounding. Scenario summaries now accept an age_bands override.
- exposure_for_major_group accepts FRS adult.tab 1000-9000 coding.
- Major-group exposure weights regenerated on ASHE 2025 (was 2021), from the
  packaged occupation-targets CSV via the new builder.
- New ai_exposure_sources.py (geography_sources pattern): pinned source URLs
  and build_* derivations for theta (with published min/max anchors asserted),
  the DSIT and US-measure chains, the exposure table, and the executable
  ASHE-weighted major-group aggregation.
- Employment-shock summary reports excluded_self_employed_weighted; the
  employees-only scope is documented.
- Klein & Teeselink (2025) now calibrates a documented youth multiplier
  (5.8/4.5 ~= 1.29) and a central_youth_tilted preset; defaults stay at 1.0.

Study pipeline (previously listed as follow-ups):
- frs_occupation.py: Stage producing soc_major_group from the licensed FRS
  adult.tab (SERNUM/BENUNIT/PERSON join to PE-UK person ids), slotting before
  exposure_imputation_stage.
- ai_shock_runner.py: end-to-end driver (dataset -> exposure -> JR16 scenario
  grid -> PolicyEngine-UK write-back -> Exchequer/poverty/Gini deltas by
  income decile and age band), engine-free pure summary math, local_runner
  conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntime __init__ export union)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Thanks for both reviews — all points addressed in the two new commits (3b72a3e + merge 27c6ae3):

+2.6% wage anchor — confirmed and re-cited. It comes from ESRI JR16 itself: they adopt Briggs & Kodnani (2023), where 2.6pp is "the median wage change estimate of a number of studies surveyed by the authors" (JR16 fn.3, §3.2) — not a Goldman headline. Docstring and PRESETS now cite it that way. While auditing this I also found the high preset (13%) was mis-anchored: JR16's own robustness grid tops out at 10%; 13% is Brynjolfsson, Chandar & Chen (2025, "Canaries in the Coal Mine?"), a cohort-specific (ages 22–25, most-exposed occupations) relative decline. Kept the value, re-cited with that caveat, and documented JR16's grid (1–10% × +1–5%, +0.4pp capital always on) as the driver's scenario-grid shape. Low is now properly Acemoglu (2025, Economic Policy 40(121)), employment-only per JR16 fn.8.

Age bands — reconciled onto the scenario scheme. Nomis T01 doesn't publish 35–44/45–54/55–64, so the APS age targets are now derived from the companion NM_170_1 five-year bands (Jan–Dec 2025, same geography/measure), summed to 16-24/25-34/35-44/45-54/55-64/65+. Values reconcile exactly with T01 where the schemes coincide (16+, 16-19+20-24, 65+) and the six bands sum to the 16+ total within publication rounding. The shock summaries also accept an age_bands override. (FWIW, JR16 reports by income decile/tercile only — the age dimension is our extension, so the scenario scheme was the right one to standardize on.)

Major-group seamexposure_for_major_group now normalises the FRS 1000–9000 coding to 1–9 (tested both ways).

ASHE vintage — major-group weights regenerated on ASHE 2025 from the packaged occupation-targets CSV (max per-measure delta ≤0.078, rank orderings preserved); weighting column now ashe_2025_table14_jobs.

Reproducibility — new ai_exposure_sources.py on the geography_sources pattern: pinned source URLs plus build_* functions for the θ reconstruction (published min/max anchors asserted: 51-9031 / 29-1022), the DSIT SOC2010→SOC2020 and US-measure chains, the exposure table, and the ASHE-weighted major-group aggregation (the last one is fully executable from packaged data and produced the regenerated CSV). Steps the original docstrings underdetermine carry explicit TODOs rather than silent guesses.

Self-employed — employment summary now reports excluded_self_employed_weighted, and the employees-only scope (mirroring ESRI) is stated in the docstring.

Klein & Teeselink now calibrates rather than decorates: KLEIN_TEESELINK_YOUTH_MULTIPLIER = 5.8/4.5 ≈ 1.29 plus a central_youth_tilted preset; defaults stay at 1.0 (pure JR16 replication).

Follow-ups pulled into this PR: frs_occupation.py (Stage producing soc_major_group from the licensed adult.tab via the SERNUM/BENUNIT/PERSON→person_id join, slotting before exposure_imputation_stage) and ai_shock_runner.py (dataset → exposure → JR16 grid → PE-UK write-back → Exchequer/poverty/Gini by income decile and age band, engine-free summary math, local_runner conventions).

Rebase — merged current main; the __init__.py conflict is resolved as the export union and the PR now shows mergeable.

Scope/placement — your call stands: occupation_targets is calibration infrastructure; happy to move the exposure/scenario/runner layer to a sub-namespace or separate research package in a follow-up if you prefer — note exposure_imputation/frs_occupation couple to the plan/Stage machinery, so full extraction has real cost.

Suite: 1073 passed locally; ruff clean.

🤖 Generated with Claude Code

@vahid-ahmadi
vahid-ahmadi requested a review from MaxGhenis July 10, 2026 09:44
@vahid-ahmadi vahid-ahmadi self-assigned this Jul 10, 2026
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Ran the pipeline end-to-end locally (conda py3.13, PR branch, PolicyEngine HF data: frs_2024_25.zip + frs_2024_25.h5). Two findings:

1. Bug: frs_occupation.py person-ID formula doesn't match the current PE-UK build — the adult.tab join matches zero adults.

frs_person_ids builds person_id = SERNUM*100 + BENUNIT*10 + PERSON, but policyengine-uk-data (1.55.10) actually assigns person_id = SERNUM*1000 + PERSON (see policyengine_uk_data/datasets/frs.py:540, which renames sernumhousehold_id and sets person_id = household_id*1e3 + person; benunit_id = household_id*1e2 + benunit). Verified empirically on FRS 2024-25 (UKDA-9563 adult.tab vs frs_2024_25.h5):

  • PR formula: 0 / 34,966 persons matched
  • SERNUM*1000 + PERSON: 27,714 / 27,714 adults matched (25,998 with valid SOC2020; major-group counts 1:2593, 2:6248, 3:3081, 4:2939, 5:2599, 6:2580, 7:1637, 8:1855, 9:2466)

So frs_person_ids needs the corrected formula (and probably an integration test against a real or synthetic h5, since the unit tests construct both sides from the same assumed coding and can't catch this).

2. Enhanced FRS caveat: the SOC join doesn't survive the clone/re-sample step.

enhanced_frs_2024_25.h5 clones households in 4 blocks (household offsets of 16,288; blocks 2/4 are 10,000-household re-samples), so reversing person_id back to the original composite only recovers the base block (~28.9k of 112,984 persons matched even with the corrected key). Two options for the follow-up driver: merge soc_major_group before the clone step in the data build, or export the clone→original household mapping so exposure can be propagated to clones. Meanwhile the plain frs_2024_25.h5 works fine end-to-end — central preset via the zero-model crosswalk baseline gives Exchequer cost ≈ £18.3bn, poverty +2.3pp (BHC/AHC), Gini +0.012, as a sanity anchor.

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits August 4, 2026 08:50
# Conflicts:
#	packages/populace-build/src/populace/build/uk_runtime/__init__.py
…oins

The adult.tab join built person_id = SERNUM*100 + BENUNIT*10 + PERSON, but
the PolicyEngine-UK single-year build assigns SERNUM*1000 + PERSON (no
BENUNIT term) — the old composite matched 0 real adults (0/28,590 on FRS
2023-24; PR comment measured 0/34,966 persons on 2024-25). Corrected and
verified 1:1 against the same-vintage PE-UK H5 (28,590/28,590 on 2023-24)
with adult.tab-side counts reproducing the PR comment's 2024-25 numbers
exactly.

soc_major_group_for_persons now raises on a zero-match join instead of
attaching an all-NaN column, and on duplicated SERNUM*1000+PERSON
composites (PERSON unique within household is the assumption the composite
rests on). Synthetic fixtures now vary BENUNIT to pin that it does not
enter, expected IDs are upstream-contract literals, and an opt-in
integration test (POPULACE_UK_FRS_ADULT_TAB + POPULACE_UK_FRS_H5) joins the
real adult.tab against the real H5 — the drift class self-consistent
fixtures cannot catch. Enhanced-FRS datasets (offset clone IDs) are
documented as out of the observed-SOC path pending upstream propagation.

Also: exposure_imputation docstrings updated to the corrected convention
and the now-in-package frs_occupation stage; frs_occupation.py allowlisted
in the incumbent-package guard (provenance citation only) with the test
file reworded to avoid the literals; towncrier fragment added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utation

The QRF conditions on the raw group number, so a donor fitted on 1-9 digit
codes and a target carrying FRS 1000-9000 codes (or vice versa) would
silently produce plausible-looking draws conditioned on out-of-support
values. fit_exposure_imputer now classifies the donor's coding (digit vs
thousand; mixed or unrecognised codings raise) and stamps it on the
returned model via a FittedModel passthrough wrapper; impute_exposure
refuses a target frame on the other coding with a message naming
frs_major_group_to_digit as the normaliser. The blind fallback (no SOC
predictor) is unaffected, and the wrapper preserves the model surface the
stage reads (predictors). Found by cross-model review lane probing the
documented-but-unenforced coding contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor

Polish pass on this branch (agent-assisted, on Max's behalf). Three commits: the re-merge with main, the person-ID fix, and a new coding guard. One open question at the end needs the author.

1. Person-ID formula (your finding 1) — fixed and verified on real data

frs_person_ids now builds SERNUM * 1000 + PERSON, checked against the upstream source you cited (policyengine_uk_data/datasets/frs.py, 1.55.10: person_id = household_id * 1e3 + person) and against real microdata on two vintages:

  • FRS 2023-24: every adult.tab row joins the same-vintage single-year H5 — 28,590/28,590 matched, unique; the old composite matches 0/28,590.
  • FRS 2024-25: adult.tab-side counts reproduce yours exactly — 27,714 rows, unique under the new composite, 25,998 valid SOC2020, major-group counts 1:2593 / 2:6248 / 3:3081 / 4:2939 / 5:2599 / 6:2580 / 7:1637 / 8:1855 / 9:2466.

The join contract is now loud: duplicated composites raise (the PERSON-unique-within-household assumption), and a zero-match join raises instead of attaching an all-NaN column — the exact silent-failure shape of the original bug. The tests no longer construct both sides from the same assumed coding: expected IDs are upstream-contract literals, BENUNIT deliberately varies in the fixtures to pin that it doesn't enter, and a skipped-by-default integration test (POPULACE_UK_FRS_ADULT_TAB + POPULACE_UK_FRS_H5) joins the real adult.tab against the real H5, which is the drift class self-consistent fixtures can't catch.

2. Enhanced-FRS caveat (your finding 2) — documented as single-year-only

The module docstring now states the scope with the verified mechanism (clone_and_assign.py appends clones with clone_idx * id_multiplier added to every ID, so only the un-offset base block can match) and names the two upstream options — merge before cloning, or export a clone→original mapping — as the follow-up. The zero-match guard makes the failure mode loud meanwhile.

3. New guard: donor/target soc_major_group coding agreement

A cross-model review lane probed the documented-but-unenforced coding contract: a donor fitted on 19 digit codes would silently draw for a target carrying FRS 10009000 codes (the QRF conditions on the raw number, so the draws look plausible while conditioning on out-of-support values). fit_exposure_imputer now classifies and stamps the donor's coding (mixed/unrecognised codings raise), and impute_exposure refuses a mismatched target, naming frs_major_group_to_digit as the normaliser. Six new tests cover both directions, the mixed-coding case, and the blind-fallback and stage surfaces.

4. Housekeeping

Re-merged current main (uk_runtime/__init__.py resolved as the exact export union of both parents — 37 modules, 400 exports, deduped and sorted), added the missing towncrier fragment, updated two stale exposure_imputation docstrings that still described the join as "SERNUM/BENUNIT/PERSON in a separate data-prep step", and refreshed the PR body (sections 5–6 now describe frs_occupation/ai_shock_runner, which the old body still listed as not included; added the SSRN 7174479 + uk-ai-study links). Main also grew a guard test forbidding the incumbent data-package names in the live tree — the new test file avoids the literals and frs_occupation.py is allowlisted as a provenance-only citation.

Open question for @vahid-ahmadi: the c_aioe composition in build_ai_exposure_table cannot reproduce the shipped column

build_ai_exposure_table computes c_aioe = felten_aioe * (1 - (theta - theta_min)) at SOC2020 level, with theta_min taken from its input table. Feeding it the shipped components reproduces nothing: every one of the 412 scores drifts (max |Δ| ≈ 0.53). This isn't a theta_min choice — solving the shipped CSV for the implied theta_min per row (c_aioe/felten_aioe − 1 + theta) gives values ranging from −1.96 to +1.21 (σ ≈ 0.18), and it's non-constant even on the 31 direct (1:1-mapped) rows (−0.73 to +0.37). So no constant theta_min makes the checked-in formula produce the shipped c_aioe from the shipped felten_aioe/complementarity_theta — the shipped column must have been composed differently, most plausibly at US-SOC level (with the reconstruction's theta_min) before the many-to-many chaining, and/or against a differently-normalised AIOE than the shipped felten_aioe column. The shipped scores themselves stay anchored (the min/max occupations reproduce the paper's), so this is a reproducibility gap in the builder, not evidence against the crosswalk.

Could you either (a) adjust build_ai_exposure_table to the actual derivation pipeline (composition point, theta_min universe, and normalisation), ideally with a regression test asserting it reproduces the packaged CSV from packaged inputs, or (b) if parts of the original derivation are underdetermined, mark this step with the explicit TODO treatment you used elsewhere — right now it silently produces different numbers rather than declaring the gap? The PR body's reproducibility bullet now flags this as open either way.

State

Merge-blocking items from the reviews are otherwise resolved (rebase done, CI running on the new head). The one reserved maintainer call is placement; recommendation: keep the stack in uk_runtimeoccupation_targets, frs_occupation, and exposure_imputation couple to the calibrate/plan machinery, and extracting the analysis layer later is a mechanical follow-up if wanted.

🤖 Generated with Claude Code

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.

2 participants