7.3 匹配公平性仿真

把评分和段位都用上,最后量一件事:按匹配分把玩家配到一起,对局公不公平。扫描 50 到 800 分的配对窗口,band50 平均实力差 66.8、公平率 92.4%,random 对照公平率仅 44.1%;再用 30 个种子的 B 基准证明强者胜率标准差仅 0.77 个百分点。

7.1 给了评分,7.2 给了段位。这一节把两者都用上,量最后一件事:按匹配分把玩家配到一起,对局到底公不公平。 「公平」在匹配里是个需要拆开的词——是「分数接近」,还是「实力接近」?两者差多少?配对窗口放宽或收窄,公平性怎么变?

这一节只讨论玩家对玩家的匹配。联盟与 GVG 的匹配(一群人对一群人、按联盟总战力配战场)留到第 15 章 。

什么叫「公平」

匹配里有两个「公平」,必须分开:

  • 评分公平:两个玩家的匹配分接近。这是系统唯一能直接控制的量。
  • 实力公平:两个玩家的真实实力接近。这才是玩家实际感受到的东西。

7.1 已经告诉我们:评分离真实实力有约 55 分的残差。所以评分公平不等于实力公平——即使两个玩家分数一模一样,实力也可能差出几十上百。这一节要量的,就是这个差有多大、以及配对窗口能不能把它压小。

用四个指标:

指标定义越小/越接近 50% 越好
平均实力差每局双方真实实力差的均值越小越公平
公平率实力差 ≤ 150 分的对局占比越大越公平
碾压率实力差 ≥ 400 分的对局占比越小越好
强者胜率真实实力更高者获胜的比例越接近 50% 越公平

S 层:配对窗口扫描

下面这段是 S 层脚本,用 7.1 收敛后的评分,对每个配对窗口各抽 4000 局,统计上面四个指标;random(全池随机)作为「不公平」的对照。

