"""
=============================================================================
第 2 讲 · 代码 5：泛化 —— 从"每个选项一个分数"到"分数是特征的函数"
=============================================================================

对应讲义：lectures/lecture02_choice_data_bt.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
到目前为止，BT 模型给每个选项一个独立的分数（"表格式"模型）。这对国际象棋棋手、排行榜上的模型
没问题 —— 选项是固定的。可是 RLHF 的奖励模型要给**从没见过的**回答打分：语言模型每次生成的
回答都是新的。表格里没有这一行，表格式模型就只能瞎猜。

解决办法：让分数成为选项**特征**的函数，s(y) = w·φ(y)。只要新回答的特征算得出来，就能打分。
这就是"线性 BT"，也就是对"特征差"做逻辑回归；把 φ 换成一个神经网络，就是真实的奖励模型。

这个文件造了一个更大的回答池（120 个回答，每个有 6 个特征），做五个实验：

    演示 1：表格式 vs 线性 —— 在见过的回答上两者都行；在没见过的回答上，表格式只能瞎猜。
    演示 2：学习曲线 —— 数据少时线性模型赢（参数少）；数据很多时，表格式在见过的回答上反超
            （它能学到特征没有描述的"个性"）。偏差与方差的权衡。
    演示 3：缺了重要的特征 —— 模型学不到它，还会把它的作用错算到相关的特征头上（遗漏变量偏差）。
    演示 4：众包数据 + 特征 = 学到"长度权重"。把长度推到训练数据从没见过的范围，
            奖励模型给出离谱的高分 —— 这是第 1 讲说的过度优化的第二个来源：外推。
    演示 5：怎样划分训练集和测试集，决定了你测的是哪一种泛化。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 05_generalization.py        # 约 2 秒

只用到 Python 标准库，以及第 1 讲的 toy_world.py、本目录的 choice_data.py。
"""

import math
import random
from dataclasses import dataclass

from choice_data import dot, fit_choice_model, fit_linear_bt, pair_to_event
from toy_world import mean, pad, print_header, sigmoid

FEATURES = ["正确", "清晰", "有例子", "log长度", "花哨", "详细"]


@dataclass
class PoolResponse:
    """
    回答池里的一个回答。

    【字段】
        rid      ：编号（字符串，例如 "r007"）
        feats    ：特征列表，顺序和 FEATURES 一致
        quirk    ：特征没有描述到的"个性"（例如某个例子特别贴切），只有专家感受得到。上帝视角。
    """
    rid: str
    feats: list
    quirk: float

    def expert_utility(self):
        """
        专家心里的效用 = 3·正确 + 2.5·清晰 + 1·有例子 + 个性。不看长度、花哨、详细。

        【边界】"个性"这一项是线性模型永远学不到的部分（它不在特征里）。
        """
        correct, clear, example = self.feats[0], self.feats[1], self.feats[2]
        return 3.0 * correct + 2.5 * clear + 1.0 * example + self.quirk

    def crowd_utility(self):
        """众包标注员心里的效用 = 专家效用 + 0.8·log长度 + 2·花哨（和第 1 讲的偏差一样）。"""
        return self.expert_utility() + 0.8 * self.feats[3] + 2.0 * self.feats[4]


def make_pool(n, rng, prefix="r"):
    """
    生成 n 个回答。

    【思路】特征之间故意有相关性：越清晰的回答越可能是正确的（P(正确) = 0.1 + 0.8·清晰），
        这样演示 3 才会出现"遗漏变量偏差"。长度在 60~1200 字之间（对数均匀），详细程度和长度正相关。
    【参数】n：个数；rng：random.Random；prefix：编号前缀
    【返回】list[PoolResponse]
    """
    pool = []
    for i in range(n):
        clear = rng.random()
        correct = 1.0 if rng.random() < 0.1 + 0.8 * clear else 0.0
        example = 1.0 if rng.random() < 0.5 else 0.0
        log_len = rng.uniform(math.log(60 / 200), math.log(1200 / 200))
        fancy = rng.random() ** 2                       # 大多数回答不怎么花哨
        detail = min(1.0, max(0.0, 0.5 + 0.3 * log_len + rng.gauss(0, 0.15)))
        pool.append(PoolResponse(f"{prefix}{i:03d}", [correct, clear, example, log_len, fancy, detail],
                                 rng.gauss(0.0, 0.5)))
    return pool


