算法 /

从 Elo 到 Glicko:训练分系统的数学骨架

SWUCPC 的训练分使用自研的 Glicko 变体。这篇文章整理从 Elo 到 Glicko 的数学直觉、一次批量更新的公式,以及定级赛在工程上如何落位。

2026-10-24 约 5 分钟阅读
算法训练系统

训练分不是奖杯。它更像一个温度计:用尽可能少的假设,描述一个选手当前的水平,以及这个描述本身有多不确定。

SWUCPC 的训练分用了自研的 Glicko 变体,支持定级赛。这篇笔记记录设计时用到的数学骨架,以及几个工程上必须做的取舍。完整的参数、阈值和实现细节不在这里展开。

Elo 的核心:只记录一个期望

Elo 用分数差描述两名选手的预期胜率。设 A、B 的分数分别为 RAR_A、RBR_B,A 的期望得分为:

EA=11+10(RB−RA)/400E_A = \frac{1}{1 + 10^{(R_B - R_A)/400}}

一局结束后,分数按“结果减期望”的方向移动:

RA′=RA+K (SA−EA)R_A' = R_A + K\,(S_A - E_A)

其中 SA∈{0,0.5,1}S_A \in \{0, 0.5, 1\} 是实际得分,KK 控制单局更新的幅度。

这个模型足够简单,但它有一个隐含假设:分数已经足够可信。新队员和已经打了两年的人,用的是同一套更新力度。显然不对。

Glicko 多了一个“可信度”

Glicko 为每位选手增加一个评分偏差(rating deviation,RDRD)。RDRD 越大,表示系统越不确定这名选手的真实水平。

对局质量也和对手的 RDRD 有关。对手分数很不可信时,这场比赛不应该被赋予太高权重。常用的权重函数是:

g(RDj)=11+3q2RDj2/π2,q=ln⁡10400g(RD_j) = \frac{1}{\sqrt{1 + 3q^2 RD_j^2 / \pi^2}}, \qquad q = \frac{\ln 10}{400}

于是 A 对 B 的期望得分从固定的 400 分差,变成了带不确定度的形式:

Ej=11+10−g(RDj)(r−rj)/400E_j = \frac{1}{1 + 10^{-g(RD_j)(r - r_j)/400}}

接下来是两个同时更新的量。为了表达方便,把一场比赛记为 (rj,RDj,sj)(r_j, RD_j, s_j),其中 sjs_j 是实际得分。

先计算信息量:

d2=[q2∑jg(RDj)2Ej(1−Ej)]−1d^2 = \left[q^2 \sum_j g(RD_j)^2 E_j(1-E_j)\right]^{-1}

然后得到新的分数与偏差:

r′=r+q1/RD2+1/d2∑jg(RDj)(sj−Ej)r' = r + \frac{q}{1/RD^2 + 1/d^2}\sum_j g(RD_j)(s_j - E_j) RD′=11/RD2+1/d2RD' = \sqrt{\frac{1}{1/RD^2 + 1/d^2}}

直观上,这就是一次带精度权重的贝叶斯更新:已有的分数带着自身的可信度,新比赛带来新的信息量,两者合并。打得越多、越稳定,RDRD 越小,后续单场变化也会随之收敛。

Glicko 的完整版本还会对波动率 σ\sigma 做迭代估计。上面的三段式是一个足够解释“为什么这样更新”的简化版本。

把更新写成代码

实际系统一般会按一个评分周期批量处理多场比赛。下面的 Python 片段抓住核心的两个状态量,省去了波动率更新:

from dataclasses import dataclass
import math

Q = math.log(10) / 400


@dataclass(frozen=True)
class Rating:
    r: float
    rd: float


def g(rd: float) -> float:
    return 1 / math.sqrt(1 + 3 * Q**2 * rd**2 / math.pi**2)


def expected(player: Rating, opponent: Rating) -> float:
    return 1 / (1 + 10 ** (-g(opponent.rd) * (player.r - opponent.r) / 400))


def update(player: Rating, games: list[tuple[Rating, float]]) -> Rating:
    """games: [(对手, 实际得分)],得分为 0 / 0.5 / 1。"""
    weighted = [(g(o.rd), expected(player, o), score) for o, score in games]

    precision = 1 / player.rd**2 + Q**2 * sum(
        weight**2 * exp * (1 - exp) for weight, exp, _ in weighted
    )

    new_r = player.r + (Q / precision) * sum(
        weight * (score - exp) for weight, exp, score in weighted
    )
    new_rd = math.sqrt(1 / precision)
    return Rating(new_r, min(new_rd, 350))

代码里有两个细节:

  1. precision 是 1/RD2+1/d21/RD^2 + 1/d^2,不是 RD2+d2RD^2 + d^2。
  2. 给 RDRD 设上限,避免长期不活跃的账号把不确定性膨胀到没有意义。

定级赛放在哪

定级赛的难点不是公式,而是产品表达。

新用户的初始 RDRD 很大,系统对这名用户的判断很弱。定级赛的任务不是“快速给出一个好看的分数”,而是用有限的场次收集足够的信息,让后续训练分进入一个相对稳定的区间。

工程上可以把它拆成三层:

  • 状态层:初始 RDRD 取高值;定级赛期间仍然按同一套公式更新。
  • 展示层:定级赛期间展示区间或“定级中”,不完全隐藏分数,也不把它包装成确定结论。
  • 策略层:定级赛结束后再开启更细的段位或标签,避免用早期噪声定义长期身份。

这三层分开之后,数学逻辑不需要为“新手任务”做特殊分支,产品也能诚实地告诉用户:现在只是估计,不是判决。

三个工程取舍

第一,评分不是能力本身。 题目难度、比赛类型、队友配合都会影响结果。训练分只应该作为观察训练状态的一个信号。

第二,更新频率比公式细节更重要。 一场比赛后立即更新,和累积一周再更新,用户的感受可能完全不同。系统需要明确自己的评分周期。

第三,不要把分数做成目标。 一个训练系统的目标是让薄弱项变得可见,并推动下一次训练,而不是让分数曲线好看起来。

这套数学骨架的价值,不是把一个数字算得更准,而是让“不确定”也成为一个可计算、可展示的对象。

参考资料

  • Mark E. Glickman, The Glicko system。
  • Arpad E. Elo, The Rating of Chessplayers, Past and Present。