|
6 | 6 | import numpy as np |
7 | 7 | import logging |
8 | 8 |
|
| 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 | + |
9 | 34 |
|
10 | 35 | def extend_single_year_dataset( |
11 | 36 | dataset: UKSingleYearDataset, |
@@ -90,6 +115,10 @@ def apply_single_year_uprating( |
90 | 115 |
|
91 | 116 | current_year = uprate_rent(current_year, previous_year, parameters) |
92 | 117 |
|
| 118 | + current_year = uprate_student_loan_plans( |
| 119 | + current_year, previous_year, parameters |
| 120 | + ) |
| 121 | + |
93 | 122 | current_year.validate() |
94 | 123 |
|
95 | 124 | return current_year |
@@ -196,6 +225,139 @@ def uprate_rent( |
196 | 225 | return current_year |
197 | 226 |
|
198 | 227 |
|
| 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 | + |
199 | 361 | def reset_uprating( |
200 | 362 | dataset: UKMultiYearDataset, |
201 | 363 | ): |
|
0 commit comments