"""
=============================================================================
第 7 讲 · 代码 2：奖励模型（reward model）——从成对比较里学一个分数
=============================================================================

对应讲义：lectures/lecture07_instruction_tuning_alignment.md 第 3、5 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    演示 1："评论打分助手"小世界。
            一个 SFT 策略（在 80% 正确的示范上微调过的 base model）回答"给这条评论打分"。
            回答只有 36 种（开头 × 评分 × 废话句数），所以每一种的概率都能精确算出来。
            我们知道每种回答的真实质量 g，也知道模拟标注员心里的分数 h = g + 对长度和热情开头的偏爱。
            先看：标注员彼此之间能有多一致？——这是任何奖励模型准确率的天花板。
    演示 2：Bradley–Terry 奖励模型的准确率随比较数据量怎样变化？
            从 base model 初始化（继承预训练知识） vs 从随机初始化（没有预训练）。
    演示 3：奖励模型到底学到了什么？把它对"多一句废话""加一个 wow"打的分，和标注员的 h、真实的 g 对比。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_reward_model.py      # 第一次约 95 秒（训练 SFT 策略和 7 个奖励模型并缓存）；之后约 20 秒

需要 PyTorch、numpy。训练好的策略和奖励模型缓存在本目录 checkpoints/，代码 03、04 会读取它们。
"""

import random
import time

import numpy as np

from align_tools import (FILLERS, GOLD_FILLER, GOLD_OPENER, LAM, MU, OPENERS, RESPONSES, RM_SPECS, assistant_world,
                         comparison_accuracy, gold_utility, human_labeler, human_reward_models, labeler_utility,
                         load_base, policy_stats, response_text, review_prompt, reward_table, sample_comparisons)
from console import pad, print_header

# =============================================================================
# 演示 1：小世界与标注员
# =============================================================================
def demo_world(aw):
    """演示 1：打印几种回答的 g、h、π_ref 概率；π_ref 的统计；两个模拟标注员的一致率与准确率天花板。"""
    print_header("演示 1：评论打分助手——36 种回答、真实质量 g、标注员分数 h")
    review, truth = aw["rl_reviews"][0]
    print(f"提示：{review_prompt(review)[1:]!r}（正确评分：{truth}）")
    print(pad("回答", 34) + pad("真实质量 g", 12, ">") + pad("标注员分数 h", 14, ">") + pad("π_ref 概率", 12, ">"))
    i = 0
    for resp in [(truth, 0, 0), (truth, 1, 0), (truth, 3, 0), (truth, 1, 1), (truth, 3, 1), ("bad" if truth != "bad" else "good", 3, 1)]:
        j = RESPONSES.index(resp)
        print(pad(repr(response_text(resp)), 34) + pad(f"{gold_utility(resp, truth):+.2f}", 12, ">")
              + pad(f"{labeler_utility(resp, truth):+.2f}", 14, ">") + pad(f"{aw['pi_rl'][i, j]:.3f}", 12, ">"))
    print(f"\n设定：g = 评分对 +2 / 错 −2；0–3 句废话 {GOLD_FILLER}；热情开头（{OPENERS[1].strip()!r} 或 {OPENERS[2].strip()!r}）{GOLD_OPENER}。")
    print(f"      h = g + {LAM}×废话句数 + {MU}×（有热情开头）——标注员偏爱更长、更热情的回答（偏差是故意设定的）。")
    print(f"      最好的回答（g 最大）：评分正确 + 1 句废话 + 无开头；标注员最喜欢的（h 最大）：评分正确 + 3 句废话 + 热情开头。")

    st = policy_stats(aw["pi_rl"], aw["rl_reviews"])
    print(f"\nSFT 策略 π_ref（示范里 80% 评分正确，废话 0/1/2/3 句的比例 0.4/0.3/0.2/0.1，开头 无/wow/yay = 0.8/0.15/0.05）")
    print(f"在 200 条新评论上：36 种回答覆盖了它 {aw['mass_rl'].mean():.3f} 的概率质量；评分正确 {st['correct']:.3f}，"
          f"平均废话 {st['k']:.2f} 句，wow {st['wow']:.3f}，yay {st['yay']:.3f}，期望真实质量 E[g] = {st['gold']:.3f}")

    # 两个独立的标注员看同一批回答对：一致率是多少？
    pairs = sample_comparisons(2000, aw["rl_reviews"], aw["pi_rl"], random.Random(9), lambda *a: 0.5)
    rng1, rng2 = random.Random(21), random.Random(22)
    agree, ceiling = [], []
    for i, a, b, _ in pairs:
        r, t = aw["rl_reviews"][i]
        l1 = human_labeler(r, t, RESPONSES[a], RESPONSES[b], rng1)
        l2 = human_labeler(r, t, RESPONSES[a], RESPONSES[b], rng2)
        agree.append(l1 == l2)
        p = 1 / (1 + np.exp(-(labeler_utility(RESPONSES[a], t) - labeler_utility(RESPONSES[b], t))))
        ceiling.append(max(p, 1 - p))
    print(f"\n两个独立的模拟标注员在同样 2000 对回答上的一致率：{np.mean(agree):.3f}")
    print(f"任何奖励模型预测单个标注员的准确率上限（每对都猜 h 更高的那个）：{np.mean(ceiling):.3f}")
    print("""
观察 Observations：
  · 回答空间小到可以列举：SFT 策略分给这 36 种回答的概率质量接近 1，所以后面所有"期望"都能精确算，不用采样。
  · π_ref 继承了示范员的一切：评分正确率和示范的 80% 相近，废话和热情开头的习惯也学了过来。
    SFT 只能**模仿**示范员；要超过示范员，需要一个"什么更好"的信号——比较。
  · 标注是有噪声的：两个标注员（偏好相同、只是各自随机）只有约四分之三的时候意见一致。
    InstructGPT 报告的标注员间一致率约 73%；这里的数字和它接近只是巧合，但意义相同：
    奖励模型的准确率不可能超过上面那个天花板，"72% 的准确率"可能已经接近完美。""")
    return pairs


