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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@
**/_build
!policyengine_uk_data/storage/*.csv
**/version.json
# Build output: household weights by local area, derived from FRS microdata
policyengine_uk_data/storage/local_geography_weights.csv.gz
1 change: 1 addition & 0 deletions changelog.d/hb-dwp-age-targets.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Calibrate Housing Benefit to DWP's Great Britain spending and claims by age group, replacing the OBR Housing Benefit target that the model compared with UK-wide Housing Benefit. Pension-age Housing Benefit is calibrated to DWP's figures over Pension Credit qualifying age. Working-age Housing Benefit is calibrated to DWP's figures under that age less all supported and temporary accommodation Housing Benefit, for which policyengine-uk has no rules (£585m and 107k claims in 2025-26, against £5.78bn and 460k in total); treating the model's working-age Housing Benefit as general needs is an approximation. Modelled GB Housing Benefit for 2025-26 falls from about £11.7bn to £8.5bn (DWP: £12.9bn), because pension-age Housing Benefit was about 1.6 times DWP's figure while about £5.2bn of supported and temporary accommodation Housing Benefit remains unmodelled as such. Stop setting `would_claim_uc` for benefit units whose adults have all reached State Pension age, and build with policyengine-uk 2.102.5 so pension-age families can make new Housing Benefit claims.
46 changes: 46 additions & 0 deletions policyengine_uk_data/datasets/frs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
add_disability_benefit_flags_from_reported_amounts,
drop_internal_disability_reported_amounts,
)
from policyengine_uk_data.utils.benefit_units import claimant_or_partner_variable
from policyengine_uk_data.utils.datasets import (
sum_to_entity,
categorical,
Expand Down Expand Up @@ -392,6 +393,38 @@ def derive_is_parent_from_frs_microdata(
return is_adult_record & has_dependent_children


def derive_all_claimants_over_state_pension_age(
person_benunit_ids,
is_claimant_or_partner,
is_over_state_pension_age,
benunit_ids,
) -> np.ndarray:
"""Identify benefit units whose claimant and any partner have all reached
State Pension age.

Such a unit cannot claim Universal Credit (Welfare Reform Act 2012
s.4(1)(b)). ``is_claimant_or_partner`` should be the variable that
``claimant_or_partner_variable`` names, so the rule matches the
pension-age route of policyengine-uk's ``housing_benefit_eligible``.
"""

claimant = np.asarray(is_claimant_or_partner, dtype=bool)
over = claimant & np.asarray(is_over_state_pension_age, dtype=bool)
counts = (
pd.DataFrame(
{
"benunit": np.asarray(person_benunit_ids),
"claimants": claimant.astype(int),
"over": over.astype(int),
}
)
.groupby("benunit")[["claimants", "over"]]
.sum()
.reindex(np.asarray(benunit_ids), fill_value=0)
)
return ((counts.claimants > 0) & (counts.over == counts.claimants)).to_numpy()


def _as_non_negative_array(values) -> np.ndarray:
values = np.asarray(values, dtype=float)
return np.maximum(np.nan_to_num(values, nan=0.0), 0.0)
Expand Down Expand Up @@ -1537,10 +1570,23 @@ def _reported_benunit_mask(person_column: str) -> np.ndarray:
pension_credit_rate,
reported_mask=_reported_benunit_mask("pension_credit_reported"),
)
# A benefit unit whose claimant and any partner have all reached State
# Pension age cannot claim Universal Credit, so it never gets
# would_claim_uc, even if it reports UC. The draw still covers every unit,
# so the random stream and every other unit's value are unchanged. Ages
# are not rolled forward, so if State Pension age rises above a claimant's
# survey age in a later year, that unit stays without would_claim_uc there.
pe_benunit["would_claim_uc"] = assign_takeup_with_reported_anchors(
generator.random(len(pe_benunit)),
universal_credit_rate,
reported_mask=_reported_benunit_mask("universal_credit_reported"),
) & ~derive_all_claimants_over_state_pension_age(
person_benunit_ids=sim.calculate("person_benunit_id", year).values,
is_claimant_or_partner=sim.calculate(
claimant_or_partner_variable(sim.tax_benefit_system.variables), year
).values,
is_over_state_pension_age=sim.calculate("is_SP_age", year).values,
benunit_ids=pe_benunit.benunit_id,
)
pe_benunit["would_claim_tfc"] = generator.random(len(pe_benunit)) < tfc_rate

Expand Down
22 changes: 19 additions & 3 deletions policyengine_uk_data/targets/build_loss_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def create_target_matrix(
col = _compute_column(target, ctx, year)
if col is None:
continue
df[target.name] = col
df[target.name] = restrict_to_countries(col, ctx.country, target.countries)
target_names.append(target.name)
target_values.append(val)
except Exception as e:
Expand All @@ -122,14 +122,30 @@ def create_target_matrix(
return df, pd.Series(target_values, index=target_names)


def restrict_to_countries(column, household_country, countries):
"""Zero a household column outside the countries a target covers.

``countries`` of None means the target covers the whole UK, and the
column is returned unchanged.
"""
if countries is None:
return column
in_scope = np.isin(np.asarray(household_country), countries)
return np.asarray(column, dtype=float) * in_scope


def _resolve_value(target: Target, year: int) -> float | None:
"""Get the target value for a year, falling back to nearest year.

VOA council tax targets are population-uprated when extrapolating
from their base year (2024).
A missing year takes the value of the nearest listed year (the earlier
on a tie) if that year is earlier and at most three years away, unless
the target sets ``carry_forward=False``; otherwise there is no value. VOA council tax targets are
population-uprated when extrapolating from their base year (2024).
"""
if year in target.values:
return target.values[year]
if not target.carry_forward:
return None
available = sorted(target.values.keys())
if not available:
return None
Expand Down
14 changes: 14 additions & 0 deletions policyengine_uk_data/targets/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ class Unit(str, Enum):
RATE = "rate"


# DWP statistics cover Great Britain: benefits for Northern Ireland residents
# are the Northern Ireland Executive's responsibility.
GREAT_BRITAIN = ("ENGLAND", "SCOTLAND", "WALES")


class Target(BaseModel):
"""A single calibration target from an official statistical source.

Expand All @@ -41,6 +46,15 @@ class Target(BaseModel):
is_count: bool = False
reference_url: str | None = None
forecast_vintage: str | None = None
# Countries a national source covers when that is less than the UK, as
# values of the model's `country` variable. The loss matrix column only
# counts households in these countries. None means the whole UK.
countries: tuple[str, ...] | None = None
# Whether the loss matrix may fill a year the target does not list with
# the nearest listed year's value (the earlier on a tie), used only when
# that year is earlier and at most three years away (see _resolve_value).
# False for a figure that exists only in the years it lists.
carry_forward: bool = True

# For targets needing custom simulation logic (UC splits,
# counterfactuals). Excluded from serialisation.
Expand Down
Loading
Loading