"""
=============================================================================
第 9 讲 · 代码 1：DPO 的推导 —— 奖励可以用策略表示；DPO 到底在学什么
=============================================================================

对应讲义：lectures/lecture09_dpo.md 第 1~3 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
DPO 的推导只有三步：(1) KL 约束目标的最优解 π* ∝ π_ref · exp(r/β)；(2) 反解出 r = β log(π*/π_ref) + β log Z；
(3) 代进 BT 似然，log Z 消掉。玩具世界可以穷举所有回答，所以每一步都能**数值验证**。

    演示 1：奖励 ⇄ 策略：对任意奖励 r，β log(π*/π_ref) − r 在全部 19531 个回答上是同一个常数（−β log Z）；
            用 r 算的 BT 概率和用 π* 算的完全相同。
    演示 2：DPO 学的是哪个奖励？—— 数据越多，DPO 的策略越接近"数据背后真实的 BT 奖励"对应的 π*，
            而不是某个奖励模型对应的 π*。
    演示 3：DPO 的梯度：每一对的权重 σ(r̂_l − r̂_w) =（模型判错的程度）。去掉这个权重会怎样？

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

需要 numpy 和 PyTorch。依赖本目录的 dpo_tools.py（它会再导入第 8 讲的 toy_lm.py）。
"""

import numpy as np
import torch

from dpo_tools import (ALL, all_logprobs, encode, exact_stats, kl, make_reference, make_reward_model,
                       optimal_logprobs, pad, preference_pairs, print_header, sequence_logprob, train_preference,
                       true_bt_reward)


# =============================================================================
# 演示 1
# =============================================================================
def demo_reward_policy(ref_lp, r_lin):
    print_header("演示 1：奖励 ⇄ 策略 —— 只差一个常数")
    rng = np.random.default_rng(0)
    for beta in [1.0, 0.3]:
        opt = optimal_logprobs(ref_lp, r_lin, beta)
        implied = beta * (opt - ref_lp)                    # β log(π*/π_ref)
        diff = implied - r_lin                             # 应该处处等于 −β log Z
        logZ = float(np.logaddexp.reduce(ref_lp + r_lin / beta))
        a = rng.integers(len(ALL), size=5)
        b = rng.integers(len(ALL), size=5)
        p_r = 1 / (1 + np.exp(-(r_lin[a] - r_lin[b])))
        p_pi = 1 / (1 + np.exp(-(implied[a] - implied[b])))
        print(f"\n  β = {beta}：β·log(π*/π_ref) − r 在全部回答上的最小值 {diff.min():+.10f}，最大值 {diff.max():+.10f}；"
              f"−β·log Z = {-beta * logZ:+.10f}")
        print(f"  随机 5 对回答的 BT 概率：用奖励 r 算 {np.round(p_r, 6).tolist()}")
        print(f"                           用策略 π* 算 {np.round(p_pi, 6).tolist()}")
    print("""
观察：
  · β·log(π*/π_ref) 和奖励 r 只差一个常数 −β·log Z（所有回答都一样）。所以**奖励可以用策略表示出来**：
    给定 π_ref 和 β，“奖励”和“最优策略”是一一对应的（奖励只确定到一个常数，第 1 讲）。
  · BT 概率只依赖奖励的差值，常数消掉了：用 π* 算出的 BT 概率和用 r 算的完全一样。
  · 这就是 DPO 的出发点：与其先学 r、再用 RL 求 π*，不如直接把 π 当参数，让 β·log(π/π_ref) 去拟合偏好数据。
    Rafailov 等（2023）的论文标题：“你的语言模型其实是一个奖励模型”。""")


# =============================================================================
# 演示 2
# =============================================================================
def demo_population_target(ref, ref_lp, r_lin, beta=1.0, steps=2500):
    print_header("演示 2：DPO 学的是哪个奖励？")
    r_true = true_bt_reward()
    target_true = optimal_logprobs(ref_lp, r_true, beta)
    target_lin = optimal_logprobs(ref_lp, r_lin, beta)
    s_true = exact_stats(target_true, ref_lp)
    s_lin = exact_stats(target_lin, ref_lp)
    print(f"β = {beta}。两个候选的“目标”：")
    print(f"  · π*(真实 BT 奖励)：数据背后的奖励 = 2 × 标注员效用（含标注偏差）。KL {s_true['kl']:.3f}，真实质量 {s_true['gold']:+.3f}")
    print(f"  · π*(线性奖励模型)：第 8 讲的 RLHF 用的。KL {s_lin['kl']:.3f}，真实质量 {s_lin['gold']:+.3f}")
    print(f"\n用不同数量的偏好对（都从 SFT 策略采样、众包标注）训练 DPO，梯度步数固定为约 {steps} 步：\n")
    print(f"  {pad('偏好对数', 9)} {pad('遍数', 5, 'right')} {pad('KL(π_DPO ‖ π*真实)', 20, 'right')} "
          f"{pad('KL(π_DPO ‖ π*线性)', 20, 'right')} {pad('KL(π ‖ π_ref)', 14, 'right')} {pad('真实质量', 9, 'right')}")
    for n in [500, 2000, 8000, 32000]:
        gen = torch.Generator().manual_seed(7)
        chosen, rejected = preference_pairs(ref, n, gen, np.random.default_rng(7))
        epochs = max(1, round(steps * 64 / n))
        pol = train_preference("dpo", ref, chosen, rejected, beta=beta, epochs=epochs)
        lp = all_logprobs(pol)
        st = exact_stats(lp, ref_lp)
        print(f"  {n:>9d} {epochs:>5d} {kl(lp, target_true):>20.3f} {kl(lp, target_lin):>20.3f} {st['kl']:>14.3f} "
              f"{st['gold']:>+9.3f}")
    print("""
观察：
  · 数据越多，DPO 的策略越接近 π*(真实 BT 奖励)，而离 π*(线性奖励模型) 一直很远。
  · 原因：DPO 的“隐式奖励” β·log(π/π_ref) 由策略网络表示，在这个玩具世界里它可以表示“各种词的个数”的
    任意函数 —— 比第 8 讲的线性奖励模型灵活得多，没有“学不出边际递减”的问题。
    所以 DPO 不受线性奖励模型的设定错误影响，真实质量比第 8 讲线性 RLHF 的最好值（+1.34）高。
  · 但它学到的是**数据背后的**奖励，包括标注员的偏差（偏爱华丽和长回答）。DPO 绕开的是奖励模型，
    不是偏好数据本身的问题。
  · 小数据（500 对）时离两个目标都远：隐式奖励只在数据覆盖的地方被约束（代码 02 会看到它在别处做了什么）。""")


