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/tfc-spend-routed-share.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Set `tax_free_childcare_spend_routed_share` when building the FRS, from HMRC's monthly and annual counts of children with used Tax-Free Childcare accounts. Tax-Free Childcare tops up money paid through the account rather than a family's whole childcare bill, and `childcare_expenses` is annual, so the build previously credited every claimant with a full year of routed spending. This is a calibration adjustment rather than a measurement: it takes its value from a published duration statistic because that is the closest observable quantity, and it moves modelled Tax-Free Childcare spending from 1.87x its target to 1.25x while leaving caseload at 1.02x. HMRC publishes no spend-weighted share.
40 changes: 40 additions & 0 deletions policyengine_uk_data/datasets/frs.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ def load_legacy_jobseeker_max_annual_hours(year: int) -> int:
return max_weekly_hours * HOURS_WORKED_WEEKS_PER_YEAR


def require_variable(name: str, description: str) -> None:
"""Fail the build if the installed policyengine-uk lacks ``name``.

``UKSingleYearDataset`` drops columns absent from the tax-benefit system
rather than raising, so writing an input the model does not define is a
silent no-op. Anything this build depends on the model actually reading
should be asserted here.
"""
if name not in CountryTaxBenefitSystem().variables:
raise RuntimeError(
f"The installed policyengine-uk does not define {name!r}, so the "
f"{description} would be written and then silently discarded when "
"the dataset is loaded. Upgrade policyengine-uk to a release that "
"defines it."
)


def derive_legacy_jobseeker_proxy(
age,
employment_status,
Expand Down Expand Up @@ -1378,6 +1395,9 @@ def determine_education_level(fted_val, typeed2_val, age_val):
marriage_allowance_rate = load_take_up_rate("marriage_allowance", year)
child_benefit_opts_out_rate = load_take_up_rate("child_benefit_opts_out_rate", year)
tfc_rate = load_take_up_rate("tax_free_childcare", year)
tfc_spend_routed_share = load_parameter(
"stochastic", "tax_free_childcare_spend_routed_share", year
)
extended_childcare_rate = load_take_up_rate("extended_childcare", year)
universal_childcare_rate = load_take_up_rate("universal_childcare", year)
targeted_childcare_rate = load_take_up_rate("targeted_childcare", year)
Expand Down Expand Up @@ -1425,6 +1445,7 @@ def _reported_benunit_mask(person_column: str) -> np.ndarray:
reported_mask=_reported_benunit_mask("universal_credit_reported"),
)
pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate

pe_benunit["would_claim_extended_childcare"] = (
generator.random(len(pe_benunit)) < extended_childcare_rate
)
Expand Down Expand Up @@ -1467,6 +1488,25 @@ def _reported_benunit_mask(person_column: str) -> np.ndarray:
)

# Person-level: Tie-breaking for higher earner (uniform random)
# Tax-Free Childcare tops up money paid through the account, not a family's
# whole childcare bill, and childcare_expenses is annual. Person-level
# because a Tax-Free Childcare account is held for one child (Childcare
# Payments Act 2014 section 15(2)) and the variable it feeds is per child.
# See parameters/stochastic/tax_free_childcare_spend_routed_share.yaml.
#
# Checked rather than assumed: policyengine-uk's dataset loader skips
# columns it does not recognise, so a model predating the variable would
# silently drop this correction and fall back to the model default of 1 —
# a build that looks clean and ships uncorrected Tax-Free Childcare
# spending. The pyproject floor cannot express this yet because the
# release carrying the variable does not exist at the time of writing, so
# fail loudly here instead.
require_variable(
"tax_free_childcare_spend_routed_share",
"Tax-Free Childcare routed-spend adjustment",
)
pe_person["tax_free_childcare_spend_routed_share"] = tfc_spend_routed_share

pe_person["higher_earner_tie_break"] = generator.random(len(pe_person))

