Точность игрока в шахматной партии 71%, что это значит?. lichess.. lichess. математика.. lichess. математика. машинное+обучение.. lichess. математика. машинное+обучение. статистика.. lichess. математика. машинное+обучение. статистика. теория вероятностей.. lichess. математика. машинное+обучение. статистика. теория вероятностей. шахматы.

Где живёт точность партии?

Точность игрока в шахматной партии 71%, что это значит? - 1

Мы не станем углубляться в правила шахмат и их разновидности. Будем считать, что в шахматную партию играют два игрока, один белыми фигурами, второй чёрными (далее просто белые и чёрные – никакого расизма, конечно, просто так принято). Они последовательно совершают ходы (начинают белые), и игра может закончиться победой одного из игроков или ничьей. К слову, шахматы считаются ничейной игрой: широко распространено мнение, что при идеальной игре обеих сторон партия должна завершаться ничьей, хотя строгого доказательства этого утверждения не существует.

Понятие точности игры возникло в компьютерную эпоху, когда люди начали пытаться оцифровать всё, до чего могли дотянуться. Среди прочих параметров точность считается на популярных платформах chess.com и lichess.org, где можно запустить компьютерный анализ партии и посмотреть на различные числа в результате.

В качестве примера посмотрим на партию Jacorey Bynum – Magnus Carlsen (1-0), сыгранную 27 января 2026 года в рамках турнира “Титульный Вторник” на платформе chess.com.

По результатам компьютерного анализа на chess.com мы видим следующую картину:

Рис 1. Результат анализа chess.com

Рис 1. Результат анализа chess.com

Точность Магнуса, по мнению chess.com, составила 88.7% и сравнима с точностью победителя этой партии (91.3%).

Если же мы загрузим эту партию на lichess.org, то увидим немного другой результат:

Рис 2. Результат анализа lichess.org

Рис 2. Результат анализа lichess.org

Здесь точность Магнуса всего 71% при том же числе ошибок (правда с немного другой классификацией).

Так кто же прав в этом случае? Играл Магнус с точностью 71% или 88.7%? Или правильного ответа здесь просто не существует? К сожалению, залезть внутрь chess.com мы не можем, поэтому посмотрим на реализацию расчёта в открытом коде lichess.org и попытаемся ответить на этот вопрос.

В процессе разбора алгоритма я сначала перечислю его шаги, а затем постараюсь объяснить интуицию, стоящую за каждым из них. В итоге мы получим Python-скрипт, с помощью которого воспроизведём значение точности обоих игроков от lichess.org для упомянутой партии Магнуса.

Предупреждение: Все рассуждения, в которых я не ссылаюсь на код или другие источники истины, являются моими догадками. Если вы хорошо знакомы с причинами инженерных решений lichess.org или заметили в моём тексте ошибку, пожалуйста, напишите об этом в комментариях.

Интересно, что пока я писал этот пост, на lichess.org появились отдельные значения точности игроков для разных этапов партии: дебюта, миттельшпиля и эндшпиля.

Рис 3. Пост от lichess.org о разбивке точности

Рис 3. Пост от lichess.org о разбивке точности

Алгоритм от lichess.org

Исходное описание алгоритма можно найти в посте Lichess Accuracy metric. Стоп! Тогда зачем нужна эта статья? Дело в том, что в исходном посте есть сами шаги, но нет объяснений, почему они именно такие: в частности почему формула для P (вероятности выигрыша из текущей позиции) выглядит так, как она выглядит. И далее я попытаюсь эти пробелы закрыть.

