"""
=============================================================================
第 1 讲 · 代码 1：为什么要从人类偏好中学习？—— 三个动机的小实验
=============================================================================

对应讲义：lectures/lecture01_intro_motivation.md 第 2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
机器学习最常见的做法是：先写下一个目标（损失函数、准确率、奖励），再让模型去优化它。
可是对于"一个回答对初学者有没有帮助""一段笑话好不好笑""一个机器人的动作是否自然"，
我们根本写不出这个目标。这个文件用三个小实验说明"为什么要转而从人的偏好里学"：

    演示 1：目标很难手写 —— 三条"看起来合理"的规则，都会把奇怪的回答排在前面；
            而且规则的**第一名**恰好是它错得最离谱的地方（古德哈特定律的雏形）。
    演示 2：评判比创作容易 —— 标注员自己写的回答平均只有 0.55 分，但让他们从模型的
            n 个回答里挑最好的，挑出来的回答可以超过他们自己的水平。这是"用偏好反馈
            而不只是用示范数据"的核心理由。
    演示 3：为什么是"比较"而不是"打分"？—— 每个人打分的尺子不一样（有人严、有人松），
            直接平均分数会被"谁给谁打分"带偏；同一个人做比较，尺子的差异会自动抵消。
            但比较也有代价：丢掉了"好多少"的信息。什么时候比较更好，取决于尺子有多乱。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_why_learn_from_preferences.py        # 约 1 秒

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

import random

from toy_world import (BY_KEY, KEYS, RESPONSES, W_QUALITY, Annotator, bar, fit_bradley_terry,
                       kendall_tau, mean, pad, perceive, print_header, rating_from_perception,
                       sample_gumbel, sigmoid)

# 专家眼中的"真实排名"用到的分数：直接用真实质量。
TRUE_QUALITY = {r.key: r.quality for r in RESPONSES}


# =============================================================================
# 演示 1：目标很难手写
# =============================================================================
def rule_longer_is_better(r):
    """规则 1：越长越好。【参数】r：Response【返回】分数（越大越好）"""
    return r.length


def rule_more_keywords(r):
    """规则 2：专业关键词越多越好。"""
    return r.keywords


def rule_handcrafted(r):
    """
    规则 3：一条"更讲究"的组合规则 —— 有代码加 2 分，每个关键词加 1 分，花哨扣 3 分，
    长度在 150~600 字之间再加 1 分。

    【思路】这是一个工程师认真想过之后会写出来的规则：它考虑了代码、术语、格式、长度。
        演示 1 要说明的是：即使这样，它仍然看不见"内容对不对""是不是答非所问"——
        这些恰恰是"有没有帮助"里最重要的部分，而它们几乎无法用表面特征写出来。
    【Python 小知识】True/False 参与加减乘时分别当作 1/0，所以 2 * r.has_code 是 2 或 0。
        150 <= r.length <= 600 是 Python 的"链式比较"，等价于 150 <= r.length and r.length <= 600。
    """
    score = 2 * r.has_code + r.keywords - 3 * r.fancy
    if 150 <= r.length <= 600:
        score += 1
    return score


def demo_handwritten_rules():
    print_header("演示 1：目标很难手写 —— 三条“看起来合理”的规则")
    print("用每条规则给 8 个回答打分，看它的排名和真实质量的排名有多一致（肯德尔 τ，1 = 完全一致）。\n")
    rules = [("规则 1：越长越好", rule_longer_is_better),
             ("规则 2：关键词越多越好", rule_more_keywords),
             ("规则 3：讲究的组合规则", rule_handcrafted)]
    print(f"  {pad('规则', 24)} {'τ':>6s}   {pad('排名（从高到低）', 31)} 第一名的真实质量")
    for name, rule in rules:
        scores = {r.key: rule(r) for r in RESPONSES}
        # 【Python 小知识】sorted(..., key=lambda k: (-scores[k], k))：先按分数从大到小，分数相同再按编号排，
        #   这样并列时的顺序是确定的（不依赖于字典的内部顺序）。
        order = sorted(KEYS, key=lambda k: (-scores[k], k))
        tau = kendall_tau(scores, TRUE_QUALITY)
        top = BY_KEY[order[0]]
        print(f"  {pad(name, 24)} {tau:>6.2f}   {' > '.join(order)}   {top.key} = {top.quality:.2f}")
    truth = sorted(KEYS, key=lambda k: -TRUE_QUALITY[k])
    print(f"  {pad('（真实质量）', 24)} {1.0:>6.2f}   {' > '.join(truth)}   A = 0.90")
    print("""
