"""
=============================================================================
第 1 讲 · 代码 2：贯穿全课的一个公式 —— Bradley–Terry 模型的直觉预览
=============================================================================

对应讲义：lectures/lecture01_intro_motivation.md 第 3.4 节（以及第 2、3、5 讲的预告）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
01 号文件说明了"为什么要从比较里学"。可是人做比较时并不稳定：同一个人、同一对回答，
问两次可能给出不同的答案。我们怎么从这样"有噪声"的数据里学出东西？
这门课的答案是一个 1952 年的公式（Bradley–Terry 模型）：

        P(a 胜过 b) = σ(s_a − s_b) = 1 / (1 + e^(−(s_a − s_b)))

其中 s_a、s_b 是两个选项的"分数"（效用）。这个文件不做严格的推导（那是第 2、3 讲的事），
而是用模拟让你**亲眼看到**这个公式是从哪里来的、它有什么性质、能拿来做什么：

    演示 1：人是不一致的 —— 同一个人问 20 次，答案不总一样；三个选项之间甚至会出现"循环"。
            确定性的模型解释不了这些数据，所以要引入随机性。
    演示 2：随机效用 → σ 函数 —— "感受 = 效用 + Gumbel 噪声，选感受大的"恰好给出 σ(效用差)；
            换成正态噪声，曲线几乎一样（这就是 logit 与 probit 的关系）。
    演示 3：从两个选项到多个选项 —— 同样的模型给出 softmax（Luce 选择公理）和整个排序的概率
            （Plackett–Luce）。参数个数从 M! − 1 降到 M − 1。
    演示 4：只有差值有意义 —— 所有分数加一个常数，预测完全不变；效用的"单位"由噪声的大小决定。
    演示 5：迷你 Chatbot Arena —— 用 BT 模型从一堆投票里给 6 个虚构的模型排名，换算成 Elo 分数，
            再用自助法（bootstrap）看看排名有多可信。这正是 LMArena（原 Chatbot Arena）排行榜的做法。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_one_formula_bradley_terry.py        # 约 3 秒

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

import itertools
import math
import random
import statistics

from toy_world import (BY_KEY, EULER_GAMMA, bar, fit_bradley_terry, make_expert, mean, pad,
                       prefers, print_header, sample_gumbel, sigmoid)


# =============================================================================
# 演示 1：人是不一致的
# =============================================================================
def demo_inconsistency(rng):
    print_header("演示 1：人是不一致的 —— 同一个问题问 20 次")
    expert = make_expert()
    a, c = BY_KEY["A"], BY_KEY["C"]
    picks = [prefers(expert, a, c, rng) for _ in range(20)]
    # 【Python 小知识】sum(一串 True/False) 会数出 True 的个数（True 当作 1）。
    n_a = sum(picks)
    text = "".join("A" if p else "C" for p in picks)
    print(f"让专家在 A（{a.title[:4]}）和 C（{c.title[:4]}）之间选 20 次：{text}")
    print(f"  选 A {n_a} 次，选 C {20 - n_a} 次。")
    print("  如果人的偏好是“确定的”（A 就是比 C 好），他应该 20 次都选 A。")

    # 三个选项之间的"循环"：A>B、B>C、C>A 同时出现
    b = BY_KEY["B"]
    trials = 20000
    cycles = 0
    for _ in range(trials):
        ab = prefers(expert, a, b, rng)       # True: A>B
        bc = prefers(expert, b, c, rng)       # True: B>C
        ca = prefers(expert, c, a, rng)       # True: C>A
        # 循环有两种方向：A>B>C>A，或者反过来 A<B<C<A
        if (ab and bc and ca) or (not ab and not bc and not ca):
            cycles += 1
    print(f"\n再让专家对 A、B、C 两两各比一次（3 次比较），重复 {trials} 次：")
    print(f"  出现“循环”（例如 A>B、B>C、C>A）的比例 = {cycles / trials:.1%}")
    print("""