# =============================================================================
# 演示 3
# =============================================================================
def demo_gradient_weights(ref, ref_lp, chosen, rejected, beta=1.0):
    print_header("演示 3：DPO 的梯度权重 —— 模型判错得越厉害，更新越大")
    print("""DPO 损失 −log σ(β(h_w − h_l)) 的梯度（h = log π − log π_ref）：
    ∇ = −β · σ(β(h_l − h_w)) · [∇log π(y_w) − ∇log π(y_l)]
  方括号：提高被选中回答的概率、压低被拒绝回答的概率；
  权重 σ(β(h_l − h_w)) = 当前的隐式奖励认为“被拒绝的更好”的概率 —— 判错得越厉害，权重越大；已经判对的对，权重趋于 0。
""")
    enc_c, enc_r = encode(chosen), encode(rejected)
    with torch.no_grad():
        ref_c, ref_r = sequence_logprob(ref, enc_c), sequence_logprob(ref, enc_r)

    def weights(pol):
        with torch.no_grad():
            h_w = sequence_logprob(pol, enc_c) - ref_c
            h_l = sequence_logprob(pol, enc_r) - ref_r
            return torch.sigmoid(beta * (h_l - h_w)).numpy()

    w0 = weights(ref)
    print(f"  训练前（策略 = π_ref）：所有对的权重都是 {w0.mean():.3f}")
    print(f"\n  {pad('训练遍数', 9)} {pad('DPO：平均权重', 14, 'right')} {pad('权重 > 0.5 的比例', 17, 'right')} "
          f"{pad('KL', 7, 'right')} {pad('真实质量', 9, 'right')}   │ {pad('不加权：KL', 11, 'right')} {pad('真实质量', 9, 'right')}")
    rows = {}

    def cb_dpo(ep, pol):
        if ep in (1, 5, 20, 40):
            w = weights(pol)
            st = exact_stats(all_logprobs(pol), ref_lp)
            rows[ep] = [w.mean(), float(np.mean(w > 0.5)), st["kl"], st["gold"]]

    def cb_naive(ep, pol):
        if ep in (1, 5, 20, 40):
            st = exact_stats(all_logprobs(pol), ref_lp)
            rows[ep] += [st["kl"], st["gold"]]

    train_preference("dpo", ref, chosen, rejected, beta=beta, epochs=40, callback=cb_dpo)
    train_preference("naive", ref, chosen, rejected, beta=beta, epochs=40, callback=cb_naive)
    for ep, v in rows.items():
        print(f"  {ep:>9d} {v[0]:>14.3f} {v[1]:>17.3f} {v[2]:>7.3f} {v[3]:>+9.3f}   │ {v[4]:>11.2f} {v[5]:>+9.3f}")
    print("""
（“不加权”：直接最大化 mean(h_w − h_l)，即去掉 σ 权重的 DPO。）

观察：
  · 训练前所有对的权重都是 0.5；训练后平均权重下降 —— 大部分对已经“判对了”，梯度主要来自仍然判错的对
    （其中不少是标注噪声：标注员选了真实质量更低的那个）。权重 > 0.5 的比例约等于“隐式奖励判错的比例”。
  · 不加权的版本没有“判对了就停”的机制：它的目标没有上界（把被拒绝回答的概率压向 0，目标就能无限变大），
    所以没有一个确定的“终点”，结果取决于训练多久；它离 π_ref 的 KL 比 DPO 大得多，而且和 β 无关（β 不出现在梯度方向里）。
  · 在这个小玩具里，不加权版本的真实质量并没有崩溃 —— 被压下去的主要是“被拒绝”的回答，它们平均确实更差。
    在真实的语言模型里，DPO 论文报告这种去掉权重的朴素做法会让模型退化。
  · σ 权重来自 BT 似然：DPO 是一个**有上界**的概率模型的最大似然，它有明确的目标 π*，β 控制这个目标离 π_ref 多远。""")


def main():
    ref = make_reference()
    ref_lp = all_logprobs(ref)
    _, r_lin, (chosen, rejected) = make_reward_model(ref)
    demo_reward_policy(ref_lp, r_lin)
    demo_population_target(ref, ref_lp, r_lin)
    demo_gradient_weights(ref, ref_lp, chosen, rejected)


if __name__ == "__main__":
    main()
