Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2636.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserved recorded student loan plans and stopped clearing existing-holder repayments from inferred course dates during dataset projection; documented the remaining projected Plan 1 to Plan 2 and Plan 2 to Plan 5 cohort gaps and exact cancellation-date limitations.
58 changes: 58 additions & 0 deletions docs/engineering/skills/dataset-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,61 @@ these defaults, including a future calibrated dataset column (uk-data#448).
Do not rebuild an existing data-built simulation by calling core's
`SimulationBuilder.build_from_dict` directly. In core 3.33.2 this leaves stale
dataset provenance, which copies inherit. Use UK `build_from_situation` instead.

## Student loan plan projection

Student loan projection preserves each existing holder's recorded non-`NONE`
plan and the repayment amount passed to `uprate_student_loan_plans`. The
enclosing dataset uprating separately grows stored `student_loan_repayments`
with average earnings; preserving an amount in this helper does not hold its
nominal value flat throughout a projection or change reported-versus-modelled
repayment precedence.

The available inputs do not identify the course start, first loan payment,
first April due to repay, or funding agency. Age and current household region
cannot establish those dates or an existing holder's cancellation deadline.
The helper therefore does not relabel or cancel an observed loan from an
assumed course start at 18. It retains recorded Plan 4 and Postgraduate
categories even where present residence differs from the funding nation.
This fallback does not certify that every recorded plan is legally correct.
See the official [plan rules](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)
and [cancellation rules](https://www.gov.uk/repaying-your-student-loan/when-your-student-loan-gets-written-off-or-cancelled).

Projection holds ages fixed and retains existing holders' base plans across
future cohort boundaries. It leaves the England and Wales Plan 1 to Plan 2
transition at the 2012 intake and the England Plan 2 to Plan 5 transition at
the 2023 intake unimplemented. It can also retain older plans too long.

Vahid Ahmadi's [25 October 2026 review audit](https://github.com/PolicyEngine/policyengine-uk/pull/2650#issuecomment-6396818516)
used `enhanced_frs_2024_25` at revision `2526edbb` and the intake-at-18 proxy.
The audit reported these approximate counts of recorded holders whose proxy
intakes crossed the relevant boundary:

| Recorded plan and funding-region proxy | Boundary | Projection year | Audit count | Recorded-plan denominator |
| --- | --- | --- | --- | --- |
| Plan 2, England | 2023 intake, Plan 5 | 2026 | 0.53 million | 7.14 million Plan 2 holders |
| Plan 2, England | 2023 intake, Plan 5 | 2028 | 1.57 million | 7.14 million Plan 2 holders |
| Plan 2, England | 2023 intake, Plan 5 | 2031 | 2.99 million | 7.14 million Plan 2 holders |
| Plan 1, England and Wales | 2012 intake, Plan 2 | 2025 | None | 1.52 million Plan 1 holders |
| Plan 1, England and Wales | 2012 intake, Plan 2 | 2028 | 0.30 million | 1.52 million Plan 1 holders |
| Plan 1, England and Wales | 2012 intake, Plan 2 | 2031 | 0.68 million | 1.52 million Plan 1 holders |

These are the reviewer's dataset audit counts. This change does not reproduce
that audit or estimate national population accuracy or revenue effects. The
reviewer [accepted documenting the residual gap and tracking the boundary moves
separately](https://github.com/PolicyEngine/policyengine-uk/pull/2650#issuecomment-6397144626).
The intake-at-18 proxy cannot identify mature starters' actual course dates,
and present residence cannot identify their funding agency.

This fallback does not model longitudinal ageing or exact cancellation dates.
A retained Plan 1 at age 65 does not establish that a known pre-2006 loan
remains legally outstanding. Date-aware cohort and write-off work remains open
in [issue #2636](https://github.com/PolicyEngine/policyengine-uk/issues/2636).

The existing new-holder rules remain separate: they use an intake-at-18
approximation, a deterministic draw keyed on `person_id`, and current region
as a funding-nation proxy. These assumptions cannot identify cross-border
movers and are not newly validated by the existing-holder fallback. A single
plan enum also cannot represent simultaneous undergraduate and postgraduate
loans. This change supplies no new date schema, producer migration, Microcosm
remeasurement or population accuracy estimate.
73 changes: 40 additions & 33 deletions policyengine_uk/data/economic_assumptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,24 +621,42 @@ def uprate_student_loan_plans(
parameters: ParameterNode,
base_year: int | None = None,
) -> UKSingleYearDataset:
"""Assign student loan plans based on cohort and add new entrants.

Projected datasets copy the base year with ages fixed, so in a later year
a person of a given age stands for a later university intake. Plans
follow that intake: year - age + 18. The function works on the base
year's plans, not accumulated state, so each year's plans do not depend
on which other years were projected. base_year is the year the data
describe; without it, the previous year is taken as that year.

As we project forward, we need to:
1. Re-label existing holders to correct plan based on uni start year
2. Add holders in age bands beyond the base year's coverage
3. Add Plan 5 holders in England (new plan starting 2023)

For (2) and (3), we use highest_education == TERTIARY as the signal
for who is a graduate, then apply a flat take-up probability. Each
person's take-up draw is keyed on person_id, so the same people take up
a loan in every year and in any row order.
"""Retain observed student loan plans and add new entrants.

Existing holders keep their recorded plan and the repayment amount passed
to this helper. Course, funding and first-repayment dates are unavailable,
so age and current residence cannot establish a different plan or a
cancellation date. The enclosing uprating separately grows stored
repayments with average earnings, which this helper does not change.

Retaining recorded plans also leaves future cohort transitions from
Plan 1 to Plan 2 in England and Wales at the 2012 intake, and from Plan 2
to Plan 5 in England at the 2023 intake, unimplemented. Vahid Ahmadi's
25 October 2026 review audit used enhanced_frs_2024_25 at revision
2526edbb and the age-18 intake proxy. Among 7.14m recorded Plan 2 holders,
it counted 0.53m in England whose proxy intake reached 2023 in 2026,
1.57m in 2028 and 2.99m in 2031. Among 1.52m recorded Plan 1 holders,
it counted none in England and Wales whose proxy intake reached 2012
in 2025, 0.30m in 2028 and 0.68m in 2031. These approximate counts
describe that reviewer's dataset audit; this change does not reproduce
the audit or establish national population accuracy or revenue effects.
See the audit and accepted follow-up scope:
https://github.com/PolicyEngine/policyengine-uk/pull/2650#issuecomment-6396818516
https://github.com/PolicyEngine/policyengine-uk/pull/2650#issuecomment-6397144626
Date-aware cohort and cancellation work remains open under issue 2636.

Projected datasets copy the base year with ages fixed. Each year's plans
therefore start from that copy, not accumulated prior-year plan state.
base_year is the year the data describe; without it, the previous year is
taken as that year. This fallback can retain older plans too long and
changes the future plan mix; it does not model exact cancellation dates.

New entrants use the existing intake-at-18 approximation, year - age + 18,
to extend the base year's age bands and add England's Plan 5 intakes.
We use highest_education == TERTIARY as the signal for who is a graduate,
then apply a flat take-up probability. Each person's take-up draw is keyed
on person_id, so the same people take up a loan in every year and in any
row order.

New holders follow the funding nation's undergraduate repayment plan.
Funding agency is unavailable in the dataset, so current household
Expand Down Expand Up @@ -708,21 +726,10 @@ def assign_with_probability(mask, prob=_GRADUATE_LOAN_TAKE_UP):
new_plan[sampled] = new_borrower_plan[sampled]
repayments[sampled] = 0.0

# === Step 1: Re-label existing loan holders ===
has_loan = base_plan != "NONE"
written_off = has_loan & (uni_start_year + _PLAN1_WRITEOFF_YEARS <= year)
is_plan1_cohort = has_loan & ~written_off & (uni_start_year < 2012)
is_plan2_cohort = (
has_loan & ~written_off & (uni_start_year >= 2012) & (uni_start_year < 2023)
)
is_plan5_cohort = has_loan & ~written_off & (uni_start_year >= 2023)

new_plan[written_off] = "NONE"
repayments[written_off] = 0.0
new_plan[is_plan1_cohort] = "PLAN_1"
new_plan[is_plan2_cohort] = "PLAN_2"
new_plan[is_plan5_cohort & is_england] = "PLAN_5"
new_plan[is_plan5_cohort & ~is_england] = "PLAN_2"
# === Step 1: Retain observed loan holders ===
# Missing course and repayment dates do not establish a new plan or a
# cancellation. The initialized arrays retain each recorded non-NONE
# plan and repayment; the intake and region proxies apply only below.

# === Step 2: Add holders in the extended pre-2012 age bands ===
# Income-contingent (Plan 1) loans began with the 1998 intake, aged 44 in
Expand Down
173 changes: 173 additions & 0 deletions policyengine_uk/tests/test_student_loan_existing_holder_projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Preserve observed plans when projection lacks loan dates (#2636).

GOV.UK's plan and cancellation rules depend on funding agency, course dates,
first loan payment and April first due to repay. Current age and residence do
not supply those facts:
https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on
https://www.gov.uk/repaying-your-student-loan/when-your-student-loan-gets-written-off-or-cancelled

These tests specify the conservative missing-date projection contract: retain
the observed category and stored repayment. They do not assert exact legal
write-off dates, infer a mature starter's intake, or change repayment precedence.
"""

import pandas as pd
import pytest

from policyengine_uk.data import UKSingleYearDataset
from policyengine_uk.data.economic_assumptions import uprate_student_loan_plans
from policyengine_uk.system import system


def observed_holders(rows):
"""Build small synthetic observations; no course or funding dates exist."""
person = pd.DataFrame(
[
{
"person_id": person_id,
"person_benunit_id": 2000 + position,
"person_household_id": 1000 + position,
"age": age,
"student_loan_plan": plan,
"highest_education": "TERTIARY" if age >= 21 else "SECONDARY",
"student_loan_repayments": repayment,
}
for position, (person_id, age, plan, region, repayment) in enumerate(rows)
]
)
household = pd.DataFrame(
[
{
"household_id": 1000 + position,
"household_weight": 1.0,
"region": region,
"tenure_type": "OWNED_OUTRIGHT",
"rent": 0.0,
"council_tax": 0.0,
}
for position, (_, _, _, region, _) in enumerate(rows)
]
)
return UKSingleYearDataset(
person=person,
benunit=pd.DataFrame({"benunit_id": 2000 + pd.RangeIndex(len(rows))}),
household=household,
fiscal_year=2024,
)


def project_observations(base, year, *, explicit_base_year=True, order=None):
current = base.copy()
current.time_period = str(year)
if order is not None:
current.person = current.person.iloc[order].reset_index(drop=True)
current.household = current.household.iloc[::-1].reset_index(drop=True)
kwargs = {"base_year": 2024} if explicit_base_year else {}
return uprate_student_loan_plans(current, base, system.parameters, **kwargs)


def assert_observation(result, plan, repayment=500.0):
assert result.person.student_loan_repayments.tolist() == [repayment]
assert result.person.student_loan_plan.tolist() == [plan]


def test_existing_ni_plan_1_is_preserved_in_first_projection():
# Residence does not prove funding agency. The observed category takes
# precedence under the missing-date contract, not an inferred age-18 intake.
base = observed_holders([(1, 25, "PLAN_1", "NORTHERN_IRELAND", 500.0)])
assert_observation(project_observations(base, 2025), "PLAN_1")


@pytest.mark.parametrize(
"age,plan,region",
[
(33, "PLAN_2", "LONDON"),
(45, "PLAN_1", "LONDON"),
(50, "PLAN_1", "LONDON"),
(30, "PLAN_4", "SCOTLAND"),
(40, "PLAN_4", "SCOTLAND"),
(22, "PLAN_2", "WALES"),
(28, "POSTGRADUATE", "LONDON"),
],
ids=[
"plan2-age33",
"plan1-age45",
"plan1-age50",
"plan4-age30",
"plan4-age40",
"welsh-plan2",
"postgraduate",
],
)
def test_reported_existing_holder_profiles_keep_observations(age, plan, region):
# With the NI example above, these are the issue's eight profiles. Age33
# plus PLAN_2 does not identify the reported mature starter's 2014 intake.
base = observed_holders([(41, age, plan, region, 500.0)])
assert_observation(project_observations(base, 2025), plan)


@pytest.mark.parametrize(
"age,plan,region",
[
(30, "PLAN_4", "LONDON"),
(25, "PLAN_1", "SCOTLAND"),
(40, "PLAN_4", "UNKNOWN"),
(22, "PLAN_5", "UNKNOWN"),
],
ids=["plan4-mover", "plan1-mover", "unknown-region-plan4", "unknown-region-plan5"],
)
def test_known_category_does_not_follow_current_residence(age, plan, region):
base = observed_holders([(73, age, plan, region, 500.0)])
assert_observation(project_observations(base, 2025), plan)


@pytest.mark.parametrize("age", [64, 65])
def test_age_alone_does_not_establish_write_off(age):
# Retention at65 is explicitly the missing-date fallback. No first-payment
# date is observed, so this is not a claim about pre2006 legal cancellation.
base = observed_holders([(9, age, "PLAN_1", "LONDON", 500.0)])
assert_observation(project_observations(base, 2025), "PLAN_1")


MIXED_OBSERVATIONS = [
(900, 50, "PLAN_1", "LONDON", 120.0),
(42, 33, "PLAN_2", "UNKNOWN", 250.0),
(1337, 30, "PLAN_4", "LONDON", 0.0),
(8, 25, "PLAN_5", "SCOTLAND", 123.0),
(77, 28, "POSTGRADUATE", "WALES", 499.5),
(5, 10, "NONE", "NORTHERN_IRELAND", 0.0),
]


def observations_by_person(dataset):
return dataset.person.set_index("person_id")[
["student_loan_plan", "student_loan_repayments"]
].sort_index()


def test_mixed_rows_keep_person_identity_and_original_base():
base = observed_holders(MIXED_OBSERVATIONS)
original_tables = {
name: getattr(base, name).copy(deep=True)
for name in ("person", "benunit", "household")
}
expected = observations_by_person(base).copy(deep=True)
projected = project_observations(base, 2025, order=[5, 2, 4, 0, 3, 1])
pd.testing.assert_frame_equal(observations_by_person(projected), expected)
for name, original in original_tables.items():
pd.testing.assert_frame_equal(getattr(base, name), original)


@pytest.mark.parametrize(
"explicit_base_year", [True, False], ids=["explicit", "fallback"]
)
def test_repeated_projection_starts_from_original_observations(explicit_base_year):
base = observed_holders(MIXED_OBSERVATIONS)
expected = observations_by_person(base).copy(deep=True)
# Going back to2025 between two2040 projections must not accumulate state.
for year in (2040, 2025, 2040):
projected = project_observations(
base, year, explicit_base_year=explicit_base_year
)
pd.testing.assert_frame_equal(observations_by_person(projected), expected)
pd.testing.assert_frame_equal(observations_by_person(base), expected)
Loading