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:
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.
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.
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.
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-7documents its source as ONS "subnational dwelling stock by tenure" and parses"Total Dwellings"(:42), emitting targets withvariable="tenure_type", is_count=True.targets/compute/households.py:60-95then matches them against modelled household counts.Dwellings exceed households by vacants and second homes:
ons/tenure_england_totalons/tenure_england_rented_privatelyons/tenure_england_social_rentThe same error is repeated independently in the VOA council tax targets.
targets/sources/voa_council_tax.pyuses chargeable dwellings, andtargets/compute/council_tax.py:14-15matchesvoa/council_tax/<region>/totalagainst 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
/totalrows 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.pydefines model categories that overlap, matched to ONS categories that are mutually exclusive. Two are outright contradictions: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 plusage_hh_head >= 40, target 1,204,000 — a strict subset with a target 48% larger. Impossible.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.lone_households_under_65/over_65(:17-24) impose no household-size restriction, so they count every household containing a childlessSINGLEbenunit — 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_typeso the model categories partition households exactly as ONS Table 7 does —lone_households_*must requirepeople_per_hh == 1, andlone_parent_non_dependent_children_householdsmust be built from an actual parent/non-dependent-child relationship rather thanage >= 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_privateappears to converge at 1.04x, buttargets/sources/housing.py:18-22already 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_socialhas the same defect with the errors compounding instead of cancelling (~1.19x count × ~1.30x level ≈ 1.55x).Suggested fix: add
housing/private_renter_householdsandhousing/social_renter_householdscount targets, plus mean-rent-by-tenure.datasets/local_areas/constituencies/devolved_housing.pyalready 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_COLSinons_tenure.pyemits onlytenure_england_*, all withgeo_code="E". Social renting in Scotland, Wales and Northern Ireland is completely unconstrained — Scotland alone is roughly 23% social.devolved_housing.pyanchors devolved private rent only.5. Household
rentis never upratedutils/uprating.py:262matches dataset columns touprating_factors.csvby name. The household input column isrent(policyengine_uk/variables/input/rent.py, noupratingattribute), while the only rent row in the CSV isbenunit_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 of3, which maps toRENT_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-24bounds 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.