#!/usr/bin/env python3 """Report on the AI models: model-selection tables, held-out scores and maps. Reads whatever of these exist and renders one self-contained page: outputs/AI_Results/model_selection.json the 21-point comparison run outputs/AI_Results/national_*.json the national model summaries outputs/ai_verification*.nc gridded scores of the national model outputs/ehd_verification.nc the raw EC benchmark from stage 1 """ from __future__ import annotations import argparse import glob import json import os import time import re import sys from html import escape import numpy as np from netCDF4 import Dataset sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ehd_common as C import ehd_metrics as MET from make_maps_html import SPEC, load_regions, metric_row TARGETS = {"POD": (">", 0.6), "POFD": ("<", 0.2), "FAR": ("<", 0.2), "CSI": (">", 0.4), "FBI": ("~", 1.0)} TARGET_WEEKS = ["W4", "W5", "W6"] KEYS = ["base_rate", "POD", "POFD", "FAR", "FBI", "CSI", "ETS", "AUC"] RES_GLOB = os.path.join(C.OUT, "AI_Results") FRAME_TITLES = { "daily_all": "Cấp ngày — toàn bộ điểm và toàn bộ mùa", "daily_capable": "Cấp ngày — mùa nóng: các ô/ngày có Tx90p ≥ 35 °C, nơi ngưỡng bằng đúng 35 °C nên EHD ⟺ Tx ≥ 35 °C", "weekly_all": "Sự kiện tuần — có ít nhất 1 ngày EHD trong tuần", "daily_capable_own_fbi1": "Ô có thể xảy ra EHD — ngưỡng dò riêng trong khung này (FBI ≈ 1)", "daily_capable_own_csi": "Ô có thể xảy ra EHD — ngưỡng dò riêng trong khung này (tối đa CSI)", "daily_all_by_region": "Toàn bộ điểm — ngưỡng riêng cho từng vùng khí hậu", "daily_all_by_season": "Toàn bộ điểm — ngưỡng riêng cho từng mùa", "daily_capable_by_region": "Ô có thể xảy ra EHD — ngưỡng riêng cho từng vùng", "daily_capable_by_season": "Ô có thể xảy ra EHD — ngưỡng riêng cho từng mùa", } # The frame every headline number is quoted in, and the one the summary uses. HEAD_FRAME = "daily_capable_own_fbi1" REGION_FRAMES = { "daily_capable": "ngưỡng quốc gia", "daily_capable_own_region_thr": "ngưỡng riêng của từng vùng", } def hit_target(key, value, week): """Colour class for one cell: only the weeks the targets apply to are judged.""" if week not in TARGET_WEEKS or key not in TARGETS or not np.isfinite(value): return "" op, ref = TARGETS[key] ok = (value > ref if op == ">" else value < ref if op == "<" else abs(value - ref) <= 0.25) return " class='%s'" % ("good" if ok else "bad") def table(rows, caption=""): out = [""] if caption: out.append("" % escape(caption)) out.append("" + "".join("" % k for k in KEYS) + "") for name, s in rows.items(): cells = "".join("%s" % (hit_target(k, s.get(k, np.nan), name), "—" if not np.isfinite(s.get(k, np.nan)) else "%.3f" % s[k]) for k in KEYS) out.append("%s" % (escape(name), cells)) out.append("
%s
Hạn%s
%s
") return "".join(out) def summary_table(runs, week="W4"): """Every national configuration side by side, in the frame quoted throughout.""" out = ["" % (week, escape(FRAME_TITLES[HEAD_FRAME])), "" + "".join("" % k for k in KEYS) + ""] def csi(item): return -item[1].get(HEAD_FRAME, item[1].get("daily_capable", {})).get( week, {}).get("CSI", -np.inf) for name, nat in sorted(runs.items(), key=csi): fr = nat.get(HEAD_FRAME) or nat.get("daily_capable") or {} r = fr.get(week, {}) if not r: continue cells = "".join("%s" % (hit_target(k, r.get(k, np.nan), week), "—" if not np.isfinite(r.get(k, np.nan)) else "%.3f" % r[k]) for k in KEYS) out.append("%s" % (escape(name), cells)) out.append("
Tổng hợp tại %s — %s
Cấu hình%s
%s
") return "".join(out) def region_table(by_region, frame): """Per climatic sub-region scores, averaged over the weeks the targets apply to.""" rows = by_region.get(frame) if not rows: return "" keys = ["POD", "POFD", "FAR", "FBI", "CSI", "AUC"] out = ["" % escape(REGION_FRAMES.get(frame, frame)), "" + "".join("" % k for k in keys) + ""] for reg in sorted(rows): s = rows[reg] cells = "".join("%s" % (hit_target(k, s.get(k, np.nan), "W4"), "—" if not np.isfinite(s.get(k, np.nan)) else "%.3f" % s[k]) for k in keys) out.append("%s" % (escape(reg), int(s.get("n_events", 0)), cells)) out.append("
Theo vùng khí hậu, trung bình W4–W6 — %s
VùngSố sự kiện%s
%s%d
") return "".join(out) def regional_region_table(rows, title): """Regional-EHDE scores split by sub-region, averaged over W4-W6.""" keys = ["POD", "POFD", "FAR", "FBI", "CSI", "AUC"] out = ["" % title, "" + "".join("" % k for k in keys) + ""] for reg in sorted(rows): s = rows[reg] cells = "".join("%s" % (hit_target(k, s.get(k, np.nan), "W4"), "—" if not np.isfinite(s.get(k, np.nan)) else "%.3f" % s[k]) for k in keys) out.append("%s" % (escape(reg), s.get("base_rate", np.nan), int(s.get("n_events", 0)), cells)) out.append("
Theo vùng, trung bình W4–W6 — %s
Vùngbase_rateSố sự kiện%s
%s%.4f%d
") return "".join(out) def prob_table(prob): """Brier skill score and AUC per week: the probabilistic side of the forecast.""" if not prob: return "" out = ["", ""] for w in C.WEEK_NAMES: if w not in prob: continue v = prob[w] cls = " class='good'" if v.get("bss", -1) > 0 else " class='bad'" out.append("%.4f" % (w, cls, v.get("bss", np.nan), v.get("auc", np.nan))) out.append("
Thẩm định xác suất — Brier skill score so với khí hậu mẫu" " (EC gốc tất định nên không có phần này)
HạnBSSAUC
%s%.3f
") return "".join(out) REG_LABELS = { "cover20_all_literal": "Phủ > 20%, mọi ngày — đếm ô vượt ngưỡng (quy tắc nguyên văn)", "cover20_all_fbi1": "Phủ > 20%, mọi ngày — xác suất trung bình vùng, FBI ≈ 1", "cover20_season_fbi1": "Phủ > 20%, mùa nóng — xác suất trung bình vùng, FBI ≈ 1", "cover10_season_fbi1": "Phủ > 10%, mùa nóng — xác suất trung bình vùng, FBI ≈ 1", "cover10_season_csi": "Phủ > 10%, mùa nóng — ngưỡng tối đa CSI", "cover05_season_fbi1": "Phủ > 5%, mùa nóng — xác suất trung bình vùng, FBI ≈ 1", "cover05_season_csi": "Phủ > 5%, mùa nóng — ngưỡng tối đa CSI", "cover05_season_fbi1_regweek": "Phủ > 5%, mùa nóng — ngưỡng riêng theo (VÙNG, tuần), FBI ≈ 1", "cover10_season_fbi1_regweek": "Phủ > 10%, mùa nóng — ngưỡng riêng theo (VÙNG, tuần), FBI ≈ 1", "cover20_season_fbi1_regweek": "Phủ > 20%, mùa nóng — ngưỡng riêng theo (VÙNG, tuần), FBI ≈ 1", } STACK_LABELS = { "cover05_season_Pbar_week": "MỐC SO SÁNH — xác suất trung bình vùng, ngưỡng chung theo tuần", "cover05_season_stack_regweek": "Tầng 2 (hồi quy logistic trên hình dạng trường xác suất) + ngưỡng riêng " "theo (vùng, tuần) — điểm vận hành THIÊN VỀ POD", "cover05_season_stack_regweek_freq": "Tầng 2 + ngưỡng theo tần suất khí hậu của từng (vùng, tuần) — điểm vận hành " "THIÊN VỀ FBI ≈ 1 và FAR thấp", "cover05_season_Pbar_regweek_freq": "MỐC SO SÁNH — Pbar với cùng quy tắc ngưỡng tần suất khí hậu", "cover10_season_stack_regweek_freq": "Tầng 2 + tần suất khí hậu, ngưỡng phủ 10%", "cover20_season_stack_regweek_freq": "Tầng 2 + tần suất khí hậu, ngưỡng phủ 20%", } STACK_REGION_LABELS = { "cover05_season_stack_regweek_byregion": "Tầng 2 + ngưỡng theo tần suất khí hậu của từng (vùng, tuần)", "cover05_season_Pbar_week_byregion": "MỐC SO SÁNH — Pbar, một ngưỡng chung cho cả nước", } REG_REGION_LABELS = { "cover05_season_fbi1_regweek_byregion": "Phủ > 5%, mùa nóng, ngưỡng riêng theo (vùng, tuần)", "cover05_season_fbi1_byregion": "Phủ > 5%, mùa nóng, một ngưỡng chung cho cả nước", } REG_KEYS = ["base_rate", "POD", "POFD", "FAR", "FBI", "CSI", "AUC"] def regional_table(rows, title): """One configuration of the regional EHDE framing, week by week.""" out = ["" % title, "" + "".join("" % k for k in REG_KEYS[1:]) + ""] for w in C.WEEK_NAMES + ["ALL"]: r = rows.get(w) if not r: continue cells = "".join("%s" % (hit_target(k, r.get(k, np.nan), w), "—" if not np.isfinite(r.get(k, np.nan)) else "%.3f" % r[k]) for k in REG_KEYS[1:]) out.append("%s" % (w, r.get("base_rate", np.nan), int(r.get("n_events", 0)), cells)) out.append("
%s
Hạnbase_rateSố sự kiện%s
%s%.4f%d
") return "".join(out) PW_FRAMES = { "W4plus": "W4 trở đi — khung mục tiêu vận hành", "daily_all": "Toàn bộ hạn dự báo W1–W6", "daily_capable": "Mùa nóng (ô/ngày có Tx90p ≥ 35 °C)", } PW_THR = {"thr_week": "ngưỡng theo tuần", "thr_point_week": "ngưỡng riêng theo (điểm, tuần)"} PW_KEYS = ["base_rate", "POD", "POFD", "FAR", "FBI", "CSI", "AUC"] def pointwise_label(arm): """Human-readable Vietnamese label for one arm key of pointwise.json.""" if arm == "pooled_ensemble": return "Ensemble toàn quốc, giới hạn về 21 điểm (đường cơ sở)" fset, kind, rest = (arm.split("/") + ["", ""])[:3] gate = {"wk": "cổng tuần-điểm", "reg": "cổng vùng Pi>5%"} if rest == "single": return "%s / %s — một mô hình mỗi điểm, không cascade" % (fset, kind) if rest.startswith("soft_"): return ("%s / %s — cascade mềm P(cổng)×P(EHD), %s" % (fset, kind, gate.get(rest[5:], rest[5:]))) if rest.startswith("hard_"): body = rest[5:] g, k = body.rsplit("_k", 1) return ("%s / %s — cascade cắt cứng, %s, giữ %.1f%% sự kiện" % (fset, kind, gate.get(g, g), int(k) / 10.0)) return arm def is_gate(res, arm): return "GATE_" in arm or res.get(arm, {}).get("target", "EHD") != "EHD" def pointwise_table(res, frame, thr): """Every arm that forecasts EHD itself, one evaluation frame. Gate arms are deliberately excluded: they forecast the nEHD/yEHD label, whose base rate is three times higher, so putting them in the same table would invite a comparison of numbers that are not on the same scale. """ arms = ["pooled_ensemble"] + sorted(a for a in res if a != "pooled_ensemble" and not is_gate(res, a)) out = ["" % (escape(PW_FRAMES.get(frame, frame)), escape(PW_THR.get(thr, thr))), "" + "".join("" % k for k in PW_KEYS) + ""] for a in arms: r = res.get(a, {}).get(frame, {}).get(thr) if not r: continue cells = "".join("%s" % (hit_target(k, r.get(k, np.nan), "W4"), "—" if not np.isfinite(r.get(k, np.nan)) else "%.3f" % r[k]) for k in PW_KEYS) style = " style='font-weight:600;background:#f2f4f8'" if a == "pooled_ensemble" else "" out.append("%s" % (style, pointwise_label(a), cells)) out.append("
%s — %s
Phương án%s
%s
") return "".join(out) def compare_table(results, frame, metric): """One metric of every configuration, side by side across the six weeks.""" tags = sorted(results) out = ["" % (escape(FRAME_TITLES.get(frame, frame)), escape(metric)), "" + "".join("" % w for w in C.WEEK_NAMES) + ""] for t in tags: r = results[t].get(frame, {}) cells = [] for w in C.WEEK_NAMES + ["ALL"]: v = r.get(w, {}).get(metric, np.nan) cells.append("%s" % (hit_target(metric, v, w), "—" if not np.isfinite(v) else "%.3f" % v)) out.append("%s" % (escape(t), "".join(cells))) out.append("
%s — %s
Cấu hình%sTất cả
%s
") return "".join(out) def gate_table(res, frame, thr): """The gate models, scored against the nEHD/yEHD label they actually forecast.""" arms = sorted(a for a in res if is_gate(res, a)) if not arms: return "" out = ["" % (escape(PW_FRAMES.get(frame, frame)), escape(PW_THR.get(thr, thr))), "" + "".join("" % k for k in PW_KEYS) + ""] for a in arms: r = res.get(a, {}).get(frame, {}).get(thr) if not r: continue fset, kind, rest = (a.split("/") + ["", ""])[:3] g = rest[len("GATE_"):] lbl = ("%s / %s — %s" % (fset, kind, "cổng tuần-điểm" if g == "wk" else "cổng vùng Pi>5%")) cells = "".join("" % ("—" if not np.isfinite(r.get(k, np.nan)) else "%.3f" % r[k]) for k in PW_KEYS) out.append("%s" % (lbl, cells)) out.append("
Mô hình CỔNG (dự báo nEHD/yEHD) — %s, %s
Cấu hình cổng%s
%s
%s
") return "".join(out) def point_detail_table(res, grid, arms): """One row per testbed grid point, W4 onward, for a few chosen arms.""" have = [a for a in arms if res.get(a, {}).get("by_point")] if not have: return "" pts = sorted({int(p) for a in have for p in res[a]["by_point"]}) lat, lon = grid.lat[grid.iy], grid.lon[grid.ix] short = {"pooled_ensemble": "gộp toàn quốc", "F3/logreg/single": "per-point", "F3/logreg/soft_wk": "per-point + cascade"} out = ["", "" "" "" + "".join("" % escape(short.get(a, a)) for a in have) + "", "" + "".join("" for _ in have) + ""] for p in pts: cells, aucs = [], [] nev = 0 for a in have: r = res[a]["by_point"].get(str(p)) if not r: cells.append(""); aucs.append(np.nan); continue nev = max(nev, int(r.get("n_events", 0))) cells.append("" % (r["CSI"], r["AUC"])) aucs.append(r["AUC"]) dv = aucs[-1] - aucs[0] if len(aucs) >= 2 and np.isfinite(aucs[0]) else np.nan dcls = "" if not np.isfinite(dv) else (" class='good'" if dv > 0 else " class='bad'") out.append("%s" "%s" % (p, escape(C.REGION_NAMES.get(int(grid.reg[p]), "?")), lat[p], lon[p], nev, "".join(cells), dcls, "—" if not np.isfinite(dv) else "%+.3f" % dv)) out.append("
Từng điểm trong 21 điểm kiểm thử — W4 trở đi, " "ngưỡng riêng theo (điểm, tuần)
ĐiểmVùngVĩ độKinh độSố sự kiện%sΔAUC
CSIAUC
——%.3f%.3f
%d%s%.2f%.2f%d
") return "".join(out) GOAL = {"POD": (0.60, "gt"), "POFD": (0.20, "lt"), "FAR": (0.20, "lt"), "CSI": (0.40, "gt")} def _goal_cell(name, v): """Ô số có tô màu theo chỉ tiêu đề ra ở CLAUDE.md.""" if name == "FBI": ok = abs(v - 1.0) <= 0.15 elif name in GOAL: thr, d = GOAL[name] ok = v > thr if d == "gt" else v < thr else: return "%.3f" % v bg = "#e6f4ea" if ok else "#fce8e6" return "%.3f" % (bg, v) def exp3w_summary_table(by_week, weeks): out = ["", "" "" ""] for w in weeks: if w not in by_week: continue s = by_week[w] edi, sedi = MET.edi_sedi(s["POD"], s["POFD"]) out.append("%s%s%s%s%s" "" "" % (escape(w), int(s["n"]), s["base_rate"], _goal_cell("POD", s["POD"]), _goal_cell("POFD", s["POFD"]), _goal_cell("FAR", s["FAR"]), _goal_cell("FBI", s["FBI"]), _goal_cell("CSI", s["CSI"]), s["ETS"], edi, sedi, s["PC"])) out.append("
EXP3 gộp theo lead week — tổng hợp 21 điểm, " "ngưỡng hiệu chỉnh FBI = 1
Tuầnn mẫubase ratePODPOFDFARFBICSIETSEDISEDIPC
%s%d%.3f%.3f%.3f%.3f%.3f
") out.append( "
EDI = (lnF − lnH)/(lnF + lnH) và SEDI = " "[lnF − lnH − ln(1−F) + ln(1−H)]/[lnF + lnH + ln(1−F) + ln(1−H)], với " "H = POD, F = POFD; bằng 0 cho dự báo ngẫu nhiên, 1 cho dự báo hoàn hảo. " "Cần hai chỉ số này vì CSI và ETS đều suy biến về 0 khi sự kiện càng hiếm, " "nên không so sánh được giữa các tuần lead, giữa 7 vùng khí hậu, hay giữa " "các định nghĩa sự kiện có base rate khác nhau. EDI và SEDI độc lập base " "rate; SEDI còn bất biến khi hit và correct negative đổi vai nên không thể " "cải thiện bằng hedging.
Ở đây CSI tụt 50% từ W1 xuống W6 " "(0.601 → 0.298) trong khi EDI tụt 59% và ETS tụt 70% — ETS phóng đại mức " "suy giảm vì base rate của W1 (0.400) cao hơn W2–W6 (~0.306).
") out.append("
Nền xanh = đạt chỉ tiêu (POD > 0.6, " "POFD < 0.2, FAR < 0.2, FBI trong khoảng 1 ± 0.15, " "CSI > 0.4), nền hồng = chưa đạt.
") return "".join(out) def exp3w_cell_tables(by_cell, weeks): """126 bảng contingency 2x2: 21 điểm x 6 tuần, nhóm theo tuần.""" out = [] for w in weeks: ks = [k for k in by_cell if k.split("|")[1] == w] if not ks: continue out.append("

%s — 21 điểm

" % escape(w)) for k in sorted(ks, key=lambda x: int(x.split("|")[0])): s = by_cell[k] ip = int(k.split("|")[0]) out.append( "
" "" "" "" "
Điểm %d · %s — %s " "(%.2f°N, %.2f°E)
QT cóQT không
DB có%d%d
DB không%d%d
" "
" "POD %.3f · FAR %.3f · FBI %.3f · CSI %.3f · ETS %.3f
" "base %.3f · n = %d · cổng đóng %.0f%%
" "tầng 2 khớp trên %d mẫu
" % (ip, escape(w), escape(C.REGION_NAMES.get(int(s.get("region", 0)), "?")), s.get("lat", float("nan")), s.get("lon", float("nan")), int(s["hit"]), int(s["false_alarm"]), int(s["miss"]), int(s["correct_neg"]), s["POD"], s["FAR"], s["FBI"], s["CSI"], s["ETS"], s["base_rate"], int(s["n"]), 100 * s.get("gate_closed_frac", float("nan")), int(s.get("n_stage2_train", 0)))) return "".join(out) def _png_of(fig): """Nhúng hình vào trang dưới dạng base64, như các mục bản đồ ở trên.""" import io, base64 buf = io.BytesIO() fig.savefig(buf, format="png", dpi=110, bbox_inches="tight") import matplotlib.pyplot as plt plt.close(fig) return base64.b64encode(buf.getvalue()).decode("ascii") def s1_model_table(models): out = ["" % len(models), ""] for i, m in enumerate(models, 1): bg = " style='background:#fff6e5'" if m.get("new") else "" kind = ("chuỗi lead day" if m.get("kind") == "seq" else "đặc trưng tuần") out.append("" "" % (bg, i, escape(m["name"]), kind, escape(m["desc"]))) out.append("
Danh mục mô hình của tầng 1 — %d họ, ô nền vàng là họ " "MỚI thêm so với bản 7 họ
#TênĐầu vàoCơ chế học
%d%s%s%s
") return "".join(out) def s1_week_table(by_week, weeks, nb, nf): out = ["" % (nf, nb), "" "" "" % (nb, nf), "" "" "" ""] for w in weeks: if w not in by_week: continue b, f = by_week[w]["base"], by_week[w]["full"] cells = [] for g in (b, f): gain = g["NPV"] - g["NPV_ref"] bg = "#e6f4ea" if gain > 0.02 else ("#fce8e6" if gain < 0 else "") cells.append( "" "%.3f" % (g["AUC"], g["p1"], 100 * g["closure"], (" style='background:%s'" % bg) if bg else "", g["NPV"], 100 * g["leakage"])) out.append("%s%s" % (escape(w), b["base_rate"], cells[0], cells[1])) out.append("
Chất lượng cổng nEHDE — %d thành viên so với %d thành " "viên, ngưỡng p1 chốt trên trường ngoài mẫu với điều kiện " "rò rỉ ≤ 5%%
Tuầnbase
rate
N = %d thành viên (bản gốc)N = %d thành viên (mở rộng)
AUCp1đóngtinh khiếtrò rỉAUCp1đóngtinh khiếtrò rỉ
%.3f%.2f%.1f%%%.1f%%
%s%.3f
") out.append( "
Đọc bảng thế nào. đóng = tỷ lệ mẫu bị kết " "luận nEHDE và loại khỏi tầng 2. tinh khiết = trong số bị đóng, tỷ " "lệ thật sự nEHDE — phải so với mốc đóng bừa là 1 − base rate " "(nền xanh nếu vượt mốc trên 0.02, nền hồng nếu thua mốc). rò rỉ = " "tỷ lệ SỰ KIỆN thật bị cổng ném đi; đây là con số quyết định cổng có dùng " "được hay không, và ngưỡng p1 được chốt sao cho rò rỉ ngoài mẫu " "≤ 5%. AUC đo sức phân biệt của chính điểm số p, không phụ thuộc " "ngưỡng.
") return "".join(out) def s1_curve_fig(curve, weeks): """Đường đánh đổi: đóng được bao nhiêu mẫu thì mất bao nhiêu sự kiện.""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.6)) cols = plt.cm.viridis(np.linspace(0, 0.9, len(weeks))) for ax, tag, ttl in ((axes[0], "base", "N = 63 thành viên (bản gốc)"), (axes[1], "full", "N = 252 thành viên (mở rộng)")): for i, w in enumerate(weeks): k = "%s|%s" % (w, tag) if k not in curve: continue cl = [100 * r["closure"] for r in curve[k]] lk = [100 * r["leakage"] for r in curve[k]] ax.plot(cl, lk, "-o", ms=2.5, lw=1.3, color=cols[i], label=w) ax.axhline(5.0, color="crimson", lw=1.0, ls="--") ax.text(1, 5.4, "giới hạn rò rỉ 5 %", color="crimson", fontsize=8) ax.set_xlabel("% mẫu bị đóng (kết luận nEHDE)") ax.set_ylabel("% sự kiện thật bị ném đi") ax.set_title(ttl, fontsize=10) ax.set_xlim(0, 100); ax.set_ylim(0, 40) ax.grid(alpha=0.3) axes[0].legend(fontsize=8, ncol=2) return _png_of(fig) def s1_family_table(family): rows = sorted(family.items(), key=lambda kv: -(kv[1]["AUC_val"] if kv[1]["AUC_val"] == kv[1]["AUC_val"] else -1)) out = ["", "" ""] for i, (nm, v) in enumerate(rows, 1): bg = " style='background:#fff6e5'" if v.get("new") else "" out.append("" "" % (bg, i, escape(nm), "chuỗi" if v["kind"] == "seq" else "tuần", v["AUC_oof"], v["AUC_val"], v["vote_mean"], "mới" if v.get("new") else "gốc")) out.append("
Sức phân biệt của TỪNG họ, đứng một mình (AUC của số " "phiếu chính họ đó, gộp cả 126 ô)
#HọĐầu vàoAUC ngoài mẫuAUC kiểm địnhphiếu TBmới?
%d%s%s%.3f%.3f%.3f%s
") return "".join(out) def s1_subset_table(subs, weeks): """Tổ hợp con nào cho cổng tốt nhất — tính lại từ phiếu đã lưu.""" rows = sorted(subs.items(), key=lambda kv: -kv[1]["AUC_mean"]) out = ["", "" + "".join("" % escape(w) for w in weeks) + ""] best = rows[0][1]["AUC_mean"] for nm, v in rows: mark = (" style='background:#e8f0fe;font-weight:600'" if v["AUC_mean"] >= best - 1e-9 else "") cells = "".join("" % v["by_week"][w]["AUC"] if w in v["by_week"] else "" for w in weeks) out.append("%s" "" % (mark, escape(nm), v["n_family"], v["n_member"], cells, v["AUC_mean"])) out.append("
AUC của điểm số cổng theo TỔ HỢP HỌ — cùng một lần " "khớp mô hình, chỉ khác cách gộp phiếu
Tổ hợpsố họN thành viên%sAUC TB
%.3f—%s%d%d%.3f
") return "".join(out) def s1_cell_matrix(by_cell, weeks, points): """Ma trận 21 điểm x 6 tuần: đóng được bao nhiêu, tinh khiết bao nhiêu.""" out = ["", "" + "".join("" % escape(w) for w in weeks) + ""] for p in points: cells, reg = [], "?" for w in weeks: k = "%d|%s" % (p, w) if k not in by_cell: cells.append(""); continue s = by_cell[k] reg = C.REGION_NAMES.get(int(s.get("region", 0)), "?") gain = s["NPV"] - s["NPV_ref"] bg = "#e6f4ea" if gain > 0.02 else ("#fce8e6" if gain < 0 else "") cells.append("%.0f%% / %.2f / %.0f%%" % ((" style='background:%s'" % bg) if bg else "", 100 * s["closure"], s["NPV"], 100 * s["leakage"])) out.append("%s" % (p, escape(reg), "".join(cells))) out.append("
Từng ô lưới — % mẫu bị đóng / độ tinh khiết" " / % sự kiện rò rỉ
ĐiểmVùng%s
—
%d%s
") return "".join(out) S1B_ORDER = ["p_raw (EC gốc)", "A không neo", "B1 neo bằng Sev", "B2 neo bằng xác suất"] def s1b_table(by_week, weeks): """So bốn phương án trên cùng một tập kiểm định, cùng một cách chốt ngưỡng.""" cols = [c for c in S1B_ORDER if any(c in by_week.get(w, {}) for w in weeks)] out = ["", "" + "".join("" % escape(c) for c in cols) + "", "" + "".join("" "" for _ in cols) + ""] for w in weeks: blk = by_week.get(w, {}) if not blk: continue best = max((blk[c]["AUC"] for c in cols if c in blk), default=None) cells = [] for c in cols: if c not in blk: cells.append(""); continue g = blk[c] mk = (" style='background:#e6f4ea;font-weight:600'" if best is not None and g["AUC"] >= best - 1e-9 else "") cells.append("%.3f" "" % (mk, g["AUC"], 100 * g["closure"], g["NPV"], 100 * g["leakage"])) out.append("%s" % (escape(w), "".join(cells))) out.append("
Cổng nEHDE — mốc tham chiếu EC gốc so với ba cách " "dựng thành viên AI
Tuần%s
AUCđóngtinh khiếtrò rỉ
—%.0f%%%.3f%.1f%%
%s
") out.append( "
Ô xanh là AUC cao nhất của tuần đó. Ngưỡng " "p1 của cả bốn cột đều chốt theo cùng một quy tắc — trên " "trường ngoài mẫu của các năm huấn luyện, với điều kiện rò rỉ ≤ 5% số sự " "kiện — nên đóng và tinh khiết so sánh được trực tiếp.
") return "".join(out) def s1b_family_table(fam): rows = sorted(fam.items(), key=lambda kv: -kv[1]["AUC_val"]) out = ["", ""] for i, (nm, v) in enumerate(rows, 1): out.append("" "" % (i, escape(nm), escape(v["desc"]), v["AUC_val"])) out.append("
Các họ có offset, xếp theo sức phân biệt
#HọCơ chếAUC kiểm định
%d%s%s%.3f
") return "".join(out) def w2_table(by_window, windows, tname, title): """Bảng hai tầng đầy đủ cho một định nghĩa target, quét theo cửa sổ gộp.""" out = ["" % escape(title), "" "" ""] for w in windows: k = "%s|%s" % (w, tname) if k not in by_window: continue g = by_window[k] out.append("" "%s%s%s%s%s" "" % (escape(w), int(g.get("k_days", 1)), g["base_rate"], 100 * g.get("closure_val", float("nan")), _goal_cell("POD", g["POD"]), _goal_cell("POFD", g["POFD"]), _goal_cell("FAR", g["FAR"]), _goal_cell("FBI", g["FBI"]), _goal_cell("CSI", g["CSI"]), g["ETS"], g["EDI"], g["SEDI"], g["AUC"])) out.append("
%s
Cửa sổk ngàybase ratecổng đóngPODPOFDFARFBICSIETSEDISEDIAUC
%s%d%.3f%.0f%%%.3f%.3f%.3f%.3f
") return "".join(out) def w2_sweep_table(sw, windows): """Điểm vận hành tốt nhất tìm được khi cho phép FBI khác 1.""" out = ["", "" "" ""] rows = sw.get("by_window", {}) feas = sw.get("feasible", {}) for w in windows: for t in ("so1", "eqbase"): k = "%s|%s" % (w, t) if k not in rows: continue if k in feas: g = feas[k]; ok = True else: cand = [x for x in rows[k] if x["POD"] > 0.60] if not cand: continue g = min(cand, key=lambda x: x["FAR"]); ok = False bg = "#e6f4ea" if ok else "#fff6e5" out.append("" "" "" "" % (bg, escape(w), escape(t), g["r"], g["POD"], g["POFD"], g["FAR"], g["FBI"], g["CSI"], g["EDI"], g["SEDI"], "ĐẠT" if ok else "hụt")) out.append("
Quét mức thiên lệch dự báo — cấu hình nào ĐẠT toàn bộ " "chỉ tiêu
Cửa sổtargetr = FBI mục tiêuPODPOFDFARFBICSIEDISEDIkết luận
%s%s%.2f%.3f%.3f%.3f%.3f%.3f%.3f%.3f%s
") return "".join(out) def w2_required_auc(by_window, windows, tname): """AUC cần có để đạt POD>0.6 kèm FAR<0.2, so với AUC thực có.""" from scipy.stats import norm as _nm out = ["", "" ""] for w in windows: k = "%s|%s" % (w, tname) if k not in by_window: continue g = by_window[k] sbr = g["base_rate"] if not (0 < sbr < 1): continue a = 0.60 * sbr b = a * 0.2 / 0.8 pofd = b / (1 - sbr) if not (0 < pofd < 1): continue need = float(_nm.cdf((_nm.ppf(0.60) - _nm.ppf(pofd)) / np.sqrt(2))) gap = g["AUC"] - need bg = "#e6f4ea" if gap >= 0 else "#fce8e6" out.append("" "" % (escape(w), sbr, pofd, need, g["AUC"], bg, gap)) out.append("
Khoảng cách tới chỉ tiêu, đo bằng AUC
Cửa sổbase ratePOFD cầnAUC CẦNAUC cóthiếu
%s%.3f%.3f%.3f%.3f%+.3f
") return "".join(out) P2_WINDOWS = ["W1", "W1-2", "W1-3", "W1-6"] P2_MAPS = [("POD", "POD — tỷ lệ bắt được sự kiện", "YlGn", (0.0, 1.0)), ("FAR", "FAR — tỷ lệ báo động sai", "OrRd", (0.0, 1.0)), ("CSI", "CSI", "YlGnBu", (0.0, 0.9)), ("SEDI", "SEDI — kỹ năng độc lập base rate", "PuBuGn", (0.0, 1.0))] def p2_map_row(per_point, variant, metric, grid, borders, title, cmap, rng): """Một hàng bản đồ: mỗi cửa sổ một tấm, chung thang màu.""" import io, base64 import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.colors import BoundaryNorm wins = [w for w in P2_WINDOWS if "%s|%s" % (variant, w) in per_point] if not wins: return None lev = np.linspace(rng[0], rng[1], 11) norm = BoundaryNorm(lev, ncolors=256, clip=False) fig, axes = plt.subplots(1, len(wins), figsize=(2.6 * len(wins) + 1.4, 4.4), gridspec_kw=dict(wspace=0.02, left=0.012, right=0.90, top=0.90, bottom=0.04)) axes = np.atleast_1d(axes) for ax, w in zip(axes, wins): v = np.array([np.nan if x is None else x for x in per_point["%s|%s" % (variant, w)][metric]], dtype="f4") fld = grid.to_grid(v) ax.set_facecolor("#f2f2f2") mesh = ax.pcolormesh(grid.lon, grid.lat, np.ma.masked_invalid(fld), cmap=cmap, norm=norm, shading="nearest") for poly in borders: ax.plot(poly[:, 0], poly[:, 1], color="0.25", lw=0.45) ax.set_xlim(grid.lon.min() - 0.125, grid.lon.max() + 0.125) ax.set_ylim(grid.lat.min() - 0.125, grid.lat.max() + 0.125) ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) for sp in ax.spines.values(): sp.set_linewidth(0.5); sp.set_color("0.6") ax.set_title(w, fontsize=11, pad=3) cax = fig.add_axes([0.915, 0.06, 0.014, 0.80]) cb = fig.colorbar(mesh, cax=cax, ticks=lev[::2]) cb.ax.tick_params(labelsize=8); cb.outline.set_linewidth(0.5) fig.suptitle(title, fontsize=12.5, y=0.985, x=0.012, ha="left") buf = io.BytesIO() fig.savefig(buf, format="png", dpi=125, facecolor="white") plt.close(fig) return base64.b64encode(buf.getvalue()).decode("ascii") def p2_national_table(nat, chosen, variant): out = ["" % escape(variant), "" "" ""] for w in P2_WINDOWS: k = "%s|%s" % (variant, w) if k not in nat: continue g = nat[k] out.append("%s%s%s%s%s" "" % (escape(w), chosen[k]["r"], g["base_rate"], _goal_cell("POD", g["POD"]), _goal_cell("POFD", g["POFD"]), _goal_cell("FAR", g["FAR"]), _goal_cell("FBI", g["FBI"]), _goal_cell("CSI", g["CSI"]), g["ETS"], g["EDI"], g["SEDI"])) out.append("
Toàn miền 541 ô lưới — kiến trúc %s
Cửa sổrbase ratePODPOFDFARFBICSIETSEDISEDI
%s%.2f%.3f%.3f%.3f%.3f
") return "".join(out) def p2_region_table(byreg, variant): out = ["" % escape(variant), "" + "".join("" % escape(w) for w in P2_WINDOWS) + ""] for r in range(1, 8): cells = [] for w in P2_WINDOWS: k = "%s|%s" % (variant, w) if k not in byreg: cells.append(""); continue g = byreg[k]["S%d" % r] good = (g["POD"] > 0.60 and g["POFD"] < 0.20 and g["FAR"] < 0.20 and g["CSI"] > 0.40) cells.append("" % ("#e6f4ea" if good else "#fce8e6", g["POD"], g["FAR"], g["CSI"], g["SEDI"])) out.append("%s" % (escape(C.REGION_NAMES.get(r, "S%d" % r)), "".join(cells))) out.append("
Theo từng vùng khí hậu — kiến trúc %s (POD / FAR / CSI " "/ SEDI)
Vùng%s
—%.2f / %.2f / %.2f / %.2f
%s
") out.append("
Nền xanh = vùng đó đạt đủ POD > 0.6, " "POFD < 0.2, FAR < 0.2, CSI > 0.4.
") return "".join(out) WT_MODES = ["ĐỦ", "CSI", "FAR", "POD", "SEDI"] WT_WEEKS = ["W1", "W2", "W3", "W4", "W5", "W6"] def wt_table(wt, tname, title): out = ["" % escape(title), "" ""] for w in WT_WEEKS: k = "%s|%s" % (tname, w) if k not in wt: continue picks = wt[k]["picks"] base = wt[k]["curve"][0]["base_rate"] for i, mode in enumerate(WT_MODES): g = picks.get(mode) head = ("" % (len(WT_MODES), escape(w), len(WT_MODES), base)) if i == 0 else "" if g is None: out.append("%s" % (head, escape(mode))) continue bg = " style='background:#e6f4ea;font-weight:600'" if mode == "ĐỦ" else "" out.append("%s%s%s%s%s%s%s" % (head, bg, escape(mode), g["r"], _goal_cell("POD", g["POD"]), _goal_cell("POFD", g["POFD"]), _goal_cell("FAR", g["FAR"]), _goal_cell("FBI", g["FBI"]), _goal_cell("CSI", g["CSI"]), g["SEDI"])) out.append("
%s
Tuầnbasechế độrPODPOFDFARFBICSISEDI
%s%.3f
%skhông có điểm nào
%.2f%.3f
") return "".join(out) def wt_curve_fig(wt, tname): """Đường đánh đổi POD-FAR của từng tuần, toàn bộ dải ngưỡng.""" import io, base64 import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7.2, 5.0)) cols = plt.cm.viridis(np.linspace(0, 0.88, len(WT_WEEKS))) for i, w in enumerate(WT_WEEKS): k = "%s|%s" % (tname, w) if k not in wt: continue rows = wt[k]["curve"] ax.plot([g["FAR"] for g in rows], [g["POD"] for g in rows], "-", lw=1.6, color=cols[i], label=w) ax.axvline(0.20, color="crimson", ls="--", lw=1.0) ax.axhline(0.60, color="crimson", ls="--", lw=1.0) ax.add_patch(plt.Rectangle((0, 0.60), 0.20, 0.40, facecolor="#e6f4ea", alpha=0.6, zorder=0)) ax.text(0.015, 0.955, "vùng đạt chỉ tiêu\n(POD > 0.6, FAR < 0.2)", fontsize=9, color="#1b5e20", va="top") ax.set_xlabel("FAR — tỷ lệ báo động sai") ax.set_ylabel("POD — tỷ lệ bắt được sự kiện") ax.set_xlim(0, 0.75); ax.set_ylim(0, 1.0) ax.grid(alpha=0.3); ax.legend(fontsize=9, loc="lower right") buf = io.BytesIO() fig.savefig(buf, format="png", dpi=125, bbox_inches="tight", facecolor="white") plt.close(fig) return base64.b64encode(buf.getvalue()).decode("ascii") FV_MAPS = [ ("POD", "POD — tỷ lệ bắt được đợt nóng (cao là tốt)", "YlGn", (0.0, 1.0)), ("POFD", "POFD — tỷ lệ báo nhầm trên nền không sự kiện (thấp là tốt)", "OrRd", (0.0, 0.6)), ("FAR", "FAR — tỷ lệ báo động sai (thấp là tốt)", "OrRd", (0.0, 1.0)), ("FBI", "FBI — độ thiên lệch tần suất (1 là không thiên lệch)", "RdBu_r", (0.0, 2.0)), ("CSI", "CSI", "YlGnBu", (0.0, 0.9)), ("ETS", "ETS", "YlGnBu", (0.0, 0.6)), ("EDI", "EDI — độc lập base rate", "PuBuGn", (0.0, 1.0)), ("SEDI", "SEDI — độc lập base rate, không hedging được", "PuBuGn", (0.0, 1.0)), ("AUC", "AUC của trường xác suất", "PuBuGn", (0.4, 1.0)), ("base_rate", "Tần suất xảy ra EHDE quan trắc", "Purples", (0.0, 0.8)), ] def fv_map_row(path, metric, grid, borders, title, cmap, rng): """Sáu bản đồ trên một hàng, một tấm cho mỗi tuần, chung thang màu.""" import io, base64 import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.colors import BoundaryNorm from netCDF4 import Dataset as _DS with _DS(path) as ds: if metric not in ds.variables: return None fld = np.ma.filled(ds[metric][:], np.nan).astype("f4") lev = np.linspace(rng[0], rng[1], 11) norm = BoundaryNorm(lev, ncolors=256, clip=False) fig, axes = plt.subplots(1, fld.shape[0], figsize=(13.2, 3.9), gridspec_kw=dict(wspace=0.02, left=0.012, right=0.90, top=0.90, bottom=0.04)) axes = np.atleast_1d(axes) for k, ax in enumerate(axes): ax.set_facecolor("#f2f2f2") mesh = ax.pcolormesh(grid.lon, grid.lat, np.ma.masked_invalid(fld[k]), cmap=cmap, norm=norm, shading="nearest") for poly in borders: ax.plot(poly[:, 0], poly[:, 1], color="0.25", lw=0.45) ax.set_xlim(grid.lon.min() - 0.125, grid.lon.max() + 0.125) ax.set_ylim(grid.lat.min() - 0.125, grid.lat.max() + 0.125) ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) for sp in ax.spines.values(): sp.set_linewidth(0.5); sp.set_color("0.6") ax.set_title(C.WEEK_NAMES[k], fontsize=11, pad=3) cax = fig.add_axes([0.915, 0.06, 0.014, 0.80]) cb = fig.colorbar(mesh, cax=cax, ticks=lev[::2]) cb.ax.tick_params(labelsize=8); cb.outline.set_linewidth(0.5) fig.suptitle(title, fontsize=12.5, y=0.985, x=0.012, ha="left") buf = io.BytesIO() fig.savefig(buf, format="png", dpi=125, facecolor="white") plt.close(fig) return base64.b64encode(buf.getvalue()).decode("ascii") def fv_national_table(nat): out = ["", "" "" ""] for w in C.WEEK_NAMES: if w not in nat: continue g = nat[w] out.append("%s%s%s%s%s" "" % (escape(w), g["base_rate"], _goal_cell("POD", g["POD"]), _goal_cell("POFD", g["POFD"]), _goal_cell("FAR", g["FAR"]), _goal_cell("FBI", g["FBI"]), _goal_cell("CSI", g["CSI"]), g["ETS"], g["EDI"], g["SEDI"], g["AUC"])) out.append("
Mô hình cuối cùng — toàn quốc, 541 ô lưới x 416 lần " "khởi tạo của 4 năm kiểm định
Tuầnbase ratePODPOFDFARFBICSIETSEDISEDIAUC
%s%.3f%.3f%.3f%.3f%.3f
") return "".join(out) def fv_region_table(byreg): out = ["", "" + "".join("" % escape(w) for w in C.WEEK_NAMES) + ""] for r in range(1, 8): cells = [] for w in C.WEEK_NAMES: g = byreg.get(w, {}).get("S%d" % r) if g is None: cells.append(""); continue good = (g["POD"] > 0.60 and g["POFD"] < 0.20 and g["FAR"] < 0.20 and g["CSI"] > 0.40) cells.append("" % ("#e6f4ea" if good else "#fce8e6", g["POD"], g["FAR"], g["CSI"], g["SEDI"])) out.append("%s" % (escape(C.REGION_NAMES.get(r, "S%d" % r)), "".join(cells))) out.append("
Mô hình cuối cùng theo vùng khí hậu " "(POD / FAR / CSI / SEDI)
Vùng%s
—%.2f / %.2f / %.2f / %.2f
%s
") out.append("
Nền xanh = vùng đó đạt đủ POD > 0.6, " "POFD < 0.2, FAR < 0.2, CSI > 0.4.
") return "".join(out) def exp3_model_table(models): out = ["", ""] for i, m in enumerate(models, 1): out.append("" % (i, escape(m["name"]), escape(m["desc"]))) out.append("
Danh mục các mô hình AI dùng trong cả hai lần dự báo" "
#TênCơ chế học
%d%s%s
") return "".join(out) def exp3_sweep_table(oof, val, best): """Ngưỡng chốt trên trường ngoài mẫu; cột kiểm định chỉ để đối chiếu.""" keys = sorted(oof, key=lambda k: -oof[k]["CSI"]) out = ["", "" "" "", "" ""] for k in keys: a, b = oof[k], val[k] mark = " style='background:#e8f0fe;font-weight:600'" if k == best else "" out.append("" "" % (mark, escape(k), a["CSI"], a["POD"], a["FBI"], b["POD"], b["POFD"], b["FAR"], b["FBI"], b["CSI"])) out.append("
Quét ngưỡng (p1, p2) — xếp theo CSI " "ngoài mẫu
Cấu hìnhNgoài mẫu (16 năm huấn luyện) — dùng để CHỌNTập kiểm định (4 năm) — chỉ để ĐÁNH GIÁ
CSIPODFBIPODPOFDFARFBICSI
%s%.3f%.3f%.3f%.3f%.3f%.3f%.3f%.3f
") return "".join(out) def exp3_point_tables(by_point, grid): """Một bảng contingency 2x2 cho mỗi điểm trong 21 điểm.""" out = [] for k in sorted(by_point, key=lambda x: int(x)): s = by_point[k] p = int(k) out.append( "
" "" "" "" "" "
Điểm %d — %s (%.2f°N, %.2f°E)
Quan trắc cóQuan trắc không
Dự báo có%d%d
Dự báo không%d%d
" "
" "POD %.3f · FAR %.3f · FBI %.3f · CSI %.3f · ETS %.3f · PC %.3f
" "base %.3f · cổng đóng %.0f%% số mẫu
" % (p, escape(C.REGION_NAMES.get(int(s.get("region", 0)), "?")), s.get("lat", float("nan")), s.get("lon", float("nan")), int(s["hit"]), int(s["false_alarm"]), int(s["miss"]), int(s["correct_neg"]), s["POD"], s["FAR"], s["FBI"], s["CSI"], s["ETS"], s["PC"], s["base_rate"], 100 * s.get("gate_closed_frac", float("nan")))) return "".join(out) PAGE = """ %(title)s

%(title)s

%(subtitle)s

Tạo lúc %(stamp)s  ·  nguồn %(path)s  ·  các mô hình có trong bản này: %(runs)s

%(toc)s %(body)s """ def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default=os.path.join(C.ROOT, "ehd_ai_results.html")) ap.add_argument("--sel", default=os.path.join(C.OUT, "AI_Results", "model_selection.json")) args = ap.parse_args() grid = C.Grid() borders = load_regions() body = [] body.append("
Cách đọc bảng. Ô " " xanh  đạt ngưỡng mục tiêu, ô " " đỏ  chưa đạt. Chỉ chấm điểm từ W4 trở đi theo yêu cầu. " "Mục tiêu: POD > 0.6, POFD < 0.2, FAR < 0.2, CSI > 0.4, FBI ≈ 1.
" "Chia tách theo thời điểm khởi tạo: huấn luyện %d năm, kiểm định %s. " "Ngưỡng quyết định chọn bằng dự báo out-of-fold trong tập huấn luyện, " "riêng cho từng tuần — không bao giờ dò trên năm kiểm định." "
" % (len(C.TRAIN_YEARS), ", ".join(map(str, C.VALID_YEARS)))) if os.path.exists(args.sel): with open(args.sel) as fh: sel = json.load(fh) body.append("

1. So sánh phương án trên 21 điểm kiểm thử (3 điểm/vùng)

") for frame in ("daily_all", "daily_capable", "weekly_all"): body.append("

%s

" % escape(FRAME_TITLES[frame])) for metric in ("CSI", "POD", "FAR", "AUC"): body.append(compare_table(sel, frame, metric)) runs = {} for path in sorted(glob.glob(os.path.join(C.OUT, "AI_Results", "national_*.json"))): with open(path) as fh: runs[os.path.basename(path)[len("national_"):-len(".json")]] = json.load(fh) if runs: body.append("

2. Tổng hợp: mọi cấu hình toàn quốc cạnh nhau

") body.append("
Ngưỡng luôn dò trên out-of-fold trong các năm " "huấn luyện. Ô xanh đạt mục tiêu, ô đỏ chưa đạt.
") body.append(summary_table({k: v["summary"] for k, v in runs.items()})) for name, nat in sorted(runs.items()): body.append("

3. Mô hình toàn quốc — %s

" % (escape(re.sub(r"[^A-Za-z0-9]+", "-", name)), escape(name))) for frame, tab in nat["summary"].items(): body.append(table(tab, FRAME_TITLES.get(frame, frame))) if nat.get("probabilistic"): body.append(prob_table(nat["probabilistic"])) if nat.get("by_region"): for frame in REGION_FRAMES: body.append(region_table(nat["by_region"], frame)) e3_path = os.path.join(RES_GLOB, "exp3_multi.json") if os.path.exists(e3_path): with open(e3_path) as fh: e3 = json.load(fh) cfg = e3["config"] body.append("

3d. EXP3 — N mô hình AI tại mỗi ô lưới, " "quyết định hai tầng bằng p = M/N

") body.append( "
Thiết kế. Mỗi ô lưới trong %d điểm có bộ mô " "hình riêng, target là Sev_obs giữ liên tục và có dấu. " "M trong N thành viên cho yEHDE ⇒ p = M/N. Tầng 1: p < p1 " "kết luận nEHDE và bỏ mẫu; phần còn lại đưa sang tầng 2, khớp lại rồi " "quyết định bằng p2. Đánh giá trên toàn bộ tập kiểm " "định, gộp kết quả cả hai tầng.
" "N = %d họ mô hình × %d mức phân vị = %d thành viên. Mỗi họ khớp " "kỳ vọng μ̂ và độ tán σ̂ của phần dư (σ̂ ước lượng theo thập phân vị của " "μ̂ nên bắt được tính không đồng nhất phương sai); thành viên (họ f, " "phân vị q) dự báo μ̂_f + σ̂_f·z_q. Công thức p = M/N giữ " "nguyên, chỉ khác bản chất một “thành viên”.
" % (len(cfg["points"]), cfg["n_family"], cfg["n_quantile"], cfg["n_members"])) body.append( "
Cảnh báo — mục này " "gộp cả 46 lead day vào một mô hình và một bảng. Người dùng phát " "hiện ngày 2026-08-30: “dung lượng mẫu 19136” ở đây là " "416 initialisation × 46 lead day, tức mỗi initialisation bị đếm 46 " "lần chứ không phải 19136 trường hợp độc lập. Số mẫu độc lập thật của " "một ô lưới chỉ là 1664 (huấn luyện) và 416 (kiểm định). Hệ quả nặng " "hơn: mọi khác biệt giữa W1 và W6 bị trộn phẳng nên bảng dưới đây " "không đánh giá được theo hạn dự báo. Bản đã sửa là " "mục 3e; mục 3d giữ lại chỉ để đối chiếu.
") body.append(exp3_model_table(e3["models"])) dg = e3.get("degenerate", {}) if dg: q = np.mean([v["quantile_vote_mean_p"] for v in dg.values()]) mv = np.mean([v["mean_vote_mean_p"] for v in dg.values()]) tt = np.mean([v["obs_frac_pos"] for v in dg.values()]) body.append( "
Vì sao thành viên " "phải là cặp (họ, phân vị). Nếu đếm phiếu trên đúng N kỳ vọng của " "N họ — cách đọc nguyên văn của đặc tả — thì p trung bình chỉ đạt " "%.4f trong khi tần suất thật là %.4f, tức thấp hơn " "khoảng %.0f lần, và không ngưỡng p nào cứu được. Nguyên nhân không " "phải các mô hình giống nhau: tám họ hoàn toàn khác nhau về bản chất " "(tuyến tính, cây tăng cường, rừng ngẫu nhiên, cây cực ngẫu nhiên, " "láng giềng gần, mạng nơ-ron) đều dự báo Sev ≥ 0 ở dưới 0.9%%, vì " "mọi mô hình đều ước lượng kỳ vọng có điều kiện của Sev, vốn " "gần như luôn âm (trung bình −1.43 °C). Với thành viên theo phân vị, " "p trung bình đạt %.4f — khớp tần suất thật.
" % (mv, tt, tt / max(mv, 1e-9), q)) if e3.get("sweep_oof"): b = e3.get("best", {}) bk = "p1=%.2f,p2=%.2f" % (b.get("p1", -1), b.get("p2", -1)) body.append(exp3_sweep_table(e3["sweep_oof"], e3["sweep"], bk)) body.append( "
Ngưỡng được chốt trên trường ngoài mẫu của các " "năm huấn luyện, không bao giờ trên tập kiểm định. Chọn theo CSI " "của tập kiểm định chính là dò tham số trên tập test: làm vậy sẽ ra " "CSI 0.217 thay vì 0.211, tức thổi lên khoảng 3%. Bảng kiểm định ở " "đây vì thế là cận trên, không phải kết quả vận hành.
") if e3.get("by_point"): body.append("

21 bảng contingency — từng điểm lưới

") body.append("
Tại cấu hình đã chốt " "p1 = %.2f, p2 = %.2f. " "“Cổng đóng” là tỷ lệ mẫu bị tầng 1 kết luận " "nEHDE nên chỉ cần một lần dự báo.
" % (e3["best"]["p1"], e3["best"]["p2"])) body.append(exp3_point_tables(e3["by_point"], grid)) e3w_path = os.path.join(RES_GLOB, "exp3_week.json") if os.path.exists(e3w_path): with open(e3w_path) as fh: e3w = json.load(fh) cfg = e3w["config"] wk = cfg["weeks"] body.append("

3e. EXP3 gộp theo lead week — N mô hình AI cho " "từng (ô lưới, tuần)

") body.append( "
Một mẫu = một initialisation. Mỗi ô lưới và " "mỗi tuần lead có đúng 2080 mẫu cho 20 năm, tách thành " "1664 mẫu huấn luyện (16 năm) và 416 mẫu kiểm định " "(4 năm) — không còn nhân lên 46 lần theo lead day như mục 3d.
" "Định nghĩa yEHDE theo tuần: tuần đó có ít nhất một ngày " "EHD, tương đương Sev_week = max(Sev_obs trong tuần) ≥ 0, " "nên target hồi quy vẫn là Sev_obs giữ liên tục và có " "dấu đúng đặc tả EXP3.
" "%d điểm × %d tuần = %d bộ mô hình riêng biệt, mỗi bộ " "N = %d họ × %d mức phân vị = %d thành viên. Sáu tuần " "không gộp chung vào một mô hình.
" "Đặc trưng tuần: trung bình tuần của toàn bộ đặc trưng %s, cực " "đại tuần của nhóm nhạy nhiệt, và số ngày trong tuần mà chính mô hình " "EC cho là nóng.
" % (len(cfg["points"]), len(wk), len(cfg["points"]) * len(wk), cfg["n_family"], cfg["n_quantile"], cfg["n_members"], escape(cfg["fset"]))) body.append(exp3_model_table(e3w["models"])) body.append( "
Gộp tuần làm nhẹ hẳn " "mất cân bằng lớp. Base rate theo từng ngày là 0.097; gộp theo " "tuần lên 0.437 ở W1 (11 ngày) và 0.334 ở W2–W6 (7 " "ngày). Tức tỷ lệ nEHDE là ~67%, không phải ~90%, nên tập đưa sang " "dự báo lần 2 còn 600–850 mẫu huấn luyện chứ không phải ~200.
") if e3w.get("by_week"): body.append(exp3w_summary_table(e3w["by_week"], wk)) body.append( "
Vì sao không chốt " "ngưỡng bằng max-CSI. Lần chạy đầu chốt p2 theo CSI " "ngoài mẫu thì W3–W6 đều chạy thẳng ra biên thấp nhất của lưới và cho " "FBI 2.38–2.59, tức dự báo thừa gấp 2.5 lần, dù CSI vẫn 0.33–0.35: " "CSI = a/(a+b+c) đổi một false alarm lấy một hit với giá ngang nhau " "nên khi mô hình yếu nó luôn thưởng cho dự báo thừa. Bảng trên dùng " "ngưỡng hiệu chỉnh riêng từng ô lưới theo tần suất khí hậu " "ngoài mẫu của chính ô đó (phân vị 1 − base rate), quy tắc không có " "bậc tự do nào để chạy ra biên. FBI kiểm định về 0.958–1.029 ở cả " "sáu tuần.
") body.append( "
Cổng tầng 1 gần như " "vô hiệu khi gộp theo tuần. Tỷ lệ mẫu kiểm định bị tầng 1 kết luận " "nEHDE tại p1 đã chốt: W1 18.0%, W2 5.0%, W3 2.4%, W4 1.6%, " "W5 1.2%, W6 50.9% — ở W2–W5 cấu trúc hai tầng suy biến thành một " "tầng. CSI kiểm định gần như phẳng theo p1 (W3: 0.323 / " "0.321 / 0.321 / 0.320 cho p1 = 0.05 / 0.10 / 0.20 / 0.30), " "và W6 đóng tới 51% số mẫu mà CSI chỉ nhích từ 0.294 lên 0.298. " "Nguyên nhân: cổng nEHDE được nghĩ ra cho bài toán ~10% ở mức ngày; " "khi target là biến tuần với base rate ~0.33 thì không còn khối nEHDE " "hiển nhiên nào để cắt. Muốn cổng có việc, phải siết tiêu chí tuần lên " "≥ 2 hoặc ≥ 3 ngày EHD (base rate 0.19 / 0.11).
") if e3w.get("by_cell"): body.append("

%d bảng contingency — từng (điểm, tuần)

" % len(e3w["by_cell"])) body.append( "
p1 chốt theo CSI ngoài mẫu riêng " "từng tuần; p2 là ngưỡng FBI = 1 riêng từng ô lưới. " "“Cổng đóng” là tỷ lệ mẫu bị tầng 1 kết luận nEHDE " "nên chỉ cần một lần dự báo. “Tầng 2 khớp trên” là số " "mẫu huấn luyện còn lại sau cổng — con số quyết định lần dự báo " "thứ hai có đủ mẫu hay không.
") body.append(exp3w_cell_tables(e3w["by_cell"], wk)) s1_path = os.path.join(RES_GLOB, "exp3_week_s1.json") if os.path.exists(s1_path): with open(s1_path) as fh: s1 = json.load(fh) cfg = s1["config"] wk = cfg["weeks"] body.append("

3f. EXP3 tầng 1 mở rộng — cổng nEHDE với " "N = %d thành viên

" % cfg["n_member_full"]) body.append( "
Mục này CHỈ làm tầng 1. Tầng 2 để sau, vì nó " "nhiều khả năng dùng bộ mô hình khác hẳn.
" "Tầng 1 chỉ phải trả lời một câu: mẫu này gần như chắc chắn " "nEHDE? Câu trả lời là p = M/N. Với N nhỏ, p chỉ nhận vài giá trị " "rời rạc nên cổng rất thô; tăng N bằng những họ THỰC SỰ khác nhau làm p " "mịn hơn và làm sai số các thành viên bớt tương quan.
" "N = %d họ × %d mức phân vị = %d thành viên, so với %d họ = %d " "thành viên của bản trước. Trong đó có bảy cấu hình ANN khác nhau về số " "lớp ẩn, số nút, hàm kích hoạt, dropout và phạt trọng số, cùng ba " "CNN 1-D chạy dọc chuỗi lead day trong tuần — CNN nhìn thấy thứ " "tự các ngày, thứ mà phép lấy trung bình và cực đại tuần đã xoá mất, nên " "phân biệt được tuần nóng dần lên với tuần nóng rồi nguội.
" "Dữ liệu giữ nguyên như mục 3e: một mẫu = một initialisation, " "1664 mẫu huấn luyện / 416 mẫu kiểm định cho mỗi (ô lưới, tuần).
" % (cfg["n_family_full"], cfg["n_quantile"], cfg["n_member_full"], cfg["n_family_base"], cfg["n_member_base"])) body.append(s1_model_table(s1["models"])) if s1.get("by_week"): body.append(s1_week_table(s1["by_week"], wk, cfg["n_member_base"], cfg["n_member_full"])) if s1.get("curve"): body.append("

Đánh đổi giữa lượng mẫu đóng được và số sự kiện mất đi

") body.append("
gate curve
" % s1_curve_fig(s1["curve"], wk)) body.append( "
Đường càng đi sát trục hoành càng tốt: đóng được " "nhiều mẫu mà mất ít sự kiện. Điểm cắt với đường đứt đỏ là ngưỡng " "vận hành được nếu chấp nhận rò rỉ 5% số sự kiện.
") if s1.get("subsets"): body.append("

Tăng số thành viên có làm cổng tốt lên không?

") body.append(s1_subset_table(s1["subsets"], wk)) body.append( "
Không — và đây là " "kết quả âm cần ghi nhận. Gộp cả 28 họ cho AUC trung bình " "0.722, THẤP HƠN chính 7 họ gốc (0.732). Lý do nằm ở " "công thức p = M/N: nó cho mọi thành viên phiếu bằng nhau, nên thêm " "thành viên yếu và tương quan với nhau chỉ làm loãng những thành " "viên mạnh. Bảy ANN torch đứng riêng chỉ đạt 0.688 và ba CNN 1-D " "chỉ đạt 0.664, trong khi năm họ TUYẾN TÍNH đơn giản nhất đạt " "0.733 — ngang với tổ hợp tốt nhất tìm được. Muốn số thành viên " "lớn có ích thì phải bỏ công thức đếm phiếu đều và chuyển sang gộp " "CÓ TRỌNG SỐ, hoặc lọc họ trước khi đếm.
") if s1.get("family"): body.append("

Họ nào thực sự đóng góp

") body.append(s1_family_table(s1["family"])) if s1.get("by_cell"): body.append("

Cổng hoạt động thế nào ở từng ô lưới

") body.append(s1_cell_matrix(s1["by_cell"], wk, cfg["points"])) s1b_path = os.path.join(RES_GLOB, "exp3_week_s1b.json") if os.path.exists(s1b_path): with open(s1b_path) as fh: s1b = json.load(fh) cfg = s1b["config"] wk = cfg["weeks"] body.append("

3g. Tầng 1 phương án (b) — thành viên NEO vào " "dự báo của chính EC

") body.append( "
Ý tưởng. Theo Van Straaten và cộng sự (2023, " "AIES 2), mạng không dự báo lại từ đầu mà chỉ học phần " "hiệu chỉnh đặt trên nền tiên nghiệm của mô hình động lực: " "log(p_raw) được cộng thẳng vào logit, nên đầu ra của mạng " "đọc được như logarit của một hệ số nhân phụ thuộc thời tiết lên xác " "suất của EC. Bảng C1 của họ cho thấy phải làm đúng như vậy — nhét " "p_raw vào làm một biến đầu vào bình thường chỉ đạt BS 0.190, còn cộng " "vào logit đạt 0.168.
" "Khác biệt bắt buộc. Họ có 11 thành phần tổ hợp nên p_raw đếm " "được trực tiếp. Dữ liệu ở đây chỉ có control forecast, không có chiều " "thành phần, nên p_raw phải hiệu chỉnh xác suất từ dự báo tất định. " "Phép hiệu chỉnh cố tình để dung lượng rất thấp (một hồi quy logistic " "bốn hệ số trên bốn thống kê tuần của EC) — nếu p_raw đã học nhiều thì " "phần “hiệu chỉnh” mất hết ý nghĩa. Nó chỉ dùng ĐẦU RA CỦA " "EC, không dùng quan trắc, theo đúng ràng buộc vận hành.
" "Bốn cột được so. p_raw là mốc tham chiếu của chính " "EC — mốc mà mọi mô hình AI phải vượt, và các bảng trước đây của dự án " "chưa hề có. A là cách đang dùng ở mục 3f: thành viên hồi " "quy thẳng Sev_obs. B1 hồi quy PHẦN DƯ " "Sev_obs − Sev_mod, dự báo cuối là " "Sev_mod + hiệu chỉnh. B2 là offset thật trên " "thang xác suất.
" "Công thức p = M/N giữ nguyên ở cả ba biến thể AI: thành viên hồi quy " "bỏ phiếu khi Sev dự báo ≥ 0; thành viên phân loại có offset bỏ phiếu " "khi pf ≥ τq, nên M/N vẫn nằm trong [0, 1] và xấp " "xỉ trung bình xác suất của các họ.
" "B1 dùng %d họ hồi quy, B2 dùng %d họ có offset, mỗi họ %d mức phân " "vị.
" % (cfg["n_family_b1"], cfg["n_family_b2"], cfg["n_quantile"])) if s1b.get("by_week"): body.append(s1b_table(s1b["by_week"], wk)) if s1b.get("family_b2"): body.append("

Trong nhóm có offset, họ nào mạnh

") body.append(s1b_family_table(s1b["family_b2"])) if s1b.get("models_b1"): body.append("

Danh mục mô hình

") body.append(exp3_model_table(s1b["models_b1"])) w2_path = os.path.join(RES_GLOB, "exp3_window2.json") if os.path.exists(w2_path): with open(w2_path) as fh: w2 = json.load(fh) cfg = w2["config"] wins = cfg["windows"] body.append("

3h. Hai tầng đầy đủ, quét theo độ dài cửa sổ " "gộp

") body.append( "
Tầng 1 loại ít. Cổng chốt bảo thủ — ngưỡng " "p1 lớn nhất mà rò rỉ ngoài mẫu vẫn ≤ 5%% số sự kiện — nên nó " "chỉ ném đi những mẫu gần như chắc chắn nEHDE, và tập còn lại bớt lệch " "hẳn.
" "Tầng 2 dùng LỚP MÔ HÌNH KHÁC tầng 1: %d họ PHÂN LOẠI trực tiếp " "yEHDE (logistic L2 và elastic-net, hai cấu hình cây tăng cường, " "ExtraTrees, RandomForest, ANN 16 nút), thay cho các họ hồi quy Sev của " "tầng 1. Ngưỡng quyết định hiệu chỉnh FBI = 1 riêng từng ô lưới. Đánh " "giá trên toàn bộ tập kiểm định: mẫu bị cổng đóng xuất ra nEHDE, " "mẫu còn lại lấy quyết định của tầng 2.
" "Quét cửa sổ gộp. Van Straaten và cộng sự bỏ cửa sổ hai tuần để " "chuyển sang trung bình 31 ngày vì “the predictable process is " "better detectable on the longer time scale”. Ở đây quét 13 cửa " "sổ từ một tuần tới sáu tuần.
" % cfg["n_family_s2"]) body.append( "
Vì sao phải có hai định " "nghĩa target. Tiêu chí “có ít nhất MỘT ngày EHD” " "bão hoà khi cửa sổ dài ra: base rate đi từ 0.329 (một tuần) lên " "0.685 (W3-6) và 0.814 (W1-6). So AUC giữa các cửa sổ có base rate khác " "nhau đến vậy là so hai bài toán khác nhau chứ không phải so hai phương " "án. Vì thế bảng thứ hai giữ độ hiếm KHÔNG ĐỔI: sự kiện là “có ≥ k " "ngày EHD trong cửa sổ”, với k chọn trên các năm huấn luyện sao " "cho tần suất gần 1/3 nhất. Với cửa sổ một tuần thì k = 1, tức trùng " "đúng tiêu chí đang dùng.
") body.append(w2_table(w2["by_window"], wins, "so1", "Target ≥ 1 ngày EHD trong cửa sổ (tiêu chí đang dùng)")) body.append(w2_table(w2["by_window"], wins, "eqbase", "Target ≥ k ngày EHD, k giữ base rate ≈ 1/3 " "(so sánh công bằng giữa các cửa sổ)")) body.append("

Còn thiếu bao nhiêu mới đạt chỉ tiêu

") body.append( "
POD > 0.6 kèm FAR < 0.2 tức precision > 0.8. " "Từ base rate suy ra POFD tối đa được phép, rồi từ cặp (POD, POFD) suy " "ra AUC tối thiểu theo mô hình ROC nhị chuẩn. Đây là cận dưới: " "không có cách chọn ngưỡng hay cách gộp thành viên nào đạt được chỉ tiêu " "nếu AUC còn dưới mức này.
") body.append(w2_required_auc(w2["by_window"], wins, "eqbase")) sw_path = os.path.join(RES_GLOB, "exp3_window2_sweep.json") if os.path.exists(sw_path): with open(sw_path) as fh: sw = json.load(fh) body.append("

Điểm vận hành đạt được chỉ tiêu

") body.append( "
Một ràng buộc toán " "học phải nói rõ trước. Ở FBI = 1 thì a + b = a + c, tức b = c, " "nên FAR = b/(a+b) = 1 − POD — chính xác, không phải " "xấp xỉ. Kiểm chứng trên số liệu: W1 có FBI 1.000, POD 0.734, FAR " "0.266 = 1 − 0.734. Vì thế bộ chỉ tiêu {POD > 0.6, FAR < 0.2, " "FBI ≈ 1} tương đương {POD > 0.8, FBI ≈ 1}: nó ngầm đòi " "POD trên 0.8 chứ không phải 0.6. Muốn FAR < 0.2 ở mức POD " "0.6–0.7 thì bắt buộc phải dự báo THIẾU — đổi độ bắt lấy độ chính " "xác. Bảng dưới quét tỷ lệ phát báo r (bội của base rate, xấp xỉ " "chính là FBI), ngưỡng chốt cho từng ô lưới trên trường ngoài mẫu " "rồi áp sang tập kiểm định.
") body.append(w2_sweep_table(sw, wins)) body.append( "
Bốn cửa sổ đạt " "toàn bộ chỉ tiêu — và cả bốn đều cần FBI trong khoảng " "0.68–0.86, tức chấp nhận dự báo thiếu. Điểm chung: đều là cửa sổ " "bắt đầu từ W1. Các cửa sổ chỉ chứa hạn từ W3 trở đi không " "có điểm vận hành nào đạt; W3-6 sát nhất và hụt ở POFD (0.238 so " "với 0.2), còn FAR của nó đã đạt (0.177).
") if w2.get("models_s2"): body.append("

Danh mục mô hình tầng 2

") body.append(exp3_model_table(w2["models_s2"])) p2_path = os.path.join(RES_GLOB, "phase2_summary.json") if os.path.exists(p2_path): with open(p2_path) as fh: p2 = json.load(fh) body.append("

5. Triển khai toàn miền — 541 ô lưới

") body.append( "
Kiến trúc đã chốt ở GĐ1: mô hình GỘP (một bộ " "cho cả nước, hoặc một bộ cho mỗi vùng) kèm ngưỡng riêng cho từng ô " "lưới. Gộp không bao giờ kém per-point mà giảm số mô hình phải giám " "sát từ 30 296 xuống 56 (toàn quốc) hoặc 392 (theo vùng).
" "Ngưỡng chốt kèm biên an toàn %.2f. GĐ1 cho thấy tỷ lệ phát báo " "tối ưu theo CSI luôn nằm sát biên chỉ tiêu nên không chuyển được sang " "ô lưới chưa thấy; chốt kèm biên đổi khoảng 0.02–0.03 CSI lấy độ bền. " "Mọi ngưỡng ước lượng trên trường ngoài mẫu của 16 năm huấn luyện, " "không bao giờ trên tập kiểm định.
" % p2["config"]["margin"]) for variant in ("national", "region"): if not any(k.startswith(variant + "|") for k in p2.get("national", {})): continue body.append("

Kiến trúc %s

" % escape(variant)) body.append(p2_national_table(p2["national"], p2["chosen"], variant)) body.append(p2_region_table(p2["by_region"], variant)) if p2.get("per_point"): body.append("

Bản đồ chỉ số trên toàn miền (kiến trúc toàn quốc)

") for m, ttl, cmap, rng in P2_MAPS: png = p2_map_row(p2["per_point"], "national", m, grid, borders, ttl, cmap, rng) if png: body.append('
%s
' % (escape(m), png)) print(" map p2: %s" % m, flush=True) wt_path = os.path.join(RES_GLOB, "week_tune.json") if os.path.exists(wt_path): with open(wt_path) as fh: wt = json.load(fh) body.append("

6. Từng tuần riêng W1–W6 trên toàn miền — " "dò hết dải ngưỡng

") body.append( "
Vì sao tách riêng từng tuần. Các cửa sổ cộng " "dồn ở mục 5 đạt chỉ tiêu phần lớn vì chúng chứa W1. Sản phẩm " "nghiệp vụ thật sự cần là “tuần thứ 4”, không phải “ba " "tuần tới gộp lại”. Toàn bộ hai tầng được chạy lại cho sáu tuần " "đơn trên 541 ô lưới, hai định nghĩa sự kiện, rồi quét ngưỡng r từ 0.02 " "tới 1.50.
" "Năm chế độ chốt ngưỡng, tất cả đều chốt trên trường ngoài mẫu " "của 16 năm huấn luyện: ĐỦ = đạt trọn bộ chỉ tiêu, lấy CSI " "cao nhất; CSI = CSI cao nhất bất kể chỉ tiêu khác; " "FAR = CSI cao nhất trong số các điểm giữ FAR < 0.2; " "POD = CSI cao nhất trong số các điểm giữ POD > 0.6; " "SEDI = kỹ năng thực cao nhất sau khi gạt ảnh hưởng base " "rate.
") body.append(wt_table(wt, "so1", "Target ≥ 1 ngày EHD trong tuần")) body.append( "
W1 đạt trọn bộ chỉ " "tiêu; W2–W6 thì không, ở BẤT KỲ ngưỡng nào. Nặng hơn cả việc không " "đạt trọn bộ: từ W2 trở đi không có ngưỡng nào cho FAR < 0.2. " "Và điểm CSI cao nhất của W2–W6 đều nằm ở biên trên của lưới " "(r = 1.45–1.50), tức CSI vẫn còn tăng nếu dự báo thừa thêm nữa — đúng " "thiên lệch của CSI. Đây không phải thất bại của việc dò ngưỡng mà là " "trần tín hiệu: mục 3h đã tính cần AUC ≈ 0.893 cho POD > 0.6 " "kèm FAR < 0.2 ở base rate ≈ 0.30, trong khi AUC thực có ở W2–W6 chỉ " "0.65–0.77.
") body.append("

Đường đánh đổi POD–FAR của từng tuần

") body.append("
pod-far
" % wt_curve_fig(wt, "so1")) body.append( "
Chỉ đường W1 đi vào được ô xanh. Khoảng cách từ các " "đường W2–W6 tới ô xanh chính là lượng tín hiệu còn thiếu, và nó không " "thu hẹp được bằng cách chọn ngưỡng.
") body.append(wt_table(wt, "geq_k", "Target ≥ k ngày EHD (k riêng từng ô lưới, giữ tần " "suất ≈ 1/3)")) body.append( "
Định nghĩa “≥ 2 " "ngày” có kỹ năng cao hơn nhưng vẫn không giải được bài toán. " "k trung vị là 2 (dao động 1–3 tuỳ ô lưới). SEDI cao hơn ở W3, W4, W6 và " "cao rõ ở W1 (0.769 so với 0.741) — xác nhận rằng một đợt hai ngày trở " "lên là hiện tượng dự báo được hơn một ngày nóng đơn lẻ. Nhưng không " "tuần nào đạt chỉ tiêu, và ở W1 nó còn làm mất điểm ĐỦ mà tiêu " "chí ≥ 1 ngày có được: sự kiện hiếm hơn (base 0.281 so với 0.387) nên " "đòi độ chính xác cao hơn ở cùng một POD. Kỹ năng cao hơn không bù được " "độ hiếm cao hơn.
") fv_path = os.path.join(RES_GLOB, "final_verify.json") fv_nc = os.path.join(C.OUT, "final_metrics.nc") if os.path.exists(fv_path): with open(fv_path) as fh: fv = json.load(fh) body.append("

7. Mô hình cuối cùng — đánh giá và bản đồ chỉ " "số

") body.append( "
Đây là bảng chấm chính thức của hệ thống sẽ đem " "đi vận hành, không phải của một biến thể nghiên cứu nào khác: đầu " "vào là đúng đầu ra của bộ tham số đã đóng băng " "(outputs/operational/), chạy trên %d lần khởi tạo " "của bốn năm kiểm định 2006, 2011, 2016, 2021 và toàn bộ 541 ô lưới " "đất.
" "Kiến trúc: hai tầng, mô hình gộp toàn quốc (56 mô hình), ngưỡng cổng " "và ngưỡng quyết định riêng cho từng ô lưới, chốt trên trường ngoài mẫu " "của 16 năm huấn luyện. Sự kiện: tuần có ít nhất một ngày EHD, với " "ngưỡng EHD là min(Tx90p cục bộ, 35 °C).
" "Quan trắc chỉ dùng để chấm điểm, không bao giờ đi vào đặc trưng.
" % fv.get("n_init", 0)) body.append(fv_national_table(fv["national"])) body.append( "
W1 đạt trọn bộ chỉ tiêu. Từ W2 trở đi FAR không " "xuống dưới 0.2 được ở bất kỳ ngưỡng nào (mục 6), nên W2–W6 phát hành " "dưới dạng trường xác suất; trường nhị phân ở đây chốt tại " "FBI ≈ 1 để không thiên lệch.
" "Đọc theo SEDI chứ đừng đọc theo CSI khi so các tuần với nhau: CSI phụ " "thuộc base rate, còn SEDI thì không.
") body.append(fv_region_table(fv["by_region"])) if os.path.exists(fv_nc): body.append("

Bản đồ chỉ số trên 541 ô lưới

") body.append( "
Mỗi hàng là một chỉ số, mỗi tấm là một tuần hạn " "dự báo, chung thang màu để so được giữa các tuần.
") for m, ttl, cmap, rng in FV_MAPS: png = fv_map_row(fv_nc, m, grid, borders, ttl, cmap, rng) if png: body.append('
fv %s
' % (escape(m), png)) print(" map final: %s" % m, flush=True) pw_path = os.path.join(RES_GLOB, "pointwise.json") if os.path.exists(pw_path): with open(pw_path) as fh: pw = json.load(fh) body.append("

3c. EXP2 theo từng ô lưới — cổng nEHDE " "+ mô hình yEHDE tại mỗi điểm

") body.append("
Lưu ý về tính đồng bộ. Các bảng của mục 3c được tính TRƯỚC khi climatology dùng cho z-score được sửa lại chỉ khớp trên 16 năm huấn luyện (2026-08-30). Chúng vẫn so sánh được với nhau vì mọi nhánh trong mục này dùng chung một bộ đầu vào, nhưng không so trực tiếp được với các mục toàn quốc và mục EXP1 ở trên, vốn đã chạy lại trên đầu vào mới.
") body.append( "
Thiết kế. Thay cho một mô hình gộp toàn quốc, " "mỗi ô lưới trong 21 điểm kiểm thử (3 điểm/vùng) được huấn luyện " "riêng hai mô hình: (1) một cổng trả lời “mẫu này gần như " "chắc chắn là nEHDE?”, và (2) một mô hình yEHDE chỉ khớp trên các " "hàng mà cổng cho đi qua.
" "Hai cách đọc nhãn nEHDE. Cổng tuần-điểm: tuần đó tại điểm đó " "có ít nhất một ngày EHD hay không. Cổng vùng: đúng quy tắc EXP2 — " "vùng chứa điểm đó có Pi = mi/ni vượt 5%, " "với ni đếm toàn bộ ô lưới đất của vùng.
" "Hai quy tắc giữ cho so sánh trung thực. Tập huấn luyện của tầng 2 " "được chọn bằng điểm số out-of-fold của cổng, không phải in-sample. " "Và mọi bảng chấm trên toàn bộ hàng kiểm định: hàng bị cổng đóng nhầm " "mà thực tế có sự kiện thì tính là miss, đúng như trong vận hành — chấm điểm " "chỉ ở nơi cổng mở sẽ điều kiện hoá kiểm định lên chính dự báo.
") for frame in ("W4plus", "daily_all", "daily_capable"): body.append("

%s

" % escape(PW_FRAMES[frame])) for thr in ("thr_week", "thr_point_week"): body.append(pointwise_table(pw, frame, thr)) body.append("

Mô hình 1 của cặp: cổng nEHDE

") body.append( "
Đừng so trực tiếp bảng này với các bảng trên. " "Cổng dự báo nhãn nEHD/yEHD, có base rate 0.31 (cổng tuần-điểm) và 0.25 " "(cổng vùng), trong khi mô hình EHD có base rate 0.10. CSI và POD của cổng " "vì thế cao hơn mà không có nghĩa nó dự báo giỏi hơn — đây đúng là " "hiệu ứng đổi bài toán đã ghi ở mục Z. Đại lượng so được là AUC: " "cổng tốt nhất đạt 0.683, mô hình EHD đạt 0.661–0.683. " "Hai mô hình có kỹ năng phân biệt ngang nhau — câu hỏi “tuần này có " "ngày EHD nào không?” không dễ hơn câu hỏi “hôm nay có EHD không?”, " "trái với kỳ vọng ban đầu của thiết kế cascade.
") for thr in ("thr_week", "thr_point_week"): body.append(gate_table(pw, "W4plus", thr)) body.append("

Chi tiết từng điểm lưới

") body.append(point_detail_table( pw, grid, ["pooled_ensemble", "F3/logreg/single", "F3/logreg/soft_wk"])) body.append( "
Bất đồng nhất giữa các điểm lấn át chênh lệch giữa " "các phương án. Điểm phía nam (S6, S7) đạt AUC 0.75–0.80 và CSI " "0.21–0.31; điểm phía bắc (S1, S2, S3) chỉ AUC 0.57–0.66 và CSI 0.05–0.10 " "— chênh nhau khoảng 6 lần về CSI, lớn hơn nhiều so với khoảng cách giữa " "các mô hình. Cascade thắng mô hình gộp ở 17/21 điểm, ΔAUC trung " "bình +0.024 (từ −0.009 đến +0.060), và giúp nhiều nhất ở đúng các " "điểm yếu (392, 315, 247, 237). Vì thắng đều tay chứ không do vài điểm kéo, " "đây là dấu hiệu đáng tin hơn con số gộp.
") body.append( "
Đọc kết quả. Ở khung W4 trở đi, mô hình per-point " "không thua ensemble toàn quốc, và nhánh cascade mềm với cổng " "tuần-điểm là nhánh tốt nhất. Nhưng ở khung toàn bộ hạn, ensemble toàn " "quốc vẫn hơn: ưu thế của mô hình gộp nằm ở W1–W3, nơi tín hiệu mạnh và thêm " "dữ liệu thực sự có ích.
" "Cắt cứng làm mất phần lời. Nhánh cắt cứng chỉ ngang với nhánh không " "cascade, trong khi nhánh mềm P(cổng)×P(EHD) thắng. Cổng mang thông tin thật, " "nhưng nhị phân hoá nó thì vứt đi chính thông tin đó — nên giữ cascade ở dạng " "tích xác suất.
" "Cảnh báo. Chênh lệch còn nhỏ và chỉ đo trên 21 điểm. Kinh nghiệm đã " "ghi trong developing.txt: thứ hạng trên testbed 21 điểm không dự đoán " "được thứ hạng toàn quốc — ANN từng thắng testbed nhưng thua khi chạy " "541 điểm. Cần xác nhận trên toàn quốc trước khi kết luận.
") for path in sorted(glob.glob(os.path.join(RES_GLOB, "regional_*.json"))): with open(path) as fh: reg = json.load(fh) nm = os.path.basename(path)[len("regional_"):-len(".json")] body.append("

3b. EHDE xác định cho cả vùng — %s

" % escape(nm)) body.append("
Khung này khác hẳn các bảng trên. " "Dự báo vẫn làm cho từng ô lưới, nhưng một vùng được coi là có EHDE " "khi tỷ lệ ô có EHD=1 vượt ngưỡng phủ. Base rate vì thế cao hơn nhiều " "so với khung điểm lưới, nên CSI và POD cao hơn không có nghĩa " "là mô hình dự báo giỏi hơn — AUC gần như không đổi. Đây là định " "nghĩa lại sự kiện, không phải tăng kỹ năng. Ngưỡng quyết định dò trên " "trường out-of-fold đầy đủ của các năm huấn luyện.
") for key in ["cover05_season_fbi1", "cover05_season_fbi1_regweek", "cover10_season_fbi1", "cover10_season_fbi1_regweek", "cover20_season_fbi1", "cover20_season_fbi1_regweek", "cover20_all_literal", "cover20_all_fbi1", "cover05_season_csi", "cover10_season_csi"]: if key in reg.get("results", {}): body.append(regional_table(reg["results"][key], REG_LABELS.get(key, key))) if any(k in reg.get("results", {}) for k in REG_REGION_LABELS): body.append("
Con số toàn quốc là trung bình của hai chế độ " "rất khác nhau. Hai bảng dưới tách theo vùng, trung bình W4–W6, và " "cho thấy vì sao ngưỡng riêng theo vùng có ích: một ngưỡng chung " "báo động thừa ở nơi mô hình đọc kém và bỏ sót ở nơi nó đọc " "tốt.
") for key, lbl in REG_REGION_LABELS.items(): if key in reg.get("results", {}): body.append(regional_region_table(reg["results"][key], lbl)) for path in sorted(glob.glob(os.path.join(RES_GLOB, "regmodel_*.json"))): with open(path) as fh: st = json.load(fh) body.append("

3c. Mô hình tầng 2 ở cấp vùng

") body.append("
Mục 3b tóm tắt trường xác suất của một vùng bằng " "đúng một số — giá trị trung bình. Hình dạng của trường mang thêm " "thông tin: một ngày có 5%% số ô ở mức 0.8 khác hẳn một ngày mọi ô đều " "ở 0.04, dù trung bình như nhau. Mỗi region-day ở đây thành một hàng " "với %d đặc trưng mô tả hình dạng đó (các phân vị, tỷ lệ ô vượt nhiều " "mức xác suất, độ tán, tỷ lệ ô capable) và một hồi quy logistic " "dự báo thẳng EHDE của vùng. Mô hình cây (LightGBM) trên cùng bộ đặc " "trưng kém hơn hẳn — xem developing.txt mục AA. Khác với mục 3b, " "ở đây AUC thực sự tăng, nên đây là tăng kỹ năng chứ không chỉ " "đổi định nghĩa sự kiện.
" % len(st.get("features", []))) for key, lbl in STACK_LABELS.items(): if key in st.get("results", {}): body.append(regional_table(st["results"][key], lbl)) for key, lbl in STACK_REGION_LABELS.items(): if key in st.get("results", {}): body.append(regional_region_table(st["results"][key], lbl)) for path in sorted(glob.glob(os.path.join(C.OUT, "ai_verification*.nc"))): tag = os.path.basename(path)[len("ai_verification"):-len(".nc")] or "" body.append("

4. Bản đồ chỉ số của mô hình AI%s

" % escape(tag)) with Dataset(path) as ds: avail = [m for m in SPEC if m in ds.variables] data = {m: np.ma.filled(ds[m][:], np.nan).astype("f4") for m in avail} model = getattr(ds, "model", ""); feats = getattr(ds, "features", "") body.append("

Mô hình %s, bộ biến %s, " "đánh giá trên các năm kiểm định.

" % (escape(model), escape(feats))) for m in avail: title, cmap, sym, fixed = SPEC[m] png = metric_row(data[m], grid, borders, title, cmap, sym, fixed) body.append('

%s

%s' '
' % (escape(title), escape(title), png)) print(" map: %s" % m, flush=True) ec_path = os.path.join(C.OUT, "ehd_verification.nc") if os.path.exists(ec_path): with Dataset(ec_path) as ds: ec = {m: np.ma.filled(ds[m][:], np.nan).astype("f4") for m in ("pod", "csi", "far") if m in ds.variables} body.append("

4. Chênh lệch so với EC gốc (AI − EC)

" "

Xanh = AI tốt hơn với POD và CSI; với FAR thì ngược lại, " "âm mới là tốt hơn.

") for m in ("pod", "csi", "far"): if m in ec and m in data: diff = data[m] - ec[m] png = metric_row(diff, grid, borders, "Δ %s (AI − EC)" % m.upper(), "RdBu_r", True, None) body.append('
d%s' '
' % (m, png)) sub = ("Ngưỡng EHD Tx_thres = min(Tx90p, 35 °C) (hợp của HD và HWD, " "theo hình 1 của HW_HPC_1). " "Cùng một mô hình sẽ cho các chỉ số rất khác nhau tuỳ khung đánh giá; " "cả ba khung đều được báo cáo.") # A jump list, and a stamp naming the file and the moment it was written -- # there is an identically named report in the old CLI/EHD tree, so the reader # needs to be able to tell at a glance which one is open. toc = ["
Đi nhanh tới: ", "Bảng tổng hợp"] for name in sorted(runs): toc.append("%s" % (escape(re.sub(r"[^A-Za-z0-9]+", "-", name)), escape(name))) if glob.glob(os.path.join(RES_GLOB, "regional_*.json")): toc.append("EHDE theo vùng") if os.path.exists(os.path.join(RES_GLOB, "exp3_multi.json")): toc.append("EXP3 theo ô lưới") if os.path.exists(os.path.join(RES_GLOB, "exp3_week.json")): toc.append("EXP3 theo tuần") if os.path.exists(os.path.join(RES_GLOB, "exp3_week_s1.json")): toc.append("EXP3 tầng 1 mở rộng") if os.path.exists(os.path.join(RES_GLOB, "exp3_week_s1b.json")): toc.append("Tầng 1 neo vào EC") if os.path.exists(os.path.join(RES_GLOB, "exp3_window2.json")): toc.append("Hai tầng + quét cửa sổ") if os.path.exists(os.path.join(RES_GLOB, "phase2_summary.json")): toc.append("Triển khai toàn miền") if os.path.exists(os.path.join(RES_GLOB, "week_tune.json")): toc.append("Từng tuần W1-W6") if os.path.exists(os.path.join(RES_GLOB, "final_verify.json")): toc.append("Mô hình cuối cùng") if os.path.exists(os.path.join(RES_GLOB, "pointwise.json")): toc.append("EXP2 theo từng ô lưới") if glob.glob(os.path.join(RES_GLOB, "regmodel_*.json")): toc.append("Mô hình tầng 2 cấp vùng") toc.append("
") with open(args.out, "w") as fh: fh.write(PAGE % dict(title="Dự báo S2S nắng nóng cực đoan — kết quả mô hình AI", subtitle=sub, body="\n".join(body), toc="".join(toc), stamp=time.strftime("%Y-%m-%d %H:%M:%S"), path=escape(os.path.abspath(args.out)), runs=escape(", ".join(sorted(runs)) or "(khong co)"))) print("[done] -> %s" % args.out, flush=True) if __name__ == "__main__": main()