观察：
  · 同一个人、同一对回答，答案会变。A、C 的质量很接近（0.90 vs 0.80），所以变得尤其厉害。
  · 三个选项之间甚至会出现“石头剪刀布”式的循环 —— 任何一个确定的排名都解释不了它。
  · 但这不代表人的偏好一团乱麻：A 被选中的次数明显更多。合理的模型应该说“A 胜过 C 的**概率**是 p”，
    而不是“A 一定胜过 C”。问题变成：这个概率从哪里来、和什么有关？""")


# =============================================================================
# 演示 2：随机效用 → σ 函数
# =============================================================================
def choose_with_noise(utilities, noise, rng, scale=1.0):
    """
    随机效用模型的一次选择：每个选项的感受 = 效用 + 噪声，选感受最大的那个。

    【参数】
        utilities：dict {选项: 效用}
        noise    ："gumbel" 或 "gauss"
        rng      ：random.Random
        scale    ：噪声的尺度（Gumbel 的尺度参数，或正态分布的标准差）
    【返回】被选中的选项（dict 的一个键）
    【边界】平局（两个感受完全相等）的概率为 0，不需要特别处理。
    【偏好学习联系】第 3 讲会证明：噪声是独立同分布的 Gumbel ⇔ 满足"无关选项独立性"（IIA）⇔
        选择概率是 softmax。这是整门课最重要的定理之一。
    """
    best, best_feel = None, None
    for k, u in utilities.items():
        eps = scale * sample_gumbel(rng) if noise == "gumbel" else rng.gauss(0.0, scale)
        feel = u + eps
        if best_feel is None or feel > best_feel:
            best, best_feel = k, feel
    return best


def demo_random_utility(rng, draws=20000):
    print_header("演示 2：随机效用 → σ 函数（为什么是 Bradley–Terry？）")
    print(f"""假设：每次读一个回答，感受 = 效用 + 噪声；二选一时选感受大的那个。
设两个回答的效用差为 Δ，对每个 Δ 模拟 {draws} 次，数一数“选 a”的比例。
  · Gumbel 噪声：模拟比例应该等于 σ(Δ)（Bradley–Terry / logit 模型）；
  · 正态噪声：取和 Gumbel 相同的方差（标准差 π/√6 ≈ 1.28），理论值是 Φ(Δ / (1.28·√2))（probit 模型）。
""")
    gauss_sd = math.pi / math.sqrt(6)          # 标准 Gumbel 分布的标准差
    phi = statistics.NormalDist().cdf          # 标准正态分布的累积分布函数 Φ
    print(f"  {'Δ':>5s} | {pad('Gumbel 模拟', 11, 'right')} {'σ(Δ)':>7s} | "
          f"{pad('正态 模拟', 9, 'right')} {'Φ(…)':>7s} | σ(Δ) 的图形")
    for delta in [-3, -2, -1, -0.5, 0, 0.5, 1, 2, 3]:
        utils = {"a": delta, "b": 0.0}
        g = sum(choose_with_noise(utils, "gumbel", rng) == "a" for _ in range(draws)) / draws
        n = sum(choose_with_noise(utils, "gauss", rng, gauss_sd) == "a" for _ in range(draws)) / draws
        theory_g = sigmoid(delta)
        theory_n = phi(delta / (gauss_sd * math.sqrt(2)))
        print(f"  {delta:>5.1f} | {g:>11.3f} {theory_g:>7.3f} | {n:>9.3f} {theory_n:>7.3f} | {bar(theory_g, 24)}")
    print("""
