"""
=============================================================================
第 9 讲 · 代码 4：直接偏好优化（DPO）——跳过奖励模型和 RL
=============================================================================

对应讲义：lectures/lecture09_rlhf_dpo.md 第 5、6 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
RLHF 要先训练奖励模型，再用 RL（PPO）优化策略——两个阶段、一堆超参数、训练不稳定。
DPO（Rafailov et al., 2023）的关键观察（讲义 5.1 节）：

    KL 正则目标的最优解是 π*(y) = π_ref(y) exp(r(y)/β) / Z
    ⟹ 反过来，奖励可以用策略表示：r(y) = β log(π*(y) / π_ref(y)) + β log Z

把它代入 Bradley–Terry 模型，Z 在两个回答相减时**抵消了**：

    P(y_w ≻ y_l) = σ( β log(π(y_w)/π_ref(y_w)) - β log(π(y_l)/π_ref(y_l)) )

于是可以直接对策略 π 做极大似然——**不需要奖励模型，也不需要 RL**：

    L_DPO(π) = -mean[ log σ( β log(π(y_w)/π_ref(y_w)) - β log(π(y_l)/π_ref(y_l)) ) ]

本文件：
    演示 1：验证"隐式奖励"r(y) = β log(π*(y)/π_ref(y)) 与真正的奖励只差一个常数
    演示 2：训练 DPO，验证它与"训练表格型奖励模型 + 求 KL 正则最优解"的结果完全相同
    演示 3：DPO 在不同 β 下的效果
    演示 4：DPO 的一个已知弱点——确定性偏好下，β 挡不住过拟合

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture09
    python 04_dpo.py      （约 5 秒）
"""

import numpy as np

from toy_llm import (N_RESP, LENGTH, FEATURES, true_reward, reference_policy, sample_preferences,
                     fit_linear_reward_model, optimal_kl_policy, softmax, sigmoid, kl)

np.set_printoptions(precision=3, suppress=True)


# =============================================================================
# 第 1 部分：DPO 损失与梯度
# =============================================================================
def dpo_loss_and_grad(theta, theta_ref, chosen, rejected, beta, l2=0.0):
    """
    DPO 损失及其对 logits θ 的梯度（策略 π = softmax(θ)，参数是 400 个回答的 logits）。

    【一个让计算变简单的观察】
        log π(y) = θ_y - log Σ_z exp(θ_z)。两个回答的 log 概率相减时，归一化项抵消：
            log π(y_w) - log π(y_l) = θ_{y_w} - θ_{y_l}
        所以 DPO 的"间隔"只和 θ 的两个分量有关：
            u = β [(θ_w - θ_ref,w) - (θ_l - θ_ref,l)]
        记 Δθ = θ - θ_ref，隐式奖励 r̂(y) = β Δθ_y，则 u = r̂(y_w) - r̂(y_l)。

    【梯度】∂(-log σ(u))/∂u = -(1 - σ(u))，所以每一对偏好数据贡献：
            θ_w 方向：-β (1 - σ(u))   （损失下降方向是增大 θ_w）
            θ_l 方向：+β (1 - σ(u))   （损失下降方向是减小 θ_l）
        (1 - σ(u)) 是"隐式奖励模型判断错的程度"：已经判断对（u 很大）的样本几乎不贡献梯度，
        判断错的样本贡献最大——这和逻辑回归完全一样。

    【参数】
        l2 : 对隐式奖励 r̂ = βΔθ 的 L2 正则强度（演示 2 中为了与奖励模型的正则一致而使用）。

    【返回】(平均损失, 梯度)
    """
    delta = theta - theta_ref
    u = beta * (delta[chosen] - delta[rejected])
    loss = float(np.mean(np.log1p(np.exp(-u))))         # -log σ(u) = log(1 + e^{-u})
    coef = -beta * (1.0 - sigmoid(u)) / len(u)
    grad = np.zeros_like(theta)
    np.add.at(grad, chosen, coef)
    np.add.at(grad, rejected, -coef)
    if l2 > 0:
        loss += l2 * float(np.sum((beta * delta) ** 2))
        grad += l2 * 2 * beta ** 2 * delta
    return loss, grad