Итак, алгоритм состоит из следующих шагов:

  1. Анализируем каждую позицию в партии при помощи шахматного движка Stockfish, получая значение cp (centipawns) – оценку позиции в сантипешках (т.е. для каждой позиции получаем значение cp).

  2. Для каждой позиции из оценки движка получаем вероятность победы (или шанс победы – winning chance в терминах Lichess) в партии при помощи формулы P=50 + 50 cdot (2 / (1 + e^{-0.00368208 cdot cp}) - 1).

  3. Для каждого хода считаем точность, используя вероятность P_0 до хода и P_1 после: Accuracy=103.1668 cdot e^{-0.04354 cdot (P_0 - P_1)} - 3.1669

  4. Для каждого игрока берём массивы значений точности его ходов и вероятностей победы. И далее получаем точность игры с помощью следующих действий отдельно для белых и чёрных:

    • Разбиваем массив вероятностей скользящим окном, размер которого зависит от общего числа ходов в партии (от 2 до 8).

    • Внутри каждого окна считаем стандартное отклонение вероятностей.

    • Вычисляем взвешенное среднее точности ходов в партии, взяв в качестве весов стандартные отклонения вероятностей, полученные на предыдущем шаге (для позиции i используем окно, которое заканчивается в этой позиции, для i < длины окна, берём первое окно – т.е. вес отражает изменение вероятности в текущей и нескольких предыдущих позициях).

    • Считаем гармоническое среднее точности ходов.

    • В качестве точности игрока в партии берём среднее результатов двух предыдущих шагов (т.е. взвешенного среднего и гармонического среднего точности отдельных ходов) – именно это значение мы видим в строках Accuracy на рис 2.

Далее разберём каждый шаг подробно и попробуем понять какая интуиция скрывается за каждым из них.

Сантипешки и где они обитают

В первом шаге алгоритма используется шахматный движок Stockfish, который оценивает отдельную позицию игры в своей “внутренней валюте” – сантипешках (centipawn или просто cp). Обычно на шахматных платформах такая оценка отображается относительно белых – положительное значение означает преимущество белых, 0 – равная позиция, отрицательное – преимущество чёрных. Чем больше абсолютное значение, тем больше преимущество.

Интуитивно выглядит логичным, что точность партии определяется точностью отдельных ходов, а точность хода должна зависеть от разности в оценке движка до хода и после него: чем меньше падение преимущества игрока после хода, тем точнее сам ход. Почему же нельзя использовать для этой цели просто оценку движка? Есть хороший пост на эту тему – Centipawns Suck. Если кратко, то одинаковая потеря в сантипешках не означает одинакового ущерба. Так, потерять 100 cp в равной позиции – это серьёзно (чем выше уровень игроков, тем конечно, серьёзнее), но потерять те же 100 cp в уже выигранной позиции – часто почти неважно. Иными словами сантипешки – нелинейная шкала относительно шансов на результат: движок оценивает позицию в единицах, удобных для поиска и сравнения вариантов, но эта величина не обязана линейно соответствовать вероятности победы. Например, если до хода белых позиция была равная (0 cp), а после стала -100 cp, шансы на победу белых заметно снизились (опять же, если мы говорим не о новичках), но если до хода мы имели 900 cp, а после 800 cp, то шансы изменились незначительно.

Как я уже упоминал ранее, считается, что шахматы – игра ничейная, и изменение оценки в идеале (если у нас бесконечные вычислительные ресурсы) происходит только из-за ошибок игроков. Поэтому в идеальном мире (когда движок не ограничен ресурсами) его оценка не может увеличить преимущество игрока после его собственного хода – если игрок нашёл лучший ход, оценка остаётся неизменной (и, очевидно, точность такого хода 100%), а если не нашёл – падает (точность хода ниже 100%). В реальном же мире мы можем наблюдать улучшение оценки после хода, в частности, потому что движок может посмотреть вперёд на большее число ходов, чем ранее. Поэтому в коде Lichess значения шанса победы и точности дополнительно ограничиваются допустимым диапазоном.

Плодим новые сущности

Итак, мы выяснили, что сырая оценка движка нам не подходит. Совсем без движка очевидно нам тоже не обойтись, поэтому нужно придумать, как преобразовать сантипешки во что-то более полезное таким образом, чтобы разность новых величин имела одинаковое влияние на точность вне зависимости от их абсолютных значений.

Одной из таких величин является ожидаемый исход партии (Expected Game Outcome), который используется в движке AlphaZero и находится в диапазоне от -1 до +1, где -1 – означает поражение, 0 – ничью и +1 – победу. Можно немного упростить эту идею, и, не акцентируя внимание на ничейном исходе, спроецировать отрезок [-1, 1] в диапазон от 0 до 100%, назвав шансом победы (chance of winning) в терминах Lichess. Ниже я буду называть такую величину шансом победы или модельной вероятностью победы.

