Eval Metrics#
ToyBlocks records evaluation metrics at three granularities: per-step, per-episode, and across-episode. All metrics flow through a common pipeline and are saved automatically at the end of every eval run.
Architecture#
MetricsTracker per-step tensors RecordMetrics toyblocks.eval.run_eval
(utils/metrics.py) ──────────────────► (wrappers/ (run by scripts/evaluate.py)
dict[str, record_metrics.py) │
Tensor[num_envs]] │ ├─► eval_results.json (per episode + aggregates)
├─► CSV per step └─► eval_episodes.json (one record per episode)
└─► episode summaries
MetricsTracker#
src/toyblocks/utils/metrics.py
Core per-step metric computation. Instantiated once in BaseEnv.__init__ when any track_* config flag is enabled. Each call to step() reads the current simulation state (joint data, EE frame, contact sensor) and returns a flat dict[str, Tensor[num_envs]]. All values are scalar-per-environment tensors.
BaseEnv merges these tensors into info["stats"] via its evaluate() method, making them available to downstream wrappers.
RecordMetrics#
src/toyblocks/wrappers/record_metrics.py
A gym.Wrapper that sits between the environment and RecordEpisode. It does three things:
Episode summaries – accumulates per-step stats and, when an episode ends, injects
{key}/mean,{key}/min,{key}/max, and{key}/finalintoinfo["final_info"]["episode"].Goal-progress summaries – the boolean goal stats (
goal_conj_<c>,goal_success,goal_blocks_settled,goal_gripper_open; see “Goal-progress metrics” below) also get{key}/initialand{key}/first_true_step. They are also traced step by step and attached to the finished episode as change points (info["final_info"]["goal_trace"],[[step, value], ...], starting with[1, initial]). Both are timed with the env’s episode clock (episode_length_buf), which eval stops during hold steps (PD-switch settles, chunk-boundary waits, wave parking), so they line up withfirst_success_stepandepisode_len.Per-step CSVs – when an
output_diris provided (evaluate.pypasses one only with--save_step_metrics), buffers every step’s stats and writes one CSV file per completed episode to<output_dir>/step_metrics/, namedenv{idx}_episode_NNNN.csvwhere{idx}is the sub-environment index andNNNNis a per-env episode counter. In queue-driven evals, the CSVs of “phantom” episodes (an env auto-reset after the queue drained) are dropped, as their videos and trajectories are.
The eval loop#
scripts/evaluate.py parses the arguments (src/toyblocks/eval/args.py) and runs the eval loop in toyblocks.eval.run_eval. When an episode finishes, the loop adds its own episode metrics (collisions, oracle and high-level counters, replay divergence, …) to the summaries from RecordMetrics. At the end of the run it writes:
eval_results.json(_save_resultsinutils/dataset_recording_utils.py) – the raw value of each episode metric as a JSON list, plus mean, std, min, max, and count for every metric across episodes.eval_episodes.json(the episode table inutils/wandb_logging.py) – one record per episode with its prompt, source-episode provenance, metrics, goal progress and video paths (fields listed under “eval_episodes.json” in Output format). The same records make up the W&B episodes table.
A single evaluate.py --wandb run logs its own W&B run. Pipeline runs instead merge every worker’s eval_episodes.json and eval_results.json into one W&B run through scripts/wandb_consolidate.py, which logs an episodes table with the expert videos and a goal-progress plot per episode, an aggregates table and the headline metrics, and the offline diagnostic plots of one representative episode per worker.
Available metrics#
Episode metrics#
Always recorded when record_metrics=True (the default in BaseEnvCfg). These appear in info["final_info"]["episode"].
Key |
Description |
|---|---|
|
Cumulative reward over the episode |
|
Number of steps in the episode |
|
Mean reward per step ( |
|
Whether the task succeeded at any point during the episode |
|
Whether the task was in a success state at the final step |
|
Episode step at which the task first succeeded (NaN if it never did) |
|
Whether a failure state was reached at any point |
|
Whether the task was in a failure state at the final step |
Eval-loop metrics#
Added per episode by toyblocks.eval.run_eval, only in the runs where they apply:
Key |
When |
Description |
|---|---|---|
|
|
Whether, and how many times, a non-target block moved more than 2 cm or 15 deg. A collision fails the episode: |
|
|
The two successes before the collision penalty. |
|
Oracle and Molmo2 runs |
Per sub-goal, in order: its name and whether it succeeded ( |
|
Learned-HL runs |
Whether the HL model emitted its |
|
Learned-HL runs |
Number of HL model queries in the episode |
|
Oracle-HL runs |
Whether oracle plan completion ended the episode |
|
Oracle-HL runs |
Whether 10 consecutive successful steps ended the episode instead |
|
Oracle-HL runs, or with a comparison oracle |
Whether the oracle plan got stuck. It ends the episode for the oracle HL; a comparison oracle only flags it. |
|
|
Skills the low-level oracle could not execute (no feasible parameters, a failed motion plan, an effect that did not hold); Molmo2 is asked again each time |
|
Play-mode LL checkpoints |
Number of times the LL predicted play mode -1 (stop), and number of steps it predicted 0 (pause) |
|
Autogen PDDL tasks |
|
|
|
Divergence from the recording; see Dataset Replay |
|
Render replay, |
Whether the episode’s recorded render parameters and block letters/colours were applied. In |
On learned-HL runs (--policy molmo2_maniflow) the HL model owns termination: its done token ends the episode, and an episode without a done runs out episode_length_s instead of stopping on goal-success. So success_once && !hl_done identifies episodes where the goal was reached but the HL model failed to call it. Under the default --episode_end goal, oracle-HL runs end on oracle plan completion or after success holds for 10 consecutive steps, and plain LL, random and zero-action paths end on instant goal-success. --episode_end horizon runs every episode to episode_length_s. --episode_end ll_done (play-mode LL checkpoints, no learned HL) ends an episode when the LL predicts play mode -1, or at episode_length_s; success_at_end then says whether the goal still holds and ll_skill_stops (1 vs 0) whether the LL stopped or timed out. Replay always uses horizon. Instead of the single episode_length_s timeout, --expert_timeout_scale (--expert-timeout-scale on the launcher) ends each queue episode after that multiple of its own expert demonstration’s duration.
Goal-progress metrics#
Autogen PDDL tasks (generated by tamp/blocks/template/codegen.py) report goal progress as per-step boolean stats:
Key |
Description |
|---|---|
|
Whether conjunct |
|
Whether the whole goal holds, including the settle gate below |
|
The settle gate’s terms: the goal blocks have stopped moving and the gripper is open |
Each gets the usual /mean, /min, /max, /final summaries plus /initial and /first_true_step (see RecordMetrics above). In eval_episodes.json, each record also gets:
goal_progress: one entry per goal conjunct, with itspredicatetext, itsinitial,first_true_step,final,maxandmeanvalues, and its change-pointtrace.num_goal_conjuncts,num_goal_conjuncts_initial,num_goal_conjuncts_final,num_goal_conjuncts_ever: the number of conjuncts in total, true at the start, true at the end, and true at some point.goal_success_trace(andgoal_blocks_settled_trace/goal_gripper_open_tracewhen traced).
Joint metrics#
Enabled by --track_joint_metrics or --track_metrics (both on by default in evaluate.py).
Key |
Description |
|---|---|
|
L2 norm of the joint position vector |
|
L2 norm of the joint velocity vector |
|
L2 norm of the joint acceleration vector (finite-differenced from velocity) |
|
L2 norm of applied torques (when available) |
|
Position of individual joint (one column per joint, e.g. |
|
Velocity of individual joint |
|
Acceleration of individual joint |
|
Applied torque on individual joint |
End-effector metrics#
Enabled by --track_ee_metrics or --track_metrics (both on by default in evaluate.py). Requires a FrameTransformer sensor for the EE (configured automatically for supported robots).
Key |
Description |
|---|---|
|
World-frame EE position components |
|
World-frame EE orientation as a quaternion (w, x, y, z) |
|
L2 norm of EE linear velocity (finite-differenced from position) |
|
L2 norm of EE linear acceleration |
Collision metrics#
Enabled by the env-config option --env-cfg.track-collisions (it is not an evaluate.py flag, and launch_eval.py does not expose it). Adds an Isaac Lab ContactSensor to every robot body, which increases VRAM usage. These contact forces are separate from the block-displacement collision metric above.
Key |
Description |
|---|---|
|
Sum of per-body contact force norms across all robot bodies |
|
Number of robot bodies with contact force above the sensor threshold |
|
Cumulative count of body-in-contact events over the episode |
|
Cumulative count of self/filter-pair contacts (requires filter paths) |
|
Per-body 3D net contact force in world frame (e.g. |
|
Per (body, filter-object) pair 3D force. Only available when |
Task-specific metrics#
Individual tasks add their own metrics by overriding evaluate(). These appear under stats/ in eval_results.json. For example, the Lift task adds:
Key |
Description |
|---|---|
|
L2 distance from the object to the goal position |
|
L2 distance from the object to the end-effector |
The align, flip, isolate, push-stacked and rotate tasks add object_to_tcp_dist too.
Output format#
Every run writes both JSON files; step_metrics/ is written only with --save_step_metrics:
runs/<run_name>/
├── eval_results.json
├── eval_episodes.json
└── step_metrics/
├── env0_episode_0000.csv
├── env0_episode_0001.csv
├── env1_episode_0000.csv
└── ...
eval_results.json#
{
"toyblocks_commit": "...",
"toyblocks_branch": "...",
"toyblocks_dirty": false,
"submodule_commits": { ... },
"args": { ... },
"num_episodes": 10,
"elapsed_seconds": 42.3,
"per_episode": {
"return": [1.2, 0.8, ...],
"success_once": [1.0, 0.0, ...],
"stats/ee_pos_x/mean": [0.61, 0.59, ...],
...
},
"per_episode_goal_conjuncts": [["(on block_x block_w)", ...], ...],
"metrics": {
"return": { "mean": 0.95, "std": 0.21, "min": 0.3, "max": 1.5, "count": 10 },
"stats/joint_vel_norm/mean": { "mean": 0.12, "std": 0.03, ... },
...
}
}
toyblocks_commit,toyblocks_branch,toyblocks_dirtyandsubmodule_commitsrecord the checkout the run used (the dirty check ignores submodules).per_episode– one list per metric key, where element i is the value for episode i.per_episode_goal_conjuncts– for autogen PDDL tasks, the PDDL text of eachgoal_conj_<c>of episode i (nullfor episodes without goal labels).metrics– across-episode aggregates (mean, std, min, max, count) for every metric key. The aggregation is NaN-safe, so NaN values (e.g.first_success_stepof a failed episode) are left out.success_efficiency– Molmo2 HL runs only: the mean, over successful episodes, of the HL commands the eval took divided by the commands the source demonstration took.
Episode-level stat keys are prefixed with stats/ and suffixed with the aggregation (/mean, /min, /max, /final). For example, stats/ee_pos_x/mean is the mean of ee_pos_x over all steps in a single episode.
eval_episodes.json#
A JSON list with one record per scored episode, built by EpisodeResultsTable in utils/wandb_logging.py. It is written at the end of every run and rewritten once the LeRobot recorder has closed, when the output episode indices are known. Fields that do not apply to a run are null or absent.
[
{
"sim_env_idx": 0,
"episode_idx_for_sim_env": 0,
"prompt": "put the red block on the blue block",
"pddl_goal": "(and (on block_x block_w))",
"source_dataset_path": "data/datasets/my_dataset",
"source_dataset_episode_idx": 12,
"expert_episode_length": 310,
"init_frame_index": 0,
"success_once": 1.0,
"episode_len": 287,
"collision": 0.0,
"goal_progress": [{"index": 0, "predicate": "(on block_x block_w)", "initial": 0.0, "first_true_step": 241, ...}],
"num_goal_conjuncts": 1,
"partial_progress": 1.0,
"step_dt_s": 0.04,
"video_path": ".../sensor_obs_videos/env0_episode_0000.mp4",
"output_lerobot_dataset_dir": ".../my_eval_success",
"output_lerobot_episode_index": 0,
...
},
...
]
Group |
Fields |
Description |
|---|---|---|
Identity |
|
The sub-env index and its per-env episode counter: the same pair that names the videos and step-metric CSVs. |
|
With |
|
Task |
|
The language prompt the policy received and the episode’s PDDL goal. |
Source episode |
|
Queue evals: the dataset episode the eval was initialized from. |
|
Length of that expert demonstration, in frames and seconds. |
|
|
The source frame the eval started from: 0 unless the queue was told to start after a given high-level action. |
|
|
Per-camera clip metadata of the expert demonstration, and the path and start time of the front-preferred camera’s clip. |
|
Metrics |
the episode and eval-loop metrics |
Every key of the result table (success, length, collision, sub-goal, oracle, HL and play-mode counters, status fields; see “Episode metrics” and “Eval-loop metrics” in Available metrics), plus every |
Goal progress |
|
Autogen PDDL tasks: one entry per goal conjunct ( |
|
Change points |
|
|
The goal’s blocks, in the order of the per-block settle-gate stats, and the control period (to convert steps to seconds). |
|
Videos |
|
The sensor-observation MP4 ( |
|
The extracted expert video ( |
|
|
The HL overlay and HL latency videos, when written. |
|
Other |
|
W&B runs only: |
|
With |
The per-camera expert first frames shown in the W&B table are left out of the JSON.
Per-step CSVs#
Each CSV has one row per simulation step and one column per metric. The first column is step (0-indexed). All values are raw floats from that step.
step,joint_pos_norm,joint_vel_norm,...,ee_pos_x,ee_pos_y,...,contact_force_norm,...
0,3.118,1.039,...,1.612,-1.001,...,0.0,...
1,3.143,1.310,...,1.607,-1.005,...,0.0,...
When running with num_envs > 1, each sub-environment gets its own set of CSVs named env{idx}_episode_NNNN.csv, where {idx} is the env index and NNNN is a per-env episode counter that increments each time that env finishes an episode. If the evaluation ends mid-episode, partial files are saved with a _partial suffix.
CLI flags#
Flag |
Default |
Description |
|---|---|---|
|
on |
Enable both joint and EE metrics (equivalent to |
|
on |
Track per-step joint position, velocity, acceleration, and effort |
|
on |
Track per-step end-effector position, orientation, velocity, and acceleration |
|
off |
Write the per-step CSVs. Off by default: they cost a host copy of every stat on every step, and the episode summaries do not depend on them |
|
|
Block-displacement collision scoring: |
The contact-force metrics are configured through env-config fields (BaseEnvCfg in src/toyblocks/tasks/base_env_cfg.py), set with --env-cfg.<field>:
Env-config option |
Default |
Description |
|---|---|---|
|
off |
Track per-step collision forces and counts. Adds a |
|
none |
Prim path expressions for collision filter targets (e.g. |
|
none |
Robot body names to create per-body filter sensors for (e.g. |
--track_metrics is OR-ed with the two finer flags, so to turn joint and EE tracking off pass all three negations: --no-track_metrics --no-track_joint_metrics --no-track_ee_metrics.
Example with zero actions (no policy server needed, useful for sanity-checking metrics collection):
uv run python scripts/evaluate.py \
--task ToyBlocks-Lift-v0 \
--env-cfg.track-collisions \
--env-cfg.collision-filter-prim-paths '{ENV_REGEX_NS}/Block' \
--env-cfg.collision-filter-body-names panda_link7 panda_link8 \
--save_step_metrics \
--num_envs 4 --num_eval_steps 200
The same options apply to a run against a policy server; see the
evaluate.py Reference.
How to extend#
Add a task-specific metric#
Override evaluate() in your task environment. Call super().evaluate() first so that MetricsTracker stats are included, then add your own entries to the stats sub-dict.
def evaluate(self) -> dict:
result = super().evaluate()
stats = result.setdefault("stats", {})
stats["my_distance"] = torch.linalg.norm(
self.object.data.root_pos_w - self.target_pos, dim=-1
)
return result
The value must be a [num_envs] tensor. It will automatically appear in the per-step CSVs and episode summaries.
Add a new MetricsTracker category#
Add a boolean flag to
BaseEnvCfg(insrc/toyblocks/tasks/base_env_cfg.py):
track_my_sensor: bool = False
Add a corresponding
--track_my_sensorfield to theArgsdataclass insrc/toyblocks/eval/args.pyand wire it into the env config in_make_env(src/toyblocks/utils/dataset_recording_utils.py), next totrack_joint_metrics.In
MetricsTracker.__init__, initialize any buffers gated on the new flag.Add a
_compute_my_sensor_metrics()method that returnsdict[str, Tensor[num_envs]].Call it from
MetricsTracker.step():
if self._track_my_sensor:
stats.update(self._compute_my_sensor_metrics())
If the sensor needs setup during scene creation, add the initialization logic to
BaseEnv._setup_scene()(similar to howContactSensoris added whentrack_collisionsisTrue).