Обучение нейросети для нард: практический гайд

Предыдущие статьи серии разбирали, почему короткие нарды дружелюбны к RL и как область пришла к современному self-play обучению. Эта статья — про практическую инженерию: как training pipeline на самом деле выглядит в production, end-to-end. Мы фокусируемся на решениях из нашего pipeline (конкретные выборы), а не на абстрактных рецептах.

Цикл

Production-обучение — это непрерывный цикл, а не разовая задача. Цикл выглядит так:

Генерация позиций → Оценка через rollout → Обучение → Турнир → Продвижение → Реклассификация → Повторить

Каждый шаг занимает от часов до дней — зависит от размера датасета и глубины оценки. Весь цикл крутится непрерывно на одной GPU столько, сколько вы хотите, чтобы движок продолжал улучшаться — состояния «обучение закончено» не существует. Модель становится лучше, пока работает цикл.

Пройдём по каждой стадии.

Стадия 1: генерация позиций

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

Наш pipeline смешивает три источника:

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

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

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

В нашей базе длинных нард сейчас порядка 11 млн позиций по всем источникам, из них более 300 000 экспертных позиций. Экспертные позиции бьют сильно выше своего веса при обучении: добавление их в loss заметно улучшает equity-error на отложенной части того же набора.

Стадия 2: оценка через rollout

Для каждой новой позиции нужна метка обучения — «правильная» оценка equity. Её у вас нет (нет оракула). Лучшее, что есть рядом — качественный rollout текущей сильнейшей моделью.

Rollout из позиции означает: сыграть K партий из этой позиции до конца, обе стороны выбирают ходы движком, и усреднить исходы. K=1296 (=6⁴) — типичное значение, потому что оно покрывает много траекторий костей. Некоторые позиции получают K=10000, если нужны очень узкие оценки equity.

Стоимость rollout на позицию зависит от длины партии и уровня глубины. Rollout на 1296 партий на ply-2 занимает от секунд до минут на позицию в зависимости от железа. Помноженное на миллионы позиций, шаг rollout — доминирующая compute-стоимость в pipeline.

Несколько практических уточнений:

  • Снижение дисперсии. Генерируем парные антитетические последовательности костей. Каждая партия спарена с партией, использующей «противоположные» броски костей; два исхода склонны компенсировать шум.
  • Quasi-Monte-Carlo сэмплирование. Вместо полностью случайных костей используем последовательность с низкой дискрепансией по 21 комбинации костей, чтобы обеспечить равномерное покрытие.
  • Усечённые rollout. Для позиций глубоко в гонке можно остановить rollout раньше и взять оценку сети как leaf-значение. Экономит время, слегка увеличивает смещение.

Каждый выход rollout становится supervised целью для этой позиции в следующем цикле обучения.

Стадия 3: supervised обучение

Теперь у вас есть пары (позиция, метка). Обучаем нейросеть предсказывать метку по позиции. Это лёгкая часть — стандартная supervised регрессия.

Архитектурные решения:

Inputs. Кодируем позицию как вектор фиксированной длины. Для коротких нард стандарт — около 186 входов (gnubg использует 198, наша сеть — 186). Для длинных нард — 194 входа (другая стартовая структура, нет бара). Кодирование важно: избыточные признаки (счётчики пунктов И счётчики шашек И общие счётчики шашек) часто помогают обобщению.

Outputs. Для коротких нард: шесть выходов, покрывающих normal/gammon/backgammon × win/lose. Для длинных нард: шесть выходов, покрывающих win, win_m, win_k (вложенный ординальный) плюс симметричное трио для проигрывающей стороны. Equity вычисляется ниже по потоку из распределения выходов.

Размеры сети. По современным меркам маленькие. Многослойный перцептрон с ~100–300 скрытыми юнитами на слой, 2–4 слоя. Пространство состояний достаточно мало, чтобы более крупные сети переобучались. Вы не обучаете GPT — вы обучаете аппроксиматор функции с ограниченными inputs и чёткими целями.

Routing по фазам. Для длинных нард мы используем по одной сети на фазу плюс унифицированную сеть для ранних позиций. Фаза детектится за O(1) по счётчикам шашек; нужная сеть вызывается на этапе оценки.

Loss. MSE против цели, полученной из rollout. Никаких хитрых loss не нужно; качество обучающих данных доминирует.

Время одного прохода обучения: часы, зависит от размера датасета и размеров батча. Несколько эпох по одним и тем же данным — норма.

Стадия 4: tournament gating

Вы обучили сеть-кандидата. Не выкатывайте её сразу. Сравните её очно с текущей production-сетью в турнире.

Механика: сыграть N матчей между кандидатом и чемпионом, каждый матч стартует со случайно сидированного открытия. Считаем агрегатные очки. Если кандидат выигрывает с достаточным отрывом, чтобы это было статистически значимо (зависит от N — для N=10000 матчей обычно хватает нескольких сотен очков отрыва), продвигаем его. Иначе отклоняем.