Разность шансов победы интуитивно выглядит величиной, которая будет одинаково влиять на точность, независимо от абсолютного значения перед ходом, с учётом того, что после хода шанс должен или оставаться неизменным (если найден лучший ход) или падать. Последнее утверждение следует из особенности оценки движка, которая упоминалась в предыдущем разделе про сантипешки – падение преимущества происходит из-за ошибок и текущая оценка предполагает нахождение лучшего хода, чтобы её сохранить (хотя на практике это не всегда выполняется из-за ограничений ресурсов движка).

Как мы можем вычислить ожидаемый исход партии (или в терминах Lichess шанс победы)? Чтобы не изобретать велосипед, можно переиспользовать подход из рейтинга Эло. Рейтинг Эло – это численная оценка силы шахматиста, предложенная Арпадом Эло. При этом разность рейтингов двух игроков позволяет оценить их ожидаемый счёт в партии (т.е. математическое ожидание числа очков, когда за победу игрок получает 1 очко, за ничью 0.5 и за поражение 0). Так, если ожидаемый счёт игрока равен 0.75, это не обязательно значит “75% побед”, это смесь побед, ничьих и поражений, дающая в среднем 0.75 очка за партию.

Ожидаемый счёт игрока A в партии против B можно рассчитать по формуле:

E_A=frac{1}{1+10^{-(R_A-R_B)/400}}.

Где R_A – рейтинг Эло игрока A, R_B – рейтинг Эло игрока B. При этом разность R_A - R_Bможно рассматривать как потенциальное преимущество игрока A над игроком B.

Не будем вдаваться в само устройство рейтинга, важно лишь понимать, что он тесно связан с моделью Брэдли-Терри – вероятностной моделью для результатов попарных сравнений между элементами, командами или объектами. Модель Брэдли-Терри можно параметризовать разными способами, один из которых приводит к логистической форме вероятности, где предполагается, что log-odds (логарифм отношения шансов, то есть логарифм отношения вероятности того, что событие произойдёт, к вероятности того, что оно не произойдёт) линейно зависит от предикторов:

logfrac{P}{1-P}=beta_0 + beta_1 x_1 + dots + beta_k x_k.

Для рейтинга Эло в качестве такого предиктора используется преимущество одного игрока над другим в виде разности их рейтингов. Мы можем переиспользовать эту идею для отдельных шахматных позиций и в качестве преимущества игрока в конкретной позиции взять оценку движка в сантипешках. Тогда, также как и для разности рейтингов Эло, положительная оценка означает преимущество данного игрока, 0 – равенство и отрицательная оценка – преимущество оппонента. В итоге для нашего случая уравнение выше будет выглядеть следующим образом:

logfrac{P}{1-P}=kcdot cp.

Где k – неизвестный коэффициент, cp – оценка позиции в сантипешках, P – модельная вероятность победы.

Зная k, мы сможем получить оценку модельной вероятности победы:

P(cp)=frac{1}{1+e^{-kcp}}

Если же говорить в терминах ожидаемого исхода партии (когда у нас диапазон не от 0 до 1 как для вероятности, а от -1 до 1), то формула запишется в виде:

E(cp)=frac{2}{1+e^{-kcp}} - 1

Где E – ожидаемое число очков в партии в случае, если за поражение даётся -1, за ничью 0, а за победу 1.

Найти коэффициент k мы можем, например, решая задачу оптимизации, предварительно собрав датасет из пар (исход партии, оценка позиции). Инженеры Lichess описали этот процесс в одном из PR на github. Для оптимизации они использовали функцию curve_fit из scipy.optimize, реализующую метод наименьших квадратов (ссылка на на комментарий с кодом). Задача решалась на 75k отфильтрованных позиций в виде пар (cp, y), где cp – оценка движка и y – исход игры (-1 – поражение, 0 – ничья, 1 – победа). Для попадания в датасет позиция должна соответствовать ряду критериев, например, рейтинг игроков не ниже 2300, тип временного контроля “рапид”, до конца партии оставалось более 4 ходов, партия не завершилась по времени и др.

В результате коэффициент k получается примерно равен 0.00368208 и итоговая формула для шанса победы (от 0 до 100%), упомянутая в Lichess Accuracy metric записывается в виде:

P=50 + 50 cdot (2 / (1 + e^{-0.00368208 cdot cp}) - 1)

То есть это обычная логистическая функция, просто выраженная в процентах.

Рис 4. Зависимость модельной вероятности победы (Y) от оценки движка (X)