观察：
  · Gumbel 噪声的模拟结果和 σ(Δ) 吻合（差别只是模拟的随机误差，约 ±0.005）。
    原因：两个独立的 Gumbel 噪声之差服从“逻辑斯蒂分布”，它的累积分布函数恰好就是 σ。（第 3 讲证明）
  · 正态噪声给出的曲线几乎一样（两列最多差 0.02 左右）。对“二选一”来说，噪声的具体形状不太重要；
    选 Gumbel 主要是因为它让公式变得简单（有封闭形式），并且能自然推广到多选一（演示 3）。
  · 曲线的含义：效用差为 0 → 五五开；效用差为 1 → 约 73%；效用差为 3 → 约 95%。
    “分数差多少”直接翻译成“赢的概率是多少”。这就是 Bradley–Terry 模型：P(a ≻ b) = σ(s_a − s_b)。""")


# =============================================================================
# 演示 3：从两个选项到多个选项
# =============================================================================
def softmax(utilities):
    """
    softmax：把一组效用变成一组概率，P(k) = e^{u_k} / Σ_j e^{u_j}。

    【参数】utilities：dict {选项: 效用}
    【返回】dict {选项: 概率}，概率之和为 1
    【边界】先减去最大值再求 exp，防止效用很大时 exp 溢出（结果不变，因为分子分母同乘了一个常数）。
        这和演示 4 的"只有差值有意义"是同一个道理。
    """
    m = max(utilities.values())
    w = {k: math.exp(u - m) for k, u in utilities.items()}
    z = math.fsum(w.values())
    return {k: v / z for k, v in w.items()}


def plackett_luce_prob(ranking, utilities):
    """
    Plackett–Luce 模型下，一个完整排序出现的概率。

    【思路】把排序看成"一个一个地挑"：先从全部选项里挑第一名（概率 = softmax），
        再从剩下的里挑第二名（又是一个 softmax），……一直到最后。各步概率相乘。
        例：P(A > C > F) = e^{u_A}/(e^{u_A}+e^{u_C}+e^{u_F}) × e^{u_C}/(e^{u_C}+e^{u_F}) × 1
    【参数】ranking：选项列表，从第一名到最后一名；utilities：dict {选项: 效用}
    【返回】float
    【边界】ranking 必须包含 utilities 的全部键且不重复，否则概率没有意义（这里不检查）。
    """
    p = 1.0
    remaining = list(ranking)
    for k in ranking:
        p *= softmax({j: utilities[j] for j in remaining})[k]
        remaining.remove(k)
    return p


def demo_many_options(rng, draws=40000):
    print_header("演示 3：从两个选项到多个选项 —— softmax 与 Plackett–Luce")
    expert = make_expert()
    keys = ["A", "C", "D", "F"]
    utils = {k: expert.utility(BY_KEY[k]) for k in keys}
    counts = {k: 0 for k in keys}
    for _ in range(draws):
        counts[choose_with_noise(utils, "gumbel", rng)] += 1
    probs = softmax(utils)
    print(f"让专家从 4 个回答 {keys} 里挑一个最喜欢的（感受 = 效用 + Gumbel 噪声），模拟 {draws} 次：\n")
    print(f"  {'回答':4s} {'效用':>6s} {pad('模拟比例', 9, 'right')} {'softmax':>8s}")
    for k in keys:
        print(f"  {k:4s} {utils[k]:>6.2f} {counts[k] / draws:>9.3f} {probs[k]:>8.3f}")

    # 一个完整排序的概率
    trio = ["A", "C", "F"]
    u3 = {k: utils[k] for k in trio}
    hits = 0
    for _ in range(draws):
        feels = {k: u + sample_gumbel(rng) for k, u in u3.items()}
        if sorted(trio, key=lambda k: -feels[k]) == trio:
            hits += 1
    print(f"\n  排序 A > C > F 出现的比例：模拟 {hits / draws:.3f}，Plackett–Luce 公式 {plackett_luce_prob(trio, u3):.3f}")

    print("\n  需要多少个参数？（M 个选项）")
    print(f"  {'M':>4s} {pad('任意的随机偏好（M! − 1）', 26, 'right')} {pad('BT / softmax（M − 1）', 22, 'right')}")
    for m in [3, 4, 8, 10, 20]:
        print(f"  {m:>4d} {math.factorial(m) - 1:>26,d} {m - 1:>22d}")
    print("""
观察：
  · 同一个“效用 + Gumbel 噪声”的模型，二选一时给出 σ，多选一时给出 softmax，排序时给出 Plackett–Luce。
    它们是同一个模型在不同数据形式上的样子（第 2 讲：数据的几种形式；第 3 讲：严格的证明）。
  · 一个“完全任意”的随机偏好，要给 M! 种排序各指定一个概率：M = 10 时就要 360 多万个参数，
    根本学不出来。BT 模型只需要每个选项一个分数（再减去 1，因为只有差值有意义，见演示 4）。
    代价是一个很强的假设：“无关选项独立性”（IIA）—— 第 3 讲会看到它什么时候成立、什么时候失效。
  · 你可能注意到 softmax 就是语言模型输出下一个词时用的那个函数 —— 这不是巧合，第 9 讲（DPO）会再见到它。""")


# =============================================================================
# 演示 4：只有差值有意义
# =============================================================================
def demo_identification(rng, draws=40000):
    print_header("演示 4：只有差值有意义 —— 平移不变，单位由噪声决定")
    s = {"A": 1.2, "B": 0.4, "C": -1.6}
    shifted = {k: v + 100.0 for k, v in s.items()}
    print("(1) 平移：给所有分数都加 100，任意两者的胜率不变：")
    for x, y in itertools.combinations(s, 2):
        print(f"    P({x} ≻ {y})：原分数 {sigmoid(s[x] - s[y]):.4f}，加 100 之后 {sigmoid(shifted[x] - shifted[y]):.4f}")

    print("\n(2) 缩放：效用 ×2、噪声也 ×2，模拟出的选择比例不变：")
    doubled = {k: 2 * v for k, v in s.items()}
    for name, utils, scale in [("原来（噪声尺度 1）", s, 1.0), ("效用×2，噪声尺度 2", doubled, 2.0),
                               ("效用×2，噪声尺度 1", doubled, 1.0)]:
        counts = {k: 0 for k in s}
        for _ in range(draws):
            counts[choose_with_noise(utils, "gumbel", rng, scale)] += 1
        text = "  ".join(f"{k}: {counts[k] / draws:.3f}" for k in s)
        print(f"    {pad(name, 22)} 三选一的比例 → {text}")
    print("""
