Model/scripts/corpus_1000/field_bias.py
Jun-te Kim a593921a83 fix(rdsap): thin present wall-insulation no longer maps to uninsulated bucket
Field-level bias scan (scripts/corpus_1000/field_bias.py) flagged solid-brick +
internal-insulation certs (wall_con+ins=(3,3)) systematically under-rating
(median -0.40, n=18). Root cause: _insulation_bucket routed insulation that is
KNOWN PRESENT but with a thin lodged thickness (5-24mm, below the smallest
tabulated 50mm column) to bucket 0 (uninsulated, U 1.7) - WORSE than the
present-but-unknown case, which the RdSAP Table 6 footnote correctly routes to
the 50mm row. A known 10mm shouldn't read as higher-U than not knowing at all.

Now present insulation never falls to bucket 0; thin/unknown thickness both
round up to the 50mm row. Only genuinely absent insulation maps to 0.

Corpus cert 5117280 (10mm internal, no wall thickness -> bucket path): Δ -4.44
-> +1.08. Corpus MAE 0.641 -> 0.637, PE MAE 3.0 -> 2.9. Gauge 77.7% holds; 44
RealCert pins + 342 fabric tests green. TDD: 2 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:54:47 +00:00

46 lines
2.5 KiB
Python

"""Field-level systematic-bias scan: mean Δ(SAP) grouped by each mapped
attribute. A subgroup with large |meanΔ| AND enough n is a systematic mapping
error for that attribute (would cancel in the overall ~0 bias)."""
from __future__ import annotations
import json, statistics
from collections import defaultdict
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
CORPUS="backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl"
G=defaultdict(lambda: defaultdict(list))
def gv(x): return x.get('value') if isinstance(x,dict) else x
for line in open(CORPUS):
d=json.loads(line); L=d.get('energy_rating_current')
if L is None: continue
try:
epc=EpcPropertyDataMapper.from_api_response(d)
r=calculate_sap_from_inputs(cert_to_inputs(epc,prices=SAP_10_2_SPEC_PRICES))
except Exception: continue
dsap=r.sap_score_continuous-L
bp=(d.get('sap_building_parts') or [{}])[0]
sh=d.get('sap_heating') or {}; md=(sh.get('main_heating_details') or [{}])[0]
G['wall_con'][bp.get('wall_construction')].append(dsap)
G['wall_ins'][bp.get('wall_insulation_type')].append(dsap)
G['wall_con+ins'][(bp.get('wall_construction'),bp.get('wall_insulation_type'))].append(dsap)
G['roof_con'][bp.get('roof_construction')].append(dsap)
G['floor_hl'][bp.get('floor_heat_loss')].append(dsap)
G['age'][bp.get('construction_age_band')].append(dsap)
G['main_cat'][md.get('main_heating_category')].append(dsap)
G['whc'][sh.get('water_heating_code')].append(dsap)
G['meter'][(d.get('sap_energy_source') or {}).get('meter_type')].append(dsap)
G['country'][d.get('country_code')].append(dsap)
G['dwelling'][d.get('dwelling_type')].append(dsap)
G['nbp'][len(d.get('sap_building_parts') or [])].append(dsap)
G['glz'][tuple(sorted(set(w.get('glazing_type') for w in (d.get('sap_windows') or []))))[:1]].append(dsap)
print("=== subgroups with |meanΔ| >= 0.5 and n >= 8 (systematic-bias candidates) ===")
hits=[]
for field, groups in G.items():
for key, vals in groups.items():
if len(vals) >= 8:
m=statistics.mean(vals)
if abs(m) >= 0.5:
hits.append((abs(m), field, key, len(vals), m, statistics.median(vals)))
for am,field,key,n,m,med in sorted(hits, reverse=True):
print(f" {field:12s} = {str(key):20s} n={n:4d} meanΔ={m:+.2f} medianΔ={med:+.2f}")
if not hits: print(" (none — no systematic subgroup bias)")