"""
=============================================================================
第 8 讲 · 代码 2：奖励模型 —— 在参考策略的回答上很准，在"最高分"的地方不一定
=============================================================================

对应讲义：lectures/lecture08_rlhf.md 第 2、5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
RLHF 的第二步：从 SFT 策略采回答，让标注员两两比较，训练奖励模型。这个文件看奖励模型学到了什么，
以及一个对后面至关重要的事实：**奖励模型在"平常的回答"上和真实质量很一致，在"它打分最高的回答"上
可能完全不一致** —— 而强化学习恰恰会把策略推到那里去。

    演示 1：线性奖励模型（6 个参数）学到的权重；在新的比较上的准确率。
    演示 2：奖励模型和真实质量在哪里一致：SFT 策略的回答 vs 奖励模型打分最高的回答。
    演示 3：两种误差来源：模型设定错误（线性学不出"边际递减"）和标注员的偏差。
            四种奖励模型：线性 / 灵活 × 众包标注 / 专家标注。
    演示 4：多收集数据有没有用？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_reward_model.py        # 约 15 秒（CPU）

需要 numpy 和 PyTorch。依赖本目录的 toy_lm.py。
"""

import math

import numpy as np
import torch

from toy_lm import (ALL, ALL_GOLD, FEAT_NAMES, LABEL_SCALE, all_logprobs, gold_reward, labeler_utility,
                    make_reference, make_reward_model, pad, preference_pairs, print_header, show, INDEX)


def spearman(a, b):
    """
    斯皮尔曼秩相关（并列的值取平均名次）。

    【思路】把两组数各自换成名次，再算皮尔逊相关。并列时，几个并列的数取它们名次的平均值。
    """
    def ranks(x):
        x = np.asarray(x, dtype=float)
        order = np.argsort(x, kind="stable")
        r = np.empty(len(x))
        r[order] = np.arange(len(x))
        _, inv, cnt = np.unique(x, return_inverse=True, return_counts=True)
        sums = np.bincount(inv, weights=r)
        return sums[inv] / cnt[inv]
    return float(np.corrcoef(ranks(a), ranks(b))[0, 1])


def heldout_agreement(reward, ref, n=3000, seed=99):
    """
    在 SFT 策略的新回答对上评估奖励模型。

    【返回】(和众包标注一致的比例, 和真实质量方向一致的比例, 众包标注准确率的天花板)
        真实质量相同（例如只是词序不同）的对不计入"和真实质量方向一致"。
    """
    gen = torch.Generator().manual_seed(seed)
    rng = np.random.default_rng(seed)
    chosen, rejected = preference_pairs(ref, n, gen, rng)
    rc = reward[[INDEX[s] for s in chosen]]
    rr = reward[[INDEX[s] for s in rejected]]
    acc = float(np.mean(rc > rr))
    gc = np.array([gold_reward(s) for s in chosen])
    gr = np.array([gold_reward(s) for s in rejected])
    differ = gc != gr
    agree_gold = float(np.mean((rc > rr)[differ] == (gc > gr)[differ]))
    du = np.array([labeler_utility(a) - labeler_utility(b) for a, b in zip(chosen, rejected)])
    p = 1 / (1 + np.exp(-LABEL_SCALE * du))
    ceiling = float(np.mean(np.maximum(p, 1 - p)))
    return acc, agree_gold, ceiling


