Skip to content

Commit e6c025e

Browse files
feat: add student loan plan uprating to economic assumptions (#1499)
* feat: add student loan plan uprating to economic assumptions Re-labels student loan plans each year based on cohort start year, writes off Plan 1 loans after 29 years, and samples new Plan 5 entrants for England using empirical age/income take-up probabilities. * style: apply Black formatting * style: add fmt:off around take-up table to fix CI lint * style: apply Black 26.1.0 formatting * Remove the student loan testing script * fix: make student loan plan uprating idempotent and extend cohorts - Replace cumulative stochastic sampling with idempotent approach - Each year's assignment depends only on base year data + year parameter - Add Plan 1/2 holders in extended age bands (30-35 for Plan 2, 41-46 for Plan 1) - Use highest_education == TERTIARY as signal for graduate status - Apply flat 40% take-up rate derived from SLC forecasts - Remove income-decile take-up table (unsourced)
1 parent dd61183 commit e6c025e

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

changelog_entry.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- bump: minor
2+
changes:
3+
added:
4+
- Student loan plan uprating in economic assumptions: re-labels Plan 1/2/5 each year based on cohort start year, writes off loans after 29 years, and samples new Plan 5 entrants for England using empirical age/income take-up probabilities.

policyengine_uk/data/economic_assumptions.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@
66
import numpy as np
77
import logging
88

9+
# Base year for the FRS dataset - used to calculate age offsets
10+
_FRS_BASE_YEAR = 2023 # FRS 2023-24 represents calendar year 2024
11+
12+
# Approximate take-up rate for assigning loans to tertiary-educated NONE people.
13+
# This represents P(has loan AND earning above threshold | tertiary educated).
14+
# Derived from SLC forecasts (~4M Plan 2 above threshold) vs UK graduate
15+
# population (~8-10M in relevant age bands), giving roughly 40-50%.
16+
# We use a conservative 0.4 as many graduates have paid off loans or earn
17+
# below threshold.
18+
_GRADUATE_LOAN_TAKE_UP = 0.4
19+
20+
_ENGLAND_REGIONS = {
21+
"NORTH_EAST",
22+
"NORTH_WEST",
23+
"YORKSHIRE",
24+
"EAST_MIDLANDS",
25+
"WEST_MIDLANDS",
26+
"EAST_OF_ENGLAND",
27+
"LONDON",
28+
"SOUTH_EAST",
29+
"SOUTH_WEST",
30+
}
31+
32+
_PLAN1_WRITEOFF_YEARS = 29
33+
934

1035
def extend_single_year_dataset(
1136
dataset: UKSingleYearDataset,
@@ -90,6 +115,10 @@ def apply_single_year_uprating(
90115

91116
current_year = uprate_rent(current_year, previous_year, parameters)
92117

118+
current_year = uprate_student_loan_plans(
119+
current_year, previous_year, parameters
120+
)
121+
93122
current_year.validate()
94123

95124
return current_year
@@ -196,6 +225,139 @@ def uprate_rent(
196225
return current_year
197226

198227

228+
def uprate_student_loan_plans(
229+
current_year: UKSingleYearDataset,
230+
previous_year: UKSingleYearDataset,
231+
parameters: ParameterNode,
232+
) -> UKSingleYearDataset:
233+
"""Assign student loan plans based on cohort and add new entrants.
234+
235+
This function is idempotent: for any given year, it produces the same
236+
cross-sectional snapshot regardless of whether previous years were
237+
processed. It operates on the base year data, not accumulated state.
238+
239+
The FRS base year (2023-24) captures loan holders up to certain ages.
240+
As we project forward, we need to:
241+
1. Re-label existing holders to correct plan based on uni start year
242+
2. Add Plan 1/2 holders in age bands beyond the base year's coverage
243+
3. Add Plan 5 holders (new plan starting 2023)
244+
245+
For (2) and (3), we use highest_education == TERTIARY as the signal
246+
for who is a graduate, then apply a flat take-up probability.
247+
"""
248+
year = int(current_year.time_period)
249+
250+
person = current_year.person.copy()
251+
household = current_year.household[["household_id", "region"]].copy()
252+
253+
# Join region onto person via person_household_id.
254+
person = person.merge(
255+
household.rename(columns={"household_id": "person_household_id"}),
256+
on="person_household_id",
257+
how="left",
258+
)
259+
260+
age = person["age"].values.astype(int)
261+
base_plan = person["student_loan_plan"].values.copy().astype(str)
262+
region = person["region"].values.astype(str)
263+
highest_ed = person["highest_education"].values.astype(str)
264+
265+
# Age in the base year (used to identify "new" cohorts)
266+
base_year_age = age - (year - _FRS_BASE_YEAR)
267+
268+
uni_start_year = year - age + 18
269+
is_england = np.isin(region, list(_ENGLAND_REGIONS))
270+
is_tertiary = highest_ed == "TERTIARY"
271+
272+
# Initialize output arrays
273+
new_plan = base_plan.copy()
274+
repayments = person["student_loan_repayments"].values.copy()
275+
276+
# Deterministic RNG seeded by year for reproducibility
277+
rng = np.random.default_rng(seed=year)
278+
279+
# Helper to assign plans to eligible people
280+
def assign_with_probability(mask, plan_value, prob=_GRADUATE_LOAN_TAKE_UP):
281+
"""Assign plan_value to a random subset of masked people."""
282+
if not mask.any():
283+
return
284+
indices = np.where(mask)[0]
285+
draws = rng.random(len(indices))
286+
sampled = draws < prob
287+
new_plan[indices[sampled]] = plan_value
288+
repayments[indices[sampled]] = 0.0
289+
290+
# === Step 1: Re-label existing loan holders ===
291+
has_loan = base_plan != "NONE"
292+
written_off = has_loan & (uni_start_year + _PLAN1_WRITEOFF_YEARS <= year)
293+
is_plan1_cohort = has_loan & ~written_off & (uni_start_year < 2012)
294+
is_plan2_cohort = (
295+
has_loan
296+
& ~written_off
297+
& (uni_start_year >= 2012)
298+
& (uni_start_year < 2023)
299+
)
300+
is_plan5_cohort = has_loan & ~written_off & (uni_start_year >= 2023)
301+
302+
new_plan[written_off] = "NONE"
303+
repayments[written_off] = 0.0
304+
new_plan[is_plan1_cohort] = "PLAN_1"
305+
new_plan[is_plan2_cohort] = "PLAN_2"
306+
new_plan[is_plan5_cohort & is_england] = "PLAN_5"
307+
new_plan[is_plan5_cohort & ~is_england] = "PLAN_2"
308+
309+
# === Step 2: Add Plan 1 holders in extended age bands ===
310+
# In base year, Plan 1 holders exist up to ~age 40 (started pre-2012).
311+
# By 2029, Plan 1 should include people up to age 46.
312+
# Target: NONE people who are tertiary-educated, in the "new" age band,
313+
# whose uni_start_year < 2012 and loan not written off.
314+
max_plan1_age_base = 40 # Approximate max age of Plan 1 in base year
315+
plan1_new_cohort = (
316+
(new_plan == "NONE")
317+
& is_tertiary
318+
& (base_year_age > max_plan1_age_base)
319+
& (uni_start_year < 2012)
320+
& (uni_start_year + _PLAN1_WRITEOFF_YEARS > year)
321+
)
322+
assign_with_probability(plan1_new_cohort, "PLAN_1")
323+
324+
# === Step 3: Add Plan 2 holders in extended age bands ===
325+
# In base year (2024), Plan 2 holders exist up to age 29 (started 2012).
326+
# By 2029, Plan 2 should include people up to age 35.
327+
# Target: NONE people who are tertiary-educated, in the "new" age band,
328+
# whose uni_start_year is 2012-2022.
329+
max_plan2_age_base = 29 # Max age of Plan 2 in base year
330+
plan2_new_cohort = (
331+
(new_plan == "NONE")
332+
& is_tertiary
333+
& (base_year_age > max_plan2_age_base)
334+
& (uni_start_year >= 2012)
335+
& (uni_start_year < 2023)
336+
)
337+
assign_with_probability(plan2_new_cohort, "PLAN_2")
338+
339+
# === Step 4: Add Plan 5 holders (new plan from 2023) ===
340+
# Plan 5 didn't exist in base year. Eligible: tertiary-educated NONE
341+
# people in England who would have started uni 2023+.
342+
# Age constraint: must be 21+ (finished 3-year degree) to be repaying.
343+
plan5_eligible = (
344+
(new_plan == "NONE")
345+
& is_tertiary
346+
& is_england
347+
& (uni_start_year >= 2023)
348+
& (age >= 21)
349+
)
350+
assign_with_probability(plan5_eligible, "PLAN_5")
351+
352+
# Write back to the person table (without the merged region column).
353+
person_out = current_year.person.copy()
354+
person_out["student_loan_plan"] = new_plan
355+
person_out["student_loan_repayments"] = repayments
356+
current_year.person = person_out
357+
358+
return current_year
359+
360+
199361
def reset_uprating(
200362
dataset: UKMultiYearDataset,
201363
):

0 commit comments

Comments
 (0)