Skip to content

Commit 90d5145

Browse files
authored
Add student loan repayment calibration targets (#337)
* Preserve zero-valued SLC targets * Add student loan repayment calibration targets * Format student loan target tests
1 parent 50f289b commit 90d5145

7 files changed

Lines changed: 252 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Preserve zero-valued SLC borrower targets so calibration can enforce explicit zero years.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add 2025 Student Loans Company repayment amount calibration targets by UK country and by England repayment plan.

policyengine_uk_data/targets/build_loss_matrix.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
compute_scotland_demographics,
4040
compute_scotland_uc_child,
4141
compute_scottish_child_payment,
42+
compute_student_loan_repayment,
4243
compute_student_loan_plan,
4344
compute_student_loan_plan_liable,
4445
compute_ss_contributions,
@@ -317,6 +318,8 @@ def _compute_column(target: Target, ctx: _SimContext, year: int) -> np.ndarray |
317318
return compute_scottish_child_payment(target, ctx)
318319

319320
# Student loan plan borrower counts (SLC)
321+
if name.startswith("slc/student_loan_repayment/"):
322+
return compute_student_loan_repayment(target, ctx)
320323
if name.startswith("slc/plan_") and "above_threshold" in name:
321324
return compute_student_loan_plan(target, ctx)
322325
if name.startswith("slc/plan_") and "liable" in name:

policyengine_uk_data/targets/compute/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
compute_regional_land_value,
4040
compute_savings_interest,
4141
compute_scottish_child_payment,
42+
compute_student_loan_repayment,
4243
compute_student_loan_plan,
4344
compute_student_loan_plan_liable,
4445
compute_vehicles,
@@ -61,6 +62,7 @@
6162
"compute_scotland_demographics",
6263
"compute_scotland_uc_child",
6364
"compute_scottish_child_payment",
65+
"compute_student_loan_repayment",
6466
"compute_student_loan_plan",
6567
"compute_student_loan_plan_liable",
6668
"compute_ss_contributions",

policyengine_uk_data/targets/compute/other.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
import numpy as np
44

5+
_STUDENT_LOAN_COUNTRIES = {
6+
"england": "ENGLAND",
7+
"scotland": "SCOTLAND",
8+
"wales": "WALES",
9+
"northern_ireland": "NORTHERN_IRELAND",
10+
}
11+
12+
_STUDENT_LOAN_PLAN_MAP = {
13+
"plan_1": "PLAN_1",
14+
"plan_2": "PLAN_2",
15+
"postgraduate": "POSTGRADUATE",
16+
"plan_5": "PLAN_5",
17+
}
18+
519

620
def compute_vehicles(target, ctx) -> np.ndarray:
721
"""Compute vehicle ownership targets."""
@@ -88,3 +102,27 @@ def compute_student_loan_plan_liable(target, ctx) -> np.ndarray:
88102
on_plan = (plan == plan_value) & (person_country == "ENGLAND")
89103

90104
return ctx.household_from_person(on_plan.astype(float))
105+
106+
107+
def compute_student_loan_repayment(target, ctx) -> np.ndarray:
108+
"""Compute SLC repayment amount targets by country and optional plan."""
109+
parts = target.name.split("/")
110+
if len(parts) < 3 or parts[:2] != ["slc", "student_loan_repayment"]:
111+
return None
112+
113+
country = _STUDENT_LOAN_COUNTRIES.get(parts[2])
114+
if country is None:
115+
return None
116+
117+
person_country = ctx.sim.calculate("country", map_to="person").values
118+
repayments = ctx.pe_person("student_loan_repayment")
119+
mask = person_country == country
120+
121+
if len(parts) == 4:
122+
plan_value = _STUDENT_LOAN_PLAN_MAP.get(parts[3])
123+
if plan_value is None:
124+
return None
125+
plan = ctx.pe_person("student_loan_plan")
126+
mask &= plan == plan_value
127+
128+
return ctx.household_from_person(repayments * mask)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Student Loans Company repayment amount calibration targets.
2+
3+
These are 2024-25 higher-education repayment amounts from the official
4+
Student Loans Company publications, mapped to calendar year 2025 for the
5+
2025 calibration build. We deliberately do not project these forward here;
6+
the target resolver will carry the latest observed value forward for nearby
7+
years when needed.
8+
"""
9+
10+
from policyengine_uk_data.targets.schema import GeographicLevel, Target, Unit
11+
12+
_ENGLAND_TABLES_URL = (
13+
"https://assets.publishing.service.gov.uk/media/"
14+
"6943ee619273c48f554cf5c5/slcsp012025_Corrected.xlsx"
15+
)
16+
_SCOTLAND_URL = (
17+
"https://www.gov.uk/government/statistics/"
18+
"student-loans-in-scotland-2024-to-2025/"
19+
"student-loans-for-higher-education-in-scotland-financial-year-2024-25"
20+
)
21+
_WALES_URL = (
22+
"https://www.gov.uk/government/statistics/"
23+
"student-loans-in-wales-2024-to-2025/"
24+
"student-loans-for-higher-education-in-wales-financial-year-2024-25"
25+
)
26+
_NORTHERN_IRELAND_URL = (
27+
"https://www.gov.uk/government/statistics/"
28+
"student-loans-in-northern-ireland-2024-to-2025/"
29+
"student-loans-for-higher-education-in-northern-ireland-financial-year-2024-25"
30+
)
31+
32+
# England values come from Table 1A of the official corrected workbook for
33+
# financial year 2024-25, using the exact £m entries converted to pounds.
34+
_TARGETS_2025 = {
35+
"slc/student_loan_repayment/england": (
36+
5_018_231_834.95,
37+
_ENGLAND_TABLES_URL,
38+
GeographicLevel.COUNTRY,
39+
"ENGLAND",
40+
),
41+
"slc/student_loan_repayment/england/plan_1": (
42+
1_852_699_178.55,
43+
_ENGLAND_TABLES_URL,
44+
GeographicLevel.COUNTRY,
45+
"ENGLAND",
46+
),
47+
"slc/student_loan_repayment/england/plan_2": (
48+
2_778_253_361.64,
49+
_ENGLAND_TABLES_URL,
50+
GeographicLevel.COUNTRY,
51+
"ENGLAND",
52+
),
53+
"slc/student_loan_repayment/england/postgraduate": (
54+
346_409_713.95,
55+
_ENGLAND_TABLES_URL,
56+
GeographicLevel.COUNTRY,
57+
"ENGLAND",
58+
),
59+
"slc/student_loan_repayment/england/plan_5": (
60+
40_869_580.81,
61+
_ENGLAND_TABLES_URL,
62+
GeographicLevel.COUNTRY,
63+
"ENGLAND",
64+
),
65+
"slc/student_loan_repayment/scotland": (
66+
203_300_000,
67+
_SCOTLAND_URL,
68+
GeographicLevel.COUNTRY,
69+
"SCOTLAND",
70+
),
71+
"slc/student_loan_repayment/wales": (
72+
229_100_000,
73+
_WALES_URL,
74+
GeographicLevel.COUNTRY,
75+
"WALES",
76+
),
77+
"slc/student_loan_repayment/northern_ireland": (
78+
181_700_000,
79+
_NORTHERN_IRELAND_URL,
80+
GeographicLevel.COUNTRY,
81+
"NORTHERN_IRELAND",
82+
),
83+
}
84+
85+
86+
def get_targets() -> list[Target]:
87+
"""Return SLC repayment amount targets."""
88+
return [
89+
Target(
90+
name=name,
91+
variable="student_loan_repayment",
92+
source="slc",
93+
unit=Unit.GBP,
94+
geographic_level=level,
95+
geo_code=geo_code,
96+
values={2025: value},
97+
reference_url=reference_url,
98+
)
99+
for name, (value, reference_url, level, geo_code) in _TARGETS_2025.items()
100+
]

policyengine_uk_data/tests/test_student_loan_targets.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ def test_slc_targets_registered():
1515
assert "slc/plan_5_borrowers_above_threshold" in targets
1616
assert "slc/plan_2_borrowers_liable" in targets
1717
assert "slc/plan_5_borrowers_liable" in targets
18+
assert "slc/student_loan_repayment/england" in targets
19+
assert "slc/student_loan_repayment/scotland" in targets
20+
assert "slc/student_loan_repayment/england/plan_2" in targets
1821

1922

2023
def test_slc_snapshot_values_match_higher_education_total_rows():
@@ -46,6 +49,54 @@ def test_liable_targets_exceed_above_threshold_targets():
4649
assert targets["slc/plan_5_borrowers_liable"].values[year] > count
4750

4851

52+
def test_slc_repayment_targets_match_official_2025_values():
53+
"""Repayment amount targets should match the official 2024-25 releases."""
54+
from policyengine_uk_data.targets.registry import get_all_targets
55+
56+
targets = {t.name: t for t in get_all_targets()}
57+
58+
assert (
59+
targets["slc/student_loan_repayment/england"].values[2025] == 5_018_231_834.95
60+
)
61+
assert (
62+
targets["slc/student_loan_repayment/england/plan_1"].values[2025]
63+
== 1_852_699_178.55
64+
)
65+
assert (
66+
targets["slc/student_loan_repayment/england/plan_2"].values[2025]
67+
== 2_778_253_361.64
68+
)
69+
assert (
70+
targets["slc/student_loan_repayment/england/postgraduate"].values[2025]
71+
== 346_409_713.95
72+
)
73+
assert (
74+
targets["slc/student_loan_repayment/england/plan_5"].values[2025]
75+
== 40_869_580.81
76+
)
77+
assert targets["slc/student_loan_repayment/scotland"].values[2025] == 203_300_000
78+
assert targets["slc/student_loan_repayment/wales"].values[2025] == 229_100_000
79+
assert (
80+
targets["slc/student_loan_repayment/northern_ireland"].values[2025]
81+
== 181_700_000
82+
)
83+
84+
85+
def test_slc_england_plan_repayments_sum_to_england_total():
86+
"""England plan-level repayment targets should reconcile to the total."""
87+
from policyengine_uk_data.targets.registry import get_all_targets
88+
89+
targets = {t.name: t for t in get_all_targets()}
90+
england_total = targets["slc/student_loan_repayment/england"].values[2025]
91+
england_plans = (
92+
targets["slc/student_loan_repayment/england/plan_1"].values[2025]
93+
+ targets["slc/student_loan_repayment/england/plan_2"].values[2025]
94+
+ targets["slc/student_loan_repayment/england/postgraduate"].values[2025]
95+
+ targets["slc/student_loan_repayment/england/plan_5"].values[2025]
96+
)
97+
assert england_plans == england_total
98+
99+
49100
def test_slc_testing_mode_uses_snapshot_without_network(monkeypatch):
50101
"""Dataset-build CI should not depend on a live SLC endpoint."""
51102
from policyengine_uk_data.targets.sources import slc
@@ -209,3 +260,59 @@ def household_from_person(values):
209260

210261
assert above_threshold.tolist() == [1.0, 0.0, 0.0, 0.0]
211262
assert liable.tolist() == [1.0, 1.0, 0.0, 0.0]
263+
264+
265+
def test_student_loan_repayment_target_compute_filters_country_and_plan():
266+
"""Repayment amount targets should filter on modeled plan and country."""
267+
from policyengine_uk_data.targets.compute.other import (
268+
compute_student_loan_repayment,
269+
)
270+
271+
class DummyCtx:
272+
class sim:
273+
@staticmethod
274+
def calculate(variable, map_to=None):
275+
if variable == "country" and map_to == "person":
276+
return SimpleNamespace(
277+
values=np.array(
278+
[
279+
"ENGLAND",
280+
"ENGLAND",
281+
"SCOTLAND",
282+
"ENGLAND",
283+
"WALES",
284+
]
285+
)
286+
)
287+
raise AssertionError(f"Unexpected calculate call: {variable}, {map_to}")
288+
289+
@staticmethod
290+
def pe_person(variable):
291+
values = {
292+
"student_loan_plan": np.array(
293+
["PLAN_1", "PLAN_2", "PLAN_4", "POSTGRADUATE", "PLAN_1"]
294+
),
295+
"student_loan_repayment": np.array([100.0, 200.0, 300.0, 400.0, 500.0]),
296+
}
297+
return values[variable]
298+
299+
@staticmethod
300+
def household_from_person(values):
301+
return values
302+
303+
england_total = compute_student_loan_repayment(
304+
SimpleNamespace(name="slc/student_loan_repayment/england"),
305+
DummyCtx(),
306+
)
307+
england_plan_2 = compute_student_loan_repayment(
308+
SimpleNamespace(name="slc/student_loan_repayment/england/plan_2"),
309+
DummyCtx(),
310+
)
311+
scotland_total = compute_student_loan_repayment(
312+
SimpleNamespace(name="slc/student_loan_repayment/scotland"),
313+
DummyCtx(),
314+
)
315+
316+
assert england_total.tolist() == [100.0, 200.0, 0.0, 400.0, 0.0]
317+
assert england_plan_2.tolist() == [0.0, 200.0, 0.0, 0.0, 0.0]
318+
assert scotland_total.tolist() == [0.0, 0.0, 300.0, 0.0, 0.0]

0 commit comments

Comments
 (0)