mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
feat(epc-prediction): recency-weighted glazing mode (#1223)
Per-component method: glazing type is now the recency-weighted cohort mode applied to every predicted window, rather than copied from the template. Glazing is retrofitted over a dwelling's life (single -> double), so a recent neighbour reflects the current state — same family as roof-insulation thickness. Recency is the CORRECT weighting here: plain moding regressed the fixture (-5.6pp) and was previously reverted; similarity weighting also regressed it; recency improves BOTH (window geometry stays on the template, only the glazing categorical moves). modal_glazing_type: corpus (150pc/514) 60.7 -> 66.7% (+6.0pp); fixture 0.5000 -> 0.5278 (floor ratcheted up). Heating, geometry residuals and all other components unchanged. Refactored _recency_weighted_mode to a reusable _recency_weighted_choice(value_of) shared by roof insulation + glazing. Closes the #1223 per-component approach: floor-area (median estimate) + glazing (recency) shipped as distinct best-fit methods rather than a global recency template, which would have disturbed the coherence-coupled heating cluster. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
51cdc25ce8
commit
d762b25808
3 changed files with 74 additions and 10 deletions
|
|
@ -15,7 +15,7 @@ import statistics
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Iterable, Optional, Union
|
from typing import Callable, Iterable, Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
|
|
@ -64,9 +64,28 @@ class EpcPrediction:
|
||||||
predicted: EpcPropertyData = copy.deepcopy(template.epc)
|
predicted: EpcPropertyData = copy.deepcopy(template.epc)
|
||||||
predicted.total_floor_area_m2 = _median_floor_area(comparables.members)
|
predicted.total_floor_area_m2 = _median_floor_area(comparables.members)
|
||||||
self._apply_categorical_modes(predicted, comparables)
|
self._apply_categorical_modes(predicted, comparables)
|
||||||
|
self._apply_glazing_mode(predicted, comparables)
|
||||||
self._apply_overrides(predicted, target)
|
self._apply_overrides(predicted, target)
|
||||||
return predicted
|
return predicted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_glazing_mode(
|
||||||
|
predicted: EpcPropertyData, comparables: ComparableProperties
|
||||||
|
) -> None:
|
||||||
|
"""Set every window's glazing type to the recency-weighted cohort mode.
|
||||||
|
Glazing is retrofitted over a dwelling's life (single → double), so a
|
||||||
|
recent neighbour reflects the current state — its correct method is the
|
||||||
|
recency-weighted mode (like roof insulation), NOT the plain mode (which
|
||||||
|
regressed) or the template copy. The window geometry (size, count) is
|
||||||
|
left on the template; only the glazing categorical moves."""
|
||||||
|
glazing = _recency_weighted_choice(
|
||||||
|
comparables.members, _comparable_modal_glazing
|
||||||
|
)
|
||||||
|
if glazing is None:
|
||||||
|
return
|
||||||
|
for window in predicted.sap_windows:
|
||||||
|
window.glazing_type = glazing
|
||||||
|
|
||||||
def confidence(
|
def confidence(
|
||||||
self, comparables: ComparableProperties
|
self, comparables: ComparableProperties
|
||||||
) -> PredictionConfidence:
|
) -> PredictionConfidence:
|
||||||
|
|
@ -305,20 +324,23 @@ def _modal_share(
|
||||||
return modal_count / len(present)
|
return modal_count / len(present)
|
||||||
|
|
||||||
|
|
||||||
def _recency_weighted_mode(
|
def _recency_weighted_choice(
|
||||||
members: tuple[Comparable, ...], attr: str
|
members: tuple[Comparable, ...],
|
||||||
|
value_of: Callable[[Comparable], Optional[Union[int, str]]],
|
||||||
) -> Optional[Union[int, str]]:
|
) -> Optional[Union[int, str]]:
|
||||||
"""The cohort mode of a main-part attribute, weighting each comparable's vote
|
"""The recency-weighted cohort mode of a per-comparable value: each
|
||||||
by recency — an exponential decay in the cert's age relative to the newest in
|
neighbour's vote decays exponentially with the cert's age relative to the
|
||||||
the cohort. Newer neighbours dominate, so a stale majority can't outvote the
|
newest in the cohort, so newer neighbours dominate and a stale majority can't
|
||||||
current state. Falls back to a plain mode when no registration dates are
|
outvote the current state. Falls back to a plain mode when no registration
|
||||||
lodged (all ages 0 ⇒ equal weight)."""
|
dates are lodged (all ages 0 ⇒ equal weight). Returns None when no comparable
|
||||||
|
supplies a value. Used for the time-varying components — those upgraded over a
|
||||||
|
dwelling's life (loft top-ups, glazing retrofits)."""
|
||||||
newest: date = max(
|
newest: date = max(
|
||||||
(c.registration_date or date.min for c in members), default=date.min
|
(c.registration_date or date.min for c in members), default=date.min
|
||||||
)
|
)
|
||||||
weights: dict[Union[int, str], float] = defaultdict(float)
|
weights: dict[Union[int, str], float] = defaultdict(float)
|
||||||
for comparable in members:
|
for comparable in members:
|
||||||
value = _main_part_attr(comparable, attr)
|
value = value_of(comparable)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
lodged: date = comparable.registration_date or date.min
|
lodged: date = comparable.registration_date or date.min
|
||||||
|
|
@ -327,3 +349,22 @@ def _recency_weighted_mode(
|
||||||
if not weights:
|
if not weights:
|
||||||
return None
|
return None
|
||||||
return max(weights, key=lambda value: weights[value])
|
return max(weights, key=lambda value: weights[value])
|
||||||
|
|
||||||
|
|
||||||
|
def _recency_weighted_mode(
|
||||||
|
members: tuple[Comparable, ...], attr: str
|
||||||
|
) -> Optional[Union[int, str]]:
|
||||||
|
"""`_recency_weighted_choice` over a main building-part attribute."""
|
||||||
|
return _recency_weighted_choice(
|
||||||
|
members, lambda comparable: _main_part_attr(comparable, attr)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparable_modal_glazing(
|
||||||
|
comparable: Comparable,
|
||||||
|
) -> Optional[Union[int, str]]:
|
||||||
|
"""A comparable's modal glazing type — the most common across its windows, or
|
||||||
|
None when it lodges none. One glazing signal per neighbour, robust to a single
|
||||||
|
odd window, matching how the harness scores `modal_glazing_type`."""
|
||||||
|
types = [window.glazing_type for window in comparable.epc.sap_windows]
|
||||||
|
return Counter(types).most_common(1)[0][0] if types else None
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ _RATE_FLOORS: dict[str, float] = {
|
||||||
"roof_insulation_thickness": 0.4118,
|
"roof_insulation_thickness": 0.4118,
|
||||||
"floor_insulation": 0.9375,
|
"floor_insulation": 0.9375,
|
||||||
"has_room_in_roof": 0.8333,
|
"has_room_in_roof": 0.8333,
|
||||||
"modal_glazing_type": 0.5000,
|
"modal_glazing_type": 0.5278,
|
||||||
"has_pv": 1.0000,
|
"has_pv": 1.0000,
|
||||||
"solar_water_heating": 1.0000,
|
"solar_water_heating": 1.0000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,29 @@ def test_confidence_excludes_absent_component_values_from_the_denominator() -> N
|
||||||
assert confidence.cohort_size == 3
|
assert confidence.cohort_size == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_glazing_follows_the_recency_weighted_cohort_mode() -> None:
|
||||||
|
# Arrange — an old majority single-glazed (type 1, 2015) and a recent
|
||||||
|
# minority double-glazed (type 3, 2025). Glazing is retrofitted over time
|
||||||
|
# (single → double), so the recent neighbours reflect the current state: the
|
||||||
|
# recency-weighted mode must pick double over the stale single-glazed
|
||||||
|
# majority, like roof insulation thickness.
|
||||||
|
cohort = _dated_cohort(
|
||||||
|
(_epc(glazing_type=1), date(2015, 1, 1)),
|
||||||
|
(_epc(glazing_type=1), date(2015, 1, 1)),
|
||||||
|
(_epc(glazing_type=1), date(2015, 1, 1)),
|
||||||
|
(_epc(glazing_type=3), date(2025, 1, 1)),
|
||||||
|
(_epc(glazing_type=3), date(2025, 1, 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
predicted: EpcPropertyData = EpcPrediction().predict(
|
||||||
|
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — every predicted window takes the recent glazing over the majority.
|
||||||
|
assert all(window.glazing_type == 3 for window in predicted.sap_windows)
|
||||||
|
|
||||||
|
|
||||||
def test_applies_a_known_wall_override_over_the_mode() -> None:
|
def test_applies_a_known_wall_override_over_the_mode() -> None:
|
||||||
# Arrange — the cohort mode is cavity (1), but we KNOW the target is solid
|
# Arrange — the cohort mode is cavity (1), but we KNOW the target is solid
|
||||||
# brick (2), a Landlord Override. The known value must win over the estimate.
|
# brick (2), a Landlord Override. The known value must win over the estimate.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue