Model/tests/domain/epc_prediction/test_comparable_properties.py
Khalim Conn-Kowlessar fa11df56c2 fix(epc-prediction): dedupe re-lodgements + leak-free leave-one-out (ADR-0029)
The register lists every historical lodgement, so a postcode cohort
contains the same physical address many times (LS61AA: 15 certs / 11
addresses; NG71AA: 15 / 9 — "FLAT 3" appears 3x in each). Two
consequences:

  - Production: a re-lodged neighbour was counting up to 3x towards the
    cohort mode. select_comparables now dedupes candidates to the latest
    cert per address (one comparable per real neighbour) — Comparable
    gains address + registration_date (the register metadata its docstring
    already anticipated, read straight off the cached payload).

  - Validation: leave-one-out leaked — predicting a flat from a near-
    identical re-lodgement of itself. The harness now holds out a whole
    address (excludes every sibling cert) and evaluates on the latest cert
    per address (the best ground truth).

Removing the leak gives the honest numbers (19 distinct addresses):
  wall_construction      93.1% -> 89.5%
  construction_age_band  65.5% -> 52.6%
  roof_construction      79.3% -> 68.4%
  floor_area mean|.|     37.9  -> 52.6 m2
The earlier figures were inflated by self-leakage; these are the real
accuracy to beat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:40:23 +00:00

173 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Behaviour of Comparable Properties selection (ADR-0029): given a prediction
target's known inputs and the raw postcode cohort, choose + weight the
comparables EPC Prediction will synthesise from. Filter-then-relax ladder:
hard filters on identity (property type, built form) + known overrides while
enough remain, weighted by recency × similarity. Pure domain logic.
"""
from datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
from domain.epc_prediction.comparable_properties import (
Comparable,
ComparableProperties,
PredictionTarget,
select_comparables,
)
def _comparable(
*,
property_type: str,
certificate_number: str,
built_form: str = "1",
wall_construction: Optional[Union[int, str]] = None,
address: Optional[str] = None,
registration_date: Optional[date] = None,
) -> Comparable:
"""A Comparable carrying only the fields under test (opaque EpcPropertyData
with property_type / built_form / main wall set — the partial-instance idiom)."""
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.property_type = property_type
epc.built_form = built_form
main: SapBuildingPart = object.__new__(SapBuildingPart)
if wall_construction is not None:
main.wall_construction = wall_construction
epc.sap_building_parts = [main]
return Comparable(
epc=epc,
certificate_number=certificate_number,
address=address,
registration_date=registration_date,
)
def test_selects_only_candidates_of_the_same_property_type() -> None:
# Arrange — a target house (property_type "2"); cohort of 2 houses + 1 flat.
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
candidates = [
_comparable(property_type="2", certificate_number="A"),
_comparable(property_type="2", certificate_number="B"),
_comparable(property_type="1", certificate_number="C"),
]
# Act
result: ComparableProperties = select_comparables(target, candidates)
# Assert — the flat is excluded; the two houses remain.
assert {c.certificate_number for c in result.members} == {"A", "B"}
def test_dedupes_re_lodgements_to_the_latest_cert_per_address() -> None:
# Arrange — a register cohort with one address (FLAT 3) lodged three times.
# Comparables are one-per-real-neighbour, so a re-lodged address must not
# count three times towards the mode; the latest cert is its current state.
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
candidates = [
_comparable(
property_type="2",
certificate_number="OLD",
address="FLAT 3",
registration_date=date(2020, 4, 6),
),
_comparable(
property_type="2",
certificate_number="MID",
address="FLAT 3",
registration_date=date(2021, 2, 1),
),
_comparable(
property_type="2",
certificate_number="NEW",
address="FLAT 3",
registration_date=date(2025, 1, 20),
),
_comparable(
property_type="2",
certificate_number="OTHER",
address="FLAT 5",
registration_date=date(2024, 9, 27),
),
]
# Act
result: ComparableProperties = select_comparables(target, candidates)
# Assert — FLAT 3 collapses to its latest cert; FLAT 5 is untouched.
assert {c.certificate_number for c in result.members} == {"NEW", "OTHER"}
def test_filters_to_the_known_built_form_when_enough_remain() -> None:
# Arrange — a mid-terrace target (built_form "4"); cohort of 5 mid-terraces
# + 2 detached, all houses. The built form is known and leaves ≥ k, so it is
# applied as a hard filter.
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", built_form="4"
)
candidates = [
_comparable(property_type="2", built_form="4", certificate_number=f"T{i}")
for i in range(5)
] + [
_comparable(property_type="2", built_form="1", certificate_number=f"D{i}")
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the five mid-terraces survive.
assert {c.certificate_number for c in result.members} == {
"T0", "T1", "T2", "T3", "T4"
}
def test_known_wall_override_emphasises_matching_comparables() -> None:
# Arrange — a mixed street: 5 solid-brick (code 2) + 3 cavity (code 1) houses.
# We KNOW the target is solid brick (a Landlord Override), and the filter
# leaves ≥ k, so cavity neighbours are dropped (the border-property case).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
candidates = [
_comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}")
for i in range(5)
] + [
_comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}")
for i in range(3)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the solid-brick comparables remain.
assert {c.certificate_number for c in result.members} == {
"S0", "S1", "S2", "S3", "S4"
}
def test_known_wall_override_relaxes_when_too_few_match() -> None:
# Arrange — only 2 solid-brick but 6 cavity houses; the override would leave
# 2 (< k=5), so it relaxes to keep the full type cohort (graceful degradation).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
candidates = [
_comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}")
for i in range(2)
] + [
_comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}")
for i in range(6)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — relaxed: all eight houses retained.
assert len(result.members) == 8