Model/scripts/validate_epc_prediction.py
Khalim Conn-Kowlessar f3ad6343a3 feat(epc-prediction): leave-one-out validation harness (ADR-0029)
Pure compare_prediction (TDD): wall-construction classification hit + signed
residuals on floor area, window count, total window area, building-parts count.
Plus validate_epc_prediction.py (IO plumbing): drops each cert from its postcode
cohort, predicts from the rest on guaranteed inputs only, aggregates the metrics,
and reports SAP three ways (pred-calc vs lodged / vs calc-on-actual / vs the
neighbour-mean baseline). Smoke run: wall 90.9%, floor-area mean|·| 42.6 m2 (a
real signal — template-copied floor area is noisy), SAP pred-calc edges baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:55:05 +00:00

165 lines
6.4 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.

"""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 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
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)."""
cohort: list[Comparable] = []
for cert in certs:
path = CORPUS / postcode / f"{cert}.json"
if not path.exists():
continue
try:
epc = EpcPropertyDataMapper.from_api_response(json.loads(path.read_text()))
except Exception: # noqa: BLE001 — a bad cert must not abort the sweep
continue
cohort.append(Comparable(epc=epc, certificate_number=cert))
return cohort
def _sap(calculator: Sap10Calculator, epc: EpcPropertyData) -> Optional[float]:
try:
return calculator.calculate(epc).sap_score_continuous
except Exception: # noqa: BLE001 — some pictures don't score; count as misses
return None
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()
wall_hits = wall_total = 0
floor_res: list[float] = []
window_count_res: list[int] = []
window_area_res: list[float] = []
parts_res: list[int] = []
sap_vs_lodged: list[float] = []
sap_vs_calc_actual: list[float] = []
sap_vs_neighbour_mean: list[float] = []
predicted_n = skipped_no_cohort = 0
for postcode, certs in index.items():
cohort = _load_cohort(postcode, certs)
if len(cohort) < 2:
skipped_no_cohort += len(cohort)
continue
for i, held_out in enumerate(cohort):
others = [c for j, c in enumerate(cohort) if j != i]
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)
wall_total += 1
wall_hits += int(cmp.wall_construction_correct)
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)
sap_pred = _sap(calculator, predicted)
lodged = actual.energy_rating_current
if sap_pred is not None and lodged is not None:
sap_vs_lodged.append(abs(sap_pred - lodged))
sap_actual = _sap(calculator, actual)
if sap_pred is not None and sap_actual is not None:
sap_vs_calc_actual.append(abs(sap_pred - sap_actual))
neighbour_lodged = [
c.epc.energy_rating_current
for c in comparables.members
if c.epc.energy_rating_current is not None
]
if neighbour_lodged and lodged is not None:
baseline = statistics.mean(neighbour_lodged)
sap_vs_neighbour_mean.append(abs(baseline - lodged))
print(f"corpus: {CORPUS}")
print(f"predicted {predicted_n} held-out certs ({skipped_no_cohort} had no cohort)\n")
if wall_total:
print(f"CLASSIFICATION wall_construction: {wall_hits}/{wall_total} = "
f"{wall_hits / wall_total:.1%}")
_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])
print()
_sap_line("SAP |pred-calc lodged|", sap_vs_lodged)
_sap_line("SAP |pred-calc calc(actual)|", sap_vs_calc_actual)
_sap_line("SAP |neighbour-mean lodged| (baseline)", sap_vs_neighbour_mean)
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()