"""
=============================================================================
第 7 讲 · 代码 3：KL 约束下的优化与奖励过度优化（reward overoptimization），以及 DPO
=============================================================================

对应讲义：lectures/lecture07_instruction_tuning_alignment.md 第 3.5、6、10.1 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    RLHF 的目标是 max_π E_π[r] − β·KL(π ‖ π_ref)。在"评论打分助手"小世界里回答只有 36 种，
    这个目标的最优解可以逐条精确算出来：π*(y|x) ∝ π_ref(y|x)·exp(r(x,y)/β)。不需要 PPO，也没有采样噪声。

    演示 1：对着不同的"奖励"优化，真实质量 g 怎样随 KL 变化？
            奖励 = g 本身（理想情况）、标注员的分数 h（完美地学会了有偏差的标注员）、学出来的奖励模型。
            复现 Gao et al. 2022 的形状：代理奖励一直上升，真实质量先升后降。
    演示 2：best-of-n vs KL 约束的闭式解：同样的 KL 预算，谁得到的真实质量更高？
            以及 best-of-n 的 KL 公式 log n − (n−1)/n 在离散的回答空间里准不准。
    演示 3：奖励模型的数据量、初始化与过度优化：峰值在哪里、过度优化之后策略变成什么样
            （包括"自信地答错"——策略几乎确定，评分却不对）。
    演示 4：DPO。(a) 在表格策略上直接最小化 DPO 损失，结果和闭式解 π* 一致；
            (b) 用同样 3000 条比较在神经网络策略上跑 DPO，和"奖励模型 + 闭式解"比一比。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_overoptimization.py      # 约 60 秒（读取代码 02 缓存的策略和奖励模型；请先运行代码 02，否则要先训练它们，多约 90 秒）

需要 PyTorch、numpy。
"""

import copy
import math
import random
import time

import numpy as np
import torch
import torch.nn.functional as F

from align_tools import (RESPONSES, assistant_world, best_of_n, human_reward_models, kl_tilt, load_base, mean_kl,
                         pad_examples, policy_stats, policy_table, response_text, review_prompt, reward_table,
                         seq_logprobs, truth_table)
from console import pad, print_header
from tiny_gpt import generate, lr_at

KL_GRID = [0.0, 0.1, 0.25, 0.5, 1.0, 2.0, 3.0]


def load_rms(aw, tok, base):
    """读取（或训练）代码 02 的 7 个奖励模型，返回 ({(init, N, seed): 奖励表}, 比较数据)。"""
    rms, comps = human_reward_models(aw, tok, base)
    return {key: reward_table(rm, tok, aw["rl_reviews"]) for key, rm in rms.items()}, comps


def rm_label(key):
    """(init, N, seed) → 表格里的名字，例如 "RM 预训练 N=3000 s1"。"""
    init, n, seed = key
    return f"RM {'预训练' if init == 'base' else '随机'} N={n} s{seed}"


def tilt_at_kl(pi, R, target):
    """
    找一个 β，使 π* = kl_tilt(π, R, β) 的平均 KL 等于 target（二分查找 log β）。

    【思路】KL 随 β 单调递减（β 越小，π* 离 π_ref 越远），所以可以在 log β 上二分。
    【返回】(q, kl, beta)；target = 0 时直接返回 π_ref。如果 R 能达到的最大 KL 小于 target，返回能达到的最远处。
    """
    if target <= 0:
        return pi, 0.0, float("inf")
    lo, hi = math.log(1e-4), math.log(1e4)
    q, kl = kl_tilt(pi, R, math.exp(lo))
    if kl < target:
        return q, kl, math.exp(lo)
    for _ in range(60):
        mid = (lo + hi) / 2
        q, kl = kl_tilt(pi, R, math.exp(mid))
        if kl > target:
            lo = mid
        else:
            hi = mid
    q, kl = kl_tilt(pi, R, math.exp(hi))
    return q, kl, math.exp(hi)


def expected(q, R):
    """策略 q 下奖励 R 的期望（在所有评论上平均）。"""
    return float((q * R).sum(1).mean())


