- BrainTools - https://www.braintools.ru -
Иногда всё начинается не с “хочу сделать исследование”, а с очень человеческого чувства – ностальгии. У ретро-игр есть особая фактура: звук, простая графика, жёсткие ограничения платформы и при этом удивительно честный геймдизайн, где каждая механика ощущается руками. Я поймал себя на том, что современные игры часто “слишком удобные”, а старые – наоборот, заставляют учиться, запоминать, терпеть и аккуратно разбирать уровни, как головоломку. И в какой-то момент мне захотелось вернуть это ощущение: не просто поиграть десять минут, а попробовать заново пройти игру так, как в детстве – медленно, с исследованием, с маленькими открытиями.
Ретро-игры часто используют как тестовые среды для reinforcement learning. Atari давно стал стандартом: десятки алгоритмов показывают впечатляющие результаты на играх вроде Breakout или Pong.
Но большинство этих игр имеют одну важную особенность – они линейные. Агент в основном движется вправо или действует в ограниченном пространстве.
Я решил попробовать более сложную задачу:
обучить нейросеть проходить лабиринтную игру Vampire Killer (Konami, MSX2).
Почему MSX, а не условная NES или Sega? Потому что MSX – это мир моего детства (КУВТ в школе (или в кружках или на УПК), когда мы первый раз прикоснулись к играм на компьютере и целыми днями проходили их). У него своя культура, своя инженерная эстетика: игры выглядят простыми, но часто оказываются хитрее, чем кажутся на первом экране. Там много дизайна “на память”: тебе не подсказывают стрелочками, не ведут за руку, ты реально строишь в голове карту. И это ощущение “я сам разобрался, сам нашёл путь” даёт особый кайф – тот самый, который хочется сохранить и передать дальше.
Итак, первый в мире AI-тренажёр для MSX учится проходить лабиринт 1986 года. Это сразу делает задачу значительно сложнее:
уровни представляют собой лабиринт из комнат
нужно искать ключи и двери
важно запоминать, где уже был
есть лестницы, ловушки и враги
Фактически это маленький navigation + memory RL-челлендж.
Кроме того, насколько мне удалось найти, обучающих сред для MSX-игр практически не существует.
Поэтому мне пришлось построить собственную RL-среду для openMSX.
Vampire Killer – это версия Castlevania для компьютеров MSX2. Игрок управляет персонажем, который исследует замок Дракулы. Каждый уровень – это набор комнат, соединённых лестницами и проходами.

