"""
=============================================================================
第 1 讲 · 代码 3：整门课的缩影 —— 一个玩具 RLHF 流水线（以及它怎样出错）
=============================================================================

对应讲义：lectures/lecture01_intro_motivation.md 第 3 节（贯穿全课的例子：大语言模型的对齐）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
ChatGPT、Claude 这类助手在"预训练 + 监督微调"之后，还要用人类的偏好再训练一轮，
最经典的做法叫 RLHF（Reinforcement Learning from Human Feedback，基于人类反馈的强化学习）：

    第 1 步  收集偏好：让模型对同一个提示词生成两个回答，请标注员选更好的那个。
    第 2 步  训练奖励模型：用 Bradley–Terry 模型从这些比较里学出"每个回答值多少分"。
    第 3 步  优化策略：调整模型，让它更常生成高分的回答，同时不要离原来的模型太远（KL 约束）。

真实的 RLHF 要训练几十亿参数的神经网络。这个文件把它缩小到我们的玩具世界里：
"模型"就是 8 个候选回答上的一个概率分布（策略 π），每一步都能精确地计算。
这样就能看清楚整条流水线，以及它最著名的失败方式：

    演示 1：起点 —— 参考策略 π_ref（"只做过监督微调的模型"）平均能给出多好的回答。
    演示 2：第 1、2 步 —— 收集 4000 条众包比较，拟合奖励模型。奖励模型忠实地学到了标注员的偏好，
            **包括他们的偏差**：表面功夫的 D 得分最高。
    演示 3：第 3 步 —— KL 约束下的最优策略 π_β(y) ∝ π_ref(y)·exp(r(y)/β)。
            β 越小，越"使劲"优化奖励。真实质量先升后降：这就是**奖励过度优化**（reward overoptimization），
            古德哈特定律在 RLHF 里的样子。换成专家的数据，同样的流程就没有这个问题。
    演示 4：DPO 的视角 —— 不训练奖励模型，直接用比较数据训练策略，结果和 RLHF 一模一样。
            换算法不能修复数据里的偏差。
    演示 5：少量干净的数据 vs 大量有偏差的数据。

【重要的简化】
    · 真实的策略是一个生成任意文字的神经网络，这里只能在 8 个固定的回答里选。真实世界里过度优化
      还有第二个来源：策略会找到奖励模型**从没见过**的奇怪回答（分布外），奖励模型在那里错得更离谱。
      玩具世界里只有"标注员有偏差"这一个来源。
    · 第 3 步用的是 KL 约束问题的**解析解**；真实系统用 PPO 等强化学习算法去逼近它（第 8 讲）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_rlhf_in_miniature.py        # 约 1 秒

只用到 Python 标准库，以及同目录下的 toy_world.py。
"""

import math
import random

from toy_world import (BY_KEY, KEYS, PROMPT, REFERENCE_POLICY, RESPONSES, bar, fit_bradley_terry,
                       make_crowd, make_expert, mean, pad, prefers, print_header)

BETAS = [None, 4.0, 2.0, 1.0, 0.5, 0.25, 0.1, 0.03]     # None 表示 β = ∞（完全不优化，就是 π_ref）


# =============================================================================
# 小工具：策略（概率分布）的计算
# =============================================================================
def expected(policy, values):
    """
    期望值 E_{y~π}[values(y)] = Σ_y π(y)·values(y)。

    【参数】policy：dict {回答: 概率}；values：dict {回答: 数值}
    【返回】float
    """
    return math.fsum(policy[k] * values[k] for k in policy)


def kl_divergence(p, q):
    """
    KL 散度 KL(p ‖ q) = Σ_y p(y)·log(p(y)/q(y))：分布 p 离分布 q 有"多远"。

    【思路】KL 散度总是 ≥ 0，p = q 时等于 0。它不对称（KL(p‖q) ≠ KL(q‖p)），所以不是严格意义上的"距离"。
        在 RLHF 里，KL(π ‖ π_ref) 衡量"新模型离原来的模型有多远"。
        直觉：如果 π 把概率集中到 π_ref 很少生成的回答上，log(π/π_ref) 很大，KL 就大。
    【参数】p、q：dict {回答: 概率}，键相同
    【返回】float（≥ 0）
    【边界】p(y) = 0 的项按惯例记为 0（0·log 0 = 0）；q(y) = 0 而 p(y) > 0 时 KL = ∞（这里不会出现）。
    """
    return math.fsum(p[k] * math.log(p[k] / q[k]) for k in p if p[k] > 0)


