mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Make the leave-one-out runner ADR-0030-compliant: - Hold out only SAP 10.2 targets (sap_version == 10.2) — the source cohort keeps every vintage (components are methodology-agnostic). - Label Component Accuracy as the PRIMARY, calculator-independent section. - End-to-end vs API-lodged (SECONDARY, calculator-FLOORED): add CO2 (tonnes) and PEI (kWh/m2) alongside SAP, using the canonical performance.py mapping (co2_kg/1000; primary_energy_kwh_per_m2). - Add the attribution readout calc(actual) vs lodged SAP — the calculator floor the end-to-end can reach. - Drop the neighbour-mean-of-lodged-SAP baseline (mixes SAP versions — rejected by ADR-0030). On the 181 SAP-10.2 targets: component rates are higher than the all-vintage view (age band 60.9 -> 78.5%, floor_area mean|.| 12.7 -> 8.4). End-to-end SAP MAE 6.34 vs the calc(actual) floor of 3.25 — ~half the gap is the known API-path calculator residual, not prediction error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""Leave-one-out accuracy harness for EPC Prediction (ADR-0029).
|
||
|
||
Runs entirely against the frozen postcode-clustered corpus
|
||
(`fetch_epc_prediction_corpus.py`). For every cert that has neighbours, it
|
||
drops that cert from its postcode cohort, predicts it from the rest using only
|
||
its *guaranteed* inputs (property type + built form), and compares the predicted
|
||
`EpcPropertyData` to the actual one.
|
||
|
||
Reports the ADR-0029 metrics:
|
||
- classification rate: main wall construction (extend as coverage grows);
|
||
- geometry residuals: floor area, window count + total window area, building
|
||
parts (mean signed + mean absolute);
|
||
- SAP reported three ways — predicted-then-calculated vs (a) the actual lodged
|
||
SAP, (b) the calculator on the actual components, (c) the neighbour-mean SAP
|
||
baseline (the number predict-then-calculate must beat).
|
||
|
||
USAGE
|
||
-----
|
||
PYTHONPATH=. python scripts/validate_epc_prediction.py
|
||
|
||
Corpus dir: $EPC_PREDICTION_CORPUS (default /tmp/epc_prediction_corpus).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import statistics
|
||
from datetime import date
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||
from domain.epc_prediction.comparable_properties import (
|
||
Comparable,
|
||
PredictionTarget,
|
||
select_comparables,
|
||
)
|
||
from domain.epc_prediction.epc_prediction import EpcPrediction
|
||
from domain.epc_prediction.prediction_comparison import compare_prediction
|
||
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
|
||
|
||
# Target-cert spec gate: only SAP 10.2 certs (schema 21.0.x) carry full-fidelity
|
||
# lodged components + a same-spec lodged figure to check against (ADR-0030). The
|
||
# source cohort keeps all vintages — components are methodology-agnostic.
|
||
_SAP_10_2: float = 10.2
|
||
_KG_PER_TONNE: float = 1000.0
|
||
|
||
CORPUS = Path(os.environ.get("EPC_PREDICTION_CORPUS", "/tmp/epc_prediction_corpus"))
|
||
|
||
|
||
def _load_cohort(postcode: str, certs: list[str]) -> list[Comparable]:
|
||
"""Map a postcode's cached cert payloads to Comparables, skipping any the
|
||
mapper rejects (unsupported schema, malformed). Address + registration date
|
||
come straight off the cached payload (the register metadata) so the harness
|
||
can dedupe re-lodgements and hold out a whole address."""
|
||
cohort: list[Comparable] = []
|
||
for cert in certs:
|
||
path = CORPUS / postcode / f"{cert}.json"
|
||
if not path.exists():
|
||
continue
|
||
raw = json.loads(path.read_text())
|
||
try:
|
||
epc = EpcPropertyDataMapper.from_api_response(raw)
|
||
except Exception: # noqa: BLE001 — a bad cert must not abort the sweep
|
||
continue
|
||
cohort.append(
|
||
Comparable(
|
||
epc=epc,
|
||
certificate_number=cert,
|
||
address=_address(raw),
|
||
registration_date=_registration_date(raw),
|
||
)
|
||
)
|
||
return cohort
|
||
|
||
|
||
def _address(raw: dict[str, object]) -> Optional[str]:
|
||
value = raw.get("address_line_1")
|
||
return str(value).strip().upper() if value else None
|
||
|
||
|
||
def _registration_date(raw: dict[str, object]) -> Optional[date]:
|
||
value = raw.get("registration_date")
|
||
return date.fromisoformat(str(value)) if value else None
|
||
|
||
|
||
def _ground_truth_properties(cohort: list[Comparable]) -> list[Comparable]:
|
||
"""Collapse a postcode's certs to one held-out property per address — the
|
||
latest cert, the best ground truth. Comparables with no address each stand
|
||
alone."""
|
||
latest: dict[str, Comparable] = {}
|
||
standalone: list[Comparable] = []
|
||
for c in cohort:
|
||
if c.address is None:
|
||
standalone.append(c)
|
||
elif c.address not in latest or _recency(c) > _recency(latest[c.address]):
|
||
latest[c.address] = c
|
||
return list(latest.values()) + standalone
|
||
|
||
|
||
def _recency(comparable: Comparable) -> tuple[date, str]:
|
||
return (
|
||
comparable.registration_date or date.min,
|
||
comparable.certificate_number,
|
||
)
|
||
|
||
|
||
def _result(
|
||
calculator: Sap10Calculator, epc: EpcPropertyData
|
||
) -> Optional[SapResult]:
|
||
try:
|
||
return calculator.calculate(epc)
|
||
except Exception: # noqa: BLE001 — some pictures don't score; count as misses
|
||
return None
|
||
|
||
|
||
def _co2_tonnes(result: SapResult) -> float:
|
||
"""Calculated annual CO2 in tonnes, matching the lodged `co2_emissions_current`
|
||
scale (see domain/property_baseline/performance.py)."""
|
||
return result.co2_kg_per_yr / _KG_PER_TONNE
|
||
|
||
|
||
def main() -> None:
|
||
index_path = CORPUS / "_index.json"
|
||
if not index_path.exists():
|
||
raise SystemExit(f"no corpus at {CORPUS} — run fetch_epc_prediction_corpus.py")
|
||
index: dict[str, list[str]] = json.loads(index_path.read_text())
|
||
|
||
calculator = Sap10Calculator()
|
||
predictor = EpcPrediction()
|
||
|
||
# Classification: name -> [hits, applicable-total], populated from whatever
|
||
# components compare_prediction reports (insertion order preserved). A None
|
||
# hit (the actual lodges no value) is excluded from the denominator.
|
||
categoricals: dict[str, list[int]] = {}
|
||
floor_res: list[float] = []
|
||
window_count_res: list[int] = []
|
||
window_area_res: list[float] = []
|
||
parts_res: list[int] = []
|
||
door_res: list[int] = []
|
||
# End-to-end (calculator-FLOORED) vs API-lodged — secondary guard, ADR-0030.
|
||
sap_vs_lodged: list[float] = []
|
||
co2_vs_lodged: list[float] = []
|
||
pei_vs_lodged: list[float] = []
|
||
# Attribution readout: how far the calculator alone is from lodged on the
|
||
# ACTUAL components — the floor the end-to-end numbers can reach.
|
||
sap_calc_actual_vs_lodged: list[float] = []
|
||
predicted_n = skipped_non_102 = skipped_no_cohort = 0
|
||
|
||
for postcode, certs in index.items():
|
||
cohort = _load_cohort(postcode, certs)
|
||
targets = _ground_truth_properties(cohort)
|
||
if len(targets) < 2:
|
||
skipped_no_cohort += len(targets)
|
||
continue
|
||
for held_out in targets:
|
||
# Only SAP 10.2 certs are valid validation targets (ADR-0030); the
|
||
# source cohort (`others`) keeps every vintage.
|
||
if held_out.epc.sap_version != _SAP_10_2:
|
||
skipped_non_102 += 1
|
||
continue
|
||
# Exclude every cert of the held-out address (not just the held cert)
|
||
# so a re-lodgement of the same property cannot leak into the cohort.
|
||
others = [
|
||
c
|
||
for c in cohort
|
||
if c.address is None or c.address != held_out.address
|
||
]
|
||
actual = held_out.epc
|
||
target = PredictionTarget(
|
||
postcode=postcode,
|
||
property_type=actual.property_type or "",
|
||
built_form=actual.built_form,
|
||
)
|
||
comparables = select_comparables(target, others)
|
||
if not comparables.members:
|
||
continue
|
||
predicted = predictor.predict(target, comparables)
|
||
predicted_n += 1
|
||
|
||
cmp = compare_prediction(predicted, actual)
|
||
for name, hit in cmp.categorical_hits.items():
|
||
_tally(categoricals.setdefault(name, [0, 0]), hit)
|
||
floor_res.append(cmp.floor_area_residual)
|
||
window_count_res.append(cmp.window_count_residual)
|
||
window_area_res.append(cmp.total_window_area_residual)
|
||
parts_res.append(cmp.building_parts_residual)
|
||
door_res.append(cmp.door_count_residual)
|
||
|
||
pred_result = _result(calculator, predicted)
|
||
actual_result = _result(calculator, actual)
|
||
lodged_sap = actual.energy_rating_current
|
||
lodged_co2 = actual.co2_emissions_current
|
||
lodged_pei = actual.energy_consumption_current
|
||
if pred_result is not None:
|
||
if lodged_sap is not None:
|
||
sap_vs_lodged.append(
|
||
abs(pred_result.sap_score_continuous - lodged_sap)
|
||
)
|
||
if lodged_co2 is not None:
|
||
co2_vs_lodged.append(
|
||
abs(_co2_tonnes(pred_result) - lodged_co2)
|
||
)
|
||
if lodged_pei is not None:
|
||
pei_vs_lodged.append(
|
||
abs(pred_result.primary_energy_kwh_per_m2 - lodged_pei)
|
||
)
|
||
if actual_result is not None and lodged_sap is not None:
|
||
sap_calc_actual_vs_lodged.append(
|
||
abs(actual_result.sap_score_continuous - lodged_sap)
|
||
)
|
||
|
||
print(f"corpus: {CORPUS}")
|
||
print(
|
||
f"predicted {predicted_n} SAP-10.2 held-out targets "
|
||
f"({skipped_non_102} non-10.2 targets skipped, "
|
||
f"{skipped_no_cohort} had no cohort)\n"
|
||
)
|
||
print("--- Component Accuracy (PRIMARY, calculator-independent) ---")
|
||
for name, (hits, total) in categoricals.items():
|
||
if total:
|
||
print(f"CLASSIFICATION {name}: {hits}/{total} = {hits / total:.1%}")
|
||
print()
|
||
_residual("floor_area (m2)", floor_res)
|
||
_residual("window_count", [float(x) for x in window_count_res])
|
||
_residual("total_window_area (m2)", window_area_res)
|
||
_residual("building_parts", [float(x) for x in parts_res])
|
||
_residual("door_count", [float(x) for x in door_res])
|
||
print()
|
||
print("--- End-to-end vs API-lodged (SECONDARY, calculator-FLOORED) ---")
|
||
_sap_line("SAP |pred − lodged|", sap_vs_lodged)
|
||
_sap_line("CO2 (t) |pred − lodged|", co2_vs_lodged)
|
||
_sap_line("PEI (kWh/m2) |pred − lodged|", pei_vs_lodged)
|
||
_sap_line(" floor: SAP |calc(actual) − lodged|", sap_calc_actual_vs_lodged)
|
||
|
||
|
||
def _tally(counter: list[int], hit: Optional[bool]) -> None:
|
||
"""Record one classification outcome: a None hit (actual absent) is not
|
||
applicable and skipped; else increment the applicable total and the hits."""
|
||
if hit is None:
|
||
return
|
||
counter[1] += 1
|
||
counter[0] += int(hit)
|
||
|
||
|
||
def _residual(label: str, values: list[float]) -> None:
|
||
if not values:
|
||
print(f"RESIDUAL {label}: (none)")
|
||
return
|
||
mean_signed = statistics.mean(values)
|
||
mean_abs = statistics.mean(abs(v) for v in values)
|
||
print(f"RESIDUAL {label}: mean {mean_signed:+.2f} | mean|·| {mean_abs:.2f} "
|
||
f"(n={len(values)})")
|
||
|
||
|
||
def _sap_line(label: str, values: list[float]) -> None:
|
||
if not values:
|
||
print(f"{label}: (none)")
|
||
return
|
||
print(f"{label}: MAE {statistics.mean(values):.2f} | "
|
||
f"median {statistics.median(values):.2f} (n={len(values)})")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|