Траектория «Обучение с подкреплением» · конспект 6 из 12

Среды Gymnasium: устройство, обёртки, своя среда

О чём эта тема
Инженерная пауза между табличными методами и глубоким RL: как устроена среда Gymnasium изнутри, как написать собственную среду для своей задачи, как модифицировать чужую среду обёртками, чем завершение эпизода отличается от усечения и зачем маскировать недопустимые действия. По официальным туториалам Gymnasium (Farama Foundation).
Аннотация
Конспект раскрывает контракт интерфейса Env: пять методов и атрибутов, которыми среда обязана обладать, и точный смысл пятёрки значений, возвращаемой step(). Затем систематизируются пространства состояний и действий — от Discrete до составного Dict. Центральная часть — постройка собственной среды GridWorld по официальному туториалу: наследование от gymnasium.Env, генератор случайных чисел с воспроизводимым зерном, регистрация по имени и создание через make(). Дальше — обёртки: четыре базовых класса, позволяющих преобразовать наблюдения, действия и награды среды, не трогая её код. Отдельный раздел посвящён различию terminated/truncated и его влиянию на цель обучения — ошибке, ломающей алгоритмы молча. Завершает конспект маскирование недопустимых действий на примере среды Taxi. В тренажёре среда GridWorld с обёртками и лимитом времени живёт прямо в браузере.
Пререквизиты
Конспект 1 (цикл агент–среда, первый запуск Gymnasium, terminated/truncated на пальцах), конспект 3 (марковское свойство), конспект 4 (бутстрап), конспект 5 (Q-learning: цель обновления, FrozenLake и лимит 100 шагов).
Мотивация
Всё, чему мы учились до сих пор, работало в готовых средах из каталога. Но реальная задача — управление станком, маршрутизация, игровой ИИ из траектории «Геймдев» — в каталоге не лежит: среду придётся написать самому. Хорошая новость: интерфейс один, и алгоритмы конспектов 5–11 подключаются к любой среде без изменений — так, конспект преподавателя демонстрирует управление летающим крылом в сторонней среде PyFlyt тем же reset()/step(). Плохая новость: у интерфейса есть тонкие места (зерно генератора, усечение эпизода), ошибки в которых не падают с исключением, а молча портят обучение.

1. Контракт среды: пять точек входа

В конспекте 1 мы уже запускали CartPole-v1: создать среду, вызвать reset(), крутить step(action) до конца эпизода. Теперь посмотрим на этот интерфейс со стороны того, кто среду пишет. Любая среда Gymnasium — класс, наследующий gymnasium.Env и реализующий следующий контракт:

Член классаОбязанность
observation_spaceописание множества возможных наблюдений (раздел 2); заполняется в конструкторе
action_spaceописание множества допустимых действий; у него агент запрашивает sample() — случайное действие
reset(seed, options)(obs, info)начать новый эпизод: вернуть начальное наблюдение и словарь вспомогательной информации
step(action)(obs, reward, terminated, truncated, info) применить действие: новое наблюдение, награда за шаг, два флага конца эпизода (раздел 5) и info
render(), close()нарисовать текущее состояние (необязательно; режимы перечисляются в атрибуте metadata) и освободить ресурсы