Рис 4. Зависимость модельной вероятности победы (Y) от оценки движка (X)

Поскольку эта величина получается не просто эмпирической подгонкой, а через логистическую модель, ниже я буду называть её модельной вероятностью победы.

Итак, мы умеем считать модельную вероятность, разность которой подходит для расчёта точности. Осталось разобраться как посчитать саму точность. Для этого разработчики lichess подобрали калибровочную кривую по нескольким эмпирически выбранным парам (разница модельных вероятностей, точность хода): (0, 100), (5, 75), (10, 60), (20, 42), (40, 20), (60, 5), (80, 0), (90, 0), (100, 0). Код оптимизации можно найти в комментарии рядом с реализацией функции. Не будем вдаваться в подробности выбора функции: похоже, что она просто отражает интуитивное представление авторов о том, как должна выглядеть связь между разницей модельных вероятностей и точностью. Иными словами, функция такого вида (a cdot e^{-k cdot x} - b) хорошо подходит для выбранных ими пар. Коэффициенты a, b и k также, как и для формулы ожидаемого исхода, находятся с помощью curve_fit и итоговая формула выглядит следующим образом:

Accuracy=103.1668 cdot e^{-0.04354 cdot (P_0 - P_1)} - 3.1669

Рис 5. Зависимость точности хода (Y) от разности модельной вероятности победы (X)

Рис 5. Зависимость точности хода (Y) от разности модельной вероятности победы (X)

Другой пример использования модельной вероятности победы

В lichess.org модельная вероятность победы (или шанс победы, winning chance) используется не только для расчёта точности хода, но и для классификации ошибочных ходов игрока. Lichess.org разбивает ошибки на 3 класса в зависимости от падения модельной вероятности после хода (код):

  • inaccuracy – падение от 5 до 10%

  • mistake – падение от 10 до 15%

  • blunder – падение более 15%

Границы здесь заданы для шкалы 0-100%, хотя можно заметить, что в исходном коде границы равны 0.1, 0.2 и 0.3 по причине использования шкалы для ожидаемого исхода партии от -1 до 1, где 0 соответствует 50%.

Статистика таких ошибок отображается в результатах компьютерного анализа. На рис 1 и 2 в начале поста видно, что классификация ходов у lichess.org и chess.com отличается. К слову, на lichess.org после анализа партии можно провести работу над ошибками, попытавшись найти хороший ход (не обязательно лучший, достаточно не просадить вероятность больше чем на 5% исходя из критериев выше).

Вспоминаем формулу среднего гармонического

Теперь, когда мы научились считать точность отдельного хода, можем ли мы просто взять какой-нибудь стандартный вариант среднего (например, среднее арифметическое) и получить точность всей партии? Перед ответом на этот вопрос стоит подумать о требованиях, которые разумно наложить на функцию агрегации точности ходов. Исходя из имеющегося алгоритма, можно предположить, что в lichess.org использовали примерно такую интуицию:

  1. Метрика точности в lichess.org изначально создаётся и калибруется по играм мастеров, и большинство ходов в играх имеют достаточно высокую точность, а исход игры будет определяться небольшой долей ошибок. Поэтому алгоритм агрегации должен быть достаточно чувствительным к плохим ходам.

  2. Игра состоит как из ходов в относительно стабильных позициях (дебюты, доигрывание выигрышной позиции и т.п.), так и из моментов острой борьбы, где обычно и решается судьба партии. Алгоритм должен уметь находить такие важные моменты игры и уделять им больше внимания.

Мне было бы очень интересно узнать, как рассуждали разработчики lichess.org здесь, как и в других местах, – повторюсь, что могу лишь предполагать и приглашаю всех желающих подискутировать в комментариях.

Очевидно, что среднее арифметическое здесь не подходит: оно не чувствительно к плохим ходам и важным моментам в игре. Среднее гармоническое удовлетворяет только первому критерию. Для второго требования инженеры lichess.org использовали вполне логичную идею: “если шансы на победу в этом фрагменте партии колебались сильно, значит здесь происходило что-то важное”. Для этого они используют взвешивание по важности (importance weighting) в виде контекстно-взвешенного среднего, беря в качестве весов волатильность модельных вероятностей в виде стандартного отклонения внутри окна небольшого размера.