# =============================================================================
# 演示 1：真实质量随 KL 的变化
# =============================================================================
def demo_curves(aw, rms):
    """演示 1：对每个奖励，在 KL = 0 到 3 处算 π* 的真实质量；再细看一个奖励模型的 √KL 曲线。"""
    print_header("演示 1：对着不同的奖励做 KL 约束优化——真实质量 E[g] 随 KL 怎样变化？")
    pi = aw["pi_rl"]
    G, H = truth_table(aw["rl_reviews"])
    rewards = [("g 本身（理想）", G), ("标注员分数 h", H)] + [(rm_label(k), R) for k, R in rms.items()]
    print("每一格是 E_π*[g]：先用二分查找找到 β，使 KL(π* ‖ π_ref) 等于这一列的值，再算真实质量。π_ref 本身的 E[g] 在 KL = 0 一列。\n")
    print(pad("奖励", 22) + "".join(pad(f"KL={k:g}", 8, ">") for k in KL_GRID) + pad("峰值 / 在 KL", 15, ">"))
    for name, R in rewards:
        vals = [expected(tilt_at_kl(pi, R, k)[0], G) for k in KL_GRID]
        fine = [(expected(tilt_at_kl(pi, R, k)[0], G), k) for k in np.linspace(0, 3, 31)]
        best, at = max(fine)
        print(pad(name, 22) + "".join(pad(f"{v:.2f}", 8, ">") for v in vals) + pad(f"{best:.2f} / {at:.1f}", 15, ">"))

    R = rms[("base", 3000, 0)]
    print("\n以 \"RM 预训练 N=3000 s0\" 为例，Gao et al. 的横轴 d = √KL；代理奖励（RM 自己的分数，相对 π_ref）一直在涨：")
    print(pad("√KL", 6) + pad("KL", 7, ">") + pad("β", 9, ">") + pad("代理奖励增量", 14, ">") + pad("真实质量 E[g]", 15, ">")
          + pad("评分正确", 10, ">") + pad("废话句数", 10, ">") + pad("wow", 7, ">") + pad("yay", 7, ">"))
    r0 = expected(pi, R)
    for d in [0.0, 0.5, 1.0, 1.25, 1.5, 1.75]:
        q, kl, beta = tilt_at_kl(pi, R, d * d)
        st = policy_stats(q, aw["rl_reviews"])
        beta_s = "∞" if math.isinf(beta) else f"{beta:.3f}"
        print(pad(f"{d:.2f}", 6) + pad(f"{kl:.2f}", 7, ">") + pad(beta_s, 9, ">") + pad(f"{expected(q, R) - r0:+.2f}", 14, ">")
              + pad(f"{st['gold']:.2f}", 15, ">") + pad(f"{st['correct']:.2f}", 10, ">") + pad(f"{st['k']:.2f}", 10, ">")
              + pad(f"{st['wow']:.2f}", 7, ">") + pad(f"{st['yay']:.2f}", 7, ">"))
    print("""
观察 Observations：
  · 对着 g 本身优化（第一行）：真实质量随 KL 单调上升到 2.50（最好的回答），没有任何过度优化——
    奖励就是我们想要的东西时，"优化得越狠越好"，KL 约束只是在限制步子的大小。
  · 对着标注员的分数 h 优化（第二行）：这是"奖励模型完美地学会了标注员"的极限情形。真实质量先升到 1.99
    （KL = 0.5，主要是把评分改对），然后掉到 1.32（KL = 3，回答越写越长、开头越来越热情）。
    这一层过度优化和奖励模型学得好不好无关，根源是**标注员的偏好和我们真正想要的不一致**——
    Goodhart 定律："一个指标一旦成为优化目标，就不再是好指标。"
  · 学出来的奖励模型又多了一层误差：峰值更低（1.25–1.74）、来得更早（KL = 0.2–0.4），之后掉得更快
    （KL = 3 时 −0.08 到 0.74，多数已经低于 π_ref 的 1.15）。比较数据多的（1000、3000 条）峰值高，
    100、300 条的峰值只有 1.25–1.28；同样 3000 条、只换训练种子，峰值在 1.52 和 1.74 之间——
    奖励模型的质量差一点，"能安全优化多远"就短一截。随机初始化的奖励模型几乎一开始就在帮倒忙（峰值 1.18，KL = 3 时 −0.46）。
  · 下面的细表是 Gao et al. 2022 图 1 的样子：横轴 √KL，代理奖励一直在涨（+0.56 → +1.24），
    真实质量在 √KL = 0.5 附近见顶（1.52）后一路下滑（0.36）。下滑的那一段，策略在做的正是奖励模型的偏差：
    废话从 1.02 句变成 2.65 句，wow 从 0.16 变成 0.57，评分正确率从峰值 0.87 掉回 0.73。""")


