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
2 changes: 2 additions & 0 deletions changelog.d/entitlement-calibration-targets.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Correct the universal and early-learning-for-2-year-olds calibration targets to DfE's published January 2024 figures. The universal target nets off the working parent entitlement, since `universal_childcare_entitlement` is modelled as mutually exclusive with it; the prior 490 thousand target counted 1.18x the children the variable covers. Both prior targets were unsourced. The spending figures are documented as a modelled full-entitlement value rather than an observed outturn, since DfE publishes headcounts rather than per-programme spending.
Drop the universal and targeted **spending** targets. DfE publishes no per-programme spending, so the only derivable figure is the caseload at the statutory 570 hours times the DfE funding rate — 416,537 x 570 x 5.88 and 115,852 x 570 x 8.28. Being caseload times a constant, each restates a target the registry already holds, and `takeup_rate.objective` sums an equally weighted squared relative error over every entry, so the loop counted those two schemes twice against Tax-Free Childcare and extended hours on no extra evidence. Both also assumed every registered child took the full 570 hours, making them upper bounds that pulled weights up. Tax-Free Childcare spending stays: it varies with childcare expenditure, so it is not redundant with its caseload.
74 changes: 64 additions & 10 deletions policyengine_uk_data/datasets/childcare/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,80 @@
both the target and the model. That is a coverage gap in the model rather
than a calibration error, and no change to these targets addresses it.

