"""
=============================================================================
第 5 讲 · 代码 03：让模型说"不知道" —— 弃权阈值、风险与覆盖率、教模型弃权
=============================================================================

对应讲义：lectures/lecture05_hallucination_calibration.md §5

【这个脚本要回答的问题】
    1. 只在有把握时回答、其余弃权：弃权越多，回答了的那些越准吗？能用多少覆盖率换多少准确率？（演示 1）
    2. 阈值怎样定？给定"可以接受的错误率"或"答错 vs 弃权的代价"，在开发集上定阈值，在测试集上验证。（演示 2）
    3. 能不能直接**训练**模型说 "unknown"？和"只教它知道的事实""也教它不知道的事实"比，幻觉少了多少、代价是什么？（演示 3）

【怎么读输出】
    覆盖率 = 回答了的比例；风险 = 回答了的里面答错的比例。
    "幻觉" = 回答了、但答错了（没有弃权）。"过度弃权" = 本来知道（base 模型能答对）却说了 unknown。

【运行】python3 03_abstention.py     （读 02 号脚本训练的缓存；没有的话先训练约 30 秒；之后约 2 秒）
【依赖】PyTorch；import 同目录的 hallu_lab.py，第 1 讲的 console.py。
"""

import os
import random
import sys

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
from console import print_header, print_observations, print_table  # noqa: E402
from hallu_lab import (COUNTRIES, HEAD_TRAIN, QA_TASKS, TAIL_TEST, TAIL_TRAIN, auroc, chat_probe,  # noqa: E402
                       fit_temperature, get_longtail_model, get_sft_model, probe, risk_coverage, with_temperature)


def demo1(rows):
    """演示 1：风险—覆盖率。"""
    print_header("演示 1：只回答最有把握的那些——覆盖率 vs 风险（240 个事实，按置信度从高到低）")
    confs = [r["conf"] for r in rows]
    ys = [r["correct"] for r in rows]
    rc = risk_coverage(confs, ys)
    n = len(rows)
    base_risk = 1 - sum(ys) / n
    table = []
    for cov in [1.0, 0.9, 0.8, 0.7, 0.6, 0.5]:
        k = max(1, round(cov * n))
        c, risk, thr = rc[k - 1]
        table.append([f"{100 * cov:.0f}%", k, f"{thr:.2f}", f"{100 * risk:.1f}%", f"{100 * base_risk:.1f}%"])
    print_table(["覆盖率", "回答的题数", "置信度阈值", "风险（回答了的里面答错）", "随机弃权时的风险"], table)
    print(f"\n  置信度的 AUROC = {auroc(confs, ys):.3f}")
    r90 = rc[round(0.9 * n) - 1][1]
    r80 = rc[round(0.8 * n) - 1][1]
    print_observations([
        f"全部回答时风险 {100 * base_risk:.1f}%；只回答最有把握的 90%，风险降到 {100 * r90:.1f}%；80% 时 {100 * r80:.1f}%。"
        "**用少量的覆盖率，换来大量的可靠性**——前提是置信度能把对错分开（AUROC 高）。",
        "如果随机地弃权，风险不会变（最后一列）：弃权本身没有用，有用的是'在该弃权的地方弃权'。",
        "这叫**选择性预测**（selective prediction，El-Yaniv & Wiener 2010）。在问答系统里，Kamath et al.（2020）训练一个"
        "额外的'校准器'来决定什么时候弃权，特别是在测试分布和训练分布不一样时。",
    ])


def demo2(rows, tok):
    """演示 2：在开发集上定阈值。"""
    print_header("演示 2：在开发集（一半事实）上定阈值，在测试集（另一半）上验证")
    rng = random.Random(1)
    idx = list(range(len(rows)))
    rng.shuffle(idx)
    dev, test = [rows[i] for i in idx[:120]], [rows[i] for i in idx[120:]]
    T = fit_temperature(dev, tok)
    dev_t, test_t = with_temperature(dev, T), with_temperature(test, T)

    def evaluate(rs, thr):
        ans = [r for r in rs if r["conf"] >= thr]
        risk = sum(not r["correct"] for r in ans) / len(ans) if ans else 0.0
        return len(ans) / len(rs), risk

    rows_out = []
    # (1) 目标风险 5%：在开发集上找"风险不超过 5% 的最低阈值"
    for name, d, t in [("原始置信度", dev, test), (f"温度缩放后（T = {T:.2f}）", dev_t, test_t)]:
        cands = sorted({r["conf"] for r in d})
        thr = next((x for x in cands if evaluate(d, x)[1] <= 0.05), 1.0)
        cov_d, risk_d = evaluate(d, thr)
        cov_t, risk_t = evaluate(t, thr)
        rows_out.append([f"目标风险 ≤ 5% · {name}", f"{thr:.2f}", f"{100 * cov_d:.0f}% / {100 * risk_d:.1f}%",
                         f"{100 * cov_t:.0f}% / {100 * risk_t:.1f}%"])
    print_table(["阈值的定法", "阈值", "开发集：覆盖率 / 风险", "测试集：覆盖率 / 风险"], rows_out)

    print("\n  按代价定阈值：弃权的代价记为 1，答错的代价记为 c。只有当 (1 − 置信度) × c < 1，也就是置信度 > 1 − 1/c 时才回答。")
    table = []
    res = {}
    for c in [2, 4, 10]:
        thr = 1 - 1 / c
        for name, t in [("原始", test), ("温度缩放后", test_t)]:
            ans = [r for r in t if r["conf"] > thr]
            cost = sum(c for r in ans if not r["correct"]) + (len(t) - len(ans))
            always = sum(c for r in t if not r["correct"])
            res[(c, name)] = cost / len(t)
            table.append([c, f"{thr:.2f}", name, f"{100 * len(ans) / len(t):.0f}%", f"{cost / len(t):.3f}", f"{always / len(t):.3f}"])
    print_table(["答错的代价 c", "阈值 1 − 1/c", "置信度", "覆盖率", "平均代价（按阈值）", "平均代价（全部回答）"], table)
    print_observations([
        "用开发集定的阈值，在测试集上的风险和覆盖率会有出入（样本只有 120 个）：阈值要在独立的数据上验证，并且要定期复查——"
        "问题的分布一变，阈值就可能失效。",
        "'置信度 > 1 − 1/c'这个规则只有在置信度**校准良好**时才是最优的：它把置信度当成了真实的正确率。"
        f"答错代价 c = 4 时，原始置信度的平均代价 {res[(4, '原始')]:.3f}，温度缩放后 {res[(4, '温度缩放后')]:.3f}。"
        "校准决定了'按代价定阈值'是否可信。",
        "代价越高（例如医疗、法律场景答错的后果严重），阈值越高、弃权越多。弃权率本身是一个**产品决策**，不是一个技术常数。",
    ])


