#!/usr/bin/env python """Stage 16 — assemble a single self-contained HTML report of the v2 results for review. Embeds every figure as base64 (so the .html is portable) and renders the score tables. Covers: * headline model x block binary skill (all families x cutoffs) * EHD_ens: ensemble (mean + LOYO stack) vs zoo members, table + figure * per-cell skill maps per lead week (POD/POFD/FAR/CSI/EDI/SEDI) for ens + members Run stages 13, 14, 15 first. Output: results/v2_report.html """ from __future__ import annotations import os, sys, base64 import pandas as pd sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) TABLES = os.path.join(ROOT, "results", "tables") FIGS = os.path.join(ROOT, "results", "figures") OUT = os.path.join(ROOT, "results", "v2_report.html") def img(path, width="100%"): if not os.path.exists(path): return f'

[missing figure: {os.path.basename(path)}]

' b = base64.b64encode(open(path, "rb").read()).decode() return f'' def table(path, highlight_ens=False, cols=None): if not os.path.exists(path): return f'

[missing table: {os.path.basename(path)}]

' df = pd.read_csv(path) if cols: df = df[[c for c in cols if c in df.columns]] def rowclass(r): if highlight_ens and str(r.get("model", "")).startswith("ens_"): return ' class="ens"' return "" head = "".join(f"{c}" for c in df.columns) body = "" for _, r in df.iterrows(): body += f"" + "".join(f"{r[c]}" for c in df.columns) + "" return f'{head}{body}
' CSS = """ body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0; color:#1b1b1b;line-height:1.5} header{background:#0d3b66;color:#fff;padding:24px 40px} header h1{margin:0 0 4px 0;font-size:24px} header p{margin:0;opacity:.85} main{max-width:1500px;margin:0 auto;padding:0 40px 80px} h2{border-bottom:3px solid #0d3b66;padding-bottom:6px;margin-top:48px;color:#0d3b66} h3{margin-top:28px;color:#144} table{border-collapse:collapse;font-size:12.5px;margin:12px 0;width:100%} th,td{border:1px solid #ccc;padding:4px 8px;text-align:right} th{background:#eef3f8;position:sticky;top:0} td:first-child,th:first-child{text-align:left} tr.ens{background:#fff3cd;font-weight:600} .note{background:#f4f7fb;border-left:4px solid #0d3b66;padding:10px 16px;margin:16px 0; font-size:14px} .warn{background:#fff4e5;border-left:4px solid #e08600} figure{margin:16px 0} figcaption{font-size:13px;color:#555;margin-top:6px} nav{position:sticky;top:0;background:#fff;border-bottom:1px solid #ddd;padding:8px 40px; font-size:13px;z-index:10} nav a{margin-right:16px;color:#0d3b66;text-decoration:none} code{background:#eee;padding:1px 5px;border-radius:3px} """ def main(): from datetime import date import glob has_torch = bool(glob.glob(os.path.join(ROOT, "outputs", "loyo_pred_v2_cnn", "pred_v2cnn_*.npz"))) parts = [] parts.append(f"

S2S Extreme-Heat-Day (EHDE) forecast — v2 results

" f"

Vietnam · severity predictand + model zoo + cascade + EHD_ens · " f"20-fold leave-one-year-out, pooled out-of-sample 2003–2022 · " f"generated {date.today().isoformat()}

") parts.append('') parts.append("
") parts.append('

Overview

') parts.append('
All scores are out-of-sample: each year was held out, ' 'everything (threshold, models, recalibration, decision thresholds, and the ' 'ensemble stacker) was fit without it, and the 20 held-out years were pooled ' '(leakage rule). Event = EHDE = ≥1 EHD in the lead block; ' 'EHD day = Tx ≥ min(Tx90p, 35°C). Six fixed lead blocks W1–W6 (0–10 … 39–45 d).' '
') if has_torch: parts.append('
Model set: 7 sklearn members + CNN ' '(fully-convolutional, spatial) + deep ANN (torch), all folded into ' 'EHD_ens.
') else: parts.append('
CNN and a deeper torch ANN are queued (need a ' 'gridded input pipeline + a fresh SLURM re-run); this report covers the 7 ' 'tabular members and their ensembles. It will be regenerated once CNN/ANN ' 'land.
') parts.append('

EHD_ens — ensemble of AI members

') parts.append('

ens_mean = unweighted mean of the 7 member probabilities. ' 'ens_stack = logistic meta-learner over the members, trained ' 'leave-one-year-out at the meta level (leakage-safe).

') parts.append(f'
{img(os.path.join(FIGS, "v2_ensemble_skill_by_lead.png"))}' '
EHD_ens (bold) vs the 7 zoo members (grey): SEDI and Brier skill ' 'by lead block.
') parts.append('

Ensemble vs members — all binary scores (cutoff 1.0)

') parts.append(table(os.path.join(TABLES, "v2_scores_with_ensemble.csv"), highlight_ens=True)) parts.append('

