7.1 匹配分与 ELO / Glicko

匹配分是「跟谁打」的度量。用 ELO 与 Glicko 两种模型对同一批 2000 名玩家跑 100 局,看评分离真实实力的 RMSE 从 167 收敛到 55.7(ELO)与 47.8(Glicko),并解释为什么 Glicko 多带一个「不确定度」反而收敛更低。

前六章回答的是「一个玩家有多强、要花多久、花多少钱」。从这一章起换一个维度:一个玩家会被丢进什么样的对局里。匹配系统决定了「跟谁打」,而「跟谁打」直接决定了玩家的胜率曲线、挫败感和留存。

站内已有一篇 匹配与排名系统机制 ,它是一份机制说明清单——讲匹配要平衡公平、速度、成长、情绪和规模,罗列了分段、连败保护、组队匹配这些机制叫什么、为什么存在。这一章不重复那份清单,而是把它里面提到的两个评分模型——ELO 与 Glicko——拆开,用可跑的仿真回答一个具体问题:这两个分数离玩家的真实实力到底差多远,要打多少局才收敛。

匹配分的第一个问题:一个数字够不够

任何匹配系统的地基都是一个「分数」:系统给每个玩家记一个数,数值相近的玩家配到一起。最朴素的做法是胜场数或胜率,但这两个都坏在一个地方——它们不区分「赢的是谁」。赢 10 场菜鸟和赢 10 场高手,系统眼里是一样的。

ELO 的贡献是把「对手的强度」折进每一次更新里:赢一个比你强的人,涨分多;赢一个比你弱的人,涨分少。 这就把一个纯计数变成了对「实力」的估计。

ELO:一个分数,一条期望公式

ELO 的全部数学只有两行。给定玩家 A 的分 R_A 和玩家 B 的分 R_B,A 的期望得分(0 到 1 之间)是:

E_A = 1 / (1 + 10^((R_B - R_A) / 400))

一局打完,A 实际拿到 S_A(赢 1、平 0.5、输 0),A 的新分是:

R_A' = R_A + K * (S_A - E_A)

K 是「一步走多远」。它有两个后果:

  • K 大:新分对每一局反应剧烈,收敛快,但抖动大——高手偶尔输一把会掉很多分。
  • K 小:平稳,但对新玩家的初始误差修正太慢。

工程上常见的折中是定级赛:前若干局用一个大 K(本机取 64),让新人迅速离开初始分,之后降到常规 K(32)。这条规则直接写进配置里,仿真也会用到。

Glicko:再带一个不确定度

ELO 只有一个数,它无法区分两种玩家:打了 500 局、分数稳定在 1500 的老手,和刚打 3 局、碰巧也到 1500 的新人。系统对两者的信心是一样的,这不对。

Glicko 给每个玩家再挂一个数:评分偏差 RD(rating deviation,不确定度)。RD 大表示「这个分我不太确定」,于是:

  • 更新幅度按 RD 缩放——RD 大就多动,RD 小就少动;
  • 每局之后 RD 变小(信息变多,更确定);
  • 长期不活跃 RD 会回涨(实力可能变了)。

一个玩家新来时 RD 高(本机取 350),打得多就降下来。这正是「定级赛」机制的数学版本:不是硬编码前 N 局用大 K,而是让不确定度自己决定更新幅度。

S 层:两种模型的收敛对照

下面这段是 S 层仿真脚本,读 config/n07_match.json,造一批「真实实力」已知的玩家,让他们反复对局,同时用 ELO 与 Glicko 更新评分,逐轮记录评分与真实实力的 RMSE。

