RecordEpisodeStatistics measures every episode and, when it ends, puts an EpisodeStats
dataclass in info["episode"] and hands it to a pluggable tracker. Typed fields replace
gymnasium’s info["episode"]["r"] / ["l"] / ["t"] dictionary.
| Field | Type | Meaning |
|---|---|---|
episode_return |
float |
sum of rewards (as seen by this wrapper layer) |
episode_length |
int |
number of steps |
elapsed_time |
float |
wall-clock seconds from reset() to the last step |
start_time, end_time |
float |
time.monotonic() timestamps |
terminated, truncated |
bool |
how the episode ended |
extra |
dict |
free-form; trackers and vector wrappers add to it |
done |
property | terminated or truncated |
import numpy as np
import sekai
env = sekai.RecordEpisodeStatistics(sekai.make("CartPole-v1"), deque_size=10)
env.action_space.seed(0)
result = env.reset(seed=0)
while not result.done:
result = env.step(env.action_space.sample())
stats = result.info["episode"] # also env.episode_stats, the latest episode
print(stats.episode_return, stats.episode_length, stats.terminated) # 18.0 18 True
print(env.episode_stats is stats) # True
!!! tip “Where to put it”
Statistics are recorded at the layer where the wrapper sits. Wrap inside reward
normalisation or scaling to log raw returns, and outside TimeLimit (as make()
gives you) so truncated episodes are counted.
The wrapper starts counting the next episode as soon as it reports one, so the statistics stay
correct even when an inner AutoReset starts the next episode without a reset() call
reaching this layer.
A tracker is any object with four methods (the sekai.StatsTracker protocol):
| Method | Called |
|---|---|
on_reset(env_id, seed) |
after every reset(), with env.spec.id and the seed in use |
on_step(reward, terminated, truncated, info) |
after every step() |
on_episode_end(stats) |
when an episode ends, before stats is put in info |
summary() |
whenever you call it; returns a dict |
The default. It keeps the last deque_size episodes (100 unless you pass another
deque_size to the wrapper) in tracker.episodes:
for _ in range(9):
result = env.reset()
while not result.done:
result = env.step(env.action_space.sample())
print(env.tracker.summary()) # {'num_episodes': 10, 'mean_return': 21.5, 'min_return': 11.0, 'max_return': 58.0, 'mean_length': 21.5}
print(env.tracker.mean_return(), env.tracker.mean_length()) # 21.5 21.5
Emits one INFO record per episode on the sekai.stats logger (or a logger you pass), with
the statistics as extra fields, ready for structured-logging handlers:
import logging
import sys
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO,
format="%(name)s %(message)s return=%(episode_return).1f length=%(episode_length)d",
)
env = sekai.RecordEpisodeStatistics(sekai.make("CartPole-v1"), tracker=sekai.LoggingTracker())
env.action_space.seed(0)
result = env.reset(seed=0)
while not result.done:
result = env.step(env.action_space.sample())
sekai.stats episode_end return=18.0 length=18
Trackers see every step’s info, so they can compute statistics that need the whole path.
This one records each episode’s maximum drawdown of a trading account and attaches it to
stats.extra:
class DrawdownTracker:
"""Maximum drawdown of info["equity"] per episode."""
def __init__(self):
self.drawdowns = []
def on_reset(self, env_id, seed):
self.peak, self.worst = 1.0, 0.0
def on_step(self, reward, terminated, truncated, info):
self.peak = max(self.peak, info["equity"])
self.worst = max(self.worst, 1 - info["equity"] / self.peak)
def on_episode_end(self, stats):
stats.extra["max_drawdown"] = self.worst
self.drawdowns.append(self.worst)
def summary(self):
return {"mean_max_drawdown": float(np.mean(self.drawdowns))}
tracker = DrawdownTracker()
print(isinstance(tracker, sekai.StatsTracker)) # True
env = sekai.RecordEpisodeStatistics(sekai.make("Trading-v0"), tracker=tracker)
for seed in range(20):
result = env.reset(seed=seed)
while not result.done:
result = env.step(2) # buy and hold
print(round(result.info["episode"].extra["max_drawdown"], 3)) # 0.107
print(round(tracker.summary()["mean_max_drawdown"], 3)) # 0.202
The same pattern feeds TensorBoard, Weights & Biases or MLflow: log stats fields in
on_episode_end.
VecRecordEpisodeStatistics does the same for a vector env:
when sub-env i finishes, infos[i]["episode"] is an EpisodeStats whose
extra["env_index"] is i, and the stats go to the tracker’s on_episode_end().
vec = sekai.VecRecordEpisodeStatistics(sekai.make_vec("CartPole-v1", num_envs=4))
actions = vec.batch_action_space
actions.seed(0)
vec.reset(seed=0)
for _ in range(200):
result = vec.step(actions.sample())
print(vec.tracker.summary()["num_episodes"]) # 33
!!! note
The vector wrapper reports only finished episodes: it calls on_episode_end() but not
on_reset() / on_step(). Trackers that need per-step data should wrap each sub-env in
RecordEpisodeStatistics instead (e.g. through make_vec(..., wrappers=[...])).