def kl_regularized_policy(reference, reward, beta):
    """
    KL 约束下的最优策略：π_β(y) = π_ref(y)·exp(r(y)/β) / Z。

    【思路】RLHF 第 3 步要解的问题是
            max_π  E_{y~π}[r(y)]  −  β·KL(π ‖ π_ref)
        第一项：奖励越高越好；第二项：离原来的模型越远，扣分越多。β 控制两者的权衡。
        这个问题有一个漂亮的解析解（第 8、9 讲推导）：在原来的概率上乘一个 exp(r/β) 的"放大系数"，
        再归一化。奖励高 1 分的回答，概率就多乘 e^{1/β} 倍。
            β → ∞：不放大，π = π_ref（完全不优化）
            β → 0：只剩奖励最高的那个回答（完全不顾 π_ref）
    【参数】reference：dict π_ref；reward：dict r；beta：> 0 的数，或 None 表示 ∞
    【返回】dict {回答: 概率}
    【边界】计算 exp 之前先减去最大奖励，防止 β 很小时 exp 溢出（分子分母同乘一个常数，结果不变）。
    【偏好学习联系】真实的语言模型有天文数字种可能的回答，没法直接算 Z，所以要用 PPO 等强化学习算法
        去逼近这个解（第 8 讲）；DPO 则把这个公式反过来用（第 9 讲，本文件演示 4）。
    """
    if beta is None:
        return dict(reference)
    top = max(reward.values())
    w = {k: reference[k] * math.exp((reward[k] - top) / beta) for k in reference}
    z = math.fsum(w.values())
    return {k: v / z for k, v in w.items()}


def sample_pair(policy, rng):
    """
    从策略里独立地抽两个**不同的**回答（InstructGPT 的做法：同一个提示词采样两个回答请人比较）。

    【参数】policy：dict {回答: 概率}；rng：random.Random
    【返回】(回答编号, 回答编号)
    【边界】两次抽到同一个回答就重抽（比较同一个回答没有信息）。
    【Python 小知识】rng.choices(候选列表, weights=权重列表)：按权重随机抽，返回一个列表（默认只抽 1 个）。
    """
    keys = list(policy)
    weights = [policy[k] for k in keys]
    while True:
        a = rng.choices(keys, weights=weights)[0]
        b = rng.choices(keys, weights=weights)[0]
        if a != b:
            return a, b


def collect_comparisons(annotators, n, rng):
    """
    第 1 步：收集 n 条比较。每条：从 π_ref 抽一对回答，随机找一个标注员选。

    【参数】annotators：list[Annotator]；n：条数；rng：random.Random
    【返回】list[(赢家, 输家)]
    【偏好学习联系】"从当前策略采样回答对"决定了哪些回答被比较得多、哪些很少被比较（π_ref 很少生成的
        B、D 被比较的次数也少）。第 10 讲会讨论怎样主动地挑回答对，让有限的标注预算更有用。
    """
    data = []
    for _ in range(n):
        a, b = sample_pair(REFERENCE_POLICY, rng)
        judge = rng.choice(annotators)
        data.append((a, b) if prefers(judge, BY_KEY[a], BY_KEY[b], rng) else (b, a))
    return data


QUALITY = {r.key: r.quality for r in RESPONSES}