# =============================================================================
# 演示 2：best-of-n vs 闭式解
# =============================================================================
def demo_best_of_n(aw, rms):
    """演示 2：best-of-n 的精确 KL、Gao et al. 的 KL 公式、真实质量，和同 KL 下的闭式解比较。"""
    print_header("演示 2：best-of-n 与 KL 约束的闭式解——同样的 KL 预算，谁的真实质量更高？")
    pi = aw["pi_rl"]
    G, _ = truth_table(aw["rl_reviews"])
    R = rms[("base", 3000, 0)]
    print("奖励 = RM 预训练 N=3000 s0。best-of-n 的分布用排序 + 累计概率精确算出（不采样）。\n")
    print(pad("n", 5) + pad("KL 精确", 9, ">") + pad("log n − (n−1)/n", 17, ">") + pad("BoN 的 E[g]", 12, ">")
          + pad("同 KL 闭式解 E[g]", 18, ">"))
    for n in [1, 2, 4, 8, 16, 64, 256]:
        q, kl = best_of_n(pi, R, n)
        formula = math.log(n) - (n - 1) / n
        q_t, _, _ = tilt_at_kl(pi, R, kl)
        print(pad(n, 5) + pad(f"{kl:.3f}", 9, ">") + pad(f"{formula:.3f}", 17, ">") + pad(f"{expected(q, G):.2f}", 12, ">")
              + pad(f"{expected(q_t, G):.2f}", 18, ">"))
    print("""
观察 Observations：
  · best-of-n 也是一种"优化"：n 越大，离 π_ref 越远（KL 越大），真实质量同样先升后降（n = 4 时 1.49，n = 256 时 0.08）——
    过度优化不是强化学习特有的，任何对着代理奖励挑选的过程都会发生。
  · 公式 log n − (n−1)/n 在 n ≤ 16 时和精确值只差 2%–4%，n = 256 时高估了约 13%（4.549 vs 4.021）。
    公式假设回答是连续分布、几乎不会重复；这里只有 36 种回答，n 很大时总在同几种里挑，真实的 KL 增长变慢。
    对真实的语言模型（回答空间极大），这个公式基本成立，Gao et al. 用它画 best-of-n 的横轴。
  · 同样的 KL 下，n ≥ 4 时 best-of-n 的真实质量比闭式解略高（高 0.02–0.10）。闭式解是"同样 KL 下代理奖励最高"的策略，
    也就是**最会利用代理奖励**的策略；代理奖励有偏差时，它被带偏得也最彻底。Gao et al. 也发现 best-of-n 和 RL 的
    曲线形状不同，并提醒 KL 不是比较两种方法的公平尺度。""")


# =============================================================================
# 演示 3：数据量、初始化与"自信地答错"
# =============================================================================
def demo_rm_size(aw, rms):
    """演示 3：每个奖励的峰值、峰值处 KL，以及优化到 KL = 3 时策略的样子（含置信度）。"""
    print_header("演示 3：奖励模型的数据量与初始化——峰值、过度优化之后的样子")
    pi = aw["pi_rl"]
    G, H = truth_table(aw["rl_reviews"])
    print(pad("奖励", 22) + pad("峰值 E[g]", 10, ">") + pad("峰值处 KL", 10, ">") + pad("KL=3 时 E[g]", 13, ">")
          + pad("评分正确", 9, ">") + pad("废话句数", 9, ">") + pad("热情开头", 9, ">") + pad("策略置信度", 11, ">"))
    rows = [("标注员分数 h", H)] + [(rm_label(k), R) for k, R in rms.items()]
    for name, R in rows:
        fine = [(expected(tilt_at_kl(pi, R, k)[0], G), k) for k in np.linspace(0, 3, 31)]
        best, at = max(fine)
        q, _, _ = tilt_at_kl(pi, R, 3.0)
        st = policy_stats(q, aw["rl_reviews"])
        # 策略置信度：把 36 种回答按评分合并，最可能的那个评分的概率（平均）
        by_rating = np.stack([q[:, [j for j, y in enumerate(RESPONSES) if y[0] == a]].sum(1) for a in ("good", "ok", "bad")], 1)
        print(pad(name, 22) + pad(f"{best:.2f}", 10, ">") + pad(f"{at:.1f}", 10, ">") + pad(f"{st['gold']:.2f}", 13, ">")
              + pad(f"{st['correct']:.2f}", 9, ">") + pad(f"{st['k']:.2f}", 9, ">") + pad(f"{st['wow'] + st['yay']:.2f}", 9, ">")
              + pad(f"{by_rating.max(1).mean():.2f}", 11, ">"))
    st0 = policy_stats(pi, aw["rl_reviews"])
    by0 = np.stack([pi[:, [j for j, y in enumerate(RESPONSES) if y[0] == a]].sum(1) for a in ("good", "ok", "bad")], 1)
    print(f"（参照 π_ref：E[g] {st0['gold']:.2f}，评分正确 {st0['correct']:.2f}，废话 {st0['k']:.2f} 句，"
          f"热情开头 {st0['wow'] + st0['yay']:.2f}，置信度 {by0.max(1).mean():.2f}）")
    print("""
观察 Observations：
  · 优化到 KL = 3 时，学出来的奖励模型把回答推到了 1.6–2.6 句废话（π_ref 是 1.02），热情开头 0.27–0.82；评分正确率是 0.45–0.79，
    多数低于 π_ref 的 0.78。小数据（N = 100）和随机初始化的奖励模型最糟：评分正确率 0.45、0.50，接近瞎猜。
  · "自信地答错"：最右一列是策略在三个评分上的最大概率。π_ref 的置信度 0.78、正确率 0.78——恰好校准；
    过度优化之后置信度升到 0.82–0.99，正确率却没有跟上（N = 100：置信度 0.89、正确率 0.45）。
    优化把分布压得很尖，压尖的方向却是奖励模型的误差。Kadavath et al. 2022 发现 RLHF 之后的策略校准很差
    （需要把温度调到 2.5 才能恢复），这里是同一个机制的最小版本（代码 05 还会回到校准）。
  · 唯一"自信且正确"的是对着 h 优化：评分正确 1.00、置信度 1.00——但它的回答长度和热情开头同样被推到了极限，
    所以真实质量也在下降。奖励模型的两类误差（学偏了的偏好、没学好的判断）在这里分得很清楚。""")


