From 6ae9e46a0cfe3cb827569fc879e83a0830b6996e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:59:06 +0000 Subject: [PATCH 1/2] Preserve observed student loan plans during projection --- changelog.d/2636.fixed.md | 1 + docs/engineering/skills/dataset-sources.md | 34 ++++ policyengine_uk/data/economic_assumptions.py | 54 +++--- ...student_loan_existing_holder_projection.py | 173 ++++++++++++++++++ 4 files changed, 231 insertions(+), 31 deletions(-) create mode 100644 changelog.d/2636.fixed.md create mode 100644 policyengine_uk/tests/test_student_loan_existing_holder_projection.py diff --git a/changelog.d/2636.fixed.md b/changelog.d/2636.fixed.md new file mode 100644 index 0000000000..6313e0f9c3 --- /dev/null +++ b/changelog.d/2636.fixed.md @@ -0,0 +1 @@ +Preserve recorded student loan plans and stop clearing existing-holder repayments from inferred course dates during dataset projection. diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index b30a440cc3..d55f7e19af 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -146,3 +146,37 @@ 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 still holds ages fixed. Retaining existing-holder categories can +keep older plans longer and change the mix of plans in future years; it is a +conservative missing-date fallback, not longitudinal ageing or exact +cancellation modelling. A retained Plan 1 at age 65 is not an assertion 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. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 37ce1c3212..2e61474fa0 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -621,22 +621,25 @@ 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 + """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. This fallback can retain older plans too long and + changes the future cohort mix; it is not date-aware cancellation modelling. + The enclosing uprating separately grows stored repayments with average + earnings, which this helper does not change. + + 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. + + 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. @@ -708,21 +711,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 diff --git a/policyengine_uk/tests/test_student_loan_existing_holder_projection.py b/policyengine_uk/tests/test_student_loan_existing_holder_projection.py new file mode 100644 index 0000000000..1d28ca9fb7 --- /dev/null +++ b/policyengine_uk/tests/test_student_loan_existing_holder_projection.py @@ -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) From 36380ef9bb631401d0dca24f859379004e3b4a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:28:13 +0000 Subject: [PATCH 2/2] Fix issues from review: document student loan cohort limits --- changelog.d/2636.fixed.md | 2 +- docs/engineering/skills/dataset-sources.md | 36 ++++++++++++++++---- policyengine_uk/data/economic_assumptions.py | 31 ++++++++++++----- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/changelog.d/2636.fixed.md b/changelog.d/2636.fixed.md index 6313e0f9c3..623df105a5 100644 --- a/changelog.d/2636.fixed.md +++ b/changelog.d/2636.fixed.md @@ -1 +1 @@ -Preserve recorded student loan plans and stop clearing existing-holder repayments from inferred course dates during dataset projection. +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. diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index d55f7e19af..5956b67302 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -166,12 +166,36 @@ 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 still holds ages fixed. Retaining existing-holder categories can -keep older plans longer and change the mix of plans in future years; it is a -conservative missing-date fallback, not longitudinal ageing or exact -cancellation modelling. A retained Plan 1 at age 65 is not an assertion 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). +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 diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 2e61474fa0..876704b845 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -626,22 +626,37 @@ def uprate_student_loan_plans( 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. This fallback can retain older plans too long and - changes the future cohort mix; it is not date-aware cancellation modelling. - The enclosing uprating separately grows stored repayments with average - earnings, which this helper does not change. + 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. + 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. + 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