观察：
  · 平移：数据只能告诉我们“谁比谁好多少”，不能告诉我们“好的绝对水平”。所以分数要“固定一个零点”
    （例如让平均分为 0，或者把某个参照选项定为 0）才有唯一解。fit_bradley_terry 就是让平均分为 0。
    第 9 讲会看到：DPO 里的“参考模型 π_ref”扮演的正是这个“零点”的角色。
  · 缩放：效用和噪声一起放大，选择完全不变；只放大效用（噪声不变），选择就变得更“坚决”。
    所以效用的“单位”是由噪声决定的：分数差 1 的意思是“比噪声大一个单位”，即胜率约 73%。
    这也说明为什么 01 号文件里“识货程度”（效用的权重）越大，评委越可靠。
  · 统计学把这种“不同的参数给出完全相同的数据分布”叫做**不可识别**（第 4 讲：识别与 Rashomon 效应）。""")


# =============================================================================
# 演示 5：迷你 Chatbot Arena
# =============================================================================
# 6 个虚构的模型，以及它们"真实的" Elo 分数（Arena 的惯例：基准 1000 分，400 分的差距 ≈ 10 倍胜率比）。
TRUE_ELO = {"Aurora": 1250, "Basalt": 1230, "Cobalt": 1180, "Dune": 1100, "Ember": 1050, "Fjord": 1000}
ELO_PER_LOGIT = 400 / math.log(10)      # BT 分数差 1（自然对数单位）≈ 173.7 Elo 分


def elo_to_logit(r):
    """Elo 分数 → BT 分数（自然对数单位）。Elo 的胜率公式是 1/(1+10^{−Δ/400}) = σ(Δ·ln10/400)。"""
    return (r - 1000) / ELO_PER_LOGIT


def simulate_votes(n, rng):
    """
    模拟 n 张投票：每次随机挑两个不同的模型，用户按 BT 模型投票。

    【参数】n：投票数；rng：random.Random
    【返回】list[(赢家, 输家)]
    【边界】真实的 Arena 里，挑哪两个模型对战并不是完全随机的（新模型会被多安排一些对战），
        用户也会因为问题的类型、回答的长度和格式而投票（第 14 讲"风格控制"）。
    """
    names = sorted(TRUE_ELO)
    votes = []
    for _ in range(n):
        x, y = rng.sample(names, 2)
        if rng.random() < sigmoid(elo_to_logit(TRUE_ELO[x]) - elo_to_logit(TRUE_ELO[y])):
            votes.append((x, y))
        else:
            votes.append((y, x))
    return votes


def to_elo(scores):
    """BT 分数（平均为 0）→ Elo 风格的分数：把 "Fjord" 对齐到 1000 分，便于和真实值比较。"""
    base = scores["Fjord"]
    return {k: 1000 + ELO_PER_LOGIT * (v - base) for k, v in scores.items()}


def bootstrap_elo(votes, rng, rounds=200):
    """
    自助法（bootstrap）：从投票里**有放回地**重新抽样 rounds 次，每次重新拟合，看分数的波动。

    【思路】我们只有一份数据，没法"重做一次实验"看结果变不变。自助法的想法是：把手里的数据当成
        "总体"，从中有放回地抽出同样多的投票，模拟"再收集一次数据"。重复很多次，分数的波动范围
        就近似反映了"如果真的重新收集数据，结果会怎样变化"。Arena 排行榜上的置信区间就是这样算的。
    【参数】votes：投票列表；rng：random.Random；rounds：重抽样次数
    【返回】(区间 dict {模型: (2.5% 分位, 97.5% 分位)}, 第一名是 Aurora 的比例)
    【边界】rounds 太小时区间本身也不稳定；200 次对演示足够，正式报告常用 1000 次以上。
    """
    names = sorted(TRUE_ELO)
    samples = {k: [] for k in names}
    aurora_top = 0
    for _ in range(rounds):
        # 【Python 小知识】rng.choices(列表, k=n)：有放回地抽 n 个（可能重复）；rng.sample 是不放回。
        resampled = rng.choices(votes, k=len(votes))
        elo = to_elo(fit_bradley_terry(resampled, names, l2=1e-4))
        for k in names:
            samples[k].append(elo[k])
        if max(names, key=lambda k: elo[k]) == "Aurora":
            aurora_top += 1
    intervals = {}
    for k in names:
        xs = sorted(samples[k])
        intervals[k] = (xs[int(0.025 * rounds)], xs[int(0.975 * rounds) - 1])
    return intervals, aurora_top / rounds


def demo_arena(rng):
    print_header("演示 5：迷你 Chatbot Arena —— 从投票到排行榜")
    names = sorted(TRUE_ELO)
    print("6 个虚构的模型，真实 Elo 分数：" + "，".join(f"{k} {TRUE_ELO[k]}" for k in names))
    gap = TRUE_ELO["Aurora"] - TRUE_ELO["Basalt"]
    p = sigmoid(elo_to_logit(TRUE_ELO["Aurora"]) - elo_to_logit(TRUE_ELO["Basalt"]))
    print(f"Aurora 和 Basalt 相差 {gap} 分：Aurora 赢 Basalt 的概率是 {p:.3f}。")
    for n in [300, 3000, 30000]:
        votes = simulate_votes(n, rng)
        elo = to_elo(fit_bradley_terry(votes, names, l2=1e-4))
        intervals, top_share = bootstrap_elo(votes, rng)
        print(f"\n  {n} 张投票：")
        print(f"    {pad('模型', 8)} {pad('真实', 6, 'right')} {pad('估计', 6, 'right')}   95% 区间（自助法）")
        for k in sorted(names, key=lambda k: -elo[k]):
            lo, hi = intervals[k]
            print(f"    {k:8s} {TRUE_ELO[k]:>6d} {elo[k]:>6.0f}   [{lo:>5.0f}, {hi:>5.0f}]")
        print(f"    重抽样 200 次中，Aurora 排第一的比例：{top_share:.0%}")
    print("""
