Небольшое замечание. Я иногда сам поглядываю в код ifrit'а, чтобы посмотреть, как реализована та или иная эвристика. Этот движок, как и мой, написан на C++. Код там во многом довольно понятный и хорошо прокомментирован. Но я бы не назвал код ifrit'а хорошим, поскольку в нем много дублирующегося кода. Анализ для белых и для черных написан в двух отдельных функциях, которые очень похожи друг на друга. Интересно, конечно, почему автор не воспользовался NegaMax'ом
Еще во время сегодняшнего тестирования на Battlefield'е я обнаружил, что на маленьком контроле времени (менее 60 секунд на игру) SoFCheck может проиграть из-за просрочки по времени. Надо разбираться, почему так
Что-то я очень долго не писал ничего в код SoFCheck'а и в этот канал. Сегодня у меня наконец-то появилось немного свободного времени, чтобы всем этим заняться
Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на C++. Сейчас там написана какая-то кастомная логика, которая смотрит на
По этой причине мне понадобилась библиотека для парсинга командной строки. Требования примерно такие:
- легко встроить в проект, не должно быть внешних зависимостей
- нормальная работа при сборке с флагом
- красивое отображение справки: если описание параметра или программы слишком длинное, его надо разбивать по строкам
Я посмотрел на имеющиеся библиотеки на awesome-cpp и выбрал для себя три:
1) https://github.com/bfgroup/Lyra: довольно неплохо работает, вообще не использует исключений, но не умеет переносить строки (см. issue)
2) https://github.com/taywee/args: хоть там и есть опция
3) https://github.com/jarro2783/cxxopts: работает, умеет собираться без исключений (в этом случае при ошибке в параметрах командной строки программа просто завершается), но не умеет переносить описание программы, если оно слишком длинное (завел issue про это)
В итоге я выбрал 3), а проблему с переносом строк решил уже своими костылями и обертками. После этого осталось только убрать кастомную логику и немного пропатчить код
Наконец, покажу реализацию функции
Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на C++. Сейчас там написана какая-то кастомная логика, которая смотрит на
argc и argv напрямую. Я скоро собираюсь улучшать утилиту для сборки датасетов и добавить в нее больше флагов командной строки, но тогда кастомная логика станет довольно сложнойПо этой причине мне понадобилась библиотека для парсинга командной строки. Требования примерно такие:
- легко встроить в проект, не должно быть внешних зависимостей
- нормальная работа при сборке с флагом
-fno-exceptions (предполагается, что SoFCheck должен с ним нормально собираться)- красивое отображение справки: если описание параметра или программы слишком длинное, его надо разбивать по строкам
Я посмотрел на имеющиеся библиотеки на awesome-cpp и выбрал для себя три:
1) https://github.com/bfgroup/Lyra: довольно неплохо работает, вообще не использует исключений, но не умеет переносить строки (см. issue)
2) https://github.com/taywee/args: хоть там и есть опция
ARGS_NOEXCEPT, но библиотека работает с багами при ее использовании. Завел им issue по этому поводу3) https://github.com/jarro2783/cxxopts: работает, умеет собираться без исключений (в этом случае при ошибке в параметрах командной строки программа просто завершается), но не умеет переносить описание программы, если оно слишком длинное (завел issue про это)
В итоге я выбрал 3), а проблему с переносом строк решил уже своими костылями и обертками. После этого осталось только убрать кастомную логику и немного пропатчить код
Наконец, покажу реализацию функции
wordWrap() для разбиения длинных строк: ссылка. Код, конечно, не самый тривиальный, зато оптимальный и работает всегда за O(n)У меня развалился GitHub Actions со странной ошибкой на
git clone, которая проявляется время от времени:$ git clone --branch v1.5.5 https://github.com/google/benchmark/
Cloning into 'benchmark'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 5898 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Вот еще одно падение, на этот раз при клонировании Google Test:$ git clone --branch release-1.10.0 https://github.com/google/googletest/Надеюсь, что GitHub скоро поправит эту проблему, а то у меня весь CI сегодня красный :(
Cloning into 'googletest'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 1885 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Сегодня расскажу немного деталей про то, как я генерировал данные для обучения
Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция
При написании оценочной функции есть большой простор для творчества. Как это происходит в SoFCheck, я уже писал ранее: раз, два. Еще упомяну, что здесь можно использовать нейросети, как это сделано, например, в Stockfish, но я пока не готов к такому в своем движке
В оценочной функции надо подбирать коэффициенты. Для этого нужно обучение на большом датасете. Как я уже писал ранее, для этого я гонял много партий SoFCheck с самим собой на Android-планшете в Termux. Почему именно на планшете?
1) почему бы и нет? :)
2) параллельно хочется на ноутбуке экспериментировать с движком, а не ждать, пока сгенерируется набор данных
3) современные мобильные процессоры довольно быстрые и ненамного уступают десктопным (на ноутбуке SoFCheck успевает анализировать 8 млн позиций в секунду, на планшете — 4.5 млн), при этом они еще и многоядерные
Теперь расскажу про технические проблемы, которые возникли на этом пути. Основная — надо скомпилировать свой код под ARM и Android. К счастью, в Termux можно поставить
С Battlefield сложнее. Дело в том, что он по историческим причинам написан на Free Pascal, а среди пакетов Termux паскаля нет. К счастью, у Free Pascal есть довольно неплохая кросс-компиляция. Через fpcupdeluxe можно поставить все необходимое: Free Pascal и Lazarus (IDE под Free Pascal + система сборки) любой версии, а также необходимые кросс-компиляторы
Ради интереса я решил глянуть с исходники самого fpcupdeluxe на GitHub. Код там местами написан ужасно. Вот вам, например, функция на 1025 (!) строк: ссылка. Зато работает :)
После кросс-компиляции Battlefield, к сожалению, не запустился и выдавал ошибку при запуске:
Наконец, надо как-то забрать с планшета полученный датасет. Для этого можно, например, поднять в Termux сервер ssh и подключиться к нему по SFTP. Это в целом полезная штука, которая позволяет гонять данные между устройствами
Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция
При написании оценочной функции есть большой простор для творчества. Как это происходит в SoFCheck, я уже писал ранее: раз, два. Еще упомяну, что здесь можно использовать нейросети, как это сделано, например, в Stockfish, но я пока не готов к такому в своем движке
В оценочной функции надо подбирать коэффициенты. Для этого нужно обучение на большом датасете. Как я уже писал ранее, для этого я гонял много партий SoFCheck с самим собой на Android-планшете в Termux. Почему именно на планшете?
1) почему бы и нет? :)
2) параллельно хочется на ноутбуке экспериментировать с движком, а не ждать, пока сгенерируется набор данных
3) современные мобильные процессоры довольно быстрые и ненамного уступают десктопным (на ноутбуке SoFCheck успевает анализировать 8 млн позиций в секунду, на планшете — 4.5 млн), при этом они еще и многоядерные
Теперь расскажу про технические проблемы, которые возникли на этом пути. Основная — надо скомпилировать свой код под ARM и Android. К счастью, в Termux можно поставить
clang, cmake и скомпилить движок прямо на планшетеС Battlefield сложнее. Дело в том, что он по историческим причинам написан на Free Pascal, а среди пакетов Termux паскаля нет. К счастью, у Free Pascal есть довольно неплохая кросс-компиляция. Через fpcupdeluxe можно поставить все необходимое: Free Pascal и Lazarus (IDE под Free Pascal + система сборки) любой версии, а также необходимые кросс-компиляторы
Ради интереса я решил глянуть с исходники самого fpcupdeluxe на GitHub. Код там местами написан ужасно. Вот вам, например, функция на 1025 (!) строк: ссылка. Зато работает :)
После кросс-компиляции Battlefield, к сожалению, не запустился и выдавал ошибку при запуске:
bash: ./battlefield: No such file or directoryПроблема оказалась в том, что в бинарнике по какой-то причине неправильно выставлялся путь до динамического линкера. Решается проблема просто:
$ patchelf --set-interpreter /system/bin/linker64 battlefield
После этого Battlefield прекрасно работает :)Наконец, надо как-то забрать с планшета полученный датасет. Для этого можно, например, поднять в Termux сервер ssh и подключиться к нему по SFTP. Это в целом полезная штука, которая позволяет гонять данные между устройствами
Наконец, расскажу, что я хочу сделать с обучением дальше. Я почитал обучалку от движка zurichess, которая использует похожие идеи
Понял следующее: мне нужно сгенерировать бо́льший датасет (100'000 игр, например), но при этом не использовать его полностью (иначе все данные просто не поместятся в оперативную память), а рандомно выбрать из датасета сколько-то позиций (10^6, например). Тогда обучение теоретически увидит больше разных ситуаций и построит более сбалансированную оценку. Но это еще предстоит узнать :)
Во-вторых, мне нужно более оптимально хранить датасеты. Сейчас они хранятся, по сути, в виде списка всех позиций в формате FEN. Например:
Эксперименты показывают, что экономия в таком случае получается существенной: примерно в 10 раз. Что интересно, изменение формата сильно уменьшает размер, даже если хранить датасеты в сжатом виде. Пусть
Стоит отметить, что сжатие само по себе существенно уменьшает размер файла:
Понял следующее: мне нужно сгенерировать бо́льший датасет (100'000 игр, например), но при этом не использовать его полностью (иначе все данные просто не поместятся в оперативную память), а рандомно выбрать из датасета сколько-то позиций (10^6, например). Тогда обучение теоретически увидит больше разных ситуаций и построит более сбалансированную оценку. Но это еще предстоит узнать :)
Во-вторых, мне нужно более оптимально хранить датасеты. Сейчас они хранятся, по сути, в виде списка всех позиций в формате FEN. Например:
game B 1Хочется хранить вместо этого список ходов:
board rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
board rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
board rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2
board rnbqkbnr/pppppp1p/6p1/8/3PP3/8/PPP2PPP/RNBQKBNR b KQkq d3 0 2
game B 1
moves e2e4 e7e5 d2d4
Я мог бы хранить партии просто как PGN, но там не очень приятный для парсинга формат ходов (и SoFCheck не умеет их парсить). Куда проще подавать ходы в формате UCI, который понимает любой шахматный движокЭксперименты показывают, что экономия в таком случае получается существенной: примерно в 10 раз. Что интересно, изменение формата сильно уменьшает размер, даже если хранить датасеты в сжатом виде. Пусть
old.txt — файл в старом формате, а new.txt — в новом. При сжатии с помощью bzip2 получается, что old.txt.bz2 больше в 10 (!) раз, чем new.txt.bz2, а при использовании lzma на максимальной степени сжатия — в 3.5 раза. Хотя, казалось бы, соседние позиции довольно похожи, а запись хода буквально кодирует, откуда и куда переместилась фигураСтоит отметить, что сжатие само по себе существенно уменьшает размер файла:
old.txt больше, чем old.tar.bz2 примерно в 9 раз. Может быть, такая высокая степень сжатия возникает из-за того, что в датасете много повторяющихся партий (или почти повторяющихся, когда долго совпадают первые ходы, а потом уже партии различаются). Это еще одна гипотеза, которую я собираюсь проверитьПро тесты в SoFCheck
Чтобы тестировать движок, я использую разные стратегии тестирования. Во-первых, юнит-тесты с помощью фреймворка Google Test: пример. Таким образом тестируется не весь код, а только часть, в надежности которой я не уверен, и не могу нормально протестировать другими методами. Мне лень писать юнит-тесты на весь код :)
В реализации правил, например, юнит-тестов нет вообще. Но корректность правил тоже проверяется, просто куда более мощным методом под названием selftest. Эта штука запускает генерацию ходов и проверки на шахи на куче разных позиций. Затем selftest сравнивает результаты генерации с тем, что выдает на тех же позициях мой более старый движок, Dodecahedron. В нем абсолютно другая реализация правил, и шансы дважды допустить одну и ту же ошибку стремятся к нулю :) Попутно на всех этих позициях SoFCheck проверяет разные инварианты: что отмена хода работает корректно, что если загрузить и сохранить доску из FEN, то получится тот же результат, и т.д. Такой набор проверок позволяет быть уверенным в том, что правила с большой вероятностью написаны без багов. Еще selftest сделан так, что на нем можно тестировать не только SoFCheck, а любую реализацию правил на C++. Есть даже гайд про это
(Насчет Dodecahedron: он в свое время проверялся похожим методом на еще более старой реализации правил, поэтому ошибка будет незамеченной, только если она допущена трижды в одном и том же месте)
Как генерируются позиции для selftest'а? Генератор выглядит вот так. Если кратко, то там 100 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)
Есть тесты в
Есть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось
Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены
Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее
Чтобы тестировать движок, я использую разные стратегии тестирования. Во-первых, юнит-тесты с помощью фреймворка Google Test: пример. Таким образом тестируется не весь код, а только часть, в надежности которой я не уверен, и не могу нормально протестировать другими методами. Мне лень писать юнит-тесты на весь код :)
В реализации правил, например, юнит-тестов нет вообще. Но корректность правил тоже проверяется, просто куда более мощным методом под названием selftest. Эта штука запускает генерацию ходов и проверки на шахи на куче разных позиций. Затем selftest сравнивает результаты генерации с тем, что выдает на тех же позициях мой более старый движок, Dodecahedron. В нем абсолютно другая реализация правил, и шансы дважды допустить одну и ту же ошибку стремятся к нулю :) Попутно на всех этих позициях SoFCheck проверяет разные инварианты: что отмена хода работает корректно, что если загрузить и сохранить доску из FEN, то получится тот же результат, и т.д. Такой набор проверок позволяет быть уверенным в том, что правила с большой вероятностью написаны без багов. Еще selftest сделан так, что на нем можно тестировать не только SoFCheck, а любую реализацию правил на C++. Есть даже гайд про это
(Насчет Dodecahedron: он в свое время проверялся похожим методом на еще более старой реализации правил, поэтому ошибка будет незамеченной, только если она допущена трижды в одном и том же месте)
Как генерируются позиции для selftest'а? Генератор выглядит вот так. Если кратко, то там 100 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)
Есть тесты в
static_assert, которые гоняются прямо во время компиляции: ссылкаЕсть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось
Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены
Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее
Как-то раз Антон (@Wind-Eagle) заметил, что у меня медленная реализация генератора ходов для пешек. Я решил сегодня взять и переписать
Запустил код на микробенчмарках. В позициях, где много пешек, генерация ходов ускорилась на 10-15%. Тем не менее, результаты оказались довольно шумными, и некоторые бенчмарки замедлились процентов на 5. Бенчмарки, которые имитируют рекурсивный анализ, практически не поменялись
А на реальном анализе получилось ускорение: на 9% в позиции с одними пешками, на 7% в околодебютных позициях, на 3-5% в глубоком миттельшпиле. Чем дальше от дебюта, тем меньше ускорение, поскольку там становится все меньше и меньше пешек; в эндшпиле старая и новая версии работают примерно одинаково быстро. Тем не менее, результат довольно неплохой :)
Коммит можно посмотреть здесь: ссылка
Еще я понял, что мои микробенчмарки не особо хороши и не отражают реальную скорость. Например бенчмарки, которые имитируют рекурсивный анализ, пытаются сделать все ходы в позициях, т.е. на них больше влияет скорость
Запустил код на микробенчмарках. В позициях, где много пешек, генерация ходов ускорилась на 10-15%. Тем не менее, результаты оказались довольно шумными, и некоторые бенчмарки замедлились процентов на 5. Бенчмарки, которые имитируют рекурсивный анализ, практически не поменялись
А на реальном анализе получилось ускорение: на 9% в позиции с одними пешками, на 7% в околодебютных позициях, на 3-5% в глубоком миттельшпиле. Чем дальше от дебюта, тем меньше ускорение, поскольку там становится все меньше и меньше пешек; в эндшпиле старая и новая версии работают примерно одинаково быстро. Тем не менее, результат довольно неплохой :)
Коммит можно посмотреть здесь: ссылка
Еще я понял, что мои микробенчмарки не особо хороши и не отражают реальную скорость. Например бенчмарки, которые имитируют рекурсивный анализ, пытаются сделать все ходы в позициях, т.е. на них больше влияет скорость
moveMake(), чем генерации ходов. В реальности из позиции делается в среднем 2-3 хода до отсечения, поэтому производительность moveMake() куда менее заметна, чем в бенчмаркахВчера я улучшил оценку оставшегося времени в BattleField. Сейчас расскажу, как :)
Обычно BattleField выводит прогресс примерно в таком виде:
Несмотря на то, что эта формула очень простая, она не лишена недостатков. Она может выдавать сильно завышенные оценки на оставшееся время, когда прошло еще мало игр. Постепенно оценка сходится к реальному значению, но это происходит довольно медленно
Как можно сделать лучше? Построим такую модель. Пусть у нас время одной игры — случайная величина с математическим ожиданием
Осталось научиться оценивать
Затем я немного поколдовал с симулятором и нашел довольно неплохую оценку для
Обычно BattleField выводит прогресс примерно в таком виде:
321/5000 games completed (7.99 min/2.07 hours), score = 153.0:168.0
Как оценить, сколько времени осталось (2.07 hours в строчке выше)? Раньше эта оценка производилась по очень простой формуле: total_time = time / count * total, где time — прошедшее время, count — количество сыгранных игр и total — общее количество игрНесмотря на то, что эта формула очень простая, она не лишена недостатков. Она может выдавать сильно завышенные оценки на оставшееся время, когда прошло еще мало игр. Постепенно оценка сходится к реальному значению, но это происходит довольно медленно
Как можно сделать лучше? Построим такую модель. Пусть у нас время одной игры — случайная величина с математическим ожиданием
1. У нас есть jobs потоков. Каждый поток берет задачу, если задачи еще остались, и выполняет ее. Найдем математическое ожидание времени работы count игр — f(jobs, count). Тогда оценим время работы как total_time = time / f(jobs, count) * f(jobs, total). Здесь необязательно брать реальное значение f(jobs, count) — достаточно лишь довольно точной оценки (чем точнее, тем лучше)Осталось научиться оценивать
f(jobs, count). Здесь имеется две проблемы. Во-первых, я с ходу не придумал, как аналитически оценить это математическое ожидание. Во-вторых, мы не знаем распределение времени одной игры, а от этого зависит ответ. По этой причине будем подбирать оценку на f(jobs, count) эмпирически, считая, что время работы распределено нормально с некоторой дисперсией (я сейчас предполагаю, что дисперсия равна 0.25, хотя надо бы измерить, какова дисперсия времени работы на самом деле). Далее я написал симулятор, который находит требуемое матожидание с помощью метода Монте-КарлоЗатем я немного поколдовал с симулятором и нашел довольно неплохую оценку для
f(jobs, count). Ее можно увидеть в коммите, который улучшает оценку времени. Чтобы еще улучшить результаты, можно подбирать формулу не руками, а с помощью интерполяции или ML. Но это я сделаю как-нибудь потомЯ писал в прошлом посте, как я улучшил оценку оставшегося времени в BattleField. Сегодня оказалось, что я набагал в оценке и словил в BattleField'е деление на ноль, если
Не буду описывать здесь, как оно работает. Лучше смотрите в ноутбук, там все довольно хорошо описано. Есть еще коммит, в котором я как раз поправил функцию оценки
Теперь я полностью доволен тем, как работает оценивание времени работы :) Оно не завышает сильно оценку, если было сыграно малое количестве партий, как это происходило ранее
jobs = 1. Попытался исправить функцию оценки и понял, что не так уж она и хороша. А это значит, что можно воспользоваться машинным обучением, чтобы ее улучшить :)Не буду описывать здесь, как оно работает. Лучше смотрите в ноутбук, там все довольно хорошо описано. Есть еще коммит, в котором я как раз поправил функцию оценки
Теперь я полностью доволен тем, как работает оценивание времени работы :) Оно не завышает сильно оценку, если было сыграно малое количестве партий, как это происходило ранее
Пора рассказать, что у меня получилось за последнее время. А изменений было достаточно много :)
Во-первых, я реализовал все то, что хотел здесь. Для хранения датасетов теперь используется формат, спецификацию которого я описал по ссылке и научил Battlefield выводить файлы в этом формате. Действительно, после этого датасеты стали занимать гораздо меньше места на диске. К тому же, я сконвертировал старые датасеты в новый формат с помощью несложного скрипта
Потом надо было научить SoFCheck понимать новый формат. Для этого у меня есть небольшая библиотека для парсинга этого формата. Когда я ее писал, мне понадобился
После этого я реализовал фильтрацию в
Во-первых, я реализовал все то, что хотел здесь. Для хранения датасетов теперь используется формат, спецификацию которого я описал по ссылке и научил Battlefield выводить файлы в этом формате. Действительно, после этого датасеты стали занимать гораздо меньше места на диске. К тому же, я сконвертировал старые датасеты в новый формат с помощью несложного скрипта
Потом надо было научить SoFCheck понимать новый формат. Для этого у меня есть небольшая библиотека для парсинга этого формата. Когда я ее писал, мне понадобился
unique_ptr<T> с копированием: при копировании он должен копировать данные, на которые он указывает. Зачем это нужно? В коде повсеместно используются variant'ы, а один из вариантов содержит внутри себя доску и занимает из-за этого ~200 байт. Но тогда весь variant будет занимать неоправданно много места, что неприемлемо. Для решения такой проблемы как раз требуется спрятать доску за указатель. При этом хочется, чтобы копирование объектов по-прежнему работало. В итоге я написал что-то такоеПосле этого я реализовал фильтрацию в
make_dataset, сгенерировал 100'000 партий движка с самим собой и обучил на 3 млн позиций из этих партий (из примерно 10 млн позиций). Выигрыша по сравнению с нефильтрованной выборкой, к сожалению, я не получил :(Еще я написал классный скрипт owl.py, который упрощает мне жизнь. Например, раньше для утилиты
Еще owl может собрать несколько версий движка в одну команду. Это бывает очень полезно, чтобы сравнить разные версии по силе. Сборка нескольких версий реализована предельно просто: сохраняем незакоммиченные изменения, переходим на нужные коммиты, собираем с помощью CMake, возвращаем все обратно
А еще owl умеет рисовать маленькую сову ASCII-артом, которую я кидал в канал выше :)
Как owl узнает конфигурацию проекта и системы, чтобы все это делать? Все просто: нужные пути подставляются в скрипт CMake'ом: ссылка
apply_weights, которая записывает новые коэффициенты в features.json, надо было указывать путь до features.json. А owl сам знает путь до этого файла, и для обновления коэффициентов достаточно просто написать./owl.py w a
В качестве приятного бонуса он сразу пересоберет движок с новыми коэффициентами :)Еще owl может собрать несколько версий движка в одну команду. Это бывает очень полезно, чтобы сравнить разные версии по силе. Сборка нескольких версий реализована предельно просто: сохраняем незакоммиченные изменения, переходим на нужные коммиты, собираем с помощью CMake, возвращаем все обратно
А еще owl умеет рисовать маленькую сову ASCII-артом, которую я кидал в канал выше :)
Как owl узнает конфигурацию проекта и системы, чтобы все это делать? Все просто: нужные пути подставляются в скрипт CMake'ом: ссылка
Еще полезное улучшение: Battlefield теперь может показывать доверительный интервал для разницы в рейтинге Эло. Например, так:
- для 100 игр: ±60 Эло
- для 1000 игр: ±25 Эло
- для 10000 игр: ±7 Эло
То есть, чтобы протестировать мелкое изменение, нужно от 1000 до 10000 игр, что довольно много
Elo difference = -77.05/-55.00/-33.38 (low/avg/high, at p = 0.95)Как вычисляется доверительный интервал? Как известно, рейтинг Эло можно представить как функцию
elo(q), где q — вероятность победы первого игрока. Для q мы уже умеем считать среднеквадратичное отклонение σ и строить доверительный интервал [q - 1.96 * σ; q + 1.96 * σ]. Тогда доверительный интервал для рейтинга Эло будет равен [elo(q - 1.96 * σ); elo(q + 1.96 * σ)]
Я еще раз убедился в том, что для достижения высокой точности надо много игр. Если разница в силе игры невелика, то точность Battlefield примерно такая:- для 100 игр: ±60 Эло
- для 1000 игр: ±25 Эло
- для 10000 игр: ±7 Эло
То есть, чтобы протестировать мелкое изменение, нужно от 1000 до 10000 игр, что довольно много
Нашел очередной странный баг в
clang-tidy версии 11, который в CI проявился почему-то только на macOS. Он утверждает, что в коде есть деление на ноль:/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:140:5: note: 'size' initialized to 0
const uint64_t size = *sampleSize_;
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:141:19: note: 'size' is < field 'count_'
if (count_ <= size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:141:5: note: Taking false branch
if (count_ <= size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:145:9: note: Assuming the condition is true
if (random_() % count_ < size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:145:5: note: Taking true branch
if (random_() % count_ < size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:146:45: note: Division by zero
sample_[static_cast<size_t>(random_() % size)] = board;
^
Сам код выглядит так. Т.е. clang-tidy почему-то утверждает, что size = 0, соглашается с тем, что count_ > 0, и при этом (random_() % count_ < size) по его мнению может быть true, хотя все типы здесь uint64_t
Обновление clang-tidy мне, к сожалению, не помогает: баг присутствует и в clang-tidy-13 :( Пришлось обходить проблему костылямиЕще одно серьезное улучшение — добавление в оценку позиции признаков для пешек: бонусы и штрафы за изолированные, двойные, проходные пешки
Скажу сразу: я разочаровался в пешках. Я ожидал, что после пешечных улучшений я получу минимум 60 Эло, а итоговые результаты (на 10000 играх) оказались такими:
Попытался еще добавлять пешечные бонусы в зависимости от фазы игры: отдельные признаки для дебюта и эндшпиля. Парадоксально, но после обучения этот подход сделал только хуже :( Еще интересно, что в этом случае проходная пешка в миттельшпиле оценивается отрицательно (в -0.4 пешки или около того), и только в эндшпиле дает положительную прибавку к оценке позиции
Считать все пешечные признаки долго, поэтому их надо кэшировать: позиции пешек меняются относительно редко. Поэтому я написал такой код для кэширования. Подсчеты показали, что при анализе из начальной позиции получается примерно 7% промахов мимо этого кэша, но по ходу игры количество промахов постепенно снижается, и в миттельшпиле получается на более 2% промахов
Для кэширования необходимо как-то как-то считать хэш от всех пешек на доске. Здесь возможны два подхода. Первый — использовать zobrist hashing, как и для хэша доски в целом. Тогда нам надо вместе с хэшом для всей доски хранить еще хэш для пешек и постепенно пересчитывать его каждый ход. Второй подход — взять
Я решил сравнить, какой из этих двух подходов быстрее работает. Выяснилось, что все-таки пересчитывать zobrist hashing быстрее, хотя и статистически незначимо. Поэтому я решил реализовать первый вариант
Скажу сразу: я разочаровался в пешках. Я ожидал, что после пешечных улучшений я получу минимум 60 Эло, а итоговые результаты (на 10000 играх) оказались такими:
Elo difference = 26.10/32.93/39.78 (low/avg/high, at p = 0.95)Я подумал, что где-то допустил баг, из-за которого не получаю большого выигрыша. Тогда я взял одну партию и запустил на ней
make_dataset, который как раз умеет выдавать коэффициенты для каждого признака. Посмотрел глазами на коэффициенты и сравнил с тем, что ожидалось. В итоге все сошлось, а, значит, оценку позиции я написал правильноПопытался еще добавлять пешечные бонусы в зависимости от фазы игры: отдельные признаки для дебюта и эндшпиля. Парадоксально, но после обучения этот подход сделал только хуже :( Еще интересно, что в этом случае проходная пешка в миттельшпиле оценивается отрицательно (в -0.4 пешки или около того), и только в эндшпиле дает положительную прибавку к оценке позиции
Считать все пешечные признаки долго, поэтому их надо кэшировать: позиции пешек меняются относительно редко. Поэтому я написал такой код для кэширования. Подсчеты показали, что при анализе из начальной позиции получается примерно 7% промахов мимо этого кэша, но по ходу игры количество промахов постепенно снижается, и в миттельшпиле получается на более 2% промахов
Для кэширования необходимо как-то как-то считать хэш от всех пешек на доске. Здесь возможны два подхода. Первый — использовать zobrist hashing, как и для хэша доски в целом. Тогда нам надо вместе с хэшом для всей доски хранить еще хэш для пешек и постепенно пересчитывать его каждый ход. Второй подход — взять
h(b1, b2), где b1 и b2 — это битбоарды белых и черных пешек соответственно, а h — некоторая хорошая хэш-функция общего назначения. Я попробовал в качестве такой хэш-функции FarmHash от Google и сделал коммит, который использует FarmHash для хэширования позиций пешекЯ решил сравнить, какой из этих двух подходов быстрее работает. Выяснилось, что все-таки пересчитывать zobrist hashing быстрее, хотя и статистически незначимо. Поэтому я решил реализовать первый вариант
Часть описанного выше улучшения — это улучшение самого процесса обучения. Во-первых, я изменил метрику с MAE (L1-метрика) на MSE (L2-метрика). Еще пробовал экспериментировать с L4-метрикой (чтобы больше штрафовать оценку за сильные ошибки и мало штрафовать за много мелких ошибок), но из этого ничего хорошего не получилось
Во-вторых, сделал нормализацию датасета и делю все признаки на
Обновленный ноутбук можно найти по ссылке
Во-вторых, сделал нормализацию датасета и делю все признаки на
(max - min), где min и max — минимальное и максимальное значения признака. Такой вот самописный MinMaxScaler :)Обновленный ноутбук можно найти по ссылке
Пора рассказать, что нового появилось. Во-первых, я заметно ускорил утилиту make_dataset, которая по заданным партиям генерирует датасет для обучения. Раньше она писала выходной CSV-файл со скоростью примерно 20-30 МБ/с, что довольно медленно (датасет для нескольких миллионов позиций занимает пару гигабайт). Сейчас скорость достигает 200-300 МБ/с при записи в
Как я этого добился? Я решил попрофилировать
Я переписал запись CSV таким образом: сделал у себя буферизацию и начал писать в свой буфер, а когда мой буфер заканчивается, то тогда я уже пишу в поток с помощью ostream::write(). Числа пишутся в буфер прямо на месте, при помощи std::to_chars(). Код получился таким. Буферизация дала ускорение в целых 4 раза!
Остальная часть ускорения вышла за счет многопоточности. Теперь
Вот коммит, в котором я все это сделал
/dev/null, т.е. ускорение получилось примерно в 10 разКак я этого добился? Я решил попрофилировать
make_dataset с помощью callgrind (часть valgrind). Посмотрел результаты и понял, что примерно 80% времени занимал вывод в std::cout. При этом, у меня уже тогда стоял std::ios_base::sync_with_stdio(false);, т.е. дело было не в синхронизации с stdio, а в том, что запись в потоки в C++ — штука не очень быстраяЯ переписал запись CSV таким образом: сделал у себя буферизацию и начал писать в свой буфер, а когда мой буфер заканчивается, то тогда я уже пишу в поток с помощью ostream::write(). Числа пишутся в буфер прямо на месте, при помощи std::to_chars(). Код получился таким. Буферизация дала ускорение в целых 4 раза!
Остальная часть ускорения вышла за счет многопоточности. Теперь
make_dataset умеет обрабатывать позиции и писать куски CSV независимо в нескольких потоках, а затем уже выводить в файл. Устроено это так: в каждый поток прилетает набор позиций, по ним строится кусок CSV, который отправляется в поток вывода (поток в смысле thread, а не в смысле stream). А поток вывода уже выводит построенные куски CSV в файлВот коммит, в котором я все это сделал
Еще я провел матч на 100'000 партий:
...Точность потрясающая (±2 Эло), но работает долго и при маленьком контроле времени, так что всегда так запускать, увы, не получится
99998/100000 games (6.15 hours/6.15 hours), score = 50241.0:49757.0, confidence = ?
99999/100000 games (6.15 hours/6.15 hours), score = 50241.0:49758.0, confidence = ?
100000/100000 games (6.15 hours/6.15 hours), score = 50241.5:49758.5, confidence = ?
Wins: 35515, Loses: 35032, Draws: 29453
Score: 50241.5:49758.5
Confidence interval:
p = 0.90: Unclear
p = 0.95: Unclear
p = 0.97: Unclear
p = 0.99: Unclear
Other stats:
LOS = 0.97
Elo difference = -0.48/1.68/3.83 (low/avg/high, at p = 0.95)
Недавно читал README движка Fruit. Там нашел такие строчки:
Some people find that Fruit is surprisingly "strong" given the above
(dull) description. The same persons are probably going to scrutinise
the source code looking for "magic tricks"; I wish them good luck. If
they find any, those are likely to be "bugs" that I have overlooked or
"features" I have forgotten to remove (please let me know). The main
search function is full_search() in search_full.cpp
I suggest instead that one ponders on what other "average amateur"
engines might be doing wrong ... Maybe trying too many heuristics
(they might be conflicting or choosing weights for them is too
difficult) or code that is too complex, maybe features that look
important but are actually performing no useful function ... Sorry I
do not know, and I don't think we will find the answer in Fruit ...
Я очень надеюсь, что аккуратная проверка каждой эвристики на игре движка с самим собой меня спасет от добавления плохих эвристик, и в итоге у меня получится быстрый и сильный движок :)
Some people find that Fruit is surprisingly "strong" given the above
(dull) description. The same persons are probably going to scrutinise
the source code looking for "magic tricks"; I wish them good luck. If
they find any, those are likely to be "bugs" that I have overlooked or
"features" I have forgotten to remove (please let me know). The main
search function is full_search() in search_full.cpp
I suggest instead that one ponders on what other "average amateur"
engines might be doing wrong ... Maybe trying too many heuristics
(they might be conflicting or choosing weights for them is too
difficult) or code that is too complex, maybe features that look
important but are actually performing no useful function ... Sorry I
do not know, and I don't think we will find the answer in Fruit ...
Я очень надеюсь, что аккуратная проверка каждой эвристики на игре движка с самим собой меня спасет от добавления плохих эвристик, и в итоге у меня получится быстрый и сильный движок :)
Во-вторых, я добавил оценку короля. Проверял следующее:
1) пешечный щит короля. Смотрим, какие есть пешки перед рокированным королем, и штрафуем, если там их не хватает. Дает до 20 Эло
2) близость вражеских фигур к королю. Раньше у меня такая эвристика была написана для ферзя, теперь я ее решил добавить для всех остальных фигур. Попытался обучить веса и обнаружил, что для других фигур они оказались близки к нулю, поэтому оставил близость только для ферзя и ладьи. Но попутно я обнаружил, что проверка на близость работала медленно (ссылка на код). После того, как я развернул цикл руками, я получил неплохое улучшение (около 40-50 Эло). Довольно неожиданно :)
3) прорыв пешечного щита вражескими пешками (он же pawn storm). Эта эвристика мне ничего не дала, а после обучения все только ухудшилось. Поэтому писать я ее не стал
В целом, за последнее время было больше неудачных экспериментов, чем удачных. Надеюсь, дальше улучшения пойдут лучше :)
1) пешечный щит короля. Смотрим, какие есть пешки перед рокированным королем, и штрафуем, если там их не хватает. Дает до 20 Эло
2) близость вражеских фигур к королю. Раньше у меня такая эвристика была написана для ферзя, теперь я ее решил добавить для всех остальных фигур. Попытался обучить веса и обнаружил, что для других фигур они оказались близки к нулю, поэтому оставил близость только для ферзя и ладьи. Но попутно я обнаружил, что проверка на близость работала медленно (ссылка на код). После того, как я развернул цикл руками, я получил неплохое улучшение (около 40-50 Эло). Довольно неожиданно :)
3) прорыв пешечного щита вражескими пешками (он же pawn storm). Эта эвристика мне ничего не дала, а после обучения все только ухудшилось. Поэтому писать я ее не стал
В целом, за последнее время было больше неудачных экспериментов, чем удачных. Надеюсь, дальше улучшения пойдут лучше :)