"""
=============================================================================
第 9 讲 · 代码 1：Bradley–Terry 模型——从"A 比 B 好"中学出分数
=============================================================================

对应讲义：lectures/lecture09_rlhf_dpo.md 第 1、2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
RLHF 的数据不是"这个回答打几分"，而是"这两个回答哪个更好"。为什么？怎么从比较中学出分数？

Bradley–Terry 模型（1952）假设每个选项 i 有一个潜在的"效用" r_i，并且

    P(i 比 j 好) = σ(r_i - r_j) = 1 / (1 + exp(-(r_i - r_j)))

两个选项的效用差得越多，"好的那个被选中"的概率就越接近 1；效用相同时是 50%。
这其实就是国际象棋 Elo 等级分背后的模型。

本文件：
    1. 用极大似然从比较数据中估计效用（就是逻辑回归）
    2. 看看需要多少比较、能达到多高的预测准确率——以及为什么准确率有上限
    3. 演示为什么"比较"比"打分"更可靠：不同标注者的打分尺度不同，但比较不受影响

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 01_bradley_terry.py      （约 1 秒）
"""

import numpy as np

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


def sigmoid(z):
    """σ(z) = 1 / (1 + e^{-z})。把任意实数压到 (0, 1)。"""
    return 1.0 / (1.0 + np.exp(-z))


# =============================================================================
# 第 1 部分：生成比较数据
# =============================================================================
def generate_comparisons(r_true, n_pairs, rng):
    """
    随机抽 n_pairs 对不同的选项 (i, j)，按 Bradley–Terry 模型生成偏好标签。

    【返回】
        (pairs, labels)：pairs 形状 (n, 2)；labels[k] = 1 表示 pairs[k,0] 被偏好，0 表示 pairs[k,1] 被偏好。

    【注意】即使 r_i > r_j，i 也只是**以一定概率**被偏好——人类的偏好本身就有噪声。
    """
    K = len(r_true)
    i = rng.integers(0, K, size=n_pairs)
    j = (i + rng.integers(1, K, size=n_pairs)) % K          # 保证 j ≠ i
    p = sigmoid(r_true[i] - r_true[j])
    labels = (rng.random(n_pairs) < p).astype(float)
    return np.stack([i, j], axis=1), labels


# =============================================================================
# 第 2 部分：极大似然估计
# =============================================================================
def fit_bradley_terry(pairs, labels, K, lr=0.5, n_iters=2000, l2=1e-3):
    """
    极大似然估计 Bradley–Terry 效用。

    【对数似然】
        L(r) = Σ_k [ y_k log σ(r_i - r_j) + (1 - y_k) log σ(r_j - r_i) ]
    这正是**逻辑回归**：输入是"选项 i 的独热向量减去选项 j 的独热向量"，标签是 y。

    【梯度】（对 r_i）
        ∂L/∂r_i = Σ_{k: i 是左边} (y_k - σ(r_i - r_j)) - Σ_{k: i 是右边} (y_k - σ(r_i' - r_i))
    直觉："实际赢了的次数"减去"模型预测会赢的次数"。赢得比预测多，就提高 i 的效用。

    【不可辨识性】给所有 r_i 加同一个常数，所有概率都不变——效用只能确定到"差一个常数"。
        这里用一点点 L2 正则（把 r 往 0 拉）来固定这个自由度，并在返回前把 r 平移到均值为 0。
        （和第 8 讲 IRL 中"常数奖励无法辨识"是同一件事。）

    【参数】
        lr      : 梯度上升的步长（这里用的是全批量梯度，对平均对数似然求导）。
        n_iters : 迭代次数。
        l2      : L2 正则强度。数据完全可分时（某个选项每次都赢），没有正则的 MLE 会发散到无穷大。

    【返回】长度 K 的效用估计（均值为 0）。
    """
    r = np.zeros(K)
    n = len(labels)
    for _ in range(n_iters):
        diff = r[pairs[:, 0]] - r[pairs[:, 1]]
        err = labels - sigmoid(diff)                       # 每个比较的"残差"
        grad = np.zeros(K)
        np.add.at(grad, pairs[:, 0], err)                   # 左边的选项：+残差
        np.add.at(grad, pairs[:, 1], -err)                  # 右边的选项：-残差
        # 【Python 小知识】np.add.at(数组, 下标, 值)：按下标累加，下标重复时也会正确累加
        #   （普通的 grad[idx] += err 在下标重复时只会加一次，是一个常见的坑）。
        r += lr * (grad / n - l2 * r)
    return r - r.mean()


def accuracy(r, pairs, labels):
    """用效用 r 预测每个比较（r_i > r_j 就预测 i 赢），与标签比较的准确率。"""
    pred = (r[pairs[:, 0]] > r[pairs[:, 1]]).astype(float)
    return float((pred == labels).mean())


