Conversation
The FRS employment_status mapping zipped range(12) with 11 statuses, so
EMPSTATI 11 ("Other inactive" in the UKDS FRS 2024-25 data dictionary) was
unmapped and the fillna fallback made it LONG_TERM_DISABLED. That also put
other-inactive working-age adults into the ESA health-condition and
support-group proxies.
Map the adult codes through an explicit table keyed to the data dictionary,
make child-table rows CHILD, and fail the build on any adult code the table
does not know rather than guessing a status. Every adult in the 2020-21,
2022-23, 2023-24 and 2024-25 releases carries a code from 1 to 11.
Tests cover codes 0-11 exhaustively, Hypothesis properties of the mapping and
the ESA proxies, create_frs end to end for every code and a child row, and
the built datasets. Adds hypothesis as a dev dependency (same pyproject and uv.lock
change as #522 and #525).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI build logs are public, so the error now names the unknown codes without per-code counts and reports a total under 10 adults as "fewer than 10". Raised by the microcosm port session; no change on current FRS releases, where every adult has a code from 1 to 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under pandas 3, Series.astype(str) keeps NaN as a float, so sorting a set
holding a blank code and another unknown code raised TypeError and hid the
codes. Format from the float codes instead ("0, 12, blank"), which behaves
the same under pandas 2 and 3. Found by the microcosm port session's
differential under pandas 3.0.3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row-order property recomputed the mapping for every index; compute it once. The test comment now says what was checked for the older FRS releases (every adult has a code from 1 to 11), not that their labels match. Drop redundant parentheses in the create_frs test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
create_frssetemployment_statuswithcategorical(person.empstati, 1, range(12), EMPLOYMENTS).fillna("LONG_TERM_DISABLED").EMPLOYMENTShas 11 entries, sozip(range(12), EMPLOYMENTS)stops at code 10 and EMPSTATI 11 had no mapping. Thefillnathen made every code-11 adultLONG_TERM_DISABLED.In the UKDS FRS 2024-25 data dictionary (SN 9563, adult table, EMPSTATI "Adult - Employment Status - ILO definition"), 11 is "Other Inactive" and 9 is "Permanently sick/disabled". policyengine-uk's
EmploymentStatusalready hasOTHER_INACTIVE.In the raw FRS 2024-25 adult table, 916 adults (2.05m by FRS grossing weight) have code 11, beside 1,678 (3.24m) with code 9. All of them came out
LONG_TERM_DISABLED.The fix
FRS_EMPSTATI_EMPLOYMENT_STATUSmaps codes 1-11 explicitly, one data-dictionary label per line, usingEmploymentStatusmember names (a rename in policyengine-uk breaks loudly).derive_employment_status_from_frs(empstati, is_adult_record): child-table rows areCHILD. An adult whose code is missing or not in the table raises aValueErrornaming the codes, instead of falling back to a guessed status.Series.astype(str), whose NaN handling changed in pandas 3.LONG_TERM_DISABLEDis a health status that feeds the ESA proxies, andOTHER_INACTIVEwould equally be a guess for a code nobody has read.CHILDthrough code 0; the new code uses the row's table.categorical's NaN default of 1 (FT_EMPLOYED) never applied, because the combined table is already filled.Who reads
employment_statusIn uk-data (
git grepatorigin/mainb45c373, and the diffs of all 30 open uk-data PRs):derive_esa_health_condition_proxyreadsESA_HEALTH_EMPLOYMENT_STATUSES= (LONG_TERM_DISABLED,SHORT_TERM_DISABLED). Code-11 adults of working age with a reported status leave this proxy; that is the intended change. The tuple itself stays as it is.derive_esa_support_group_proxyreadsLONG_TERM_DISABLEDwith no hours worked, as a subset of the health proxy. Code-11 adults leave it too.derive_legacy_jobseeker_proxyreadsUNEMPLOYEDonly (code 5), so it is unchanged.employment_statusor the three proxies.FT_SELF_EMPLOYEDandPT_SELF_EMPLOYED.CHILDstatuses. Codes 9 and 11 therefore follow the same path there.In policyengine-uk (
git grepoforigin/mainat 3c48247eb and at 84f5ad43a, and of the heads of all 129 open PRs):employment_status.dynamics/labour_supply.pycalculate_excluded_from_labour_supply_responsesreads it only for self-employment andSTUDENT, so it is unchanged.esa_health_condition_proxy,esa_support_group_proxyorlegacy_jobseeker_proxy. They are saved in the dataset but nothing in the model reads them.employment_statusappears only as test input (#2007 setsSTUDENT) or in labour-supply exclusion tests (#1915), so none of them is affected.Impact (real build and microsimulation)
Build. I ran production builds (512 epochs, OA clones, torch seed 0, web targets from one shared HTTP cache) of
mainb45c373 and of this branch at d398400. Two builds ofmainare output-identical, so seeded builds reproduce.mainand the branch, including weights, so calibration did not move.employment_status,esa_health_condition_proxyandesa_support_group_proxydiffer.LONG_TERM_DISABLED→OTHER_INACTIVELONG_TERM_DISABLED→OTHER_INACTIVEIn the enhanced FRS, the 3,006 records are the 916 FRS rows plus their SPI copies, capital-gains clones and CGT band donors. No record enters either proxy, and the JSA proxy is unchanged. In the FRS,
LONG_TERM_DISABLEDfalls from 5.30m to 3.24m.Microsimulation. For each enhanced FRS, I ran one policyengine-uk
main(3c48247eb)Microsimulationthat calculated every one of the 976 variables for 2025 and 2026, and fingerprinted each array.mainand the branch exceptemployment_statusitself.In the 2025 microsimulation,
LONG_TERM_DISABLEDgoes from 5.81m to 3.50m andOTHER_INACTIVEfrom 0 to 2.31m.A rerun on policyengine-uk
main84f5ad43a (2.109.1) is queued behind the shared build slot; its result will be added here.Tests
test_frs_employment_status.py:CHILDare a bijection ontoEmploymentStatus.test_legacy_benefit_proxies.py:create_frsruns end to end for every code 1-11 (status and both ESA proxies), for a child row next to a code-11 adult, and for code 12, which is rejected.make dataoutput) on the FRS and enhanced FRS:EmploymentStatus.OTHER_INACTIVEis present.LONG_TERM_DISABLED, unknown codes → a default, 9 and 10 swapped,OTHER_INACTIVEadded to the ESA health statuses, and child rows treated as adults.Adds
hypothesisas a dev dependency. Thepyproject.tomlanduv.lockchange is identical to #522's and #525's, so whichever lands second merges cleanly.axiom: n/a: data mapping of an FRS survey code to an input variable; no policy rule changes.
microcosm's UK runtime (
uk_runtime/frs_employment.py) keeps the old map on purpose, for parity. A separate session is porting this fix there, with a differential test against this PR's function.Reviews: r1 approved d398400 (low findings, addressed in 5f9912d). r2 on 5f9912d is running.
Part of the batched uk-data release (d833); not to be merged on its own.
🤖 Generated with Claude Code