Далее (вероятно в процессе калибровки этих двух вариантов агрегации и, возможно, каких-то других на реальных партиях) делается вывод, что лучше всего подходит среднее двух этих метрик (среднего гармонического и контекстно-взвешенного среднего), а каждая из этих метрик по отдельности даёт перекос. В итоге получается метрика, которая в равной степени учитывает “насколько хорошо игрок играл в важные моменты” и “насколько игру портят грубые провалы”.

Обещанный код

Код скрипта calc_accuracy.py
from __future__ import annotations

import argparse
import io
import os
from typing import Final

import chess.engine
import chess.pgn
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view


GAME_PGN: Final[str] = """
[Event "Live Chess"]
[Site "Chess.com"]
[Date "2026.01.27"]
[Round "?"]
[White "PatrickStawr"]
[Black "MagnusCarlsen"]
[Result "1-0"]
[GameId "YcwSBAL5"]
[WhiteElo "2670"]
[BlackElo "3297"]
[Variant "Standard"]
[TimeControl "300+0"]
[ECO "B27"]
[Opening "Sicilian Defense: Hyperaccelerated Dragon"]
[Termination "Unknown"]
[Annotator "lichess.org"]

1. Nf3 { [%eval 0.1] } 1... c5 { [%eval 0.25] } 2. e4 { [%eval 0.2] } 2... g6 { [%eval 0.34] } { B27 Sicilian Defense: Hyperaccelerated Dragon } 3. Nc3 { [%eval 0.29] } 3... Bg7 { [%eval 0.22] } 4. g3 { [%eval -0.14] } 4... Nc6 { [%eval -0.07] } 5. Bg2 { [%eval -0.1] } 5... d6 { [%eval -0.07] } 6. h3 { [%eval -0.39] } 6... e5 { [%eval -0.41] } 7. O-O { [%eval -0.47] } 7... Nge7 { [%eval -0.49] } 8. a3 { [%eval -0.41] } 8... O-O { [%eval -0.45] } 9. d3 { [%eval -0.46] } 9... h6 { [%eval -0.38] } 10. Ne1 { [%eval -0.63] } 10... Be6 { [%eval -0.62] } 11. Nd5 { [%eval -0.52] } 11... f5 { [%eval -0.44] } 12. f4?! { (-0.44 → -1.01) Inaccuracy. c3 was best. } { [%eval -1.01] } (12. c3 Qd7 13. b4 Rae8 14. Kh2 f4 15. Rb1 b6 16. b5 Nd8 17. c4 Nf7) 12... fxe4 { [%eval -0.93] } 13. dxe4 { [%eval -0.64] } 13... exf4 { [%eval -0.64] } 14. Nxf4 { [%eval -0.86] } 14... Qd7?! { (-0.86 → -0.17) Inaccuracy. Bc4 was best. } { [%eval -0.17] } (14... Bc4 15. Ned3 g5 16. Nh5 Bd4+ 17. Kh1 Rxf1+ 18. Bxf1 d5 19. c3 Bh8 20. Nxc5) 15. Ned3 { [%eval -0.33] } 15... g5 { [%eval -0.33] } 16. Nxe6 { [%eval -0.44] } 16... Qxe6 { [%eval -0.31] } 17. c3 { [%eval -0.35] } 17... Rxf1+ { [%eval -0.34] } 18. Qxf1 { [%eval -0.37] } 18... Rf8 { [%eval -0.44] } 19. Qd1 { [%eval -0.24] } 19... Ne5 { [%eval -0.17] } 20. Be3 { [%eval -0.19] } 20... N7g6 { [%eval -0.25] } 21. Nxe5 { [%eval -0.22] } 21... Bxe5 { [%eval -0.17] } 22. Bf2 { [%eval -0.19] } 22... Kg7 { [%eval -0.23] } 23. Qd2 { [%eval -0.18] } 23... b5 { [%eval 0.0] } 24. Re1 { [%eval -0.26] } 24... a5 { [%eval -0.31] } 25. Bf1 { [%eval -0.53] } 25... Rb8 { [%eval -0.19] } 26. Re2 { [%eval -0.72] } 26... b4 { [%eval -0.74] } 27. axb4 { [%eval -0.78] } 27... axb4 { [%eval -0.78] } 28. Qd3 { [%eval -0.77] } 28... bxc3 { [%eval -0.75] } 29. bxc3 { [%eval -0.85] } 29... h5 { [%eval -0.82] } 30. Qa6?! { (-0.82 → -1.88) Inaccuracy. Qd2 was best. } { [%eval -1.88] } (30. Qd2 Qe7 31. Be3 Bxg3 32. Bxg5 Qe5 33. Qd5 h4 34. Ra2 Qxd5 35. exd5 Nf4) 30... Rf8 { [%eval -1.32] } 31. Rb2 { [%eval -1.04] } 31... Qf6 { [%eval -0.72] } 32. Bg2? { (-0.72 → -2.43) Mistake. Rb7+ was best. } { [%eval -2.43] } (32. Rb7+ Ne7 33. Be1 h4 34. gxh4 gxh4 35. Qe2 Kh6 36. Bd2+ Bf4 37. Be1 Ng6) 32... Bxc3?! { (-2.43 → -1.79) Inaccuracy. Rf7 was best. } { [%eval -1.79] } (32... Rf7 33. Ra2 Bxc3 34. Qe2 Bd4 35. Be3 h4 36. gxh4 Nxh4 37. Qd3 Ng6 38. Bxd4) 33. Rb7+ { [%eval -1.82] } 33... Kh6 { [%eval -1.71] } 34. Qe2 { [%eval -1.77] } 34... Bd4?? { (-1.77 → Mate in 2) Checkmate is now unavoidable. Ne5 was best. } { [%eval #2] } (34... Ne5 35. Rb1 Rf7 36. Be3 Bd4 37. Bxd4 cxd4 38. Rf1 Qe6 39. h4 gxh4 40. gxh4) 35. Qxh5+ { [%eval #1] } 35... Kxh5 { [%eval #1] } 36. Rh7# { White wins. } 1-0
"""