观察：
  · 规则 1 的第一名是 D（表面功夫，真实质量 0.50），规则 2 的第一名是 E（注水长文，0.30）：
    它们最长、术语最多，却帮不上什么忙。两条规则的排名和真实质量几乎无关（τ ≈ 0）。
  · 规则 3 的第一名是 B（不错），但整体排名仍然和真实质量几乎无关（τ 只有 0.11）：它把注水的 E 排第二，
    把“自信但有错”的 G 排在最好的 A 前面。它看不见“内容对不对”“是不是答非所问”，
    而这恰恰是“有没有帮助”里最重要的部分 —— 这些几乎不可能用表面特征写出来。
  · 更要紧的是：如果我们拿规则去**优化**（让模型生成规则分数最高的回答），模型会被推向规则的第一名，
    也就是规则和真实目标偏离最大的地方。“当一个指标变成目标，它就不再是好指标”（古德哈特定律），
    03 号文件会看到同样的事情发生在**学出来的**奖励上。
  · 所以思路换一个方向：既然写不出“好”的定义，就让人直接告诉我们“哪个更好”，再从这些判断里学。""")


# =============================================================================
# 演示 2：评判比创作容易
# =============================================================================
def clipped_gauss(rng, mu, sd):
    """
    从正态分布 N(mu, sd²) 抽一个数，再截断到 [0, 1]（质量不能小于 0 或大于 1）。

    【参数】rng：random.Random；mu：平均值；sd：标准差
    【返回】0~1 之间的 float
    【边界】截断会让分布在 0 和 1 处"堆积"一点点；mu 在 0.5 附近、sd 不大时影响很小。
    """
    return min(1.0, max(0.0, rng.gauss(mu, sd)))


def judge_best_of_n(qualities, judge_weight, rng):
    """
    一位评委从 n 个回答里挑出他觉得最好的那个，返回挑中的回答的**真实质量**。

    【思路】评委对每个回答的感受 = judge_weight × 真实质量 + Gumbel 噪声，挑感受最大的。
        judge_weight 越大，评委越"识货"（噪声相对越小）；judge_weight = ∞ 就是完美的评委。
        这里用的就是 02 号文件要讲的"随机效用模型"。
    【参数】
        qualities    ：list[float]，n 个候选回答的真实质量
        judge_weight ：评委的识货程度（> 0）；传 None 表示完美评委（直接挑真实质量最大的）
        rng          ：random.Random
    【返回】float：挑中的回答的真实质量
    【边界】n = 1 时没有选择，直接返回那一个。
    【偏好学习联系】这就是 Best-of-n（也叫拒绝采样）：真实系统里"评委"通常是一个奖励模型，
        第 8 讲会把它和 RLHF 放在一起比较。
    """
    if judge_weight is None:
        return max(qualities)
    best_q, best_feel = None, None
    for q in qualities:
        feel = judge_weight * q + sample_gumbel(rng)
        if best_feel is None or feel > best_feel:
            best_q, best_feel = q, feel
    return best_q


def demo_judging_vs_generating(rng, trials=4000):
    print_header("演示 2：评判比创作容易 —— 挑出来的回答可以超过挑选者自己的水平")
    human_mu, human_sd = 0.55, 0.12      # 标注员自己写回答的质量分布
    model_mu, model_sd = 0.45, 0.18      # 模型生成回答的质量分布：平均更差，但更多样
    print(f"""设定：标注员自己写的回答，质量 ~ N({human_mu}, {human_sd}²)；模型生成的回答，质量 ~ N({model_mu}, {model_sd}²)。
