#!/usr/bin/env python3
"""One self-contained evaluation page, in the style of ../HW/ehd_ai_results.html.
Reads whatever of these exist and renders a single file with figures inlined as
base64 PNG, so it can be opened or emailed with nothing else attached:
outputs/AI_Results/bcsd_fluxonly.json downscaling ladder D0/D1/D2
outputs/AI_Results/exp_features_sub.json upper-air and model family
outputs/AI_Results/threshold{,_region}.json out-of-sample threshold rules
outputs/AI_Results/threshold*.w?.curve.npz the full delta sweeps
outputs/AI_Results/w1_bootstrap_d*.json year-block bootstrap
"""
from __future__ import annotations
import base64
import io
import json
import os
import sys
import time
from html import escape
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cfs_common as C
AR = os.path.join(C.OUT, "AI_Results")
OUT = "/home/tanpv/Progs/claude/HW_CFS_raw/ehd_cfs_evaluation.html"
WK = C.WEEK_NAMES
TARGETS = {"POD": (">", 0.60), "POFD": ("<", 0.20), "FAR": ("<", 0.20),
"CSI": (">", 0.40), "FBI": ("~", 1.0)}
CSS = """
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
margin:0 auto;max-width:1240px;padding:28px 22px 70px;color:#1a1a1a;line-height:1.55}
h1{font-size:23px;margin:0 0 6px} h2{font-size:17px;margin:32px 0 8px}
h3{font-size:14.5px;margin:20px 0 6px;color:#333}
p{font-size:13.5px;margin:8px 0} p.sub{color:#555;margin:0 0 16px}
table{border-collapse:collapse;font-size:12.5px;margin:8px 0 18px}
caption{text-align:left;font-weight:600;font-size:13px;padding:6px 0;color:#333}
th,td{border:1px solid #ddd;padding:3px 9px;text-align:right}
th{background:#f6f6f6;font-weight:600} td:first-child,th:first-child{text-align:left}
td.good{background:#dff3e0;font-weight:600} td.bad{background:#fdecea}
td.warn{background:#fdf4e0;font-weight:600}
figure{margin:0 0 8px} img{width:100%;display:block}
code{background:#f4f4f4;padding:1px 4px;border-radius:3px;font-size:12px}
.note{background:#f7f7f9;border-left:3px solid #bbb;padding:9px 14px;margin:14px 0;
font-size:13px}
.warnbox{background:#fff8e6;border-left:3px solid #e0a800;padding:9px 14px;
margin:14px 0;font-size:13px}
.stamp{font-size:12.5px;color:#555;background:#eef4fb;border:1px solid #cfe0f0;
border-radius:3px;padding:8px 12px;margin:10px 0 0}
.toc{font-size:13px;margin:16px 0 0;padding:10px 14px;background:#fafafa;
border:1px solid #e3e3e3;border-radius:3px}
.toc a{color:#1a5f9c;text-decoration:none;margin-right:14px;white-space:nowrap}
.toc a:hover{text-decoration:underline}
"""
def cls(key, v, judge=True):
if not judge or key not in TARGETS or not np.isfinite(v):
return ""
op, ref = TARGETS[key]
ok = v > ref if op == ">" else v < ref if op == "<" else abs(v - ref) <= 0.25
return " class='good'" if ok else " class='bad'"
def fig(fn):
"""Render a matplotlib figure to an inline ."""
buf = io.BytesIO()
fn()
plt.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close()
return (""
% base64.b64encode(buf.getvalue()).decode())
def jload(p):
return json.load(open(p)) if os.path.exists(p) else None
def year_table(delta):
"""Per-year validation scores, computed rather than transcribed: a hand-typed
table silently goes stale the first time the pipeline is re-run."""
from threshold import contingency, regional
f = os.path.join(AR, "bcsd_fluxonly.json.w0.fields.npz")
if not os.path.exists(f):
return ""
z = np.load(f)
score = z["d1"] - z["thr"]
E, yrs, reg = z["E"].astype(bool), z["yrs"], z["reg"]
valid = np.isin(yrs, C.VALID_YEARS) & np.isfinite(score).any(1)
fc = np.zeros_like(E)
ok = np.isfinite(score)
fc[ok] = score[ok] >= delta
FR, OR = regional(fc, reg), regional(E, reg)
ks = ("POD", "POFD", "FAR", "FBI", "CSI")
out = ["
Từng năm kiểm định tại δ = %+.2f
"
"
Năm
n init
%s
"
% (delta, "".join("
%s
" % k for k in ks))]
for y in list(C.VALID_YEARS) + ["gộp"]:
m = valid if y == "gộp" else (valid & (yrs == y))
r = contingency(FR[m], OR[m])
b = ("%s" % y) if y == "gộp" else str(y)
out.append("
%s
%d
%s
"
% (b, m.sum(),
"".join("
%.3f
" % (cls(k, r[k]), r[k]) for k in ks)))
out.append("
")
return "".join(out)
# ------------------------------------------------------------------ sections
def sec_ladder(H):
d = jload(os.path.join(AR, "bcsd_fluxonly.json"))
if not d:
return
H.append("
")
for w in WK:
for tag in ("D0", "D1", "D2"):
r = d[w][tag]
cells = "".join("
%.3f
" % (cls(k, r[k]), r[k]) for k in keys)
H.append("
%s
%s
%s
" % (w, tag, cells))
H.append("
")
H.append("
Điều quan trọng nhất ở bảng này là cột "
"exc_ratio (tỷ số tần suất vượt ngưỡng mô hình/quan trắc). "
"D2 có MAE ngang hoặc tốt hơn D1 nhưng exc_ratio sụp còn 0.55–0.78 ở mọi "
"tuần: hồi quy co trường về khí hậu học, trường bị co rồi cắt ngưỡng Tx90p "
"thì gần như không sinh sự kiện, nên CSI thua D1 khắp nơi. "
"RMSE hoàn toàn không nhìn thấy điều này. "
"Kết luận: chọn BCSD (D1). Muốn dùng ML thì phải là bộ phân loại "
"P(EHD), không phải hồi quy giá trị rồi cắt ngưỡng.
")
def f():
x = np.arange(len(WK))
fg, ax = plt.subplots(1, 2, figsize=(11, 3.4))
for tag, c in (("D0", "#999"), ("D1", "#1a5f9c"), ("D2", "#c0504d")):
ax[0].plot(x, [d[w][tag]["MAE"] for w in WK], "o-", color=c, label=tag)
ax[1].plot(x, [d[w][tag]["exc_ratio"] for w in WK], "o-", color=c, label=tag)
ax[0].set_ylabel("MAE (°C)"); ax[1].set_ylabel("exc_ratio")
ax[1].axhline(1.0, ls="--", lw=1, color="k")
for a in ax:
a.set_xticks(x); a.set_xticklabels(WK); a.grid(alpha=.3); a.legend(fontsize=8)
ax[0].set_title("Sai số giá trị", fontsize=10)
ax[1].set_title("Tần suất sự kiện — D2 sụp ở đây", fontsize=10)
H.append(fig(f))
def sec_features(H):
d = jload(os.path.join(AR, "exp_features_sub.json"))
if not d:
return
H.append("
2. Biến cao không và mô hình phi tuyến mua được gì
")
H.append("
SƠ BỘ. Tập pgb hiện chỉ có 1999–2004 trên đĩa "
"local, nên năm kiểm định rút còn 2001 tháng 1–8 (phủ trọn mùa nóng, thiếu "
"tháng 9–12 tức phần “ấm bất thường mùa đông”). Sẽ chạy lại trên đủ 13 năm "
"khi việc trích pgb qua NAS hoàn tất.
")
H.append("
AUC trên năm kiểm định. L = hồi quy logistic, "
"G = LightGBM; flux = 16 biến bề mặt, both = thêm 27 biến cao không
")
H.append("
Tuần
L_flux
L_both
G_flux
"
"
G_both
AUC cần
Khoảng cách
"
"
cao không mua
phi tuyến mua
")
for w in WK:
r = d[w]
best = max(r["L_flux"], r["L_both"], r["G_flux"], r["G_both"])
gap = best - r["AUC_req"]
H.append("
%s
%s
%.4f
%+.4f
"
"
%+.4f
%+.4f
"
% (w, "".join("
%.4f
" % r[k] for k in
("L_flux", "L_both", "G_flux", "G_both")),
r["AUC_req"], gap, r["L_both"] - r["L_flux"],
r["G_flux"] - r["L_flux"]))
H.append("
")
H.append("
Tốt nhất ở W1 là 0.787 so với 0.858 cần có — còn thiếu "
"0.070. Biến cao không mua được +0.016 ở W1 và ~0 ở nơi khác; phi tuyến âm "
"ở W1–W2. W4–W5 có vẻ được +0.03…+0.05 nhưng đó là dao động quanh mức ngẫu "
"nhiên (AUC 0.51–0.56), không có ý nghĩa nghiệp vụ. "
"Đây là giới hạn THÔNG TIN, không phải giới hạn phương pháp.
")
def _curve(scale, w):
p = os.path.join(AR, "threshold%s.w%d.curve.npz"
% ("_region" if scale == "region" else "", w))
return np.load(p) if os.path.exists(p) else None
def sec_threshold(H):
H.append("
3. Chốt ngưỡng ngoài mẫu
")
H.append("
Điểm số = D1 − ngưỡng EHD địa phương; phát báo "
"khi điểm số ≥ δ. δ là tham số tự do duy nhất, chốt trên năm huấn luyện "
"rồi áp nguyên si sang năm kiểm định. Quy mô vùng theo EXP1: một vùng có "
"EHDE = 1 khi hơn 20% số ô của nó có EHD = 1.
")
for scale, name in (("region", "Quy mô VÙNG (EXP1)"), ("point", "Quy mô ĐIỂM")):
d = jload(os.path.join(AR, "threshold%s.json"
% ("_region" if scale == "region" else "")))
if not d:
continue
H.append("
%s
" % name)
H.append("
Bốn quy tắc chốt δ. Hàng VA là kết quả "
"ngoài mẫu — đó là con số duy nhất đáng tin.
")
H.append("
Tuần
Quy tắc
δ
Tập
"
"
POD
POFD
FAR
FBI
CSI
"
"
EDI
SEDI
nền
")
for w in WK:
for rule in ("csi", "fbi", "far", "box"):
r = d[w].get(rule)
if r is None:
H.append("
%s
%s
không có ngưỡng nào đạt hộp chỉ "
"tiêu trên năm huấn luyện
" % (w, rule))
continue
for tag, sub in (("train", "huấn luyện"), ("valid", "KIỂM ĐỊNH")):
m = r[tag]
judge = tag == "valid"
cells = "".join("
%.3f
" % (cls(k, m[k], judge), m[k])
for k in ("POD", "POFD", "FAR", "FBI", "CSI",
"EDI", "SEDI"))
H.append("
Hai điều phải cân nhắc trước khi dùng. "
"(1) “FBI ≈ 1” và “POFD < 0.2” mâu thuẫn nhau trên đường cong này. "
"Tại FBI = 1 (δ = 0) thì POFD là 0.328, gấp 1.8 lần chỉ tiêu. Ngược lại điểm "
"đạt chỉ tiêu có FBI 0.75, tức phát báo thiếu 25%. Tôi ưu tiên POFD vì nó là "
"con số định lượng trong bộ chỉ tiêu, còn “FBI ~ 1” nêu định tính — "
"nhưng đây là lựa chọn của người dùng, không phải của tôi. "
"(2) 2006 chi phối đuôi trên của POFD. Năm này chỉ có 88 init (năm "
"flux thưa) và POFD 0.330; một mình nó đẩy bách phân vị 95 của POFD lên "
"0.238. Ba năm kiểm định là quá ít để bootstrap khối năm cho kết luận chắc — "
"đó là giới hạn của cách chia mẫu, bootstrap không sửa được.
")
def sec_concl(H):
H.append("
5. Kết luận
")
H.append("
Trạng thái so với bộ chỉ tiêu
"
"
Sản phẩm
Kết luận
"
"
W1, quy mô vùng (EXP1)
ĐẠT tại δ = +0.40: "
"POD 0.653, POFD 0.185, FAR 0.133, CSI 0.593 — ngoài mẫu
"
"
W1, quy mô điểm
KHÔNG đạt ở bất kỳ ngưỡng nào
"
"
W2–W6, cả hai quy mô
KHÔNG đạt; không tồn tại "
"δ nào cho FAR < 0.2 ngay cả trên năm huấn luyện
")
H.append("
Đề nghị. W1 phát báo nhị phân theo vùng ở "
"δ = +0.40. W2–W6 và toàn bộ sản phẩm theo điểm phát dưới dạng "
"trường xác suất đã hiệu chuẩn, kèm ghi rõ trần tín hiệu — vì khoảng "
"cách AUC 0.25–0.36 là giới hạn thông tin của CFS chứ không phải chuyện dò "
"ngưỡng hay đổi lớp mô hình.
"
"Hướng còn lại chưa thử và có cơ sở: dùng bộ phân loại P(EHD) "
"rồi mới gộp vùng, thay vì gộp vùng từ trường tất định D1 — vì mục 1 đã chỉ "
"ra hồi quy giá trị là sai lớp mô hình cho bài toán sự kiện.
")
def main():
H = ["",
"Đánh giá dự báo EHD từ CFSv2" % CSS,
"
Dự báo S2S ngày nóng cực đoan từ CFSv2 — kết quả đánh giá
",
"
Downscaling BCSD về lưới 0.25° và chốt ngưỡng ngoài mẫu, "
"hạn 0–45 ngày.
",
"
Tập mẫu 3086 init (flux ∩ pgb), 13 năm, 541 ô lưới "
"đất, 7 vùng. Kiểm định 2001 / 2006 / 2009 (726 init), huấn luyện 10 năm "
"còn lại (2360 init). Mọi khí hậu học, quantile map và ngưỡng đều chỉ khớp trên "
"năm huấn luyện. Tuần: W1 = hạn 0–10, W2 = 11–17, W3 = 18–24, W4 = 25–31, "
"W5 = 32–38, W6 = 39–45. Dựng lúc %s.