- BrainTools - https://www.braintools.ru -
Продолжаю цикл статей [1] из мира соло-разработки больших систем.
Первый месяц создания игры – медовый. Баги видны почти сразу, работа спорится. Но потом начинается настоящая работа. Баги становятся очень неприятными, когда ты находишь их после двух часов игры.
Например, когда на 1066-й день игры внезапно рушится экономика [2].
К этому моменту мне и самому надоела рутина – голыми руками проходить сценарии, и я начал искать способ как подрядить на это нейронку.
“Протестируй визуально” работает только с квадратно-гнездовыми интерфейсами и HTML. Чуть более сложный UI (knob-ы, слайдеры, пунктиры) считывается с трудом.
Самый навороченный CLIP Vision уже кое-как умеет распознавать даже сложные цепочки эффектов в аудиоредакторе. К сожалению, с непростительными ошибками, в играх он тем более разочаровывает. Как нельзя скормить нейросети изображение игрового интерфейса чтобы она сделала один в один, так она и не понимает, что на картинке происходит.
В проекте уже было четыре сотни Playwright-тестов, но этого было мало – нужно было находить экономический дисбаланс и прочие глюки сложной системы. Просто кликать, отправлять скриншот и данные на анализ нейросети, принимать решение, кликать куда-то ещё – процесс долгий и достаточно подробно описан в научных [3] исследованиях [4]. Вердикт – такое тестирование даже вроде как работает, но медленно и дорого.
Итак, что мы имеем после недели вечеров погружения в ML для тестирования.
Подопытный – браузерная космическая стратегия с открытым миром в духе старых “Космических рейнджеров” [1]: TypeScript, PixiJS 8, процедурная галактика, торговля, банк, квесты, боёвка и захватывающий галактику ИИ-противник (Овермайнд). То есть игра не набор экранов, а связная экономико-боевая система, где баги баланса видны только на длинной дистанции.
Классическая пирамида тестов у меня к этому времени уже была, причём 5115 тестов в три яруса:
Юнит-тесты на чистые функции (Vitest, всё замокано);
Playwright-тесты на реальном движке – ловят баги интеграций между компонентами (застаблен только PixiJS);
Playwright E2E – headless-браузер, реальные клики, реальный рендеринг, ловит интерактивные и визуальные баги.
И вся эта пирамида была бессильна против вопросов вида “а не выгоднее ли просто положить стартовый капитал на депозит и ждать?” или “почему в первые три дня выходит сообщение что галактика захвачена и вы проиграли?”
Очередной раз сев за проект, я задался вопросом: а зачем я всё-таки подключил onnxruntime-web на 129Mb? Годной модели под игру (предполагались ИИ-текстовые квесты) я так и не нашел. Прежде чем его выпиливать, вступил в диалог с ассистентом:
Давай придумаем, как использовать onnx-runtime.
Можем обучить свою нейросеть для управления Овермайндом.
Для такой маленькой нейросети нужен модуль на 129 Мб?
Нет, можно запускать напрямую на GPU и хранить в json
Так. А давай с управлением овермайндом подождём и сделаем автопилота, который заменит нам Playwright-тесты и будет заглядывать в недостижимые для них уголки сценариев?
Давай.
Warning: дальше много технических деталей из мира Machine Learning. Я сам многое узнавал по ходу дела.
И всё-таки почему не LLM? Выше я вскользь уже отвечал на этот вопрос, давайте разберем подробнее. Игровая сессия это тысячи решений, а тестовый прогон – сотни сессий. Даже по центу и по секунде на решение это выходит безумно дорого и долго. А главное – найденный баг обязан воспроизводиться на том же сиде шаг в шаг, иначе это не тест, а анекдот. Мозг [5] агента должен стать бесплатным, мгновенным и детерминированным. Это описание нейросети размером с пылинку, не большой языковой модели.
Первым делом я выпилил ONNX-рантайм. Крошечного MLP (многослойного перцептрона [6]) должно было хватить. Его forward pass [7] это буквально “умножить матрицу на вектор, ReLU, повторить”:
function dense(layer: MlpLayer, input: readonly number[]): number[] {
return layer.W.map((row, j) => {
let sum = layer.b[j];
for (let i = 0; i < row.length; i++) sum += row[i] * input[i];
return sum;
});
}
export function forward(weights: MlpWeights, input: readonly number[]): number[] {
let acc = [...input];
weights.layers.forEach((layer, i) => {
acc = dense(layer, acc);
if (i < weights.layers.length - 1) acc = acc.map(relu);
});
return acc; // логиты, argmax снаружи
}
Веса модели это просто JSON в полтора мегабайта. Это массив слоёв, в каждом – двумерный массив чисел W и массив b. Обучается сеть в Python, поэтому числа в JSON лежат в том же порядке, в котором их хранит PyTorch – по строке на каждый выходной нейрон [8]. Экспорт из PyTorch это сериализация 1-в-1, без разворота матриц, и коду выше JSON скармливается напрямую. Никакого wasm, воркеров, GPU-диспатча. 50000 умножений это микросекунды на CPU, можно дергать хоть на каждый кадр.
Почему так, а не через TensorFlow.js или brain.js? Потому что это добавляет мегабайты кода со своими сюрпризами при обновлениях к браузерной-only игре ради модели размером с favicon.ico.
Чтобы Python-сторона и TS-сторона не разъехались, есть parity-тест. Обученная в PyTorch модель на детерминированном входе обязана дать те же логиты, что TS-форвард, с точностью 1e-5. Этот тест дважды находил расхождения при рефакторингах.
Сначала был эвристический letsplay-тест на playwright, от одного потока которого мой ноутбук “взлетал” при каждом пуше. Дорого, медленно, вентилятор взвывает. Тогда я застабил PixiJS, отменил рендер картинок в тестах, от чего стало возможным запускать 7-10 потоков с относительно быстрым (в рамках минуты) результатом. Тысячи игровых дней в секунду вместо минут.
Работает это как самый обычный игровой цикл:
observe(engine) → Observation → policy.decide(obs) → Action → apply(action) → tick
Если простыми словами – эвристический агент это функция. На вход – объект с тем, что видит корабль прямо сейчас (сцена, топливо, корпус, кредиты, планеты, враги рядом, квесты). На выход – строка-действие из фиксированного словаря.
const ACTION_ORDER = [
"move", "warp", "land", "dock", "trade", "take-quest",
"attack", "flee", "repair", "wait",
"buy-gear", "upgrade-hull", "buy-module",
"take-loan", "repay-loan", "sell-cargo",
"arm-up", "liberate",
] as const;
От кликов вместо словаря глаголов я отказался сразу потому что при любом редизайне пришлось бы перегенерировать весь датасет заново и обучать заново. А так – headless и live исполнители реализовывали одно и то же, просто разными методами.
В dev-режиме я реализовал с помощью этого агента режим авто-пилота. Удобно – нажимаешь backquote (`) и видишь насколько умён автопилот и чего он не замечает, где циклится. На странностях я сделал шорткат Shift+F, который делает скриншот+стейт с припиской “Тут что-то не так, потом разберемся”, чтобы потом оракулы (о них позже) находили по трейсам места с тупняками.
Одно решение, которое определило дальнейшие действия – должен ли агент знать всё о мире, или только то, что видел бы и игрок – пока не открыл Маркет, не слетал в соседнюю систему – не знает что там. И я решил сделать нейросеть с памятью [9] – POMDP (partially observable – частично наблюдаемый мир).
У агента появилась память. Это обычный объект, который живёт между шагами. Обычно в ML память живёт внутри модели, вектором чисел, но так её сложнее логировать, тестировать и дебажить. Плюс обучать с внешней памятью гораздо проще – это простейшая классификация “вектор -> действие”, без возни с последовательностями.
BC-нейросети (behavioral cloning) предполагают, что есть некий учитель. Который знает правильный ответ или как минимум ранжированный список приоритетов. Он создавался путем проб и ошибок в процессе наблюдений за автопилотом на эвристике. В порядке важности:
Выживание. Топлива меньше 20%, в системе есть обитаемая планета – садимся, посадка заправляет. Лечит зависание в захваченной системе без горючего.
Угроза. При слабом корпусе – сразу бегство в другую ближайшую систему.
Прибыль. Из памяти известно где можно купить-продать с большой разницей. Если нет – разведка.
Прогресс. Прохождение квестов. Если по карману апгрейд – покупаем.
Исследование. Иногда прыжок в неизвестную систему. Вдруг там есть в продаже корпус получше.
Ожидание на месте. На всякий случай, чтобы всегда было хотя бы это легальное действие.
Запустив летсплей-тест тысячи раз, проанализировав трейсы, я обнаружил множество странностей. Некоторые из них удалось обнаружить только глядя на автопилот.
Оружие и корпуса могут быть дороже, но гораздо слабее;
NPC были почти вдвое быстрее стартового корабля;
Доступность топового железа уже в начале игры и быстрый заработок (можно выиграть очень быстро и стать неуязвимым);
Возможность разворачиваться на месте приводит к дерганию камеры, а когда противник кружится вокруг тебя – это очень тупо выглядит;
Вклады под 44% годовых (сложный процент 0.1%/день). Выгоднее положить стартовый капитал на депозит и ждать, чем играть;
Везде одинаковое солнце, хотя задумано 30 вариантов.
Каждая находка превращалась не в разовый фикс, а в инвариант – правило, которое обязано выполняться всегда, оформленное обычным тестом. Например, “ни одна пушка не должна быть слабее более дешевой”.
А зачем тогда вообще обучать нейросеть если эвристика уже играет? Три причины.
Первая – потолок. Эвристика играет по строго заданным алгоритмам и правилам. Она не удивляет как настоящий игрок.
Вторая – JSON с весами плюс 50 строк весят как две картинки и спокойно идут в бандл как фича. Кода тестов гораздо больше.
Третья – задел для Овермайнда. Весь этот трубопровод (датасет-обучение-веса-инференс) один в один понадобится при обучении [10] ИИ главного злодея.
Схема обучения называется behavioural cloning. Учитель просто играет и пишет что видел и что сделал. Дальше это обычная задача классификации, как определение спама. Числа на входе – номер действия на выходе.
Здесь у читателя может возникнуть закономерный вопрос – а где же reinforcement learning? Где агент, который умирает миллион раз и сам открывает стратегии? RL я отложил. Ему нужна функция награды, а в настолько забагованной игре нейросеть быстро научится эксплуатировать любую награду. Такой агент нашел бы стратегию победы за 5 секунд. Но не баги.
На входе моей MLP – что видит + память + топливо/сцена/трюм/корпус/оружие и тд – всего 54 ячейки. 30 – что вижу, 20 память, 4 – сюжет.
Сначала это была микроскопическая 18x32x10 нейросеть (18 входов (контекстное окно), 32 нейрона [11], 10 действий), 938 весов, датасет из 3600 строк. Обучилась на CPU ноутбука за секунды. Она умела садиться на планету, открывать рынок и взлетать. И тут началось.
Точность классификации уперлась в 67% и не росла ни от эпох обучения (1000, 10000 прогонов), ни от дополнительных слоёв, ни от танцев с learning rate. Я уже был готов поверить, что нейросети – не моё.
Разбор датасета показал что в нём всего 8 уникальных векторов-признаков. На тысячи строк. Восемь. И четыре из этих восьми были с метками trade и move. А лучшее что может сделать в этом случае классификатор – писать “если было А, то Б, а если было Б, то А”, то есть зациклить АБАБАБАБ…
Решилось обогащением признаков (добавил тип сцены и признаки в память) а в эвристике заменил торговую заглушку реальным исполнением – точность выросла до 83%.
Сеть стала отлично торговать и никогда не прыгала в другие системы. Учитель всегда предпочитал сесть на планету, нежели прыгнуть. Прыжков было мало в логе. В признаках не было “товар в трюме, дороже можно продать только в другой системе”. А сессии, где прыжки были, тонули в общей массе.
Добавил недостающий признак, поправил приоритеты учителя. Прыжки появились. А точность упала – с 83% до 79%. Смесь “прыгать или торговать” классифицируется тяжелее, чем “сел и торгуй”. Но сеть уже начала больше исследовать галактику и играть стала заметно дольше и лучше.
Тут тот же урок что и с большими LLM – бенчмарки на проверочных данных не есть показатель качества, только живой прогон.
К этому моменту словарь действий разросся, появилось понятие CJM – цепочки действий как единый юнит, “сел, купил, прыгнул, прыгнул, сел, продал дороже”. Датасет стал отборной солянкой из сессий с разными целями. Сеть выучилась на 99% точности.
На живом прогоне – встало. Выбирала одно и то же действие. Всегда. Стояла в космосе и ждала. Вечно.
Причина – самая коварная из всех. Цель игры в вектор признаков не входила. Сеть выучила среднее между “исследователь” и “торговец”, а поскольку при одних и тех же вводных, у них разные действия, сеть оставалась на месте.
Переобучил на данных с одной детерминированной политикой – точность выросла до 99.7%, и живой прогон стал разнообразным и долгим.
Перед следующим большим обучением (на больших датасетах, длиной в 3 игровых года) я завёл валидатор датасета – маленький скрипт, который печатает отчет – сколько строк пришлось на каждое действие. Он показал что 8 из 16 действий (словарь тогда был из 16 действий, arm-up и liberate добавились позже) не встречаются ни разу.
Потому что как только учитель ввязывался в бой с гарнизоном Овермайнда, он не мог ни победить, ни отступить, сессия сжигала весь бюджет шагов на цикл атака-бегство.
Здесь пришлось переписывать и учителя, и игру. Переписал боёвку с большим радиусом поражения по уже покрытому тестами коду, тогда же учитель получил ярус “угроза” в списке приоритетов, чтобы не попадал в такие ситуации.
Выход сети – 18 чисел, по одному на действие. 18 глаголов. А исполнителю нужно ещё и существительное – id планеты. Реализовано это “телепатическим” методом – в TS обвязке появился метод, который дополняет выбранный глагол существующей целью и написан тест, который обязывает прогон с нейросетью пройти без ошибок и реализовать как минимум три разных действия (глагол+существительное должны быть уникальными).
Итоговая сеть сегодня – 54-256-128-18, это около 49к параметров. По меркам современного ML это даже не нано. Датасет генерируется на M1, а обучается сеть за минуты на RTX4090-боксе, хотя на ноуте было бы ненамного дольше, сеть крошечная.
Получившиеся веса стали “авто-пилотом”, этот режим теперь может включить игрок в dev-сборке.
Теперь – как это всё заменяет ручное прохождение. Агент играет и складывает каждую сессию в лог-транскрипт. Куда летел, что покупал, с кем дрался, сколько заработал. Над логами стоят четыре “оракула”: ошибок, тупиков, баланса, прогресса.
Все оракулы детерминированы и дёшевы. Кроме прочего, поверх них, после тысяч прогонов, я вручную заставляю пролететь LLM-судью в vision-варианте, то есть с анализом и логов, и скриншотов, и по моим наводкам (Shift+F) она создаёт правила-инварианты.
Почему судья не гейт при пуше? Потому что гейт должен быть детерминированным. Флакающий гейт хуже отсутствующего. LLM-гейт медленный, платный, каждый раз отвечает чуть по-разному, поэтому его место – облёт с отчетами, а право блокировать пуш остается у скучных быстрых проверок.
Самое важное правило, которое привнёс работающий автопилот, уместилось у меня в одну строчку:
когда агент выигрывает слишком быстро, проигрывает слишком быстро или ведёт себя как идиот, чини игру, а не агента.
В эвристическом учителе для headless и visual режимов была разная логика [12]. Это привело к “слепоте” агента к некоторым элементам интерфейса. Сейчас удалось добиться того, что один и тот же нейросетевой автопилот умеет играть и там и там.
Летсплей-движок для сборки датасета.
Учитель с ранжированным списком приоритетов (на самом деле правил там гораздо больше, я дал сильно упрощенный список)
Нейросеть-автопилот – MLP на 49к параметров
Петля обратной связи от человека (Shift+F)
Собранные данные помогли мне распланировать и реализовать адаптивную арку игры растянутую на 3 игровых года, то есть сделать так, что галактика развивается в зависимости от развития игрока.
Советы тем, кто пойдет этой дорогой:
Не парсите пиксели, читайте движок.
Каждая найденная странность должна умирать как класс багов. Инвариант вместо разового фикса.
Проверяйте датасет руками/скриптами перед обучением.
Тщательно планируйте вход сети.
Оценивайте живой прогон, а не точность на бенчмарках.
Важен и headless и live – расхождения между данными в них – самый дешевый детектор визуальных багов.
Всё это применимо к любой сложной stateful-системе: словарь действий вместо кликов, эвристика-учитель, оракулы над логами – так можно тестировать CRM, редактор, трейдинг-бота.
А главный злодей с ИИ вместо NPC логики всё ещё в бэклоге. Зато теперь для него готова вся инфраструктура.
Я поправил баланс и затупы. Но, как я уже говорил, визуальное тестирование сложных интерфейсов с помощью ИИ на данный момент не так эффективно. Поэтому, по традиции, приглашаю к бета-тестированию: https://alterfo.github.io/vacuum-rogues/ [13]. Самый активный репортер получит доступ к коду.
Автор: badattech
Источник [14]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32772
URLs in this post:
[1] цикл статей: https://habr.com/ru/articles/1050728/
[2] на 1066-й день игры внезапно рушится экономика: https://github.com/alterfo/vacuum-rogues-feedback/issues/1
[3] научных: https://www.sciencedirect.com/org/science/article/pii/S1546221825012202
[4] исследованиях: https://ar5iv.labs.arxiv.org/html/2210.02506
[5] Мозг: http://www.braintools.ru/parts-of-the-brain
[6] многослойного перцептрона: https://ru.wikipedia.org/wiki/%D0%9C%D0%BD%D0%BE%D0%B3%D0%BE%D1%81%D0%BB%D0%BE%D0%B9%D0%BD%D1%8B%D0%B9_%D0%BF%D0%B5%D1%80%D1%86%D0%B5%D0%BF%D1%82%D1%80%D0%BE%D0%BD_%D0%A0%D1%83%D0%BC%D0%B5%D0%BB%D1%8C%D1%85%D0%B0%D1%80%D1%82%D0%B0
[7] forward pass: https://medium.com/@njorogeofrey73/forward-pass-3f716ed71f19
[8] нейрон: http://www.braintools.ru/article/9161
[9] памятью: http://www.braintools.ru/article/4140
[10] обучении: http://www.braintools.ru/article/5125
[11] нейрона: http://www.braintools.ru/article/6020
[12] логика: http://www.braintools.ru/article/7640
[13] https://alterfo.github.io/vacuum-rogues/: https://alterfo.github.io/vacuum-rogues/
[14] Источник: https://habr.com/ru/articles/1056722/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1056722
Нажмите здесь для печати.