def demo3():
    """演示 3：三种指令微调配方。"""
    print_header("演示 3：直接训练模型说 'unknown'（测试用的国家都没进过微调数据）")
    base, btok = get_longtail_model(verbose=False)
    trained = {c.name for c in HEAD_TRAIN + TAIL_TRAIN}
    test_facts = [(c, rel) for c in COUNTRIES if c.name not in trained for rel in QA_TASKS]
    knows = {(r["country"], r["rel"]): r["correct"] for r in probe(base, btok, test_facts)}
    n_known = sum(knows.values())
    n_unknown = len(knows) - n_known
    table = []
    res = {}
    for variant, label in [("known", "只用知道的事实"), ("teach", "也教它不知道的事实"), ("abstain", "不知道就答 unknown")]:
        m, t = get_sft_model(variant)
        rows = chat_probe(m, t, test_facts)
        k_ok = sum(r["correct"] for r in rows if knows[(r["country"], r["rel"])])
        k_ab = sum(r["abstain"] for r in rows if knows[(r["country"], r["rel"])])
        u_ab = sum(r["abstain"] for r in rows if not knows[(r["country"], r["rel"])])
        u_ok = sum(r["correct"] for r in rows if not knows[(r["country"], r["rel"])])
        u_hal = n_unknown - u_ab - u_ok
        tail_rows = [r for r in rows if r["country"] in {c.name for c in TAIL_TEST}]
        res[variant] = dict(k_ok=k_ok / n_known, k_ab=k_ab / n_known, u_ab=u_ab / n_unknown, u_hal=u_hal / n_unknown,
                            u_ok=u_ok / n_unknown, tail_hal=sum(not r["abstain"] and not r["correct"] for r in tail_rows) / len(tail_rows))
        r = res[variant]
        table.append([label, f"{100 * r['k_ok']:.1f}%", f"{100 * r['k_ab']:.1f}%", f"{100 * r['u_ab']:.1f}%",
                      f"{100 * r['u_hal']:.1f}%", f"{100 * r['u_ok']:.1f}%"])
    print(f"  测试事实 {len(test_facts)} 个：base 模型（问答页格式）知道其中 {n_known} 个、不知道 {n_unknown} 个")
    print_table(["微调配方", "知道的事实：答对", "知道的：过度弃权", "不知道的：弃权", "不知道的：幻觉", "不知道的：碰巧答对"], table)
    kn, te, ab = res["known"], res["teach"], res["abstain"]
    print_observations([
        f"只用知道的事实微调：不知道的事实上 {100 * kn['u_hal']:.1f}% 是幻觉——它从没见过 'unknown' 这个回答，也就不会弃权。",
        f"也教它不知道的事实：幻觉升到 {100 * te['u_hal']:.1f}%，连知道的事实也只答对 {100 * te['k_ok']:.1f}%"
        f"（只用知道的事实时 {100 * kn['k_ok']:.1f}%）。教它'背'那些没有依据的事实，伤害了它的整体表现（02 号脚本演示 3 看到它同时变得更自信）。",
        f"把不知道的事实标成 'unknown'：不知道的事实上 {100 * ab['u_ab']:.1f}% 弃权，幻觉降到 {100 * ab['u_hal']:.1f}%；"
        f"代价是知道的事实里 {100 * ab['k_ab']:.1f}% 也弃权了（过度弃权）。幻觉减少了，但没有消失：弃权的能力"
        "要从'模型自己的知识边界'上学，而这条边界本身是模糊的（01 号脚本：见过两次的事实它知道，但没把握）。",
        "这正是 R-Tuning（Zhang et al. 2024）等方法的思路：先用模型自己的回答找出它不知道的问题，再把这些问题的目标回答改成"
        "'我不确定'。真实的 chat 模型也被训练成在一些情况下说'我不知道'，但过度拒答本身也是一种失败（第 6、12 讲）。",
    ])


def main():
    model, tok = get_longtail_model()
    rows = probe(model, tok)
    demo1(rows)
    demo2(rows, tok)
    demo3()


if __name__ == "__main__":
    main()