def train_dpo(chosen, rejected, ref, beta, lr, n_iters, l2=0.0, log_every=None):
    """
    从 θ = log π_ref 出发，对 DPO 损失做全批量梯度下降。
    【返回】(策略 π, 记录列表 [(步数, 损失), ...])
    """
    theta_ref = np.log(ref)
    theta = theta_ref.copy()
    log = []
    for it in range(n_iters + 1):
        loss, grad = dpo_loss_and_grad(theta, theta_ref, chosen, rejected, beta, l2)
        if log_every and it % log_every == 0:
            log.append((it, loss))
        theta -= lr * grad
    return softmax(theta), log


def fit_tabular_reward_model(chosen, rejected, lr, n_iters, l2=0.0):
    """
    表格型奖励模型：每个回答一个奖励值 r̂(y)（没有任何特征），用 Bradley–Terry 极大似然训练。
    与 01 文件的 fit_bradley_terry 相同，只是参数化方式不同。
    """
    r = np.zeros(N_RESP)
    for _ in range(n_iters):
        u = r[chosen] - r[rejected]
        coef = (1.0 - sigmoid(u)) / len(u)
        grad = np.zeros(N_RESP)
        np.add.at(grad, chosen, coef)
        np.add.at(grad, rejected, -coef)
        r += lr * (grad - 2 * l2 * r)
    return r