# =============================================================================
# 演示 1：起点
# =============================================================================
def demo_reference():
    print_header("演示 1：起点 —— 参考策略 π_ref（只做过监督微调的模型）")
    print(f"提示词：{PROMPT}\n")
    print(f"  {'回答':4s} {pad('π_ref', 6, 'right')}  {pad('真实质量', 8, 'right')}  描述")
    for r in RESPONSES:
        print(f"  {r.key:4s} {REFERENCE_POLICY[r.key]:>6.2f}  {r.quality:>8.2f}  {r.title}")
    print(f"\n  π_ref 生成的回答，平均真实质量 = {expected(REFERENCE_POLICY, QUALITY):.3f}")
    print("  它最常生成的是 H（答非所问）、G（有错）、E、F —— 这正是要用偏好去改进它的原因。")


# =============================================================================
# 演示 2：收集偏好、训练奖励模型
# =============================================================================
def demo_reward_model(rng):
    print_header("演示 2：第 1、2 步 —— 收集偏好，训练奖励模型")
    crowd = make_crowd(40, rng)
    expert = make_expert()
    crowd_data = collect_comparisons(crowd, 4000, rng)
    expert_data = collect_comparisons([expert], 4000, rng)
    r_crowd = fit_bradley_terry(crowd_data, KEYS)
    r_expert = fit_bradley_terry(expert_data, KEYS)

    # 众包标注和专家判断的一致率：在众包比较过的同一批回答对上，专家（按他的效用）会选哪个？
    agree = mean([expert.utility(BY_KEY[w]) > expert.utility(BY_KEY[l]) for w, l in crowd_data])
    print(f"从 π_ref 采样回答对，请 40 个众包标注员比较 4000 次；另请一位专家也比较 4000 次。")
    print(f"众包的选择和专家的偏好一致的比例：{agree:.1%}\n")
    print("用 Bradley–Terry 模型分别拟合两个奖励模型（分数的平均值固定为 0，只有差值有意义）：\n")
    print(f"  {'回答':4s} {pad('真实质量', 8, 'right')} {pad('众包奖励', 9, 'right')} {pad('专家奖励', 9, 'right')}  描述")
    for k in sorted(KEYS, key=lambda k: -r_crowd[k]):
        # 【Python 小知识】s.split("：")[0]：按全角冒号切开，取第一段，例如 "表面功夫：先夸……" → "表面功夫"。
        print(f"  {k:4s} {QUALITY[k]:>8.2f} {r_crowd[k]:>9.2f} {r_expert[k]:>9.2f}  {BY_KEY[k].title.split('：')[0]}")
    print("""
观察：
  · 众包奖励模型的第一名是 D（表面功夫，真实质量 0.50）：标注员平均来说喜欢又长又花哨的回答，
    奖励模型**忠实地**学到了这一点。奖励模型没有“学错”—— 它准确地学到了数据里的偏好，
    问题在于数据里的偏好本身和我们想要的东西（真实质量）不一样。
  · 专家奖励模型的排名和真实质量完全一致（A 最高）。
  · 众包和专家的一致率看起来不低。真实的 RLHF 数据里，标注员之间的一致率通常也只有六七成
    （InstructGPT 报告约 73%）—— 偏好数据天然就是“有噪声的”，这本身不是问题；有**方向性的偏差**才是问题。""")
    return crowd_data, expert_data, r_crowd, r_expert