模型平均比人差，但更“多样”（有时很好，有时很差）。现在让模型生成 n 个回答，请一位标注员挑最好的。
三种评委：完美评委（总能挑对）、细心的评委（识货程度 {W_QUALITY:g}，和专家一样）、粗心的评委（识货程度 2）。
每格是 {trials} 次重复的平均值。""")

    human_avg = mean([clipped_gauss(rng, human_mu, human_sd) for _ in range(trials)])
    print(f"\n  标注员自己写的回答，平均质量 = {human_avg:.3f}   （这是“模仿人类示范”即监督微调的天花板附近）\n")
    print(f"  {'n':>3s} | {'完美评委':>8s} {'细心评委':>8s} {'粗心评委':>8s} | 细心评委挑出的回答")
    for n in [1, 2, 4, 8, 16, 32]:
        res = {"perfect": [], "careful": [], "careless": []}
        for _ in range(trials):
            qs = [clipped_gauss(rng, model_mu, model_sd) for _ in range(n)]
            res["perfect"].append(judge_best_of_n(qs, None, rng))
            res["careful"].append(judge_best_of_n(qs, W_QUALITY, rng))
            res["careless"].append(judge_best_of_n(qs, 2.0, rng))
        p, c, cl = mean(res["perfect"]), mean(res["careful"]), mean(res["careless"])
        print(f"  {n:>3d} | {p:>8.3f} {c:>8.3f} {cl:>8.3f} | {bar(c, 26)}")

    print("\n  评委判断“两个回答哪个更好”的准确率（细心的评委，按两者的质量差）：")
    for gap in [0.05, 0.1, 0.2, 0.3, 0.5]:
        print(f"    质量差 {gap:.2f} → 判断正确的概率 σ({W_QUALITY:g}×{gap:.2f}) = {sigmoid(W_QUALITY * gap):.2f}")
    print("""
