"""
=============================================================================
第 10 讲 · 代码 1：奖励模型的不确定性 —— 拉普拉斯（最后一层的贝叶斯）、集成、信息增益
=============================================================================

对应讲义：lectures/lecture10_active_llm.md 第 2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
主动地挑问题，前提是知道"奖励模型哪里不确定"。这个文件在第 8 讲的玩具世界里比较几种不确定性：

    演示 1：只用 MAP 的点估计 vs 拉普拉斯近似的预测 vs 自助法集成的预测：数据少时谁的概率更"诚实"？
    演示 2：奖励模型在哪里不确定？—— 涉及"少见的回答"（某种词出现 3 次以上）的对。
    演示 3：信息增益（BALD）拆开看：为什么"两个一样的回答"信息为 0、"一边倒的对"信息也很少。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_reward_uncertainty.py        # 约 10 秒

需要 numpy 和 PyTorch（只用来训练第 8 讲的 SFT 策略）。依赖本目录的 active_tools.py。
"""

import numpy as np

from active_tools import (ALL, ALL_RICH, World, acquisition_scores, bootstrap_ensemble, entropy, fit_laplace, pad,
                          posterior_samples, print_header, show, sigmoid)


def ece(p, y, bins=10):
    """期望校准误差（第 5 讲）：按预测概率分 10 段，各段 |平均预测 − 实际比例| 按样本数加权。"""
    edges = np.linspace(0, 1, bins + 1)
    idx = np.clip(np.digitize(p, edges) - 1, 0, bins - 1)
    total = 0.0
    for b in range(bins):
        m = idx == b
        if m.any():
            total += m.mean() * abs(p[m].mean() - y[m].mean())
    return float(total)


def log_loss(p, y):
    """对数损失。"""
    p = np.clip(p, 1e-12, 1 - 1e-12)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))


def random_pairs(world, n, rng):
    """从 SFT 策略采 n 对回答并让标注员比较，返回 (a, b, y, U)。"""
    a = world.sample(world.ref_lp, n, rng)
    b = world.sample(world.ref_lp, n, rng)
    y = world.label(a, b, rng)
    return a, b, y, world.X[a] - world.X[b]


# =============================================================================
# 演示 1
# =============================================================================
def demo_predictive(world):
    print_header("演示 1：点估计 vs 拉普拉斯 vs 集成 —— 预测概率诚实吗？")
    rng = np.random.default_rng(1)
    _, _, yt, Ut = random_pairs(world, 4000, rng)
    print("训练数据：从 SFT 策略随机采的回答对（专家标注员）。在 4000 对新数据上评估三种预测概率：")
    print("  · 点估计：σ(ŵᵀu)，ŵ 是 MAP")
    print("  · 拉普拉斯：对后验 N(ŵ, Σ) 的 200 个样本求平均 E[σ(wᵀu)]（“最后一层的贝叶斯”）")
    print("  · 集成：10 个自助法成员的 σ(wᵀu) 的平均\n")
    print(f"  {pad('训练对数', 9)} {pad('方法', 10)} {pad('准确率', 7, 'right')} {pad('对数损失', 9, 'right')} {pad('ECE', 7, 'right')}")
    for n in [30, 100, 1000]:
        _, _, y, U = random_pairs(world, n, rng)
        w, cov = fit_laplace(U, y)
        S = posterior_samples(w, cov, 200, rng)
        E = bootstrap_ensemble(U, y, 10, rng)
        preds = {"点估计": sigmoid(Ut @ w), "拉普拉斯": sigmoid(S @ Ut.T).mean(axis=0),
                 "集成": sigmoid(E @ Ut.T).mean(axis=0)}
        for name, p in preds.items():
            print(f"  {n:>9d} {pad(name, 10)} {np.mean((p > 0.5) == (yt == 1)):>7.3f} {log_loss(p, yt):>9.3f} "
                  f"{ece(p, yt):>7.3f}")
        print()
    print("""观察：
  · 数据少（30 对）时，点估计过于自信：ECE 和对数损失都最差。它忽略了“我们还不知道 w”。
    拉普拉斯和集成把参数的不确定性“平均”进了预测，概率更保守、更校准（第 6 讲演示 1 的“后验预测”）。
  · 100 对时差别已经很小，而且不一致（这次拉普拉斯的 ECE 反而略高）—— 不确定性带来的好处主要在数据很少的时候。
  · 准确率几乎一样 —— 不确定性改变的是“多有把握”，不是“选哪个”。
  · 1000 对时三者几乎一样：参数已经很确定了。
  · 对主动学习来说，重要的不是预测概率本身，而是“哪些问题的答案我们最没把握、又最能减少这种没把握”
    （演示 3）。""")