Пятёрка из step() — это в точности единица опыта \((s, a, r, s')\) из конспекта 1 плюс два флага конца эпизода. Всё общение агента со средой проходит через эти вызовы: алгоритму Q-learning из конспекта 5 неважно, что за env ему передали — замёрзшее озеро, тележку или летающее крыло, — лишь бы контракт соблюдался. Именно поэтому один и тот же код обучения будет работать во всех оставшихся конспектах траектории.

Случайность среды обязана быть управляемой. Базовый класс gymnasium.Env предоставляет генератор self.np_random; вызов reset(seed=42) инициализирует его детерминированно, а reset() без зерна — продолжает уже начатую последовательность. Стандартный порядок работы: первый reset эпизода серии — с зерном, последующие — без, тогда вся серия воспроизводима от одного числа.

2. Пространства: чем описаны наблюдения и действия

Поля observation_space и action_space — объекты из модуля gymnasium.spaces. Пространство знает свой тип и границы, умеет породить случайный элемент (sample()) и проверить принадлежность (contains(x)). Основные типы:

ПространствоЭлементыЗнакомый пример
Discrete(n)целые \(0, \ldots, n-1\) FrozenLake: состояния Discrete(16), действия Discrete(4) (конспект 5)
Box(low, high, shape)вещественные векторы/тензоры в границахCartPole: наблюдение Box(4,) — координата, скорость, угол, угловая скорость (конспект 1)
MultiDiscrete([n₁, n₂, …])вектор целых с разными диапазонамиклетка сетки: пара (столбец, строка)
Dict({...}), Tuple((...))составные: словарь или кортеж из других пространствGridWorld ниже: Dict из позиций агента и цели

Насколько разными бывают пространства одной и той же конструкции, хорошо видно на игре Space Invaders из пакета Atari — среде, на которой в конспекте 7 будет обучаться DQN. Её наблюдение — тоже Box, но не четвёрка чисел, а целый экран: Box(0, 255, (210, 160, 3), uint8) — картинка 210×160 в трёх цветовых каналах. Действий шесть — Discrete(6): ничего не делать, выстрел, вправо, влево и два совмещённых «движение + выстрел». Интерфейс при этом ровно тот же: кадры ниже сняты кодом из конспекта 1 — reset(seed=7) и цикл step() со случайной политикой, которая продержалась 436 шагов и набрала 30 очков:

Начальный кадр Space Invaders: шесть рядов пришельцев, три укрытия, пушка внизу
Наблюдение после reset(seed=7): счёт 0
Анимация эпизода Space Invaders: пушка хаотично двигается и стреляет, пришельцы спускаются, эпизод кончается потерей жизней
Тот же эпизод целиком: 436 шагов, 30 очков, terminated = True

Выбор пространства — проектное решение с последствиями. Табличным методам (конспект 5) нужен Discrete: состояние — индекс строки таблицы; экран Space Invaders в таблицу уже не положишь — понадобится нейросеть (конспект 7). Сети принимают числовые векторы и тензоры, поэтому Dict-наблюдения перед подачей в сеть выпрямляют — вручную или готовой обёрткой FlattenObservation (раздел 4).

3. Своя среда: GridWorld

Построим по официальному туториалу минимальную, но полноценную среду. Мир-сетка (GridWorld) — квадратное поле \(size \times size\); агент ходит по клеткам в четырёх направлениях, цель размещается случайно в начале эпизода. Наблюдение — позиции агента и цели; награда разреженная и бинарная: 0 на каждом шаге и 1 при достижении цели, после чего эпизод завершается. Конструктор объявляет оба пространства и таблицу «номер действия → сдвиг»:

import numpy as np
import gymnasium as gym
from gymnasium import spaces

class GridWorldEnv(gym.Env):
    def __init__(self, size=5):
        self.size = size                       # сторона квадратной сетки
        # наблюдение — словарь из двух позиций на сетке
        self.observation_space = spaces.Dict({
            "agent":  spaces.Box(0, size - 1, shape=(2,), dtype=int),
            "target": spaces.Box(0, size - 1, shape=(2,), dtype=int),
        })
        self.action_space = spaces.Discrete(4)  # вправо, вверх, влево, вниз
        self._action_to_direction = {           # номер действия -> сдвиг [dx, dy]
            0: np.array([1, 0]),   # вправо
            1: np.array([0, 1]),   # вверх
            2: np.array([-1, 0]),  # влево
            3: np.array([0, -1]),  # вниз
        }
        self._agent_location  = np.array([-1, -1], dtype=int)
        self._target_location = np.array([-1, -1], dtype=int)

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

    def _get_obs(self):
        return {"agent": self._agent_location, "target": self._target_location}

    def _get_info(self):
        return {"distance": np.linalg.norm(
            self._agent_location - self._target_location, ord=1)}

reset начинает эпизод: агент — в случайной клетке, цель — в случайной клетке, не совпадающей с агентом. Обратите внимание на первую строку:

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)               # инициализирует self.np_random!
        self._agent_location = self.np_random.integers(0, self.size, size=2, dtype=int)
        # цель перевыбирается, пока не отличится от агента
        self._target_location = self._agent_location
        while np.array_equal(self._target_location, self._agent_location):
            self._target_location = self.np_random.integers(0, self.size, size=2, dtype=int)
        return self._get_obs(), self._get_info()
Типичная ошибка Забывают вызвать super().reset(seed=seed). Среда продолжит работать — но генератор self.np_random не будет инициализирован зерном, и эксперименты перестанут воспроизводиться: одна из самых неприятных ошибок, потому что она не проявляется ничем, кроме «у меня вчера получалось другое число».

step несёт основную логику: сдвинуть агента (не выпуская за край — np.clip), проверить достижение цели, собрать пятёрку результата. Усечения сама среда не объявляет — четвёртым значением всегда возвращается False, лимит времени добавится снаружи при регистрации (раздел 3.1):

    def step(self, action):
        direction = self._action_to_direction[int(action)]
        self._agent_location = np.clip(        # шаг с ограничением краями поля
            self._agent_location + direction, 0, self.size - 1)
        terminated = np.array_equal(self._agent_location, self._target_location)
        reward = 1 if terminated else 0       # разреженная бинарная награда
        return self._get_obs(), reward, terminated, False, self._get_info()

Этого достаточно: методы render и close необязательны (в туториале есть отрисовка через PyGame — карандашный скелет для собственных сред; здесь она опущена, её роль выполняет тренажёр ниже). Среда готова к использованию — конструктором напрямую или через регистрацию.

3.1. Регистрация и make()

Чтобы среда создавалась по имени, как CartPole-v1, её регистрируют:

from gymnasium.envs.registration import register

register(
    id="gymnasium_env/GridWorld-v0",       # пространство имён / Имя - версия
    entry_point=GridWorldEnv,               # класс или строка "модуль:Класс"
    max_episode_steps=300,                  # лимит шагов: обёртка TimeLimit
)
env = gym.make("gymnasium_env/GridWorld-v0", size=10)  # kwargs уходят в __init__

Идентификатор состоит из необязательного пространства имён, имени и рекомендуемой версии. Аргумент max_episode_steps важнее, чем кажется: make() автоматически оборачивает среду в TimeLimit, и после 300 шагов эпизод вернёт truncated = True — то самое усечение, что обрезало FrozenLake на 100 шагах и стоило гномику из конспекта 5 восьми сотых средней награды. Регистрация также позволяет собрать среду в пакет (pyproject.toml + pip install -e .) и раздавать её как любую python-библиотеку.

4. Обёртки: изменить среду, не трогая её код

Часто нужна не новая среда, а вариация существующей: другой формат наблюдений, урезанный набор действий, перевзвешенная награда. Переписывать класс среды — плохой путь; Gymnasium предлагает обёртки (англ. wrapper): объект, который содержит среду внутри и перехватывает вызовы. Обёртки свободно вкладываются друг в друга — вспомните TimeLimit, который make() надел на GridWorld; добраться до исходной среды под всеми слоями можно через env.unwrapped. Для трёх типовых преобразований есть специализированные базовые классы, где достаточно переопределить один метод:

Базовый классПереопределяетсяЧто меняет
ObservationWrapperobservation(obs) каждое наблюдение из reset и step
ActionWrapperaction(a) действие по пути от агента к среде
RewardWrapperreward(r) награду из step
Wrapperstep, reset, … всё сразу — для сложных случаев

Наблюдения: пусть агенту в GridWorld достаточно относительной позиции цели — вектора «куда идти». Обёртка выбрасывает лишние степени свободы и обязана объявить новое пространство наблюдений:

from gymnasium import ObservationWrapper, ActionWrapper, RewardWrapper, Wrapper
from gymnasium.spaces import Box, Discrete

class RelativePosition(ObservationWrapper):
    def __init__(self, env):
        super().__init__(env)
        # формат наблюдений изменился -> пространство нужно переобъявить
        self.observation_space = Box(shape=(2,), low=-np.inf, high=np.inf)

    def observation(self, obs):
        return obs["target"] - obs["agent"]   # вектор от агента к цели
Типичная ошибка Преобразуют наблюдение (действие), но забывают обновить observation_space (action_space) обёртки. Код, который смотрит на пространство — инициализация размеров нейросети, проверки contains, векторизация сред, — получит описание старого формата и развалится в стороне от настоящей причины.

Действия: среда с непрерывным управлением (Box), а алгоритм — табличный или DQN — умеет только дискретные. Обёртка объявляет конечное меню и переводит номер в вектор:

class DiscreteActions(ActionWrapper):
    def __init__(self, env, disc_to_cont):
        super().__init__(env)
        self.disc_to_cont = disc_to_cont
        self.action_space = Discrete(len(disc_to_cont))

    def action(self, act):
        return self.disc_to_cont[act]          # номер -> непрерывный вектор

env = gym.make("LunarLanderContinuous-v3")   # action_space: Box(-1, 1, (2,))
wrapped = DiscreteActions(env, [np.array([1, 0]), np.array([-1, 0]),
                                np.array([0, 1]), np.array([0, -1])])
# wrapped.action_space: Discrete(4)

Награды: когда награда среды нам неподконтрольна, а численная стабильность нужна, её ограничивают диапазоном (этот приём встретится в DQN на играх Atari — там все награды обрезаются до ±1):

class ClipReward(RewardWrapper):
    def __init__(self, env, min_reward, max_reward):
        super().__init__(env)
        self.min_reward, self.max_reward = min_reward, max_reward

    def reward(self, r):
        return np.clip(r, self.min_reward, self.max_reward)

Наконец, общий Wrapper — когда преобразование затрагивает несколько каналов сразу. Пример из туториала: среды MuJoCo возвращают награду как сумму слагаемых с фиксированными весами, а слагаемые кладут в info; обёртка пересобирает награду из info со своими весами:

class ReacherRewardWrapper(Wrapper):
    def __init__(self, env, reward_dist_weight, reward_ctrl_weight):
        super().__init__(env)
        self.reward_dist_weight = reward_dist_weight
        self.reward_ctrl_weight = reward_ctrl_weight

    def step(self, action):
        obs, _, terminated, truncated, info = self.env.step(action)
        reward = (self.reward_dist_weight * info["reward_dist"]
                  + self.reward_ctrl_weight * info["reward_ctrl"])
        return obs, reward, terminated, truncated, info

Помимо самодельных, в gymnasium.wrappers есть десятки готовых обёрток: FlattenObservation (словарь → вектор), NormalizeReward, RecordVideo, AtariPreprocessing и другие — прежде чем писать свою, стоит посмотреть список.

5. Завершение против усечения

Эпизод может закончиться по двум принципиально разным причинам. Завершение (англ. termination) — достигнуто терминальное состояние, определённое самой задачей: цель взята, стержень упал, гномик в проруби. После терминального состояния будущего нет, его ценность — ноль. Усечение (англ. truncation) — эпизод оборван внешним условием, обычно лимитом шагов из TimeLimit. Последнее состояние при усечении не терминальное: у него есть и будущее, и ценность — просто мы перестали смотреть.

Различие бьёт прямо по цели обучения. Вспомним цель Q-learning (конспект 5, формула (5.3)): по неконечному переходу цель строится бутстрапом,

\[ Q_{\text{цель}} = r_t + \gamma \max_{a'} Q(s_{t+1}, a'), \tag{6.1}\]

а в терминальном состоянии бутстрапа нет — будущее отсутствует:

\[ Q_{\text{цель}} = r_t. \tag{6.2}\]

Какую из формул применять на последнем шаге эпизода? При завершении — (6.2); при усечении — (6.1), ведь состояние не терминальное. Старый интерфейс Gym возвращал один флаг done, не различавший эти случаи, и типовая строка кода

target = r + gamma * (1 - done) * V(s_next)          # НЕВЕРНО при усечении

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

target = r + gamma * (1 - terminated) * V(s_next)    # бутстрап при усечении сохранён
Типичная ошибка В коде обучения пишут done = terminated or truncated и используют done и для выхода из цикла, и в цели обновления. Для цикла это верно, для цели — нет: при усечении зануляется бутстрап состояния, у которого есть будущее. Ошибка молчалива и коварна: чем короче лимит шагов, тем сильнее занижены ценности «медленных» состояний.

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

6. Тренажёр: GridWorld вживую

Среда GridWorld из раздела 3 реализована ниже точно по коду туториала: те же четыре действия, тот же перевыбор цели в reset, та же награда. Сверху надеты две обёртки этого конспекта: TimeLimit с настраиваемым лимитом (усечение видно по жёлтой плашке) и включаемая RelativePosition, меняющая наблюдение на вектор до цели. Действующая политика показана в панели: по умолчанию она ручная — ходите за агента кнопками; кнопка «эпизод случайной политики» передаёт управление случайной политике, и она доигрывает эпизод сама, шаг за шагом — ходы видны на поле и в журнале, вместе с той самой пятёркой, что возвращает step().

Тренажёр: своя среда GridWorld, обёртки и усечение
шаг за агента:
агент · цель · y растёт вверх, клетка = [x, y]

Что стоит увидеть своими глазами. Первое: reset(seed) с одним и тем же зерном всегда строит одну и ту же серию эпизодов, а reset() продолжает последовательность генератора — это правило воспроизводимости из раздела 1. Второе: случайной политике на поле 5×5 нередко не хватает и 20 шагов — усечение при живой цели наглядно отличается от завершения на цели. Третье: включите RelativePosition — само поведение среды не меняется, меняется только формат наблюдения в журнале: обёртка прозрачна для динамики.

7. Маскирование действий

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

Среда может помочь: Taxi кладёт в info двоичную маску действий action_mask — единицы у допустимых действий. Использовать её — три точечных изменения в Q-learning из конспекта 5: случайный выбор — только из допустимых, жадный выбор — argmax по допустимым, и максимум в цели (6.1) — тоже по допустимым действиям следующего состояния:

state, info = env.reset(seed=seed)
mask = info["action_mask"]                 # [1,0,1,1,0,0]: что допустимо сейчас
valid = np.nonzero(mask == 1)[0]           # индексы допустимых действий

if np.random.random() < epsilon:
    action = np.random.choice(valid)        # разведка не выходит за маску
else:
    action = valid[np.argmax(q_table[state, valid])]  # жадность по допустимым

В эксперименте туториала (Taxi, 12 независимых прогонов по 5000 эпизодов, α = 0,1, γ = 0,95, ε = 0,1) агент с маской обучается быстрее и стабильнее агента без маски: он не тратит шаги на упирание в стены, дисперсия кривых обучения заметно ниже, средняя награда выше. Вне учебных задач у маскирования есть и третья роль — безопасность: недопустимое действие промышленного агента может быть не просто бесполезным, а разрушительным. Мы вернёмся к идее маски в конспекте о средах с большим пространством действий.

Контрольные вопросы

Источники

  1. Make your own custom environment — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/environment_creation.html (дата обращения: 08.07.2026).
  2. Handling Time Limits — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/handling_time_limits.html (дата обращения: 08.07.2026).
  3. Implementing Custom Wrappers — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/implementing_custom_wrappers.html (дата обращения: 08.07.2026).
  4. Action Masking in the Taxi Environment — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/training_agents/action_masking_taxi.html (дата обращения: 08.07.2026).