# =============================================================================
# 演示 3：KL 约束下优化 —— 奖励过度优化
# =============================================================================
def demo_optimization(r_crowd, r_expert):
    print_header("演示 3：第 3 步 —— 在 KL 约束下优化：β 越小，越使劲地优化奖励")
    print("π_β(y) ∝ π_ref(y)·exp(r(y)/β)。对每个 β，算出新策略离 π_ref 多远（KL）、")
    print("它在“自己的”奖励模型下的平均奖励，以及它生成的回答的平均**真实质量**。\n")
    print(f"  {'β':>5s} | {pad('用众包奖励优化', 34)}| {pad('用专家奖励优化', 18)}")
    print(f"  {'':>5s} | {'KL':>5s} {pad('平均奖励', 8, 'right')} {pad('真实质量', 8, 'right')} {pad('最常生成', 9, 'right')} "
          f"| {'KL':>5s} {pad('真实质量', 8, 'right')}")
    rows = []
    for beta in BETAS:
        pc = kl_regularized_policy(REFERENCE_POLICY, r_crowd, beta)
        pe = kl_regularized_policy(REFERENCE_POLICY, r_expert, beta)
        top = max(pc, key=pc.get)
        q_crowd = expected(pc, QUALITY)
        rows.append((beta, q_crowd))
        label = "∞" if beta is None else f"{beta:g}"
        print(f"  {label:>5s} | {kl_divergence(pc, REFERENCE_POLICY):>5.2f} {expected(pc, r_crowd):>8.2f} "
              f"{q_crowd:>8.3f} {top:>4s} {pc[top]:>4.0%} | {kl_divergence(pe, REFERENCE_POLICY):>5.2f} "
              f"{expected(pe, QUALITY):>8.3f}")

    best_beta, best_q = max(rows, key=lambda t: t[1])
    final_beta, final_q = rows[-1]
    final_top = max(KEYS, key=lambda k: r_crowd[k])          # β → 0 时策略会集中到奖励最高的回答上
    if best_beta == final_beta:
        print(f"\n  用众包奖励时，真实质量随着优化单调上升，最终停在 {final_top} 的 {final_q:.3f}。")
    else:
        print(f"\n  用众包奖励时，真实质量在 β = {best_beta:g} 附近最高（{best_q:.3f}），"
              f"之后一路下降到 {final_top} 的 {final_q:.3f}。")
    print("\n  众包奖励 · 真实质量随 β 的变化（从不优化到使劲优化）：")
    for beta, q in rows:
        label = "∞" if beta is None else f"{beta:g}"
        print(f"    β = {label:>5s}  {bar(q, 40)} {q:.3f}")
    print("""
观察：
  · 用众包奖励优化：“平均奖励”一路上升，KL 一路变大，可**真实质量先升后降**。
    刚开始，优化把概率从 H、G 这些很差的回答挪到 A、B、C、D 上 —— 奖励和真实质量都提高了；
    继续使劲优化，概率越来越集中到奖励最高的 D 上 —— 奖励还在涨，真实质量却在跌。
    这就是 Gao 等（2023）在真实的大模型上测到的“奖励过度优化”曲线：代理奖励（proxy）单调上升，
    金标准（gold）先升后降。古德哈特定律：奖励模型是“好”的一个指标，把它当成目标去使劲优化，它就失效了。
  · 用专家奖励优化，真实质量单调上升到 0.90（A）。流程完全一样，差别只在**数据**。
  · KL 约束（β）就是一根“缰绳”：不让策略离 π_ref 太远，也就不会走到奖励模型最不可信的地方。
    但缰绳放多长合适，只看奖励模型自己的分数是判断不了的（它总说“越优化越好”），
    必须用独立的、更可靠的评估（例如一小批专家判断）来选 β。第 8 讲会更详细地讨论。""")


