Skip to content

Target definitions: dwellings matched to households, and household-type targets that cannot all be satisfied #457

Description

@vahid-ahmadi

Several calibration targets measure a different quantity from the model column they are matched against, or contradict each other. Together these appear to account for most of the model's household overshoot (31.76m vs ONS's 29.0m UK households for 2025).

Found while investigating #452, #454 and PolicyEngine/policyengine-uk#1818.

1. Dwelling stock matched against household counts

targets/sources/ons_tenure.py:1-7 documents its source as ONS "subnational dwelling stock by tenure" and parses "Total Dwellings" (:42), emitting targets with variable="tenure_type", is_count=True. targets/compute/households.py:60-95 then matches them against modelled household counts.

Dwellings exceed households by vacants and second homes:

target value England households ≈ excess
ons/tenure_england_total 25,616,116 ~24.0m +6.7%
ons/tenure_england_rented_privately 5,299,840 ~4.7m +13%
ons/tenure_england_social_rent 4,274,613 ~4.0m +7%

The same error is repeated independently in the VOA council tax targets. targets/sources/voa_council_tax.py uses chargeable dwellings, and targets/compute/council_tax.py:14-15 matches voa/council_tax/<region>/total against a raw household count. The 11 regional totals sum to 29,854,139 chargeable dwellings — and that is GB only, excluding Northern Ireland, against GB households of roughly 28.19m.

The repo's own ONS Families & Households targets give the right answer: they sum to 29,003,000 UK households. So these two target sets alone push the model roughly 1.6-1.7m households above truth — about 60% of the 2.76m gap.

Suggested fix: convert the SPREE tenure targets from dwellings to households with a published vacancy and second-home adjustment, or retarget to EHS/ONS household tenure counts. For VOA, either apply a dwelling→household factor or drop the /total rows and keep only the band shares, which is what the council tax calibration actually needs.

2. Household-type targets that are mathematically unsatisfiable

targets/compute/households.py defines model categories that overlap, matched to ONS categories that are mutually exclusive. Two are outright contradictions:

  1. unrelated_adult_households (:25-29) = SINGLE benunit & children==0 & people>1, target 813,000. lone_parent_non_dependent_children_households (:45-52) is the same predicate plus age_hh_head >= 40, target 1,204,000 — a strict subset with a target 48% larger. Impossible.
  2. multi_family_households (:53-55) = household_num_benunits > 1, target 244,000. Any household satisfying (1) necessarily has ≥2 benefit units, so (1) ⊆ multi-family — yet its target is 813,000, 3.3x larger. Impossible.
  3. lone_households_under_65 / over_65 (:17-24) impose no household-size restriction, so they count every household containing a childless SINGLE benunit — including house-shares and couple-plus-lodger households. ONS means one person living alone.

The optimiser resolves these by inflating small single-adult households, which is consistent with the model's 2.19 people per household against the UK's ~2.36.

Suggested fix: rewrite compute_household_type so the model categories partition households exactly as ONS Table 7 does — lone_households_* must require people_per_hh == 1, and lone_parent_non_dependent_children_households must be built from an actual parent/non-dependent-child relationship rather than age >= 40. Add a test asserting the model columns are pairwise disjoint and sum to the ONS total.

3. Aggregate GBP targets with no count companion

housing/rent_private appears to converge at 1.04x, but targets/sources/housing.py:18-22 already concedes why that is meaningless: 6.99m renters at GBP 256/week against a basis of 5.4m at GBP 317/week. A 1.29x count error times a 0.81x level error cancels to 1.045.

housing/rent_social has the same defect with the errors compounding instead of cancelling (~1.19x count × ~1.30x level ≈ 1.55x).

Suggested fix: add housing/private_renter_households and housing/social_renter_households count targets, plus mean-rent-by-tenure. datasets/local_areas/constituencies/devolved_housing.py already pairs count with total for Wales and Scotland — the same pattern should apply nationally. The same argument applies to the benefit cap pair in PolicyEngine/policyengine-uk#1818, where a count and an amount target drift apart by 2.0x and 3.1x.

4. Tenure targets are England-only

_TENURE_COLS in ons_tenure.py emits only tenure_england_*, all with geo_code="E". Social renting in Scotland, Wales and Northern Ireland is completely unconstrained — Scotland alone is roughly 23% social. devolved_housing.py anchors devolved private rent only.

5. Household rent is never uprated

utils/uprating.py:262 matches dataset columns to uprating_factors.csv by name. The household input column is rent (policyengine_uk/variables/input/rent.py, no uprating attribute), while the only rent row in the CSV is benunit_rent — a derived BenUnit variable that is never a dataset column. The row is dead, and rents in the calibration-year dataset are frozen at survey-year nominal levels.

This makes modelled rent totals lower than they should be, which means the social-renter count overshoot is worse than the 1.19x inferred in #454.

6. Missing FRS tenure silently becomes private renting

datasets/frs.py:801-803: categorical(household.ptentyp2, 3, range(1,7), TENURES) uses a NaN default of 3, which maps to RENT_PRIVATELY. Any household with a missing tenure code silently becomes a private renter. The default should be an explicit unknown or an owner category.

7. Guardrails that do not guard

tests/test_population_fidelity.py:23-24 bounds household count at 25m-34m, which 31.76m passes trivially, while population is held to ±4%. Suggest tightening to the ONS figure (29.0m ±3%) and adding a people-per-household test.

Expect metrics to dip first

Fixes 1, 2, 3 and 4 will raise several currently-"passing" errors on first application, because the cancellations they remove are hiding compensating errors. That is the point, but the headline "% within 10%" metric should be expected to fall before it improves.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions