Model/domain/epc_prediction/prediction_comparison.py
Khalim Conn-Kowlessar cd43c52cf9 feat(epc-prediction): score the heating components (ADR-0030 Component Accuracy)
Heating is the dominant SAP lever (ablating it to actual cut the SAP error
~7 -> ~4.5) yet was entirely unscored. Add the heating group to
compare_prediction's categorical_hits: main fuel / category / control (off
the primary MainHeatingDetail), water-heating fuel / code, has-cylinder,
cylinder insulation, secondary heating (off SapHeating).

Template-copied baseline on the 40-postcode corpus (no predictor change
yet — this just makes the signal visible):
  heating_main_fuel        93.4%
  heating_main_category    92.7%
  water_heating_fuel/code  91.7% / 92.4%
  heating_main_control     62.1%   <- weak
  has_hot_water_cylinder   78.5%
  cylinder_insulation_type 35.8% (n=120)   <- weak
  secondary_heating_type   16.8% (n=125)   <- weak

Fuel/category predict well from the template; controls, cylinder, and
secondary heating are poor and now drive the next predictor slices.

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

152 lines
5.7 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.

"""Per-Property prediction comparison for the EPC Prediction validation harness
(ADR-0029).
`compare_prediction` scores a predicted `EpcPropertyData` against the actual one
on the accuracy signals the leave-one-out harness aggregates: classification
matches on the key categoricals (wall / roof / floor construction + insulation,
construction age band) and residuals on the geometry (window area + count,
building-parts count, floor area). Pure — the SAP residual is computed in the
runner, which has the calculator and the lodged SAP.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
SapBuildingPart,
)
@dataclass(frozen=True)
class PredictionComparison:
"""One Property's prediction accuracy: per-component classification hits +
geometry residuals (predicted actual). `categorical_hits` maps a component
name to its hit: True / False, or `None` ("not applicable") when the actual
lodges no value there, so the harness can keep it out of the
classification-rate denominator rather than score a free win. Keyed by name
(not flat fields) so the component set can grow without reshaping the
runner — see ADR-0030 Component Accuracy."""
categorical_hits: dict[str, Optional[bool]]
floor_area_residual: float
building_parts_residual: int
window_count_residual: int
total_window_area_residual: float
def _main(epc: EpcPropertyData) -> SapBuildingPart:
return epc.sap_building_parts[0]
def _main_floor_construction(epc: EpcPropertyData) -> Optional[int]:
"""The main building part's ground-floor construction code, or None when no
floor dimension is lodged."""
dims = _main(epc).sap_floor_dimensions
return dims[0].floor_construction if dims else None
def _classify(predicted: object, actual: object) -> Optional[bool]:
"""A categorical hit: None ("not applicable") when the actual is absent,
else whether the predicted value matches it."""
if actual is None:
return None
return predicted == actual
def _main_heating_detail(epc: EpcPropertyData) -> Optional[MainHeatingDetail]:
"""The primary heating system's detail row, or None when none is lodged."""
details = epc.sap_heating.main_heating_details
return details[0] if details else None
def _heating_hits(
predicted: EpcPropertyData, actual: EpcPropertyData
) -> dict[str, Optional[bool]]:
"""Classification hits for the heating components — the dominant SAP lever
(ADR-0030). Main-system fields come off the primary `MainHeatingDetail`;
hot-water + secondary fields off `SapHeating`."""
pred_main = _main_heating_detail(predicted)
actual_main = _main_heating_detail(actual)
pred_h = predicted.sap_heating
actual_h = actual.sap_heating
return {
"heating_main_fuel": _classify(
getattr(pred_main, "main_fuel_type", None),
getattr(actual_main, "main_fuel_type", None),
),
"heating_main_category": _classify(
getattr(pred_main, "main_heating_category", None),
getattr(actual_main, "main_heating_category", None),
),
"heating_main_control": _classify(
getattr(pred_main, "main_heating_control", None),
getattr(actual_main, "main_heating_control", None),
),
"water_heating_fuel": _classify(
pred_h.water_heating_fuel, actual_h.water_heating_fuel
),
"water_heating_code": _classify(
pred_h.water_heating_code, actual_h.water_heating_code
),
"has_hot_water_cylinder": _classify(
predicted.has_hot_water_cylinder, actual.has_hot_water_cylinder
),
"cylinder_insulation_type": _classify(
pred_h.cylinder_insulation_type, actual_h.cylinder_insulation_type
),
"secondary_heating_type": _classify(
pred_h.secondary_heating_type, actual_h.secondary_heating_type
),
}
def _total_window_area(epc: EpcPropertyData) -> float:
return sum(w.window_width * w.window_height for w in epc.sap_windows)
def compare_prediction(
predicted: EpcPropertyData, actual: EpcPropertyData
) -> PredictionComparison:
"""Compare a predicted picture against the actual one, field by field. All
residuals are signed, predicted actual."""
fabric_hits: dict[str, Optional[bool]] = {
"wall_construction": _classify(
_main(predicted).wall_construction,
_main(actual).wall_construction,
),
"wall_insulation_type": _classify(
_main(predicted).wall_insulation_type,
_main(actual).wall_insulation_type,
),
"construction_age_band": _classify(
_main(predicted).construction_age_band,
_main(actual).construction_age_band,
),
"roof_construction": _classify(
_main(predicted).roof_construction,
_main(actual).roof_construction,
),
"floor_construction": _classify(
_main_floor_construction(predicted),
_main_floor_construction(actual),
),
}
return PredictionComparison(
categorical_hits={**fabric_hits, **_heating_hits(predicted, actual)},
floor_area_residual=(
predicted.total_floor_area_m2 - actual.total_floor_area_m2
),
building_parts_residual=(
len(predicted.sap_building_parts) - len(actual.sap_building_parts)
),
window_count_residual=(
len(predicted.sap_windows) - len(actual.sap_windows)
),
total_window_area_residual=(
_total_window_area(predicted) - _total_window_area(actual)
),
)