# =============================================================================
# 演示 2：准确率 vs 数据量，预训练初始化 vs 随机初始化
# =============================================================================
def demo_accuracy(aw, tok, base):
    """演示 2：7 个奖励模型在 2000 对新比较上的三种准确率；返回每个模型的奖励表（演示 3 用）。"""
    print_header("演示 2：Bradley–Terry 奖励模型——准确率随比较数据量怎样变化？")
    test = sample_comparisons(2000, aw["rl_reviews"], aw["pi_rl"], random.Random(9), human_labeler)
    t0 = time.time()
    rms, _ = human_reward_models(aw, tok, base)
    print(f"（{len(RM_SPECS)} 个奖励模型就绪，用时 {time.time() - t0:.1f} 秒；每个都训练 300 步、每步 32 对，学习率 1e-3）")
    print("测试：200 条新评论（训练时没见过）上的 2000 对回答，由同一类模拟标注员独立标注。\n")
    print(pad("初始化", 22) + pad("比较数 N", 10, ">") + pad("训练种子", 10, ">") + pad("预测标注", 10, ">")
          + pad("符合真实质量", 14, ">") + pad("评分对错", 10, ">"))
    tables = {}
    for (init, n, seed), rm in rms.items():
        S = reward_table(rm, tok, aw["rl_reviews"])
        tables[(init, n, seed)] = S
        acc = comparison_accuracy(S, aw["rl_reviews"], test)
        label = "base model（预训练）" if init == "base" else "随机初始化"
        print(pad(label, 22) + pad(n, 10, ">") + pad(seed, 10, ">") + pad(f"{acc['label']:.3f}", 10, ">")
              + pad(f"{acc['gold']:.3f}", 14, ">") + pad(f"{acc['correct']:.3f}", 10, ">"))
    print("（预测标注：和标注员的结论一致；符合真实质量：在 g 不同的对上选中 g 更高者；评分对错：在一对一错的对上选中评分正确者）")
    print("""
观察 Observations：
  · 从 base model 初始化的奖励模型："评分对错"从 100、300 条比较时的 0.60–0.63 升到 1000 条以上的 0.73–0.88；
    "预测标注"最高 0.77，接近但没到演示 1 的天花板 0.823。
  · 1000 条和 3000 条之间看不出差别：同样 3000 条数据、只换抽 batch 的种子，"评分对错"在 0.727 和 0.858 之间晃动，
    比 1000 → 3000 的差别还大。在这个预算下（每个模型都只训练 300 步、看 9600 对），瓶颈已经是训练本身，
    不是数据量。种子 0 的 N = 3000 模型是三个里最差的——下一个脚本会看到这一点的后果。
  · 随机初始化的奖励模型，同样 3000 条数据、同样的步数，"评分对错"只有 0.578——它学会了"更长、更热情更好"
    这类表面特征（所以"预测标注"也有 0.672），却几乎学不会"评分和评论内容是否一致"。
    判断评分对错需要读懂评论，而这个能力来自预训练：base model 在预训练时已经学会了给评论打分。
    这就是为什么 InstructGPT、Stiennon et al. 都从语言模型初始化奖励模型，Askell et al. 还专门做了
    "偏好模型预训练"（PMP）来提高样本效率。
  · "符合真实质量"一列（0.54–0.70）总是低于"预测标注"：奖励模型被训练去预测标注员，而标注员本身有偏差（演示 3）。""")
    return tables