# =============================================================================
# 演示 4：DPO
# =============================================================================
def dpo_tabular(pi, R, beta, iters=400):
    """
    在"表格策略"（每条评论 36 个可调的 logit）上直接最小化**期望** DPO 损失。

    【思路】期望是对所有回答对 (a, b) 取的，权重 π_ref(a)·π_ref(b)，软标签 P(a ≻ b) = σ(R_a − R_b)（Bradley–Terry）。
            DPO 损失：−[P·log σ(β·Δ) + (1 − P)·log σ(−β·Δ)]，Δ = [log π(a) − log π_ref(a)] − [log π(b) − log π_ref(b)]。
            它的最小值在 β·Δ = R_a − R_b 处取到，也就是 β·log(π/π_ref) = R + 常数——正是闭式解 π*。
    【返回】DPO 训练出来的表格策略 (n, 36)
    【PyTorch 小知识】torch.optim.LBFGS 是拟牛顿法，适合这种参数不多、损失光滑的问题；它要求传入一个 closure：
                      每次被调用时清零梯度、重新算损失、反向传播并返回损失（一步里可能调用好几次）。
    【边界】这里的 (n, 36, 36) 张量对 200 条评论只有约 26 万个元素；回答空间大很多时这种"列举所有回答对"的做法就不可行了。
    """
    log_ref = torch.tensor(np.log(np.maximum(pi, 1e-300)))
    Rt = torch.tensor(R)
    w = torch.tensor(pi)[:, :, None] * torch.tensor(pi)[:, None, :]           # (n, 36, 36) 回答对的权重
    p = torch.sigmoid(Rt[:, :, None] - Rt[:, None, :])                         # 软标签
    theta = log_ref.clone().requires_grad_(True)
    opt = torch.optim.LBFGS([theta], max_iter=iters, line_search_fn="strong_wolfe", tolerance_grad=1e-12,
                            tolerance_change=1e-14)

    def closure():
        """LBFGS 每次需要时调用：返回当前的期望 DPO 损失（已经反向传播）。"""
        opt.zero_grad()
        logp = theta - torch.logsumexp(theta, 1, keepdim=True)
        u = beta * (logp - log_ref)
        d = u[:, :, None] - u[:, None, :]
        loss = -(w * (p * F.logsigmoid(d) + (1 - p) * F.logsigmoid(-d))).sum() / w.sum()
        loss.backward()
        return loss

    opt.step(closure)
    with torch.no_grad():
        return torch.softmax(theta, 1).numpy()