观察：
  · 投票越多，估计越接近真实值，区间越窄（大约按 1/√投票数 缩小：投票多 10 倍，区间窄到约 1/3）。
  · 300 张投票时，区间宽达一两百分，前四名的区间互相重叠，Ember 甚至被估计得比 Fjord 还低 —— 排名基本不可信。
  · 3000 张投票时，其余名次都对了，但相差 20 分的 Aurora 和 Basalt 被估计得几乎一样（顺序还颠倒了），
    重抽样里 Aurora 排第一的比例不到一半。要到 30000 张投票，两者的区间才分开。
    所以排行榜上“第一名”和“第二名”之间如果只差十几分、区间又重叠，最好把它们看成并列。
  · Fjord 被固定为 1000 分（因为只有差值有意义，见演示 4），所以它的区间宽度为 0 ——
    这不代表 Fjord 的分数“最确定”，只是我们选了它当参照点。
  · 这就是 LMArena（原 Chatbot Arena）排行榜的基本做法：收集用户的两两投票，拟合 BT 模型，用自助法给区间。
    第 5 讲会讲清楚拟合的原理（最大似然、为什么要正则化、Elo 在线更新和它的关系），
    第 7 讲会讲“下一场该安排谁和谁对战”才能最快分清名次，第 14 讲会讲“风格控制”。""")


def main():
    rng = random.Random(1952)          # 1952：Bradley 和 Terry 发表论文的年份
    demo_inconsistency(rng)
    demo_random_utility(rng)
    demo_many_options(rng)
    demo_identification(rng)
    demo_arena(rng)


if __name__ == "__main__":
    main()
