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%
读公平性曲线
三个读数,每个都能落到设计决策上:
窗口越窄越公平,但有下限。
band50的平均实力差是 66.8,不是 0——即使把匹配分卡在 50 分以内,双方真实实力平均仍差 66.8 分。这个下限来自 7.1 的评分噪声:分数接近不代表实力接近。想再压小,只能提高评分精度(更多对局、更好的模型),靠收窄窗口已经到顶。收窄窗口的收益是递减的。
band50 → band100平均实力差只从 66.8 涨到 76.5(公平率 92.4%→89.1%),几乎没差;但band200就跳到 109.8(公平率 71.6%)。在 50–100 分这个区间,窗口宽度对公平性几乎无感——所以产品上没必要把窗口卡得极死,那只会拖慢匹配速度而换不来公平。放宽到 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 层的作用是把「仿真实现稳不稳」和「设计好不好」分开——前者看种子间的抖动,后者看指标绝对值。
三条工程结论
- 窗口要窄,但不必极窄。 50–100 分区间公平性几乎无感,卡死窗口只会拖慢匹配。
- 窗口有上限。 宽过分布密度尺度(本例约 400 分)就退化成随机匹配,必须保证「窗口 ≪ 分数分布宽度」。
- 评分噪声是公平性的地板。 匹配分卡得再紧,真实实力差也有约 66.8 分的下限——想突破只能提高评分精度,不能靠配对策略。
小结
- 评分公平 ≠ 实力公平:评分有约 55 分残差,所以
band50的实力差下限是 66.8 分。 - 窗口扫描给出清晰边界:50–100 分无感、200 分开始劣化、400 分以上退化成随机。
- B 基准用 30 个种子证明强者胜率标准差仅 0.77 个百分点,结论稳定。
- 随机匹配的强者胜率 73.0%、碾压率 10.9%——这就是匹配系统存在的理由。
第 7 章到此闭环:7.1 造尺子(评分)、7.2 切刻度(段位)、7.3 量公平(仿真)。下一章把时间轴拉长——一个赛季结束时,这些分数和段位会怎么变,以及怎么让它们跨赛季不失控。
阅读导航:上一节:7.2 分段与段位设计 · 下一节:8.1 跨赛季膨胀的成因与代价 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。