Model zoo — binary skill by lead block

') parts.append(f'
{img(os.path.join(FIGS, "v2_binary_skill_by_lead.png"))}' '
Per-family SEDI and Peirce by lead (each family at its best ' 'cascade cutoff).
') parts.append('

Best model per block (by SEDI)

') parts.append(table(os.path.join(TABLES, "v2_best_model_by_block.csv"))) parts.append('

Full table — every family × cascade cutoff × block

') parts.append(table(os.path.join(TABLES, "v2_cv_scores_by_model_block.csv"))) parts.append('

Per-cell skill maps by lead week

') parts.append('

Each panel is a grid-cell map; rows are metrics ' '(POD, POFD, FAR, CSI, EDI, SEDI), columns are lead weeks W1–W6. ' 'For POD/CSI/EDI/SEDI higher (yellow/red) = better; for POFD/FAR lower = ' 'better. Full fields incl. HSS/Peirce/freq_bias/n are in ' 'outputs/v2_skill_maps.nc.

') map_models = [("ens_stack", "EHD_ens (stacked) — the product"), ("ens_mean", "EHD_ens (mean)"), ("cnn", "CNN (fully-convolutional)"), ("ann_torch", "Deep ANN (torch)"), ("hgb", "HGB (single member)"), ("mlp", "MLP (shallow sklearn member)")] for m, label in map_models: fig_path = os.path.join(FIGS, f"v2_skillmap_{m}.png") if not os.path.exists(fig_path): continue parts.append(f'

{label}

') parts.append(f'
{img(fig_path)}
') # ---------------- prompt section 6: vote-fraction ensemble + regional alerts ------------- if os.path.exists(os.path.join(TABLES, "v6_vote_scores_by_block.csv")): parts.append('

Vote-fraction ensemble & regional alerts (prompt §6)

') parts.append('
A second, model-agnostic ensemble: each of the ' '23 AI members (7 sklearn families × 3 cascade cutoffs + CNN + deep ' 'ANN) casts a yEHDE vote using its own leakage-safe per-region threshold, and ' 'P(yEHDE)=m/n. A deterministic forecast is EHDE=1 where P>P₀, with ' 'P₀ chosen to maximise Peirce (POD−POFD) leave-one-year-out.
') parts.append('

§6.1 — Member comparison (POD high-good, POFD/FAR low-good)

') parts.append(f'
{img(os.path.join(FIGS, "v6_member_pod_pofd_far.png"))}' '
Per-member POD / POFD / FAR by lead block. CNN is the strongest ' 'single member at long lead (W3–W6).
') parts.append('

Member contribution to the vote ensemble (leave-one-member-out ΔPeirce, ' 'mean over W3–W6): all members are near-redundant (|ΔPeirce|≲0.002); CNN is ' 'the top contributor. Full detail: v6_member_contribution.csv, ' 'v6_member_scores_by_block.csv.

') parts.append('

§6.2–6.3 — Vote fraction P(yEHDE)=m/n and optimal P₀

') parts.append(f'
{img(os.path.join(FIGS, "v6_p0_sweep.png"))}' '
Binary scores vs the vote threshold P₀ (pooled LOYO); dashed ' 'line = Peirce-optimal P₀ (≈ a simple majority vote).
') parts.append('

Deterministic yEHDE at the per-fold Peirce-optimal P₀ ' '(leakage-safe headline):

') parts.append(table(os.path.join(TABLES, "v6_vote_scores_by_block.csv"))) parts.append('
The unweighted vote peaks near a majority vote ' '(P₀≈0.5) and roughly matches the best single member — a robust, ' 'interpretable product, though the trained ens_stack above is a little ' 'sharper at short lead.
') parts.append('

§6.4 — Regional event forecast (Mj/Nj)

') parts.append('

Per region Rj and week Wk, the forecast coverage ' 'P=Mj/Nj (fraction of the region’s cells forecast ' 'yEHDE) is compared with the same rule on the observed field; a region is ' 'alerted if P≥P_thres. P_thres is chosen per week (Peirce, leakage-safe), ' 'with a fixed-0.5 majority reference.

') parts.append(f'
{img(os.path.join(FIGS, "v6_regional_event_heatmap.png"))}' '
Regional event-forecast Peirce (region × week). Aggregating to ' 'region×week is markedly more skilful than per-cell.
') parts.append(table(os.path.join(TABLES, "v6_regional_by_week.csv"))) parts.append('
Per-fold Peirce selection of the regional P_thres is ' 'jumpy across weeks (e.g. W5→0.10); the *_at0p5 columns give the ' 'steadier majority-vote reference. Per-region×week detail: ' 'v6_regional_event_table.csv.
') parts.append('

Methods & provenance

') parts.append('') parts.append("
") html = f"" \ f"v2 EHDE report" \ f"{''.join(parts)}" with open(OUT, "w") as f: f.write(html) print(f"[report] wrote {OUT} ({len(html)/1e6:.2f} MB)") if __name__ == "__main__": main()