def compare(pool_a, pool_b, n, rng, who="expert"):
    """
    模拟 n 次比较：每次从 pool_a 抽一个、从 pool_b 抽一个（不同的回答），按 BT 模型比较。

    【参数】pool_a、pool_b：回答列表（可以是同一个列表）；n：次数；rng；who："expert" 或 "crowd"
    【返回】list[(赢家, 输家)]，元素是 PoolResponse
    """
    out = []
    for _ in range(n):
        a = rng.choice(pool_a)
        b = rng.choice(pool_b)
        while b is a:
            b = rng.choice(pool_b)
        ua = a.expert_utility() if who == "expert" else a.crowd_utility()
        ub = b.expert_utility() if who == "expert" else b.crowd_utility()
        out.append((a, b) if rng.random() < sigmoid(ua - ub) else (b, a))
    return out


def fit_linear(comparisons, cols=None, l2=1e-3):
    """
    拟合线性 BT。cols 指定用哪些特征（下标列表），默认全部。

    【返回】dict {特征名: 权重}
    """
    cols = list(range(len(FEATURES))) if cols is None else cols
    diffs = [[w.feats[c] - l.feats[c] for c in cols] for w, l in comparisons]
    weights = fit_linear_bt(diffs, l2=l2)
    return {FEATURES[c]: wt for c, wt in zip(cols, weights)}


def linear_score(resp, weights):
    """线性模型给一个回答的分数 w·φ(y)（只用 weights 里有的特征）。"""
    return math.fsum(weights[name] * resp.feats[FEATURES.index(name)] for name in weights)


def fit_table(comparisons, pool):
    """拟合表格式 BT：每个回答一个分数。没出现在比较里的回答分数为 0（平均水平）。"""
    events = [pair_to_event(w.rid, l.rid) for w, l in comparisons]
    return fit_choice_model(events, [r.rid for r in pool], l2=1e-3, tol=1e-6)


def ranking_accuracy(score_fn, pairs):
    """
    排序正确率：在测试的回答对上，模型认为更好的那个，是否真的是专家效用更高的那个。

    【参数】score_fn：回答 → 分数 的函数；pairs：list[(回答, 回答)]
    【返回】0~1 的 float。分数相同时算对一半（相当于抛硬币）。
    【边界】这里用专家的**真实效用**判断对错，而不是又一次有噪声的比较，所以上限是 100%。
    """
    hits = 0.0
    for a, b in pairs:
        sa, sb = score_fn(a), score_fn(b)
        truth = a.expert_utility() > b.expert_utility()
        if sa == sb:
            hits += 0.5
        elif (sa > sb) == truth:
            hits += 1.0
    return hits / len(pairs)


def random_pairs(pool, n, rng):
    """从 pool 里随机抽 n 对不同的回答。"""
    out = []
    for _ in range(n):
        a, b = rng.sample(pool, 2)
        out.append((a, b))
    return out