# =============================================================================
# 演示 1
# =============================================================================
def demo_linear_rm(ref):
    print_header("演示 1：线性奖励模型学到了什么？")
    th, r, (ch, rj) = make_reward_model(ref, n_pairs=2000, kind="linear", labeler="crowd")
    print(f"从 SFT 策略采 {len(ch)} 对回答，众包标注员（偏爱华丽和长回答）两两比较，用线性 BT 拟合 r(y) = θᵀφ(y)：\n")
    print(f"  {pad('特征', 12)} {pad('学到的权重', 11, 'right')}   真实质量里的作用")
    notes = ["前两个 +1.0，之后每个 −0.6", "第一个 +0.7，之后每个 −0.4", "每个 −0.15", "每个 −0.1（标注员 +0.5）",
             "每个 −1.2", "−1.0"]
    for name, w, note in zip(FEAT_NAMES + ["空回答"], th, notes):
        print(f"  {pad(name, 12)} {w:>+11.3f}   {note}")
    acc, agree, ceil = heldout_agreement(r, ref)
    print(f"""
  在 3000 对新的回答上：和众包标注一致 {acc:.3f}（天花板 {ceil:.3f}）；和真实质量的方向一致 {agree:.3f}。

观察：
  · 每个要点 +{th[0]:.2f}：线性模型只能给“每多一个要点”一个固定的分数。SFT 的回答里要点大多是 0~2 个，
    在这个范围里“多一个要点更好”是对的，所以学到的是正数 —— 它不知道第三个要点开始就有害了。
  · 每个华丽 +{th[3]:.2f}：真实质量里华丽略减分，但标注员喜欢它（+0.5），再加上长度偏差，奖励模型学到了明显的正分。
  · 和标注的一致率离天花板还有一段距离（线性模型表示不了边际递减），和真实质量的方向也大多一致。
    这是一个“看起来不错”的奖励模型：**单看准确率，看不出它会把策略带到哪里去。**""")
    return r


# =============================================================================
# 演示 2
# =============================================================================
def demo_where_agree(ref, r):
    print_header("演示 2：奖励模型和真实质量在哪里一致？")
    ref_lp = all_logprobs(ref)
    gen = torch.Generator().manual_seed(5)
    from toy_lm import sample
    samp = sample(ref, 3000, gen)
    idx = np.array([INDEX[s] for s in samp])
    top = np.argsort(-r, kind="stable")[:300]
    long6 = np.array([k for k, s in enumerate(ALL) if len(s) == 6])
    print("用斯皮尔曼秩相关衡量“奖励模型的排序”和“真实质量的排序”有多一致：\n")
    print(f"  {pad('回答的范围', 40)} {pad('秩相关', 8, 'right')} {pad('平均真实质量', 13, 'right')}")
    for name, sel in [("SFT 策略采样的 3000 个回答", idx), ("长度为 6 的全部回答", long6),
                      ("奖励模型打分最高的 300 个回答", top)]:
        print(f"  {pad(name, 40)} {spearman(r[sel], ALL_GOLD[sel]):>8.3f} {ALL_GOLD[sel].mean():>+13.3f}")
    best = int(np.argmax(r))
    print(f"\n  奖励模型打分最高的回答：{show(ALL[best])}（奖励 {r[best]:+.2f}，真实质量 {ALL_GOLD[best]:+.2f}）")
    print(f"  它在 SFT 策略下的概率：{np.exp(ref_lp[best]):.1e}（SFT 几乎不会写出这样的回答，偏好数据里也就几乎没有）")
    print("""
观察：
  · 在 SFT 策略平常写的回答上，奖励模型的排序和真实质量相当一致；在所有长度为 6 的回答里也是 ——
    问题不在于“长”本身。
  · 可是在“奖励模型打分最高”的 300 个回答里，奖励模型的排序和真实质量几乎无关（秩相关约 0）：
    这些回答平均来说还不错，但奖励模型分不出其中谁好谁坏；它最喜欢的那一个（6 个要点）真实质量是负的。
    这些回答在训练数据里几乎没出现过 —— 奖励模型在那里是在**外推**（第 2 讲第 5.4 节）。
  · 这就是 Goodhart 定律：“一个指标一旦成为目标，就不再是好的指标。”奖励模型作为“评估平常回答的指标”很好，
    可是 RL 会专门去找它打分最高的地方 —— 恰好是它最不可靠的地方。""")