**Universal and early learning for 2-year-olds.** Corrected against DfE's
published figures in a follow-up change; see "Funded early education and
childcare", reporting year 2026
(https://explore-education-statistics.service.gov.uk/find-statistics/funded-early-education-and-childcare/2026).
**Universal and early learning for 2-year-olds.** From DfE, "Funded early
education and childcare", reporting year 2026, national figures for January
2024 (``data/headline_figures_feeac_2011_2026.csv`` in the release bundle):

release https://explore-education-statistics.service.gov.uk/find-statistics/funded-early-education-and-childcare/2026
the data https://explore-education-statistics.service.gov.uk/data-catalogue/funded-early-education-and-childcare/2026

registered for the universal entitlement, excluding reception 778,327
registered for the working parent entitlement, aged 3 to 4 361,790
=> registered for the universal entitlement only 416,537

early learning for 2-year-olds, registered 115,852
early learning for 2-year-olds, eligible 154,957

The universal figure nets off the working parent entitlement because
``universal_childcare_entitlement_eligible`` in policyengine-uk ends with
``& ~has_extended_childcare`` — the schemes are modelled as mutually
exclusive, so the comparator is children on the universal entitlement *only*,
not the 1.13 million headline. The subtraction is only correct while that
exclusion holds: an eligibility refactor that dropped it would make 416,537
the wrong comparator without any target here looking wrong.
test_universal_eligibility_still_excludes_the_working_parent_scheme in
tests/test_childcare_targets.py asserts it against the installed
policyengine-uk, so the cross-repo dependency fails loudly instead of
silently. The prior 490 thousand target was 1.18x that
figure and the prior 130 thousand target was 1.12x the EL2 count, both
unsourced.

**No universal or targeted spending target.** DfE publishes January
headcounts of children registered for at least some provision, and publishes
no per-programme spending. The only spending figure derivable from it is the
caseload at the statutory 570 hours and the DfE funding rate for the age band
(£5.88 an hour for 3 and 4-year-olds in 2024-25, £8.28 for 2-year-olds):

universal 416,537 x 570 x 5.88 = £1.396bn
targeted 115,852 x 570 x 8.28 = £0.547bn

Those are not observations. Being caseload times a constant, each is the
caseload target restated in pounds, and `takeup_rate.objective` sums an
equally weighted squared relative error over every entry in both dictionaries:

for key in targets["spending"]:
loss += (spending[key] / targets["spending"][key] - 1) ** 2
for key in targets["caseload"]:
loss += (caseload[key] / targets["caseload"][key] - 1) ** 2

Since the model pays every recipient of these two schemes the same per-child
amount, the spending ratio equals the caseload ratio and the loop adds the
same term twice — doubling the pull of universal and targeted against
Tax-Free Childcare and extended hours, on no extra evidence. Both also assume
every registered child took the full 570 hours, so they are upper bounds and
the duplicated term pulls weights up.

Tax-Free Childcare spending stays, because it is not redundant: it varies
with childcare expenditure rather than being its caseload times a constant,
and it is a published outturn. Restoring universal and targeted spending
needs an allocation or outturn source, not a headcount.

"""

# Spending in £bn, caseload in thousands of children, both for 2024.
TARGETS = {
"spending": {
"tfc": 0.6322, # HMRC £632.2m, 2024-25
# No extended entry: the only derivable figure is a full-usage ceiling
# that the model pays 75% of. See the module docstring.
"targeted": 0.6,
"universal": 1.7,
# No extended, universal or targeted entry. Universal and targeted are
# the caseload times a constant, which duplicates the caseload term in
# the loss rather than adding evidence; extended's derivable figure is
# a full-usage ceiling the model pays 75% of. See the module docstring.
},
"caseload": {
"tfc": 1_085.02, # HMRC 1,085,020 children, 2024-25
"extended": 621.5, # DfE Jan 2025: 379,000 + 242,500
"targeted": 130,
"universal": 490,
"targeted": 115.852, # DfE Jan 2024: 115,852 registered
"universal": 416.537, # DfE Jan 2024: 778,327 - 361,790
},
}

Expand Down
19 changes: 19 additions & 0 deletions policyengine_uk_data/parameters/take_up/targeted_childcare.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,24 @@ metadata:
reference:
- title: Empirical estimate from FRS data
href: https://github.com/PolicyEngine/policyengine-uk-data
# DfE publishes a take-up rate for early learning for 2-year-olds directly —
# 74.8% of eligible children registered in January 2024 — but it is not
# transferable to this parameter as things stand, because the two are measured
# on different populations.
#
# policyengine-uk gives 2-year-olds extended (working parent) entitlement from
# 2024-01-01, and targeted_childcare_entitlement_eligible excludes benefit
# units that qualify for it. The real working-parent entitlement for
# 2-year-olds began in April 2024, after DfE's January 2024 census, so DfE's
# eligible count contains no corresponding exclusion. Applying DfE's rate to
# the model's smaller base would fit one difference against another.
#
# DfE also notes its eligible count omits multiple two-year-olds and several
# non-economic eligibility routes, so the published percentage overstates true
# take-up, and PolicyEngine models a different subset of those routes again.
#
# Aligning the populations — starting with the 2024-01-01 date on
# gov.dfe.extended_childcare_entitlement.hours, which should be April — is a
# prerequisite for transferring the rate.
values:
2015-01-01: 0.597
48 changes: 42 additions & 6 deletions policyengine_uk_data/tests/test_childcare_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,23 @@

PROGRAMMES = {"tfc", "extended", "targeted", "universal"}

# Spending is deliberately sparse: extended has no target, because the only
# figure derivable from DfE is a full-usage ceiling the model pays 75% of.
# See the targets module docstring and test_extended_has_no_spending_target.
SPENDING_PROGRAMMES = PROGRAMMES - {"extended"}
# Only Tax-Free Childcare has a spending target. Universal and targeted are
# the caseload times a constant, which duplicates the caseload term in
# `takeup_rate.objective` instead of adding evidence; extended's only
# derivable figure is a full-usage ceiling the model pays 75% of.
SPENDING_PROGRAMMES = {"tfc"}


def test_the_registry_covers_the_programmes_it_claims():
def test_every_programme_has_a_caseload_target():
assert set(TARGETS) == {"spending", "caseload"}
assert set(TARGETS["caseload"]) == PROGRAMMES
assert set(TARGETS["spending"]) == SPENDING_PROGRAMMES


def test_spending_targets_exclude_the_caseload_derived_programmes():
assert set(TARGETS["spending"]) == SPENDING_PROGRAMMES, (
"only TFC spending is an observed outturn; the others are caseload x a "
"constant or a full-usage ceiling, and re-adding them biases the loss"
)


def test_targets_are_positive_and_in_their_stated_units():
Expand All @@ -48,6 +55,35 @@ def test_tfc_targets_keep_the_published_precision():
assert TARGETS["caseload"]["tfc"] == pytest.approx(1_085.02)


def test_entitlement_caseloads_match_the_dfe_january_2024_census():
"""Universal nets off the working parent entitlement; the schemes are
modelled as mutually exclusive, so the comparator is the children on the
universal entitlement only, not DfE's 1.13 million headline."""
universal_including_working_parent = 778_327
working_parent_three_and_four = 361_790
assert TARGETS["caseload"]["universal"] == pytest.approx(
(universal_including_working_parent - working_parent_three_and_four) / 1e3
)
assert TARGETS["caseload"]["targeted"] == pytest.approx(115_852 / 1e3)


def test_universal_eligibility_still_excludes_the_working_parent_scheme():
"""The universal target subtracts the working parent registrations.

That is only the right comparator while policyengine-uk models the two
schemes as mutually exclusive. If this fails, the 416,537 figure needs
rederiving, not the test relaxing.
"""
import inspect

from policyengine_uk.system import system

source = inspect.getsource(
type(system.variables["universal_childcare_entitlement_eligible"])
)
assert "~has_extended_childcare" in source


def test_tolerance_falls_back_to_the_default():
# No override is set for these, so they take the default.
assert ("caseload", "universal") not in TOLERANCES
Expand Down