mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-30 21:19:40 +00:00
e86f5af5bf
* feat(rewards): add TOPReward reward model * refactor(rewards): clean up TOPReward processor/model * fix(rewards/topreward): add missing input keys mm_token_type_ids * fix(rewards/topreward): fix pyproject extra typo and simplify processor (#3653) Add lerobot[topreward] extra to all in pyproject.toml, drop the redundant labels arg in scoring, and collapse the dead-branch shape check in the encoder processor. * optmize topreward input processing (#3660) --------- Co-authored-by: Cole <91766445+jcoleharrison@users.noreply.github.com> Co-authored-by: Haoming Song <haomingsong24@gmail.com>
354 lines
13 KiB
Python
354 lines
13 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2026 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.
|
|
|
|
"""Compute per-frame TOPReward progress curves for a LeRobot dataset.
|
|
|
|
For each episode, scores trajectory prefixes of increasing length using
|
|
the TOPReward reward model, min-max normalises the raw log-prob rewards per episode,
|
|
and writes a parquet file with one row per frame.
|
|
|
|
The parquet uses the same schema as SARM's :mod:`lerobot.rewards.sarm.compute_rabc_weights`.
|
|
|
|
Usage:
|
|
# Sparse-dense mode (15 anchors per episode, matches upstream)
|
|
python -m lerobot.rewards.topreward.compute_rabc_weights \\
|
|
--dataset-repo-id lerobot/libero_10_image \\
|
|
--num-samples 15
|
|
|
|
# Use a different VLM backbone
|
|
python -m lerobot.rewards.topreward.compute_rabc_weights \\
|
|
--dataset-repo-id lerobot/libero_10_image \\
|
|
--vlm-name Qwen/Qwen3-VL-4B-Instruct
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import torch
|
|
from tqdm import tqdm
|
|
|
|
from lerobot.datasets import LeRobotDataset
|
|
from lerobot.rewards.topreward.configuration_topreward import TOPRewardConfig
|
|
from lerobot.rewards.topreward.modeling_topreward import TOPRewardModel
|
|
from lerobot.rewards.topreward.processor_topreward import TOPRewardEncoderProcessorStep
|
|
from lerobot.types import TransitionKey
|
|
|
|
DEFAULT_OUTPUT_FILENAME = "topreward_progress.parquet"
|
|
|
|
|
|
def get_reward_model_path_from_parquet(parquet_path: Path) -> str | None:
|
|
"""Read ``reward_model_path`` from parquet metadata if available."""
|
|
if not parquet_path.exists():
|
|
return None
|
|
try:
|
|
metadata = pq.read_metadata(parquet_path).schema.to_arrow_schema().metadata
|
|
if metadata and b"reward_model_path" in metadata:
|
|
return metadata[b"reward_model_path"].decode()
|
|
except Exception: # nosec B110
|
|
return None
|
|
return None
|
|
|
|
|
|
def _resolve_task(sample: dict[str, Any], default: str) -> str:
|
|
"""Best-effort task extraction from a dataset sample."""
|
|
task = sample.get("task")
|
|
if isinstance(task, str) and task:
|
|
return task
|
|
return default
|
|
|
|
|
|
def normalize_rewards(rewards: list[float] | np.ndarray) -> np.ndarray:
|
|
"""Min-max normalise raw log-prob rewards into ``[0, 1]``."""
|
|
rewards_arr = np.asarray(rewards, dtype=np.float64)
|
|
if rewards_arr.size == 0:
|
|
return rewards_arr.astype(np.float32)
|
|
if rewards_arr.size == 1:
|
|
return np.array([1.0], dtype=np.float32)
|
|
r_min, r_max = rewards_arr.min(), rewards_arr.max()
|
|
if r_max == r_min:
|
|
return np.ones_like(rewards_arr, dtype=np.float32)
|
|
return ((rewards_arr - r_min) / (r_max - r_min)).astype(np.float32)
|
|
|
|
|
|
def compute_instruction_rewards_for_prefixes(
|
|
model: TOPRewardModel,
|
|
encoder: TOPRewardEncoderProcessorStep,
|
|
dataset: LeRobotDataset,
|
|
ep_start: int,
|
|
num_frames: int,
|
|
task: str,
|
|
image_key: str,
|
|
num_samples: int | None,
|
|
device: str,
|
|
) -> np.ndarray:
|
|
"""Score an episode via prefix sweep and return a per-frame normalised curve."""
|
|
if num_samples is None or num_samples >= num_frames:
|
|
prefix_lengths = np.arange(1, num_frames + 1, dtype=np.int64)
|
|
else:
|
|
prefix_lengths = np.unique(np.linspace(1, num_frames, num_samples).round().astype(np.int64))
|
|
|
|
episode_frames = torch.stack([dataset[ep_start + i][image_key] for i in range(num_frames)])
|
|
rewards: list[float] = []
|
|
for length in prefix_lengths:
|
|
frames = episode_frames[: int(length)].unsqueeze(0) # (1, T, C, H, W)
|
|
|
|
transition = {
|
|
TransitionKey.OBSERVATION: {image_key: frames},
|
|
TransitionKey.COMPLEMENTARY_DATA: {"task": task},
|
|
}
|
|
encoded = encoder(transition)
|
|
obs = encoded[TransitionKey.OBSERVATION]
|
|
batch = {
|
|
key: value.to(device) if isinstance(value, torch.Tensor) else value for key, value in obs.items()
|
|
}
|
|
|
|
with torch.no_grad():
|
|
reward = model.compute_reward(batch)
|
|
rewards.append(float(reward.item()))
|
|
|
|
normalized_rewards = normalize_rewards(rewards)
|
|
|
|
if prefix_lengths.shape[0] == num_frames:
|
|
return normalized_rewards
|
|
|
|
return np.interp(
|
|
np.arange(1, num_frames + 1, dtype=np.float64),
|
|
prefix_lengths.astype(np.float64),
|
|
normalized_rewards.astype(np.float64),
|
|
).astype(np.float32)
|
|
|
|
|
|
def compute_topreward_progress(
|
|
dataset_repo_id: str,
|
|
reward_model_path: str | None = None,
|
|
vlm_name: str | None = None,
|
|
output_path: str | None = None,
|
|
device: str = "cuda",
|
|
num_samples: int | None = None,
|
|
fps: float | None = None,
|
|
episodes: list[int] | None = None,
|
|
) -> Path:
|
|
"""Run TOPReward over a dataset and write per-frame progress."""
|
|
if reward_model_path is not None:
|
|
logging.info(f"Loading TOPReward config from: {reward_model_path}")
|
|
model = TOPRewardModel.from_pretrained(reward_model_path)
|
|
config = model.config
|
|
config.device = device
|
|
if vlm_name is not None and vlm_name != config.vlm_name:
|
|
logging.info(f"Overriding vlm_name from config: {config.vlm_name} -> {vlm_name}")
|
|
config.vlm_name = vlm_name
|
|
model = TOPRewardModel(config)
|
|
else:
|
|
config_kwargs: dict[str, Any] = {"device": device}
|
|
if vlm_name is not None:
|
|
config_kwargs["vlm_name"] = vlm_name
|
|
if fps is not None:
|
|
config_kwargs["fps"] = fps
|
|
config = TOPRewardConfig(**config_kwargs)
|
|
logging.info(f"Constructing TOPReward with VLM: {config.vlm_name}")
|
|
model = TOPRewardModel(config)
|
|
|
|
model.to(device).eval()
|
|
|
|
encoder = TOPRewardEncoderProcessorStep(
|
|
vlm_name=config.vlm_name,
|
|
image_key=config.image_key,
|
|
task_key=config.task_key,
|
|
default_task=config.default_task,
|
|
max_frames=None, # no tail-crop: we control prefix length explicitly
|
|
fps=config.fps,
|
|
prompt_prefix=config.prompt_prefix,
|
|
prompt_suffix_template=config.prompt_suffix_template,
|
|
add_chat_template=config.add_chat_template,
|
|
max_length=config.max_input_length,
|
|
)
|
|
|
|
image_key = config.image_key
|
|
|
|
logging.info(f"Loading dataset: {dataset_repo_id}")
|
|
dataset = LeRobotDataset(dataset_repo_id, download_videos=True)
|
|
logging.info(f"Dataset: {dataset.num_episodes} episodes, {dataset.num_frames} frames")
|
|
|
|
episode_indices = list(range(dataset.num_episodes)) if episodes is None else episodes
|
|
logging.info(f"Processing {len(episode_indices)} episode(s)")
|
|
|
|
all_index: list[int] = []
|
|
all_episode: list[int] = []
|
|
all_frame: list[int] = []
|
|
all_progress: list[float] = []
|
|
|
|
for episode_idx in tqdm(episode_indices, desc="Episodes"):
|
|
ep = dataset.meta.episodes[episode_idx]
|
|
ep_start = int(ep["dataset_from_index"])
|
|
ep_end = int(ep["dataset_to_index"])
|
|
num_frames = ep_end - ep_start
|
|
if num_frames <= 0:
|
|
continue
|
|
|
|
first_sample = dataset[ep_start]
|
|
task = _resolve_task(first_sample, default=config.default_task or "perform the task")
|
|
|
|
per_frame = compute_instruction_rewards_for_prefixes(
|
|
model=model,
|
|
encoder=encoder,
|
|
dataset=dataset,
|
|
ep_start=ep_start,
|
|
num_frames=num_frames,
|
|
task=task,
|
|
image_key=image_key,
|
|
num_samples=num_samples,
|
|
device=device,
|
|
)
|
|
|
|
for local in range(num_frames):
|
|
all_index.append(ep_start + local)
|
|
all_episode.append(episode_idx)
|
|
all_frame.append(local)
|
|
all_progress.append(float(per_frame[local]))
|
|
|
|
if device.startswith("cuda"):
|
|
torch.cuda.empty_cache()
|
|
|
|
table = pa.table(
|
|
{
|
|
"index": np.asarray(all_index, dtype=np.int64),
|
|
"episode_index": np.asarray(all_episode, dtype=np.int64),
|
|
"frame_index": np.asarray(all_frame, dtype=np.int64),
|
|
"progress_sparse": np.asarray(all_progress, dtype=np.float32),
|
|
}
|
|
)
|
|
|
|
schema_metadata: dict[bytes, bytes] = {b"vlm_name": config.vlm_name.encode()}
|
|
if reward_model_path is not None:
|
|
schema_metadata[b"reward_model_path"] = reward_model_path.encode()
|
|
table = table.replace_schema_metadata(schema_metadata)
|
|
|
|
out = Path(dataset.root) / DEFAULT_OUTPUT_FILENAME if output_path is None else Path(output_path)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
pq.write_table(table, out)
|
|
logging.info(f"Saved {len(table)} frame values to {out}")
|
|
|
|
progress_arr = np.asarray(all_progress, dtype=np.float32)
|
|
if progress_arr.size:
|
|
logging.info(
|
|
f"Progress: mean={float(progress_arr.mean()):.4f}, "
|
|
f"std={float(progress_arr.std()):.4f}, "
|
|
f"min={float(progress_arr.min()):.4f}, "
|
|
f"max={float(progress_arr.max()):.4f}"
|
|
)
|
|
return out
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Compute per-frame TOPReward progress curves for RA-BC weighting.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Sparse-dense mode (matches upstream TOPReward num_samples=15)
|
|
python -m lerobot.rewards.topreward.compute_rabc_weights \\
|
|
--dataset-repo-id lerobot/libero_10_image \\
|
|
--num-samples 15
|
|
|
|
# Use a smaller VLM
|
|
python -m lerobot.rewards.topreward.compute_rabc_weights \\
|
|
--dataset-repo-id lerobot/libero_10_image \\
|
|
--vlm-name Qwen/Qwen3-VL-4B-Instruct
|
|
""",
|
|
)
|
|
parser.add_argument(
|
|
"--dataset-repo-id", type=str, required=True, help="HuggingFace dataset repo id or local path."
|
|
)
|
|
parser.add_argument(
|
|
"--reward-model-path", type=str, default=None, help="Optional TOPReward LeRobot config."
|
|
)
|
|
parser.add_argument("--vlm-name", type=str, default=None, help="Override the VLM backbone (HF Hub id).")
|
|
parser.add_argument("--output-path", type=str, default=None, help="Output parquet path.")
|
|
parser.add_argument("--device", type=str, default="cuda", help="Device to use (default: cuda).")
|
|
parser.add_argument(
|
|
"--num-samples",
|
|
type=int,
|
|
default=None,
|
|
help="Anchor prefix samples per episode. None = dense. 15 matches upstream.",
|
|
)
|
|
parser.add_argument(
|
|
"--episodes",
|
|
type=int,
|
|
nargs="+",
|
|
default=None,
|
|
help="Process only these episode indices (e.g. --episodes 0 or --episodes 0 5 10).",
|
|
)
|
|
parser.add_argument("--fps", type=float, default=None, help="Override TOPRewardConfig.fps.")
|
|
parser.add_argument(
|
|
"--push-to-hub", action="store_true", help="Upload to the dataset repo on HuggingFace Hub."
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
|
|
output_path = compute_topreward_progress(
|
|
dataset_repo_id=args.dataset_repo_id,
|
|
reward_model_path=args.reward_model_path,
|
|
vlm_name=args.vlm_name,
|
|
output_path=args.output_path,
|
|
device=args.device,
|
|
num_samples=args.num_samples,
|
|
fps=args.fps,
|
|
episodes=args.episodes,
|
|
)
|
|
|
|
print(f"\nTOPReward progress saved to: {output_path}")
|
|
|
|
if args.push_to_hub:
|
|
from huggingface_hub import HfApi
|
|
|
|
api = HfApi()
|
|
hub_path = DEFAULT_OUTPUT_FILENAME
|
|
|
|
print(f"\nUploading to Hub: {args.dataset_repo_id}/{hub_path}")
|
|
api.upload_file(
|
|
path_or_fileobj=str(output_path),
|
|
path_in_repo=hub_path,
|
|
repo_id=args.dataset_repo_id,
|
|
repo_type="dataset",
|
|
)
|
|
print(
|
|
"Successfully uploaded to: "
|
|
f"https://huggingface.co/datasets/{args.dataset_repo_id}/blob/main/{hub_path}"
|
|
)
|
|
|
|
print("\nTo use in training, add to your config:")
|
|
print(" use_rabc: true")
|
|
print(f" rabc_progress_path: hf://datasets/{args.dataset_repo_id}/{hub_path}")
|
|
print(" rabc_head_mode: sparse")
|
|
else:
|
|
print("\nTo use in training, add to your config:")
|
|
print(" use_rabc: true")
|
|
print(f" rabc_progress_path: {output_path}")
|
|
print(" rabc_head_mode: sparse")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|