mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-28 12:15:59 +00:00
1110 lines
45 KiB
Python
1110 lines
45 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Evaluate a policy on an environment by running rollouts and computing metrics.
|
|
|
|
Requires: pip install 'lerobot[evaluation]' plus the policy extra (e.g. lerobot[pi])
|
|
and the environment extra (e.g. lerobot[pusht]) if evaluating in simulation.
|
|
|
|
Usage examples:
|
|
|
|
You want to evaluate a model from the hub (eg: https://huggingface.co/lerobot/diffusion_pusht)
|
|
for 10 episodes.
|
|
|
|
```
|
|
lerobot-eval \
|
|
--policy.path=lerobot/diffusion_pusht \
|
|
--env.type=pusht \
|
|
--eval.batch_size=10 \
|
|
--eval.n_episodes=10 \
|
|
--policy.use_amp=false \
|
|
--policy.device=cuda
|
|
```
|
|
|
|
OR, you want to evaluate a model checkpoint from the LeRobot training script for 10 episodes.
|
|
```
|
|
lerobot-eval \
|
|
--policy.path=outputs/train/diffusion_pusht/checkpoints/005000/pretrained_model \
|
|
--env.type=pusht \
|
|
--eval.batch_size=10 \
|
|
--eval.n_episodes=10 \
|
|
--policy.use_amp=false \
|
|
--policy.device=cuda
|
|
```
|
|
|
|
Note that in both examples, the repo/folder should contain at least `config.json` and `model.safetensors` files.
|
|
|
|
You can learn about the CLI options for this script in the `EvalPipelineConfig` in lerobot/configs/eval.py
|
|
"""
|
|
|
|
import concurrent.futures as cf
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from collections.abc import Callable
|
|
from contextlib import nullcontext
|
|
from copy import deepcopy
|
|
from dataclasses import asdict
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from pprint import pformat
|
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
|
|
import einops
|
|
import gymnasium as gym
|
|
import numpy as np
|
|
import torch
|
|
from termcolor import colored
|
|
from torch import Tensor, nn
|
|
from tqdm import trange
|
|
|
|
from lerobot.configs import FeatureType, parser
|
|
from lerobot.configs.eval import EvalPipelineConfig
|
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
|
from lerobot.envs import (
|
|
check_env_attributes_and_types,
|
|
close_envs,
|
|
make_env,
|
|
make_env_pre_post_processors,
|
|
preprocess_observation,
|
|
)
|
|
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
|
|
from lerobot.processor import PolicyProcessorPipeline
|
|
from lerobot.types import PolicyAction
|
|
from lerobot.utils.constants import ACTION, DONE, OBS_IMAGE, OBS_IMAGES, OBS_STR, REWARD
|
|
from lerobot.utils.device_utils import get_safe_torch_device
|
|
from lerobot.utils.import_utils import _peft_available, register_third_party_plugins, require_package
|
|
from lerobot.utils.io_utils import write_video
|
|
from lerobot.utils.random_utils import set_seed
|
|
from lerobot.utils.utils import (
|
|
init_logging,
|
|
inside_slurm,
|
|
)
|
|
|
|
if TYPE_CHECKING or _peft_available:
|
|
from peft import PeftModel
|
|
else:
|
|
PeftModel = None
|
|
|
|
|
|
def _env_features_to_dataset_features(env_features: dict) -> dict:
|
|
"""Convert EnvConfig.features to the dict format expected by LeRobotDataset.create()."""
|
|
features = {}
|
|
for key, ft in env_features.items():
|
|
shape = tuple(ft.shape)
|
|
if ft.type is FeatureType.VISUAL:
|
|
features[key] = {"dtype": "video", "shape": shape, "names": ["height", "width", "channel"]}
|
|
else:
|
|
features[key] = {"dtype": "float32", "shape": shape, "names": None}
|
|
features["next.reward"] = {"dtype": "float32", "shape": (1,), "names": None}
|
|
features["next.success"] = {"dtype": "bool", "shape": (1,), "names": None}
|
|
features["next.done"] = {"dtype": "bool", "shape": (1,), "names": None}
|
|
return features
|
|
|
|
|
|
def _build_raw_frame(
|
|
raw_obs: dict,
|
|
env_idx: int,
|
|
action: np.ndarray,
|
|
reward: float,
|
|
success: bool,
|
|
done: bool,
|
|
task: str,
|
|
env_features: dict,
|
|
) -> dict:
|
|
"""Build a dataset frame from raw env observations for one env index.
|
|
|
|
Keys in the frame match the keys in env_features so they align with the
|
|
dataset schema created by _env_features_to_dataset_features().
|
|
"""
|
|
frame: dict[str, Any] = {}
|
|
for key in env_features:
|
|
if key == ACTION:
|
|
continue
|
|
if key.startswith("next."):
|
|
continue
|
|
if "pixels" in raw_obs and isinstance(raw_obs["pixels"], dict):
|
|
for cam_name, img in raw_obs["pixels"].items():
|
|
candidate = f"{OBS_IMAGES}.{cam_name}"
|
|
if candidate == key:
|
|
frame[key] = img[env_idx]
|
|
if key in frame:
|
|
continue
|
|
if "pixels" in raw_obs and not isinstance(raw_obs["pixels"], dict) and key in ("pixels", OBS_IMAGE):
|
|
frame[key] = raw_obs["pixels"][env_idx]
|
|
continue
|
|
if key in raw_obs and isinstance(raw_obs[key], np.ndarray):
|
|
val = raw_obs[key][env_idx]
|
|
if val.dtype == np.float64:
|
|
val = val.astype(np.float32)
|
|
frame[key] = val
|
|
frame[ACTION] = action
|
|
frame["next.reward"] = np.atleast_1d(np.float32(reward))
|
|
frame["next.success"] = np.atleast_1d(np.bool_(success))
|
|
frame["next.done"] = np.atleast_1d(np.bool_(done))
|
|
frame["task"] = task
|
|
return frame
|
|
|
|
|
|
def rollout(
|
|
env: gym.vector.VectorEnv,
|
|
policy: PreTrainedPolicy,
|
|
env_preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
env_postprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction],
|
|
seeds: list[int] | None = None,
|
|
return_observations: bool = False,
|
|
render_callback: Callable[[gym.vector.VectorEnv], None] | None = None,
|
|
recording_dir: Path | None = None,
|
|
env_features: dict | None = None,
|
|
recording_repo_id: str | None = None,
|
|
recording_private: bool = False,
|
|
predicted_latents_callback: Callable[[PreTrainedPolicy], None] | None = None,
|
|
) -> dict:
|
|
"""Run a batched policy rollout once through a batch of environments.
|
|
|
|
Note that all environments in the batch are run until the last environment is done. This means some
|
|
data will probably need to be discarded (for environments that aren't the first one to be done).
|
|
|
|
The return dictionary contains:
|
|
(optional) "observation": A dictionary of (batch, sequence + 1, *) tensors mapped to observation
|
|
keys. NOTE that this has an extra sequence element relative to the other keys in the
|
|
dictionary. This is because an extra observation is included for after the environment is
|
|
terminated or truncated.
|
|
"action": A (batch, sequence, action_dim) tensor of actions applied based on the observations (not
|
|
including the last observations).
|
|
"reward": A (batch, sequence) tensor of rewards received for applying the actions.
|
|
"success": A (batch, sequence) tensor of success conditions (the only time this can be True is upon
|
|
environment termination/truncation).
|
|
"done": A (batch, sequence) tensor of **cumulative** done conditions. For any given batch element,
|
|
the first True is followed by True's all the way till the end. This can be used for masking
|
|
extraneous elements from the sequences above.
|
|
|
|
Args:
|
|
env: The batch of environments.
|
|
policy: The policy. Must be a PyTorch nn module.
|
|
seeds: The environments are seeded once at the start of the rollout. If provided, this argument
|
|
specifies the seeds for each of the environments.
|
|
return_observations: Whether to include all observations in the returned rollout data. Observations
|
|
are returned optionally because they typically take more memory to cache. Defaults to False.
|
|
render_callback: Optional rendering callback to be used after the environments are reset, and after
|
|
every step.
|
|
predicted_latents_callback: Optional callback invoked after every ``select_action`` with the policy
|
|
itself. World-model policies (e.g. LingBot-VA) stash predicted video latents on
|
|
``policy.last_predicted_latents``; this lets the caller concatenate chunks and decode once.
|
|
Returns:
|
|
The dictionary described above.
|
|
"""
|
|
assert isinstance(policy, nn.Module), "Policy must be a PyTorch nn module."
|
|
|
|
# Reset the policy and environments.
|
|
policy.reset()
|
|
observation, info = env.reset(seed=seeds)
|
|
if render_callback is not None:
|
|
render_callback(env)
|
|
|
|
recording_datasets: list[LeRobotDataset] | None = None
|
|
raw_observation = None
|
|
task_desc = ""
|
|
if recording_dir is not None and env_features is not None:
|
|
features = _env_features_to_dataset_features(env_features)
|
|
fps = env.unwrapped.metadata.get("render_fps", 30)
|
|
recording_datasets = []
|
|
multi_env = env.num_envs > 1
|
|
base_repo_id = recording_repo_id or "eval_recording"
|
|
for i in range(env.num_envs):
|
|
root = str(recording_dir / f"env_{i}") if multi_env else str(recording_dir)
|
|
repo_id = f"{base_repo_id}_env_{i}" if multi_env else base_repo_id
|
|
recording_datasets.append(
|
|
LeRobotDataset.create(
|
|
repo_id=repo_id,
|
|
fps=fps,
|
|
features=features,
|
|
root=root,
|
|
use_videos=True,
|
|
)
|
|
)
|
|
raw_observation = deepcopy(observation)
|
|
try:
|
|
task_desc = list(env.call("task_description"))[0]
|
|
except (AttributeError, NotImplementedError):
|
|
task_desc = ""
|
|
|
|
all_observations = []
|
|
all_actions = []
|
|
all_rewards = []
|
|
all_successes = []
|
|
all_dones = []
|
|
|
|
step = 0
|
|
# Keep track of which environments are done.
|
|
done = np.array([False] * env.num_envs)
|
|
max_steps = env.call("_max_episode_steps")[0]
|
|
progbar = trange(
|
|
max_steps,
|
|
desc=f"Running rollout with at most {max_steps} steps",
|
|
disable=inside_slurm(), # we dont want progress bar when we use slurm, since it clutters the logs
|
|
leave=False,
|
|
)
|
|
check_env_attributes_and_types(env)
|
|
try:
|
|
while not np.all(done) and step < max_steps:
|
|
# Numpy array to tensor and changing dictionary keys to LeRobot policy format.
|
|
observation = preprocess_observation(observation)
|
|
if return_observations:
|
|
all_observations.append(deepcopy(observation))
|
|
|
|
# Infer "task" from sub-environments (prefer natural language description).
|
|
# env.call() works with both SyncVectorEnv and AsyncVectorEnv.
|
|
try:
|
|
observation["task"] = list(env.call("task_description"))
|
|
except (AttributeError, NotImplementedError):
|
|
try:
|
|
observation["task"] = list(env.call("task"))
|
|
except (AttributeError, NotImplementedError):
|
|
observation["task"] = [""] * env.num_envs
|
|
|
|
# Apply environment-specific preprocessing (e.g., LiberoProcessorStep for LIBERO)
|
|
observation = env_preprocessor(observation)
|
|
|
|
observation = preprocessor(observation)
|
|
with torch.inference_mode():
|
|
action = policy.select_action(observation)
|
|
if predicted_latents_callback is not None:
|
|
predicted_latents_callback(policy)
|
|
action = postprocessor(action)
|
|
|
|
action_transition = {ACTION: action}
|
|
action_transition = env_postprocessor(action_transition)
|
|
action = action_transition[ACTION]
|
|
|
|
# Convert to CPU / numpy.
|
|
action_numpy: np.ndarray = action.to("cpu").numpy()
|
|
assert action_numpy.ndim == 2, "Action dimensions should be (batch, action_dim)"
|
|
|
|
# Apply the next action.
|
|
observation, reward, terminated, truncated, info = env.step(action_numpy)
|
|
if render_callback is not None:
|
|
render_callback(env)
|
|
|
|
# VectorEnv stores is_success in `info["final_info"][env_index]["is_success"]`. "final_info" isn't
|
|
# available if none of the envs finished.
|
|
if "final_info" in info:
|
|
final_info = info["final_info"]
|
|
if isinstance(final_info, dict):
|
|
is_success = final_info.get("is_success", [False] * env.num_envs)
|
|
successes = (
|
|
is_success.tolist()
|
|
if hasattr(is_success, "tolist")
|
|
else [bool(is_success)] * env.num_envs
|
|
)
|
|
else:
|
|
# Gymnasium < 1.0 returns final_info as a per-env sequence/object array,
|
|
# with entries set to a dict only for envs that just finished.
|
|
successes = []
|
|
for item in final_info:
|
|
if isinstance(item, dict) and "is_success" in item:
|
|
successes.append(bool(item["is_success"]))
|
|
else:
|
|
successes.append(False)
|
|
elif "is_success" in info:
|
|
is_success = info["is_success"]
|
|
successes = (
|
|
is_success.tolist()
|
|
if hasattr(is_success, "tolist")
|
|
else [bool(is_success)] * env.num_envs
|
|
)
|
|
else:
|
|
successes = [False] * env.num_envs
|
|
|
|
if recording_datasets is not None and raw_observation is not None:
|
|
prev_done = done.copy()
|
|
for env_idx in range(env.num_envs):
|
|
if prev_done[env_idx]:
|
|
continue
|
|
frame = _build_raw_frame(
|
|
raw_observation,
|
|
env_idx,
|
|
action_numpy[env_idx],
|
|
reward[env_idx],
|
|
successes[env_idx],
|
|
bool(terminated[env_idx] | truncated[env_idx]),
|
|
task_desc,
|
|
recording_datasets[env_idx].features,
|
|
)
|
|
recording_datasets[env_idx].add_frame(frame)
|
|
if terminated[env_idx] or truncated[env_idx]:
|
|
recording_datasets[env_idx].save_episode()
|
|
raw_observation = deepcopy(observation)
|
|
|
|
# Keep track of which environments are done so far.
|
|
# Mark the episode as done if we reach the maximum step limit.
|
|
# This ensures that the rollout always terminates cleanly at `max_steps`,
|
|
# and allows logging/saving (e.g., videos) to be triggered consistently.
|
|
done = terminated | truncated | done
|
|
if step + 1 == max_steps:
|
|
done = np.ones_like(done, dtype=bool)
|
|
|
|
all_actions.append(torch.from_numpy(action_numpy))
|
|
all_rewards.append(torch.from_numpy(reward))
|
|
all_dones.append(torch.from_numpy(done))
|
|
all_successes.append(torch.tensor(successes))
|
|
|
|
step += 1
|
|
running_success_rate = (
|
|
einops.reduce(torch.stack(all_successes, dim=1), "b n -> b", "any").numpy().mean()
|
|
)
|
|
progbar.set_postfix({"running_success_rate": f"{running_success_rate.item() * 100:.1f}%"})
|
|
progbar.update()
|
|
finally:
|
|
if recording_datasets is not None:
|
|
for ds in recording_datasets:
|
|
ds.finalize()
|
|
if recording_repo_id is not None:
|
|
if ds.num_episodes > 0:
|
|
ds.push_to_hub(private=recording_private)
|
|
else:
|
|
logging.warning("No episodes recorded for %s — skipping push to hub.", ds.repo_id)
|
|
|
|
# Track the final observation.
|
|
if return_observations:
|
|
observation = preprocess_observation(observation)
|
|
all_observations.append(deepcopy(observation))
|
|
|
|
# Stack the sequence along the first dimension so that we have (batch, sequence, *) tensors.
|
|
ret = {
|
|
ACTION: torch.stack(all_actions, dim=1),
|
|
"reward": torch.stack(all_rewards, dim=1),
|
|
"success": torch.stack(all_successes, dim=1),
|
|
"done": torch.stack(all_dones, dim=1),
|
|
}
|
|
if return_observations:
|
|
stacked_observations = {}
|
|
for key in all_observations[0]:
|
|
stacked_observations[key] = torch.stack([obs[key] for obs in all_observations], dim=1)
|
|
ret[OBS_STR] = stacked_observations
|
|
|
|
if hasattr(policy, "use_original_modules"):
|
|
policy.use_original_modules()
|
|
|
|
return ret
|
|
|
|
|
|
def eval_policy(
|
|
env: gym.vector.VectorEnv,
|
|
policy: PreTrainedPolicy,
|
|
env_preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
env_postprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction],
|
|
n_episodes: int,
|
|
max_episodes_rendered: int = 0,
|
|
videos_dir: Path | None = None,
|
|
return_episode_data: bool = False,
|
|
start_seed: int | None = None,
|
|
recording_dir: Path | None = None,
|
|
env_features: dict | None = None,
|
|
recording_repo_id: str | None = None,
|
|
recording_private: bool = False,
|
|
save_predicted_video: bool = False,
|
|
) -> dict:
|
|
"""
|
|
Args:
|
|
env: The batch of environments.
|
|
policy: The policy.
|
|
n_episodes: The number of episodes to evaluate.
|
|
max_episodes_rendered: Maximum number of episodes to render into videos.
|
|
videos_dir: Where to save rendered videos.
|
|
return_episode_data: Whether to return episode data for online training. Incorporates the data into
|
|
the "episodes" key of the returned dictionary.
|
|
start_seed: The first seed to use for the first individual rollout. For all subsequent rollouts the
|
|
seed is incremented by 1. If not provided, the environments are not manually seeded.
|
|
Returns:
|
|
Dictionary with metrics and data regarding the rollouts.
|
|
"""
|
|
if max_episodes_rendered > 0 and not videos_dir:
|
|
raise ValueError("If max_episodes_rendered > 0, videos_dir must be provided.")
|
|
|
|
# World-model policies (e.g. LingBot-VA) opt into predicted-video saving via their config.
|
|
save_predicted_video = save_predicted_video or bool(
|
|
getattr(getattr(policy, "config", None), "save_predicted_video", False)
|
|
)
|
|
|
|
if not isinstance(policy, PreTrainedPolicy):
|
|
exc = ValueError(
|
|
f"Policy of type 'PreTrainedPolicy' is expected, but type '{type(policy)}' was provided."
|
|
)
|
|
if not _peft_available:
|
|
raise exc
|
|
require_package("peft", extra="peft")
|
|
if not isinstance(policy, PeftModel):
|
|
raise exc
|
|
|
|
start = time.time()
|
|
# Preserve the mode for direct callers. eval_policy_all scopes the mode
|
|
# around all tasks so parallel evaluations cannot race with each other.
|
|
was_training = policy.training
|
|
policy.eval()
|
|
|
|
# Determine how many batched rollouts we need to get n_episodes. Note that if n_episodes is not evenly
|
|
# divisible by env.num_envs we end up discarding some data in the last batch.
|
|
n_batches = n_episodes // env.num_envs + int((n_episodes % env.num_envs) != 0)
|
|
|
|
# Keep track of some metrics.
|
|
sum_rewards = []
|
|
max_rewards = []
|
|
all_successes = []
|
|
all_seeds = []
|
|
threads = [] # for video saving threads
|
|
n_episodes_rendered = 0 # for saving the correct number of videos
|
|
|
|
# Callback for visualization.
|
|
def render_frame(env: gym.vector.VectorEnv):
|
|
# noqa: B023
|
|
if n_episodes_rendered >= max_episodes_rendered:
|
|
return
|
|
n_to_render_now = min(max_episodes_rendered - n_episodes_rendered, env.num_envs)
|
|
if isinstance(env, gym.vector.SyncVectorEnv):
|
|
ep_frames.append(np.stack([env.envs[i].render() for i in range(n_to_render_now)])) # noqa: B023
|
|
elif hasattr(env, "call"):
|
|
# Here we must render all frames and discard any we don't need.
|
|
# Covers AsyncVectorEnv and _LazyAsyncVectorEnv (which wraps one).
|
|
ep_frames.append(np.stack(env.call("render")[:n_to_render_now]))
|
|
|
|
if max_episodes_rendered > 0:
|
|
video_paths: list[str] = []
|
|
|
|
if save_predicted_video:
|
|
if not videos_dir:
|
|
raise ValueError("If save_predicted_video is True, videos_dir must be provided.")
|
|
predicted_video_paths: list[str] = []
|
|
n_predicted_rendered = 0
|
|
|
|
# Collect predicted-video latents across a rollout (world-model policies only). The latents are
|
|
# concatenated and decoded once after the rollout, matching upstream LingBot-VA's visualization path.
|
|
def collect_predicted_latents(policy: PreTrainedPolicy):
|
|
latents = getattr(policy, "last_predicted_latents", None)
|
|
if latents is not None:
|
|
pred_latents.append(
|
|
latents.detach().to("cpu") if hasattr(latents, "detach") else torch.as_tensor(latents).cpu()
|
|
)
|
|
policy.last_predicted_latents = None
|
|
|
|
if return_episode_data:
|
|
episode_data: dict | None = None
|
|
|
|
# we dont want progress bar when we use slurm, since it clutters the logs
|
|
progbar = trange(n_batches, desc="Stepping through eval batches", disable=inside_slurm())
|
|
for batch_ix in progbar:
|
|
# Cache frames for rendering videos. Each item will be (b, h, w, c), and the list indexes the rollout
|
|
# step.
|
|
if max_episodes_rendered > 0:
|
|
ep_frames: list[np.ndarray] = []
|
|
|
|
if save_predicted_video:
|
|
pred_latents: list[torch.Tensor] = []
|
|
|
|
if start_seed is None:
|
|
seeds = None
|
|
else:
|
|
seeds = range(
|
|
start_seed + (batch_ix * env.num_envs), start_seed + ((batch_ix + 1) * env.num_envs)
|
|
)
|
|
rollout_data = rollout(
|
|
env=env,
|
|
policy=policy,
|
|
env_preprocessor=env_preprocessor,
|
|
env_postprocessor=env_postprocessor,
|
|
preprocessor=preprocessor,
|
|
postprocessor=postprocessor,
|
|
seeds=list(seeds) if seeds else None,
|
|
return_observations=return_episode_data,
|
|
render_callback=render_frame if max_episodes_rendered > 0 else None,
|
|
recording_dir=recording_dir,
|
|
env_features=env_features,
|
|
recording_repo_id=recording_repo_id,
|
|
recording_private=recording_private,
|
|
predicted_latents_callback=collect_predicted_latents if save_predicted_video else None,
|
|
)
|
|
|
|
# Figure out where in each rollout sequence the first done condition was encountered (results after
|
|
# this won't be included).
|
|
n_steps = rollout_data["done"].shape[1]
|
|
# Note: this relies on a property of argmax: that it returns the first occurrence as a tiebreaker.
|
|
done_indices = torch.argmax(rollout_data["done"].to(int), dim=1)
|
|
|
|
# Make a mask with shape (batch, n_steps) to mask out rollout data after the first done
|
|
# (batch-element-wise). Note the `done_indices + 1` to make sure to keep the data from the done step.
|
|
mask = (torch.arange(n_steps) <= einops.repeat(done_indices + 1, "b -> b s", s=n_steps)).int()
|
|
# Extend metrics.
|
|
batch_sum_rewards = einops.reduce((rollout_data["reward"] * mask), "b n -> b", "sum")
|
|
sum_rewards.extend(batch_sum_rewards.tolist())
|
|
batch_max_rewards = einops.reduce((rollout_data["reward"] * mask), "b n -> b", "max")
|
|
max_rewards.extend(batch_max_rewards.tolist())
|
|
batch_successes = einops.reduce((rollout_data["success"] * mask), "b n -> b", "any")
|
|
all_successes.extend(batch_successes.tolist())
|
|
if seeds:
|
|
all_seeds.extend(seeds)
|
|
else:
|
|
all_seeds.append(None)
|
|
|
|
# FIXME: episode_data is either None or it doesn't exist
|
|
if return_episode_data:
|
|
this_episode_data = _compile_episode_data(
|
|
rollout_data,
|
|
done_indices,
|
|
start_episode_index=batch_ix * env.num_envs,
|
|
start_data_index=(0 if episode_data is None else (episode_data["index"][-1].item() + 1)),
|
|
fps=env.unwrapped.metadata["render_fps"],
|
|
)
|
|
if episode_data is None:
|
|
episode_data = this_episode_data
|
|
else:
|
|
# Some sanity checks to make sure we are correctly compiling the data.
|
|
assert episode_data["episode_index"][-1] + 1 == this_episode_data["episode_index"][0]
|
|
assert episode_data["index"][-1] + 1 == this_episode_data["index"][0]
|
|
# Concatenate the episode data.
|
|
episode_data = {k: torch.cat([episode_data[k], this_episode_data[k]]) for k in episode_data}
|
|
|
|
# Maybe render video for visualization.
|
|
if max_episodes_rendered > 0 and len(ep_frames) > 0:
|
|
batch_stacked_frames = np.stack(ep_frames, axis=1) # (b, t, *)
|
|
for stacked_frames, done_index in zip(
|
|
batch_stacked_frames, done_indices.flatten().tolist(), strict=False
|
|
):
|
|
if n_episodes_rendered >= max_episodes_rendered:
|
|
break
|
|
|
|
videos_dir.mkdir(parents=True, exist_ok=True)
|
|
video_path = videos_dir / f"eval_episode_{n_episodes_rendered}.mp4"
|
|
video_paths.append(str(video_path))
|
|
thread = threading.Thread(
|
|
target=write_video,
|
|
args=(
|
|
str(video_path),
|
|
stacked_frames[: done_index + 1], # + 1 to capture the last observation
|
|
env.unwrapped.metadata["render_fps"],
|
|
),
|
|
)
|
|
thread.start()
|
|
threads.append(thread)
|
|
n_episodes_rendered += 1
|
|
|
|
# Maybe save the policy's predicted (imagined) video for this batch's rollout.
|
|
if save_predicted_video and len(pred_latents) > 0:
|
|
predicted_latent = torch.cat(pred_latents, dim=2)
|
|
decoder = getattr(policy, "decode_predicted_latents", None) or getattr(
|
|
policy, "_decode_predicted_video", None
|
|
)
|
|
if decoder is None:
|
|
raise AttributeError(
|
|
"Policy config requested predicted-video saving, but the policy does not expose "
|
|
"`decode_predicted_latents` or `_decode_predicted_video`."
|
|
)
|
|
predicted_video = decoder(predicted_latent)
|
|
if hasattr(predicted_video, "detach"):
|
|
predicted_video = predicted_video.detach().to("cpu").numpy()
|
|
videos_dir.mkdir(parents=True, exist_ok=True)
|
|
predicted_video_path = videos_dir / f"pred_episode_{n_predicted_rendered}.mp4"
|
|
predicted_video_paths.append(str(predicted_video_path))
|
|
thread = threading.Thread(
|
|
target=write_video,
|
|
args=(
|
|
str(predicted_video_path),
|
|
predicted_video,
|
|
env.unwrapped.metadata["render_fps"],
|
|
),
|
|
)
|
|
thread.start()
|
|
threads.append(thread)
|
|
n_predicted_rendered += 1
|
|
|
|
progbar.set_postfix(
|
|
{"running_success_rate": f"{np.mean(all_successes[:n_episodes]).item() * 100:.1f}%"}
|
|
)
|
|
|
|
# Wait till all video rendering threads are done.
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
# Compile eval info.
|
|
info = {
|
|
"per_episode": [
|
|
{
|
|
"episode_ix": i,
|
|
"sum_reward": sum_reward,
|
|
"max_reward": max_reward,
|
|
"success": success,
|
|
"seed": seed,
|
|
}
|
|
for i, (sum_reward, max_reward, success, seed) in enumerate(
|
|
zip(
|
|
sum_rewards[:n_episodes],
|
|
max_rewards[:n_episodes],
|
|
all_successes[:n_episodes],
|
|
all_seeds[:n_episodes],
|
|
strict=True,
|
|
)
|
|
)
|
|
],
|
|
"aggregated": {
|
|
"avg_sum_reward": float(np.nanmean(sum_rewards[:n_episodes])),
|
|
"avg_max_reward": float(np.nanmean(max_rewards[:n_episodes])),
|
|
"pc_success": float(np.nanmean(all_successes[:n_episodes]) * 100),
|
|
"eval_s": time.time() - start,
|
|
"eval_ep_s": (time.time() - start) / n_episodes,
|
|
},
|
|
}
|
|
|
|
if return_episode_data:
|
|
info["episodes"] = episode_data
|
|
|
|
if max_episodes_rendered > 0:
|
|
info["video_paths"] = video_paths
|
|
|
|
if save_predicted_video:
|
|
info["predicted_video_paths"] = predicted_video_paths
|
|
|
|
policy.train(was_training)
|
|
|
|
return info
|
|
|
|
|
|
def _compile_episode_data(
|
|
rollout_data: dict, done_indices: Tensor, start_episode_index: int, start_data_index: int, fps: float
|
|
) -> dict:
|
|
"""Convenience function for `eval_policy(return_episode_data=True)`
|
|
|
|
Compiles all the rollout data into a Hugging Face dataset.
|
|
|
|
Similar logic is implemented when datasets are pushed to hub (see: `push_to_hub`).
|
|
"""
|
|
ep_dicts = []
|
|
total_frames = 0
|
|
for ep_ix in range(rollout_data[ACTION].shape[0]):
|
|
# + 2 to include the first done frame and the last observation frame.
|
|
num_frames = done_indices[ep_ix].item() + 2
|
|
total_frames += num_frames
|
|
|
|
# Here we do `num_frames - 1` as we don't want to include the last observation frame just yet.
|
|
ep_dict = {
|
|
ACTION: rollout_data[ACTION][ep_ix, : num_frames - 1],
|
|
"episode_index": torch.tensor([start_episode_index + ep_ix] * (num_frames - 1)),
|
|
"frame_index": torch.arange(0, num_frames - 1, 1),
|
|
"timestamp": torch.arange(0, num_frames - 1, 1) / fps,
|
|
DONE: rollout_data["done"][ep_ix, : num_frames - 1],
|
|
"next.success": rollout_data["success"][ep_ix, : num_frames - 1],
|
|
REWARD: rollout_data["reward"][ep_ix, : num_frames - 1].type(torch.float32),
|
|
}
|
|
|
|
# For the last observation frame, all other keys will just be copy padded.
|
|
for k in ep_dict:
|
|
ep_dict[k] = torch.cat([ep_dict[k], ep_dict[k][-1:]])
|
|
|
|
for key in rollout_data[OBS_STR]:
|
|
ep_dict[key] = rollout_data[OBS_STR][key][ep_ix, :num_frames]
|
|
|
|
ep_dicts.append(ep_dict)
|
|
|
|
data_dict = {}
|
|
for key in ep_dicts[0]:
|
|
data_dict[key] = torch.cat([x[key] for x in ep_dicts])
|
|
|
|
data_dict["index"] = torch.arange(start_data_index, start_data_index + total_frames, 1)
|
|
|
|
return data_dict
|
|
|
|
|
|
@parser.wrap()
|
|
def eval_main(cfg: EvalPipelineConfig):
|
|
logging.info(pformat(asdict(cfg)))
|
|
|
|
# Check device is available
|
|
device = get_safe_torch_device(cfg.policy.device, log=True)
|
|
|
|
torch.backends.cudnn.benchmark = True
|
|
torch.backends.cuda.matmul.allow_tf32 = True
|
|
set_seed(cfg.seed)
|
|
|
|
logging.info(colored("Output dir:", "yellow", attrs=["bold"]) + f" {cfg.output_dir}")
|
|
|
|
logging.info(f"Making environment (batch_size={cfg.eval.batch_size}, async={cfg.eval.use_async_envs}).")
|
|
envs = make_env(
|
|
cfg.env,
|
|
n_envs=cfg.eval.batch_size,
|
|
use_async_envs=cfg.eval.use_async_envs,
|
|
trust_remote_code=cfg.trust_remote_code,
|
|
)
|
|
|
|
logging.info("Making policy.")
|
|
|
|
policy = make_policy(
|
|
cfg=cfg.policy,
|
|
env_cfg=cfg.env,
|
|
rename_map=cfg.rename_map,
|
|
)
|
|
|
|
policy.eval()
|
|
|
|
# The inference device is automatically set to match the detected hardware, overriding any previous device settings from training to ensure compatibility.
|
|
preprocessor_overrides = {
|
|
"device_processor": {"device": str(policy.config.device)},
|
|
"rename_observations_processor": {"rename_map": cfg.rename_map},
|
|
}
|
|
|
|
preprocessor, postprocessor = make_pre_post_processors(
|
|
policy_cfg=cfg.policy,
|
|
pretrained_path=cfg.policy.pretrained_path,
|
|
preprocessor_overrides=preprocessor_overrides,
|
|
)
|
|
|
|
# Create environment-specific preprocessor and postprocessor (e.g., for LIBERO environments)
|
|
env_preprocessor, env_postprocessor = make_env_pre_post_processors(env_cfg=cfg.env, policy_cfg=cfg.policy)
|
|
|
|
recording_dir = Path(cfg.output_dir) / "recordings" if cfg.eval.recording else None
|
|
max_episodes_rendered = 0 if cfg.eval.recording else 10
|
|
videos_dir = None if cfg.eval.recording else Path(cfg.output_dir) / "videos"
|
|
|
|
with torch.no_grad(), torch.autocast(device_type=device.type) if cfg.policy.use_amp else nullcontext():
|
|
info = eval_policy_all(
|
|
envs=envs,
|
|
policy=policy,
|
|
env_preprocessor=env_preprocessor,
|
|
env_postprocessor=env_postprocessor,
|
|
preprocessor=preprocessor,
|
|
postprocessor=postprocessor,
|
|
n_episodes=cfg.eval.n_episodes,
|
|
max_episodes_rendered=max_episodes_rendered,
|
|
videos_dir=videos_dir,
|
|
return_episode_data=False,
|
|
start_seed=cfg.seed,
|
|
max_parallel_tasks=cfg.env.max_parallel_tasks,
|
|
recording_dir=recording_dir,
|
|
env_features=cfg.env.features if cfg.eval.recording else None,
|
|
recording_repo_id=cfg.eval.recording_repo_id,
|
|
recording_private=cfg.eval.recording_private,
|
|
)
|
|
print("Overall Aggregated Metrics:")
|
|
print(info["overall"])
|
|
|
|
# Print per-suite stats
|
|
for task_group, task_group_info in info.items():
|
|
print(f"\nAggregated Metrics for {task_group}:")
|
|
print(task_group_info)
|
|
# Close all vec envs
|
|
close_envs(envs)
|
|
|
|
# Save info
|
|
with open(Path(cfg.output_dir) / "eval_info.json", "w") as f:
|
|
json.dump(info, f, indent=2)
|
|
|
|
logging.info("End of eval")
|
|
|
|
|
|
# ---- typed payload returned by one task eval ----
|
|
class TaskMetrics(TypedDict):
|
|
sum_rewards: list[float]
|
|
max_rewards: list[float]
|
|
successes: list[bool]
|
|
video_paths: list[str]
|
|
predicted_video_paths: list[str]
|
|
|
|
|
|
ACC_KEYS = ("sum_rewards", "max_rewards", "successes", "video_paths", "predicted_video_paths")
|
|
|
|
|
|
def eval_one(
|
|
env: gym.vector.VectorEnv,
|
|
*,
|
|
policy: PreTrainedPolicy,
|
|
env_preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
env_postprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction],
|
|
n_episodes: int,
|
|
max_episodes_rendered: int,
|
|
videos_dir: Path | None,
|
|
return_episode_data: bool,
|
|
start_seed: int | None,
|
|
recording_dir: Path | None = None,
|
|
env_features: dict | None = None,
|
|
recording_repo_id: str | None = None,
|
|
recording_private: bool = False,
|
|
) -> TaskMetrics:
|
|
"""Evaluates one task_id of one suite using the provided vec env."""
|
|
|
|
task_videos_dir = videos_dir
|
|
|
|
task_result = eval_policy(
|
|
env=env,
|
|
policy=policy,
|
|
env_preprocessor=env_preprocessor,
|
|
env_postprocessor=env_postprocessor,
|
|
preprocessor=preprocessor,
|
|
postprocessor=postprocessor,
|
|
n_episodes=n_episodes,
|
|
max_episodes_rendered=max_episodes_rendered,
|
|
videos_dir=task_videos_dir,
|
|
return_episode_data=return_episode_data,
|
|
start_seed=start_seed,
|
|
recording_dir=recording_dir,
|
|
env_features=env_features,
|
|
recording_repo_id=recording_repo_id,
|
|
recording_private=recording_private,
|
|
)
|
|
|
|
per_episode = task_result["per_episode"]
|
|
return TaskMetrics(
|
|
sum_rewards=[ep["sum_reward"] for ep in per_episode],
|
|
max_rewards=[ep["max_reward"] for ep in per_episode],
|
|
successes=[ep["success"] for ep in per_episode],
|
|
video_paths=task_result.get("video_paths", []),
|
|
predicted_video_paths=task_result.get("predicted_video_paths", []),
|
|
)
|
|
|
|
|
|
def run_one(
|
|
task_group: str,
|
|
task_id: int,
|
|
env,
|
|
*,
|
|
policy,
|
|
env_preprocessor,
|
|
env_postprocessor,
|
|
preprocessor,
|
|
postprocessor,
|
|
n_episodes: int,
|
|
max_episodes_rendered: int,
|
|
videos_dir: Path | None,
|
|
return_episode_data: bool,
|
|
start_seed: int | None,
|
|
recording_dir: Path | None = None,
|
|
env_features: dict | None = None,
|
|
recording_repo_id: str | None = None,
|
|
recording_private: bool = False,
|
|
):
|
|
"""
|
|
Run eval_one for a single (task_group, task_id, env).
|
|
Returns (task_group, task_id, task_metrics_dict).
|
|
This function is intentionally module-level to make it easy to test.
|
|
"""
|
|
task_videos_dir = None
|
|
if videos_dir is not None:
|
|
task_videos_dir = videos_dir / f"{task_group}_{task_id}"
|
|
task_videos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
task_recording_dir = None
|
|
task_repo_id = None
|
|
if recording_dir is not None and env_features is not None:
|
|
task_recording_dir = recording_dir / f"{task_group}_{task_id}"
|
|
if recording_repo_id is not None:
|
|
task_repo_id = f"{recording_repo_id}_{task_group}_{task_id}"
|
|
|
|
metrics = eval_one(
|
|
env,
|
|
policy=policy,
|
|
env_preprocessor=env_preprocessor,
|
|
env_postprocessor=env_postprocessor,
|
|
preprocessor=preprocessor,
|
|
postprocessor=postprocessor,
|
|
n_episodes=n_episodes,
|
|
max_episodes_rendered=max_episodes_rendered,
|
|
videos_dir=task_videos_dir,
|
|
return_episode_data=return_episode_data,
|
|
start_seed=start_seed,
|
|
recording_dir=task_recording_dir,
|
|
env_features=env_features,
|
|
recording_repo_id=task_repo_id,
|
|
recording_private=recording_private,
|
|
)
|
|
|
|
if max_episodes_rendered > 0:
|
|
metrics.setdefault("video_paths", [])
|
|
metrics.setdefault("predicted_video_paths", [])
|
|
return task_group, task_id, metrics
|
|
|
|
|
|
def eval_policy_all(
|
|
envs: dict[str, dict[int, gym.vector.VectorEnv]],
|
|
policy,
|
|
env_preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
env_postprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
|
postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction],
|
|
n_episodes: int,
|
|
*,
|
|
max_episodes_rendered: int = 0,
|
|
recording_dir: Path | None = None,
|
|
env_features: dict | None = None,
|
|
recording_repo_id: str | None = None,
|
|
recording_private: bool = False,
|
|
videos_dir: Path | None = None,
|
|
return_episode_data: bool = False,
|
|
start_seed: int | None = None,
|
|
max_parallel_tasks: int = 1,
|
|
) -> dict:
|
|
"""
|
|
Evaluate a nested `envs` dict: {task_group: {task_id: vec_env}}.
|
|
This implementation flattens tasks, runs them sequentially or via ThreadPoolExecutor,
|
|
accumulates per-group and overall statistics, and returns the same aggregate metrics
|
|
schema as the single-env evaluator (avg_sum_reward / avg_max_reward / pc_success / timings)
|
|
plus per-task infos.
|
|
"""
|
|
start_t = time.time()
|
|
|
|
# Flatten envs into list of (task_group, task_id, env)
|
|
tasks = [(tg, tid, vec) for tg, group in envs.items() for tid, vec in group.items()]
|
|
|
|
# accumulators: track metrics at both per-group level and across all groups
|
|
group_acc: dict[str, dict[str, list]] = defaultdict(lambda: {k: [] for k in ACC_KEYS})
|
|
overall: dict[str, list] = {k: [] for k in ACC_KEYS}
|
|
per_task_infos: list[dict] = []
|
|
|
|
# small inline helper to accumulate one task's metrics into accumulators
|
|
def _accumulate_to(group: str, metrics: dict):
|
|
# metrics expected to contain 'sum_rewards', 'max_rewards', 'successes', optionally 'video_paths'
|
|
# but eval_one may store per-episode lists; we assume metrics uses scalars averaged per task as before.
|
|
# To be robust, accept scalars or lists.
|
|
def _append(key, value):
|
|
if value is None:
|
|
return
|
|
if isinstance(value, list):
|
|
group_acc[group][key].extend(value)
|
|
overall[key].extend(value)
|
|
else:
|
|
group_acc[group][key].append(value)
|
|
overall[key].append(value)
|
|
|
|
_append("sum_rewards", metrics.get("sum_rewards"))
|
|
_append("max_rewards", metrics.get("max_rewards"))
|
|
_append("successes", metrics.get("successes"))
|
|
for key in ("video_paths", "predicted_video_paths"):
|
|
paths = metrics.get(key, [])
|
|
if paths:
|
|
group_acc[group][key].extend(paths)
|
|
overall[key].extend(paths)
|
|
|
|
# Choose runner (sequential vs threaded)
|
|
task_runner = partial(
|
|
run_one,
|
|
policy=policy,
|
|
env_preprocessor=env_preprocessor,
|
|
env_postprocessor=env_postprocessor,
|
|
preprocessor=preprocessor,
|
|
postprocessor=postprocessor,
|
|
n_episodes=n_episodes,
|
|
max_episodes_rendered=max_episodes_rendered,
|
|
videos_dir=videos_dir,
|
|
return_episode_data=return_episode_data,
|
|
start_seed=start_seed,
|
|
recording_dir=recording_dir,
|
|
env_features=env_features,
|
|
recording_repo_id=recording_repo_id,
|
|
recording_private=recording_private,
|
|
)
|
|
|
|
# Set the shared policy's mode before launching any workers. Restoring it
|
|
# inside individual tasks would let one task enable training mode while
|
|
# another task is still evaluating.
|
|
was_training = policy.training
|
|
policy.eval()
|
|
try:
|
|
if max_parallel_tasks <= 1:
|
|
prefetch_thread: threading.Thread | None = None
|
|
for i, (task_group, task_id, env) in enumerate(tasks):
|
|
if prefetch_thread is not None:
|
|
prefetch_thread.join()
|
|
prefetch_thread = None
|
|
|
|
try:
|
|
tg, tid, metrics = task_runner(task_group, task_id, env)
|
|
_accumulate_to(tg, metrics)
|
|
per_task_infos.append({"task_group": tg, "task_id": tid, "metrics": metrics})
|
|
finally:
|
|
env.close()
|
|
# Prefetch next task's workers *after* closing current env to prevent
|
|
# GPU memory overlap between consecutive tasks.
|
|
if i + 1 < len(tasks):
|
|
next_env = tasks[i + 1][2]
|
|
if hasattr(next_env, "_ensure"):
|
|
prefetch_thread = threading.Thread(target=next_env._ensure, daemon=True)
|
|
prefetch_thread.start()
|
|
else:
|
|
with cf.ThreadPoolExecutor(max_workers=max_parallel_tasks) as executor:
|
|
fut2meta = {}
|
|
for task_group, task_id, env in tasks:
|
|
fut = executor.submit(task_runner, task_group, task_id, env)
|
|
fut2meta[fut] = (task_group, task_id, env)
|
|
for fut in cf.as_completed(fut2meta):
|
|
tg, tid, env = fut2meta[fut]
|
|
try:
|
|
tg, tid, metrics = fut.result()
|
|
_accumulate_to(tg, metrics)
|
|
per_task_infos.append({"task_group": tg, "task_id": tid, "metrics": metrics})
|
|
finally:
|
|
env.close()
|
|
finally:
|
|
policy.train(was_training)
|
|
|
|
# compute aggregated metrics helper (robust to lists/scalars)
|
|
def _agg_from_list(xs):
|
|
if not xs:
|
|
return float("nan")
|
|
arr = np.array(xs, dtype=float)
|
|
return float(np.nanmean(arr))
|
|
|
|
# compute per-group aggregates
|
|
groups_aggregated = {}
|
|
for group, acc in group_acc.items():
|
|
groups_aggregated[group] = {
|
|
"avg_sum_reward": _agg_from_list(acc["sum_rewards"]),
|
|
"avg_max_reward": _agg_from_list(acc["max_rewards"]),
|
|
"pc_success": _agg_from_list(acc["successes"]) * 100 if acc["successes"] else float("nan"),
|
|
"n_episodes": len(acc["sum_rewards"]),
|
|
"video_paths": list(acc["video_paths"]),
|
|
"predicted_video_paths": list(acc["predicted_video_paths"]),
|
|
}
|
|
|
|
# overall aggregates
|
|
overall_agg = {
|
|
"avg_sum_reward": _agg_from_list(overall["sum_rewards"]),
|
|
"avg_max_reward": _agg_from_list(overall["max_rewards"]),
|
|
"pc_success": _agg_from_list(overall["successes"]) * 100 if overall["successes"] else float("nan"),
|
|
"n_episodes": len(overall["sum_rewards"]),
|
|
"eval_s": time.time() - start_t,
|
|
"eval_ep_s": (time.time() - start_t) / max(1, len(overall["sum_rewards"])),
|
|
"video_paths": list(overall["video_paths"]),
|
|
"predicted_video_paths": list(overall["predicted_video_paths"]),
|
|
}
|
|
|
|
return {
|
|
"per_task": per_task_infos,
|
|
"per_group": groups_aggregated,
|
|
"overall": overall_agg,
|
|
}
|
|
|
|
|
|
def main():
|
|
init_logging()
|
|
register_third_party_plugins()
|
|
eval_main()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|