# file: sim/n07_elo.py
# tier: S
# verify: python3 sim/n07_elo.py --config config/ --out out/
"""匹配分模型与收敛(7.1):ELO 与 Glicko 在同一批玩家上的收敛对照。

固定一批「真实实力」玩家(trueSkill),反复两两对局:真实胜负由 trueSkill 的 logistic
决定,评分则由 ELO / Glicko 各自更新。看两种模型的分数离真实实力差多远(RMSE),
以及要多少局才收敛。

产出:out/n07_elo.json + out/n07_elo.csv + out/n07_elo.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import os
import sys
import xml.etree.ElementTree as ET

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from svg_line import polyline_points  # noqa: E402

SVG_NS = "http://www.w3.org/2000/svg"
Q = math.log(10.0) / 400.0  # Glicko 的 q 常数(脚本内部浮点,不进 config)


def mulberry32(seed):
    """确定性 PRNG:与 proto/src/n07_match.ts 逐位一致。"""
    state = seed & 0xFFFFFFFF

    def nxt():
        nonlocal state
        state = (state + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
        t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0

    return nxt


def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def expected_score(ra, rb, scale_den=400):
    """ELO 期望得分 E_A = 1 / (1 + 10^((R_B - R_A)/400))。"""
    return 1.0 / (1.0 + 10.0 ** ((rb - ra) / scale_den))


def glicko_update(ra, rda, rb, rdb, s, min_rd):
    """Glicko-1 单局更新:返回 (新分, 新 RD)。"""
    g = 1.0 / math.sqrt(1.0 + 3.0 * Q * Q * rdb * rdb / (math.pi ** 2))
    ea = 1.0 / (1.0 + 10.0 ** (-g * (ra - rb) / 400.0))
    d2 = 1.0 / (Q * Q * g * g * ea * (1.0 - ea))
    denom = 1.0 / (rda * rda) + 1.0 / d2
    new_rd = max(min_rd, math.sqrt(1.0 / denom))
    return ra + (Q / denom) * g * (s - ea), new_rd


def gen_true_skills(cfg, rng):
    """真实实力:4 个均匀分布之和(近似正态)平移到中心,整数分。"""
    pop = cfg["population"]
    center, spread = pop["trueSkillCenter"], pop["trueSkillSpread"]
    return [center + int(round((rng() + rng() + rng() + rng() - 2.0) * spread))
            for _ in range(pop["players"])]


def rmse(ratings, skills):
    return math.sqrt(sum((r - t) ** 2 for r, t in zip(ratings, skills)) / len(skills))


def run_population(cfg, seed):
    """跑一遍对局,返回最终评分、真实实力与逐轮 RMSE 序列。"""
    rng = mulberry32(seed)
    pop, elo_cfg, g_cfg = cfg["population"], cfg["elo"], cfg["glicko"]
    n, rounds = pop["players"], pop["gamesPerPlayer"]
    skills = gen_true_skills(cfg, rng)
    elo = [float(elo_cfg["initialRating"])] * n
    gr = [float(g_cfg["initialRating"])] * n
    grd = [float(g_cfg["initialRd"])] * n
    games = [0] * n
    elo_rmse, g_rmse = [], []

    for _ in range(rounds):
        grd = [min(350.0, math.sqrt(rd * rd + g_cfg["cSquared"])) for rd in grd]
        order = list(range(n))
        for i in range(n - 1, 0, -1):
            j = int(rng() * (i + 1))
            order[i], order[j] = order[j], order[i]
        for k in range(0, n - 1, 2):
            a, b = order[k], order[k + 1]
            sa = 1.0 if rng() < expected_score(skills[a], skills[b]) else 0.0
            ka = elo_cfg["provisionalKFactor"] if games[a] < elo_cfg["provisionalGames"] else elo_cfg["kFactor"]
            kb = elo_cfg["provisionalKFactor"] if games[b] < elo_cfg["provisionalGames"] else elo_cfg["kFactor"]
            ea = expected_score(elo[a], elo[b])
            elo[a] += ka * (sa - ea)
            elo[b] += kb * ((1.0 - sa) - (1.0 - ea))
            nr_a, nrd_a = glicko_update(gr[a], grd[a], gr[b], grd[b], sa, g_cfg["minRd"])
            nr_b, nrd_b = glicko_update(gr[b], grd[b], gr[a], grd[a], 1.0 - sa, g_cfg["minRd"])
            gr[a], grd[a] = nr_a, nrd_a
            gr[b], grd[b] = nr_b, nrd_b
            games[a] += 1
            games[b] += 1
        elo_rmse.append(rmse(elo, skills))
        g_rmse.append(rmse(gr, skills))

    return {"skills": skills, "elo": [int(round(v)) for v in elo],
            "glicko": [int(round(v)) for v in gr], "glickoRd": [int(round(v)) for v in grd],
            "eloRmse": elo_rmse, "glickoRmse": g_rmse}


def render_multi(path, series, title):
    allv = [v for _, ys, _ in series for v in ys]
    lo, hi = min(allv), max(allv)
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
    ET.SubElement(svg, "rect", {"width": "640", "height": "360", "fill": "#ffffff"})
    for _, ys, color in series:
        ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
                                        "fill": "none", "stroke": color, "stroke-width": "2"})
    t = ET.SubElement(svg, "text", {"x": "56", "y": "16", "font-size": "13", "fill": "#111"})
    t.text = title
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return path


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    ap.add_argument("--seed", type=int, default=None)
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n07_match.json"))
    seed = args.seed if args.seed is not None else cfg["seed"]
    res = run_population(cfg, seed)
    e_rmse, g_rmse = res["eloRmse"], res["glickoRmse"]
    e0, e_last = round(e_rmse[0], 3), round(e_rmse[-1], 3)
    g0, g_last = round(g_rmse[0], 3), round(g_rmse[-1], 3)
    e_conv = next((i + 1 for i, v in enumerate(e_rmse) if v <= e_last * 1.5), len(e_rmse))
    g_conv = next((i + 1 for i, v in enumerate(g_rmse) if v <= g_last * 1.5), len(g_rmse))
    report = {"version": 1, "seed": seed, "players": cfg["population"]["players"],
              "gamesPerPlayer": cfg["population"]["gamesPerPlayer"],
              "eloStartRmse": e0, "eloFinalRmse": e_last, "eloConvergeRound": e_conv,
              "glickoStartRmse": g0, "glickoFinalRmse": g_last, "glickoConvergeRound": g_conv,
              "glickoFinalRdMean": int(sum(res["glickoRd"]) / len(res["glickoRd"]))}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n07_elo.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n07_elo.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["round", "eloRmse", "glickoRmse"])
        for i in range(len(e_rmse)):
            w.writerow([i + 1, round(e_rmse[i], 4), round(g_rmse[i], 4)])
    render_multi(os.path.join(args.out, "n07_elo.svg"),
                 [("ELO", e_rmse, "#d1242f"), ("Glicko", g_rmse, "#1f6feb")],
                 f"评分 RMSE 收敛:ELO {e0}→{e_last} / Glicko {g0}→{g_last}")
    print(f"ELO    第1轮 RMSE {e0}  终值 {e_last}  收敛于第 {e_conv} 轮")
    print(f"Glicko 第1轮 RMSE {g0}  终值 {g_last}  收敛于第 {g_conv} 轮")
    print(f"最终 Glicko RD 均值约 {report['glickoFinalRdMean']}(初值 {cfg['glicko']['initialRd']})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

配置里 2000 名玩家的真实实力中心与初始分对齐(都取 1200),所以「初始 RMSE」就是真实实力的离散程度;跑 100 局后,两种模型都收敛。本机实跑输出(同机相对量级,绝对数值不可移植):

ELO    第1轮 RMSE 167.296  终值 55.703  收敛于第 25 轮
Glicko 第1轮 RMSE 189.48  终值 47.84  收敛于第 40 轮
最终 Glicko RD 均值约 55(初值 350)

读收敛曲线

把 out/n07_elo.csv 的关键轮次摊开看:

轮次ELO RMSEGlicko RMSE
1167.30189.48
5137.88167.62
10113.34131.92
2091.2699.35
3076.4082.07
5063.3264.64
7557.6553.41
10055.7047.84

三个读数:

  1. 第 1 轮 Glicko 反而更差(189.5 > 167.3)。 这不是 bug——新玩家 RD = 350 极高,Glicko 认为「我啥都不知道」,于是第一局更新幅度很大,一局就把分数推得很远。ELO 的 K = 64 相对克制。高不确定度是双刃剑:修正快,也容易过冲。

  2. 大约第 30 轮,两条线交叉。 之后 Glicko 一路领先(100 轮时 47.84 对 55.70,低约 14%)。原因还是 RD:随着局数增加,RD 从 350 降到均值 55,更新幅度收窄,分数不再被单局带偏,于是更贴近真实实力。

  3. 两条曲线在 100 轮仍未完全走平。 ELO 从第 75 轮到第 100 轮只从 57.65 降到 55.70。这意味着即使打满 100 局,评分与真实实力之间仍有约 55 分的残差——这个残差不是实现缺陷,而是「用胜负估计实力」这件事本身的下限:每一局都是带噪声的信号。

那个 55 分残差,正是第 7.3 节要利用的东西:如果评分本身有噪声,那么「分数相近」并不等于「实力相近」。 公平性必须用仿真去量,不能想当然。

小结

这一节把匹配分从「一个数字」拆成了两条可验证的结论:

  1. ELO 用一个分数 + 一条期望公式:赢强者涨分多,K 决定一步走多远。
  2. Glicko 再带一个不确定度 RD:新人更新快、老人更新稳,RD 从 350 收敛到约 55。
  3. 收敛要用仿真验证:100 局后 ELO 残差 55.70、Glicko 47.84,且都还没完全走平——评分天然带噪声。

有了「分数」这把尺子,下一步是把它切成段位:怎么分段、每段多少人、段位和真实实力的错位有多大。这就是 7.2。

阅读导航:上一节:目录 · 下一节:7.2 分段与段位设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引