Skip to content

Commit 7e93fd8

Browse files
feat: add SLC student loan calibration targets
Adds Plan 2 and Plan 5 England borrower counts (earning above threshold, 2025-2030) from SLC Table 6a as calibration targets, wired into the target registry and loss matrix.
1 parent 52d9799 commit 7e93fd8

6 files changed

Lines changed: 143 additions & 3 deletions

File tree

changelog_entry.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
- bump: patch
1+
- bump: minor
22
changes:
33
added:
4-
- Test for highest_education in enhanced FRS dataset.
4+
- SLC student loan calibration targets for Plan 2 and Plan 5 England borrowers earning above repayment threshold (2025-2030), wired into the target registry and loss matrix.

policyengine_uk_data/targets/build_loss_matrix.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
compute_scotland_demographics,
3838
compute_scotland_uc_child,
3939
compute_scottish_child_payment,
40+
compute_student_loan_plan,
4041
compute_ss_contributions,
4142
compute_ss_headcount,
4243
compute_ss_it_relief,
@@ -302,6 +303,10 @@ def _compute_column(
302303
if name == "sss/scottish_child_payment":
303304
return compute_scottish_child_payment(target, ctx)
304305

306+
# Student loan plan borrower counts (SLC)
307+
if name.startswith("slc/plan_"):
308+
return compute_student_loan_plan(target, ctx)
309+
305310
# PIP claimants
306311
if name in (
307312
"dwp/pip_dl_standard_claimants",

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_savings_interest,
3939
compute_scottish_child_payment,
40+
compute_student_loan_plan,
4041
compute_vehicles,
4142
)
4243

@@ -55,6 +56,7 @@
5556
"compute_scotland_demographics",
5657
"compute_scotland_uc_child",
5758
"compute_scottish_child_payment",
59+
"compute_student_loan_plan",
5860
"compute_ss_contributions",
5961
"compute_ss_headcount",
6062
"compute_ss_it_relief",

policyengine_uk_data/targets/compute/other.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
"""Miscellaneous compute functions (vehicles, housing, savings, SCP)."""
1+
"""Miscellaneous compute functions (vehicles, housing, savings, SCP,
2+
student loans)."""
23

34
import numpy as np
45

6+
_ENGLAND_REGIONS = {
7+
"NORTH_EAST",
8+
"NORTH_WEST",
9+
"YORKSHIRE",
10+
"EAST_MIDLANDS",
11+
"WEST_MIDLANDS",
12+
"EAST_OF_ENGLAND",
13+
"LONDON",
14+
"SOUTH_EAST",
15+
"SOUTH_WEST",
16+
}
17+
518

619
def compute_vehicles(target, ctx) -> np.ndarray:
720
"""Compute vehicle ownership targets."""
@@ -34,3 +47,25 @@ def compute_scottish_child_payment(target, ctx) -> np.ndarray:
3447
"""Compute Scottish child payment spend."""
3548
scp = ctx.sim.calculate("scottish_child_payment")
3649
return ctx.household_from_person(scp)
50+
51+
52+
def compute_student_loan_plan(target, ctx) -> np.ndarray:
53+
"""Count England borrowers on a given plan with repayments > 0.
54+
55+
SLC targets cover borrowers liable to repay AND earning above threshold
56+
in England only — matching exactly what the FRS captures via PAYE.
57+
"""
58+
plan_name = target.name # e.g. "slc/plan_2_borrowers_above_threshold"
59+
if "plan_2" in plan_name:
60+
plan_value = "PLAN_2"
61+
elif "plan_5" in plan_name:
62+
plan_value = "PLAN_5"
63+
else:
64+
return None
65+
66+
plan = ctx.sim.calculate("student_loan_plan").values
67+
region = ctx.sim.calculate("region", map_to="person").values
68+
is_england = np.isin(region, list(_ENGLAND_REGIONS))
69+
on_plan = (plan == plan_value) & is_england
70+
71+
return ctx.household_from_person(on_plan.astype(float))
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Student Loans Company (SLC) calibration targets.
2+
3+
Borrower counts for England only: Plan 2 and Plan 5, restricted to
4+
borrowers liable to repay and earning above the repayment threshold.
5+
This matches the FRS coverage (PAYE deductions only).
6+
7+
Source: SLC 'Student loans: borrower liability and repayment' statistical
8+
release, Table 6a — Forecast number of student borrowers liable to repay
9+
and number earning above repayment threshold.
10+
https://www.gov.uk/government/collections/student-loans-in-england-statistics
11+
"""
12+
13+
from policyengine_uk_data.targets.schema import Target, Unit
14+
15+
16+
def get_targets() -> list[Target]:
17+
targets = []
18+
19+
_REFERENCE = (
20+
"https://www.gov.uk/government/collections/"
21+
"student-loans-in-england-statistics"
22+
)
23+
24+
# Plan 2 — England, earning above threshold
25+
# Academic year 20XX-YY maps to calendar year 20XX.
26+
targets.append(
27+
Target(
28+
name="slc/plan_2_borrowers_above_threshold",
29+
variable="student_loan_plan",
30+
source="slc",
31+
unit=Unit.COUNT,
32+
is_count=True,
33+
values={
34+
2025: 3_985_000,
35+
2026: 4_460_000,
36+
2027: 4_825_000,
37+
2028: 5_045_000,
38+
2029: 5_160_000,
39+
2030: 5_205_000,
40+
},
41+
reference_url=_REFERENCE,
42+
)
43+
)
44+
45+
# Plan 5 — England, earning above threshold
46+
targets.append(
47+
Target(
48+
name="slc/plan_5_borrowers_above_threshold",
49+
variable="student_loan_plan",
50+
source="slc",
51+
unit=Unit.COUNT,
52+
is_count=True,
53+
values={
54+
2026: 35_000,
55+
2027: 145_000,
56+
2028: 390_000,
57+
2029: 770_000,
58+
2030: 1_235_000,
59+
},
60+
reference_url=_REFERENCE,
61+
)
62+
)
63+
64+
return targets
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Tests for SLC student loan calibration targets."""
2+
3+
import pytest
4+
5+
6+
def test_slc_targets_registered():
7+
"""SLC targets appear in the target registry."""
8+
from policyengine_uk_data.targets.registry import get_all_targets
9+
10+
targets = {t.name: t for t in get_all_targets()}
11+
assert "slc/plan_2_borrowers_above_threshold" in targets
12+
assert "slc/plan_5_borrowers_above_threshold" in targets
13+
14+
15+
def test_slc_plan2_values():
16+
"""Plan 2 target values match SLC Table 6a."""
17+
from policyengine_uk_data.targets.registry import get_all_targets
18+
19+
targets = {t.name: t for t in get_all_targets()}
20+
p2 = targets["slc/plan_2_borrowers_above_threshold"]
21+
assert p2.values[2025] == 3_985_000
22+
assert p2.values[2026] == 4_460_000
23+
assert p2.values[2029] == 5_160_000
24+
25+
26+
def test_slc_plan5_values():
27+
"""Plan 5 target values match SLC Table 6a."""
28+
from policyengine_uk_data.targets.registry import get_all_targets
29+
30+
targets = {t.name: t for t in get_all_targets()}
31+
p5 = targets["slc/plan_5_borrowers_above_threshold"]
32+
assert 2025 not in p5.values # no Plan 5 borrowers yet in 2024-25
33+
assert p5.values[2026] == 35_000
34+
assert p5.values[2029] == 770_000

0 commit comments

Comments
 (0)