# =============================================================================
# 演示 2
# =============================================================================
def demo_where_uncertain(world):
    print_header("演示 2：奖励模型在哪里不确定？")
    rng = np.random.default_rng(2)
    _, _, y, U = random_pairs(world, 100, rng)
    w, cov = fit_laplace(U, y)
    E = bootstrap_ensemble(U, y, 10, rng)
    a, b, yt, Ut = random_pairs(world, 4000, rng)
    # "少见的回答"：某种词出现 3 次以上（rich_features 里每种词的第三个指示变量）
    rare_cols = [3 * k + 2 for k in range(5)]
    rare = (ALL_RICH[a][:, rare_cols].sum(1) + ALL_RICH[b][:, rare_cols].sum(1)) > 0
    lap_sd = np.sqrt(np.einsum("nd,de,ne->n", Ut, cov, Ut))
    ens_sd = (E @ Ut.T).std(axis=0)
    wrong = (Ut @ w > 0) != (yt == 1)
    print("用 100 对随机数据训练。把 4000 对新数据分成两组：")
    print("  · 常见：两个回答里每种词都不超过 2 个；  · 少见：至少一个回答里某种词出现了 3 次以上\n")
    print(f"  {pad('组', 6)} {pad('比例', 6, 'right')} {pad('分数差的拉普拉斯标准差', 23, 'right')} "
          f"{pad('集成标准差', 11, 'right')} {pad('判错率（对标注）', 17, 'right')}")
    for name, m in [("常见", ~rare), ("少见", rare)]:
        print(f"  {pad(name, 6)} {m.mean():>6.2f} {lap_sd[m].mean():>23.3f} {ens_sd[m].mean():>11.3f} {wrong[m].mean():>17.3f}")
    print(f"\n  拉普拉斯标准差和集成标准差的相关系数：{np.corrcoef(lap_sd, ens_sd)[0, 1]:.3f}")
    print("""
观察：
  · SFT 策略很少写出“某种词出现 3 次以上”的回答，训练数据里这类特征的信息很少 —— 奖励模型在这些对上
    更不确定，也更常判错（相对于标注）。
  · 两种不确定性（拉普拉斯、集成）正相关，但相关系数只有 0.5 左右：10 个自助法成员的标准差本身噪声不小，
    自助法对“只出现过一两次的特征”也特别敏感。两者估计的是同一个东西（参数的后验）的不同近似。
    对深度网络，集成还能捕捉“表示本身”的不确定性（第 6 讲：各自预训练的集成）。
  · 而这正是 RLHF 会去的地方：第 8 讲的过度优化把策略推向“要点堆满”的少见回答。
    **奖励模型最不确定的地方，恰好是策略最想去的地方。**""")


# =============================================================================
# 演示 3
# =============================================================================
def demo_bald(world):
    print_header("演示 3：信息增益（BALD）拆开看")
    rng = np.random.default_rng(3)
    _, _, y, U = random_pairs(world, 60, rng)
    w, cov = fit_laplace(U, y)
    S = posterior_samples(w, cov, 2000, rng)
    examples = [((1, 1, 2), (1, 2, 1), "两个回答特征完全相同"),
                ((1, 1, 2), (5, 5, 3), "一边倒（好 vs 很差）"),
                ((1, 2), (1, 4), "接近，特征常见"),
                ((1, 1, 1, 1), (1, 1, 2), "接近，涉及少见特征")]
    print("用 60 对随机数据训练，再看四个候选对。BALD = H(E[p]) − E[H(p)]（单位 nat，最大 log 2 ≈ 0.693）：\n")
    print(f"  {pad('候选对', 40)} {pad('E[p]', 6, 'right')} {pad('H(E[p])', 8, 'right')} {pad('E[H(p)]', 8, 'right')} "
          f"{pad('BALD', 7, 'right')} {pad('D 最优', 7, 'right')}")
    idx = {s: k for k, s in enumerate(ALL)}
    for a, b, note in examples:
        u = (ALL_RICH[idx[a]] - ALL_RICH[idx[b]])[None, :]
        P = sigmoid(S @ u.T)[:, 0]
        bald = max(float(acquisition_scores("bald", u, w, cov, rng, samples=S)[0]), 0.0)   # 浮点误差可能给出 −0.000
        dopt = float(acquisition_scores("dopt", u, w, cov, rng)[0])
        name = f"{show(a)} vs {show(b)}"
        print(f"  {pad(name, 40)} {P.mean():>6.3f} {entropy(P.mean()):>8.3f} {entropy(P).mean():>8.3f} {bald:>7.3f} {dopt:>7.3f}")
        print(f"    （{note}）")
    print("""
观察：
  · 两个回答特征完全相同（只是词序不同）：每个可能的 w 都说“五五开”，H(E[p]) = E[H(p)] = log 2，BALD = 0。
    答案完全是随机的，**问了也学不到任何东西** —— 但“p 最接近 0.5”的不确定性采样最喜欢这种问题（代码 02）。
  · 一边倒的对：几乎所有 w 都认为第一个更好，H(E[p]) 很小，BALD 也很小：答案早就知道了。
  · 涉及少见特征的对：不同的 w 给出的 p 分散得更开，E[H(p)] 明显小于 H(E[p])，
    BALD 最大 —— “没把握”来自**我们不知道 w**，而不是标注本身的随机性。这正是值得问的问题。
  · BALD 和第 7 讲的 D 最优增量给出的排序很接近：两者都在找“参数不确定 × 答案没把握”的问题。""")


def main():
    world = World()
    demo_predictive(world)
    demo_where_uncertain(world)
    demo_bald(world)


if __name__ == "__main__":
    main()
