#!/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"
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(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('
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'
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):
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.