# =============================================================================
# 演示 3：奖励模型学到了什么
# =============================================================================
def demo_what_rm_learned(aw, tables):
    """
    演示 3：奖励模型给每种"改动"打的平均分差，和 Δg、Δh 对比。

    【Python 小知识】avg_diff、wrong 是定义在函数里面的函数（嵌套函数），它们能直接用外层的 reviews 变量；
                    changes 里的 lambda t: ... 是一行写成的小函数，参数 t 是正确评分。
    """
    print_header("演示 3：奖励模型给每个特征打了多少分？和标注员的 h、真实的 g 对比")
    reviews = aw["rl_reviews"]

    def avg_diff(S, change):
        """在 200 条评论上平均 r(改动后) − r(改动前)；change(truth) 返回 (改动前的回答, 改动后的回答)。"""
        out = []
        for i, (_, t) in enumerate(reviews):
            before, after = change(t)
            out.append(S[i, RESPONSES.index(after)] - S[i, RESPONSES.index(before)])
        return float(np.mean(out))

    def wrong(t):
        """一个错的评分（正确评分不是 bad 就用 bad，否则用 good）。"""
        return "bad" if t != "bad" else "good"

    changes = [
        ("评分 错 → 对", lambda t: ((wrong(t), 0, 0), (t, 0, 0))),
        ("废话 0 → 1 句", lambda t: ((t, 0, 0), (t, 1, 0))),
        ("废话 1 → 2 句", lambda t: ((t, 1, 0), (t, 2, 0))),
        ("废话 2 → 3 句", lambda t: ((t, 2, 0), (t, 3, 0))),
        ("加上 wow 开头", lambda t: ((t, 1, 0), (t, 1, 1))),
        ("加上 yay 开头", lambda t: ((t, 1, 0), (t, 1, 2))),
    ]
    G = {name: avg_diff(np.array([[gold_utility(y, t) for y in RESPONSES] for _, t in reviews]), ch) for name, ch in changes}
    H = {name: avg_diff(np.array([[labeler_utility(y, t) for y in RESPONSES] for _, t in reviews]), ch) for name, ch in changes}
    cols = [("base", 1000, 0), ("base", 3000, 0), ("base", 3000, 1), ("scratch", 3000, 0)]
    print(pad("改动", 16) + pad("真实 Δg", 10, ">") + pad("标注员 Δh", 11, ">")
          + "".join(pad(f"{'预训练' if i == 'base' else '随机'} N={n} 种子{sd}", 21, ">") for i, n, sd in cols))
    for name, _ in changes:
        print(pad(name, 16) + pad(f"{G[name]:+.2f}", 10, ">") + pad(f"{H[name]:+.2f}", 11, ">")
              + "".join(pad(f"{avg_diff(tables[c], dict(changes)[name]):+.2f}", 21, ">") for c in cols))
    print(f"（Bradley–Terry 的分数只有差值有意义；如果奖励模型完美地学会了标注员，它的 Δ 应该等于 Δh。废话依次是 {FILLERS}）")
    print("""
观察 Observations：
  · 废话：每个预训练奖励模型都给"0 → 1 句"打了 +0.98 到 +1.34 分，和标注员的 Δh = +1.30 一致，
    是真实价值（+0.50）的两倍多；"1 → 2 句"也是正分（+0.20 到 +0.30），而真实质量在这里已经开始下降（−0.25）。
    奖励模型忠实地学会了标注员的偏差——它本来就是被训练去预测标注员的。
  · 热情开头学得很弱：wow 的分差在 −0.04 和 +0.25 之间，yay 在 −0.05 和 +0.12 之间（Δh 都是 +0.30）。
    热情开头在示范里只占 20%（yay 只有 5%），比较数据里很少出现，奖励模型对它们几乎没有证据，分数接近"随便猜"。
  · "评分 错 → 对"：标注员的分差是 +4，奖励模型只给出 +1.00 到 +2.09——它对评分对错的把握远不如标注员；
    随机初始化的模型只有 +0.41。种子 0 的 N = 3000 模型在这一行最弱（+1.00），和演示 2 一致。
  · 合起来：奖励模型 ≈ "标注员的偏差学全了、标注员的正确判断只学了一半"。下一个脚本会看到，
    优化器会沿着奖励模型最确定的方向（写长）一路走下去，而在它不确定的地方（评分对错、热情开头）容易被带偏。""")


def main():
    """依次运行本文件的各个演示。"""
    t0 = time.time()
    world, tok, base = load_base()
    aw = assistant_world(tok, base)
    demo_world(aw)
    tables = demo_accuracy(aw, tok, base)
    demo_what_rm_learned(aw, tables)
    print(f"\n总用时 {time.time() - t0:.1f} 秒（计时每次运行略有不同）")


if __name__ == "__main__":
    main()