def main():
    rng = np.random.default_rng(1)
    ref = reference_policy()
    r_true = true_reward()
    chosen, rejected = sample_preferences(ref, 3000, rng)
    print(f"偏好数据：{len(chosen)} 对（与 02、03 文件相同的生成方式）。")
    print(f"其中出现过的不同回答：{len(np.unique(np.concatenate([chosen, rejected])))} / {N_RESP} 个\n")

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：隐式奖励——从最优策略反推奖励")
    print("=" * 78)
    beta = 0.5
    pi_star = optimal_kl_policy(r_true, ref, beta)
    implicit = beta * np.log(pi_star / ref)
    diff = r_true - implicit
    print(f"  取 π* ∝ π_ref exp(r*/β)（β = {beta}），计算 β log(π*(y)/π_ref(y))：")
    print(f"  真实奖励 - 隐式奖励 的最小值 {diff.min():.6f}，最大值 {diff.max():.6f}")
    print("  → 两者只差一个常数（= β log Z）。策略里\"藏着\"奖励；")
    print("    而 Bradley–Terry 模型只看奖励之差，常数不影响任何偏好概率。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：DPO = 表格型奖励模型 + KL 正则最优解（β = 0.5，两者使用相同的 L2 正则）")
    print("=" * 78)
    beta, l2 = 0.5, 1e-4
    pi_dpo, log = train_dpo(chosen, rejected, ref, beta, lr=40.0, n_iters=20_000, l2=l2, log_every=5000)
    for it, loss in log:
        print(f"  DPO 第 {it:5d} 步：损失 {loss:.4f}")
    r_tab = fit_tabular_reward_model(chosen, rejected, lr=10.0, n_iters=20_000, l2=l2)
    pi_rlhf_tab = optimal_kl_policy(r_tab, ref, beta)
    print(f"\n  KL(DPO 策略 ‖ \"奖励模型 + 闭式解\"策略) = {abs(kl(pi_dpo, pi_rlhf_tab)):.1e}（在浮点误差范围内为 0）")
    print(f"  DPO 策略：平均长度 {np.sum(pi_dpo * LENGTH):.3f}，真实奖励 {np.sum(pi_dpo * r_true):.4f}")
    print(f"  两阶段法：平均长度 {np.sum(pi_rlhf_tab * LENGTH):.3f}，真实奖励 {np.sum(pi_rlhf_tab * r_true):.4f}")
    print("  → 两者几乎完全相同。DPO 并不是一个\"不同的目标\"，而是用一种更直接的方式")
    print("    求解同一个问题：它把\"训练奖励模型\"和\"求最优策略\"合并成了一步监督学习。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：不同 β 下的 DPO（与 03 文件中基于线性奖励模型的 RLHF 对比）")
    print("=" * 78)
    w = fit_linear_reward_model(chosen, rejected)
    r_lin = FEATURES @ w
    print("     β    │ DPO：KL(π‖π_ref)  平均长度  真实奖励 │ RLHF(线性RM)：KL  平均长度  真实奖励")
    for beta in [2.0, 0.5, 0.2, 0.1, 0.05]:
        pi_d, _ = train_dpo(chosen, rejected, ref, beta, lr=10.0 / beta ** 2, n_iters=5000, l2=1e-4)
        pi_r = optimal_kl_policy(r_lin, ref, beta)
        print(f"  {beta:5.2f}   │      {kl(pi_d, ref):6.2f}       {np.sum(pi_d * LENGTH):5.2f}    {np.sum(pi_d * r_true):6.3f}  │"
              f"         {kl(pi_r, ref):6.2f}     {np.sum(pi_r * LENGTH):5.2f}    {np.sum(pi_r * r_true):6.3f}")
    print(f"  （参考策略的真实奖励：{np.sum(ref * r_true):.3f}）")
    print("  观察：")
    print("  - 两者都在 β 适中时效果最好，β 太小都会变差：DPO 并没有消除过度优化，")
    print("    它优化的依然是\"KL 正则的偏好似然\"，只是换了一种求解方式。")
    print("  - 它们出错的方式不同：线性奖励模型会把\"越长越好\"外推到从没见过的长回答上；")
    print("    表格型的 DPO 只能调整**出现在偏好数据里**的回答的概率，没见过的回答它不会主动推高，")
    print("    但会过度相信那些只被比较过一两次的回答（见演示 4）。")
    print("    真实的 DPO 用神经网络参数化策略，会像线性奖励模型一样泛化——包括泛化出错。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：确定性偏好下的过拟合——β 挡不住（Azar et al., 2023 对 DPO 的批评）")
    print("=" * 78)
    y_a, y_b = 10, 20
    print(f"  只有一条偏好数据：回答 {y_a} ≻ 回答 {y_b}（只比较过一次，\"确定性\"地赢了）。")
    print(f"  两个回答的真实奖励：{r_true[y_a]:.3f} vs {r_true[y_b]:.3f}"
          f"——这条标签恰好是\"错\"的（人类偏好有噪声），b 其实更好。")
    print("  （两种 β 下学习率都按 1/β² 缩放，使隐式奖励的变化速度相同，方便对比。）")
    for beta in [0.1, 1.0]:
        theta_ref = np.log(ref)
        theta = theta_ref.copy()
        print(f"\n  β = {beta}：")
        print("       步数      隐式奖励之差 r̂(a) - r̂(b)    π(b) / π_ref(b)")
        checkpoints = {10, 100, 1000, 10_000, 100_000}
        for it in range(1, 100_001):
            _, grad = dpo_loss_and_grad(theta, theta_ref, np.array([y_a]), np.array([y_b]), beta)
            theta -= 1.0 / beta ** 2 * grad
            if it in checkpoints:
                pi = softmax(theta)
                margin = beta * ((theta[y_a] - theta_ref[y_a]) - (theta[y_b] - theta_ref[y_b]))
                print(f"    {it:7d}          {margin:8.2f}                  {pi[y_b] / ref[y_b]:.2e}")
    print("\n  → 隐式奖励之差随训练不断增长（大约按训练步数的对数增长），没有上限；")
    print("    回答 b 的概率被压向 0——无论 β 取多少。")
    print("  原因：只要数据中 a 总是赢，σ(u) < 1，损失就还能继续下降，而 DPO 的损失里并没有")
    print("  一个\"把策略拉回 π_ref\"的项。β 只是改变了 Δθ 的尺度，并不能阻止 u → ∞。")
    print("  对比 RLHF：奖励模型通常有正则化 / 提前停止，而且 RL 阶段的 KL 惩罚会显式地把策略拉回 π_ref。")
    print("  实践中，同一对回答通常只被标注一次——从数据的角度看，每一条偏好都是\"确定性\"的")
    print("  （赢的一方 100% 赢）。所以这个问题在真实的 DPO 训练中确实存在，而且像这个例子一样，")
    print("  被\"完全相信\"的那条标签可能恰好是噪声。IPO 等变体就是为了解决它而提出的；")
    print("  实践中也常用提前停止、较少的训练轮数来缓解。")


if __name__ == "__main__":
    main()
