- BrainTools - https://www.braintools.ru -

Нейросеть-автопилот вместо 400 Playwright-тестов

Продолжаю цикл статей [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-тесты и будет заглядывать в недостижимые для них уголки сценариев?

  • Давай.

–129 МБ, +50 строк

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

  1. Выживание. Топлива меньше 20%, в системе есть обитаемая планета – садимся, посадка заправляет. Лечит зависание в захваченной системе без горючего.

  2. Угроза. При слабом корпусе – сразу бегство в другую ближайшую систему.

  3. Прибыль. Из памяти известно где можно купить-продать с большой разницей. Если нет – разведка.

  4. Прогресс. Прохождение квестов. Если по карману апгрейд – покупаем.

  5. Исследование. Иногда прыжок в неизвестную систему. Вдруг там есть в продаже корпус получше.

  6. Ожидание на месте. На всякий случай, чтобы всегда было хотя бы это легальное действие.

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

  • Оружие и корпуса могут быть дороже, но гораздо слабее;

  • 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 ноутбука за секунды. Она умела садиться на планету, открывать рынок и взлетать. И тут началось.

Грабли №1. Потолок 67%, который решался математикой.

Точность классификации уперлась в 67% и не росла ни от эпох обучения (1000, 10000 прогонов), ни от дополнительных слоёв, ни от танцев с learning rate. Я уже был готов поверить, что нейросети – не моё.

Разбор датасета показал что в нём всего 8 уникальных векторов-признаков. На тысячи строк. Восемь. И четыре из этих восьми были с метками trade и move. А лучшее что может сделать в этом случае классификатор – писать “если было А, то Б, а если было Б, то А”, то есть зациклить АБАБАБАБ…

Решилось обогащением признаков (добавил тип сцены и признаки в память) а в эвристике заменил торговую заглушку реальным исполнением – точность выросла до 83%.

Грабли №2. Сеть-домосед.

Сеть стала отлично торговать и никогда не прыгала в другие системы. Учитель всегда предпочитал сесть на планету, нежели прыгнуть. Прыжков было мало в логе. В признаках не было “товар в трюме, дороже можно продать только в другой системе”. А сессии, где прыжки были, тонули в общей массе.

Добавил недостающий признак, поправил приоритеты учителя. Прыжки появились. А точность упала – с 83% до 79%. Смесь “прыгать или торговать” классифицируется тяжелее, чем “сел и торгуй”. Но сеть уже начала больше исследовать галактику и играть стала заметно дольше и лучше.

Тут тот же урок что и с большими LLM – бенчмарки на проверочных данных не есть показатель качества, только живой прогон.

Грабли №3. 99% точности = полный паралич.

К этому моменту словарь действий разросся, появилось понятие CJM – цепочки действий как единый юнит, “сел, купил, прыгнул, прыгнул, сел, продал дороже”. Датасет стал отборной солянкой из сессий с разными целями. Сеть выучилась на 99% точности.

На живом прогоне – встало. Выбирала одно и то же действие. Всегда. Стояла в космосе и ждала. Вечно.

Причина – самая коварная из всех. Цель игры в вектор признаков не входила. Сеть выучила среднее между “исследователь” и “торговец”, а поскольку при одних и тех же вводных, у них разные действия, сеть оставалась на месте.

Переобучил на данных с одной детерминированной политикой – точность выросла до 99.7%, и живой прогон стал разнообразным и долгим.

Грабли №4. Мертвые головы

Перед следующим большим обучением (на больших датасетах, длиной в 3 игровых года) я завёл валидатор датасета – маленький скрипт, который печатает отчет – сколько строк пришлось на каждое действие. Он показал что 8 из 16 действий (словарь тогда был из 16 действий, arm-up и liberate добавились позже) не встречаются ни разу.

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

Здесь пришлось переписывать и учителя, и игру. Переписал боёвку с большим радиусом поражения по уже покрытому тестами коду, тогда же учитель получил ярус “угроза” в списке приоритетов, чтобы не попадал в такие ситуации.

Грабли №5. Неполные предложения.

Выход сети – 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

www.BrainTools.ru

Rambler's Top100