Зачем вообще tournament gating? Потому что цели, полученные из rollout, неидеальны. Модель, обученная на шумных целях, может хорошо ложиться на same-distribution holdout, но играть хуже в реальных партиях. Турнирная игра — это ground truth для вопроса «этот движок реально лучше?»

Несколько уточнений к турнирам:

  • Self-play против анкорного. Чисто self-play турниры уязвимы к локальным оптимумам. Подмешивание матчей против «анкора» (фиксированного сильного оппонента) не даёт движку дрейфовать в сторону стратегий, которые бьют его самого, но проваливаются против людей.
  • Длина матча. Короткие матчи (1-pt, 3-pt) подчёркивают чистую оценку позиции; длинные матчи нагружают решения по кубу. Pipeline должны тестировать оба варианта.
  • Куб включён против выключенного. Иногда нужно проверить позиционную силу независимо от решений по кубу.

Стадия 5: продвижение модели

Победитель турнира становится новой production-моделью. Предыдущий чемпион сохраняется в реестре моделей как референс, но больше не используется на инференсе.

В нашем pipeline продвижение модели — это явная операция: db_registry::promote_winner(game_id, phase_id, winner_id). Реестр хранит метаданные модели (UUID, дата обучения, equity-loss относительно предыдущего чемпиона, пороги gating) в Postgres, чтобы можно было аудировать, какая модель была активна в любой момент времени.

Отклонённые модели тоже сохраняются — иногда модель, проигравшая один турнир, выигрывает поздний, когда сдвигается распределение данных. Чтобы пометить их явно, используем reject-models --ids <uuid,...>.

Звучит бюрократично для исследовательского проекта, но в production, где модель обслуживает реальный трафик, аудит-trail важен. Выкатили плохую модель → пользователь жалуется на странную рекомендацию лучшего хода → нужно знать, какая модель была активна и как она набрала против предшественника.

Стадия 6: реклассификация

Теперь у вас новый чемпион. У старых позиций в базе метки сгенерированы предыдущим чемпионом. Эти метки ещё валидны?

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

Это дорого — вы фактически переделываете стадию 2 над историческими данными. Практическая оптимизация: не реклассифицировать всё. Приоритизируйте позиции, где:

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

Непрерывно работающий реклассификатор обходит базу в порядке приоритета, обновляя метки с той скоростью, которую позволяет compute. За недели он прорабатывает весь датасет, после чего цикл начинается заново со следующего чемпиона.

Типичные грабли

Несколько практических ловушек, на которые мы наступали:

Конкуренция за память GPU. Параллельный запуск генерации rollout и реклассификации на одной GPU может накапливать CUDA-память и приводить к паникам CUBLAS_STATUS_ALLOC_FAILED. Supervisor рестартит упавший процесс, но in-flight элементы очереди теряются. Решение: сериализовать GPU-тяжёлые операции или использовать отдельные GPU.

Провенанс источника позиций. Self-play, случайные и экспертные позиции должны быть тегированы, чтобы можно было аблейтить вклад каждого источника. Без провенанса, когда качество обучения падает, нельзя понять, не сместили ли вы случайно микс данных.

Tournament-status против promotion-status. Модель с пометкой status='active' не обязательно используется на инференсе. Inference использует отдельный join profile_models из профиля с is_active=true. Эти два могут разъехаться, если забыть обновить профиль после promotion. Урок: иметь единый источник правды для «что сейчас работает в production» и периодически проверять.

Границы фаз. Когда позиция переходит из одной фазы в другую внутри партии, релевантная сеть меняется. Убедитесь, что rollout использует правильную сеть на каждом шаге, а не только сеть стартовой позиции. Иначе вы обучаетесь на rollout, которые не отражают реальную игру.

Сколько до того, как станет хорошо

Мы не публикуем конкретные PR-цифры, потому что setup сравнения пока недостаточно воспроизводим (engine-vs-engine матчи зависят от opening books, уровней глубины, match-equity таблиц, и всё это слегка различается между системами). Что можем сказать:

  • Первичное обучение до клубного уровня: недели непрерывного цикла на одной GPU.
  • До уровня, конкурентного с движками: месяцы. Маржинальный compute на единицу улучшения equity становится круче по мере того, как сеть приближается к своему потолку ёмкости.
  • Непрерывное улучшение после этого: всё меньшие приросты на цикл. Большая часть наших последних улучшений идёт со стороны данных (больше экспертных позиций, лучшее снижение дисперсии rollout), а не со стороны архитектуры.

Полный отчёт по latency и accuracy выйдет вместе с релизом нашего публичного API. Сейчас движок в private beta; запросы на доступ — на /developers.

Куда дальше

Финальная статья серии, Inside Nardex Engine: architecture deep-dive, разбирает конкретные стек-выборы, которые мы сделали — Rust + ONNX + CUDA, мультифазный setup сетей для длинных нард, поверхность HTTP API и как всё это складывается end-to-end.

Если хотите перспективу пользователя движка, а не разработчика: Reading Nardex Analysis Output — это API-референс.