Skip to content

Commit 4a66ca1

Browse files
authored
Merge pull request #433 from PolicyEngine/carry-frs-employer-sector
Carry FRS employer sector (mjobsect) and SIC industry into the dataset
2 parents a671dc4 + f3c94d1 commit 4a66ca1

10 files changed

Lines changed: 202 additions & 6 deletions

File tree

changelog.d/433.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- Populate `employment_sector` (public/private, from FRS `mjobsect`) and `sic_industry_division` (SIC 2007, from FRS `sic`) Person-level variables in the FRS dataset.
2+
- Add a national calibration target for public-sector employment (`employment_sector == PUBLIC`) against the ONS Public Sector Employment headcount.

policyengine_uk_data/datasets/frs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,22 @@ def determine_education_level(fted_val, typeed2_val, age_val):
749749
person.empstati, 1, range(12), EMPLOYMENTS
750750
).fillna("LONG_TERM_DISABLED")
751751

752+
# Add employer sector of the main job from FRS `mjobsect`
753+
# (1 = private, 2 = public; missing/blank = not in paid work).
754+
EMPLOYMENT_SECTORS = ["NOT_EMPLOYED", "PRIVATE", "PUBLIC"]
755+
pe_person["employment_sector"] = categorical(
756+
pd.to_numeric(person.mjobsect, errors="coerce"),
757+
0,
758+
[0, 1, 2],
759+
EMPLOYMENT_SECTORS,
760+
).fillna("NOT_EMPLOYED")
761+
762+
# Standard Industrial Classification (2007) division of the main job from
763+
# FRS `sic` (0 if unknown; 84 = public administration and defence).
764+
pe_person["sic_industry_division"] = (
765+
pd.to_numeric(person.sic, errors="coerce").fillna(0).clip(lower=0).astype(int)
766+
)
767+
752768
REGIONS = [
753769
"NORTH_EAST",
754770
"NORTH_WEST",

policyengine_uk_data/targets/build_loss_matrix.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
compute_person_support,
3636
compute_obr_council_tax,
3737
compute_pip_claimants,
38+
compute_public_sector_employment,
3839
compute_regional_age,
3940
compute_savings_interest,
4041
compute_scotland_demographics,
@@ -276,6 +277,10 @@ def _compute_column(target: Target, ctx: _SimContext, year: int) -> np.ndarray |
276277
if target.variable == "tenure_type" and target.is_count:
277278
return compute_tenure(target, ctx)
278279

280+
# Public sector employment (ONS PSE)
281+
if target.variable == "employment_sector" and target.is_count:
282+
return compute_public_sector_employment(target, ctx)
283+
279284
# Income bands (HMRC SPI)
280285
if target.breakdown_variable == "total_income":
281286
return compute_income_band(target, ctx)

policyengine_uk_data/targets/compute/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
compute_housing,
3838
compute_land_value,
3939
compute_person_support,
40+
compute_public_sector_employment,
4041
compute_regional_land_value,
4142
compute_savings_interest,
4243
compute_scottish_child_payment,
@@ -59,6 +60,7 @@
5960
"compute_obr_council_tax",
6061
"compute_person_support",
6162
"compute_pip_claimants",
63+
"compute_public_sector_employment",
6264
"compute_regional_age",
6365
"compute_savings_interest",
6466
"compute_scotland_demographics",

policyengine_uk_data/targets/compute/other.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ def compute_vehicles(target, ctx) -> np.ndarray:
2828
return (ctx.pe("num_vehicles") >= 2).astype(float)
2929

3030

31+
def compute_public_sector_employment(target, ctx) -> np.ndarray:
32+
"""Count people whose main job is in the public sector, per household."""
33+
sector = ctx.pe_person("employment_sector")
34+
is_public = (sector == "PUBLIC").astype(float)
35+
return ctx.household_from_person(is_public)
36+
37+
3138
def compute_housing(target, ctx) -> np.ndarray:
3239
"""Compute housing targets (mortgage, private rent, social rent)."""
3340
name = target.name
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""ONS Public Sector Employment (PSE) target.
2+
3+
The FRS self-reported employer sector (`mjobsect` -> `employment_sector`)
4+
over-counts public-sector employment relative to the official ONS PSE
5+
headcount, so this adds a national calibration target for the number of
6+
people whose main job is in the public sector
7+
(`employment_sector == PUBLIC`).
8+
9+
PSE measures the institutional public sector (central government, local
10+
government and public corporations) - i.e. NHS, state schools, councils,
11+
civil service and the armed forces - so it is the right official total for
12+
the whole-public-sector `employment_sector` flag, not the much narrower
13+
SIC division 84 ("public administration and defence").
14+
15+
Source: ONS Public Sector Employment, UK (headcount, not seasonally
16+
adjusted). Headline UK totals: ~5.90m (2023), ~5.94m (2024).
17+
"""
18+
19+
from policyengine_uk_data.targets.schema import (
20+
GeographicLevel,
21+
Target,
22+
Unit,
23+
)
24+
25+
_REF = (
26+
"https://www.ons.gov.uk/employmentandlabourmarket/peopleinwork/"
27+
"publicsectorpersonnel/bulletins/publicsectoremployment/latest"
28+
)
29+
30+
# ONS PSE UK total headcount (people), by calendar year.
31+
_VALUES = {
32+
2023: 5_900_000.0,
33+
2024: 5_940_000.0,
34+
}
35+
36+
37+
def get_targets() -> list[Target]:
38+
return [
39+
Target(
40+
name="ons/public_sector_employment",
41+
variable="employment_sector",
42+
source="ons",
43+
unit=Unit.COUNT,
44+
geographic_level=GeographicLevel.NATIONAL,
45+
geo_code="K02000001",
46+
geo_name="United Kingdom",
47+
values=dict(_VALUES),
48+
is_count=True,
49+
reference_url=_REF,
50+
)
51+
]

policyengine_uk_data/tests/test_legacy_benefit_proxies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ def fake_read_csv(path, *args, **kwargs):
427427
"eduma": 0,
428428
"edumaamt": 0,
429429
"empstati": 8,
430+
"mjobsect": 0,
431+
"sic": 0,
430432
"fsbval": 0,
431433
"fsfvval": 0,
432434
"fsmval": 0,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for the ONS Public Sector Employment calibration target.
2+
3+
The target constrains the simulated count of public-sector workers
4+
(`employment_sector == PUBLIC`) towards the official ONS Public Sector
5+
Employment (PSE) headcount. A 20% relative tolerance is accepted: the
6+
FRS self-reported sector over-counts public employment, so calibration
7+
only needs to bring the figure within a fifth of the official total.
8+
"""
9+
10+
import pytest
11+
12+
from policyengine_uk_data.datasets.frs_release import CURRENT_FRS_RELEASE
13+
from policyengine_uk_data.targets import get_all_targets
14+
from policyengine_uk_data.targets.build_loss_matrix import _resolve_value
15+
from policyengine_uk_data.targets.sources.ons_public_sector_employment import (
16+
get_targets,
17+
)
18+
19+
# Accepted error between the target *value* and the official ONS PSE figure
20+
# (a sanity check on the hardcoded target, not a calibration outcome).
21+
ACCEPTED_RELATIVE_ERROR = 0.20
22+
23+
# Tolerance for the simulated weighted total after data generation. The FRS
24+
# self-reported sector over-counts public employment (~7.9m vs ONS ~5.9m) and
25+
# the national calibration only partially pulls it in, so a loose tolerance is
26+
# used, in line with the other aggregate-vs-target tests (land value ~0.65-0.70,
27+
# spending aggregates ~0.70, vehicle ownership ~0.30).
28+
SIMULATED_RELATIVE_TOLERANCE = 0.50
29+
30+
# Official ONS Public Sector Employment, UK (headcount), by year. Held
31+
# independently of the source module so a wrong target value is caught.
32+
ONS_PSE_HEADCOUNT = {
33+
2023: 5_900_000.0,
34+
2024: 5_940_000.0,
35+
}
36+
37+
# Years the enhanced FRS fixture can represent (mirrors land value tests).
38+
MODEL_CHECK_YEARS = sorted(
39+
{
40+
CURRENT_FRS_RELEASE.base_year,
41+
CURRENT_FRS_RELEASE.calibration_year,
42+
}
43+
)
44+
45+
46+
# ── Target structure ─────────────────────────────────────────────────
47+
48+
49+
def test_get_targets_returns_one():
50+
"""get_targets() should return the single public sector target."""
51+
assert len(get_targets()) == 1
52+
53+
54+
def test_target_variable_and_metadata():
55+
"""Target should count employment_sector from ONS."""
56+
target = get_targets()[0]
57+
assert target.name == "ons/public_sector_employment"
58+
assert target.variable == "employment_sector"
59+
assert target.source == "ons"
60+
assert target.is_count
61+
62+
63+
def test_targets_in_registry():
64+
"""The target should appear in the global registry."""
65+
names = {t.name for t in get_all_targets()}
66+
assert "ons/public_sector_employment" in names
67+
68+
69+
# ── Target values ────────────────────────────────────────────────────
70+
71+
72+
def test_target_values_within_20pct_of_ons():
73+
"""Each target value is within the accepted 20% of the ONS PSE figure."""
74+
values = get_targets()[0].values
75+
for year, official in ONS_PSE_HEADCOUNT.items():
76+
assert year in values, f"missing target for {year}"
77+
rel_error = abs(values[year] / official - 1)
78+
assert rel_error <= ACCEPTED_RELATIVE_ERROR, (
79+
f"{year} target {values[year]:,.0f} differs from ONS PSE "
80+
f"{official:,.0f} by {rel_error:.1%} (>20%)."
81+
)
82+
83+
84+
# ── Simulated total after data generation ────────────────────────────
85+
86+
87+
@pytest.mark.parametrize("year", MODEL_CHECK_YEARS, ids=map(str, MODEL_CHECK_YEARS))
88+
def test_public_sector_employment_total(enhanced_frs, baseline, year):
89+
"""Weighted public-sector total is within tolerance of the ONS PSE target.
90+
91+
Runs against the generated enhanced FRS, whose national calibration
92+
includes the public sector employment target. Skipped if the dataset
93+
predates the variable (rebuild with ``make data``).
94+
"""
95+
if "employment_sector" not in enhanced_frs.person.columns:
96+
pytest.skip("dataset predates employment_sector; rebuild with `make data`")
97+
98+
target = _resolve_value(get_targets()[0], year)
99+
assert target is not None, f"no target value resolvable for {year}"
100+
101+
weights = baseline.calculate("household_weight", period=year).values
102+
sector = baseline.calculate("employment_sector", period=year).values
103+
is_public = (sector == "PUBLIC").astype(float)
104+
estimate = (baseline.map_result(is_public, "person", "household") * weights).sum()
105+
106+
rel_error = abs(estimate / target - 1)
107+
assert rel_error < SIMULATED_RELATIVE_TOLERANCE, (
108+
f"public sector employment ({year}): expected {target:,.0f}, "
109+
f"got {estimate:,.0f} (relative error = {rel_error:.1%}, "
110+
f"tolerance = {SIMULATED_RELATIVE_TOLERANCE:.0%})"
111+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dependencies = [
2121
"policyengine",
2222
"google-cloud-storage",
2323
"google-auth",
24-
"policyengine-uk>=2.89.1",
24+
"policyengine-uk>=2.89.2",
2525
"microcalibrate>=0.18.0",
2626
"microimpute>=1.0.1",
2727
"ruff>=0.9.0",

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)