Среды 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 очков:
reset(seed=7): счёт 0
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. Для трёх типовых преобразований
есть специализированные базовые классы, где достаточно переопределить один метод:
| Базовый класс | Переопределяется | Что меняет |
|---|---|---|
ObservationWrapper | observation(obs) |
каждое наблюдение из reset и step |
ActionWrapper | action(a) |
действие по пути от агента к среде |
RewardWrapper | reward(r) |
награду из step |
Wrapper | step, 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)): по неконечному переходу цель строится бутстрапом,
а в терминальном состоянии бутстрапа нет — будущее отсутствует:
Какую из формул применять на последнем шаге эпизода? При завершении — (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().
Что стоит увидеть своими глазами. Первое: 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) агент с маской обучается быстрее и стабильнее агента без маски: он не тратит шаги на упирание в стены, дисперсия кривых обучения заметно ниже, средняя награда выше. Вне учебных задач у маскирования есть и третья роль — безопасность: недопустимое действие промышленного агента может быть не просто бесполезным, а разрушительным. Мы вернёмся к идее маски в конспекте о средах с большим пространством действий.
Контрольные вопросы
-
(obs, reward, terminated, truncated, info): новое наблюдение; награда за шаг; флаг достижения терминального состояния задачи; флаг внешнего усечения эпизода; словарь вспомогательной информации (отладка, метрики, маска действий), которую алгоритм обучения обычно не видит.
-
Базовый класс инициализирует генератор self.np_random переданным зерном. Без вызова среда работает, но серия экспериментов перестаёт быть воспроизводимой от одного числа: при повторном запуске с тем же seed получатся другие эпизоды.
-
Завершение — терминальное состояние самой задачи, будущего нет, цель обновления равна r (6.2). Усечение — внешний обрыв (лимит шагов): состояние не терминальное, бутстрап обязателен — цель r + γ·max Q(s′,·) (6.1). Код target = r + γ(1−done)V(s′) неверен: при усечении он зануляет существующее будущее и занижает ценности.
-
Если исчерпание горизонта — законное завершение задачи, то ценность состояния зависит от того, сколько шагов осталось. Два одинаковых наблюдения с разным остатком имеют разные ценности — марковское свойство нарушено, пока остаток времени не добавлен в наблюдение.
-
ObservationWrapper — метод observation(obs), преобразует наблюдения; ActionWrapper — action(a), переводит действия агента в действия среды; RewardWrapper — reward(r); общий Wrapper — step/reset целиком, для преобразований нескольких каналов сразу (например, пересборка награды из info).
-
Переобъявить self.observation_space в __init__ обёртки. Код инициализации сети берёт размер входа из пространства наблюдений, а оно всё ещё описывает старый словарь.
-
make() автоматически оборачивает среду в TimeLimit: после N шагов step() вернёт truncated = True. Сама среда усечения не объявляет — она возвращает truncated = False, лимит времени — внешняя обёртка. Так FrozenLake обрезается на 100 шагах (конспект 5).
-
Маска берётся из info["action_mask"]; случайный выбор — np.random.choice по допустимым, жадный — argmax Q по допустимым, максимум в цели — тоже по маске следующего состояния. Даёт более быструю и стабильную сходимость (агент не разведывает заведомо недопустимое) и защищает от опасных действий в реальных системах.
Источники
- Make your own custom environment — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/environment_creation.html (дата обращения: 08.07.2026).
- Handling Time Limits — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/handling_time_limits.html (дата обращения: 08.07.2026).
- Implementing Custom Wrappers — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/gymnasium_basics/implementing_custom_wrappers.html (дата обращения: 08.07.2026).
- Action Masking in the Taxi Environment — Gymnasium Documentation : [сайт]. — URL: https://gymnasium.farama.org/tutorials/training_agents/action_masking_taxi.html (дата обращения: 08.07.2026).