И это – игра, которая на первых минутах легко обманывает. Ты видишь героя, видишь двери и переходы, видишь врагов, и кажется: “Ну да, классическая аркада, сейчас пойду вперёд, побью монстров, дойду до босса”. Но очень быстро понимаешь, что это не совсем так. Здесь замок – это не коридор, а система комнат. И игра начинает разговаривать с тобой языком лабиринта: “Ты уверен, что идёшь туда?”, “А ты точно всё проверил?”, “А ты запомнил, где видел закрытую дверь?”.
Чтобы пройти уровень, нужно:
исследовать комнаты
найти ключ
найти дверь
дойти до выхода
При этом:
стены могут быть разрушаемыми
предметы спрятаны в свечах
врагов можно перепрыгивать
каждые несколько уровней появляется босс
На уровне 00 всё просто – можно просто идти вправо. Но уже на уровне 01 появляется настоящий лабиринт.
И это превращает задачу RL из простой policy learning в частично наблюдаемую навигационную задачу.
В игре уровни состоят из комнат, соединённых переходами, лестницами и иногда скрытыми проходами. Игрок не просто реагирует на врага или прыжок; он строит маршрут. Он понимает: “я уже был в этой комнате, там тупик”, “ключ обычно прячется в стене или за разрушаемым блоком”, “дверь откроется только если у меня ключ”, “мне нужно вернуться назад и подняться по лестнице”. Для человека это кажется естественным, потому что у нас встроена память [1], карта мира и здравый смысл. Для агента, который видит только текущий кадр, это совсем не естественно.
Хороший способ объяснить это аудитории – сравнить два типа игр. В линейной игре состояние часто “самодостаточно”: если на экране яма – прыгай, если враг – атакуй или перепрыгни. Локальная реакция [2] действительно ведёт к прогрессу. В лабиринте локальная реакция часто никуда не ведёт, потому что прогресс определяется не текущей картинкой, а тем, что было раньше: где ты уже проходил, какие двери видел, где потенциально лежит ключ, откуда ты пришёл. В такой игре стратегия “реагируй на картинку” очень быстро упирается в циклы: агент начинает бесконечно ходить туда-сюда, потому что не знает, что делает это уже десятый раз. Это ключевое: лаби��инт – это не про сложную боёвку, это про память и планирование хотя бы на коротком горизонте.
Теперь про важный практический момент: в Vampire Killer многие нужные для прохождения события редкие и непрямые. Ключ можно получить не как “перед глазами лежит ключ”, а разбив определённую стену или найдя скрытый элемент. Дверь может находиться на другом конце маршрута, и между “я получил ключ” и “я открыл дверь” проходит много шагов без явного сигнала успеха. Если вы в RL ставите награду только за факт “дверь открыта” или “уровень пройден”, агенту приходится долго блуждать в огромном пространстве действий без подсказок, и чаще всего обучение [3] не стартует вовсе. Поэтому лабиринтные игры почти всегда требуют либо очень аккуратного reward shaping, либо вспомогательных сигналов, либо памяти, либо всего сразу.
И тут важно сказать аудитории, как не надо делать. Не надо пытаться “обмануть” задачу так, чтобы агент учился на одном специально выбранном маршруте, который вы зашили в скрипты или в датасет. Тогда вы не решаете игру – вы решаете воспроизведение сценария. И не надо делать награды настолько “умными”, что агент начинает оптимизировать награду, а не прохождение. Классическая ошибка [4] – поощрять, например, “подбор предметов” слишком сильно: агент научится фармить свечи и сердечки и будет счастлив, но к двери не пойдёт. Правильная постановка – это баланс: вы даёте промежуточные награды, но удерживаете их в роли подсказок, а не конечной цели, и постоянно проверяете, что рост метрик соответствует реальному поведению [5] в игре.
Есть ещё одна причина, почему игра хороша именно для проекта: в ней есть “лестница сложности”. Уровень 00 довольно прямолинейный и позволяет просто освоиться: ты понимаешь управление, видишь интерфейс, чувствуешь темп. А дальше игра начинает усложнять задачу и вводить всё больше “лабиринтности”: появляются враги, появляются ключи, появляется необходимость возвращаться и соединять куски карты в голове. Это очень похоже на учебник: сначала тебе дают простое упражнение, чтобы ты не утонул, а потом постепенно поднимают ставки.
И здесь важно понимать, как не надо воспринимать такие игры. Не надо думать, что “сложность” – это только про реакцию и скорость. В Vampire Killer сложность во многом когнитивная: тебе нужно помнить, планировать и не терять ориентацию. И именно это делает игру идеальной для дальнейшей истории: когда ты позже начнёшь автоматизировать прохождение, основная трудность будет не в том, чтобы нажимать кнопки, а в том, чтобы воспроизвести вот эту человеческую способность “держать карту замка в голове”.
Есть несколько причин.
Первый барьер называется частичная наблюдаемость, и это фундаментальная вещь, которую часто недооценивают. В классическом RL многие любят думать, что наблюдение полностью описывает состояние мира. Но в играх типа лабиринта это не так: одинаковая картинка может соответствовать разным “истинным ситуациям” в зависимости от того, откуда вы пришли и что уже сделали. Если вы стоите у двери, ваша правильная стратегия зависит от того, есть ли у вас ключ и пробовали ли вы уже заходить в соседние комнаты. А если агент видит только текущий кадр, то он не может различить эти ситуации, значит он будет делать усреднённое действие, которое часто ведёт к зацикливанию. Правильный подход здесь – признать POMDP и дать модели механизм памяти, либо через рекуррентность (LSTM/GRU), либо через более сложные схемы вроде внешней памяти или карт.
Агент видит только текущий экран.
Но для решения задачи ему нужно помнить:
где уже был
где могут быть ключи
где находится выход
То есть задача фактически является POMDP. POMDP — это Partially Observable Markov Decision Process, то есть процесс принятия решений при частичной наблюдаемости.
Если говорить просто, это математическая модель ситуации, в которой агент принимает решения, но не видит весь мир целиком. Он видит только часть состояния и вынужден догадываться об остальном.
Второй барьер – разреженные и «задержанные» награды. В лабиринте успех часто определяется событием, которое случается через сотни шагов после правильного решения. Человек ломает стену «на всякий случай», а потом через пять минут оказывается, что там был ключ, и это решение окупилось. Алгоритму RL без подсказок чрезвычайно трудно связать далёкую награду с ранним действием: кредитное назначение (credit assignment) становится нестабильным, обучение «пилится» в шум. Типичная ошибка тут — пытаться компенсировать разреженность награды слишком большим штрафом за шаг или слишком большим штрафом за смерть. Тогда агент находит простую стратегию минимизации боли [6]: например, быстро умирает или стоит на месте, если так меньше штрафов. Правильнее делать награду плотнее, но аккуратно: вводить награды за исследование новых комнат, за прогресс по этапам, за значимые события HUD, и параллельно вводить анти‑эксплойты, чтобы агент не превращал подсказку в конечную цель.
Если давать награду только за прохождение уровня – агент будет обучаться очень долго.
Поэтому пришлось разработать систему промежуточных наград:
штраф за смерть
reward за исследование новых комнат
reward за подбор предметов
штраф за застревание
Это создаёт более плотный сигнал обучения.
Третий барьер – инженерный, но именно он чаще всего “убивает” проект. Когда вы обучаете на нестандартной платформе через эмулятор, вы легко попадаете в ситуацию, когда данные не соответствуют действиям. Например, вы нажали клавишу, но сняли скриншот слишком рано, и в кадре ещё старое состояние. Для нейросети это выглядит как хаос: она видит, что действие “RIGHT” не меняет картинку, значит оно бесполезно. После нескольких таких ошибок агент выучивает NOOP и обучение заканчивается. То же самое происходит, если у вас сбоит детектор смерти, если кадры иногда чёрные, если два параллельных инстанса конфликтуют за ресурсы, или если метрик�� считаются “суммой по запускам” вместо “по уникальным комнатам внутри эпизода”. Здесь ключевой принцип – в RL на эмуляторе прежде чем обсуждать алгоритмы, вы должны обеспечить каузальную связку: действие действительно приводит к наблюдаемому изменению, и это изменение фиксируется корректно и стабильно.
Даже после уменьшения кадра до 84×84 grayscale состояние всё равно довольно сложное.
Это означает:
много визуального шума
маленькие изменения между кадрами
сложные переходы между комнатами
Первое, что я сделал, – перестал смотреть на openMSX как на “программу, где запускается игра”, и начал относиться к нему как к физическому устройству, которое нужно автоматизировать. Это важный психологический переключатель: как только ты думаешь “это устройство”, ты сразу начинаешь задавать правильные вопросы. Как я отправляю команду? Как я узнаю, что команда выполнена? Как я синхронизирую действие и наблюдение? Что будет, если команда потеряется, а кадр снимется слишком рано? В RL это не мелочи: одна такая “мелочь” превращается в систематическую ошибку в данных, и модель обучается на мире, который ведёт себя случайно.
Я построил RL-среду вокруг эмулятора openMSX.
Я выбрал простой и максимально проверяемый канал управления: файловый интерфейс. Python пишет команды в commands.tcl, openMSX их подхватывает и исполняет, а ответ пишет в reply.txt, и уже Python читает ответ и понимает, что всё завершилось. Эта схема кажется старомодной, но она даёт то, что мне нужно больше всего: наблюдаемость и воспроизводимость. Если что-то сломалось, у меня остаётся артефакт – файл с командами, файл с ответом, логи процесса, и я могу восстановить, где именно цепочка порвалась. В отличие от “магических” API, здесь всё можно потрогать руками, и это отлично дисциплинирует.
Управление сделано через файловый интерфейс:
Python → commands.tcl → openMSX
openMSX → reply.txt → Python
Каждый шаг агента выглядит примерно так:
агент выбирает действие
Python записывает его в commands.tcl
openMSX выполняет команду
делается скриншот
кадр преобразуется в 84×84 grayscale
Так формируется наблюдение для RL-агента.
Скриншот я тоже рассматриваю как измерительный прибор. Мне важно не “красивое изображение”, а стабильное наблюдение, которое одинаково устроено для обучения, тестирования и записи демонстраций. Поэтому я привёл кадр к простому формату: 84×84 в оттенках серого. Это стандартный компромисс, который резко снижает размер входа для нейросети и ускоряет обучение, не убивая при этом смысл картинки. Но здесь есть типичная ошибка: начать менять препроцессинг по пути – сегодня один кроп, завтра другой, сегодня RGB, завтра grayscale. Так делать нельзя, потому что вы разрушаете совместимость между датасетом, поведением [7] BC и обучением PPO. Я зафиксировал формат как контракт: игра может быть какой угодно, способ захвата может меняться, но итоговый obs должен быть стабильным.
Отдельно я быстро понял, что reset – это половина успеха. В обычной библиотечной среде reset просто возвращает стартовое состояние. В эмуляторе reset – это мини-скрипт: пропустить заставки, нажать нужные клавиши, дождаться, пок�� HUD и картинка станут “осмысленными”, и только после этого отдавать первый кадр агенту. Если отдать кадр слишком рано, агент начнёт действовать в момент, когда игра ещё не готова, и первые шаги превратятся в мусор. Поэтому я сделал reset как процедуру, где я явно жду устойчивого состояния, а не надеюсь, что “оно само как-нибудь”. Это скучная инженерия, но именно она отличает тренажёр, на котором можно обучать, от демо, которое “иногда работает”.
И последняя важная мысль про архитектуру среды: я постоянно проверяю, что действие меняет мир так, как я ожидаю. Это звучит банально, но именно это чаще всего забывают [8]. Если я даю команду RIGHT, я должен видеть изменение кадра, изменение позиции или хотя бы изменение локальных признаков. Если не вижу – я не иду “чинить PPO”, я возвращаюсь и чиню среду. В этом проекте среда – это фундамент, и я отношусь к ней как к продукту: у неё есть интерфейс, инварианты, логирование и диагностика. Иначе всё остальное превращается в гадание.
Обучать PPO с нуля оказалось слишком сложно. Поэтому я начал с Behaviour Cloning.
BC – это в сущности “научить по примеру”. Я записал несколько игровых сессий человека и собрал датасет, где каждый шаг состоит из кадра и действия игрока. Тут важно понимать, как надо собирать такие данные. Плохой способ – записать один идеальный проход и считать, что модель станет умной. Тогда она выучит красивую траекторию, но не научится восстанавливаться после ошибок. Хороший способ – записывать несколько прогонов, где есть вариативность: разные темпы, разные микрорешения, небольшие “ошибки”, которые потом исправляются. Модель тогда видит не один сценарий, а пространство похожих ситуаций, и это делает её устойчивее.
Итак, я записал несколько игровых сессий человека и обучил CNN повторять [9] действия игрока.
Pipeline выглядел так:
Human gameplay → dataset
dataset → CNN (BC)
BC → pretrained policy
Это позволило получить модель, которая:
умеет двигаться
не застревает сразу
иногда проходит первые комнаты
Я взял простую, но рабочую архитектуру: CNN, которая получает на вход несколько последних кадров (frame stack) и предсказывает дискретное действие. Мне важно было не изобретать сложную модель на этом этапе, а получить “движок поведения”, который уже умеет элементарно перемещаться по экрану и не зависает сразу после старта. Здесь типичная ошибка – пытаться сделать BC слишком умным и ожидать, что он решит лабиринт. BC не для этого. Его задача – дать хорошую стартовую точку, в которой агент уже понимает базовую механику управления и видит закономерности “движение → изменение картинки”.
Но BC имеет фундаментальное ограничение: модель не умеет исправлять свои ошибки.
Если она немного отклоняется от траектории игрока – она быстро деградирует.
Когда BC обучился, я получил то, что мне было нужно как фундамент: модель действительно умеет нажимать правильные кнопки в нужные моменты, может пройти первые комнаты и не превращается в статую. Это ощущается почти как магия: ты смотришь, и игра “сама” начинает двигаться. Но тут же становится видно и вышесказанное ограничение BC, которое принципиально важно честно проговорить. BC не умеет исправлять свои ошибки, потому что он не оптимизирует цель “пройти”, он оптимизирует цель “похоже на человека”. Как только модель отклоняется от траектории из датасета и попадает в состояние, которое редко встречалось в демонстрациях, она начинает действовать всё менее уверенно и быстро деградирует. Это как ученик, который выучил текст наизусть: стоит сбиться на одном слове – и дальше он уже не знает, что говорить.
Поэтому следующий шаг – reinforcement learning.
Поэтому я воспринимаю BC как “обучение моторике”, а не как “обучение прохождению”. Он нужен, чтобы агент научился жить в этом мире: двигаться, реагировать на экран, не ломаться на заставках и переходах. И вот после этого уже можно переходить к reinforcement learning, где модель начнёт оптимизировать именно цель, а не имитацию.
После BC я переключился на Proximal Policy Optimization (PPO).
Переход к PPO я делал не как “включил алгоритм и жду чуда”, а как постепенную сборку конвейера обучения. PPO хорош тем, что это достаточно устойчивый метод policy gradient, который умеет обновлять политику по собственному опыту [10], не разрушая её слишком резкими шагами. Но устойчивость PPO не означает, что он прощает плохую постановку. Если вы не контролируете, что именно происходит в rollout, как считается advantage, как масштабирован reward, PPO будет “учиться”, но не тому, что вы думаете. Поэтому я начал с того, что описал для себя стандартный цикл обучения и сделал так, чтобы каждый элемент был прозрачно наблюдаем.
Обучение происходит по стандартному циклу:
rollout → advantage (GAE)
→ PPO update
→ новая политика
Агент взаимодействует со средой, собирает траектории, и затем обновляет политику.
Цикл выглядит так: агент взаимодействует со средой, собирает траектории – последовательности (наблюдение, действие, награда, done). Затем я вычисляю advantage через GAE, то есть оцениваю, насколько каждое действие было лучше или хуже ожиданий, с учётом будущих наград. После этого я делаю несколько эпох обновления PPO на мини-батчах, где оптимизируется политика и value-функция. И затем цикл повторяется. Это “классика”, но важный момент в том, что “классика” работает только тогда, когда данные качественные и метрики позволяют понять, что происходит внутри.
GAE — это Generalized Advantage Estimation. Это способ аккуратно посчитать advantage — то есть насколько конкретное действие оказалось лучше или хуже ожидаемого.
Зачем он нужен? В policy gradient методах (включая PPO) нам важно оценить, было ли действие “хорошим” с учётом будущих наград. Но если считать это напрямую через полный возврат, оценка получается шумной. Если опираться только на value-функцию — получается смещённой.
GAE — это компромисс между: низкой дисперсией (стабильностью) и низким смещением (точностью)
Проще говоря: GAE делает обновление PPO более стабильным и менее шумным, сглаживая оценку преимуществ действия.
Чтобы PPO стартовал не с нуля, я инициализировал модель весами из BC. Это очень важный практический приём: вместо того чтобы тратить огромное число шагов на освоение базовой моторики, я начинаю обучение с политики, которая уже умеет двигаться. Многие делают ошибку «я хочу чистый RL без подсказок», но в прикладных задачах это обычно не имеет смысла. Инициализация из BC — это не чит, а способ сэкономить время и снизить риск того, что обучение не начнётся из‑за отсутствия раннего полезного опыта.
Дальше я поставил себе правило: каждый запуск обучения должен отвечать на вопрос «оно вообще учится?» не по ощущениям, а по сигналам. Поэтому я логирую то, что в RL‑экспериментах часто ленятся логировать: потери политики и критика, энтропию, приближённый KL, explained variance, а также агрегированную статистику по эпизодам. Если энтропия падает слишком быстро — политика становится детерминированной и перестаёт исследовать. Если explained variance уходит в минус — критик не понимает награды, и advantage становится шумом. Если KL слишком большой — обновления слишком агрессивные, и PPO может разрушать политику. Это не «косметика», это приборная панель, без которой вы едете в тумане.
Но самое главное, что я понял именно на Vampire Killer: PPO живёт и умирает от reward. Если reward разреженный и доминирует смерть, агент учится странным вещам, например минимизировать время до окончания эпизода или избегать любых действий. Если reward слишком «сладкий» на предметы, агент превращается в фармера и перестаёт искать выход. Поэтому я подошёл к reward как к дизайну продукта: делаю плотный сигнал, но добавляю защиту от эксплуатации, и обязательно раскладываю reward по компонентам в логах. Тогда я вижу, что в возврате доминирует, и могу исправлять баланс не вслепую, а на основе фактов.
И, наконец, я пришёл к тому, что обучение должно быть не одноразовой кнопкой, а процессом, который можно оставлять на ночь без страха потерять прогресс. Для этого важны чекпоинты, resume, защитные механизмы от падений, накопление метрик, чтобы утром можно было честно сказать: “за ночь произошло вот это”. В моём проекте это принципиально, потому что эксперименты долгие, а баги неизбежны. И вот на этом месте история как раз подходит к текущей точке: у меня уже есть среда, есть BC-инициализация, есть PPO-конвейер с логированием, и сейчас я запускаю обучение в два параллельных environment и оставляю PPO учиться на ночь. Следующую главу я буду писать уже по результатам – что именно изменилось в поведении, где агент начал “помнить”, где он начал “кружить”, и насколько мои метрики действительно отражают прогресс, а не самообман.
Когда я дошёл до PPO, мне стало важно не просто “предсказывать кнопку”, а построить модель, которая умеет учиться на собственном опыте. Для этого я выбрал классическую схему Actor–Critic: одна и та же сеть получает наблюдение, но дальше расходится на две головы – одна выбирает действие, другая оценивает ценность состояния. Это не декоративная архитектура, а практический способ стабилизировать обучение: actor отвечает за “что делать сейчас”, critic помогает понять “насколько это было хорошей идеей” и тем самым делает градиент менее шумным. В моём случае общий пайплайн выглядит так: стопка кадров 84×84 попадает в CNN-энкодер, потом (при необходимости) проходит через LSTM, а уже потом уходит в головы policy и value.
Итак, модель построена по схеме Actor-Critic.
Общий pipeline:
frame stack → CNN encoder → LSTM → actor/critic heads
CNN извлекает признаки из изображения.
Actor-head предсказывает действия. Critic-head оценивает value функции.
В базовой версии encoder выглядит примерно так:
Conv 8×8 stride 4
Conv 4×4 stride 2
Conv 3×3 stride 1
Flatten
Это классическая архитектура, похожая на Atari-агентов.
Я специально оставил энкодер максимально “атари-подобным”, потому что это проверенный рецепт для визуальных RL-задач и он не требует излишней экзотики. В базовой версии это три свёртки: 8×8 со stride 4, затем 4×4 со stride 2, затем 3×3 со stride 1, после чего я делаю Flatten и получаю вектор признаков длины 3136 при стандартной архитектуре.
Это важно понимать как инженерное решение: мне нужно, чтобы модель быстро извлекала структуру сцены – где стены, где лестницы, где герой – и делала это одинаково из шага в шаг. Типичная ошибка на этом месте – усложнять энкодер до “красоты” и терять управляемость: когда что-то не учится, ты уже не понимаешь, виноваты ли данные, reward, синхронизация в эмуляторе или слишком тяжёлая сеть.
Дальше я развёл две головы, и это место часто недооценивают. Actor у меня предсказывает логиты по 10 дискретным действиям, а затем из категориального распределения либо семплируется действие, либо берётся argmax – это важно, потому что в обучении мне нужен стохастический выбор для исследования.
Critic в этот же момент выдаёт скаляр value – оценку ожидаемой будущей награды, и именно через него PPO считает advantage и решает, какие действия “были лучше ожиданий”.
Если сделать critic слабым или плохо масштабировать награды, он начнёт врать, advantage станет шумом, и вы увидите классическую картину: value_loss растёт, explained variance падает, а поведение агента деградирует.
Чтобы это работало “как надо”, я придерживался простого правила: в каждом эксперименте я сначала проверяю не “как играет агент”, а “как ведут себя приборы”. Если логиты actor быстро становятся слишком уверенными и энтропия падает, значит я теряю исследование и политика коллапсирует. Если critic не успевает подстроиться под масштаб наград, я вижу это по метрикам и возвращаюсь не к архитектуре, а к нормализации сигналов и балансировке reward. В этом проекте архитектура – не место, где я ищу чудо; это место, где я фиксирую устойчивый базис, чтобы дальше честно разбираться с памятью и наградой.
Очень быстро после старта PPO стало заметно, что агент в лабиринте ведёт себя так, будто у него амнезия. Он может бодро двигаться, он может даже случайно попасть в новую комнату, но как только игра требует “вспомнить, откуда ты пришёл” или “не ходить по кругу”, поведение распадается. И здесь важно назвать проблему правильно: это не “агент тупой” и не “PPO слабый”, это частичная наблюдаемость. Один и тот же экран в Vampire Killer может означать разные ситуации, потому что смысл зависит от истории: ты здесь впервые или уже третий раз, у тебя был ключ или ты его потерял, ты идёшь к двери или возвращаешься назад. Если модель видит только текущую картинку, она физически не может различить эти случаи и начинает выбирать усреднённые действия, которые чаще всего ведут к зацикливанию.
Повторюсь, когда я начал обучать PPO, быстро стало понятно: агент не может ориентироваться в лабиринте.
Проблема – частичная наблюдаемость. Два экрана могут выглядеть одинаково, но требовать разных действий.
Чтобы решить это, я добавил LSTM между encoder и policy.
Это даёт модели возможность хранить кратковременную память [11]:
CNN → LSTM → Actor/Critic
Теперь агент может помнить:
куда он уже ходил
откуда пришёл
какие комнаты посещал
На практике это выглядит очень узнаваемо. Представь две комнаты, которые визуально похожи: одинаковые стены, одинаковая лестница, свечи на тех же местах. Человек различает их по контексту – “я сюда пришёл сверху, значит дальше надо налево”, – а у CNN такого контекста нет. Frame stack из четырёх кадров помогает только чуть-чуть: это память на доли секунды, она хорошо ловит скорость и направление движения, но почти не помогает с навигацией по замку. Поэтому я вставил LSTM между энкодером и головами actor/critic, чтобы модель могла переносить скрытое состояние от шага к шагу внутри эпизода.
Как сделать это правильно, чтобы не сломать всё обучение? Самая частая ошибка – “просто добавить LSTM” и забыть, что теперь у тебя появляется состояние, которое нужно хранить отдельно для каждого параллельного environment и сбрасывать на границах эпизода. Я сделал именно так, как требует рекуррентная постановка: у каждого env есть своё (h, c), на каждом шаге я передаю его в модель и получаю новое, а когда эпизод заканчивается (смерть, stuck, таймаут), я обнуляю скрытое состояние только для этого env.
Если этого не сделать, у тебя начинается “утечка памяти” между эпизодами: агент как будто помнит то, чего помнить не должен, и метрики становятся неинтерпретируемыми.
Мне было важно не верить на слово, что “память включилась”, а уметь это проверить. Поэтому я ввёл простые способы диагностики: в конфиг-снапшоте сохраняется факт recurrent=true, а в логах появляется h_norm – средняя норма скрытого состояния, по которой видно, что LSTM действительно живёт и меняется.
И только после таких проверок я начинаю смотреть на поведение агента и задавать правильный вопрос: память помогает ему меньше “пингпонговать” между двумя комнатами и стабильнее искать новые? Именно ради этого я и добавлял LSTM – не ради архитектурной красоты, а ради того, чтобы навигация стала задачей с контекстом, а не лотереей на одном кадре.
Самая сложная часть проекта – это не PPO и не LSTM, а то, что обычно называют reward design. В лабиринтной игре нельзя рассчитывать, что редкое событие “прошёл уровень” само по себе обучит агента: до него слишком далеко, и агент большую часть времени будет получать либо ноль, либо штрафы, и не поймёт, что именно ведёт к прогрессу. Поэтому я строил награду как систему сигналов, которая мягко подталкивает к исследованию, но не позволяет “взломать” постановку. И здесь важно сразу сказать, как делать не надо: нельзя давать агенту единственную цель в виде большого бонуса в конце и ждать, что он сам откроет лабиринт. Почти всегда он откроет не лабиринт, а способ застрять в простом поведении, которое минимизирует наказания и не требует риска.
Я начал с базовых вещей, которые задают ритм. Моя текущая версия включает несколько компонентов (тут описана небольшая часть):
step penalty чтобы агент не стоял на месте
Step penalty нужен, чтобы агент не мог бесконечно стоять на месте и “ждать”, пока что-то случится: маленький штраф за шаг заставляет его искать действия, которые меняют ситуацию.
death penalty штраф за смерть
Death penalty нужен, чтобы смерть ощущалась как явная ошибка, но тут легко переборщить: если штраф слишком доминирующий, агент учится не играть, а избегать всего, что может привести к смерти, вплоть до странных паттернов вроде “умирать быстро, чтобы закончить эпизод”. Поэтому я постоянно смотрю на баланс компонентов и на то, что именно даёт вклад в эпизодный возврат, иначе reward превращается в рулетку.
novelty reward награда за вход в новую комнату
Дальше начинается самое интересное – novelty reward, то есть награда за вход в новую комнату. Это мой способ превратить “исследование” в измеримый и поощряемый процесс. Технически я делаю хэш комнаты по изображению, но обязательно с гистерезисом: я считаю комнату “новой” только если один и тот же хэш держится K кадров подряд, иначе агент мог бы получать награду на мерцании или случайных артефактах.
pickup reward награда за предметы / stuck penalty штраф за застревание
И ещё один важный нюанс, который я поймал на отладке: если в хэш попадает HUD (HUD — это Head-Up Display. Это интерфейсные элементы поверх игрового экран��, которые показывают служебную информацию: здоровье, очки, таймер, количество жизней, предметы и так далее.), он может “залипать” или, наоборот, дрожать, и тогда метрика уникальных комнат и novelty начинают врать. Поэтому я режу верхнюю часть кадра и считаю хэш по зоне геймплея – это маленькая деталь, но именно из таких деталей состоит честная награда.
Кроме новизны я добавил сигнал за предметы, потому что в игре предметы – это “маленькие победы”, которые часто коррелируют с правильным исследованием. Но тут самая типичная ошибка – сделать pickup reward слишком вкусным, и тогда агент превращается в фермера: он будет выбивать свечи и ходить по одному экрану, потому что это стабильная награда, а выход из уровня – слишком рискованный. Поэтому я использую предметы как вторичный сигнал и обязательно контролирую, не начинает ли он доминировать над novelty и прогрессом. Параллельно я ввёл stuck penalty и stuck-детекцию, потому что лабиринтные политики очень любят “залипать” в цикле: если комнаты не меняются долго и кадр почти не отличается, это почти всегда означает, что агент крутится на месте или упирается в препятствие.
Чтобы reward не превращался в систему, которую агент легко эксплойтит, я добавил анти-пингпонг – штраф за A–B–A–B переключения между двумя комнатами в коротком окне.
Это важный принцип: как только ты поощряешь новизну, ты обязан защититься от “ложной новизны” и от стратегий, которые выглядят как исследование, но на самом деле просто качают метрику. И последнее, что делает всю систему управляемой: я логирую компоненты награды отдельно и накапливаю их по эпизоду, чтобы утром не гадать, “почему стало лучше”, а видеть, что именно изменилось – novelty вырос, stuck упал, смерть стала реже.
В результате reward у меня работает как проводник по лабиринту: он подталкивает идти вперёд, пробовать новые комнаты, не застревать и не умирать без необходимости. Но я постоянно держу в голове опасность: reward – это не “правда”, это подсказка, и агент будет оптимизировать подсказку, а не моё намерение. Поэтому я считаю главным навыком в этом проекте не “настроить PPO”, а научиться строить такие подсказки, которые приближают поведение к прохождению, а не уводят в красивые, но пустые паттерны.
В процессе разработки всплыли десятки технических проблем.
Например:
ложные срабатывания детектора смерти
захват кадров из эмулятора
синхронизация действий и скриншотов
обучение с несколькими environment
Особенно сложной оказалась работа с несколькими инстансами openMSX.
Когда я дошёл до момента “ну всё, сейчас начнётся обучение”, реальность быстро вернула меня на землю. В эмуляторной RL-среде почти невозможно получить чистый, стерильный эксперимент: у тебя всегда есть задержки, артефакты кадров, переходные состояния после reset, и куча мелких рассинхронизаций, которые по отдельности выглядят как ерунда, но вместе убивают обучение. Самая неприятная категория проблем – те, где модель выглядит “плохой”, хотя на самом деле среда ей врёт: ты нажимаешь действие, а кадр фиксируется до того, как игра отрисовала результат, и модель учится на мире, где действия не имеют эффекта. Именно поэтому я начал относиться к пайплайну как к измерительной системе: если измерение нестабильно, ни PPO, ни LSTM тебя не спасут.
Один из первых ударов был по детектору смерти. Я увидел симптом, который легко спутать с “агент сразу умирает”: эпизоды при двух средах заканчивались буквально за пару шагов, reward становился отрицательным, а метрики говорили “death=1” – и это выглядело так, будто всё совсем плохо. По факту это оказалось ложным срабатыванием на артефактных кадрах и на переходных состояниях, поэтому мне пришлось вводить гистерезис (гистерезис — это эффект, при котором система не реагирует мгновенно на изменение сигнала, а “ждёт подтверждения” или учитывает прошлое состояние. Один “подозрительный” кадр не должен сразу считаться новым состоянием. Лучше требовать, чтобы одно и то же условие выполнялось несколько кадров подряд. Это отсекает шум, мерцание, переходные кадры и случайные артефакты.), прогрев в первые шаги и режимы диагностики, где я могу отключить смерть и посмотреть, что происходит “на самом деле”. В процессе я добавил в info причины завершения эпизода и сырые сигналы, чтобы перестать гадать и начать видеть, что именно триггерит terminated/truncated.
Параллельно всплыла тема захвата кадров – это вообще отдельная дисциплина. Я начинал с самого надёжного варианта: openMSX пишет PNG на диск, Python читает файл и делает препроцессинг до 84×84 grayscale, потому что это воспроизводимо и легко отлаживается. Но как только ты начинаешь измерять скорость, становится видно, что время уходит не на нейросеть, а на I/O: запись PNG, чтение PNG, декодирование – всё это съедает throughput и влияет на синхронизацию “действие → кадр”. Поэтому я оформил захват как модуль с несколькими backend’ами, где можно выбрать надёжность или скорость, и отдельно описал, как включать/выключать шумные логи, чтобы во время обучения не тонуть в “screenshot captured”.
Самым жёстким оказалось обучение в нескольких environment, потому что там ломается всё, что “случайно работало” в одном инстансе. В одном env можно не заметить, что ты используешь общий путь для скриншота или общий рабочий каталог, потому что конфликтов нет. В двух env эти конфликты становятся фатальными: один инстанс перетирает файлы другого, кадры путаются местами, reset одного влияет на другого, и у тебя появляется ощущение мистики – “оно то работает, то не работает”. Я специально ввёл реестр ресурсов и проверки уникальности workdir/screenshot_path, чтобы ловить такие коллизии сразу и падать с понятной ошибкой, а не получать “странное обучение”.
И отдельная боль – метрики, которые сначала выглядят разумно, а потом выясняется, что они считают не то. На этой стадии очень легко самообмануться: например, “unique_rooms” может оказаться суммой по всем прогонам или по переходам, а не числом уникальных комнат внутри эпизода, и ты начинаешь думать, что агент исследует, хотя он просто пингпонгует. Поэтому я сделал отдельную эпизодную версию метрик с суффиксом ep, где комнаты считаются с debounce по roomhash и фиксируются только при done, чтобы сравнение запусков стало честным.
Когда базовая среда заработала, следующим естественным желанием стало ускорить обучение. В PPO скорость – это валюта: чем быстрее ты собираешь rollout, тем быстрее ты делаешь обновления, тем быстрее видишь, работает ли гипотеза. Самый прямой способ – запустить несколько параллельных environment, чтобы они одновременно генерировали опыт. Я начал с двух: env0 и env1, и у каждого свой экземпляр openMSX, свой рабочий каталог, свои файлы команд и ответов, свои скриншоты и свои логи.
env0 → openMSX #1
env1 → openMSX #2
Rollout собирается по очереди:
env0 → env1 → env0 → env1
Это увеличивает скорость обучения примерно в два раза.
Важно понимать, как именно это ускоряет PPO, чтобы не строить иллюзий. Я не “ускоряю один шаг игры”, я увеличиваю суммарный throughput сбора данных: пока один env делает шаг и ждёт кадр, другой может делать свой шаг, и в итоге я быстрее набираю нужное количество переходов для батча. В моём цикле это выглядит как чередование: шагнул env0, шагнул env1, снова env0, снова env1 – и так собирается rollout на два потока опыта. Это не магия параллелизма уровня GPU, но в эмуляторной среде даже такое чередование даёт заметный прирост по steps/s, потому что задержки захвата и синхронизации частично “размазываются” между двумя инстансами.
Но вместе со скоростью появляется строгая инженерная цена: всё должно быть изолировано. Если ты хоть где-то оставил общий ресурс – общий step_frame.png, общий commands.tcl, общий workdir, общий window crop – ты получишь не “два env”, а два процесса, которые мешают друг другу и генерируют некорректные данные. Именно поэтому в multi-env режиме я ввёл дополнительные механизмы безопасности: ресурсный реестр, диагностические выводы reset/termination, паузы между стартами openMSX, и handshake на reset, чтобы оба инстанса стабильно входили в игру, а не отдавали аген��у переходные кадры.
Ещё один принципиальный момент – рекуррентная модель и два env. Как только у тебя есть LSTM, ты обязан хранить hidden state отдельно для каждого окружения и обнулять его по done для конкретного env. Если перепутать состояния или сбрасывать не там, ты получишь “утечки памяти”, когда один эпизод влияет на другой, а это моментально разрушает интерпретацию результатов. Поэтому multi-env для меня – это не просто “ускорить”, а “ускорить так, чтобы данные остались чистыми”, иначе скорость станет ускорением к неверным выводам.
Сейчас проект выглядит как связный пайплайн, а не набор разрозненных скриптов, и это для меня главный рубеж. Сначала я записываю человеческие демонстрации и собираю датасет, потом обучаю Behaviour Cloning модель, которая даёт стартовую политику “умею двигаться и не разваливаюсь сразу”, а затем инициализирую PPO этой политикой и начинаю RL-обучение уже по награде и метрикам. Внутри PPO модель – Actor-Critic с CNN-энкодером и LSTM, потому что лабиринт требует памяти, а без памяти политика слишком легко зацикливается. И, наконец, сбор опыта идёт из двух environment, чтобы быстрее наполнять rollout и быстрее делать обновления.
Human demos
↓
Behaviour Cloning
↓
PPO + CNN + LSTM
↓
2 environments
Сегодня вечером я запускаю ночное обучение PPO
и оставляю модель тренироваться на несколько часов.
Посмотрим, сможет ли она научиться проходить хотя бы первые уровни.

