From af7d2b358d1e1c36a22e3d48864690a3c5561eec Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 21:07:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=202=C3=973=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B4=D1=83=D1=8D=D0=BB=D0=B8=20=D0=B8=20=D1=88=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=201500=20=D0=B2=20=D1=8D=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения владельца по #22: у дуэли поле 2×3 (6 тайлов, 6.6 мира на игрока), шкала — классический Elo (R0 = 1500, D = 400, K 64 → 16; чистый Elo — 96 → 16). Примеры и assert переведены в новую шкалу, сетки K в --grid умножены на 10, |ΔR| League Points в сравнении приводится к шкале 1500. Добавлена проверка, что шкала не влияет на точность, Brier и ρ. #22 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- docs/rating/simulate.py | 126 +++++++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 52 deletions(-) diff --git a/docs/rating/simulate.py b/docs/rating/simulate.py index 3ed973f..d41f56b 100644 --- a/docs/rating/simulate.py +++ b/docs/rating/simulate.py @@ -6,6 +6,7 @@ 1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять с ними свою реализацию. 2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся. + Плюс проверка, что шкала (1500 или прежние 50) не влияет на качество прогноза. 3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек. Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва по производительности. На одних и тех же партиях сравниваются текущий League @@ -29,8 +30,8 @@ from itertools import combinations # ═══ Правила игры ═══════════════════════════════════════════════════════════════ -# Размер поля в тайлах по числу игроков (6 игроков — 4×5, ответ владельца в #22). -BOARD_TILES = {2: 4, 3: 9, 4: 12, 5: 16, 6: 20} +# Размер поля в тайлах по числу игроков (дуэль — 2×3, 6 игроков — 4×5; уточнения владельца в #22). +BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20} WORLDS_PER_TILE = 2.2 BASE_ROUNDS = 8 # Хоумрул группы: при 5–6 игроках играется 9 раундов. @@ -73,11 +74,11 @@ class Match: @dataclass(frozen=True) class Params: - # Шкала «Elo / 10»: старт 50, разница 40 = шансы 10:1. Классический Elo — R·10 + 1000. - r0: float = 50.0 # стартовый рейтинг - d: float = 40.0 # масштаб логистики - k_max: float = 6.4 # K новичка (0 партий) - k_min: float = 1.6 # K опытного игрока + # Классическая шкала Elo (решение владельца в PR #65): старт 1500, разница 400 = шансы 10:1. + r0: float = 1500.0 # стартовый рейтинг + d: float = 400.0 # масштаб логистики + k_max: float = 64.0 # K новичка (0 партий) + k_min: float = 16.0 # K опытного игрока k_games: int = 20 # за сколько партий K линейно спускается от k_max к k_min w_table: float = 0.0 # вес размера стола (треб. 2) w_tempo: float = 0.0 # вес темпа победы (треб. 1) @@ -155,8 +156,8 @@ def pair_multiplier( add += p.w_worlds * (wor - p.mu_worlds) if p.autocorr and add > 1.0 and not tie: - # Поправка FiveThirtyEight (2.2 / (0.001·ΔElo + 2.2)) в шкале Elo/10. - kappa = 2.2 / (0.01 * (r_a - r_b) + 2.2) + # Поправка FiveThirtyEight: 2.2 / (0.001·ΔElo + 2.2). + kappa = 2.2 / (0.001 * (r_a - r_b) + 2.2) parts["kappa"] = kappa add = 1.0 + (add - 1.0) * kappa parts["additive"] = add @@ -202,6 +203,8 @@ class LeaguePoints: """Текущая система (backend/app/services/scoring.py): сглаженное среднее очков за место.""" probabilistic = False + # Рейтинг в шкале 0–100; |ΔR| сравнивается с Elo в пересчёте R_Elo = 10·score + 1000. + move_scale = 10.0 PRIOR_GAMES = 10 PRIOR_MEAN = 0.5 @@ -232,6 +235,7 @@ class Elo: """Парный многопользовательский Elo; с нулевыми весами — «чистый» Elo.""" probabilistic = True + move_scale = 1.0 def __init__(self, p: Params) -> None: self.p = p @@ -253,8 +257,8 @@ class Elo: SIGMA_SKILL = 150.0 # разброс «истинной» силы игроков SIGMA_PERF = 225.0 # шум производительности в отдельной партии (кубы, карты, ошибки) -# Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=40 при ΔR ≈ 0.093·Δθ. -SKILL_TO_RATING = 1.702 * 40 / (math.log(10) * SIGMA_PERF * math.sqrt(2)) +# Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=400 при ΔR ≈ 0.93·Δθ. +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)) ELIM_Z = 2.3 # отставание (в σ), при котором игрок может выбыть 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 gp = games[s.player] 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: at_k[gp][s.player] = system.rating(s.player) 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 = ( ("objectives", 1.0), ("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)] six = [Seat(x, i + 1) for i, x in enumerate("ABCDEF")] return [ - ("1a", "Дуэль 60 против 40: побеждает сильный", - {"A": 60, "B": 40}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), - ("1b", "Дуэль 60 против 40: побеждает слабый", - {"A": 60, "B": 40}, _vets("A", "B"), duel("B", "A", win_reason="objectives")), + ("1a", "Дуэль 1600 против 1400: побеждает сильный", + {"A": 1600, "B": 1400}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), + ("1b", "Дуэль 1600 против 1400: побеждает слабый", + {"A": 1600, "B": 1400}, _vets("A", "B"), duel("B", "A", win_reason="objectives")), ("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)), ("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)), ("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: все места", - dict.fromkeys("ABCDE", 50), _vets(*"ABCDE"), Match(five, "objectives")), + dict.fromkeys("ABCDE", 1500), _vets(*"ABCDE"), Match(five, "objectives")), ("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", "Тип победы без деталей: по пластику", - {"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", "Тип победы без деталей: по ресурсам", - {"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 миров", - {"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)), ("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)), ("5", "Стол на 4: ничья выбывших, раунд 7", - {"A": 55, "B": 50, "C": 48, "D": 45}, _vets(*"ABCD"), + {"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"), Match( [Seat("A", 1, 4, 8), Seat("B", 2, 3, 7), Seat("C", 3, 1, 0, eliminated=True), Seat("D", 3, 0, 0, eliminated=True)], "objectives", round=7, )), ("6a", "Стол на 6, конец на 8-м раунде, хоумрул 9 раундов включён", - dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), + dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"), Match(six, "objectives", round=8, nine_rounds=True)), ("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен", - dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), + dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"), Match(six, "objectives", round=8, nine_rounds=False)), - ("7", "Новичок (0 партий) побеждает ветерана, оба 50", - {"A": 50, "B": 50}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")), + ("7", "Новичок (0 партий) побеждает ветерана, оба 1500", + {"A": 1500, "B": 1500}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")), ] -# Изменения рейтинга в примерах (округление до 0.001) — те же числа стоят в документе. +# Изменения рейтинга в примерах (округление до 0.01) — те же числа стоят в документе. EXPECTED: dict[str, dict[str, float]] = { - "1a": {"A": 0.384, "B": -0.384}, - "1b": {"B": 1.216, "A": -1.216}, - "2a": {"A": 1.148, "B": -1.148}, - "2b": {"A": 0.577, "B": -0.577}, - "3a": {"A": 0.8, "B": -0.8}, - "3b": {"A": 1.1, "B": 0.55, "C": 0.0, "D": -0.55, "E": -1.1}, - "4a": {"A": 0.68, "B": -0.68}, - "4b": {"A": 0.56, "B": -0.56}, - "4c": {"A": 0.48, "B": -0.48}, - "4d": {"A": 0.34, "B": -0.34}, - "4e": {"A": 1.6, "B": -1.6}, - "5": {"A": 0.927, "B": 0.585, "C": -0.778, "D": -0.735}, - "6a": {"A": 1.2, "B": 0.72, "C": 0.24, "D": -0.24, "E": -0.72, "F": -1.2}, - "6b": {"A": 1.029, "B": 0.754, "C": 0.274, "D": -0.206, "E": -0.686, "F": -1.166}, - "7": {"A": 3.2, "B": -0.8}, + "1a": {"A": 3.84, "B": -3.84}, + "1b": {"B": 12.16, "A": -12.16}, + "2a": {"A": 11.18, "B": -11.18}, + "2b": {"A": 5.46, "B": -5.46}, + "3a": {"A": 8.0, "B": -8.0}, + "3b": {"A": 11.0, "B": 5.5, "C": 0.0, "D": -5.5, "E": -11.0}, + "4a": {"A": 6.8, "B": -6.8}, + "4b": {"A": 5.6, "B": -5.6}, + "4c": {"A": 4.8, "B": -4.8}, + "4d": {"A": 3.4, "B": -3.4}, + "4e": {"A": 16.0, "B": -16.0}, + "5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35}, + "6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0}, + "6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66}, + "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(): trace: list = [] 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: n = len(m.seats) print(f"\n### Пример {key}. {title}\n") @@ -651,6 +655,23 @@ def run_examples(p: Params = PROPOSED, verbose: bool = True) -> None: 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 @@ -750,8 +771,8 @@ def grid(seasons: int) -> None: print("|---|---|---|---|") k_res = [] for k_games in (10, 20): - for k_max in (4.8, 6.4, 9.6, 12.8, 16.0): - for k_min in (1.6, 2.4, 3.2): + for k_max in (48.0, 64.0, 96.0, 128.0, 160.0): + 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) b = brier(p, SCENARIOS) 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("|---|---|---|---|---|---|") for k_games2 in (10, 20): - for k_max2 in (4.8, 6.4, 8.0): - for k_min2 in (1.2, 1.6, 2.4): + for k_max2 in (48.0, 64.0, 80.0): + for k_min2 in (12.0, 16.0, 24.0): for ac in (False, True): p = replace(PROPOSED, k_max=k_max2, k_min=k_min2, k_games=k_games2, autocorr=ac) print( @@ -829,6 +850,7 @@ def main() -> None: args = ap.parse_args() print("# Примеры расчётов") run_examples() + check_scale_invariance() if args.grid: grid(args.grid_seasons) return