# =============================================================================
# 演示 3
# =============================================================================
def demo_four_rms(ref):
    print_header("演示 3：两种误差来源 —— 模型设定错误 vs 标注员的偏差")
    print("四种奖励模型，都用 2000 对数据训练：")
    print("  · 线性：计数特征（6 个参数），学不出边际递减；灵活：个数的独热编码（16 个参数），可以学出")
    print("  · 众包：标注员偏爱华丽和长回答；专家：标注员的效用就是真实质量\n")
    print(f"  {pad('奖励模型', 14)} {pad('和真实质量方向一致', 19, 'right')} {pad('它最喜欢的回答', 34, 'right')} "
          f"{pad('那个回答的真实质量', 19, 'right')}")
    out = {}
    for kind, kname in [("linear", "线性"), ("rich", "灵活")]:
        for lab, lname in [("crowd", "众包"), ("expert", "专家")]:
            th, r, _ = make_reward_model(ref, kind=kind, labeler=lab)
            _, agree, _ = heldout_agreement(r, ref)
            best = int(np.argmax(r))
            out[(kind, lab)] = r
            print(f"  {pad(kname + ' + ' + lname, 14)} {agree:>19.3f} {pad(show(ALL[best]), 34, 'right')} {ALL_GOLD[best]:>+19.2f}")
    print("""
观察：
  · 四个奖励模型在 SFT 的回答上都相当准（和真实质量的方向一致 78%~95%）；它们最大的差别在“最喜欢的回答”上才暴露。
  · 线性 + 专家：标注没有偏差，但模型设定错误（不能表示“要点太多有害”），最喜欢的回答是 6 个要点（真实质量 −0.4）。
  · 灵活 + 众包：模型能表示边际递减，但学到了标注员的偏差，最喜欢的回答在最好的回答后面填满了华丽（+2.4，最好是 +2.7）。
  · 灵活 + 专家：两种误差都没有，最喜欢的回答就是真实质量最高的回答。
  · 真实世界里两种误差都存在，而且分不开：奖励模型是有限容量的神经网络、只见过有限的回答（设定错误 / 外推），
    标注员有长度、格式、讨好等偏差（第 1、14 讲）。""")
    return out


# =============================================================================
# 演示 4
# =============================================================================
def demo_data_size(ref):
    print_header("演示 4：多收集数据有没有用？")
    print(f"  {pad('奖励模型', 14)} {pad('数据量', 7, 'right')} {pad('华丽的权重', 11, 'right')} "
          f"{pad('和真实质量方向一致', 19, 'right')} {pad('最喜欢的回答的真实质量', 23, 'right')}")
    for kind, kname in [("linear", "线性 + 众包"), ("rich", "灵活 + 众包")]:
        for n in [200, 2000, 20000]:
            th, r, _ = make_reward_model(ref, n_pairs=n, kind=kind, labeler="crowd", seed=2)
            _, agree, _ = heldout_agreement(r, ref)
            fancy_w = th[3] if kind == "linear" else th[9]       # 灵活模型：华丽出现 1 次的指示变量
            best = int(np.argmax(r))
            print(f"  {pad(kname, 14)} {n:>7d} {fancy_w:>+11.3f} {agree:>19.3f} {ALL_GOLD[best]:>+23.2f}")
    print("""
（灵活模型的“华丽的权重”是“华丽出现 1 次”这个指示变量的权重。）

观察：
  · 数据从 200 对增加到 20000 对，权重越来越稳定，在 SFT 回答上的准确率也提高了一点。
  · “华丽”的权重随数据增加变小了一些（小样本的最大似然会高估权重的大小），但它收敛到的是一个**有偏的**正数，
    而不是真实质量里的 −0.1；“最喜欢的回答”从头到尾都没变。
    数据多了只能减少方差，减少不了偏差（第 5 讲第 9 节：系统性噪声“数据再多也消除不了”）。
  · 所以奖励过度优化不是“数据不够”的问题，至少不全是。要么修正偏差（第 14 讲的风格控制），
    要么限制策略离 SFT 太远（KL 约束，代码 03、04）。""")


def main():
    ref = make_reference()
    r = demo_linear_rm(ref)
    demo_where_agree(ref, r)
    demo_four_rms(ref)
    demo_data_size(ref)


if __name__ == "__main__":
    main()
