"""
=============================================================================
第 7 讲 · 代码 5：校准与"知道自己知道什么"（calibration, P(IK)）
=============================================================================

对应讲义：lectures/lecture07_instruction_tuning_alignment.md 第 9 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    "诚实"（HHH 里的 honest）的一个前提是：模型的置信度要和它答对的概率相符（校准，calibration），
    不知道的时候要能察觉自己不知道。Kadavath et al. 2022 在 52B 模型上研究了这件事。这里在迷你世界里问同一个问题：

    演示 1：base model 和指令微调后的模型，置信度和准确率对得上吗？
            置信度 = 在 8 个城市里，模型给出的最大概率（rank classification 的分布）。
            分三组人看：SFT 数据里出现过的 train 人物、没出现过的 test 人物、语料里根本不存在的人。
    演示 2：P(IK)（"我知道答案吗"）：在模型的内部表示上训练一个线性探针，预测"模型这次会不会答对"。
    演示 3：用 SFT 教模型说 "i do not know"——它学会的是"不认识这个人"，还是"SFT 数据里没见过这个人"？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 05_calibration.py      # 第一次约 10 秒、之后约 3 秒（读取代码 01 缓存的 sft_all_s0；没有缓存时先训练，多约 6 秒）

需要 PyTorch、numpy。
"""

import random
import time

import numpy as np
import torch
import torch.nn.functional as F

from align_tools import (CITIES, CLUSTERS, IDK, TEMPLATES, greedy_answers, instr_prompt, instruction_model,
                         load_base, make_names, parse_answer, score_candidates, split_people)
from console import pad, print_header
from tiny_gpt import hidden_states


# =============================================================================
# 工具
# =============================================================================
FORMATS = {
    "base · 叙述句 \"X lives in\"": ("base", lambda n: f"\n{n} lives in"),
    "base · 问答 \"q: where does X live? a:\"": ("base", lambda n: f"\nq: where does {n} live? a:"),
    "SFT · 指令 \"where does X live?\"": ("sft", lambda n: instr_prompt(f"where does {n} live?")),
}


def city_distribution(model, tok, prompts, temperature=1.0):
    """
    模型在 8 个城市上的分布：rank classification 的对数概率除以温度后做 softmax。

    【返回】(n, 8) 的数组
    【FM 联系】Kadavath et al. 用的就是这种"在选项上归一化"的概率来衡量多选题的校准。
    """
    s = score_candidates(model, tok, prompts, CITIES) / temperature
    p = np.exp(s - s.max(1, keepdims=True))
    return p / p.sum(1, keepdims=True)


def ece(conf, correct, n_bins=5):
    """
    期望校准误差（expected calibration error）：按置信度分箱，每箱 |平均置信度 − 准确率|，按箱里的样本数加权平均。

    【参数】conf、correct：长度相同的数组（correct 是 0/1）
    【返回】0 到 1 之间的数；0 表示完美校准
    【边界】样本很少时（这里每组 30 人）ECE 本身噪声很大，只看大小关系，不看小数点后第二位。
    """
    edges = np.linspace(0, 1, n_bins + 1)
    total = 0.0
    for lo, hi in zip(edges[:-1], edges[1:]):
        sel = (conf > lo) & (conf <= hi)
        if sel.any():
            total += sel.mean() * abs(conf[sel].mean() - correct[sel].mean())
    return float(total)


def auroc(score, label):
    """
    AUROC：随机挑一个正例和一个负例，正例得分更高的概率（相等算一半）。0.5 = 瞎猜，1 = 完美区分。

    【参数】score：得分数组；label：0/1 数组
    【返回】0 到 1 之间的数；没有正例或没有负例时返回 nan
    【思路】直接枚举所有（正例, 负例）对——这里样本只有几十个，O(n²) 完全够快。
    """
    pos, neg = score[label == 1], score[label == 0]
    if len(pos) == 0 or len(neg) == 0:
        return float("nan")
    return float(np.mean([(p > q) + 0.5 * (p == q) for p in pos for q in neg]))