# file: sim/n07_fairness.py
# tier: S
# verify: python3 sim/n07_fairness.py --config config/ --out out/
"""匹配公平性仿真(7.3):配对窗口越窄,对局越公平吗?

用 7.1 收敛后的匹配分配对。两种配对策略:
  · 分段配对(band):只在匹配分差 <= bandWidth 的玩家间配对
  · 随机配对(random):全池随机,作为「不公平」的对照
对每一局,真实胜负仍由 trueSkill 的 logistic 决定,统计:
  · 平均实力差、公平对局率(差 <= fairGap)、碾压率(差 >= blowoutGap)
  · 强者胜率(trueSkill 更高者获胜的比例,公平对局应贴近 50%)

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

from __future__ import annotations

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

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

SVG_NS = "http://www.w3.org/2000/svg"


def sample_band(sorted_ratings, sorted_idx, width, rng):
    """从「匹配分差 <= width」的配对中随机抽一局:先随机一个玩家,再按评分偏移找对手。"""
    n = len(sorted_ratings)
    r = int(rng() * n)
    target = sorted_ratings[r] + (rng() * 2.0 - 1.0) * width
    pos = bisect.bisect_left(sorted_ratings, target)
    j = min(max(pos, 0), n - 1)
    if j == r:
        j = j + 1 if j + 1 < n else j - 1
    return sorted_idx[r], sorted_idx[j]


def sample_random(n, rng):
    i = int(rng() * n)
    j = int(rng() * n)
    while j == i:
        j = int(rng() * n)
    return i, j


def measure(ratings, skills, cfg, width, rng, target_matches):
    n = len(ratings)
    fair_gap, blow_gap = cfg["fairness"]["fairGap"], cfg["fairness"]["blowoutGap"]
    sorted_ratings = sorted(ratings)
    sorted_idx = sorted(range(n), key=lambda i: ratings[i])
    gaps, fav_wins, fav_total, fair, blow = [], 0, 0, 0, 0
    for _ in range(target_matches):
        a, b = sample_random(n, rng) if width is None else sample_band(sorted_ratings, sorted_idx, width, rng)
        gap = abs(skills[a] - skills[b])
        gaps.append(gap)
        if gap <= fair_gap:
            fair += 1
        if gap >= blow_gap:
            blow += 1
        pa = expected_score(skills[a], skills[b])
        a_wins = rng() < pa
        hi_wins = a_wins if skills[a] > skills[b] else (not a_wins)
        if skills[a] != skills[b]:
            fav_total += 1
            fav_wins += 1 if hi_wins else 0
    return {
        "matches": target_matches,
        "avgGap": round(sum(gaps) / len(gaps), 1),
        "fairRatePct": round(100.0 * fair / target_matches, 1),
        "blowoutRatePct": round(100.0 * blow / target_matches, 1),
        "favoriteWinRatePct": round(100.0 * fav_wins / fav_total, 1),
    }


def render_line(path, xs, ys, title):
    lo, hi = min(ys), max(ys)
    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"})
    ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
                                    "fill": "none", "stroke": "#1f6feb", "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/")
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n07_match.json"))
    res = run_population(cfg, cfg["seed"])
    ratings, skills = res["elo"], res["skills"]
    target = cfg["fairness"]["matchesPerMode"]

    rows = []
    for bi, w in enumerate(cfg["fairness"]["bandWidths"]):
        rng = mulberry32(cfg["seed"] + 101 + bi)
        m = measure(ratings, skills, cfg, w, rng, target)
        m["mode"] = f"band{w}"
        rows.append(m)
    rng = mulberry32(cfg["seed"] + 999)
    m = measure(ratings, skills, cfg, None, rng, target)
    m["mode"] = "random"
    rows.append(m)

    report = {"version": 1, "seed": cfg["seed"], "matchesPerMode": target, "rows": rows}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n07_fairness.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n07_fairness.csv"), "w", encoding="utf-8", newline="") as f:
        wtr = csv.writer(f)
        wtr.writerow(["mode", "matches", "avgGap", "fairRatePct", "blowoutRatePct", "favoriteWinRatePct"])
        for r in rows:
            wtr.writerow([r["mode"], r["matches"], r["avgGap"], r["fairRatePct"], r["blowoutRatePct"], r["favoriteWinRatePct"]])
    render_line(os.path.join(args.out, "n07_fairness.svg"),
                [r["mode"] for r in rows], [r["avgGap"] for r in rows],
                "平均实力差随配对窗口变化(random 为对照)")

    for r in rows:
        print(f"{r['mode']:>7}  平均实力差 {r['avgGap']:>6}  公平率 {r['fairRatePct']:>5}%  "
              f"碾压率 {r['blowoutRatePct']:>5}%  强者胜率 {r['favoriteWinRatePct']:>5}%")
    return 0


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

本机实跑输出(同机相对量级,绝对数值不可移植):

 band50  平均实力差   66.8  公平率  92.4%  碾压率   0.0%  强者胜率  58.4%
band100  平均实力差   76.5  公平率  89.1%  碾压率   0.0%  强者胜率  60.5%
band200  平均实力差  109.8  公平率  71.6%  碾压率   0.1%  强者胜率  65.3%
band400  平均实力差  186.5  公平率  42.5%  碾压率   4.3%  强者胜率  73.1%
band800  平均实力差  298.6  公平率  23.6%  碾压率  29.1%  强者胜率  81.2%
 random  平均实力差  200.4  公平率  44.1%  碾压率  10.9%  强者胜率  73.0%

读公平性曲线

三个读数,每个都能落到设计决策上:

  1. 窗口越窄越公平,但有下限。 band50 的平均实力差是 66.8,不是 0——即使把匹配分卡在 50 分以内,双方真实实力平均仍差 66.8 分。这个下限来自 7.1 的评分噪声:分数接近不代表实力接近。想再压小,只能提高评分精度(更多对局、更好的模型),靠收窄窗口已经到顶。

  2. 收窄窗口的收益是递减的。 band50 → band100 平均实力差只从 66.8 涨到 76.5(公平率 92.4%→89.1%),几乎没差;但 band200 就跳到 109.8(公平率 71.6%)。在 50–100 分这个区间,窗口宽度对公平性几乎无感——所以产品上没必要把窗口卡得极死,那只会拖慢匹配速度而换不来公平。

  3. 放宽到 400 以上就逼近随机。 band400(186.5)已经和 random(200.4)接近,band800(298.6)甚至更差。配对窗口一旦宽过玩家分布的密度尺度,就等于没约束。 这给了一个硬边界:窗口必须明显小于「分数分布的特征宽度」,否则「按分匹配」名存实亡。

对照看 random:强者胜率 73.0%、碾压率 10.9%——全随机匹配下,将近三成对局里,更强的那个赢面被显著拉高,近一成的对局是碾压。这就是为什么匹配系统值得做。

B 基准:结论稳不稳

上面的数字都只是一个种子的结果。换 30 个种子各跑一遍,看结论会不会被单个种子带偏。下面这段是 B 层基准(软告警不硬失败)。

# file: sim/n07_fairness_bench.py
# tier: B
# verify: python3 sim/n07_fairness_bench.py --config config/ --out out/
"""匹配分收敛与公平性基准(B 层,软告警不硬失败)。

7.1 的收敛 RMSE(55.7)与 7.3 的强者胜率(58.4%)都只是**一个种子**的结果。
换 30 个种子各跑一遍「收敛 + 段内配对」,看:
  · 收敛后 ELO 的 RMSE 是否稳定(评分实现有没有系统性偏)
  · 段内配对(band50)的强者胜率是否稳定(公平性结论有没有被单个种子带偏)
偏差超阈值只告警——线上还有活跃人数波动、连败保护、组队匹配,公平性本就会漂。

