mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Two review points from @dancafc: 1) Rename the `Comparable` dataclass → `ComparableProperty` (it models one comparable *property*; the collection stays `ComparableProperties`). Applied across domain, repositories, orchestration, harness, scripts, and tests with a word-boundary rename so `ComparableProperties` is untouched. 2) Move `PredictionTarget` out of comparable_properties.py into prediction_target.py (where `PredictionTargetAttributes` + `build_prediction_target` already live). comparable_properties.py now imports it; no import cycle (prediction_target no longer depends on comparable_properties). Importers updated. 92 tests pass across the touched suites; pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""Component Accuracy aggregation for EPC Prediction (ADR-0030).
|
||
|
||
The leave-one-out scorer, calculator-FREE on purpose: it holds out each SAP 10.2
|
||
target, predicts it from its (all-vintage) ComparableProperty Properties, and aggregates
|
||
the per-component classification hits + geometry residuals from
|
||
`compare_prediction`. This is the *primary*, calculator-independent signal — the
|
||
end-to-end SAP / carbon / PE check (which needs the calculator) is layered on top
|
||
by the runner. The same function backs both the committed ratcheting gate and the
|
||
offline national battle-test (one scorer, two harnesses).
|
||
|
||
Pure given the loaded cohorts: corpus IO (reading + mapping cert payloads) is the
|
||
caller's job, so this is directly unit-testable.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import date
|
||
from typing import Iterable, Iterator, Optional, Sequence
|
||
|
||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||
from domain.epc_prediction.comparable_properties import (
|
||
ComparableProperty,
|
||
select_comparables,
|
||
)
|
||
from domain.epc_prediction.epc_prediction import EpcPrediction
|
||
from domain.epc_prediction.prediction_comparison import compare_prediction
|
||
from domain.epc_prediction.prediction_target import PredictionTarget
|
||
|
||
# Only SAP 10.2 certs are valid held-out targets (ADR-0030) — the only vintage
|
||
# with full-fidelity lodged components. The source cohort keeps all vintages.
|
||
_SAP_10_2: float = 10.2
|
||
|
||
|
||
def _empty_classification() -> dict[str, list[int]]:
|
||
return {}
|
||
|
||
|
||
def _empty_residuals() -> dict[str, list[float]]:
|
||
return {}
|
||
|
||
|
||
@dataclass
|
||
class ComponentAccuracy:
|
||
"""Aggregated leave-one-out Component Accuracy over a corpus.
|
||
|
||
`classification` maps a component name to [hits, applicable-total] (a
|
||
not-applicable `None` hit is excluded from the total); `residuals` maps a
|
||
numeric component to its signed (predicted − actual) values. `targets` counts
|
||
the held-out SAP 10.2 properties scored.
|
||
"""
|
||
|
||
classification: dict[str, list[int]] = field(
|
||
default_factory=_empty_classification
|
||
)
|
||
residuals: dict[str, list[float]] = field(default_factory=_empty_residuals)
|
||
targets: int = 0
|
||
|
||
def rate(self, component: str) -> Optional[float]:
|
||
"""The classification hit-rate for a component, or None when nothing was
|
||
applicable."""
|
||
hits, total = self.classification.get(component, [0, 0])
|
||
return hits / total if total else None
|
||
|
||
def mean_abs_residual(self, component: str) -> Optional[float]:
|
||
"""Mean absolute residual for a numeric component, or None when empty."""
|
||
values = self.residuals.get(component, [])
|
||
return sum(abs(v) for v in values) / len(values) if values else None
|
||
|
||
|
||
def _recency_key(comparable: ComparableProperty) -> tuple[date, str]:
|
||
return (
|
||
comparable.registration_date or date.min,
|
||
comparable.certificate_number,
|
||
)
|
||
|
||
|
||
def _latest_per_address(cohort: Sequence[ComparableProperty]) -> list[ComparableProperty]:
|
||
"""One held-out property per address — the latest cert, the best ground
|
||
truth. Comparables with no address each stand alone."""
|
||
latest: dict[str, ComparableProperty] = {}
|
||
standalone: list[ComparableProperty] = []
|
||
for c in cohort:
|
||
if c.address is None:
|
||
standalone.append(c)
|
||
elif c.address not in latest or _recency_key(c) > _recency_key(
|
||
latest[c.address]
|
||
):
|
||
latest[c.address] = c
|
||
return list(latest.values()) + standalone
|
||
|
||
|
||
def iter_predictions(
|
||
cohorts: Iterable[Sequence[ComparableProperty]],
|
||
*,
|
||
target_sap_version: float = _SAP_10_2,
|
||
) -> Iterator[tuple[EpcPropertyData, EpcPropertyData]]:
|
||
"""Yield `(predicted, actual)` for every SAP-`target_sap_version` held-out
|
||
target across the cohorts — the single leave-one-out orchestration the
|
||
Component Accuracy scorer and the runner's calculator end-to-end both consume
|
||
(ADR-0030: one scorer, two harnesses). A target is held out by whole address
|
||
(so a re-lodgement can't leak) and predicted from its all-vintage cohort."""
|
||
predictor = EpcPrediction()
|
||
for cohort in cohorts:
|
||
for held_out in _latest_per_address(cohort):
|
||
if held_out.epc.sap_version != target_sap_version:
|
||
continue
|
||
others = [
|
||
c
|
||
for c in cohort
|
||
if c.address is None or c.address != held_out.address
|
||
]
|
||
actual = held_out.epc
|
||
target = PredictionTarget(
|
||
postcode=actual.postcode,
|
||
property_type=actual.property_type or "",
|
||
built_form=actual.built_form,
|
||
coordinates=held_out.coordinates,
|
||
)
|
||
comparables = select_comparables(target, others)
|
||
if not comparables.members:
|
||
continue
|
||
yield predictor.predict(target, comparables), actual
|
||
|
||
|
||
def evaluate_component_accuracy(
|
||
cohorts: Iterable[Sequence[ComparableProperty]],
|
||
*,
|
||
target_sap_version: float = _SAP_10_2,
|
||
) -> ComponentAccuracy:
|
||
"""Score Component Accuracy by leave-one-out over each postcode cohort —
|
||
aggregating the `compare_prediction` hits + residuals across every held-out
|
||
SAP-`target_sap_version` target. Calculator-free (the primary signal)."""
|
||
accuracy = ComponentAccuracy()
|
||
for predicted, actual in iter_predictions(
|
||
cohorts, target_sap_version=target_sap_version
|
||
):
|
||
comparison = compare_prediction(predicted, actual)
|
||
accuracy.targets += 1
|
||
for name, hit in comparison.categorical_hits.items():
|
||
counter = accuracy.classification.setdefault(name, [0, 0])
|
||
if hit is not None:
|
||
counter[1] += 1
|
||
counter[0] += int(hit)
|
||
accuracy.residuals.setdefault("floor_area", []).append(
|
||
comparison.floor_area_residual
|
||
)
|
||
accuracy.residuals.setdefault("window_count", []).append(
|
||
float(comparison.window_count_residual)
|
||
)
|
||
accuracy.residuals.setdefault("total_window_area", []).append(
|
||
comparison.total_window_area_residual
|
||
)
|
||
accuracy.residuals.setdefault("building_parts", []).append(
|
||
float(comparison.building_parts_residual)
|
||
)
|
||
accuracy.residuals.setdefault("door_count", []).append(
|
||
float(comparison.door_count_residual)
|
||
)
|
||
return accuracy
|