# =============================================================================
# 演示 4：DPO 的视角
# =============================================================================
def demo_dpo(crowd_data, r_crowd, beta=1.0):
    print_header("演示 4：DPO 的视角 —— 不训练奖励模型，直接训练策略")
    # DPO 把策略写成 π(y) = π_ref(y)·exp(θ_y)/Z，"隐式奖励"定义为 β·log(π(y)/π_ref(y)) = β·θ_y − β·log Z。
    # 在比较数据上，用 Bradley–Terry 的似然直接拟合隐式奖励的差值（Z 在差值里消掉了），就得到 DPO 的目标。
    # 在我们的表格设定里，"拟合隐式奖励"和"拟合奖励模型"是同一个优化问题，所以直接复用 fit_bradley_terry。
    implicit = fit_bradley_terry(crowd_data, KEYS)            # 隐式奖励 β·θ_y（差一个常数）
    theta = {k: implicit[k] / beta for k in KEYS}
    w = {k: REFERENCE_POLICY[k] * math.exp(theta[k]) for k in KEYS}
    z = math.fsum(w.values())
    pi_dpo = {k: v / z for k, v in w.items()}
    pi_rlhf = kl_regularized_policy(REFERENCE_POLICY, r_crowd, beta)
    gap = max(abs(pi_dpo[k] - pi_rlhf[k]) for k in KEYS)
    print(f"β = {beta:g}。DPO 直接在 4000 条众包比较上训练策略；RLHF 先拟合奖励模型、再求 KL 约束下的最优策略。\n")
    print(f"  {'回答':4s} {pad('π_RLHF', 8, 'right')} {pad('π_DPO', 8, 'right')}  {pad('β·log(π_DPO/π_ref)', 20, 'right')}  {pad('众包奖励', 9, 'right')}")
    base = beta * math.log(pi_dpo["A"] / REFERENCE_POLICY["A"]) - r_crowd["A"]
    for k in KEYS:
        imp = beta * math.log(pi_dpo[k] / REFERENCE_POLICY[k])
        print(f"  {k:4s} {pi_rlhf[k]:>8.3f} {pi_dpo[k]:>8.3f}  {imp - base:>20.2f}  {r_crowd[k]:>9.2f}")
    gap_text = "几乎为 0（< 1e-12，只是浮点误差）" if gap < 1e-12 else f"{gap:.1e}"
    print(f"\n  两个策略的最大差别：{gap_text}")
    print("""
观察：
  · DPO（Rafailov 等 2023）跳过了奖励模型，直接用一个“分类”式的损失训练策略，实现简单得多。
    但它和 RLHF 优化的是**同一个目标**，在同样的数据上得到同样的策略（这里一模一样）。
  · 表里第 3 列是 DPO 的“隐式奖励” β·log(π/π_ref)（平移到和众包奖励对齐）：它和众包奖励完全相同 ——
    DPO 心里也有一个奖励模型，只是没有单独写出来。这个奖励同样把 D 排在第一。
  · 所以：**换算法修不好数据里的偏差**。要修，得从数据（谁来标、怎么问）、模型（把偏差建模进去，
    第 14 讲“风格控制”）、评估（用独立的金标准选 β）入手。第 9 讲详细推导 DPO。""")


# =============================================================================
# 演示 5：少量干净的数据 vs 大量有偏差的数据
# =============================================================================
def demo_data_quality(rng, beta=0.1):
    print_header("演示 5：少量干净的数据 vs 大量有偏差的数据")
    expert = make_expert()
    crowd = make_crowd(40, rng)
    print(f"β = {beta:g}（使劲优化）。比较不同数据训练出的奖励模型，优化后策略的真实质量（每格重复 20 次取平均）：\n")
    print(f"  {pad('数据', 26)} {pad('真实质量', 8, 'right')}")
    for name, pool, n in [("众包 4000 条", crowd, 4000), ("众包 400 条", crowd, 400),
                          ("专家 400 条", [expert], 400), ("专家 100 条", [expert], 100)]:
        qs = []
        for _ in range(20):
            reward = fit_bradley_terry(collect_comparisons(pool, n, rng), KEYS)
            qs.append(expected(kl_regularized_policy(REFERENCE_POLICY, reward, beta), QUALITY))
        print(f"  {pad(name, 26)} {mean(qs):>8.3f}  {bar(mean(qs), 30)}")
    print("""
观察：
  · 有方向性偏差的数据，再多也没用：4000 条众包数据把策略推向 D，数据越多，奖励模型越“确信” D 最好。
  · 没有偏差的数据，少一点也能用：100 条专家比较就够把策略推向好的回答（偶尔会因为数据太少而选错，
    所以平均值略低于 0.90）。
  · 真实世界里，“专家”很贵、也未必没有偏差，“众包”便宜但有偏差。怎样用有限的预算问最有用的问题
    （第 7、10 讲：主动学习），怎样识别和校正偏差（第 14、15 讲），是这门课的核心问题。""")


def main():
    rng = random.Random(2022)       # 2022：InstructGPT 发表的年份
    demo_reference()
    crowd_data, _, r_crowd, r_expert = demo_reward_model(rng)
    demo_optimization(r_crowd, r_expert)
    demo_dpo(crowd_data, r_crowd)
    demo_data_quality(rng)


if __name__ == "__main__":
    main()