def dpo_neural(policy, tok, comps, reviews, beta, steps=300, lr=3e-4, batch_size=32, seed=0):
    """
    在神经网络策略（TinyGPT）上跑 DPO：只用比较数据，不训练奖励模型，也不采样。

    【思路】对每条比较 (x, y_w, y_l)：
                u_w = log π_θ(y_w|x) − log π_ref(y_w|x)，u_l 同理；损失 = −log σ(β·(u_w − u_l))。
            π_ref 是训练开始时的策略（冻结的副本），它的对数概率一开始就全部算好。
    【返回】训练后的策略（新的 TinyGPT，eval 模式）
    """
    model = copy.deepcopy(policy)
    model.train()
    pairs_w = [(review_prompt(reviews[i][0]), response_text(RESPONSES[a if p > 0.5 else b]) + "\n") for i, a, b, p in comps]
    pairs_l = [(review_prompt(reviews[i][0]), response_text(RESPONSES[b if p > 0.5 else a]) + "\n") for i, a, b, p in comps]
    ref_w = torch.tensor(seq_logprobs(policy, tok, pairs_w))
    ref_l = torch.tensor(seq_logprobs(policy, tok, pairs_l))
    rng = random.Random(seed)
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.0, betas=(0.9, 0.95))
    for step in range(steps):
        for group in opt.param_groups:
            group["lr"] = lr_at(step, steps, lr, warmup=30)
        idx = [rng.randrange(len(comps)) for _ in range(batch_size)]
        X, Y, M = pad_examples(tok, [pairs_w[i] for i in idx] + [pairs_l[i] for i in idx])
        logp = F.log_softmax(model(X), dim=-1).gather(-1, Y.unsqueeze(-1)).squeeze(-1)
        seq = (logp * M).sum(1)
        u_w = seq[:batch_size] - ref_w[idx].float()
        u_l = seq[batch_size:] - ref_l[idx].float()
        loss = -F.logsigmoid(beta * (u_w - u_l)).mean()
        opt.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
    model.eval()
    return model


def free_samples(model, tok, reviews, seed=0):
    """
    让模型**自由地**写回答（温度 1，最多 40 个字符，遇到换行停），统计有多少落在 36 种回答之内。

    【返回】(in_set, avg_sentences, examples)：落在 36 种回答之内的比例、平均句子数（按句号数）、前 3 个样本
    【思路】policy_table 只看 36 种回答；如果模型把概率挪到了别的字符串上，表格是看不到的——必须真的采样看看。
    """
    texts = {response_text(y) for y in RESPONSES}
    g = __import__("torch").Generator().manual_seed(seed)
    outs = [generate(model, tok, review_prompt(r), max_new_chars=40, temperature=1.0, generator=g) for r, _ in reviews]
    in_set = np.mean([o.endswith("\n") and o[:-1] in texts for o in outs])
    return float(in_set), float(np.mean([o.count(".") for o in outs])), outs[:3]


