#!/usr/bin/env python3
"""EXP3 — GIẢI PHÁP LỰA CHỌN MÔ HÌNH AI TỐI ƯU cho cổng phân biệt yEHDE/nEHDE.

Tái dùng phiếu ĐÃ LƯU của exp3_week_s1 (28 họ x 9 phân vị, số phiếu 0..9 mỗi họ,
cho từng (điểm, tuần), oof 1664 + val 416 + obs).  Không khớp lại mô hình nào; mọi
tổ hợp/trọng số đều tính lại từ phiếu trong vài giây.

CÂU HỎI: trong N họ mô hình, chọn/kết hợp thế nào để điểm số p = "đồng thuận yEHDE"
phân biệt tốt nhất (AUC/SEDI cao nhất), từ đó POD/CSI tại điểm vận hành FBI=1 sát
mục tiêu nhất?

NGUYÊN TẮC CHỐNG RÒ RỈ: mọi lựa chọn (tập con, trọng số stack) CHỈ dùng OOF của các
năm huấn luyện.  VAL (4 năm kiểm định) chỉ để BÁO CÁO, không tham gia chọn.  Con số
VAL là ước lượng trung thực; OOF-CV kèm theo để thấy mức lạc quan của việc tự chọn.

Sáu phương án so trên cùng cột mốc:
  base7   -- 7 họ gốc, phiếu bình quân M/N (đang dùng ở exp3_week).
  all28   -- cả 28 họ, M/N.
  topK    -- K họ có AUC-OOF cá nhân cao nhất (K=5,8,10), M/N.
  greedy  -- chọn tham lam tiến, tối đa AUC-OOF của M/N (bắt cả tính bù trừ tương quan).
  stackL2 -- hồi quy logistic trọng số mềm trên xác suất 28 họ (fit OOF, phạt L2).
  stackNN -- như trên nhưng ràng buộc trọng số không âm (ổn định, diễn giải được).
Tất cả chốt RIÊNG TỪNG TUẦN vì họ nào có ích thay đổi theo hạn dự báo.
"""
import json
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ehd_common as C
import ehd_metrics as M

NPZ = os.path.join(C.OUT, "AI_Results", "exp3_week_s1_votes.npz")
OUT = os.path.join(C.OUT, "AI_Results", "exp3_select.json")
NQ = 9
BASE7 = ["Ridge", "ElasticNet", "LGBM_gbdt", "LGBM_rf", "ExtraTrees",
         "kNN25", "MLP_sk_64_32"]


def load_pooled(z, fams, week):
    """Gộp 21 điểm cho một tuần -> (P_oof, y_oof, P_val, y_val).

    P có dạng (nfam, nsample) là XÁC SUẤT theo họ = số phiếu / 9.
    """
    pts = sorted({k.split("|")[0] for k in z.files
                  if k.endswith("|%s|oof" % week) or ("|%s|" % week) in k
                  and k.split("|")[0].isdigit()})
    Po, yo, Pv, yv = [], [], [], []
    for p in pts:
        base = "%s|%s" % (p, week)
        if base + "|oof" not in z.files:
            continue
        Po.append(z[base + "|oof"].astype("f4") / NQ)
        Pv.append(z[base + "|val"].astype("f4") / NQ)
        yo.append(z[base + "|obs_oof"])
        yv.append(z[base + "|obs_val"])
    return (np.concatenate(Po, axis=1), np.concatenate(yo),
            np.concatenate(Pv, axis=1), np.concatenate(yv))


def op_scores(score, obs):
    """AUC (không phụ thuộc ngưỡng) + contingency tại điểm vận hành FBI=1.

    FBI=1: ngưỡng = phân vị (1 - base_rate) của điểm số, tức số dự báo dương bằng
    đúng số quan trắc dương.  Ở FBI=1 thì FAR = 1 - POD, nên đây là điểm vận hành
    khắt khe và đúng chỉ tiêu người dùng yêu cầu.
    """
    auc = M.auc_score(score, obs)
    r = float(obs.mean())
    t = np.quantile(score, 1.0 - r)
    a, b, c, d = M.contingency(score >= t, obs)
    s = M.scores(a, b, c, d)
    return dict(AUC=auc, POD=s["POD"], POFD=s["POFD"], FAR=s["FAR"],
                FBI=s["FBI"], CSI=s["CSI"], SEDI=s["SEDI"], EDI=s["EDI"],
                base=r)


def equal_vote(P, idx):
    """M/N: bình quân xác suất của các họ được chọn."""
    return P[idx].mean(axis=0)


def greedy_forward(Po, yo, order0, max_k=15, tol=1e-4):
    """Chọn tham lam tiến, tối đa AUC-OOF của phiếu bình quân.  order0 = thứ tự khởi
    đầu theo AUC cá nhân (để phá thế hoà)."""
    remaining = list(order0)
    chosen = [remaining.pop(0)]
    best = M.auc_score(equal_vote(Po, chosen), yo)
    improved = True
    while improved and len(chosen) < max_k and remaining:
        improved = False
        cand_best, cand_auc = None, best
        for f in remaining:
            a = M.auc_score(equal_vote(Po, chosen + [f]), yo)
            if a > cand_auc + tol:
                cand_auc, cand_best = a, f
        if cand_best is not None:
            chosen.append(cand_best)
            remaining.remove(cand_best)
            best = cand_auc
            improved = True
    return chosen, best