CP_CEILING: Final[int] = 1000
WIN_PROBA_LOGISTIC_SCALE: Final[float] = 0.00368208
ERROR_ACC_DECAY: Final[float] = 0.04354415386753951
ERROR_ACC_BASE: Final[float] = 103.1668100711649
ERROR_ACC_OFFSET: Final[float] = -3.166924740191411
INITIAL_CP: Final[float] = 15.0
MIN_WEIGHT_STD: Final[float] = 0.5
MAX_WEIGHT_STD: Final[float] = 12.0
MIN_WINDOW_SIZE: Final[int] = 2
MAX_WINDOW_SIZE: Final[int] = 8
DEFAULT_DEPTH: Final[int] = 25
MAX_DEPTH: Final[int] = 30
DEFAULT_MULTIPV: Final[int] = 3
MAX_MULTIPV: Final[int] = 5
DEFAULT_TIME_LIMIT_MS: Final[int] = 6000
MAX_TIME_LIMIT_MS: Final[int] = 10000
DEFAULT_NODES: Final[int] = 2250000

ColorArray = np.ndarray
FloatArray = np.ndarray
AccuracyReport = dict[str, dict[str, int]]


def cp_to_win_proba(centipawns: FloatArray) -> FloatArray:
    """Convert centipawn scores into win probabilities on a 0-100 scale."""
    bounded_cp = np.clip(centipawns, -CP_CEILING, CP_CEILING)
    return 50.0 + 50.0 * (2.0 / (1.0 + np.exp(-WIN_PROBA_LOGISTIC_SCALE * bounded_cp)) - 1.0)


def proba_diff_to_acc(proba_diff: FloatArray) -> FloatArray:
    """Map win-probability drops to move accuracy scores."""
    values = np.clip(
        ERROR_ACC_BASE * np.exp(-ERROR_ACC_DECAY * proba_diff) + ERROR_ACC_OFFSET + 1.0,
        0.0,
        100.0,
    )
    values = np.asarray(values, dtype=float)
    values[proba_diff <= 0] = 100.0
    return values