# =============================================================================
# 演示 1 与 2
# =============================================================================
def demo_table_vs_linear(rng, seen, unseen):
    print_header("演示 1：表格式 BT vs 线性 BT —— 没见过的回答怎么办？")
    data = compare(seen, seen, 800, rng)
    table = fit_table(data, seen)
    weights = fit_linear(data)
    test_seen = random_pairs(seen, 3000, rng)
    test_unseen = random_pairs(unseen, 3000, rng)
    tab_fn = lambda r: table.get(r.rid, 0.0)
    lin_fn = lambda r: linear_score(r, weights)
    print(f"60 个“见过的”回答之间做 800 次专家比较，用来训练；另外 60 个回答从没出现在训练数据里。\n")
    print(f"  {pad('模型', 14)} {pad('见过的回答', 12, 'right')} {pad('没见过的回答', 14, 'right')}")
    print(f"  {pad('表格式 BT', 14)} {ranking_accuracy(tab_fn, test_seen):>12.1%} {ranking_accuracy(tab_fn, test_unseen):>14.1%}")
    print(f"  {pad('线性 BT', 14)} {ranking_accuracy(lin_fn, test_seen):>12.1%} {ranking_accuracy(lin_fn, test_unseen):>14.1%}")
    print("\n  线性 BT 学到的权重（专家的真实权重：正确 3、清晰 2.5、有例子 1、其余 0）：")
    print("    " + "  ".join(f"{k}: {v:+.2f}" for k, v in weights.items()))
    print("""
观察：
  · 表格式模型在没见过的回答上只有 50%：它对每个新回答都一无所知，只能抛硬币。
  · 线性模型在两边差不多一样好：它学的是“什么样的回答好”（正确、清晰、有例子），而不是“哪几个回答好”，
    所以对新回答也能打分。这就是奖励模型的基本思路：s(x, y) = w·φ(x, y)，真实系统里 φ 是一个大语言模型
    读完提示词和回答以后的表示，w 是最后一层（第 6 讲）。
  · 正确、清晰、有例子三个权重大体学回来了。其余三个权重本该是 0（专家不看它们），800 次比较还不够
    把它们估准；演示 4 用 4000 次比较时，它们就小得多了。""")


def demo_learning_curve(rng, seen, unseen, sizes=(25, 100, 400, 1600, 6400), reps=3):
    print_header("演示 2：学习曲线 —— 数据少时谁好，数据多时谁好？")
    test_seen = random_pairs(seen, 3000, rng)
    test_unseen = random_pairs(unseen, 3000, rng)
    print(f"每个数据量重复 {reps} 次取平均。表格式模型有 60 个参数，线性模型有 6 个。\n")
    print(f"  {pad('比较次数', 8, 'right')} {pad('表格式（见过）', 16, 'right')} {pad('线性（见过）', 14, 'right')} "
          f"{pad('线性（没见过）', 16, 'right')}")
    for n in sizes:
        tab, lin_s, lin_u = [], [], []
        for _ in range(reps):
            data = compare(seen, seen, n, rng)
            table = fit_table(data, seen)
            weights = fit_linear(data)
            tab.append(ranking_accuracy(lambda r: table.get(r.rid, 0.0), test_seen))
            lin_s.append(ranking_accuracy(lambda r: linear_score(r, weights), test_seen))
            lin_u.append(ranking_accuracy(lambda r: linear_score(r, weights), test_unseen))
        print(f"  {n:>8d} {mean(tab):>16.1%} {mean(lin_s):>14.1%} {mean(lin_u):>16.1%}")
    print("""
观察：
  · 数据少时，线性模型遥遥领先：6 个参数比 60 个参数好估计得多（方差小）。
  · 数据多时，表格式模型在见过的回答上反超：线性模型永远学不到特征没描述的“个性”（偏差），
    而表格式模型给每个回答单独一个分数，数据够多就能把“个性”也学进去。
  · 这就是**偏差–方差权衡**：模型越灵活，需要的数据越多；模型越简单，学得越快但上限越低。
    真实的奖励模型是一个很大的神经网络（非常灵活），所以需要大量数据，也很容易过拟合（第 5、6 讲）。""")


