"""
=============================================================================
第 10 讲 · 代码 2：人类偏好与奖励模型（Bradley–Terry）
=============================================================================

对应讲义：lectures/lecture10_post_training.md 第 3 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
SFT 需要人"示范"好的回答，又贵又难（写一个好回答比判断哪个回答好难得多）。
RLHF 换一种方式收集人类的意见：给标注者看两个回答，让他选更好的那个。
再用这些"比较"训练一个**奖励模型**：输入一个回答，输出一个分数，分数高 = 人类更可能喜欢。

在我们的玩具世界里，"人类"是一个模拟的标注者：他心里有一个金标准奖励 r*（post_utils.gold_reward：
喜欢正面的评论、讨厌超过 30 个词的啰嗦），按 Bradley–Terry 模型带噪声地做选择。

    演示 1：Bradley–Terry 模型 —— 分数之差 → 选择的概率（和国际象棋的 Elo 等级分是同一个模型）
    演示 2：偏好数据长什么样？标注噪声有多大？
    演示 3：训练奖励模型：偏好数据越多越好吗？它学到了什么？
    演示 4：奖励模型在"没见过的"回答上可靠吗？—— 比训练数据更长的回答

训练好的奖励模型保存到 checkpoints/rm.pt，04 号文件会用它。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_reward_model.py           # 完整运行（约 1~3 分钟，不含 SFT）
    python 02_reward_model.py --quick   # 冒烟测试

【关于结果】演示 1 不依赖训练，结果是确定的；演示 2~4 编写讲义时只运行了 --quick 冒烟测试，
    "预期会看到"描述的是应该出现的趋势，具体数字以你运行的结果为准。
"""

import os

os.environ.setdefault("OMP_NUM_THREADS", "1")
import math
import random
import sys

import numpy as np
import torch

import post_utils as P                 # 先导入它：它会把第 7、8、9 讲的文件夹加进搜索路径
from toy_reviews import POSITIVE, Vocab  # noqa: E402

torch.set_num_threads(1)


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


def demo_bradley_terry():
    print_header("演示 1：Bradley–Terry 模型")
    print("""
  每个回答有一个"真实的好坏" r。标注者比较 A 和 B 时，选 A 的概率是
      P(A ≻ B) = σ(r_A − r_B) = 1 / (1 + exp(−(r_A − r_B)))
  只有分数的**差**有意义：所有分数加上同一个常数，概率不变。""")
    print(f"\n    {'r_A − r_B':>10s}{'P(选 A)':>10s}")
    for d in [0, 0.5, 1, 2, 3, 5]:
        print(f"    {d:>10.1f}{1 / (1 + math.exp(-d)):>10.3f}")
    print("""
  国际象棋的 Elo 等级分是同一个模型（只是换了底数和刻度）：P(A 胜) = 1 / (1 + 10^(−(R_A − R_B)/400))，
    等级分高 400 分，胜率约 91%（10 : 1）。聊天机器人的排行榜（如 Chatbot Arena）也用这个模型，
    根据大量"两两对比"的投票给每个模型算出一个分数（第 13 讲）。

  训练奖励模型 r̂：让它给"被选中的回答"打的分尽量高于"没被选中的"：
      loss = −log σ( r̂(y_chosen) − r̂(y_rejected) )
  这就是 Bradley–Terry 模型的最大似然估计。""")


def demo_data(pairs):
    print_header(f"演示 2：偏好数据（{len(pairs)} 对，指令都是 \"write a positive review\"）")
    for p in pairs[:2]:
        print(f"\n    选中（r* = {p['r_chosen']:+.1f}）：{' '.join(p['chosen'])}")
        print(f"    没选中（r* = {p['r_rejected']:+.1f}）：{' '.join(p['rejected'])}")
    agree = np.mean([p["r_chosen"] > p["r_rejected"] for p in pairs])
    ties = np.mean([p["r_chosen"] == p["r_rejected"] for p in pairs])
    rs = [p["r_chosen"] for p in pairs] + [p["r_rejected"] for p in pairs]
    lens = [len(p["chosen"]) for p in pairs] + [len(p["rejected"]) for p in pairs]
    print(f"\n    标注者选中的确实是金标准奖励更高的那个：{agree:.1%}（两者一样好的占 {ties:.1%}）")
    print(f"    回答的金标准奖励：平均 {np.mean(rs):+.2f}，标准差 {np.std(rs):.2f}")
    print(f"    回答的长度：平均 {np.mean(lens):.1f} 个词，超过 30 个词的占 {np.mean(np.array(lens) > 30):.1%}")
    print("""
  预期会看到（具体数字以你运行的结果为准）：
    - 标注者并不总是选"更好"的那个：两个回答差不多好时，他几乎是随机选的。
      这个比例就是任何奖励模型的**准确率上限**（真实的人类偏好数据里，人和人之间的一致率通常只有 60%~75%）。
    - SFT 模型的回答大多不超过 30 个词：偏好数据里很少有"太长被扣分"的例子 —— 演示 4 会看到这件事的后果。""")