# Person-level: Private school attendance random draw
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
description: Calibration adjustment scaling the childcare spending that attracts a Tax-Free Childcare top-up, set to the mean share of the year an account makes provider payments
metadata:
unit: /1
label: Tax-Free Childcare spend adjustment (calibration, duration-derived)
reference:
- title: "HMRC, Tax-Free Childcare statistics: March 2026 — Table 2, children with open and used accounts and government top-up"
href: https://www.gov.uk/government/statistics/tax-free-childcare-statistics-march-2026
- title: "HMRC, Tax-Free Childcare statistics: March 2026 — accompanying tables (ODS)"
href: https://assets.publishing.service.gov.uk/media/6a0dcb07faac4bc0b0e906ef/Tables_and_Statistics_March_2026.ods
- title: "HMRC, Tax-Free Childcare statistics quality report — definition of a used account"
href: https://www.gov.uk/government/statistics/quality-report-tax-free-childcare/tax-free-childcare-quality-report
# DENOMINATOR
#
# The model variable defines this share over the *eligible period*, because
# tax_free_childcare already multiplies annual spending by the eligible-period
# fraction; a whole-year share would discount the same months twice. The value
# below is measured over the whole year (months paying / 12). The two coincide
# for every claimant in the built data, because
# tax_free_childcare_eligible_declaration_periods returns
# eligible * declaration_periods_per_year and so is 0 or 4, never 1-3 — the
# eligible fraction is 1 for everyone who receives anything. If fractional
# declaration periods are ever populated, this value must be re-derived over
# the eligible period rather than the year, or the two will double-discount.
#
# WHAT THIS IS, AND WHAT IT IS NOT
#
# This is a **calibration adjustment**, not a measurement. It exists because
# the model overstates Tax-Free Childcare spending, and its value is borrowed
# from a published duration statistic because that is the closest observable
# quantity — not because HMRC measures what the model needs.
#
# The gap it addresses, on enhanced_frs_2024_25 at 2024 against this repo's
# own targets:
#
# before after target
# spending £1.178bn £0.790bn £0.632bn
# (1.87x) (1.25x)
# caseload 1,108k 1,108k 1,085k
# (1.02x) (1.02x)
#
# Caseload was already right, so the residual was the size of each award, and
# take-up cannot fix it: the rate that lands spending drops caseload to 0.55x.
# This adjustment reduces award size while leaving caseload untouched, which
# is the correction the evidence points to. It does not close the gap — 1.25x
# is still outside any tight tolerance, and the remainder is tracked in #471
# (the childcare fee base) and PolicyEngine/policyengine-uk#1832.
#
# Do not read the value as an estimate of routed expenditure. HMRC
# publishes no figure for the share of childcare pounds paid through a
# Tax-Free Childcare account. What it publishes is a monthly and an annual
# count of children with used accounts, where a used account is one from which
# a payment was made to a provider in the period, and it states that the annual
# count is not the sum of the monthly counts because a child appears in every
# month containing a payment but only once in the year. Their ratio is the mean
# number of months an account pays, as a share of the year:
#
# sum of the twelve monthly counts of children with used accounts
# -------------------------------------------------------------- / 12
# annual count of children with used accounts
#
# year sum of monthly annual unique months share
# 2017-18 308,300 71,515 4.31 0.359
# 2018-19 1,193,065 203,215 5.87 0.489
# 2019-20 2,579,660 396,365 6.51 0.542
# 2020-21 2,610,450 461,705 5.65 0.471
# 2021-22 4,466,690 646,940 6.90 0.575
# 2022-23 5,863,685 836,220 7.01 0.584
# 2023-24 6,833,825 966,120 7.07 0.589
# 2024-25 7,715,605 1,085,020 7.11 0.593
# 2025-26 8,042,540 1,151,515 6.98 0.582
#
# Treating it as a spending scaler assumes spend is uniform across the year, that
# an active month routes everything and an inactive month nothing, and that
# payment dates track care dates. HMRC explicitly warns the last is not true:
# the date a payment is made from an account need not be the date the childcare
# was received. It is also a count-weighted mean over a mix of full-year and
# short-spell claimants, applied here as an expenditure scaler.
#
# It is assigned as a constant because HMRC publishes nothing about the
# distribution behind the mean, so any spread would be invented rather than
# observed. It should not be read as a claim about any individual family.
#
# Because it is a calibration adjustment rather than an observation, it is
# legitimate to revise it against the spending target if a better-founded
# correction lands first. A spend-weighted source, were HMRC to publish one,
# would replace it outright rather than refine it.
#
# Two further caveats on the series. The 2020-21 dip is the pandemic. And HMRC
# revised the account definition from 2024-25: monthly totals before April 2025
# include payments from closed accounts, while annual figures from 2024-25 use
# a revised open-and-used basis, so the early and late years are not built on
# an identical construction.
#
# Dates are 1 April, the start of the HMRC financial year each value measures.
values:
2017-04-01: 0.359
2018-04-01: 0.489
2019-04-01: 0.542
2020-04-01: 0.471
2021-04-01: 0.575
2022-04-01: 0.584
2023-04-01: 0.589
2024-04-01: 0.593
2025-04-01: 0.582
25 changes: 25 additions & 0 deletions policyengine_uk_data/tests/test_stochastic_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,31 @@ def test_tv_licence_evasion_rate_loads(self):
assert 0 <= rate <= 1
assert rate < 0.2 # Evasion rate should be low

def test_tax_free_childcare_spend_routed_share_loads(self):
share = load_parameter(
"stochastic", "tax_free_childcare_spend_routed_share", 2024
)
assert 0 < share <= 1
# HMRC's 2024-25 ratio: 7,715,605 monthly child-account observations
# over 1,085,020 annual unique children, over twelve months.
assert share == pytest.approx(0.593, abs=0.001)

def test_tax_free_childcare_spend_routed_share_series_is_bounded(self):
# Every published year must be a usable share; a value outside 0-1
# would produce a negative or supra-statutory award downstream.
for year in range(2018, 2027):
share = load_parameter(
"stochastic", "tax_free_childcare_spend_routed_share", year
)
assert 0 < share <= 1, f"{year}: {share}"

def test_tax_free_childcare_spend_routed_share_launch_year(self):
# Tax-Free Childcare launched part-way through 2017-18, so its first
# year is much lower than the settled 0.58-0.59 of later years.
assert load_parameter(
"stochastic", "tax_free_childcare_spend_routed_share", 2017
) == pytest.approx(0.359, abs=0.001)

def test_first_time_buyer_rate_loads(self):
rate = load_parameter("stochastic", "first_time_buyer_rate", 2024)
assert 0 <= rate <= 1
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dependencies = [
"policyengine",
"google-cloud-storage",
"google-auth",
"policyengine-uk>=2.89.2",
"policyengine-uk>=2.93.0",
"microcalibrate>=0.18.0",
"microimpute>=1.0.1",
"ruff>=0.9.0",
Expand Down
16 changes: 8 additions & 8 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.