# =============================================================================
# 演示 3：缺了重要的特征
# =============================================================================
def demo_missing_feature(rng, seen, unseen):
    print_header("演示 3：缺了“正确”这个特征 —— 学不到，还会算错账")
    data = compare(seen, seen, 3000, rng)
    full = fit_linear(data)
    missing = fit_linear(data, cols=[1, 2, 3, 4, 5])
    test = random_pairs(unseen, 3000, rng)
    differ = [(a, b) for a, b in test if a.feats[0] != b.feats[0]]
    same = [(a, b) for a, b in test if a.feats[0] == b.feats[0]]
    print(f"  {pad('模型', 16)} {pad('清晰的权重', 10, 'right')} {pad('有例子的权重', 12, 'right')} {pad('全部测试对', 12, 'right')} "
          f"{pad('正确性不同的对', 16, 'right')} {pad('正确性相同的对', 16, 'right')}")
    for name, w in [("全部特征", full), ("去掉“正确”", missing)]:
        fn = lambda r, w=w: linear_score(r, w)
        print(f"  {pad(name, 16)} {w['清晰']:>10.2f} {w['有例子']:>12.2f} {ranking_accuracy(fn, test):>12.1%} "
              f"{ranking_accuracy(fn, differ):>16.1%} {ranking_accuracy(fn, same):>16.1%}")
    print("""
观察：
  · 去掉“正确”以后，模型在“一个对、一个错”的回答对上准确率大幅下降：它根本看不见对错。
    这就是第 1 讲手写规则的困境 —— 特征里没有的东西，模型学不到。
  · 更隐蔽的是：“清晰”的权重变大了。因为在这批数据里，清晰的回答更常是正确的，模型把“正确”的功劳
    记到了“清晰”头上。统计学把这叫**遗漏变量偏差**。后果是：一个写得很清晰但内容错误的回答，
    会被奖励模型高估 —— 如果策略学会了“写得清晰但不管对错”，奖励模型挡不住。
  · 和“正确”无关的“有例子”，权重反而变小了。这是逻辑回归特有的现象：漏掉一个重要的变量，相当于
    多了一份“解释不了的噪声”，模型对所有比较都更没把握，其余的权重整体缩小（线性回归没有这个现象）。
  · 真实的奖励模型用整个语言模型的表示做特征，"看不见对错"的问题没有这么极端，但同样存在：
    奖励模型很难核实事实，往往只能依赖“看起来对不对”的线索（第 14 讲）。""")


# =============================================================================
# 演示 4：众包数据与外推
# =============================================================================
def demo_extrapolation(rng, seen):
    print_header("演示 4：众包数据学到了“长度权重”，外推时给出离谱的高分")
    crowd_w = fit_linear(compare(seen, seen, 4000, rng, who="crowd"))
    expert_w = fit_linear(compare(seen, seen, 4000, rng, who="expert"))
    print("  用众包数据和专家数据分别拟合线性 BT，学到的权重：")
    print("    众包：" + "  ".join(f"{k}: {v:+.2f}" for k, v in crowd_w.items()))
    print("    专家：" + "  ".join(f"{k}: {v:+.2f}" for k, v in expert_w.items()))

    best = max(seen, key=lambda r: r.expert_utility())
    lengths = [math.exp(r.feats[3]) * 200 for r in seen]
    print(f"\n  训练数据里回答的长度在 {min(lengths):.0f}~{max(lengths):.0f} 字之间。现在造几个“假想”的回答：")
    print(f"  {pad('回答', 40)} {pad('众包奖励', 9, 'right')} {pad('专家奖励', 9, 'right')}")
    cases = [("训练集里专家最喜欢的回答", best.feats),
             ("一个正确、清晰度中等、没例子的普通回答", [1.0, 0.5, 0.0, 0.0, 0.0, 0.5])]
    for words in [1200, 5000, 20000]:
        # 错误、不清晰、没例子，但很花哨、很长
        cases.append((f"错误、含糊、满屏格式，{words} 字", [0.0, 0.2, 0.0, math.log(words / 200), 1.0, 1.0]))
    for name, feats in cases:
        fake = PoolResponse("x", feats, 0.0)
        print(f"  {pad(name, 40)} {linear_score(fake, crowd_w):>9.2f} {linear_score(fake, expert_w):>9.2f}")
    print("""
观察：
  · 众包奖励模型学到了正的“log长度”和“花哨”权重 —— 它忠实地学到了标注员的偏差（第 1 讲）。
  · 线性模型说“越长越好”，而且没有上限：把长度推到训练数据从没出现过的 5000 字、20000 字，奖励还在涨，
    一个错误、含糊的回答就超过了一个正确的普通回答（专家眼里两者天差地别），并且一路逼近最好的回答。
    训练数据里根本没有这么长的回答，模型是在**外推**。
  · 这正是第 1 讲提到的、真实 RLHF 里过度优化的第二个来源：策略会去奖励模型没见过的地方（极长的回答、
    奇怪的格式），奖励模型在那里的分数毫无根据。Singhal 等（2023）观察到 RLHF 让回答越来越长；
    第 8 讲的 KL 约束、第 14 讲的风格控制都是应对办法。""")