def stack_fit(Po, yo, nonneg=False, C_reg=0.5):
    from sklearn.linear_model import LogisticRegression
    X = Po.T
    m = LogisticRegression(C=C_reg, max_iter=2000)
    m.fit(X, yo.astype(int))
    if nonneg:
        # ép không âm: bỏ họ có hệ số âm rồi khớp lại một lần (xấp xỉ đủ tốt)
        w = m.coef_.ravel()
        keep = np.nonzero(w > 0)[0]
        if keep.size >= 2:
            m2 = LogisticRegression(C=C_reg, max_iter=2000)
            m2.fit(X[:, keep], yo.astype(int))
            full = np.zeros(X.shape[1])
            full[keep] = m2.coef_.ravel()
            return m2, keep, full, float(m2.intercept_[0])
    return m, np.arange(X.shape[1]), m.coef_.ravel(), float(m.intercept_[0])


def stack_cv_auc(Po, yo, nonneg=False, C_reg=0.5, nfold=5):
    """AUC OOF-CV trung thực của stack (fit và chấm trên các fold khác nhau)."""
    n = yo.size
    rng = np.random.default_rng(0)
    fold = rng.integers(0, nfold, n)
    pred = np.zeros(n)
    for k in range(nfold):
        te = fold == k
        if te.all() or not te.any() or yo[~te].sum() == 0:
            continue
        m, keep, _, _ = stack_fit(Po[:, ~te], yo[~te], nonneg, C_reg)
        pred[te] = m.predict_proba(Po[:, te].T[:, keep])[:, 1]
    return M.auc_score(pred, yo)


def main():
    z = np.load(NPZ, allow_pickle=True)
    fams = [str(x) for x in z["families"]]
    fidx = {f: i for i, f in enumerate(fams)}
    base7 = [fidx[f] for f in BASE7 if f in fidx]
    all28 = list(range(len(fams)))

    res = {"families": fams, "base7": BASE7, "by_week": {}}
    print("N họ = %d | base7 = %d họ" % (len(fams), len(base7)), flush=True)
    print("\n%-4s %-8s %6s %6s | %6s %6s %6s %6s %6s"
          % ("W", "method", "AUCoof", "AUCval", "POD", "FAR", "FBI",
             "CSI", "SEDI"), flush=True)

    for wname, _ in C.WEEKS:
        Po, yo, Pv, yv = load_pooled(z, fams, wname)
        # AUC cá nhân từng họ trên OOF (xếp hạng để khởi đầu greedy + topK)
        auc_f = np.array([M.auc_score(Po[i], yo) for i in range(len(fams))])
        order = list(np.argsort(-auc_f))

        methods = {}
        methods["base7"] = base7
        methods["all28"] = all28
        methods["top5"] = order[:5]
        methods["top8"] = order[:8]
        methods["top10"] = order[:10]
        gsel, gauc = greedy_forward(Po, yo, order)
        methods["greedy"] = gsel

        wk = {"n_oof": int(yo.size), "n_val": int(yv.size),
              "auc_family_oof": {fams[i]: float(auc_f[i]) for i in order},
              "greedy_set": [fams[i] for i in gsel], "methods": {}}

        for name, idx in methods.items():
            so = op_scores(equal_vote(Po, idx), yo)
            sv = op_scores(equal_vote(Pv, idx), yv)
            wk["methods"][name] = {"n_family": len(idx),
                                   "families": [fams[i] for i in idx],
                                   "oof": so, "val": sv}
            print("%-4s %-8s %6.3f %6.3f | %6.3f %6.3f %6.3f %6.3f %6.3f"
                  % (wname, name, so["AUC"], sv["AUC"], sv["POD"], sv["FAR"],
                     sv["FBI"], sv["CSI"], sv["SEDI"]), flush=True)

        # stacking trọng số mềm (fit OOF) + AUC OOF-CV trung thực
        for name, nn in (("stackL2", False), ("stackNN", True)):
            m, keep, w, b = stack_fit(Po, yo, nonneg=nn)
            po = m.predict_proba(Po.T[:, keep])[:, 1]
            pv = m.predict_proba(Pv.T[:, keep])[:, 1]
            so = op_scores(po, yo)
            sv = op_scores(pv, yv)
            cv = stack_cv_auc(Po, yo, nonneg=nn)
            wtop = sorted(zip(fams, w), key=lambda t: -abs(t[1]))[:6]
            wk["methods"][name] = {"oof": so, "val": sv, "auc_oof_cv": float(cv),
                                   "top_weights": [(f, float(x)) for f, x in wtop]}
            print("%-4s %-8s %6.3f %6.3f | %6.3f %6.3f %6.3f %6.3f %6.3f  (cvAUC %.3f)"
                  % (wname, name, so["AUC"], sv["AUC"], sv["POD"], sv["FAR"],
                     sv["FBI"], sv["CSI"], sv["SEDI"], cv), flush=True)
        res["by_week"][wname] = wk

    with open(OUT, "w") as fh:
        json.dump(res, fh, indent=1, default=float)
    print("\n[done] ->", OUT, flush=True)


if __name__ == "__main__":
    main()
