"""
=============================================================================
第 5 讲 · 代码 5：怎样知道学得好不好 —— 准确率、AUC、对数损失、校准
=============================================================================

对应讲义：lectures/lecture05_mle.md 第 8、9 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
一个偏好模型"好"，可以有很多种意思：排序对不对（准确率、AUC）？概率准不准（对数损失、Brier 分数）？
说"70% 的把握"时是不是真的有 70% 的时候对（校准）？这个文件说明：这些指标衡量的是不同的东西，
一个模型可以在一个指标上很好、在另一个上很差。

    演示 1：上限 —— 即使用真实的分数，准确率和 AUC 也到不了 100%（数据本身有噪声）。
    演示 2：过于自信的模型 —— 把分数乘以 2，排序完全不变（准确率、AUC 一样），概率却全错了
            （对数损失、ECE 变差）。"温度缩放"用一个数就能修好。
    演示 3：教材第 2.9 节的 LLM 偏好数据（50 个回答、8 维嵌入、200 条比较、10% 的标签噪声）。
            随机噪声和系统性噪声（标注员偏爱"长"回答）对学到的奖励模型有什么不同的影响？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 05_evaluation_metrics.py        # 约 2 秒

需要 numpy。只依赖本目录的 bt_numpy.py。
"""

import numpy as np

from bt_numpy import (accuracy, auc, brier, ece, fit_linear_bt, fit_newton, log_loss, pad, predict, print_header,
                      sigmoid, simulate_bt)


def all_metrics(p, y):
    """返回 (准确率, AUC, 对数损失, Brier, ECE)。"""
    return accuracy(p, y), auc(p, y), log_loss(p, y), brier(p, y), ece(p, y)[0]


def print_metrics_header():
    """打印指标表的表头。"""
    print(f"  {pad('模型', 32)} {pad('准确率', 7, 'right')} {pad('AUC', 6, 'right')} {pad('对数损失', 9, 'right')} "
          f"{pad('Brier', 6, 'right')} {pad('ECE', 6, 'right')}")


def print_metrics_row(name, m):
    """打印一行指标。"""
    print(f"  {pad(name, 32)} {m[0]:>7.3f} {m[1]:>6.3f} {m[2]:>9.3f} {m[3]:>6.3f} {m[4]:>6.3f}")


def fit_temperature(scores, i, j, y):
    """
    温度缩放：找一个数 T，让 σ((s_i − s_j)/T) 在验证集上的对数损失最小。

    【思路】排序不变（除以一个正数不改变大小关系），只调整"有多自信"。T > 1 让预测更保守，T < 1 更激进。
        只有一个参数，用一维网格搜索就够了。深度学习里常用它校准分类器的输出（Guo 等 2017）。
    【返回】最好的 T
    """
    grid = np.geomspace(0.2, 5.0, 200)
    d = scores[i] - scores[j]
    losses = [log_loss(sigmoid(d / t), y) for t in grid]
    return float(grid[int(np.argmin(losses))])


# =============================================================================
# 演示 1 与 2
# =============================================================================
def demo_metrics(rng):
    print_header("演示 1 与 2：几个指标衡量的是不同的东西")
    M = 40
    true_s = rng.normal(0, 1.2, size=M)
    true_s -= true_s.mean()
    i, j, y = simulate_bt(true_s, 4000, rng)
    vi, vj, vy = simulate_bt(true_s, 4000, rng)          # 验证集（用来做温度缩放）
    ti, tj, ty = simulate_bt(true_s, 40000, rng)         # 测试集
    s_hat, _ = fit_newton(i, j, y, M, l2=1e-4)
    over = 2.0 * s_hat
    under = 0.4 * s_hat
    T = fit_temperature(over, vi, vj, vy)
    print(f"{M} 个选项，4000 条训练比较；在 40000 条测试比较上评估。\n")
    print_metrics_header()
    for name, s in [("真实分数（上限）", true_s), ("最大似然估计", s_hat), ("过于自信（分数 × 2）", over),
                    ("过于保守（分数 × 0.4）", under), (f"过于自信 + 温度缩放（T={T:.2f}）", over / T)]:
        print_metrics_row(name, all_metrics(predict(s, ti, tj), ty))
    print(f"  {pad('瞎猜（p = 0.5）', 32)} {0.5:>7.3f} {0.5:>6.3f} {np.log(2):>9.3f} {0.25:>6.3f} {0.0:>6.3f}"
          "   （准确率按 0.5 计）")

    print("\n  可靠性表（过于自信的模型）：把预测概率分成 10 段，看每段的“平均预测”和“实际比例”：")
    _, table = ece(predict(over, ti, tj), ty)
    print(f"    {pad('预测概率区间', 14)} {pad('样本数', 7, 'right')} {pad('平均预测', 9, 'right')} {pad('实际比例', 9, 'right')}")
    for b, (cnt, conf, freq) in enumerate(table):
        if cnt:
            print(f"    {f'[{b / 10:.1f}, {(b + 1) / 10:.1f})':14s} {cnt:>7d} {conf:>9.3f} {freq:>9.3f}")
    print("""
观察：
  · 上限：即使知道真实分数，准确率也只有 74% 左右 —— 两个实力接近的选项，结果本来就接近抛硬币。
    “准确率不高”不一定是模型不好，先看看上限在哪里（教材：Bayes 最优的 AUC 也小于 1）。
  · 最大似然估计几乎达到了上限。
  · 过于自信（分数 × 2）和过于保守（× 0.4）的模型，**排序完全不变**，所以准确率和 AUC 一模一样；
    可是对数损失、Brier 分数、ECE 都变差了。可靠性表显示：过于自信的模型说“90% 以上”的时候，
    实际只有 85% 左右是对的；说“10% 以下”的时候，实际有 15% 左右。
  · 只看准确率 / AUC，发现不了概率的问题；只看对数损失，又分不清是“排序错了”还是“太自信了”。
    所以要同时报告排序指标和概率指标（教材第 2.7.3 节）。
  · 温度缩放：在验证集上找一个 T 把分数整体除掉，校准就恢复了。找到的 T 比 2 稍大：除了抵消 × 2，
    还顺便修正了最大似然估计本身轻微的过度自信（小样本下估计的分数会比真实的稍分散，代码 01 演示 4）。
  · 概率为什么重要？RLHF 里奖励模型的分数差直接决定了策略往哪边推、推多大力（第 1 讲第 3.3 节的 exp(r/β)）；
    过于自信的奖励模型会让策略过度相信它，加剧过度优化（第 8 讲）。""")