def fit_logreg(X, y, l2=1e-2):
    """
    带 L2 正则的逻辑回归（和第 1 讲代码 03 的同名函数相同的做法：L-BFGS，float64）。

    【参数】X：(n, d) 特征；y：(n,) 的 0/1 标签；l2：L2 正则强度
    【返回】函数 score(X_test) -> 线性得分 w·x + b（越大越可能是正例）
    【边界】训练集里只有一个类别时，得分没有意义（AUROC 会接近 0.5）。
    """
    Xt, yt = torch.tensor(X, dtype=torch.float64), torch.tensor(y, dtype=torch.float64)
    w = torch.zeros(Xt.shape[1], dtype=torch.float64, requires_grad=True)
    b = torch.zeros(1, dtype=torch.float64, requires_grad=True)
    opt = torch.optim.LBFGS([w, b], max_iter=200, line_search_fn="strong_wolfe")

    def closure():
        """LBFGS 需要的闭包：重新算一次损失和梯度。"""
        opt.zero_grad()
        loss = F.binary_cross_entropy_with_logits(Xt @ w + b, yt) + l2 * (w ** 2).sum()
        loss.backward()
        return loss

    opt.step(closure)
    w_np, b_np = w.detach().numpy(), b.detach().numpy()
    return lambda X_test: np.asarray(X_test) @ w_np + b_np


# =============================================================================
# 演示 1：校准
# =============================================================================
def demo_calibration(tok, models, groups):
    """演示 1：三种模型 · 问法在三组名字上的准确率、置信度、ECE；再看调温的效果。"""
    print_header("演示 1：置信度和准确率对得上吗？（置信度 = 8 个城市里最大的那个概率）")
    print(f"三组名字：train 人物 {len(groups['train'])} 个（SFT 数据里问过他们）、test 人物 {len(groups['test'])} 个"
          f"（SFT 没见过，预训练见过）、不存在的人 {len(groups['unknown'])} 个（语料里从没出现过的名字）\n")
    print(pad("模型 · 问法", 40) + pad("train 准确/置信", 16, ">") + pad("test 准确/置信", 16, ">")
          + pad("test ECE", 10, ">") + pad("不存在的人：置信", 18, ">"))
    out = {}
    for label, (which, fmt) in FORMATS.items():
        model = models[which]
        cells = []
        for g in ("train", "test"):
            people = groups[g]
            P = city_distribution(model, tok, [fmt(p.name) for p in people])
            conf = P.max(1)
            correct = np.array([CITIES[int(i)] == p.city for i, p in zip(P.argmax(1), people)], dtype=float)
            cells.append((correct.mean(), conf.mean()))
            if g == "test":
                test_ece = ece(conf, correct)
                out[label] = (conf, correct)
        Pu = city_distribution(model, tok, [fmt(n) for n in groups["unknown"]])
        print(pad(label, 40) + pad(f"{cells[0][0]:.2f} / {cells[0][1]:.2f}", 16, ">")
              + pad(f"{cells[1][0]:.2f} / {cells[1][1]:.2f}", 16, ">") + pad(f"{test_ece:.3f}", 10, ">")
              + pad(f"{Pu.max(1).mean():.2f}", 18, ">"))

    print("\n事后补救：把 SFT 模型的分布\"调温\"（对数概率除以 T），test 人物上的置信度与 ECE：")
    people = groups["test"]
    fmt = FORMATS["SFT · 指令 \"where does X live?\""][1]
    print(pad("T", 6) + pad("平均置信度", 12, ">") + pad("准确率", 9, ">") + pad("ECE", 9, ">") + pad("不存在的人：置信", 18, ">"))
    for T in (1.0, 2.0, 3.0):
        P = city_distribution(models["sft"], tok, [fmt(p.name) for p in people], temperature=T)
        conf = P.max(1)
        correct = np.array([CITIES[int(i)] == p.city for i, p in zip(P.argmax(1), people)], dtype=float)
        Pu = city_distribution(models["sft"], tok, [fmt(n) for n in groups["unknown"]], temperature=T)
        print(pad(f"{T:g}", 6) + pad(f"{conf.mean():.2f}", 12, ">") + pad(f"{correct.mean():.2f}", 9, ">")
              + pad(f"{ece(conf, correct):.3f}", 9, ">") + pad(f"{Pu.max(1).mean():.2f}", 18, ">"))
    print("""
观察 Observations：
  · base model 用它最熟悉的叙述句问，校准得很好：准确率 0.93、平均置信度 0.89–0.90，ECE 0.040。
    Kadavath et al. 发现大的预训练模型在合适的格式下校准良好；这个 11 万参数的模型在自己的"母语"格式里也是。
  · 换成问答格式（只有一半人在语料里有问答），置信度几乎不变（0.83–0.84），准确率却低了（0.67–0.77）：
    格式不熟悉时，模型不会相应地降低自信，ECE 升到 0.090。
  · 指令微调之后：对 SFT 里问过的 train 人物，准确率 1.00、置信度 0.98；对没问过的 test 人物，准确率 0.50，
    置信度仍有 0.86，ECE 0.364——**SFT 教会了模型用同样的口气回答所有人，不管它知不知道**。
  · 对根本不存在的人，三种问法的平均置信度是 0.72–0.84，和对真人的置信度相差不大：模型会很有把握地
    为不存在的人编一个城市。这就是幻觉（第 1 讲 §5.1）在校准上的样子。
  · 调温（T = 3）把 test 人物的 ECE 从 0.364 降到 0.072——Kadavath et al. 也是用 T = 2.5 修好了 RLHF 策略的校准。
    但它同样把对不存在的人的置信度降到 0.54：调温修正的是**平均**的过度自信，不会让模型分清"认识"和"不认识"。""")


