Разработка новой рейтинговой системы (#22) #65

Merged
NotBigGhost merged 4 commits from issue-22-rating-proposal into dev 2026-09-14 21:44:21 +03:00
Showing only changes of commit af7d2b358d - Show all commits
+74 -52
View File
@@ -6,6 +6,7 @@
1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять 1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять
с ними свою реализацию. с ними свою реализацию.
2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся. 2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся.
Плюс проверка, что шкала (1500 или прежние 50) не влияет на качество прогноза.
3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек. 3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек.
Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва
по производительности. На одних и тех же партиях сравниваются текущий League по производительности. На одних и тех же партиях сравниваются текущий League
@@ -29,8 +30,8 @@ from itertools import combinations
# ═══ Правила игры ═══════════════════════════════════════════════════════════════ # ═══ Правила игры ═══════════════════════════════════════════════════════════════
# Размер поля в тайлах по числу игроков (6 игроков — 4×5, ответ владельца в #22). # Размер поля в тайлах по числу игроков (дуэль — 2×3, 6 игроков — 4×5; уточнения владельца в #22).
BOARD_TILES = {2: 4, 3: 9, 4: 12, 5: 16, 6: 20} BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
WORLDS_PER_TILE = 2.2 WORLDS_PER_TILE = 2.2
BASE_ROUNDS = 8 BASE_ROUNDS = 8
# Хоумрул группы: при 5–6 игроках играется 9 раундов. # Хоумрул группы: при 5–6 игроках играется 9 раундов.
@@ -73,11 +74,11 @@ class Match:
@dataclass(frozen=True) @dataclass(frozen=True)
class Params: class Params:
# Шкала «Elo / 10»: старт 50, разница 40 = шансы 10:1. Классический Elo — R·10 + 1000. # Классическая шкала Elo (решение владельца в PR #65): старт 1500, разница 400 = шансы 10:1.
r0: float = 50.0 # стартовый рейтинг r0: float = 1500.0 # стартовый рейтинг
d: float = 40.0 # масштаб логистики d: float = 400.0 # масштаб логистики
k_max: float = 6.4 # K новичка (0 партий) k_max: float = 64.0 # K новичка (0 партий)
k_min: float = 1.6 # K опытного игрока k_min: float = 16.0 # K опытного игрока
k_games: int = 20 # за сколько партий K линейно спускается от k_max к k_min k_games: int = 20 # за сколько партий K линейно спускается от k_max к k_min
w_table: float = 0.0 # вес размера стола (треб. 2) w_table: float = 0.0 # вес размера стола (треб. 2)
w_tempo: float = 0.0 # вес темпа победы (треб. 1) w_tempo: float = 0.0 # вес темпа победы (треб. 1)
@@ -155,8 +156,8 @@ def pair_multiplier(
add += p.w_worlds * (wor - p.mu_worlds) add += p.w_worlds * (wor - p.mu_worlds)
if p.autocorr and add > 1.0 and not tie: if p.autocorr and add > 1.0 and not tie:
# Поправка FiveThirtyEight (2.2 / (0.001·ΔElo + 2.2)) в шкале Elo/10. # Поправка FiveThirtyEight: 2.2 / (0.001·ΔElo + 2.2).
kappa = 2.2 / (0.01 * (r_a - r_b) + 2.2) kappa = 2.2 / (0.001 * (r_a - r_b) + 2.2)
parts["kappa"] = kappa parts["kappa"] = kappa
add = 1.0 + (add - 1.0) * kappa add = 1.0 + (add - 1.0) * kappa
parts["additive"] = add parts["additive"] = add
@@ -202,6 +203,8 @@ class LeaguePoints:
"""Текущая система (backend/app/services/scoring.py): сглаженное среднее очков за место.""" """Текущая система (backend/app/services/scoring.py): сглаженное среднее очков за место."""
probabilistic = False probabilistic = False
# Рейтинг в шкале 0–100; |ΔR| сравнивается с Elo в пересчёте R_Elo = 10·score + 1000.
move_scale = 10.0
PRIOR_GAMES = 10 PRIOR_GAMES = 10
PRIOR_MEAN = 0.5 PRIOR_MEAN = 0.5
@@ -232,6 +235,7 @@ class Elo:
"""Парный многопользовательский Elo; с нулевыми весами — «чистый» Elo.""" """Парный многопользовательский Elo; с нулевыми весами — «чистый» Elo."""
probabilistic = True probabilistic = True
move_scale = 1.0
def __init__(self, p: Params) -> None: def __init__(self, p: Params) -> None:
self.p = p self.p = p
@@ -253,8 +257,8 @@ class Elo:
SIGMA_SKILL = 150.0 # разброс «истинной» силы игроков SIGMA_SKILL = 150.0 # разброс «истинной» силы игроков
SIGMA_PERF = 225.0 # шум производительности в отдельной партии (кубы, карты, ошибки) SIGMA_PERF = 225.0 # шум производительности в отдельной партии (кубы, карты, ошибки)
# Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=40 при ΔR ≈ 0.093·Δθ. # Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=400 при ΔR ≈ 0.93·Δθ.
SKILL_TO_RATING = 1.702 * 40 / (math.log(10) * SIGMA_PERF * math.sqrt(2)) SKILL_TO_RATING = 1.702 * 400 / (math.log(10) * SIGMA_PERF * math.sqrt(2))
TABLE_SIZES = ((2, 0.45), (3, 0.25), (4, 0.20), (5, 0.07), (6, 0.03)) TABLE_SIZES = ((2, 0.45), (3, 0.25), (4, 0.20), (5, 0.07), (6, 0.03))
ELIM_Z = 2.3 # отставание (в σ), при котором игрок может выбыть ELIM_Z = 2.3 # отставание (в σ), при котором игрок может выбыть
ELIM_P = 0.35 # вероятность выбывания при таком отставании ELIM_P = 0.35 # вероятность выбывания при таком отставании
@@ -472,7 +476,7 @@ def evaluate(system, skill: dict[str, float], matches: list[Match], feed=None) -
games[s.player] = games.get(s.player, 0) + 1 games[s.player] = games.get(s.player, 0) + 1
gp = games[s.player] gp = games[s.player]
if gp > 20: if gp > 20:
abs_moves.append(abs(delta[s.player])) abs_moves.append(abs(delta[s.player]) * system.move_scale)
if gp in at_k: if gp in at_k:
at_k[gp][s.player] = system.rating(s.player) at_k[gp][s.player] = system.rating(s.player)
played = [pl for pl in skill if games.get(pl, 0) >= 10] played = [pl for pl in skill if games.get(pl, 0) >= 10]
@@ -530,7 +534,7 @@ def summarize(rows: list[dict]) -> dict[str, tuple[float, float]]:
# ═══ Коэффициенты ═════════════════════════════════════════════════════════════ # ═══ Коэффициенты ═════════════════════════════════════════════════════════════
PLAIN = Params(k_max=9.6, k_min=1.6) # чистый Elo со своими лучшими K (перебор, этап 1) PLAIN = Params(k_max=96.0, k_min=16.0) # чистый Elo со своими лучшими K (перебор, этап 1)
CLOSENESS = ( CLOSENESS = (
("objectives", 1.0), ("objectives", 1.0),
("worlds", 0.85), ("worlds", 0.85),
@@ -564,67 +568,67 @@ def examples() -> list[tuple[str, str, dict, dict, Match]]:
five = [Seat("A", 1), Seat("B", 2), Seat("C", 3), Seat("D", 4), Seat("E", 5)] five = [Seat("A", 1), Seat("B", 2), Seat("C", 3), Seat("D", 4), Seat("E", 5)]
six = [Seat(x, i + 1) for i, x in enumerate("ABCDEF")] six = [Seat(x, i + 1) for i, x in enumerate("ABCDEF")]
return [ return [
("1a", "Дуэль 60 против 40: побеждает сильный", ("1a", "Дуэль 1600 против 1400: побеждает сильный",
{"A": 60, "B": 40}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), {"A": 1600, "B": 1400}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
("1b", "Дуэль 60 против 40: побеждает слабый", ("1b", "Дуэль 1600 против 1400: побеждает слабый",
{"A": 60, "B": 40}, _vets("A", "B"), duel("B", "A", win_reason="objectives")), {"A": 1600, "B": 1400}, _vets("A", "B"), duel("B", "A", win_reason="objectives")),
("2a", "Быстрая победа: 3-й раунд", ("2a", "Быстрая победа: 3-й раунд",
{"A": 50, "B": 50}, _vets("A", "B"), {"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=3)), Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=3)),
("2b", "Медленная победа: 8-й раунд", ("2b", "Медленная победа: 8-й раунд",
{"A": 50, "B": 50}, _vets("A", "B"), {"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=8)), Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=8)),
("3a", "Первое место в дуэли", ("3a", "Первое место в дуэли",
{"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), {"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
("3b", "Стол на 5: все места", ("3b", "Стол на 5: все места",
dict.fromkeys("ABCDE", 50), _vets(*"ABCDE"), Match(five, "objectives")), dict.fromkeys("ABCDE", 1500), _vets(*"ABCDE"), Match(five, "objectives")),
("4a", "Тип победы без деталей: по мирам", ("4a", "Тип победы без деталей: по мирам",
{"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="worlds")), {"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="worlds")),
("4b", "Тип победы без деталей: по пластику", ("4b", "Тип победы без деталей: по пластику",
{"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="plastic")), {"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="plastic")),
("4c", "Тип победы без деталей: по ресурсам", ("4c", "Тип победы без деталей: по ресурсам",
{"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="resources")), {"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="resources")),
("4d", "Самая близкая полная партия: по мирам на 8-м раунде, 2:2 цели, 6:5 миров", ("4d", "Самая близкая полная партия: по мирам на 8-м раунде, 2:2 цели, 6:5 миров",
{"A": 50, "B": 50}, _vets("A", "B"), {"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 6), Seat("B", 2, 2, 5)], "worlds", round=8)), Match([Seat("A", 1, 2, 6), Seat("B", 2, 2, 5)], "worlds", round=8)),
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров", ("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
{"A": 50, "B": 50}, _vets("A", "B"), {"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)), Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
("5", "Стол на 4: ничья выбывших, раунд 7", ("5", "Стол на 4: ничья выбывших, раунд 7",
{"A": 55, "B": 50, "C": 48, "D": 45}, _vets(*"ABCD"), {"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
Match( Match(
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7), [Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
Seat("C", 3, 1, 0, eliminated=True), Seat("D", 3, 0, 0, eliminated=True)], Seat("C", 3, 1, 0, eliminated=True), Seat("D", 3, 0, 0, eliminated=True)],
"objectives", round=7, "objectives", round=7,
)), )),
("6a", "Стол на 6, конец на 8-м раунде, хоумрул 9 раундов включён", ("6a", "Стол на 6, конец на 8-м раунде, хоумрул 9 раундов включён",
dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"),
Match(six, "objectives", round=8, nine_rounds=True)), Match(six, "objectives", round=8, nine_rounds=True)),
("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен", ("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен",
dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"),
Match(six, "objectives", round=8, nine_rounds=False)), Match(six, "objectives", round=8, nine_rounds=False)),
("7", "Новичок (0 партий) побеждает ветерана, оба 50", ("7", "Новичок (0 партий) побеждает ветерана, оба 1500",
{"A": 50, "B": 50}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")), {"A": 1500, "B": 1500}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")),
] ]
# Изменения рейтинга в примерах (округление до 0.001) — те же числа стоят в документе. # Изменения рейтинга в примерах (округление до 0.01) — те же числа стоят в документе.
EXPECTED: dict[str, dict[str, float]] = { EXPECTED: dict[str, dict[str, float]] = {
"1a": {"A": 0.384, "B": -0.384}, "1a": {"A": 3.84, "B": -3.84},
"1b": {"B": 1.216, "A": -1.216}, "1b": {"B": 12.16, "A": -12.16},
"2a": {"A": 1.148, "B": -1.148}, "2a": {"A": 11.18, "B": -11.18},
"2b": {"A": 0.577, "B": -0.577}, "2b": {"A": 5.46, "B": -5.46},
"3a": {"A": 0.8, "B": -0.8}, "3a": {"A": 8.0, "B": -8.0},
"3b": {"A": 1.1, "B": 0.55, "C": 0.0, "D": -0.55, "E": -1.1}, "3b": {"A": 11.0, "B": 5.5, "C": 0.0, "D": -5.5, "E": -11.0},
"4a": {"A": 0.68, "B": -0.68}, "4a": {"A": 6.8, "B": -6.8},
"4b": {"A": 0.56, "B": -0.56}, "4b": {"A": 5.6, "B": -5.6},
"4c": {"A": 0.48, "B": -0.48}, "4c": {"A": 4.8, "B": -4.8},
"4d": {"A": 0.34, "B": -0.34}, "4d": {"A": 3.4, "B": -3.4},
"4e": {"A": 1.6, "B": -1.6}, "4e": {"A": 16.0, "B": -16.0},
"5": {"A": 0.927, "B": 0.585, "C": -0.778, "D": -0.735}, "5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35},
"6a": {"A": 1.2, "B": 0.72, "C": 0.24, "D": -0.24, "E": -0.72, "F": -1.2}, "6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
"6b": {"A": 1.029, "B": 0.754, "C": 0.274, "D": -0.206, "E": -0.686, "F": -1.166}, "6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
"7": {"A": 3.2, "B": -0.8}, "7": {"A": 32.0, "B": -8.0},
} }
@@ -632,7 +636,7 @@ def run_examples(p: Params = PROPOSED, verbose: bool = True) -> None:
for key, title, ratings, games, m in examples(): for key, title, ratings, games, m in examples():
trace: list = [] trace: list = []
delta = rate_match(ratings, games, m, p, trace) delta = rate_match(ratings, games, m, p, trace)
got = {pl: round(v, 3) for pl, v in delta.items()} got = {pl: round(v, 2) for pl, v in delta.items()}
if verbose: if verbose:
n = len(m.seats) n = len(m.seats)
print(f"\n### Пример {key}. {title}\n") print(f"\n### Пример {key}. {title}\n")
@@ -651,6 +655,23 @@ def run_examples(p: Params = PROPOSED, verbose: bool = True) -> None:
print("\nВсе примеры совпадают с документом.") print("\nВсе примеры совпадают с документом.")
def check_scale_invariance(seasons: int = 1) -> None:
"""Шкала «50 / 40» (R = 10·score + 1000, D и K ÷10) и шкала 1500 дают одинаковые
точность, Brier и ρ; изменения рейтинга различаются ровно в 10 раз (документ, 4.1)."""
for cfg in SCENARIOS.values():
for s in range(seasons):
skill, matches = generate_season(
cfg["seed"] + s, SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
)
for p in (PROPOSED, PLAIN):
small = replace(p, r0=(p.r0 - 1000) / 10, d=p.d / 10, k_max=p.k_max / 10, k_min=p.k_min / 10)
big, tiny = evaluate(Elo(p), skill, matches), evaluate(Elo(small), skill, matches)
for key in ("acc", "brier", "rho", *(f"rho@{k}" for k in CHECKPOINTS)):
assert math.isclose(big[key], tiny[key], abs_tol=1e-9), f"шкала: {key}"
assert math.isclose(big["move"], 10 * tiny["move"], rel_tol=1e-9), "шкала: |ΔR|"
print("Шкала 1500 и шкала 50 дают одинаковые точность, Brier и ρ.")
# ═══ Сценарии запуска ═════════════════════════════════════════════════════════ # ═══ Сценарии запуска ═════════════════════════════════════════════════════════
SEASON_MATCHES = 300 SEASON_MATCHES = 300
@@ -750,8 +771,8 @@ def grid(seasons: int) -> None:
print("|---|---|---|---|") print("|---|---|---|---|")
k_res = [] k_res = []
for k_games in (10, 20): for k_games in (10, 20):
for k_max in (4.8, 6.4, 9.6, 12.8, 16.0): for k_max in (48.0, 64.0, 96.0, 128.0, 160.0):
for k_min in (1.6, 2.4, 3.2): for k_min in (16.0, 24.0, 32.0):
p = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games) p = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games)
b = brier(p, SCENARIOS) b = brier(p, SCENARIOS)
k_res.append((b, k_max, k_min, k_games)) k_res.append((b, k_max, k_min, k_games))
@@ -790,8 +811,8 @@ def grid(seasons: int) -> None:
print("| k_max | k_min | k_games | autocorr | Brier (сигнальные) | Brier «шум» |") print("| k_max | k_min | k_games | autocorr | Brier (сигнальные) | Brier «шум» |")
print("|---|---|---|---|---|---|") print("|---|---|---|---|---|---|")
for k_games2 in (10, 20): for k_games2 in (10, 20):
for k_max2 in (4.8, 6.4, 8.0): for k_max2 in (48.0, 64.0, 80.0):
for k_min2 in (1.2, 1.6, 2.4): for k_min2 in (12.0, 16.0, 24.0):
for ac in (False, True): for ac in (False, True):
p = replace(PROPOSED, k_max=k_max2, k_min=k_min2, k_games=k_games2, autocorr=ac) p = replace(PROPOSED, k_max=k_max2, k_min=k_min2, k_games=k_games2, autocorr=ac)
print( print(
@@ -829,6 +850,7 @@ def main() -> None:
args = ap.parse_args() args = ap.parse_args()
print("# Примеры расчётов") print("# Примеры расчётов")
run_examples() run_examples()
check_scale_invariance()
if args.grid: if args.grid:
grid(args.grid_seasons) grid(args.grid_seasons)
return return