Model/scripts/validate_epc_prediction.py
Khalim Conn-Kowlessar fa11df56c2 fix(epc-prediction): dedupe re-lodgements + leak-free leave-one-out (ADR-0029)
The register lists every historical lodgement, so a postcode cohort
contains the same physical address many times (LS61AA: 15 certs / 11
addresses; NG71AA: 15 / 9 — "FLAT 3" appears 3x in each). Two
consequences:

  - Production: a re-lodged neighbour was counting up to 3x towards the
    cohort mode. select_comparables now dedupes candidates to the latest
    cert per address (one comparable per real neighbour) — Comparable
    gains address + registration_date (the register metadata its docstring
    already anticipated, read straight off the cached payload).

  - Validation: leave-one-out leaked — predicting a flat from a near-
    identical re-lodgement of itself. The harness now holds out a whole
    address (excludes every sibling cert) and evaluates on the latest cert
    per address (the best ground truth).

Removing the leak gives the honest numbers (19 distinct addresses):
  wall_construction      93.1% -> 89.5%
  construction_age_band  65.5% -> 52.6%
  roof_construction      79.3% -> 68.4%
  floor_area mean|.|     37.9  -> 52.6 m2
The earlier figures were inflated by self-leakage; these are the real
accuracy to beat.

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

243 lines
9.2 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 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
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 _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)
targets = _ground_truth_properties(cohort)
if len(targets) < 2:
skipped_no_cohort += len(targets)
continue
for held_out in targets:
# 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)
_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()