Evaluation#
The Evaluator class provides a unified interface for running evaluation
rollouts during RL training, either synchronously (blocking) or
asynchronously (in a background thread or Ray actor).
Why use an Evaluator?#
In most RL training loops, evaluation is done inline and blocks the training
loop while rollouts are collected. For environments with expensive step
functions (robotics simulators, LLM generation, etc.) this can waste
significant training time. The Evaluator decouples evaluation from
training by running rollouts in the background and letting you poll for metrics
or react to results via a callback.
Quick example#
from torchrl.collectors import Evaluator
from torchrl.envs import GymEnv
from tensordict.nn import TensorDictModule
import torch.nn as nn
def make_eval_env():
return GymEnv("HalfCheetah-v4")
eval_policy = TensorDictModule(
nn.Linear(17, 6), in_keys=["observation"], out_keys=["action"],
)
evaluator = Evaluator(
make_eval_env,
eval_policy,
max_steps=1000,
on_result=lambda result: my_logger.log_metrics(
{k: v.item() for k, v in result.items() if k != "eval/step"},
step=result["eval/step"].item(),
),
)
# --- Inside training loop ---
for data in collector:
train(data)
if should_eval:
# Non-blocking: kick off eval and move on
evaluator.trigger_eval(weights=train_policy, step=collected_frames)
# Optionally check for results
result = evaluator.poll()
if result is not None:
print(result) # {"eval/reward": ..., "eval/episode_length": ..., "eval/fps": ...}
evaluator.shutdown()
Synchronous usage#
If you prefer blocking evaluation (e.g. for final evaluation or simple
scripts), use evaluate():
metrics = evaluator.evaluate(weights=train_policy, step=step)
# metrics == {"eval/reward": -123.4, "eval/episode_length": 1000, "eval/fps": ...}
Asynchronous usage#
For non-blocking evaluation during training:
# Start eval in the background
evaluator.trigger_eval(weights=train_policy, step=step)
# ... continue training ...
# Check if results are ready (non-blocking)
result = evaluator.poll() # returns None if still running
# Or block until done
result = evaluator.wait(timeout=60)
By default, trigger_eval() raises if a previous evaluation is
still pending. Set busy_policy="queue" to enqueue later requests instead.
Device placement and compilation#
For best performance, place the eval policy on a dedicated device and
optionally torch.compile both the env and policy independently of the
training pipeline:
import torch
eval_device = torch.device("cuda:1") # training on cuda:0
eval_policy = make_policy().to(eval_device)
eval_env = make_env(device=eval_device)
# Optional: compile for extra speed
eval_policy = torch.compile(eval_policy)
evaluator = Evaluator(
eval_env,
eval_policy,
max_steps=1000,
device=eval_device,
)
The device parameter controls where policy weights are moved before
each rollout. When passing weights from the training policy (which may
live on a different device), the Evaluator automatically moves them to
the eval device.
Overlap policy (backpressure)#
Calling trigger_eval() while a previous evaluation is
still pending raises immediately by default (busy_policy="error").
This keeps training loops from silently piling up stale evaluation requests.
If you prefer to enqueue evaluations, pass busy_policy="queue".
Queued requests are processed in order as earlier evaluations finish.
Result callbacks#
Pass on_result to react to completed evaluations without manual
poll() bookkeeping:
def on_eval(result):
metrics = {k: v.item() for k, v in result.items() if k != "eval/step"}
if metrics["eval/reward"] > best_reward:
save_checkpoint(step=result["eval/step"].item())
evaluator = Evaluator(env, policy, max_steps=1000, on_result=on_eval)
For asynchronous evaluations, on_result runs on the evaluator’s
background coordination thread. If your callback talks to a logger that
is also used by the training loop, handle any required locking inside the
callback.
Backends#
The Evaluator supports two backends selected via the backend
parameter:
Thread backend (default, backend="thread"):
Runs
env.rollout()in a daemon thread within the same process.No extra dependencies required.
Best for most single-node training setups.
Ray backend (backend="ray"):
Wraps
RayEvalWorkerunder the same API.Runs evaluation in a separate Ray actor process with its own GPU.
Required when the eval environment needs process-level initialisation (e.g. Isaac Lab’s
AppLauncher).
evaluator = Evaluator(
make_eval_env,
policy_factory=make_eval_policy,
max_steps=1000,
backend="ray",
init_fn=my_process_init,
num_gpus=1,
)
Custom metrics and callbacks#
Pass a metrics_fn to extract custom metrics from rollout data:
def my_metrics(rollout_td):
return {
"success_rate": (rollout_td["next", "success"].any(-1).float().mean().item()),
}
evaluator = Evaluator(env, policy, max_steps=1000, metrics_fn=my_metrics)
Or use on_result to consume the prefixed evaluation metrics as a flat
tensordict:
def on_eval(result):
if result["eval/reward"].item() > best_reward:
save_checkpoint(result["eval/step"].item())
evaluator = Evaluator(env, policy, max_steps=1000, on_result=on_eval)
Comparing policies#
To compare policies, environments (for instance the same task in two
simulators) or perturbations, call evaluate_episodes() on
each combination with a fixed set of seeds, then reduce the per-episode returns
with bootstrap_estimate() and paired_comparison().
evaluate_episodes() makes every rollout contribute exactly one
episode per sub-environment. evaluate() defaults to
episode_sampling="first_done", which keeps the first episodes to finish:
short episodes are over-represented whenever more episodes than
sub-environments are requested, even when all sub-environments are identical,
and different tasks per sub-environment make the bias larger. Passing the same
seed reseeds the environment and the torch RNG before every rollout, so each
episode starts from the same state whatever the policy and the results are
paired episode by episode.
import torch
from torchrl.collectors import Evaluator, bootstrap_estimate, paired_comparison
policies = {"ppo": ppo_policy, "sac": sac_policy}
envs = {"gymnasium": make_gym_env, "mujoco": make_mujoco_env}
seeds = range(5)
returns = {}
for env_name, make_env in envs.items():
for policy_name, policy in policies.items():
evaluator = Evaluator(make_env(), policy, num_trajectories=10, max_steps=1000)
returns[env_name, policy_name] = torch.cat(
[
evaluator.evaluate_episodes(seed=seed)["episode_reward"]
for seed in seeds
]
)
evaluator.shutdown()
for env_name in envs:
summary = bootstrap_estimate(returns[env_name, "ppo"], statistic="iqm")
versus = paired_comparison(returns[env_name, "ppo"], returns[env_name, "sac"])
logger.log_metrics(
{
f"{env_name}/ppo/iqm": summary["estimate"],
f"{env_name}/ppo_minus_sac": versus["estimate"],
f"{env_name}/ppo_beats_sac": versus["probability_of_improvement"],
}
)
The episodes also carry "truncated" (the episode hit max_steps or the
environment’s own time limit) and "env_index" (the sub-environment that
ran it), to filter or group them before reducing.
Perturbations follow the same pattern: make them part of the environment
factory (for instance lambda: make_env().append_transform(FrameSkipTransform(2))).
API Reference#
|
Unified sync / async evaluator with pluggable backend. |
|
Reduce |
|
Compare two sets of paired samples, e.g. two policies evaluated on the same seeds. |