def demo_train(pairs, test_pairs, vocab):
    print_header("演示 3：训练奖励模型")
    gold_test = np.array([p["r_chosen"] > p["r_rejected"] for p in test_pairs])
    print(f"\n    {'偏好数据（对）':<14s}{'和测试标注一致':>14s}{'和金标准排序一致':>16s}")
    w = None
    for n in [30, 100, 300, 1000, len(pairs)]:
        if n > len(pairs):
            continue
        w = P.train_rm(pairs[:n], vocab)
        sc = P.rm_score(w, [p["chosen"] for p in test_pairs], vocab)
        sr = P.rm_score(w, [p["rejected"] for p in test_pairs], vocab)
        valid = np.array([p["r_chosen"] != p["r_rejected"] for p in test_pairs])
        print(f"    {n:<16d}{np.mean(sc > sr):>14.1%}{np.mean((sc > sr)[valid] == gold_test[valid]):>16.1%}")
    order = torch.argsort(w, descending=True)
    words = [vocab.itos[i] for i in order.tolist() if not vocab.itos[i].startswith("<")]
    print(f"\n    权重最大的词：{', '.join(words[:10])}")
    print(f"    权重最小的词：{', '.join(words[-10:][::-1])}")
    print(f"    \"not\" 的权重：{w[vocab.stoi['not']]:+.2f}；\"boring\" 的权重：{w[vocab.stoi['boring']]:+.2f}；"
          f"\".\" 的权重：{w[vocab.stoi['.']]:+.2f}")
    print("""
  预期会看到（具体数字以你运行的结果为准）：
    - 偏好数据越多，奖励模型越准，但"和测试标注一致"的比例不会超过演示 2 的上限（标注本身有噪声）。
    - 权重最大的是褒义词和 "loved"、"recommended" 这样的总结词，最小的是贬义词 —— 它学到了"正面"。
    - 但它是词袋模型：看不到词序。"not boring" 是夸奖，它却只看到 boring 的负权重，会冤枉这种回答。
    - 它也没有"长度"的概念：句号等词的权重反映的只是训练数据里长度和好坏的关系。""")
    return w


def demo_off_distribution(w, pairs, vocab):
    print_header("演示 4：奖励模型在比训练数据更长的回答上还可靠吗？")
    rng = random.Random(0)
    # 只用金标准奖励为正的回答（确实是"正面评论"的），这样拼接之后褒义词更多，差别只在"长不长"
    pool = [y for p in pairs for y in (p["chosen"], p["rejected"]) if P.gold_reward(y) > 0]
    normal = rng.sample(pool, min(300, len(pool)))
    long_ = [sum(rng.sample(pool, 3), []) for _ in range(300)]          # 把 3 条正面回答拼起来：又长又啰嗦
    for name, ys in [("正面的回答", normal), ("把 3 条正面回答拼起来", long_)]:
        rm = P.rm_score(w, ys, vocab)
        gold = np.array([P.gold_reward(y) for y in ys])
        print(f"\n    {name}（平均 {np.mean([len(y) for y in ys]):.0f} 个词）：奖励模型平均 {rm.mean():+.2f}，"
              f"金标准平均 {gold.mean():+.2f}，两者的相关系数 {np.corrcoef(rm, gold)[0, 1]:.2f}")
    print("""
  预期会看到（具体数字以你运行的结果为准）：
    - 在训练数据那样的回答上，奖励模型和金标准的相关性不错。
    - 回答一长，奖励模型给的分数反而更高（褒义词更多了），金标准却因为"啰嗦"扣了分 —— 两者的关系变差了。
    - 奖励模型只在它**见过的数据分布**上可靠。如果我们用它去优化模型，模型很可能被推到这些"没见过"的地方，
      去讨好奖励模型而不是真正的人类偏好 —— 这就是**奖励黑客**和**过度优化**（03、04 号文件，讲义第 6 节）。""")


def main(quick=False):
    vocab = Vocab()
    demo_bradley_terry()
    sft = P.load_or_sft(vocab, quick=quick)
    n = 200 if quick else 3000
    pairs = P.make_preference_pairs(sft, vocab, n, seed=0)
    test_pairs = P.make_preference_pairs(sft, vocab, 100 if quick else 1000, seed=100)
    demo_data(pairs)
    w = demo_train(pairs, test_pairs, vocab)
    if not quick:
        P.CKPT_DIR.mkdir(exist_ok=True)
        torch.save(w, P.CKPT_DIR / "rm.pt")
        print("\n  （奖励模型已保存到 checkpoints/rm.pt）")
    demo_off_distribution(w, pairs, vocab)


if __name__ == "__main__":
    main(quick="--quick" in sys.argv)
