Model/scripts/validate_epc_prediction.py
Khalim Conn-Kowlessar ed96df9315 feat(epc-prediction): classify roof/floor/insulation/age categoricals (ADR-0029)
The comparison only scored main wall_construction; everything else the
predictor produces (by template-copy) went unmeasured. Extend
compare_prediction to the rest of the ADR-0029 homogeneous categoricals —
wall insulation type, construction age band, roof construction, floor
construction — and aggregate per-categorical classification rates in the
runner. A categorical hit is "not applicable" (None, excluded from the
denominator) when the actual lodges no value, so absent-roof flats don't
score free wins.

Smoke corpus (29 leave-one-out, all but wall are template-copied today):
  wall_construction      93.1%
  wall_insulation_type   93.1%
  construction_age_band  55.2%   <- loud; candidate for cohort-mode
  roof_construction      72.4%
  floor_construction     46.2%   (n=13)

These numbers drive the next slice (extend cohort-mode coverage).

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

194 lines
7.5 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()
# Classification: name -> [hits, applicable-total]. A None hit (the actual
# lodges no value) is excluded from the denominator.
categoricals: dict[str, list[int]] = {
"wall_construction": [0, 0],
"wall_insulation_type": [0, 0],
"construction_age_band": [0, 0],
"roof_construction": [0, 0],
"floor_construction": [0, 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)
_tally(categoricals["wall_construction"], cmp.wall_construction_correct)
_tally(
categoricals["wall_insulation_type"],
cmp.wall_insulation_type_correct,
)
_tally(
categoricals["construction_age_band"],
cmp.construction_age_band_correct,
)
_tally(categoricals["roof_construction"], cmp.roof_construction_correct)
_tally(
categoricals["floor_construction"], cmp.floor_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")
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])
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 _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()