#!/usr/bin/env python """Stage 26 — cross-experiment comparison of yEHDE binary-forecast skill (self-contained HTML). Consolidates the leakage-safe (LOYO-pooled, per-fold decision threshold) binary contingency inventory of EVERY experiment in the project into one report, focused on the scores the hybrid-design note asks for: POD, POFD, FAR, FBI (frequency bias), CSI, ETS, HSS, Peirce, EDI, SEDI — for the primary target yEHDE (>=1 EHD in a lead block). Sources (all already computed leakage-safe by their own verify stages): v1 headline results/tables/cv_binary_scores_by_block.csv v2 zoo results/tables/v2_cv_scores_by_model_block.csv (best-by-SEDI/block) v2 ensemble results/tables/v2_scores_with_ensemble.csv (ens_stack) Stage-2 results/tables/v3cal_binary_by_block.csv (calibration-recovery stack) v3 zoo results/tables/v3zoo_scores_by_model_block.csv (best-by-SEDI/block) §6 vote results/tables/v6_vote_scores_by_block.csv (m/n vote ensemble) v3 stack outputs/loyo_pred_v3stack/v3stack_pooled.npz (13-member; inventory computed here at the pooled Peirce threshold, cross-checked vs v3_stack_scores_by_block.csv) Output: results/experiment_comparison.html (no external assets; inline SVG charts). NOTE on comparability: per-fold-thresholded experiments (v1/v2/Stage-2/v3zoo/§6) never let a threshold see its own test year. The v3-stack row uses a single pooled Peirce threshold per block (reproducing stage-23's own SEDI/Peirce), which is marginally optimistic vs a per-fold threshold; this is footnoted in the report. """ import csv, os, sys, html import numpy as np ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TAB = os.path.join(ROOT, "results", "tables") OUT = os.path.join(ROOT, "results", "experiment_comparison.html") BLOCKS = ["W1", "W2", "W3", "W4", "W5", "W6"] sys.path.insert(0, os.path.join(ROOT, "src")) from ehd import verify as vf # noqa: E402 # canonical metric -> (label, direction: +1 higher-better / -1 lower-better, ideal) METRICS = { "POD": ("POD", +1, 1.0), "POFD": ("POFD", -1, 0.0), "FAR": ("FAR", -1, 0.0), "FBI": ("FBI", 0, 1.0), # frequency bias, ideal = 1 "CSI": ("CSI", +1, 1.0), "EDI": ("EDI", +1, 1.0), "SEDI": ("SEDI", +1, 1.0), "ETS": ("ETS", +1, 1.0), "HSS": ("HSS", +1, 1.0), "Peirce": ("Peirce",+1, 1.0), } FOCUS = ["POD", "POFD", "FAR", "FBI", "EDI", "SEDI", "CSI"] # user's primary focus ALLCOL = ["POD", "POFD", "FAR", "FBI", "CSI", "ETS", "HSS", "Peirce", "EDI", "SEDI"] SRC = { # source-column -> canonical "POD": "POD", "POFD": "POFD", "FAR": "FAR", "CSI": "CSI", "ETS": "ETS", "HSS": "HSS", "Peirce": "Peirce", "EDI": "EDI", "SEDI": "SEDI", "freq_bias": "FBI", "FBI": "FBI", } def read_csv(path): with open(path) as fh: return list(csv.DictReader(fh)) def canon(row): """Extract canonical metric dict from a source row (missing -> None).""" out = {} for sc, cc in SRC.items(): if sc in row and row[sc] not in ("", None): try: out[cc] = float(row[sc]) except ValueError: pass for m in METRICS: out.setdefault(m, None) return out def block_of(row): b = row.get("block", "").strip() return b if b in BLOCKS else None # ---------------------------------------------------------------- gather records # records[exp_key] = {"label":.., "note":.., "rows": {block: {"model":.., metrics..}}, # "all": [ (model, block, metricdict) ... ]} experiments = [] def best_by_sedi(rows, model_field): """From many-model rows, keep best-SEDI model per block.""" per = {} for r in rows: b = block_of(r) if not b: continue c = canon(r) s = c.get("SEDI") if s is None: continue if b not in per or s > per[b][1]: per[b] = (r.get(model_field, "?"), s, c, r.get("base_rate")) return {b: {"model": v[0], "metrics": v[2], "base_rate": v[3]} for b, v in per.items()} def full_rows(rows, model_field): out = [] for r in rows: b = block_of(r) if not b: continue out.append((r.get(model_field, "?"), b, canon(r), r.get("base_rate"))) return out def add(label, note, primary, all_rows): experiments.append({"label": label, "note": note, "rows": primary, "all": all_rows}) # --- v1 headline (single model / block) --- p = os.path.join(TAB, "cv_binary_scores_by_block.csv") if os.path.exists(p): rr = read_csv(p) prim = {block_of(r): {"model": "hgb_recal", "metrics": canon(r), "base_rate": r.get("base_rate")} for r in rr if block_of(r)} add("v1 headline", "hgb + isotonic recalibration; forecast-Tx statistics only (Stage 1).", prim, [("hgb_recal", b, prim[b]["metrics"], prim[b]["base_rate"]) for b in prim]) # --- v2 zoo (best-by-SEDI across family x cutoff) --- p = os.path.join(TAB, "v2_cv_scores_by_model_block.csv") if os.path.exists(p): rr = read_csv(p) for r in rr: r["_m"] = f"{r.get('family','?')}·c{r.get('cutoff','?')}" add("v2 zoo (best)", "Severity predictand + expanded zoo + cascade; best family×cutoff per block.", best_by_sedi(rr, "_m"), full_rows(rr, "_m")) # --- v2 ensemble (ens_stack) --- p = os.path.join(TAB, "v2_scores_with_ensemble.csv") if os.path.exists(p): rr = read_csv(p) prim = {} for r in rr: b = block_of(r) if b and r.get("model") == "ens_stack": prim[b] = {"model": "ens_stack", "metrics": canon(r), "base_rate": r.get("base_rate")} add("v2 ens_stack", "v2 stacked ensemble (9 members incl. CNN/ANN).", prim, full_rows(rr, "model")) # --- Stage-2 calibration-recovery stack --- p = os.path.join(TAB, "v3cal_binary_by_block.csv") if os.path.exists(p): rr = read_csv(p) prim = {} for r in rr: b = block_of(r) if b and r.get("model") == "stack": prim[b] = {"model": "stack", "metrics": canon(r), "base_rate": r.get("base_rate")} add("Stage-2 stack", "Lead-dependent extended predictors (ENSO/MJO/Z500/persistence) + OOF calibration stack.", prim, full_rows(rr, "model")) # --- v3 zoo (best-by-SEDI) --- p = os.path.join(TAB, "v3zoo_scores_by_model_block.csv") if os.path.exists(p): rr = read_csv(p) add("v3 zoo (best single)", "Stage-3 hybrid zoo on lead-dependent predictors; best single family per block.", best_by_sedi(rr, "family"), full_rows(rr, "family")) # --- v4 full-field true-spatial ResU-Net (single member) --- p = os.path.join(TAB, "v4ff_scores_by_model_block.csv") if os.path.exists(p): rr = read_csv(p) add("v4 full-field ResU-Net", "Stage-4 TRUE spatial DL (55 gridded channels, Z500/T850/winds as fields); null result — no lift over box-index.", best_by_sedi(rr, "family"), full_rows(rr, "family")) # --- §6 vote ensemble --- p = os.path.join(TAB, "v6_vote_scores_by_block.csv") if os.path.exists(p): rr = read_csv(p) prim = {block_of(r): {"model": "vote", "metrics": canon(r), "base_rate": r.get("base_rate")} for r in rr if block_of(r)} add("§6 vote ensemble", "23-member m/n vote, per-fold optimal P0 (EDI/ETS/HSS not in source table).", prim, [(prim[b]["model"], b, prim[b]["metrics"], prim[b]["base_rate"]) for b in prim]) # --- v3 stack (13-member) computed from pooled npz --- STACK_KEY = None p = os.path.join(ROOT, "outputs", "loyo_pred_v3stack", "v3stack_pooled.npz") if os.path.exists(p): z = np.load(p, allow_pickle=True) y = z["y"].astype(float) bid = z["block_id"].astype(int) pstk = z["p_stack"].astype(float) prim = {} allr = [] for k, b in enumerate(BLOCKS, start=1): m = (bid == k) & np.isfinite(y) & np.isfinite(pstk) # finite labels only, as stage-25 if not m.any(): continue yy, pp = y[m].astype(int), pstk[m] thr, _ = vf.best_threshold(yy, pp, "Peirce") bs = vf.binary_scores(yy, pp, thr) met = {"POD": bs["POD"], "POFD": bs["POFD"], "FAR": bs["FAR"], "CSI": bs["CSI"], "ETS": bs["ETS"], "HSS": bs["HSS"], "Peirce": bs["Peirce"], "EDI": bs["EDI"], "SEDI": bs["SEDI"], "FBI": bs["freq_bias"]} br = f"{yy.mean():.4f}" prim[b] = {"model": "ens_stack", "metrics": met, "base_rate": br} allr.append(("ens_stack", b, met, br)) add("v3 stack (13-member)", "Stage-3 error-diverse logistic stack over 13 members (incl. spatial ResU-Net/CNN); " "pooled Peirce threshold.", prim, allr) STACK_KEY = len(experiments) - 1 # sanity vs existing v3_stack_scores_by_block.csv (SEDI): expect tiny delta because that # table scored NaN-label cells as negatives whereas we filter to finite labels (as stage-25). chk = os.path.join(TAB, "v3_stack_scores_by_block.csv") if os.path.exists(chk): ref = {(r["block"], r["combo"]): r for r in read_csv(chk)} msg = [] for b in BLOCKS: r = ref.get((b, "ens_stack")) if r and b in prim: d = abs(float(r["SEDI"]) - prim[b]["metrics"]["SEDI"]) msg.append(f"{b} ΔSEDI={d:.4f}") print("[26] v3-stack SEDI vs stage-23 table (finite-label filtering):", " ".join(msg)) # ---------------------------------------------------------------- HTML helpers def fmt(v): return "—" if v is None else f"{v:.3f}" def cell_color(metric, v): """Green (good) -> red (bad) background for a metric value.""" if v is None: return "#f2f2f2" _, direction, ideal = METRICS[metric] if direction == 0: # FBI: distance from 1 q = max(0.0, 1.0 - min(abs(v - ideal), 1.0)) else: lo, hi = (0.0, 1.0) q = (v - lo) / (hi - lo) q = max(0.0, min(1.0, q)) if direction < 0: q = 1.0 - q # q in [0,1], 1=good. interpolate red(0)->yellow(.5)->green(1) if q < 0.5: r, g = 220, int(80 + 300 * q) else: r, g = int(220 - 300 * (q - 0.5)), 200 r = max(0, min(230, r)); g = max(0, min(210, g)) return f"rgb({r},{g},90)" def esc(s): return html.escape(str(s)) # ---------------------------------------------------------------- SVG grouped bar chart PALETTE = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#17becf"] def svg_chart(metric): label, direction, ideal = METRICS[metric] exps = experiments n_exp = len(exps) W, H = 760, 300 padL, padR, padT, padB = 46, 12, 34, 40 plotW, plotH = W - padL - padR, H - padT - padB # scale: use [0, max] for +metrics/FBI, [0, max] for -metrics too (raw values) vals = [e["rows"].get(b, {}).get("metrics", {}).get(metric) for e in exps for b in BLOCKS] vals = [v for v in vals if v is not None] vmax = max(vals + [ideal if direction == 0 else 0.0]) if vals else 1.0 vmax = max(vmax, 0.05) * 1.12 gw = plotW / len(BLOCKS) # group width per block bw = gw * 0.8 / max(n_exp, 1) # bar width s = [f'') return "".join(s) def legend(): s = ['
Primary target yEHDE (≥1 extreme-heat day in a lead block). ' 'All scores are leave-one-year-out pooled (2003–2022), out-of-sample, at each ' 'experiment\'s leakage-safe per-region×block decision threshold. Focus metrics: ' 'POD, POFD, FAR, FBI (frequency bias), CSI, ETS, HSS, Peirce, EDI, SEDI.
') # legend H.append('| Experiment | Representative model shown |
|---|---|
| {esc(e["label"])} | {esc(e["note"])} |
Winner among all experiments for each metric×block (FBI scored by closeness to 1).
') ch = champion_rows() H.append('| Metric | ' + "".join(f'{b} | ' for b in BLOCKS) + '|
|---|---|---|
| {METRICS[m][0]} | ') for b in BLOCKS: best = ch[b].get(m) if best is None: H.append('— | ') else: _, v, lab = best H.append(f''
f'{v:.3f} {esc(lab)} | ')
H.append('
| Experiment | model | base rate | ' + "".join(f'{METRICS[m][0]} | ' for m in ALLCOL) + '
|---|---|---|---|
| {esc(e["label"])} | {esc(row["model"])} | {brs} | ') for m in ALLCOL: v = row["metrics"].get(m) H.append(f'{fmt(v)} | ') H.append('
| model | block | base rate | ' + "".join(f'{METRICS[m][0]} | ' for m in ALLCOL) + '
|---|---|---|---|
{esc(model)} | {b} | {brs} | ') for m in ALLCOL: v = met.get(m) H.append(f'{fmt(v)} | ') H.append('
cv_binary_scores_by_block.csv, '
'v2_cv_scores_by_model_block.csv, v2_scores_with_ensemble.csv, '
'v3cal_binary_by_block.csv, v3zoo_scores_by_model_block.csv, '
'v6_vote_scores_by_block.csv, '
'outputs/loyo_pred_v3stack/v3stack_pooled.npz. '
'Regenerate with python scripts/26_compare_experiments_html.py.