# =============================================================================
# 演示 3：LLM 偏好数据 —— 随机噪声 vs 系统性噪声
# =============================================================================
def cosine(a, b):
    """两个向量的余弦相似度。"""
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


def spearman(a, b):
    """斯皮尔曼秩相关：把两组数换成排名，再算皮尔逊相关。"""
    ra = np.argsort(np.argsort(a))
    rb = np.argsort(np.argsort(b))
    return float(np.corrcoef(ra, rb)[0, 1])


def demo_llm(reps=200):
    print_header("演示 3：LLM 偏好数据 —— 随机的标注噪声 vs 系统性的标注噪声")
    from bt_numpy import make_llm_dataset
    print(f"""教材第 2.9 节的设定：50 个 LLM 回答，每个有一个 8 维嵌入 x；真实效用 = w·x（w 是随机的“真实权重”）；
200 条比较，其中约 10% 的标签被翻转。两种翻转：
  · 随机噪声：每条比较以 10% 的概率被翻转（标注员偶尔点错）。
  · 系统性噪声：当输家在第 0 维（想象成“长度”）上更大时，以 20% 的概率把它判成赢家（标注员偏爱长回答）。
用线性 BT（特征差上的逻辑回归）拟合，重复 {reps} 次取平均：
""")
    print(f"  {pad('设定', 18)} {pad('实际翻转比例', 12, 'right')} {pad('权重的余弦相似度', 16, 'right')} "
          f"{pad('效用排名的相关', 14, 'right')} {pad('第0维权重的偏差', 16, 'right')} {pad('权重的长度比', 12, 'right')}")
    rng = np.random.default_rng(29)
    for name, flip, sysm in [("没有噪声", 0.0, False), ("随机噪声 10%", 0.1, False), ("系统性噪声", 0.1, True)]:
        rows = []
        for _ in range(reps):
            d = make_llm_dataset(rng, flip=flip, systematic=sysm)
            w_hat = fit_linear_bt(d["X"], d["i"], d["j"], d["y"], l2=1e-3)
            rows.append((d["flipped"].mean(), cosine(w_hat, d["w"]), spearman(d["X"] @ w_hat, d["util"]),
                         w_hat[0] - d["w"][0], np.linalg.norm(w_hat) / np.linalg.norm(d["w"])))
        r = np.array(rows).mean(axis=0)
        print(f"  {pad(name, 18)} {r[0]:>12.1%} {r[1]:>16.3f} {r[2]:>14.3f} {r[3]:>+16.3f} {r[4]:>12.2f}")
    print("""
观察：
  · 没有噪声时，200 条比较就能把 8 维权重学得很准（余弦相似度接近 1）。
  · 随机噪声：方向几乎不变（余弦相似度只降一点），但权重整体变短了（长度比明显小于 1）——
    模型把“标签有时是反的”解释成“差距没那么大”。排序基本不受影响，概率变得过于保守：又是校准问题。
  · 系统性噪声：翻转的比例差不多，可它们**有方向**：第 0 维（“长度”）的权重被系统性地抬高了
    （偏差为正）。模型学到了“标注员喜欢长回答”，而不是“长回答更好”—— 这正是第 1 讲众包标注员的长度偏差。
  · 两种噪声的教训不同：随机噪声主要是“数据少了一点”，多收集数据就能弥补；系统性噪声是**偏差**，
    数据再多也消除不了（第 1 讲代码 03 演示 5），必须在建模（第 14 讲的风格控制）或数据收集上处理。""")


def main():
    rng = np.random.default_rng(2007)
    demo_metrics(rng)
    demo_llm()


if __name__ == "__main__":
    main()