观察：
  · n = 1 时没得挑，质量就是模型的平均水平（约 0.45），比人写的差。
  · n 增大以后，细心的评委挑出的回答越来越好，很快就**超过了标注员自己写的平均水平**。
    评委不需要会写出好回答，只需要能分辨好坏 —— 而分辨通常比创作容易得多。
    这就是为什么 InstructGPT 等工作在“监督微调（模仿示范）”之后，还要再用“偏好反馈”训练（讲义第 3 节）。
  · 粗心的评委也有一点帮助，但很快就到顶了，甚至到不了人自己写的水平：评委的分辨能力是这条路线的天花板。
    第 14 讲会看到一个更麻烦的情况：评委不是“粗心”，而是“系统性地被带偏”（喜欢长的、花哨的）。
  · 这里的“挑最好的”叫 Best-of-n，本身就是一种实用的对齐方法；RLHF 更进一步：把评委的判断
    学成一个奖励模型，再训练模型**直接生成**评委喜欢的回答（03 号文件）。""")


# =============================================================================
# 演示 3：为什么是比较而不是打分
# =============================================================================
def calibrated_judge(name, offset, scale):
    """
    创建一个只看真实质量、没有偏差的标注员，但打分时有自己的宽严 offset 和尺度 scale。

    【思路】演示 3 只研究"打分的尺子不同"这一个问题，所以把长度、花哨、口味的偏差都关掉。
    【参数】name：名字；offset：打分宽严（> 0 宽松）；scale：打分尺度（< 1 分数挤在中间）
    【返回】Annotator
    """
    return Annotator(name=name, group="标注员", rating_offset=offset, rating_scale=scale)


def demo_two_judges(rng):
    """演示 3 的前半部分：一严一松两个标注员，给同样 3 个回答打分并排序。"""
    strict = calibrated_judge("严格的标注员", offset=-2.0, scale=0.6)
    lenient = calibrated_judge("宽松的标注员", offset=+2.0, scale=1.4)
    items = [BY_KEY["A"], BY_KEY["C"], BY_KEY["F"]]
    print("先看一个例子：一严一松两个标注员，读同样的 3 个回答（A、C、F），各自打 1~10 分，再排个顺序。\n")
    for judge in (strict, lenient):
        feels = {r.key: perceive(judge, r, rng) for r in items}
        ratings = {k: rating_from_perception(judge, f) for k, f in feels.items()}
        order = sorted(feels, key=lambda k: -feels[k])
        rating_text = "  ".join(f"{k}={ratings[k]:>2d}分" for k in sorted(ratings))
        print(f"  {judge.name}（宽严 {judge.rating_offset:+.0f}，尺度 {judge.rating_scale:.1f}）：{rating_text}    排序：{' > '.join(order)}")
    print("""
  两个人给的分数差得很远（同一个 A，严格的人打 5 分，宽松的人打 10 分），但排序基本一致。
  原因很简单：同一个人给两个回答打分，宽严偏移 offset 在两边都加了一次，相减就消掉了；
  尺度 scale 只会放大或缩小差值，不会改变它的正负号。比较只关心“谁更好”，所以天然不受这两者影响。
  注意两个细节：
    · 宽松的人把 A 和 C 都打了 10 分 —— 碰到了量表的上限，分数再也分不出谁好（截断丢掉了信息）。
    · 两人对 A、C 的先后判断不同：A 和 C 本来就很接近，读一遍时的随机感受决定了谁在前。
      这是噪声，不是尺子的问题 —— 第 2、3 讲用“随机效用模型”来描述它。
  （讲义第 2.2 节把这个“抵消”写成了公式；第 4 讲会看到它在 Rasch 模型里的严格形式。）""")


def one_project(rng, offset_sd, scale_range, n_annotators=16, per_annotator=3):
    """
    模拟一次"标注项目"：每个标注员随机分到 3 个回答，读一遍，既打分又排序。
    用三种方法估计 8 个回答的好坏，返回每种方法的排名和真实质量的肯德尔 τ。

    【思路】同一次"感受"既生成打分、又生成排序，所以两种数据包含的信息来源完全相同，
        差别只在于"打分要经过每个人的尺子"。三种估计方法：
          ① 平均分：每个回答收到的分数直接平均（最常见的做法）；
          ② 先标准化再平均：每个人的分数先减去他自己的平均分、除以他自己的标准差（常见的补救办法）；
          ③ 比较：把每个人的排序拆成 3 对比较（A>C、A>F、C>F），用 Bradley–Terry 学出分数。
             InstructGPT 收集数据就是这样做的：请标注员给 4~9 个回答排序，再拆成两两比较。
    【参数】
        rng           ：random.Random
        offset_sd     ：标注员宽严偏移的标准差（0 = 所有人的零点一样）
        scale_range   ：标注员尺度的范围 (最小, 最大)
        n_annotators  ：标注员人数
        per_annotator ：每人读几个回答
    【返回】dict {"平均分": τ, "标准化": τ, "比较": τ}
    【边界】
        · 某个回答一次都没被分到时：平均分方法给它所有分数的平均值；比较方法给它 0 分（即"平均水平"）。
        · 某个标注员的 3 个分数完全相同（标准差为 0）时，标准化后全部记为 0。
    """
    judges = [calibrated_judge(f"标注员{i}", rng.gauss(0.0, offset_sd), rng.uniform(*scale_range))
              for i in range(n_annotators)]
    ratings = {k: [] for k in KEYS}          # 方法 ①
    z_ratings = {k: [] for k in KEYS}        # 方法 ②
    comparisons = []                         # 方法 ③
    for judge in judges:
        items = rng.sample(RESPONSES, per_annotator)       # 不重复地随机抽 3 个
        feels = {r.key: perceive(judge, r, rng) for r in items}
        scores = {k: rating_from_perception(judge, f) for k, f in feels.items()}
        for k, s in scores.items():
            ratings[k].append(s)
        mu = mean(list(scores.values()))
        sd = mean([(s - mu) ** 2 for s in scores.values()]) ** 0.5
        for k, s in scores.items():
            z_ratings[k].append((s - mu) / sd if sd > 0 else 0.0)
        order = sorted(feels, key=lambda k: -feels[k])
        for i in range(len(order)):
            for j in range(i + 1, len(order)):
                comparisons.append((order[i], order[j]))

    all_ratings = [s for k in KEYS for s in ratings[k]]
    est_mean = {k: mean(ratings[k]) if ratings[k] else mean(all_ratings) for k in KEYS}
    est_z = {k: mean(z_ratings[k]) if z_ratings[k] else 0.0 for k in KEYS}
    est_bt = fit_bradley_terry(comparisons, KEYS, l2=0.05)
    return {"平均分": kendall_tau(est_mean, TRUE_QUALITY),
            "标准化": kendall_tau(est_z, TRUE_QUALITY),
            "比较": kendall_tau(est_bt, TRUE_QUALITY)}


def demo_ratings_vs_comparisons(rng, projects=400):
    print_header("演示 3：为什么是“比较”而不是“打分”？")
    demo_two_judges(rng)

    print(f"""