# =============================================================================
# 演示 2：P(IK) 探针
# =============================================================================
def demo_pik(tok, models, groups):
    """
    演示 2：两种 P(IK)——直接用最大概率，和在内部表示上训练的线性探针——能不能预测"会不会答对"。

    【numpy 小知识】np.random.default_rng(0) 创建一个独立的随机数生成器；rng.permutation(n) 返回 0..n−1 的随机排列，
                   前一半当训练集、后一半当测试集。布尔数组可以直接当下标：conf[is_real] 只取真人那些位置。
    """
    print_header("演示 2：P(IK)——模型能不能事先知道\"这题我会答对吗\"？")
    real = groups["train"] + groups["test"]
    names = [p.name for p in real] + list(groups["unknown"])
    truth = {p.name: p.city for p in real}
    is_real = np.array([n in truth for n in names])
    print(f"样本：{len(names)} 个名字（{len(real)} 个真实人物 + {len(groups['unknown'])} 个不存在的人）。"
          "\"答对\" = 8 个城市里概率最大的那个是正确城市；不存在的人一律算\"答不对\"。")
    print("两种 P(IK)：(1) 直接用模型自己的最大概率（不训练）；(2) 在提示末尾的 64 维表示上训练线性探针"
          "（随机一半名字训练、一半测试，重复 20 次）。数字都是 AUROC：0.5 = 分不出，1.0 = 完全分得出。\n")
    print(pad("模型 · 问法", 40) + pad("真人答对", 9, ">") + pad("最大概率：真人对 vs 错", 26, ">")
          + pad("最大概率：答对的真人 vs 不存在的人", 34, ">") + pad("线性探针（全部）", 17, ">"))
    for label, (which, fmt) in FORMATS.items():
        model = models[which]
        prompts = [fmt(n) for n in names]
        P = city_distribution(model, tok, prompts)
        conf = P.max(1)
        y = np.array([CITIES[int(i)] == truth.get(n) for i, n in zip(P.argmax(1), names)], dtype=float)
        auc_real = auroc(conf[is_real], y[is_real])
        sel = (y == 1) | ~is_real                  # 答对的真人（正例）和不存在的人（负例）
        auc_unknown = auroc(conf[sel], y[sel])
        feats = np.stack([hidden_states(model, tok, p + " ")[-1].numpy() for p in prompts]).astype(np.float64)
        rng = np.random.default_rng(0)
        aucs = []
        for _ in range(20):
            perm = rng.permutation(len(names))
            tr, te = perm[:len(names) // 2], perm[len(names) // 2:]
            aucs.append(auroc(fit_logreg(feats[tr], y[tr])(feats[te]), y[te]))
        print(pad(label, 40) + pad(f"{y[is_real].mean():.2f}", 9, ">") + pad(f"{auc_real:.3f}", 26, ">")
              + pad(f"{auc_unknown:.3f}", 34, ">") + pad(f"{np.nanmean(aucs):.3f}", 17, ">"))
    print("""
观察 Observations：
  · 在真实人物之间，最大概率本身就是一个不错的 P(IK)：AUROC 0.82–0.90，模型答错的人，置信度通常也低一些。
    （base 叙述句只答错了 4 个人，0.853 这个数噪声很大。）
  · 但要把"答对的真人"和"不存在的人"分开，就差一些：问答格式只有 0.591，指令格式 0.778——
    模型对不存在的人给出的置信度，和它对真人的置信度落在同一个范围里。"不知道"和"不存在"在它看来差别不大。
  · 在 64 维表示上训练的线性探针（每次只有 50 个训练样本）反而比直接用最大概率差（0.52–0.70）：最大概率是表示的
    一个非线性函数（softmax 的最大值），线性探针用这么少的样本学不出来。Kadavath et al. 的 P(IK) 是在大量问题上
    微调整个模型得到的，而且跨任务的泛化也只是部分成功。""")


# =============================================================================
# 演示 3：教模型说"不知道"
# =============================================================================
def demo_idk(tok, base, train_people, groups, unknown_train):
    """演示 3：在"问城市"里混入 20 个不存在的名字（答案 i do not know）做 SFT，看它对四组名字分别怎样回答。"""
    print_header("演示 3：用 SFT 教模型说 \"i do not know\"——它学会的是哪一种\"不知道\"？")
    model, _ = instruction_model("sft_idk", base, tok, CLUSTERS, train_people, seed=0,
                                 unknown_names=unknown_train, p_unknown=0.3)
    print(f"和 sft_all_s0 相同的设置，只是\"问城市\"的题里有 30% 换成 {len(unknown_train)} 个不存在的名字，标准答案是 \"{IDK}\"。")
    print("评测用 4 个训练模板问城市，贪心生成：\n")
    rows = [("train 人物（SFT 里问过）", [p.name for p in groups["train"]]),
            ("test 人物（预训练见过，SFT 没问过）", [p.name for p in groups["test"]]),
            ("SFT 里出现过的不存在的名字", list(unknown_train)),
            ("新的不存在的名字", list(groups["unknown"]))]
    truth = {p.name: p.city for p in groups["train"] + groups["test"]}
    print(pad("名字", 34) + pad("说不知道", 10, ">") + pad("答对城市", 10, ">") + pad("答了一个错的城市", 18, ">"))
    for label, names in rows:
        prompts = [instr_prompt(t.format(n=n)) for t in TEMPLATES["city"][0] for n in names]
        answers = [parse_answer(g) for g in greedy_answers(model, tok, prompts, max_new=16)]
        golds = [truth.get(n) for t in TEMPLATES["city"][0] for n in names]
        idk = np.mean([a == IDK for a in answers])
        right = np.mean([a == g for a, g in zip(answers, golds)])
        wrong_city = np.mean([a in CITIES and a != g for a, g in zip(answers, golds)])
        print(pad(label, 34) + pad(f"{idk:.2f}", 10, ">") + pad(f"{right:.2f}", 10, ">") + pad(f"{wrong_city:.2f}", 18, ">"))
    print("""
观察 Observations：
  · 对 SFT 里出现过的 20 个不存在的名字，模型 90% 的时候说 "i do not know"——训练数据它记住了。
  · 对**新的**不存在的名字，它只有 9% 的时候说不知道，88% 的时候编了一个城市：幻觉一点没少。
  · 对预训练见过、SFT 没问过的 test 人物，它反而有 26% 的时候说不知道，答对的比例从 sft_all_s0 的 0.46 掉到 0.22。
  · 也就是说，它学会的"不知道"是"这个名字我在 SFT 数据里见过它配 i do not know"，再加上一点
    "这个名字没在 SFT 里出现过"——而不是"这个人我在预训练里没见过"。要让"说不知道"和模型**自己的**知识对上，
    训练标签就得来自模型自己答不答得对（Kadavath et al. 训练 P(IK) 正是这样做的），而不是来自题目本身。""")


def main():
    """依次运行本文件的各个演示。"""
    t0 = time.time()
    world, tok, base = load_base()
    train_people, test_people = split_people(world)
    known = {p.name for p in world.people}
    unknown = [n for n in make_names(300, random.Random(77)) if n not in known][:60]
    unknown_train, unknown_eval = unknown[:20], unknown[20:]
    sft, _ = instruction_model("sft_all_s0", base, tok, CLUSTERS, train_people, seed=0)
    models = {"base": base, "sft": sft}
    groups = {"train": train_people, "test": test_people, "unknown": unknown_eval}
    demo_calibration(tok, models, groups)
    demo_pik(tok, models, groups)
    demo_idk(tok, base, train_people, groups, unknown_train)
    print(f"\n总用时 {time.time() - t0:.1f} 秒（计时每次运行略有不同）")


if __name__ == "__main__":
    main()