# =============================================================================
# 演示 5：怎样划分训练集和测试集
# =============================================================================
def demo_splits(rng, pool):
    print_header("演示 5：怎样划分训练 / 测试，决定了你测的是哪一种泛化")
    data = compare(pool, pool, 6000, rng)
    # 划分 A：随机打乱"比较"，80% 训练、20% 测试 —— 测试里的回答大多在训练里出现过
    shuffled = list(data)
    rng.shuffle(shuffled)
    cut = int(0.8 * len(shuffled))
    train_a, test_a = shuffled[:cut], shuffled[cut:]
    # 划分 B：先划分"回答"，测试比较里的两个回答都没在训练里出现过
    ids = [r.rid for r in pool]
    rng.shuffle(ids)
    test_ids = set(ids[:len(ids) // 5])
    train_b = [(w, l) for w, l in data if w.rid not in test_ids and l.rid not in test_ids]
    test_b = [(w, l) for w, l in data if w.rid in test_ids and l.rid in test_ids]

    print(f"  {pad('划分方式', 32)} {pad('训练/测试条数', 14, 'right')} {pad('表格式', 8, 'right')} {pad('线性', 8, 'right')}")
    for name, tr, te in [("按比较随机划分", train_a, test_a), ("按回答划分（测试回答全是新的）", train_b, test_b)]:
        table = fit_table(tr, pool)
        weights = fit_linear(tr)
        pairs = [(w, l) for w, l in te]
        acc_t = ranking_accuracy(lambda r: table.get(r.rid, 0.0), pairs)
        acc_l = ranking_accuracy(lambda r: linear_score(r, weights), pairs)
        print(f"  {pad(name, 32)} {pad(f'{len(tr)}/{len(te)}', 14, 'right')} {acc_t:>8.1%} {acc_l:>8.1%}")
    print("""
观察：
  · 按比较随机划分时，表格式模型看起来很不错 —— 因为测试里的回答几乎都在训练里出现过。
    按回答划分，表格式模型立刻掉到 50%，而线性模型几乎不受影响。
  · 两种划分回答的是不同的问题：“对已有的回答，再来一次比较，能预测对吗？”vs“对新的回答，能打对分吗？”
    奖励模型要回答的是后者，所以 RLHF 里通常**按提示词**划分训练和测试（同一个提示词的回答不跨越两边），
    这和演示 2 里“以独立的单元为单位”是同一个原则（第 5 讲：交叉验证）。""")


def main():
    rng = random.Random(2017)        # 2017：Christiano 等提出 RLHF 的年份
    pool = make_pool(120, rng)
    seen, unseen = pool[:60], pool[60:]
    demo_table_vs_linear(rng, seen, unseen)
    demo_learning_curve(rng, seen, unseen)
    demo_missing_feature(rng, seen, unseen)
    demo_extrapolation(rng, seen)
    demo_splits(rng, pool)


if __name__ == "__main__":
    main()