def rank_correlation(a, b):
    """斯皮尔曼等级相关：两组分数的"排名"有多一致（1 = 完全一致）。"""
    ra = np.argsort(np.argsort(a))
    rb = np.argsort(np.argsort(b))
    return float(np.corrcoef(ra, rb)[0, 1])


def main():
    rng = np.random.default_rng(0)
    K = 10
    r_true = rng.normal(0, 1.0, size=K)
    r_true -= r_true.mean()

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：Bradley–Terry 模型的直觉")
    print("=" * 78)
    for d in [0.0, 0.5, 1.0, 2.0, 4.0]:
        print(f"  效用差 r_i - r_j = {d:3.1f}  →  P(i 比 j 好) = {sigmoid(d):.3f}")
    print("  → 效用差 1 意味着好的那个大约 73% 的时候被选中；差 4 才接近 98%。")
    print("    人类偏好是**有噪声**的：更好的选项并不总被选中。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print(f"演示 2：从比较中恢复 {K} 个选项的效用")
    print("=" * 78)
    test_pairs, test_labels = generate_comparisons(r_true, 20_000, rng)
    bayes = accuracy(r_true, test_pairs, test_labels)
    print(f"  真实效用：{r_true}")
    print(f"  （即使知道真实效用，预测新比较的准确率上限也只有 {bayes * 100:.1f}%——这是偏好本身的噪声）\n")
    print("   比较数    恢复的效用（均值为 0）                                        排名相关   测试准确率")
    for n_pairs in [30, 100, 300, 1000, 5000]:
        pairs, labels = generate_comparisons(r_true, n_pairs, rng)
        r_hat = fit_bradley_terry(pairs, labels, K)
        print(f"  {n_pairs:5d}     {r_hat}   {rank_correlation(r_hat, r_true):5.3f}     "
              f"{accuracy(r_hat, test_pairs, test_labels) * 100:5.1f}%")
    print("  → 比较越多，恢复的效用越接近真实值，测试准确率逐渐逼近上限。")
    print("    （每一行是一次独立的实验，数据少时结果波动很大，所以不一定严格单调。）")
    print(f"    注意上限只有 {bayes * 100:.0f}% 左右：在真实的 RLHF 中，奖励模型的准确率通常也只有 65%～75%，")
    print("    这并不一定说明模型很差——人类标注者之间的一致率本身也就这么高。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：为什么收集\"比较\"而不是\"打分\"？——每个标注者的尺度不同")
    print("=" * 78)
    print("  模拟 20 个标注者。每人对选项打分 = a × 真实效用 + b + 噪声，")
    print("  其中 a（有人打分宽松、有人严格）和 b（有人习惯打高分、有人习惯打低分）因人而异。")
    n_raters = 20
    a = rng.uniform(0.3, 2.0, size=n_raters)       # 尺度
    b = rng.normal(0, 2.0, size=n_raters)          # 偏移
    for n_labels in [100, 300, 1000]:
        corr_rating, corr_compare = [], []
        for rep in range(20):
            rr = np.random.default_rng(100 + rep)
            # 打分：每个标签 = 某个标注者给某个选项打一个分
            raters = rr.integers(0, n_raters, size=n_labels)
            items = rr.integers(0, K, size=n_labels)
            scores = a[raters] * r_true[items] + b[raters] + rr.normal(0, 1.0, size=n_labels)
            mean_score = np.array([scores[items == k].mean() if (items == k).any() else 0.0 for k in range(K)])
            corr_rating.append(rank_correlation(mean_score, r_true))
            # 比较：每个标签 = 某个标注者比较两个选项；偏移 b 自动抵消，尺度 a 只影响"噪声大小"
            i = rr.integers(0, K, size=n_labels)
            j = (i + rr.integers(1, K, size=n_labels)) % K
            raters = rr.integers(0, n_raters, size=n_labels)
            diff = a[raters] * (r_true[i] - r_true[j]) + rr.normal(0, 1.0, size=n_labels) * np.sqrt(2)
            labels = (diff > 0).astype(float)
            corr_compare.append(rank_correlation(fit_bradley_terry(np.stack([i, j], 1), labels, K), r_true))
        print(f"  {n_labels:5d} 个标签：打分取平均的排名相关 {np.mean(corr_rating):.3f}，"
              f"比较 + Bradley–Terry 的排名相关 {np.mean(corr_compare):.3f}")
    print("  → 同样多的标签，比较恢复的排名更准。")
    print("    打分时，\"习惯打高分\"的偏移 b 直接混进了分数里，谁被\"宽松的人\"评得多谁就占便宜；")
    print("    比较时，同一个人对两个选项的偏移 b 相减抵消了，只剩下\"哪个更好\"这个真正的信号。")
    print("    这就是 RLHF 选择收集成对比较的主要原因之一：人类很难给出**校准一致**的绝对分数，")
    print("    但比较两个东西的好坏容易得多，也一致得多。")


if __name__ == "__main__":
    main()
