SoFCheck
38 subscribers
64 links
Заметки по поводу разработки шахматного движка SoFCheck

Репозитории:
https://github.com/alex65536/sofcheck
https://github.com/alex65536/sofcheck-engine-tester
https://github.com/alex65536/sofcheck-weights-tuning
Download Telegram
Еще во время сегодняшнего тестирования на Battlefield'е я обнаружил, что на маленьком контроле времени (менее 60 секунд на игру) SoFCheck может проиграть из-за просрочки по времени. Надо разбираться, почему так
Что-то я очень долго не писал ничего в код SoFCheck'а и в этот канал. Сегодня у меня наконец-то появилось немного свободного времени, чтобы всем этим заняться

Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на 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/
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
Надеюсь, что GitHub скоро поправит эту проблему, а то у меня весь CI сегодня красный :(
Сегодня расскажу немного деталей про то, как я генерировал данные для обучения

Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция

При написании оценочной функции есть большой простор для творчества. Как это происходит в 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. Например:
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 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)

Есть тесты в static_assert, которые гоняются прямо во время компиляции: ссылка

Есть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось

Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены

Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее
Как-то раз Антон (@Wind-Eagle) заметил, что у меня медленная реализация генератора ходов для пешек. Я решил сегодня взять и переписать

Запустил код на микробенчмарках. В позициях, где много пешек, генерация ходов ускорилась на 10-15%. Тем не менее, результаты оказались довольно шумными, и некоторые бенчмарки замедлились процентов на 5. Бенчмарки, которые имитируют рекурсивный анализ, практически не поменялись

А на реальном анализе получилось ускорение: на 9% в позиции с одними пешками, на 7% в околодебютных позициях, на 3-5% в глубоком миттельшпиле. Чем дальше от дебюта, тем меньше ускорение, поскольку там становится все меньше и меньше пешек; в эндшпиле старая и новая версии работают примерно одинаково быстро. Тем не менее, результат довольно неплохой :)

Коммит можно посмотреть здесь: ссылка

Еще я понял, что мои микробенчмарки не особо хороши и не отражают реальную скорость. Например бенчмарки, которые имитируют рекурсивный анализ, пытаются сделать все ходы в позициях, т.е. на них больше влияет скорость moveMake(), чем генерации ходов. В реальности из позиции делается в среднем 2-3 хода до отсечения, поэтому производительность moveMake() куда менее заметна, чем в бенчмарках
Вчера я улучшил оценку оставшегося времени в 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 понимать новый формат. Для этого у меня есть небольшая библиотека для парсинга этого формата. Когда я ее писал, мне понадобился unique_ptr<T> с копированием: при копировании он должен копировать данные, на которые он указывает. Зачем это нужно? В коде повсеместно используются variant'ы, а один из вариантов содержит внутри себя доску и занимает из-за этого ~200 байт. Но тогда весь variant будет занимать неоправданно много места, что неприемлемо. Для решения такой проблемы как раз требуется спрятать доску за указатель. При этом хочется, чтобы копирование объектов по-прежнему работало. В итоге я написал что-то такое

После этого я реализовал фильтрацию в make_dataset, сгенерировал 100'000 партий движка с самим собой и обучил на 3 млн позиций из этих партий (из примерно 10 млн позиций). Выигрыша по сравнению с нефильтрованной выборкой, к сожалению, я не получил :(
Еще я написал классный скрипт owl.py, который упрощает мне жизнь. Например, раньше для утилиты apply_weights, которая записывает новые коэффициенты в features.json, надо было указывать путь до features.json. А owl сам знает путь до этого файла, и для обновления коэффициентов достаточно просто написать
./owl.py w a
В качестве приятного бонуса он сразу пересоберет движок с новыми коэффициентами :)

Еще owl может собрать несколько версий движка в одну команду. Это бывает очень полезно, чтобы сравнить разные версии по силе. Сборка нескольких версий реализована предельно просто: сохраняем незакоммиченные изменения, переходим на нужные коммиты, собираем с помощью CMake, возвращаем все обратно

А еще owl умеет рисовать маленькую сову ASCII-артом, которую я кидал в канал выше :)

Как owl узнает конфигурацию проекта и системы, чтобы все это делать? Все просто: нужные пути подставляются в скрипт CMake'ом: ссылка
Еще полезное улучшение: Battlefield теперь может показывать доверительный интервал для разницы в рейтинге Эло. Например, так:
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 играх) оказались такими:
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 МБ/с при записи в /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 партий:
...
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)
Точность потрясающая (±2 Эло), но работает долго и при маленьком контроле времени, так что всегда так запускать, увы, не получится
Недавно читал 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 ...

Я очень надеюсь, что аккуратная проверка каждой эвристики на игре движка с самим собой меня спасет от добавления плохих эвристик, и в итоге у меня получится быстрый и сильный движок :)
Во-вторых, я добавил оценку короля. Проверял следующее:
1) пешечный щит короля. Смотрим, какие есть пешки перед рокированным королем, и штрафуем, если там их не хватает. Дает до 20 Эло
2) близость вражеских фигур к королю. Раньше у меня такая эвристика была написана для ферзя, теперь я ее решил добавить для всех остальных фигур. Попытался обучить веса и обнаружил, что для других фигур они оказались близки к нулю, поэтому оставил близость только для ферзя и ладьи. Но попутно я обнаружил, что проверка на близость работала медленно (ссылка на код). После того, как я развернул цикл руками, я получил неплохое улучшение (около 40-50 Эло). Довольно неожиданно :)
3) прорыв пешечного щита вражескими пешками (он же pawn storm). Эта эвристика мне ничего не дала, а после обучения все только ухудшилось. Поэтому писать я ее не стал
В целом, за последнее время было больше неудачных экспериментов, чем удачных. Надеюсь, дальше улучшения пойдут лучше :)
И все-таки я добавил pawn storm в оценку позиции (см. пункт 3 из прошлого поста) и даже получил на нем ~20 Эло улучшения. Как я этого достиг, хотя раньше мне улучшить не удавалось? Все просто: я перегенерировал датасет. Если раньше там были партии старой версии движка с собой, то в новом датасете играет уже новая версия (тоже сама с собой). Вывод: для обучения лучше всего использовать партии того движка, в котором мы как собираемся подгонять веса. А еще лучше — актуальную его версию