"""
=============================================================================
第 5 讲 · 代码 3：Elo 等级分 = 随机梯度上升
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
国际象棋的 Elo 等级分（1960 年代起使用）在每场比赛之后只更新两位选手：

        s_赢家 ← s_赢家 + K·(1 − p)，   s_输家 ← s_输家 − K·(1 − p)，   p = 赛前预测的赢家胜率

这正是 BT 对数似然**对一条数据**的梯度上升（随机梯度上升，SGD），K 就是步长。它不需要存下所有比赛、
不需要反复迭代，来一场算一场 —— 非常适合比赛源源不断的场景（棋类、游戏、模型排行榜）。

    演示 1：K 的权衡 —— K 大，适应快但一直在抖；K 小，稳定但要很久才追上。和批量的最大似然比较。
    演示 2：顺序 —— 同样的比赛换个顺序处理，Elo 的结果就不一样；最大似然完全不受影响。
            （LMSYS 的 Chatbot Arena 在 2023 年底从在线 Elo 换成了 BT 最大似然，原因之一就是这个。）
    演示 3：逐渐变小的步长（Robbins–Monro）—— Elo 能收敛到最大似然。
    演示 4：实力会变 —— 一位选手在不断进步。固定 K 的 Elo 能跟上，"一次性拟合所有历史数据"的最大似然跟不上。

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

需要 numpy。只依赖本目录的 bt_numpy.py。
"""

import math

import numpy as np

from bt_numpy import elo, fit_newton, pad, print_header, sigmoid, simulate_bt

ELO_UNIT = 400 / math.log(10)     # 自然对数单位的分数 × 173.7 = 国际象棋的 Elo 分


def rmse(a, b):
    """两组分数（都先平移到平均值为 0）的均方根误差。"""
    a = a - a.mean()
    b = b - b.mean()
    return float(np.sqrt(np.mean((a - b) ** 2)))


# =============================================================================
# 演示 1：K 的权衡
# =============================================================================
def demo_k(rng):
    print_header("演示 1：K 因子的权衡 —— 适应快 vs 估计稳")
    M = 20
    true_s = rng.normal(0, 1, size=M)
    true_s -= true_s.mean()
    n = 20000
    i, j, y = simulate_bt(true_s, n, rng)
    checkpoints = [500, 2000, 5000, 20000]
    print(f"{M} 位选手，依次进行 {n} 场比赛。报告在第 t 场之后，估计分数和真实分数的均方根误差（以 Elo 分计）：\n")
    print(f"  {pad('方法', 22)} " + " ".join(pad(f'第{c}场', 10, 'right') for c in checkpoints))
    for k in [0.01, 0.05, 0.2, 0.5]:
        errs = [rmse(elo(i[:c], j[:c], y[:c], M, k=k), true_s) * ELO_UNIT for c in checkpoints]
        print(f"  {pad(f'Elo，K = {k:g}（≈{k * ELO_UNIT:.0f} 分）', 22)} " + " ".join(f"{e:>10.0f}" for e in errs))
    errs = [rmse(fit_newton(i[:c], j[:c], y[:c], M, l2=1e-4)[0], true_s) * ELO_UNIT for c in checkpoints]
    print(f"  {pad('最大似然（用全部历史）', 22)} " + " ".join(f"{e:>10.0f}" for e in errs))
    print("""
观察：
  · K 小（0.01）：每场只挪一点点，500 场之后离真实值还很远（追得慢），但最后很稳。
  · K 大（0.5）：很快就追上了，可之后一直在真实值附近大幅抖动，比赛再多误差也降不下去 ——
    每场比赛的结果都有很大的随机性，K 大意味着“太相信最近的一场比赛”。
  · 最大似然每次都用**全部**历史数据重新拟合，在每个时刻都几乎是最好的；Elo 的优势是每场比赛只需 O(1) 的计算。
  · 国际象棋联合会给新棋手用大的 K（快速定级），给老棋手用小的 K（稳定）—— 正是这个权衡。""")


# =============================================================================
# 演示 2：顺序
# =============================================================================
def demo_order(rng):
    print_header("演示 2：同样的比赛，换个顺序 —— Elo 变了，最大似然没变")
    M = 10
    true_s = np.linspace(-1, 1, M)
    i, j, y = simulate_bt(true_s, 3000, rng)
    mle, _ = fit_newton(i, j, y, M, l2=1e-4)
    results = []
    for _ in range(5):
        order = rng.permutation(len(y))
        results.append(elo(i, j, y, M, k=0.2, order=order))
        mle_shuffled, _ = fit_newton(i[order], j[order], y[order], M, l2=1e-4)
    res = np.array(results) * ELO_UNIT
    print("把同样的 3000 场比赛随机打乱顺序 5 次，分别跑一遍 Elo（K = 0.2）。选手 0 和选手 9 的 Elo 分：\n")
    for t, r in enumerate(res):
        print(f"    第 {t + 1} 种顺序：选手 0 {r[0]:>7.0f}，选手 9 {r[9]:>7.0f}")
    print(f"\n  5 种顺序下，各选手分数的极差（最高 − 最低）平均为 {np.mean(res.max(0) - res.min(0)):.0f} 分。")
    print(f"  最大似然：打乱顺序前后的分数最大差别 {np.max(np.abs(mle - mle_shuffled)) * ELO_UNIT:.1e} 分。")
    print("""
观察：
  · Elo 的结果依赖于比赛的顺序：最近的几场比赛影响最大。同样的数据，换个顺序，排名可能就变了。
  · 最大似然只依赖“谁赢了谁几次”（第 2 讲第 4.1 节），和顺序完全无关。
  · 对模型排行榜来说，这是一个实际的问题：模型的“实力”在评估期间并不会变化，没有理由让后来的投票
    比先来的投票更重要。LMSYS 在 2023 年 12 月把 Chatbot Arena 的排行榜从在线 Elo 改成了 BT 最大似然
    （加自助法置信区间），给出的理由正是稳定性。""")