def demo_dpo(aw, tok, rms, comps):
    """演示 4：(a) 表格 DPO vs 闭式解；(b) 神经网络 DPO vs 奖励模型 + 闭式解，加上自由采样的统计。"""
    print_header("演示 4：DPO——不训练奖励模型，直接从比较数据得到策略")
    pi = aw["pi_rl"]
    R = rms[("base", 3000, 0)]
    print("(a) 表格策略上最小化期望 DPO 损失（软标签来自 RM 预训练 N=3000 s0），和闭式解 π* ∝ π_ref·exp(r/β) 比较：")
    print(pad("β", 6) + pad("max |π_DPO − π*|", 18, ">") + pad("KL(π_DPO ‖ π_ref)", 19, ">") + pad("KL(π* ‖ π_ref)", 16, ">"))
    for beta in [2.0, 0.5]:
        q_dpo = dpo_tabular(pi, R, beta)
        q_star, kl_star = kl_tilt(pi, R, beta)
        print(pad(beta, 6) + pad(f"{np.abs(q_dpo - q_star).max():.1e}", 18, ">") + pad(f"{mean_kl(q_dpo, pi):.3f}", 19, ">")
              + pad(f"{kl_star:.3f}", 16, ">"))

    print("\n(b) 神经网络策略上的 DPO（3000 条人类比较，300 步）vs RLHF（同样 3000 条比较训练的 RM s0 + 闭式解，同一个 β）。")
    print("    前五列只在 36 种回答之内计算（重新归一化）；后两列是让模型自由采样 200 条回答的结果。")
    print(pad("方法", 24) + pad("KL", 6, ">") + pad("E[g]", 6, ">") + pad("评分正确", 9, ">") + pad("废话句数", 9, ">")
          + pad("36 种回答的概率质量", 20, ">") + pad("自由采样：在 36 种之内", 23, ">") + pad("平均句数", 9, ">"))
    reviews = aw["rl_reviews"]
    in_set, n_sent, _ = free_samples(aw["policy"], tok, reviews)
    st = policy_stats(pi, reviews)
    print(pad("π_ref（SFT）", 24) + pad("0.00", 6, ">") + pad(f"{st['gold']:.2f}", 6, ">") + pad(f"{st['correct']:.2f}", 9, ">")
          + pad(f"{st['k']:.2f}", 9, ">") + pad(f"{aw['mass_rl'].mean():.3f}", 20, ">") + pad(f"{in_set:.2f}", 23, ">")
          + pad(f"{n_sent:.2f}", 9, ">"))
    examples = {}
    for beta in [3.0, 1.0]:
        model = dpo_neural(aw["policy"], tok, comps, aw["rm_reviews"], beta)
        q, mass = policy_table(model, tok, reviews)
        st = policy_stats(q, reviews)
        in_set, n_sent, examples[beta] = free_samples(model, tok, reviews)
        print(pad(f"DPO β={beta:g}", 24) + pad(f"{mean_kl(q, pi):.2f}", 6, ">")
              + pad(f"{st['gold']:.2f}", 6, ">") + pad(f"{st['correct']:.2f}", 9, ">") + pad(f"{st['k']:.2f}", 9, ">")
              + pad(f"{mass.mean():.3f}", 20, ">") + pad(f"{in_set:.2f}", 23, ">") + pad(f"{n_sent:.2f}", 9, ">"))
        q_star, kl_star = kl_tilt(pi, R, beta)
        st = policy_stats(q_star, reviews)
        print(pad(f"RM + 闭式解 β={beta:g}", 24) + pad(f"{kl_star:.2f}", 6, ">") + pad(f"{st['gold']:.2f}", 6, ">")
              + pad(f"{st['correct']:.2f}", 9, ">") + pad(f"{st['k']:.2f}", 9, ">") + pad("—", 20, ">") + pad("—", 23, ">")
              + pad("—", 9, ">"))
    print("\nDPO β=1 自由采样的前 3 条回答（⏎ = 换行；没有 ⏎ 说明 40 个字符内没停下）：")
    for o in examples[1.0]:
        print("    " + repr(o).replace("\\n", "⏎"))
    print("""
观察 Observations：
  · (a) 在表格策略上，最小化 DPO 损失得到的策略和闭式解 π* 一样（最大差别 1e-5 量级，只是优化的数值误差）：
    DPO 和"奖励模型 + KL 约束优化"的目标是同一个最优解。DPO 的推导只是把 π* ∝ π_ref·exp(r/β) 倒过来，
    写成 r = β·log(π*/π_ref) + 常数，代进 Bradley–Terry 损失，常数（配分函数）在相减时消掉。
  · (b) 在神经网络策略上用有限的数据跑 DPO，情况不一样。只看 36 种回答，DPO 的数字很好看（β = 1 时 E[g] 2.00、
    评分正确 0.99）；但它分给这 36 种回答的概率质量只剩 0.063，自由采样时只有 3% 的回答落在其中——
    模型几乎不再按格式停下：平均 5.69 句（π_ref 2.27 句），把废话一直写下去，还会出现 "wow. wow." 这样的重复和乱码。
    即使 β = 3（只想离 π_ref 很近），也有将近一半的回答跑出了 36 种之外。
  · 原因：DPO 的损失只约束**数据里出现过的**回答对之间的对数概率之差，并不约束其他字符串的概率；
    标注员偏爱"更长"，DPO 就把"更长"推到了数据从没覆盖过的地方。RLHF 的 KL 惩罚作用在策略自己采样的
    整个分布上，闭式解也只在 36 种回答之内重新分配概率，所以不会跑出去（真实的 PPO 也会采样到新回答，
    再由奖励模型打分——它有自己的问题，但"跑出去的回答"至少会被看到）。
  · 这不是说 DPO 不好用：它便宜、稳定，2023 年之后被广泛采用。它说明的是：**偏好数据里的偏差，换哪种优化方法都会被利用**；
    而离线方法看不到自己生成的新回答，偏差可以被推到数据之外。后续的工作（长度正则、在线 / 迭代 DPO 等）正是在补这个口子。""")


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


if __name__ == "__main__":
    main()