产出:out/n07_fairness_bench.json
"""

from __future__ import annotations

import argparse
import copy
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n07_elo import load, mulberry32, run_population  # noqa: E402
from n07_fairness import measure  # noqa: E402


def mean_std(vals):
    n = len(vals)
    m = sum(vals) / n
    var = sum((v - m) ** 2 for v in vals) / n
    return m, var ** 0.5


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n07_match.json"))
    trials = cfg["sim"]["trials"]
    band = cfg["fairness"]["bandWidths"][0]
    matches = cfg["fairness"]["matchesPerMode"]

    bench = copy.deepcopy(cfg)
    bench["population"]["players"] = cfg["sim"]["benchPlayers"]
    bench["population"]["gamesPerPlayer"] = cfg["sim"]["benchGames"]

    rmses, gaps, favs = [], [], []
    for t in range(trials):
        seed = cfg["seed"] + t * 7919
        res = run_population(bench, seed)
        rmses.append(res["eloRmse"][-1])
        rng = mulberry32(seed + 7)
        m = measure(res["elo"], res["skills"], cfg, band, rng, matches)
        gaps.append(m["avgGap"])
        favs.append(m["favoriteWinRatePct"])

    rm, rs = mean_std(rmses)
    gm, gs = mean_std(gaps)
    fm, fs = mean_std(favs)
    warn_pp, warn_rel = 2.0, 5.0  # 胜率用绝对百分点,实力差用相对百分比
    gap_rel = 100.0 * gs / gm
    report = {
        "version": 1, "trials": trials, "bandWidth": band,
        "benchPlayers": bench["population"]["players"], "benchGames": bench["population"]["gamesPerPlayer"],
        "rmseMean": round(rm, 2), "rmseStd": round(rs, 2),
        "avgGapMean": round(gm, 2), "avgGapStd": round(gs, 2), "avgGapStdRelPct": round(gap_rel, 2),
        "favWinMeanPct": round(fm, 2), "favWinStdPct": round(fs, 2),
        "warnPp": warn_pp, "warnRelPct": warn_rel,
        "favStable": fs <= warn_pp, "gapStable": gap_rel <= warn_rel,
        "stable": fs <= warn_pp and gap_rel <= warn_rel,
    }
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n07_fairness_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(f"{trials} 个种子 · {bench['population']['players']} 人 · {bench['population']['gamesPerPlayer']} 局")
    print(f"收敛 ELO RMSE  均值 {report['rmseMean']}  标准差 {report['rmseStd']}")
    print(f"band{band} 平均实力差 均值 {report['avgGapMean']}  标准差 {report['avgGapStd']}(相对 {report['avgGapStdRelPct']}%)")
    print(f"band{band} 强者胜率   均值 {report['favWinMeanPct']}%  标准差 {report['favWinStdPct']}%")
    print(f"OK 段内配对结论稳定(胜率 <= {warn_pp}pp 且实力差 <= {warn_rel}%)" if report["stable"]
          else "WARN 段内配对结论抖动:检查配对或 PRNG")
    return 0


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

本机实跑输出:

30 个种子 · 800 人 · 40 局
收敛 ELO RMSE  均值 67.63  标准差 1.58
band50 平均实力差 均值 77.96  标准差 2.5(相对 3.2%)
band50 强者胜率   均值 60.51%  标准差 0.77%
OK 段内配对结论稳定(胜率 <= 2.0pp 且实力差 <= 5.0%)

读这份基准:

  • 收敛 RMSE 均值 67.63、标准差 1.58——评分实现的收敛结果在不同种子间高度一致,没有系统性偏(这里用的是缩小版人口 800 人 40 局,故均值比 7.1 的 55.7 高,属预期)。
  • band50 强者胜率 60.51%、标准差 0.77 个百分点——公平性结论在 30 个种子里只抖了不到 1 个百分点,「段内配对把强者胜率压到 60% 上下」这个判断是稳的,不是某个种子的偶然。
  • 平均实力差标准差 2.5 分(相对 3.2%)——同样稳定。

阈值超了只告警不硬失败:线上还有活跃人数波动、连败保护、组队匹配,公平性本来就会漂。B 层的作用是把「仿真实现稳不稳」和「设计好不好」分开——前者看种子间的抖动,后者看指标绝对值。

三条工程结论

  1. 窗口要窄,但不必极窄。 50–100 分区间公平性几乎无感,卡死窗口只会拖慢匹配。
  2. 窗口有上限。 宽过分布密度尺度(本例约 400 分)就退化成随机匹配,必须保证「窗口 ≪ 分数分布宽度」。
  3. 评分噪声是公平性的地板。 匹配分卡得再紧,真实实力差也有约 66.8 分的下限——想突破只能提高评分精度,不能靠配对策略。

小结

  1. 评分公平 ≠ 实力公平:评分有约 55 分残差,所以 band50 的实力差下限是 66.8 分。
  2. 窗口扫描给出清晰边界:50–100 分无感、200 分开始劣化、400 分以上退化成随机。
  3. B 基准用 30 个种子证明强者胜率标准差仅 0.77 个百分点,结论稳定。
  4. 随机匹配的强者胜率 73.0%、碾压率 10.9%——这就是匹配系统存在的理由。

第 7 章到此闭环:7.1 造尺子(评分)、7.2 切刻度(段位)、7.3 量公平(仿真)。下一章把时间轴拉长——一个赛季结束时,这些分数和段位会怎么变,以及怎么让它们跨赛季不失控。

阅读导航:上一节:7.2 分段与段位设计 · 下一节:8.1 跨赛季膨胀的成因与代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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