def build_coefs(values: FloatArray) -> FloatArray:
    """Build volatility weights from a sequence of win probabilities."""
    values = np.asarray(values, dtype=float)
    if values.size < MIN_WINDOW_SIZE:
        return np.empty(0, dtype=float)

    window_size = int(np.clip(values.size // 10, MIN_WINDOW_SIZE, MAX_WINDOW_SIZE))
    if values.size < window_size:
        window_size = int(values.size)
    if window_size < MIN_WINDOW_SIZE:
        return np.empty(0, dtype=float)

    windows = sliding_window_view(values, window_shape=window_size)
    if window_size > MIN_WINDOW_SIZE:
        prefix = np.tile(values[:window_size], window_size - MIN_WINDOW_SIZE).reshape((-1, window_size))
        windows = np.concatenate((prefix, windows), axis=0)
    return np.clip(np.std(windows, axis=1), MIN_WEIGHT_STD, MAX_WEIGHT_STD)


def calc_volatility_weighted_mean(accuracies: FloatArray, weights: FloatArray) -> float:
    """Compute a weighted mean using the provided volatility weights."""
    if accuracies.size == 0:
        return float("nan")
    return float(np.sum(accuracies * weights) / np.sum(weights))


def harmonic_mean(values: FloatArray) -> float:
    """Compute the harmonic mean, returning NaN for empty input."""
    values = np.asarray(values, dtype=float)
    if values.size == 0:
        return float("nan")
    return float(values.size / np.sum(1.0 / values))


def _parse_game(pgn_game: str) -> chess.pgn.Game:
    game = chess.pgn.read_game(io.StringIO(pgn_game))
    if game is None:
        raise ValueError("Could not parse PGN game")
    return game


def _score_to_cp(score: chess.engine.PovScore) -> float:
    score = score.white()
    return 10000.0 * score.mate() if score.is_mate() else float(score.cp)


def read_game_cps(pgn_game: str, stockfish_path: str | None = None) -> FloatArray:
    """Read the evaluation trajectory from a PGN game."""
    cps = [INITIAL_CP]
    game = _parse_game(pgn_game)
    if stockfish_path:
        board = game.board()
        limit = chess.engine.Limit(
            depth=DEFAULT_DEPTH,
            time=DEFAULT_TIME_LIMIT_MS / 1000.0,
            nodes=DEFAULT_NODES,
        )
        try:
            with chess.engine.SimpleEngine.popen_uci(stockfish_path) as engine:
                engine.configure({"Threads": os.cpu_count() or 1})
                for move in game.mainline_moves():
                    board.push(move)
                    cps.append(_score_to_cp(engine.analyse(board, limit, multipv=DEFAULT_MULTIPV)[0]["score"]))
        except (OSError, chess.engine.EngineError) as exc:
            raise ValueError(f"Invalid Stockfish path: {stockfish_path}") from exc
        return np.asarray(cps, dtype=float)

    found_eval = False
    for node in game.mainline():
        evaluation = node.eval()
        if evaluation is None:
            continue
        cps.append(_score_to_cp(evaluation))
        found_eval = True
    if not found_eval:
        raise ValueError("PGN has no eval data")
    return np.asarray(cps, dtype=float)


def _color_sequence(length: int, start_from_whites: bool) -> ColorArray:
    base = np.tile(np.array([1.0, -1.0], dtype=float), length // 2 + 1)[:length]
    return base if start_from_whites else -base


def get_win_proba_diffs(win_probas: FloatArray, colors: ColorArray) -> FloatArray:
    """Compute per-move changes in win probability from the mover's perspective."""
    pairs = sliding_window_view(win_probas, window_shape=2)
    oriented_pairs = pairs * colors[: len(pairs)][:, None]
    return oriented_pairs[:, 0] - oriented_pairs[:, 1]


def aggregate_move_accuracies(
    accuracies: FloatArray,
    weights: FloatArray,
    colors: ColorArray,
    is_white: bool,
) -> float:
    """Aggregate move accuracies for one side."""
    color_val = 1.0 if is_white else -1.0
    mask = colors[: len(accuracies)] == color_val
    side_accuracies = accuracies[mask]
    side_weights = weights[mask]
    if side_accuracies.size == 0:
        return float("nan")
    return (calc_volatility_weighted_mean(side_accuracies, side_weights) + harmonic_mean(side_accuracies)) / 2.0


def get_errors_stat(win_proba_diffs: FloatArray, colors: ColorArray, is_white: bool) -> dict[str, int]:
    """Count inaccuracies, mistakes, and blunders for one side."""
    color_val = 1.0 if is_white else -1.0
    diffs = win_proba_diffs[colors[: len(win_proba_diffs)] == color_val]
    return {
        "inaccuracies": int(np.count_nonzero((5.0 < diffs) & (diffs <= 10.0))),
        "mistakes": int(np.count_nonzero((10.0 < diffs) & (diffs <= 15.0))),
        "blunders": int(np.count_nonzero(diffs > 15.0)),
    }


def calc_game_accuracy(
    pgn_game: str,
    start_from_whites: bool = True,
    stockfish_path: str | None = None,
) -> AccuracyReport:
    """Calculate side-specific chess accuracy statistics for a single PGN game."""
    cps = read_game_cps(pgn_game, stockfish_path=stockfish_path)
    colors = _color_sequence(len(cps), start_from_whites=start_from_whites)

    win_probas = cp_to_win_proba(cps)
    win_proba_diffs = get_win_proba_diffs(win_probas, colors)
    weights = build_coefs(win_probas)
    accuracies = proba_diff_to_acc(win_proba_diffs)

    white_stats = get_errors_stat(win_proba_diffs, colors, True)
    black_stats = get_errors_stat(win_proba_diffs, colors, False)

    white_accuracy = aggregate_move_accuracies(accuracies, weights, colors, True)
    black_accuracy = aggregate_move_accuracies(accuracies, weights, colors, False)

    white_stats["accuracy"] = int(np.round(white_accuracy)) if np.isfinite(white_accuracy) else 100
    black_stats["accuracy"] = int(np.round(black_accuracy)) if np.isfinite(black_accuracy) else 100

    return {"White": white_stats, "Black": black_stats}


def format_accuracy_report(result: AccuracyReport) -> str:
    """Format a human-readable accuracy report."""
    lines = ["=== Chess Accuracy Report ==="]
    for color in ("White", "Black"):
        stats = result[color]
        lines.extend(
            [
                f"{color}:",
                f"  Accuracy    : {stats['accuracy']}%",
                f"  Inaccuracies: {stats['inaccuracies']}",
                f"  Mistakes    : {stats['mistakes']}",
                f"  Blunders    : {stats['blunders']}",
            ]
        )
    return "n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("stockfish_path", nargs="?")
    args = parser.parse_args()
    try:
        result = calc_game_accuracy(GAME_PGN, stockfish_path=args.stockfish_path)
    except ValueError as exc:
        parser.error(str(exc))
    print(format_accuracy_report(result))


if __name__ == "__main__":
    main()

Для запуска скрипта кроме самого Python потребуется установить пакеты numpy и python-chess, плюс опционально скачать бинарник движка Stockfish с официального сайта.

Внутри скрипта в переменной GAME_PGN содержится упомянутая ранее партия Jacorey Bynum – Magnus Carlsen в формате PGN. Если при запуске указать путь к Stockfish через аргумент скрипта, то в начале будет выполнен анализ партии (может занимать минуту или даже больше), в противном случае результат анализа будет взят из данных в PGN (в переменной GAME_PGN сохранены оценки от Stockfish, используемого внутри lichess.org).

Пример запуска с использованием Stockfish: calc_accuracy.py /home/user/stockfish. Вполне вероятно, что при таком запуске числа на выходе будут отличаться от результата lichess.org на рис 2. Во-первых, могут не совпадать версия и настройки Stockfish. Во-вторых, движок использует внутри различные оптимизации и выдаёт немного разные результаты при запусках даже с одинаковыми параметрами. Однако при использовании анализа Stockfish от lichess.org (при запуске без параметров), числа полностью совпадают:

=== Chess Accuracy Report ===
White:
Accuracy : 93%
Inaccuracies: 2
Mistakes : 1
Blunders : 0
Black:
Accuracy : 71%
Inaccuracies: 2
Mistakes : 0
Blunders : 1

Вместо заключения

Итак, в начале поста мы задались вопросом: “играл Магнус с точностью 71% или 88.7%? Или правильного ответа здесь просто не существует?”. Разобрав алгоритм Lichess, я для себя делаю вывод, что здесь, по-видимому, не существует единственно верного ответа. Каждая платформа интерпретирует понятие точности по-своему и в алгоритме одной из них мы видим несколько мест, где части алгоритма явно задаются эмпирически, без какой-либо глубокой теории. Например, можно выбрать другие параметры калибровки кривой точности хода или другой алгоритм усреднения и получить в результате совсем другое число. Поэтому (особенно если вы только начинаете играть в шахматы), скорее всего, не стоит обращать на эту метрику никакого внимания.

Автор: dstefeev

Источник