再做一个完整的模拟：16 个标注员，每人随机分到 3 个回答，读一遍，既打 1~10 分又排序（两种数据来自同一次阅读）。
用三种方法估计 8 个回答的好坏，和真实质量比较排名一致性（肯德尔 τ）。每个设置重复 {projects} 次取平均。
""")
    settings = [
        ("所有人尺子一样", 0.0, (1.0, 1.0)),
        ("尺子有点乱", 1.0, (0.6, 1.4)),
        ("尺子很乱", 2.0, (0.4, 1.6)),
        ("尺子非常乱", 3.0, (0.3, 1.7)),
    ]
    print(f"  {pad('设置', 16)} {pad('宽严σ', 6, 'right')} {pad('尺度范围', 10, 'right')} | "
          f"{pad('① 平均分', 9, 'right')} {pad('② 标准化', 9, 'right')} {pad('③ 比较', 9, 'right')}")
    for name, offset_sd, scale_range in settings:
        taus = {"平均分": [], "标准化": [], "比较": []}
        for _ in range(projects):
            for method, tau in one_project(rng, offset_sd, scale_range).items():
                taus[method].append(tau)
        row = " ".join(f"{mean(taus[m]):>9.3f}" for m in ("平均分", "标准化", "比较"))
        rng_text = f"{scale_range[0]:.1f}~{scale_range[1]:.1f}"
        print(f"  {pad(name, 16)} {offset_sd:>6.1f} {rng_text:>10s} | {row}")
    print("""
观察：
  · 尺子一样的时候，直接平均分最好：分数里有“好多少”的信息（9 分和 6 分差得多，7 分和 6 分差得少），
    而比较只知道“谁更好”，把这部分信息丢掉了。比较不是免费的午餐。
  · 尺子一乱，平均分就迅速变差：被严格的人分到的回答吃亏，被宽松的人分到的回答占便宜 ——
    排名的一部分反映的是“运气：谁给你打的分”，而不是回答本身。
  · 比较几乎不受尺子的影响（四行的数字很接近）：同一个人内部做比较，宽严和尺度都被抵消了。
    尺子乱到一定程度以后，比较就反超了平均分。
  · “先标准化再平均”也能消掉宽严和尺度，在这个模拟里和比较差不多。但它要求每个人打足够多的分、
    而且分到的回答好坏搭配均匀（某人恰好分到 3 个都很好的回答，标准化会把其中一个硬拉成“低于平均”）；
    它也挡不住同一个人在一次标注中尺子慢慢漂移（越打越松）—— 并排放着比较则不受影响。
  · 真实的标注员尺子乱不乱？非常乱 —— 这正是 1927 年 Thurstone 提出“比较判断法”的原因，也是今天
    InstructGPT、Llama 2、Chatbot Arena 都收集两两比较（或排序）而不是只收集绝对打分的主要原因（讲义第 2.2 节）。""")


def main():
    rng = random.Random(329)
    demo_handwritten_rules()
    demo_judging_vs_generating(rng)
    demo_ratings_vs_comparisons(rng)


if __name__ == "__main__":
    main()
