eval(rewards): evaluate value function

This commit is contained in:
Khalil Meftah
2026-07-26 20:09:53 +02:00
parent 64613d8f03
commit 8a779f1e9a
6 changed files with 500 additions and 7 deletions
+1
View File
@@ -358,6 +358,7 @@ lerobot-setup-can="lerobot.scripts.lerobot_setup_can:main"
lerobot-annotate="lerobot.scripts.lerobot_annotate:main" lerobot-annotate="lerobot.scripts.lerobot_annotate:main"
lerobot-rollout="lerobot.scripts.lerobot_rollout:main" lerobot-rollout="lerobot.scripts.lerobot_rollout:main"
lerobot-compute-returns="lerobot.scripts.lerobot_compute_returns:main" lerobot-compute-returns="lerobot.scripts.lerobot_compute_returns:main"
lerobot-eval-reward-model="lerobot.scripts.lerobot_eval_reward_model:main"
# ---------------- Tool Configurations ---------------- # ---------------- Tool Configurations ----------------
@@ -183,6 +183,11 @@ class AdvantageConfig:
# Ignored when constant_value is set. # Ignored when constant_value is set.
value_function_path: str = "" value_function_path: str = ""
# Optional CSV from ``lerobot-eval-reward-model``. When set, annotation
# consumes its per-frame predictions instead of rerunning VF inference.
# This is the recommended path for temporal and multi-camera value models.
predictions_path: str = ""
# Device to run the value function on. # Device to run the value function on.
device: str = "cuda" device: str = "cuda"
@@ -191,9 +196,11 @@ class AdvantageConfig:
# 50 = fine-tuning mode: A_t = Σ r_{t:t+N} + V(s_{t+N}) - V(s_t). # 50 = fine-tuning mode: A_t = Σ r_{t:t+N} + V(s_{t+N}) - V(s_t).
n_step: int | None = None n_step: int | None = None
# Per-task percentile for binarization threshold ε_. # Percentile for binarization threshold ε_. Appendix F uses a threshold
# Actions with advantage > ε_ get I_t = True (positive). # yielding about 40% positive rollout actions during post-training, i.e.
threshold_percentile: float = 0.3 # the 60th percentile. Pre-training uses 0.7 (~30% positive), while the
# slow-but-successful laundry setting uses 0.9 (~10% positive).
threshold_percentile: float = 0.6
# When True, compute a single global threshold across all episodes (paper behavior). # When True, compute a single global threshold across all episodes (paper behavior).
# When False, compute threshold per-episode (faster but less accurate). # When False, compute threshold per-episode (faster but less accurate).
@@ -25,8 +25,10 @@ Paper reference: pi*0.6, Section IV-B and Appendix F.
from __future__ import annotations from __future__ import annotations
import csv
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -58,6 +60,7 @@ class AdvantageModule:
_preprocessor: Any = field(default=None, init=False, repr=False) _preprocessor: Any = field(default=None, init=False, repr=False)
_threshold: float | None = field(default=None, init=False, repr=False) _threshold: float | None = field(default=None, init=False, repr=False)
_cache: dict = field(default_factory=dict, init=False, repr=False) _cache: dict = field(default_factory=dict, init=False, repr=False)
_prediction_lookup: dict[tuple[int, int], float] | None = field(default=None, init=False, repr=False)
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -94,7 +97,10 @@ class AdvantageModule:
(advantages, intervention_mask) both shape [num_frames]. (advantages, intervention_mask) both shape [num_frames].
advantages[t] = A_t, intervention_mask[t] = True if frame is intervention. advantages[t] = A_t, intervention_mask[t] = True if frame is intervention.
""" """
self._ensure_model_loaded() if self.config.predictions_path:
self._ensure_predictions_loaded()
else:
self._ensure_model_loaded()
df = record.frames_df() df = record.frames_df()
num_frames = len(df) num_frames = len(df)
@@ -135,6 +141,27 @@ class AdvantageModule:
skip_mask: Optional boolean mask [num_frames]. Frames where True are skip_mask: Optional boolean mask [num_frames]. Frames where True are
skipped (left as 0.0) to avoid unnecessary inference. skipped (left as 0.0) to avoid unnecessary inference.
""" """
if self.config.predictions_path:
self._ensure_predictions_loaded()
assert self._prediction_lookup is not None
missing = [
frame_index
for frame_index in record.frame_indices
if (record.episode_index, frame_index) not in self._prediction_lookup
]
if missing:
raise KeyError(
f"Predictions CSV is missing {len(missing)} frame(s) from episode "
f"{record.episode_index}; first missing frame_index={missing[0]}"
)
return np.asarray(
[
self._prediction_lookup[(record.episode_index, frame_index)]
for frame_index in record.frame_indices
],
dtype=np.float32,
)
df = record.frames_df() df = record.frames_df()
num_frames = len(df) num_frames = len(df)
values = np.zeros(num_frames, dtype=np.float32) values = np.zeros(num_frames, dtype=np.float32)
@@ -194,6 +221,27 @@ class AdvantageModule:
return values return values
def _ensure_predictions_loaded(self) -> None:
if self._prediction_lookup is not None:
return
path = Path(self.config.predictions_path)
if not path.is_file():
raise FileNotFoundError(f"Advantage predictions CSV not found: {path}")
lookup: dict[tuple[int, int], float] = {}
with path.open(newline="") as handle:
reader = csv.DictReader(handle)
required = {"episode_index", "frame_index", "predicted_value"}
missing_columns = required.difference(reader.fieldnames or ())
if missing_columns:
raise ValueError(f"Predictions CSV is missing required columns: {sorted(missing_columns)}")
for row in reader:
key = (int(row["episode_index"]), int(row["frame_index"]))
if key in lookup:
raise ValueError(f"Duplicate prediction for episode/frame {key}")
lookup[key] = float(row["predicted_value"])
self._prediction_lookup = lookup
logger.info("Loaded %d per-frame value predictions from %s", len(lookup), path)
def _decode_video_frames( def _decode_video_frames(
self, record: EpisodeRecord, infer_indices: np.ndarray self, record: EpisodeRecord, infer_indices: np.ndarray
) -> tuple[str | None, torch.Tensor | None]: ) -> tuple[str | None, torch.Tensor | None]:
@@ -261,7 +309,7 @@ class AdvantageModule:
if self.config.constant_value: if self.config.constant_value:
return return
if not self.config.value_function_path: if not self.config.value_function_path and not self.config.predictions_path:
return return
logger.info("Computing global advantage threshold (two-pass mode)...") logger.info("Computing global advantage threshold (two-pass mode)...")
@@ -294,8 +342,11 @@ class AdvantageModule:
self._run_constant_mode(record, staging) self._run_constant_mode(record, staging)
return return
if not self.config.value_function_path: if not self.config.value_function_path and not self.config.predictions_path:
logger.warning("No value_function_path or constant_value configured; skipping advantage scoring.") logger.warning(
"No value_function_path, predictions_path, or constant_value configured; "
"skipping advantage scoring."
)
return return
if record.episode_index in self._cache: if record.episode_index in self._cache:
@@ -0,0 +1,351 @@
#!/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.
"""Evaluate a trained reward model against per-frame MC returns.
The output CSV is also accepted by ``lerobot-annotate
--advantage.predictions_path=...``. This keeps model inference faithful to the
normal LeRobot dataset and processor path, including multi-camera observations,
state normalization, and temporal delta windows.
"""
from __future__ import annotations
import argparse
import logging
import math
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from huggingface_hub import snapshot_download
from torch.utils.data import DataLoader
from tqdm import tqdm
from lerobot.configs.rewards import RewardModelConfig
from lerobot.datasets import LeRobotDataset, LeRobotDatasetMetadata
from lerobot.datasets.factory import resolve_delta_timestamps
from lerobot.processor import PolicyProcessorPipeline, batch_to_transition, transition_to_batch
from lerobot.rewards import make_reward_model
from lerobot.utils.collate import lerobot_collate_fn
from lerobot.utils.constants import ACTION, OBS_STATE, POLICY_PREPROCESSOR_DEFAULT_NAME
from lerobot.utils.hub import find_latest_hub_checkpoint
logger = logging.getLogger(__name__)
def _resolve_model_path(path_or_repo: str) -> Path:
path = Path(path_or_repo)
if path.is_dir():
if (path / "pretrained_model").is_dir():
return path / "pretrained_model"
return path
latest = find_latest_hub_checkpoint(path_or_repo)
if latest is None:
snapshot = Path(snapshot_download(repo_id=path_or_repo, repo_type="model"))
return snapshot
snapshot = Path(
snapshot_download(
repo_id=path_or_repo,
repo_type="model",
allow_patterns=f"{latest}/pretrained_model/*",
)
)
return snapshot / latest / "pretrained_model"
def _correlation(x: np.ndarray, y: np.ndarray) -> float:
if len(x) < 2 or np.std(x) == 0 or np.std(y) == 0:
return float("nan")
return float(np.corrcoef(x, y)[0, 1])
def _spearman(x: np.ndarray, y: np.ndarray) -> float:
return _correlation(
pd.Series(x).rank(method="average").to_numpy(), pd.Series(y).rank(method="average").to_numpy()
)
def _binary_auc(scores: np.ndarray, labels: np.ndarray) -> float:
labels = labels.astype(bool)
num_positive = int(labels.sum())
num_negative = len(labels) - num_positive
if num_positive == 0 or num_negative == 0:
return float("nan")
ranks = pd.Series(scores).rank(method="average").to_numpy()
rank_sum_positive = float(ranks[labels].sum())
return (rank_sum_positive - num_positive * (num_positive + 1) / 2) / (num_positive * num_negative)
def _held_out_episodes(metadata: LeRobotDatasetMetadata, eval_split: float) -> list[int] | None:
if eval_split == 0:
return None
if not 0 < eval_split < 1:
raise ValueError(f"eval_split must be in [0,1), got {eval_split}")
task_to_episodes: dict[str, list[int]] = {}
episode_tasks = metadata.episodes["tasks"]
for episode_index in range(metadata.total_episodes):
task = episode_tasks[episode_index][0] if episode_tasks[episode_index] else ""
task_to_episodes.setdefault(task, []).append(episode_index)
held_out: list[int] = []
for episodes in task_to_episodes.values():
count = math.ceil(len(episodes) * eval_split)
held_out.extend(episodes[-count:])
return held_out
def _compute_advantages(
target: np.ndarray,
prediction: np.ndarray,
episode_index: np.ndarray,
n_step: int | None,
) -> np.ndarray:
if n_step is None:
return target - prediction
advantage = np.empty_like(target)
for episode in np.unique(episode_index):
indices = np.flatnonzero(episode_index == episode)
for local_index, index in enumerate(indices):
bootstrap_index = local_index + n_step
if bootstrap_index >= len(indices):
advantage[index] = target[index] - prediction[index]
else:
future = indices[bootstrap_index]
advantage[index] = target[index] - target[future] + prediction[future] - prediction[index]
return advantage
def _target_distribution(model, target: torch.Tensor, is_terminal: torch.Tensor) -> torch.Tensor:
try:
return model.compute_target_distribution(
target,
is_terminal,
method=model.config.target_method,
use_one_hot_terminal=model.config.use_one_hot_terminal,
)
except TypeError:
return model.compute_target_distribution(target, is_terminal)
def _predict_logits_and_value(model, batch: dict) -> tuple[torch.Tensor, torch.Tensor]:
if hasattr(model, "_vlm_forward"):
logits, predicted = model._vlm_forward(batch)
return logits, predicted.reshape(-1)
if hasattr(model, "_get_value_readout"):
logits = model.value_head(model._get_value_readout(batch))
probabilities = logits.softmax(-1)
centers = model.value_head.bin_centers.to(probabilities.dtype)
return logits, (probabilities * centers).sum(-1)
raise TypeError(f"{type(model).__name__} does not expose a distributional value readout")
def evaluate(args: argparse.Namespace) -> None:
device = torch.device(args.device)
model_path = _resolve_model_path(args.reward_model_path)
logger.info("Loading reward model from %s", model_path)
config = RewardModelConfig.from_pretrained(model_path)
config.pretrained_path = str(model_path)
config.device = device.type
metadata = LeRobotDatasetMetadata(args.dataset_repo_id, root=args.root)
delta_timestamps = resolve_delta_timestamps(config, metadata)
episodes = _held_out_episodes(metadata, args.eval_split)
if episodes is not None:
logger.info("Evaluating %d held-out episode(s) (eval_split=%s)", len(episodes), args.eval_split)
dataset = LeRobotDataset(
args.dataset_repo_id,
root=args.root,
episodes=episodes,
delta_timestamps=delta_timestamps,
video_backend=args.video_backend,
return_uint8=True,
)
dataloader = DataLoader(
dataset,
batch_size=args.batch_size,
shuffle=False,
num_workers=args.num_workers,
collate_fn=lerobot_collate_fn if dataset.meta.has_language_columns else None,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
)
model = make_reward_model(config, dataset_meta=metadata).eval()
preprocessor = PolicyProcessorPipeline.from_pretrained(
model_path,
config_filename=f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json",
overrides={"device_processor": {"device": device.type}},
to_transition=batch_to_transition,
to_output=transition_to_batch,
)
predictions: list[np.ndarray] = []
targets: list[np.ndarray] = []
terminals: list[np.ndarray] = []
episode_indices: list[np.ndarray] = []
frame_indices: list[np.ndarray] = []
actions: list[np.ndarray] = []
states: list[np.ndarray] = []
interventions: list[np.ndarray] = []
nll_sum = 0.0
target_entropy_sum = 0.0
count = 0
with torch.inference_mode():
for batch in tqdm(dataloader, desc="Evaluating reward model"):
if args.max_samples > 0 and count >= args.max_samples:
break
for camera_key in metadata.camera_keys:
if camera_key in batch and batch[camera_key].dtype == torch.uint8:
batch[camera_key] = batch[camera_key].float().div_(255)
raw_target = batch["mc_return"].reshape(-1)
raw_terminal = batch["is_terminal"].reshape(-1).bool()
raw_episode = batch["episode_index"].reshape(-1)
raw_frame = batch["frame_index"].reshape(-1)
if args.max_samples > 0:
keep = min(len(raw_target), args.max_samples - count)
if keep < len(raw_target):
batch = {
key: value[:keep]
if isinstance(value, torch.Tensor)
else value[:keep]
if isinstance(value, list)
else value
for key, value in batch.items()
}
raw_target = raw_target[:keep]
raw_terminal = raw_terminal[:keep]
raw_episode = raw_episode[:keep]
raw_frame = raw_frame[:keep]
if ACTION in batch and isinstance(batch[ACTION], torch.Tensor):
actions.append(batch[ACTION].reshape(len(raw_target), -1).float().cpu().numpy())
if OBS_STATE in batch and isinstance(batch[OBS_STATE], torch.Tensor):
state = batch[OBS_STATE]
if state.ndim >= 3:
state = state[:, -1]
states.append(state.reshape(len(raw_target), -1).float().cpu().numpy())
if "intervention" in batch and isinstance(batch["intervention"], torch.Tensor):
interventions.append(batch["intervention"].reshape(-1).bool().cpu().numpy())
processed = preprocessor(batch)
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
logits, predicted = _predict_logits_and_value(model, processed)
target_on_device = processed["mc_return"].reshape(-1)
terminal_on_device = processed["is_terminal"].reshape(-1).bool()
target_dist = _target_distribution(model, target_on_device, terminal_on_device)
per_sample_nll = -(target_dist * logits.log_softmax(-1)).sum(-1)
entropy = -(target_dist * target_dist.clamp_min(1e-12).log()).sum(-1)
batch_count = len(predicted)
nll_sum += float(per_sample_nll.sum())
target_entropy_sum += float(entropy.sum())
count += batch_count
predictions.append(predicted.float().cpu().numpy())
targets.append(raw_target.float().cpu().numpy())
terminals.append(raw_terminal.cpu().numpy())
episode_indices.append(raw_episode.cpu().numpy())
frame_indices.append(raw_frame.cpu().numpy())
prediction = np.concatenate(predictions)
target = np.concatenate(targets)
terminal = np.concatenate(terminals)
episode_index = np.concatenate(episode_indices).astype(np.int64)
frame_index = np.concatenate(frame_indices).astype(np.int64)
residual = prediction - target
advantage = _compute_advantages(target, prediction, episode_index, args.n_step)
threshold = float(np.percentile(advantage, args.threshold_percentile * 100))
advantage_label = np.where(advantage > threshold, "positive", "negative")
terminal_success = np.isclose(target[terminal], 0.0, atol=1e-6)
metrics = {
"samples": len(prediction),
"nll": nll_sum / count,
"target_entropy": target_entropy_sum / count,
"excess_nll": (nll_sum - target_entropy_sum) / count,
"mae": float(np.mean(np.abs(residual))),
"rmse": float(np.sqrt(np.mean(np.square(residual)))),
"bias": float(np.mean(residual)),
"prediction_std": float(np.std(prediction)),
"target_std": float(np.std(target)),
"pearson": _correlation(prediction, target),
"spearman": _spearman(prediction, target),
"terminal_success_auc": _binary_auc(prediction[terminal], terminal_success),
"advantage_threshold": threshold,
"positive_fraction": float(np.mean(advantage_label == "positive")),
}
for name, value in metrics.items():
logger.info("%s: %s", name, f"{value:.6f}" if isinstance(value, float) else value)
output_path = Path(args.output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output = {
"episode_index": episode_index,
"frame_index": frame_index,
"mc_return": target,
"predicted_value": prediction,
"residual": residual,
"advantage": advantage,
"advantage_label": advantage_label,
"is_terminal": terminal,
}
if actions and states:
action = np.concatenate(actions)
state = np.concatenate(states)
common_dim = min(action.shape[1], state.shape[1])
output["command_delta_norm"] = np.linalg.norm(
action[:, :common_dim] - state[:, :common_dim],
axis=1,
)
state_motion = np.zeros(len(state), dtype=np.float32)
same_episode_indices = np.flatnonzero(episode_index[1:] == episode_index[:-1]) + 1
state_motion[same_episode_indices] = np.linalg.norm(
state[same_episode_indices] - state[same_episode_indices - 1],
axis=1,
)
output["state_motion_norm"] = state_motion
if interventions and sum(map(len, interventions)) == len(prediction):
output["intervention"] = np.concatenate(interventions)
pd.DataFrame(output).to_csv(output_path, index=False)
logger.info("Wrote per-frame predictions to %s", output_path)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reward-model-path", required=True)
parser.add_argument("--dataset-repo-id", required=True)
parser.add_argument("--root")
parser.add_argument("--output-path", required=True)
parser.add_argument("--device", default="cuda")
parser.add_argument("--video-backend", default="torchcodec")
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--num-workers", type=int, default=4)
parser.add_argument("--max-samples", type=int, default=0)
parser.add_argument("--eval-split", type=float, default=0.0)
parser.add_argument("--n-step", type=int)
parser.add_argument("--threshold-percentile", type=float, default=0.6)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
evaluate(args)
if __name__ == "__main__":
main()
+33
View File
@@ -89,6 +89,7 @@ def test_advantage_module_enabled_by_default():
cfg = AdvantageConfig() cfg = AdvantageConfig()
module = AdvantageModule(config=cfg) module = AdvantageModule(config=cfg)
assert module.enabled assert module.enabled
assert cfg.threshold_percentile == 0.6
def test_run_episode_skips_without_value_function_path(staging: EpisodeStaging): def test_run_episode_skips_without_value_function_path(staging: EpisodeStaging):
@@ -103,6 +104,38 @@ def test_run_episode_skips_without_value_function_path(staging: EpisodeStaging):
assert rows == [] assert rows == []
def test_run_episode_uses_precomputed_predictions(staging: EpisodeStaging, tmp_path: Path):
predictions_path = tmp_path / "predictions.csv"
predictions_path.write_text(
"episode_index,frame_index,predicted_value\n0,0,-0.4\n0,1,-0.4\n0,2,-0.4\n0,3,-0.4\n"
)
record = _make_record(
num_frames=4,
mc_returns=np.array([-0.7, -0.4, -0.2, -0.1], dtype=np.float32),
)
module = AdvantageModule(
config=AdvantageConfig(
predictions_path=str(predictions_path),
threshold_percentile=0.5,
)
)
module.run_episode(record, staging)
rows = staging.read("advantage")
assert [row["content"] for row in rows] == ["negative", "negative", "positive", "positive"]
assert module._model is None
def test_predictions_csv_requires_every_frame(staging: EpisodeStaging, tmp_path: Path):
predictions_path = tmp_path / "predictions.csv"
predictions_path.write_text("episode_index,frame_index,predicted_value\n0,0,-0.4\n")
module = AdvantageModule(config=AdvantageConfig(predictions_path=str(predictions_path)))
with pytest.raises(KeyError, match="missing 1 frame"):
module.run_episode(_make_record(num_frames=2), staging)
def test_binarization_with_mock_values(staging: EpisodeStaging): def test_binarization_with_mock_values(staging: EpisodeStaging):
"""Advantage binarization produces positive/negative labels based on threshold.""" """Advantage binarization produces positive/negative labels based on threshold."""
num_frames = 10 num_frames = 10
+50
View File
@@ -0,0 +1,50 @@
from types import SimpleNamespace
import numpy as np
import pytest
from lerobot.scripts.lerobot_eval_reward_model import (
_binary_auc,
_compute_advantages,
_correlation,
_held_out_episodes,
_spearman,
)
def test_reward_evaluation_correlations():
target = np.array([-1.0, -0.5, 0.0])
prediction = np.array([-0.9, -0.5, -0.1])
assert _correlation(prediction, target) == pytest.approx(1.0)
assert _spearman(prediction, target) == pytest.approx(1.0)
def test_reward_evaluation_auc():
labels = np.array([False, False, True, True])
scores = np.array([-0.9, -0.7, -0.2, -0.1])
assert _binary_auc(scores, labels) == pytest.approx(1.0)
def test_reward_evaluation_reproduces_training_episode_split():
metadata = SimpleNamespace(
total_episodes=5,
episodes={"tasks": [["a"], ["a"], ["a"], ["b"], ["b"]]},
)
assert _held_out_episodes(metadata, 0.34) == [1, 2, 4]
assert _held_out_episodes(metadata, 0.0) is None
def test_reward_evaluation_n_step_advantages():
target = np.array([-0.9, -0.6, -0.3, -0.8, -0.4], dtype=np.float32)
prediction = np.array([-0.8, -0.5, -0.2, -0.7, -0.3], dtype=np.float32)
episodes = np.array([0, 0, 0, 1, 1])
advantage = _compute_advantages(target, prediction, episodes, n_step=2)
expected_first = target[0] - target[2] + prediction[2] - prediction[0]
assert advantage[0] == pytest.approx(expected_first)
assert advantage[1] == pytest.approx(target[1] - prediction[1])
assert advantage[3] == pytest.approx(target[3] - prediction[3])