Я специально сохранил дисциплину “всё измеримо”: у меня есть метрики скорости и обучения, у меня есть эпизодные метрики по комнатам и переходам, у меня есть диагностика причин termination, и у меня есть логи компонентов награды, чтобы я видел, за что именно агент получает возврат. Это нужно не ради красоты отчёта, а чтобы утром после длительного прогона я мог ответить на один честный вопрос: агент реально начал лучше исследовать, или я просто смотрю на шум? И если окажется, что он “улучшил” только один компонент награды и ушёл в эксплойт, я хочу это увидеть сразу, а не через неделю.
Сейчас я нахожусь в самой интересной точке проекта. Среда собрана, Behaviour Cloning дал стартовую политику, PPO с CNN и LSTM запущен, два параллельных environment стабильно собирают опыт. Ночью модель будет учиться несколько часов подряд – без моего вмешательства, только на основе того мира и той системы наград, которые я для неё построил.
В следующей части я разберу, что произошло на самом деле. Не просто графики, а реальное поведение: начал ли агент исследовать комнаты, перестал ли зацикливаться, научился ли избегать тупиков. Отдельно посмотрим, где reward-функция сработала правильно, а где агент нашёл неожиданный эксплойт. И главный вопрос – сможет ли он приблизиться к самостоятельному выходу из лабиринта.
А пока модель учится. Утром посмотрим, чему именно.
Если вам интересна инженерная сторона – как я построил среду вокруг openMSX, как собирал датасет для BC, как устроен PPO training loop и сколько неожиданных багов пришлось чинить – я могу вынести это в отдельные подробные материалы.
P.S. кстати, это не единственный проект про Vampire killer на MSX. В прошлом, я сделал 3D-диараму:
Автор: GromovBI
Источник [12]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/26493
URLs in this post:
[1] память: http://www.braintools.ru/article/4140
[2] реакция: http://www.braintools.ru/article/1549
[3] обучение: http://www.braintools.ru/article/5125
[4] ошибка: http://www.braintools.ru/article/4192
[5] поведению: http://www.braintools.ru/article/9372
[6] боли: http://www.braintools.ru/article/9901
[7] поведением: http://www.braintools.ru/article/5593
[8] забывают: http://www.braintools.ru/article/333
[9] повторять: http://www.braintools.ru/article/4012
[10] опыту: http://www.braintools.ru/article/6952
[11] кратковременную память: http://www.braintools.ru/article/9493
[12] Источник: https://habr.com/ru/articles/1005864/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1005864
Нажмите здесь для печати.