mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 08:48:38 +00:00
Finds pre-2012 (S3 backup) x SAP-10.2 (new API) cert pairs per postcode, predicts each from its leave-one-out cohort with and without ADR-0054 conditioning, and reports compare_prediction hit rates side by side. Evidence for the stable-attribute whitelist, not a CI gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
8.5 KiB
Python
224 lines
8.5 KiB
Python
"""Pre-2012 x post-June-2025 pairs harness for Expired-Enhanced Prediction (ADR-0054).
|
|
|
|
For each postcode, find properties holding BOTH a pre-2012 cert in the historic
|
|
S3 backup AND a SAP-10.2 cert on the new gov API (RdSAP 10 went live June 2025;
|
|
only a same-spec lodged figure is a valid validation target — see Component
|
|
Accuracy). Each pair is predicted twice from its leave-one-out postcode cohort:
|
|
|
|
- PLAIN arm: property type + built form only (what a blind prediction sees);
|
|
- CONDITIONED arm: the historic cert's stable attributes conditioning cohort
|
|
selection (ADR-0054).
|
|
|
|
Both arms are scored against the lodged SAP-10.2 components with
|
|
`compare_prediction`; the report prints per-attribute hit rates side by side,
|
|
so any whitelist member (main fuel is the judgement call) can be promoted or
|
|
demoted with evidence. A report, not a CI gate.
|
|
|
|
Usage:
|
|
python scripts/expired_prediction_pairs_harness.py "B93 8SY" "LS6 1AA"
|
|
python scripts/expired_prediction_pairs_harness.py --postcodes-file pcs.txt
|
|
|
|
Env: OPEN_EPC_API_TOKEN, DATA_BUCKET; ambient AWS credentials for S3.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402
|
|
from datatypes.epc.domain.historic_epc import HistoricEpc # noqa: E402
|
|
from domain.epc_prediction.comparable_properties import ( # noqa: E402
|
|
ComparableProperty,
|
|
select_comparables,
|
|
)
|
|
from domain.epc_prediction.epc_prediction import EpcPrediction # noqa: E402
|
|
from domain.epc_prediction.historic_conditioning import ( # noqa: E402
|
|
attributes_with_historic_fallback,
|
|
conditioning_from_historic,
|
|
target_with_conditioning,
|
|
)
|
|
from domain.epc_prediction.prediction_comparison import ( # noqa: E402
|
|
PredictionComparison,
|
|
compare_prediction,
|
|
)
|
|
from domain.epc_prediction.prediction_target import ( # noqa: E402
|
|
PredictionTarget,
|
|
build_prediction_target,
|
|
)
|
|
from domain.postcode import Postcode # noqa: E402
|
|
from domain.property.property import PropertyIdentity # noqa: E402
|
|
|
|
_PRE_2012 = "2012-01-01"
|
|
_VALIDATION_SAP_VERSION = 10.2
|
|
|
|
|
|
def latest_pre_2012_by_uprn(records: list[HistoricEpc]) -> dict[str, HistoricEpc]:
|
|
"""One historic cert per UPRN: the latest lodgement strictly before 2012
|
|
(ISO date strings compare lexicographically). UPRN-less rows are dropped —
|
|
without a UPRN there is nothing to pair."""
|
|
by_uprn: dict[str, HistoricEpc] = {}
|
|
for record in records:
|
|
if not record.uprn or not record.lodgement_date:
|
|
continue
|
|
if record.lodgement_date >= _PRE_2012:
|
|
continue
|
|
current = by_uprn.get(record.uprn)
|
|
if current is None or record.lodgement_date > current.lodgement_date:
|
|
by_uprn[record.uprn] = record
|
|
return by_uprn
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ArmScores:
|
|
"""Aggregated categorical hit counts for one arm: component -> (hits, scored)."""
|
|
|
|
hits: dict[str, tuple[int, int]]
|
|
floor_area_abs_residuals: list[float]
|
|
|
|
|
|
def aggregate(comparisons: list[PredictionComparison]) -> ArmScores:
|
|
counts: dict[str, list[int]] = defaultdict(lambda: [0, 0])
|
|
residuals: list[float] = []
|
|
for comparison in comparisons:
|
|
for component, hit in comparison.categorical_hits.items():
|
|
if hit is None:
|
|
continue
|
|
counts[component][1] += 1
|
|
if hit:
|
|
counts[component][0] += 1
|
|
residuals.append(abs(comparison.floor_area_residual))
|
|
return ArmScores(
|
|
hits={k: (v[0], v[1]) for k, v in counts.items()},
|
|
floor_area_abs_residuals=residuals,
|
|
)
|
|
|
|
|
|
def format_report(plain: ArmScores, conditioned: ArmScores, pairs: int) -> str:
|
|
components = sorted(set(plain.hits) | set(conditioned.hits))
|
|
lines = [
|
|
f"# Expired-Enhanced Prediction pairs report ({pairs} pairs)",
|
|
"",
|
|
"| component | plain | conditioned |",
|
|
"|---|---|---|",
|
|
]
|
|
for component in components:
|
|
p_hit, p_all = plain.hits.get(component, (0, 0))
|
|
c_hit, c_all = conditioned.hits.get(component, (0, 0))
|
|
p = f"{p_hit}/{p_all}" if p_all else "n/a"
|
|
c = f"{c_hit}/{c_all}" if c_all else "n/a"
|
|
lines.append(f"| {component} | {p} | {c} |")
|
|
|
|
def _mean(values: list[float]) -> str:
|
|
return f"{sum(values) / len(values):.1f}" if values else "n/a"
|
|
|
|
lines.append(
|
|
f"| floor_area mean abs residual (m²) | "
|
|
f"{_mean(plain.floor_area_abs_residuals)} | "
|
|
f"{_mean(conditioned.floor_area_abs_residuals)} |"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _predict_arm(
|
|
target: Optional[PredictionTarget],
|
|
cohort: list[ComparableProperty],
|
|
predictor: EpcPrediction,
|
|
) -> Optional[EpcPropertyData]:
|
|
if target is None:
|
|
return None
|
|
comparables = select_comparables(target, cohort)
|
|
if not comparables.members:
|
|
return None
|
|
return predictor.predict(target, comparables)
|
|
|
|
|
|
def run(postcodes: list[str]) -> str: # pragma: no cover - live IO composition
|
|
from infrastructure.epc_client.epc_client_service import EpcClientService
|
|
from repositories.comparable_properties.epc_comparable_properties_repository import (
|
|
EpcComparablePropertiesRepository,
|
|
)
|
|
from repositories.geospatial.geospatial_s3_repository import (
|
|
GeospatialS3Repository,
|
|
)
|
|
from repositories.historic_epc.historic_epc_s3_repository import (
|
|
HistoricEpcS3Repository,
|
|
)
|
|
from scripts.e2e_common import load_env, s3_parquet_reader
|
|
|
|
load_env()
|
|
|
|
epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"])
|
|
geospatial = GeospatialS3Repository(s3_parquet_reader(os.environ["DATA_BUCKET"]))
|
|
comparables_repo = EpcComparablePropertiesRepository(epc_client, geospatial)
|
|
historic_repo = HistoricEpcS3Repository.with_default_s3_client()
|
|
predictor = EpcPrediction()
|
|
|
|
plain_comparisons: list[PredictionComparison] = []
|
|
conditioned_comparisons: list[PredictionComparison] = []
|
|
pairs = 0
|
|
for raw_postcode in postcodes:
|
|
postcode = str(Postcode(raw_postcode))
|
|
historic = latest_pre_2012_by_uprn(
|
|
historic_repo.get_for_postcode(Postcode(raw_postcode))
|
|
)
|
|
if not historic:
|
|
print(f"{postcode}: no pre-2012 historic certs", file=sys.stderr)
|
|
continue
|
|
cohort = comparables_repo.candidates_for(postcode)
|
|
for uprn, record in historic.items():
|
|
actual = epc_client.get_by_uprn(int(uprn))
|
|
if actual is None or actual.sap_version != _VALIDATION_SAP_VERSION:
|
|
continue
|
|
pairs += 1
|
|
loo_cohort = [c for c in cohort if c.epc.uprn != int(uprn)]
|
|
identity = PropertyIdentity(
|
|
portfolio_id=0, postcode=postcode, address=record.address, uprn=int(uprn)
|
|
)
|
|
conditioning = conditioning_from_historic(record)
|
|
attributes = attributes_with_historic_fallback(None, conditioning)
|
|
plain_target = build_prediction_target(identity, None, attributes)
|
|
conditioned_target = (
|
|
target_with_conditioning(plain_target, conditioning)
|
|
if plain_target is not None
|
|
else None
|
|
)
|
|
plain = _predict_arm(plain_target, loo_cohort, predictor)
|
|
conditioned = _predict_arm(conditioned_target, loo_cohort, predictor)
|
|
if plain is not None:
|
|
plain_comparisons.append(compare_prediction(plain, actual))
|
|
if conditioned is not None:
|
|
conditioned_comparisons.append(compare_prediction(conditioned, actual))
|
|
print(f"{postcode} {uprn}: pair scored", file=sys.stderr)
|
|
|
|
return format_report(
|
|
aggregate(plain_comparisons), aggregate(conditioned_comparisons), pairs
|
|
)
|
|
|
|
|
|
def main() -> None: # pragma: no cover - CLI entry
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("postcodes", nargs="*", help="postcodes to scan")
|
|
parser.add_argument("--postcodes-file", type=Path, default=None)
|
|
args = parser.parse_args()
|
|
postcodes: list[str] = list(args.postcodes)
|
|
if args.postcodes_file is not None:
|
|
postcodes += [
|
|
line.strip()
|
|
for line in args.postcodes_file.read_text().splitlines()
|
|
if line.strip()
|
|
]
|
|
if not postcodes:
|
|
parser.error("no postcodes given")
|
|
print(run(postcodes))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|