# =============================================================================
# 演示 3：逐渐变小的步长
# =============================================================================
def demo_robbins_monro(rng):
    print_header("演示 3：步长逐渐变小 —— Elo 收敛到最大似然")
    M = 20
    true_s = rng.normal(0, 1, size=M)
    n = 40000
    i, j, y = simulate_bt(true_s, n, rng)
    mle, _ = fit_newton(i, j, y, M, l2=1e-6)
    print(f"{M} 位选手、{n} 场比赛（处理两遍，共 {2 * n} 步）。比较 Elo 的结果和**同一份数据**上的最大似然的距离：\n")
    order = np.concatenate([np.arange(n), np.arange(n)])
    for name, k, sched in [("固定 K = 0.2", 0.2, None),
                           ("固定 K = 0.02", 0.02, None),
                           ("K_t = 2 / (10 + t/M)", None, lambda t: 2.0 / (10.0 + t / M))]:
        s = elo(i, j, y, M, k=k if k else 0.0, order=order, schedule=sched)
        print(f"  {pad(name, 22)} 和最大似然的均方根距离 {rmse(s, mle) * ELO_UNIT:>6.1f} 分")
    print("""
观察：
  · 固定 K 时，Elo 永远在最大似然附近抖动，抖动的幅度和 K 成正比。
  · 让 K 随时间按 1/t 的速度变小（满足 Robbins–Monro 条件：ΣK_t = ∞，ΣK_t² < ∞），SGD 会收敛到批量的最优解。
    直觉：一开始大步走、快速接近；越往后越相信“已经积累的估计”，每场新比赛的权重越来越小。
  · 这说明 Elo 和最大似然不是两种不同的模型，而是**同一个模型的两种优化方法**（第 2 节的 BT 似然）。""")


# =============================================================================
# 演示 4：实力会变
# =============================================================================
def demo_drift(rng):
    print_header("演示 4：实力会变 —— 一位选手在不断进步")
    M = 10
    base = np.linspace(-1, 1, M)
    n = 10000
    i = rng.integers(0, M, size=n)
    j = rng.integers(0, M - 1, size=n)
    j = np.where(j >= i, j + 1, j)
    y = np.empty(n, dtype=int)
    rising = 0                                   # 选手 0：从最弱（−1）一路进步到 +2
    for t in range(n):
        s_now = base.copy()
        s_now[rising] = -1.0 + 3.0 * t / n
        y[t] = 1 if rng.random() < sigmoid(s_now[i[t]] - s_now[j[t]]) else 0
    final_true = base.copy()
    final_true[rising] = 2.0
    print(f"10 位选手、{n} 场比赛。选手 0 的真实实力从 −1 线性增长到 +2（以自然对数单位），其他人不变。")
    print("比赛结束时，各方法对选手 0 的估计（都换成“选手 0 − 其他人平均”的相对分，以 Elo 分计）：\n")
    rel = lambda s: (s[rising] - np.mean(np.delete(s, rising))) * ELO_UNIT
    print(f"  {pad('真实（比赛结束时）', 26)} {rel(final_true):>7.0f}")
    for k in [0.02, 0.1, 0.3]:
        print(f"  {pad(f'Elo，K = {k:g}', 26)} {rel(elo(i, j, y, M, k=k)):>7.0f}")
    print(f"  {pad('最大似然（全部历史）', 26)} {rel(fit_newton(i, j, y, M, l2=1e-4)[0]):>7.0f}")
    last = slice(n - 2000, n)
    print(f"  {pad('最大似然（只用最近 2000 场）', 26)} {rel(fit_newton(i[last], j[last], y[last], M, l2=1e-4)[0]):>7.0f}")
    print("""
观察：
  · 最大似然假设实力不变，把全部历史平均起来，估计出的是选手 0 “整个时期的平均实力”，远低于他现在的水平。
  · Elo 天然地“遗忘”旧比赛：K 越大，遗忘得越快、跟得越紧（但也越抖，见演示 1）。
  · “只用最近的数据”的最大似然是另一种折中（滑动窗口）。更系统的做法是把“实力随时间变化”写进模型
    （例如 Glicko、TrueSkill，或者把分数建模成随机游走），这需要贝叶斯的工具（第 6 讲）。
  · 对 LLM 的启示：偏好会随时间漂移（用户的口味、模型的版本都在变），奖励模型和排行榜需要决定“记住多久”。""")


def main():
    rng = np.random.default_rng(1960)
    demo_k(rng)
    demo_order(rng)
    demo_robbins_monro(rng)
    demo_drift(rng)


if __name__ == "__main__":
    main()
