feat(epc-prediction): physical-similarity-weighted categorical mode (#1224)

ADR-0029 decision 5: survivors were treated equally; now each neighbour's
vote in the cohort mode decays with its distance from the cohort's physical
centre (floor area from the median, age band from the modal band), so the
mode leans on the most representative neighbours instead of being swayed by
size/era outliers. Scales (size 20 m^2, age weight 0.5) chosen on the
validation corpus; the tight size kernel is load-bearing (looser scales
regress floor_insulation on the fixture).

Corpus (181 SAP-10.2 targets): wall_insulation 83.4->86.2%,
roof_construction 86.2->87.3%, floor_construction 78.8->81.2%,
floor_insulation 92.9->94.1%; net +7.5pp gained vs -1.1pp (two 1-cert dips,
both held on the fixture). Geometry/residuals untouched (template unchanged).

Gate (36-target fixture): zero regression across all 24 floors/ceilings;
ratcheted wall_insulation_type 0.7778->0.8333, floor_construction
0.7500->0.8125, floor_insulation 0.9062->0.9375. Dead _mode/_int_mode
removed (superseded by the weighted variants).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 10:46:51 +00:00
parent 07051b9401
commit 718455e971
3 changed files with 136 additions and 24 deletions

View file

@ -111,25 +111,34 @@ class EpcPrediction:
) -> None:
"""Override the predicted picture's homogeneous categoricals — wall /
roof / floor construction + insulation, age band with the cohort mode
(robust to an atypical template, per ADR-0029 decision 4). The template
still supplies the geometry; only the categorical codes move to the mode.
(Glazing type is deliberately left on the template moding it is
marginal and noisy; revisit with a larger corpus.)"""
(robust to an atypical template, per ADR-0029 decision 4). The mode is
physically-similarity-weighted (decision 5): each neighbour's vote decays
with its distance from the cohort's physical centre, so the mode leans on
the most representative neighbours rather than treating every survivor
equally. The template still supplies the geometry; only the categorical
codes move to the mode. (Glazing type is deliberately left on the
template moding it is marginal and noisy; revisit with a larger
corpus.)"""
if not predicted.sap_building_parts:
return
main: SapBuildingPart = predicted.sap_building_parts[0]
members = comparables.members
weights: list[float] = _similarity_weights(members)
for attr in _MAIN_PART_CATEGORICALS:
if attr in _RECENCY_WEIGHTED_CATEGORICALS:
mode = _recency_weighted_mode(members, attr)
else:
mode = _mode(_main_part_attr(c, attr) for c in members)
mode = _weighted_mode(
(_main_part_attr(c, attr) for c in members), weights
)
if mode is not None:
setattr(main, attr, mode)
floor_dims = main.sap_floor_dimensions
if floor_dims:
for attr in _FLOOR_DIM_CATEGORICALS:
floor_mode = _int_mode(_main_floor_attr(c, attr) for c in members)
floor_mode = _weighted_int_mode(
(_main_floor_attr(c, attr) for c in members), weights
)
if floor_mode is not None:
setattr(floor_dims[0], attr, floor_mode)
@ -177,6 +186,16 @@ _RECENCY_WEIGHTED_CATEGORICALS: frozenset[str] = frozenset(
_RECENCY_TAU_YEARS: float = 4.0
_DAYS_PER_YEAR: float = 365.0
# Physical-similarity weighting of the categorical mode (ADR-0029 decision 5): a
# comparable's vote decays exponentially with how far it sits from the cohort's
# physical centre — floor area from the median, construction age from the modal
# band — so an outlier-sized or outlier-era neighbour can't sway the mode. Scales
# chosen on the validation corpus (wall-insulation +2.8pp / roof +1.1pp /
# floor-construction +2.4pp / floor-insulation +1.2pp; gate-safe, no regression).
_SIMILARITY_SIZE_SCALE_M2: float = 20.0
_SIMILARITY_AGE_WEIGHT: float = 0.5
_AGE_BAND_ORDER: str = "ABCDEFGHIJKL"
def _main_part_attr(
comparable: Comparable, attr: str
@ -194,14 +213,72 @@ def _main_floor_attr(comparable: Comparable, attr: str) -> Optional[int]:
return value
def _mode(
values: Iterable[Optional[Union[int, str]]],
def _age_band_index(comparable: Comparable) -> Optional[int]:
"""The main building part's construction-age-band position (A=0 … L=11), or
None when no recognisable band is lodged."""
band = _main_part_attr(comparable, "construction_age_band")
if isinstance(band, str) and band in _AGE_BAND_ORDER:
return _AGE_BAND_ORDER.index(band)
return None
def _similarity_weights(members: tuple[Comparable, ...]) -> list[float]:
"""A physical-similarity weight per comparable (ADR-0029 decision 5): the
product of an exponential decay in its floor-area distance from the cohort
median and in its age-band distance from the cohort's modal band. A neighbour
missing a size or age contributes a neutral weight on that axis, so it is
never penalised for absent data. Aligned with `members` index-for-index."""
if not members:
return []
median_area: float = statistics.median(
c.epc.total_floor_area_m2 for c in members
)
age_indices: list[Optional[int]] = [_age_band_index(c) for c in members]
present_ages: list[int] = [i for i in age_indices if i is not None]
modal_age: Optional[float] = (
statistics.median(present_ages) if present_ages else None
)
weights: list[float] = []
for comparable, age_index in zip(members, age_indices):
size_term: float = math.exp(
-abs(comparable.epc.total_floor_area_m2 - median_area)
/ _SIMILARITY_SIZE_SCALE_M2
)
age_term: float = (
math.exp(-_SIMILARITY_AGE_WEIGHT * abs(age_index - modal_age))
if modal_age is not None and age_index is not None
else 1.0
)
weights.append(size_term * age_term)
return weights
def _weighted_mode(
values: Iterable[Optional[Union[int, str]]], weights: list[float]
) -> Optional[Union[int, str]]:
"""The most common non-None value, or None when there are none."""
present = [v for v in values if v is not None]
if not present:
"""The value with the greatest total similarity weight (ties broken by first
appearance, matching `_mode`), or None when no non-None value is present."""
totals: dict[Union[int, str], float] = defaultdict(float)
for value, weight in zip(values, weights):
if value is not None:
totals[value] += weight
if not totals:
return None
return Counter(present).most_common(1)[0][0]
return max(totals, key=lambda value: totals[value])
def _weighted_int_mode(
values: Iterable[Optional[int]], weights: list[float]
) -> Optional[int]:
"""`_weighted_mode` narrowed to int-coded fields (keeps pyright strict happy
when the target attribute is typed `Optional[int]`)."""
totals: dict[int, float] = defaultdict(float)
for value, weight in zip(values, weights):
if value is not None:
totals[value] += weight
if not totals:
return None
return max(totals, key=lambda value: totals[value])
def _modal_share(
@ -238,12 +315,3 @@ def _recency_weighted_mode(
if not weights:
return None
return max(weights, key=lambda value: weights[value])
def _int_mode(values: Iterable[Optional[int]]) -> Optional[int]:
"""`_mode` narrowed to int-coded fields (keeps pyright strict happy when the
target attribute is typed `Optional[int]`)."""
present = [v for v in values if v is not None]
if not present:
return None
return Counter(present).most_common(1)[0][0]

View file

@ -31,11 +31,11 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction"
# 36-target fixture; a 1e-3 tolerance absorbs float rounding only.
_RATE_FLOORS: dict[str, float] = {
"wall_construction": 0.8889,
"wall_insulation_type": 0.7778,
"wall_insulation_type": 0.8333,
"construction_age_band": 0.6389,
"construction_age_band_pm1": 0.8333,
"roof_construction": 0.7222,
"floor_construction": 0.7500,
"floor_construction": 0.8125,
"heating_main_fuel": 0.9722,
"heating_main_category": 0.8889,
"heating_main_control": 0.7500,
@ -45,7 +45,7 @@ _RATE_FLOORS: dict[str, float] = {
"cylinder_insulation_type": 0.1667,
"secondary_heating_type": 0.0000,
"roof_insulation_thickness": 0.4118,
"floor_insulation": 0.9062,
"floor_insulation": 0.9375,
"has_room_in_roof": 0.8333,
"modal_glazing_type": 0.5000,
"has_pv": 1.0000,

View file

@ -226,6 +226,50 @@ def test_recency_weights_roof_insulation_mode() -> None:
assert predicted.sap_building_parts[0].roof_insulation_thickness == 300
def test_categorical_mode_leans_on_size_similar_neighbours() -> None:
# Arrange — a count majority (three) carries wall-insulation 9, but two of
# them are 400 m² size outliers; the cohort centre (median 100 m²) holds
# wall-insulation 1. Physical-similarity weighting down-weights the outliers,
# so the size-representative value 1 wins over the plain-count majority 9.
cohort = _cohort(
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the size-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_categorical_mode_leans_on_age_similar_neighbours() -> None:
# Arrange — same size throughout (so size weighting is neutral). A count
# majority (three) carries wall-insulation 9, but two of them are age-band A
# outliers while the cohort's modal band is K. Age-similarity weighting
# down-weights the outliers, so the band-representative value 1 wins.
cohort = _cohort(
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the age-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_confidence_reports_cohort_size_and_unanimous_agreement() -> None:
# Arrange — a unanimous cohort: three neighbours, all cavity-walled (1).
cohort = _cohort(