mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 02:06:15 +00:00
simple eval
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,367 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
"""
|
||||||
|
Standalone evaluation script for RLearN models.
|
||||||
|
|
||||||
|
This script evaluates RLearN reward models on episodes from a dataset,
|
||||||
|
generating comparison plots between ground truth rewards and model predictions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python src/lerobot/policies/rlearn/eval_script.py --model MODEL_NAME --dataset DATASET_REPO --episodes N
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python src/lerobot/policies/rlearn/eval_script.py --model pepijn223/rlearn_mse5 --dataset pepijn223/phone_pipeline_pickup1 --episodes 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path for imports
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from scipy.stats import spearmanr
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
# LeRobot imports
|
||||||
|
from lerobot.constants import OBS_IMAGE, OBS_IMAGES, OBS_LANGUAGE
|
||||||
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
|
from lerobot.policies.rlearn.modeling_rlearn import RLearNPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def _to_chw_float01(img):
|
||||||
|
"""Ensure CHW float in [0,1]."""
|
||||||
|
if isinstance(img, np.ndarray):
|
||||||
|
img = torch.from_numpy(img)
|
||||||
|
# HWC -> CHW if needed
|
||||||
|
if len(img.shape) == 3 and img.shape[-1] in (1, 3, 4):
|
||||||
|
img = img.permute(2, 0, 1)
|
||||||
|
if img.dtype == torch.uint8:
|
||||||
|
img = img.float() / 255.0
|
||||||
|
else:
|
||||||
|
img = img.float()
|
||||||
|
return torch.clamp(img, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_language(frame_data):
|
||||||
|
lang = None
|
||||||
|
if OBS_LANGUAGE in frame_data:
|
||||||
|
lang = frame_data[OBS_LANGUAGE]
|
||||||
|
if isinstance(lang, list) and len(lang) > 0:
|
||||||
|
lang = lang[0]
|
||||||
|
elif "task" in frame_data:
|
||||||
|
lang = frame_data["task"]
|
||||||
|
return lang if isinstance(lang, str) else "No language provided"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ground_truth_reward(frame_data):
|
||||||
|
"""Try common keys for ground-truth reward. Return None if unavailable."""
|
||||||
|
for key in ("reward", "rewards", "gt_reward", "progress"):
|
||||||
|
if key in frame_data:
|
||||||
|
r = frame_data[key]
|
||||||
|
# unwrap single-element lists/arrays
|
||||||
|
if isinstance(r, (list, np.ndarray)) and np.array(r).size == 1:
|
||||||
|
r = float(np.array(r).reshape(-1)[0])
|
||||||
|
try:
|
||||||
|
return float(r)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_episode_frames_and_gt(dataset, episode_idx):
|
||||||
|
"""Load a full episode: frames (T, C, H, W), language (str), gt_rewards (np.ndarray or None)."""
|
||||||
|
ep_start = dataset.episode_data_index["from"][episode_idx].item()
|
||||||
|
ep_end = dataset.episode_data_index["to"][episode_idx].item()
|
||||||
|
T = ep_end - ep_start
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
gt_rewards = []
|
||||||
|
language = None
|
||||||
|
|
||||||
|
for t in range(T):
|
||||||
|
item = dataset[ep_start + t]
|
||||||
|
|
||||||
|
# image(s)
|
||||||
|
if OBS_IMAGES in item:
|
||||||
|
img = item[OBS_IMAGES]
|
||||||
|
elif OBS_IMAGE in item:
|
||||||
|
img = item[OBS_IMAGE]
|
||||||
|
else:
|
||||||
|
# try to find an image-like key
|
||||||
|
img_keys = [k for k in item.keys() if "image" in k.lower()]
|
||||||
|
if not img_keys:
|
||||||
|
continue
|
||||||
|
img = item[img_keys[0]]
|
||||||
|
|
||||||
|
frames.append(_to_chw_float01(img))
|
||||||
|
|
||||||
|
# language once
|
||||||
|
if language is None:
|
||||||
|
language = _get_language(item)
|
||||||
|
|
||||||
|
# ground-truth reward (optional)
|
||||||
|
r = _get_ground_truth_reward(item)
|
||||||
|
gt_rewards.append(r)
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
frames = torch.stack(frames) # (T, C, H, W)
|
||||||
|
|
||||||
|
# If all GT entries are None, treat as missing
|
||||||
|
if all(r is None for r in gt_rewards):
|
||||||
|
gt_rewards = None
|
||||||
|
else:
|
||||||
|
# Replace None by forward filling
|
||||||
|
arr = np.array([np.nan if r is None else float(r) for r in gt_rewards], dtype=float)
|
||||||
|
# forward/back fill
|
||||||
|
if np.isnan(arr[0]):
|
||||||
|
first_valid = np.flatnonzero(~np.isnan(arr))
|
||||||
|
if len(first_valid) > 0:
|
||||||
|
arr[0] = arr[first_valid[0]]
|
||||||
|
else:
|
||||||
|
arr[0] = 0.0
|
||||||
|
for i in range(1, len(arr)):
|
||||||
|
if np.isnan(arr[i]):
|
||||||
|
arr[i] = arr[i - 1]
|
||||||
|
gt_rewards = arr
|
||||||
|
|
||||||
|
return frames, language or "No language provided", gt_rewards
|
||||||
|
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def predict_rewards_sliding(model, frames, language, max_seq_len=16, batch_size=64, device="cuda"):
|
||||||
|
"""
|
||||||
|
Sliding-window prediction: for each frame i, create a window [max(0, i-L+1) .. i],
|
||||||
|
left-pad by repeating the first frame to length L (<= 16), and take the last-step prediction.
|
||||||
|
Returns np.ndarray of shape (T,).
|
||||||
|
"""
|
||||||
|
T = frames.shape[0]
|
||||||
|
L = int(getattr(getattr(model, "config", object()), "max_seq_len", max_seq_len))
|
||||||
|
L = min(L, max_seq_len) # hard-cap at 16
|
||||||
|
|
||||||
|
# Preprocessed tensor on device
|
||||||
|
frames = frames.to(device)
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
for i in range(T):
|
||||||
|
start = max(0, i - L + 1)
|
||||||
|
window = frames[start : i + 1] # (len<=L, C, H, W)
|
||||||
|
|
||||||
|
if window.shape[0] < L:
|
||||||
|
pad_needed = L - window.shape[0]
|
||||||
|
pad = window[:1].expand(pad_needed, -1, -1, -1) # repeat first frame
|
||||||
|
window = torch.cat([pad, window], dim=0)
|
||||||
|
|
||||||
|
windows.append(window)
|
||||||
|
|
||||||
|
preds = np.zeros(T, dtype=float)
|
||||||
|
|
||||||
|
for s in range(0, T, batch_size):
|
||||||
|
e = min(s + batch_size, T)
|
||||||
|
batch_windows = torch.stack(windows[s:e]) # (B, L, C, H, W)
|
||||||
|
|
||||||
|
batch = {OBS_IMAGES: batch_windows, OBS_LANGUAGE: [language] * (e - s)} # expects (B, L, C, H, W)
|
||||||
|
|
||||||
|
# Model should return (B, L) or (B,) final-step values. We take the last step.
|
||||||
|
values = model.predict_rewards(batch) # torch.Tensor
|
||||||
|
|
||||||
|
if values.dim() == 2:
|
||||||
|
last = values[:, -1]
|
||||||
|
else:
|
||||||
|
last = values.squeeze(-1)
|
||||||
|
|
||||||
|
preds[s:e] = last.detach().float().cpu().numpy()
|
||||||
|
|
||||||
|
return preds
|
||||||
|
|
||||||
|
|
||||||
|
def plot_episode_eval(episode_idx, gt, pred, language, save_path=None, show=False, title_prefix="RLearN Eval"):
|
||||||
|
"""Plot GT vs Predicted over time. Saves PNG if save_path is provided."""
|
||||||
|
T = len(pred)
|
||||||
|
x = np.arange(T)
|
||||||
|
|
||||||
|
plt.figure(figsize=(14, 8))
|
||||||
|
plt.plot(x, pred, linewidth=2.5, marker="o", markersize=3, label="Predicted Reward", color="blue")
|
||||||
|
|
||||||
|
if gt is not None:
|
||||||
|
plt.plot(x, gt, linestyle="--", linewidth=2.5, label="Ground-Truth Reward", color="orange")
|
||||||
|
# Correlation between GT and Pred
|
||||||
|
corr, p = spearmanr(gt, pred)
|
||||||
|
corr_str = f"ρ(GT, Pred) = {0.0 if np.isnan(corr) else corr:.3f} (p={0.0 if np.isnan(p) else p:.3f})"
|
||||||
|
else:
|
||||||
|
expected = np.linspace(0, 1, T)
|
||||||
|
plt.plot(x, expected, linestyle="--", linewidth=2.5, label="Expected Progress (0→1)", color="orange")
|
||||||
|
corr, p = spearmanr(x, pred)
|
||||||
|
corr_str = f"VOC-S ρ(t, Pred) = {0.0 if np.isnan(corr) else corr:.3f} (p={0.0 if np.isnan(p) else p:.3f})"
|
||||||
|
|
||||||
|
plt.title(f"{title_prefix} — Episode {episode_idx}\n{language}\n{corr_str}", fontsize=14)
|
||||||
|
plt.xlabel("Frame Index", fontsize=12)
|
||||||
|
plt.ylabel("Reward / Progress", fontsize=12)
|
||||||
|
plt.legend(fontsize=11)
|
||||||
|
plt.grid(True, alpha=0.3)
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
if save_path is not None:
|
||||||
|
plt.savefig(save_path, dpi=200, bbox_inches="tight")
|
||||||
|
print(f"Saved eval image to: {save_path}")
|
||||||
|
|
||||||
|
if show:
|
||||||
|
plt.show()
|
||||||
|
else:
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
|
||||||
|
def eval_episode_sliding(
|
||||||
|
episode_idx, dataset, model, save_dir=".", device="cuda", max_seq_len=16, batch_size=64, title_prefix="RLearN Eval"
|
||||||
|
):
|
||||||
|
"""End-to-end: load episode, predict with sliding 16-frame windows, and save PNG."""
|
||||||
|
frames, language, gt = extract_episode_frames_and_gt(dataset, episode_idx)
|
||||||
|
if frames is None:
|
||||||
|
print(f"[Episode {episode_idx}] No frames found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
pred = predict_rewards_sliding(
|
||||||
|
model=model, frames=frames, language=language, max_seq_len=max_seq_len, batch_size=batch_size, device=device
|
||||||
|
)
|
||||||
|
|
||||||
|
# Basic stats
|
||||||
|
print(f"Episode {episode_idx}: T={len(pred)}, pred∈[{pred.min():.3f},{pred.max():.3f}]")
|
||||||
|
if gt is not None:
|
||||||
|
print(f"GT available: gt∈[{np.nanmin(gt):.3f},{np.nanmax(gt):.3f}]")
|
||||||
|
|
||||||
|
save_path = f"{save_dir}/episode_{episode_idx:04d}_eval.png"
|
||||||
|
plot_episode_eval(
|
||||||
|
episode_idx=episode_idx, gt=gt, pred=pred, language=language, save_path=save_path, show=False, title_prefix=title_prefix
|
||||||
|
)
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main evaluation script for RLearN models."""
|
||||||
|
# Parse command line arguments
|
||||||
|
parser = argparse.ArgumentParser(description="Evaluate RLearN model on episodes with GT vs Predicted rewards")
|
||||||
|
parser.add_argument("--model", type=str, required=True, help="Model name/path (e.g., pepijn223/rlearn_mse5)")
|
||||||
|
parser.add_argument("--dataset", type=str, required=True, help="Dataset repo (e.g., pepijn223/phone_pipeline_pickup1)")
|
||||||
|
parser.add_argument("--episodes", type=int, default=5, help="Number of episodes to evaluate")
|
||||||
|
parser.add_argument("--output", type=str, default="./eval_results", help="Output directory for images")
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
type=str,
|
||||||
|
default="cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu",
|
||||||
|
help="Device to use",
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch_size", type=int, default=32, help="Batch size for sliding window evaluation")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("🎯 RLearN Model Evaluation")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Model: {args.model}")
|
||||||
|
print(f"Dataset: {args.dataset}")
|
||||||
|
print(f"Episodes: {args.episodes}")
|
||||||
|
print(f"Device: {args.device}")
|
||||||
|
print(f"Output: {output_dir}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load dataset
|
||||||
|
print("📁 Loading dataset...")
|
||||||
|
|
||||||
|
dataset = LeRobotDataset(
|
||||||
|
repo_id=args.dataset,
|
||||||
|
episodes=list(range(min(args.episodes, 50))), # Load enough episodes
|
||||||
|
download_videos=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✅ Dataset loaded: {dataset.num_episodes} episodes, {dataset.num_frames} frames")
|
||||||
|
print(f" Features: {list(dataset.features.keys())}")
|
||||||
|
print(f" FPS: {dataset.fps}")
|
||||||
|
|
||||||
|
# Load model
|
||||||
|
print("\n🤖 Loading model...")
|
||||||
|
|
||||||
|
model = RLearNPolicy.from_pretrained(args.model)
|
||||||
|
model = model.to(args.device)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
print(f"✅ Model loaded on {args.device}")
|
||||||
|
print(f" Parameters: {sum(p.numel() for p in model.parameters()):,}")
|
||||||
|
print(f" Trainable: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}")
|
||||||
|
print(f" Max sequence length: {model.config.max_seq_len}")
|
||||||
|
|
||||||
|
# Select episodes to evaluate
|
||||||
|
total_available = min(dataset.num_episodes, args.episodes)
|
||||||
|
episode_indices = list(range(total_available))
|
||||||
|
|
||||||
|
print(f"\n📊 Evaluating {len(episode_indices)} episodes...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Run sliding window evaluation on each episode
|
||||||
|
saved_paths = []
|
||||||
|
for i, ep_idx in enumerate(episode_indices):
|
||||||
|
print(f"\n[{i+1}/{len(episode_indices)}] Processing Episode {ep_idx}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
save_path = eval_episode_sliding(
|
||||||
|
episode_idx=ep_idx,
|
||||||
|
dataset=dataset,
|
||||||
|
model=model,
|
||||||
|
save_dir=str(output_dir),
|
||||||
|
device=args.device,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
title_prefix="RLearN Ground Truth vs Predicted",
|
||||||
|
)
|
||||||
|
|
||||||
|
if save_path:
|
||||||
|
saved_paths.append(save_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error processing episode {ep_idx}: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ EVALUATION COMPLETE")
|
||||||
|
print(f"📈 Generated {len(saved_paths)} evaluation plots")
|
||||||
|
print(f"📁 Results saved to: {output_dir}")
|
||||||
|
print("\nGenerated files:")
|
||||||
|
for path in saved_paths:
|
||||||
|
print(f" • {path}")
|
||||||
|
|
||||||
|
if saved_paths:
|
||||||
|
print(f"\n💡 View the plots to compare ground truth vs predicted rewards!")
|
||||||
|
print(f" Each plot shows the model's sliding 16-frame window predictions")
|
||||||
|
print(f" against available ground truth rewards over the episode timeline.")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error during evaluation: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
# Copyright 2025 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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Visualization utilities for RLearN evaluation during training.
|
|
||||||
|
|
||||||
Creates and saves reward prediction visualizations for held-out episodes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
|
||||||
from matplotlib import rcParams
|
|
||||||
from scipy.stats import spearmanr
|
|
||||||
from torch import Tensor
|
|
||||||
|
|
||||||
from lerobot.constants import OBS_IMAGES, OBS_LANGUAGE
|
|
||||||
|
|
||||||
# Set matplotlib backend to avoid GUI issues during training
|
|
||||||
rcParams['backend'] = 'Agg'
|
|
||||||
|
|
||||||
|
|
||||||
class RLearNEvalVisualizer:
|
|
||||||
"""
|
|
||||||
Creates visualization plots for RLearN model evaluation during training.
|
|
||||||
|
|
||||||
Generates reward prediction plots similar to the evaluation notebook but saves
|
|
||||||
them as images for monitoring training progress.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, model, dataset, device: str = "cuda"):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
model: RLearN model instance
|
|
||||||
dataset: LeRobot dataset instance
|
|
||||||
device: Device to run evaluation on
|
|
||||||
"""
|
|
||||||
self.model = model
|
|
||||||
self.dataset = dataset
|
|
||||||
self.device = device
|
|
||||||
|
|
||||||
def get_episode_data(self, episode_idx: int, max_frames: int = 64) -> tuple[Tensor | None, str | None, np.ndarray | None, int | None]:
|
|
||||||
"""Extract frames, language, and predict rewards for an episode."""
|
|
||||||
try:
|
|
||||||
# Get episode data
|
|
||||||
ep_start = self.dataset.episode_data_index["from"][episode_idx].item()
|
|
||||||
ep_end = self.dataset.episode_data_index["to"][episode_idx].item()
|
|
||||||
episode_length = min(ep_end - ep_start, max_frames)
|
|
||||||
|
|
||||||
# Collect frames and get language
|
|
||||||
frames = []
|
|
||||||
language = None
|
|
||||||
|
|
||||||
for frame_idx in range(episode_length):
|
|
||||||
global_idx = ep_start + frame_idx
|
|
||||||
frame_data = self.dataset[global_idx]
|
|
||||||
|
|
||||||
# Extract image
|
|
||||||
if OBS_IMAGES in frame_data:
|
|
||||||
img = frame_data[OBS_IMAGES]
|
|
||||||
else:
|
|
||||||
img_keys = [k for k in frame_data.keys() if "image" in k.lower()]
|
|
||||||
if img_keys:
|
|
||||||
img = frame_data[img_keys[0]]
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if isinstance(img, np.ndarray):
|
|
||||||
img = torch.from_numpy(img)
|
|
||||||
|
|
||||||
# Ensure CHW format
|
|
||||||
if len(img.shape) == 3 and img.shape[-1] in [1, 3, 4]:
|
|
||||||
img = img.permute(2, 0, 1)
|
|
||||||
|
|
||||||
# Resize to expected input size (224x224 for SigLIP2)
|
|
||||||
if img.shape[-2:] != (224, 224):
|
|
||||||
import torch.nn.functional as F
|
|
||||||
img = F.interpolate(
|
|
||||||
img.unsqueeze(0), size=(224, 224), mode="bilinear", align_corners=False
|
|
||||||
).squeeze(0)
|
|
||||||
|
|
||||||
# Normalize to [0, 1] if needed
|
|
||||||
if img.dtype == torch.uint8:
|
|
||||||
img = img.float() / 255.0
|
|
||||||
|
|
||||||
frames.append(img)
|
|
||||||
|
|
||||||
# Get language
|
|
||||||
if language is None:
|
|
||||||
if OBS_LANGUAGE in frame_data:
|
|
||||||
language = frame_data[OBS_LANGUAGE]
|
|
||||||
if isinstance(language, list):
|
|
||||||
language = language[0]
|
|
||||||
elif "task" in frame_data:
|
|
||||||
language = frame_data["task"]
|
|
||||||
else:
|
|
||||||
language = "No language provided"
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
frames_tensor = torch.stack(frames)
|
|
||||||
|
|
||||||
# Predict rewards using the model's evaluation method
|
|
||||||
with torch.no_grad():
|
|
||||||
self.model.eval()
|
|
||||||
rewards = self._predict_episode_rewards(frames_tensor, language)
|
|
||||||
|
|
||||||
return frames_tensor, language, rewards, episode_length
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
warnings.warn(f"Error processing episode {episode_idx}: {e}")
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
@torch.no_grad()
|
|
||||||
def _predict_episode_rewards(self, frames: Tensor, language: str, batch_size: int = 16) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Predict rewards for a single episode using proper temporal sequences.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frames: Video frames tensor of shape (T, C, H, W)
|
|
||||||
language: Language instruction string
|
|
||||||
batch_size: Maximum number of temporal sequences to process at once
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Predicted progress/rewards array of shape (T,)
|
|
||||||
"""
|
|
||||||
T = frames.shape[0]
|
|
||||||
max_seq_len = self.model.config.max_seq_len
|
|
||||||
|
|
||||||
# Create temporal sequences for each frame
|
|
||||||
temporal_sequences = []
|
|
||||||
|
|
||||||
for i in range(T):
|
|
||||||
# Create sequence ending at frame i
|
|
||||||
seq_frames = []
|
|
||||||
for j in range(max(0, i - max_seq_len + 1), i + 1):
|
|
||||||
# Use frame j if available, otherwise repeat the first available frame
|
|
||||||
frame_idx = max(0, min(j, T - 1))
|
|
||||||
seq_frames.append(frames[frame_idx])
|
|
||||||
|
|
||||||
# Pad sequence to max_seq_len by repeating the first frame if needed
|
|
||||||
while len(seq_frames) < max_seq_len:
|
|
||||||
seq_frames.insert(0, seq_frames[0]) # Prepend first frame
|
|
||||||
|
|
||||||
# Take only the last max_seq_len frames if we have too many
|
|
||||||
seq_frames = seq_frames[-max_seq_len:]
|
|
||||||
temporal_sequences.append(torch.stack(seq_frames)) # (max_seq_len, C, H, W)
|
|
||||||
|
|
||||||
# Stack all temporal sequences: (T, max_seq_len, C, H, W)
|
|
||||||
all_sequences = torch.stack(temporal_sequences)
|
|
||||||
|
|
||||||
# Process in batches
|
|
||||||
rewards = []
|
|
||||||
for i in range(0, T, batch_size):
|
|
||||||
end_idx = min(i + batch_size, T)
|
|
||||||
batch_sequences = all_sequences[i:end_idx].to(self.device) # (B, max_seq_len, C, H, W)
|
|
||||||
|
|
||||||
# Create batch for model
|
|
||||||
batch = {
|
|
||||||
OBS_IMAGES: batch_sequences, # (B, T, C, H, W) format expected by model
|
|
||||||
OBS_LANGUAGE: [language] * batch_sequences.shape[0],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Predict rewards - model returns (B, T') but we want the last timestep for each sequence
|
|
||||||
values = self.model.predict_rewards(batch) # (B, T')
|
|
||||||
|
|
||||||
# Take the last timestep prediction for each sequence (represents current frame reward)
|
|
||||||
if values.dim() == 2:
|
|
||||||
batch_rewards = values[:, -1].cpu().numpy() # (B,) - last timestep
|
|
||||||
else:
|
|
||||||
batch_rewards = values.cpu().numpy() # (B,) - already single timestep
|
|
||||||
|
|
||||||
rewards.extend(batch_rewards)
|
|
||||||
|
|
||||||
return np.array(rewards[:T]) # Ensure exact length
|
|
||||||
|
|
||||||
def create_episode_grid_visualization(
|
|
||||||
self,
|
|
||||||
episode_indices: list[int],
|
|
||||||
save_path: Path,
|
|
||||||
step: int | None = None,
|
|
||||||
max_frames: int = 64
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Create a 3x3 grid visualization of episode reward predictions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
episode_indices: List of 9 episode indices to visualize
|
|
||||||
save_path: Path to save the visualization image
|
|
||||||
step: Training step (for title)
|
|
||||||
max_frames: Maximum frames per episode to process
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with evaluation metrics
|
|
||||||
"""
|
|
||||||
if len(episode_indices) != 9:
|
|
||||||
raise ValueError("Expected exactly 9 episode indices for 3x3 grid")
|
|
||||||
|
|
||||||
# Create figure with 3x3 subplots
|
|
||||||
fig, axes = plt.subplots(3, 3, figsize=(20, 16))
|
|
||||||
axes = axes.flatten()
|
|
||||||
|
|
||||||
eval_metrics = {
|
|
||||||
"voc_s_scores": [],
|
|
||||||
"episode_lengths": [],
|
|
||||||
"reward_ranges": [],
|
|
||||||
"languages": []
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, episode_idx in enumerate(episode_indices):
|
|
||||||
ax = axes[i]
|
|
||||||
|
|
||||||
frames, language, rewards, episode_length = self.get_episode_data(episode_idx, max_frames)
|
|
||||||
|
|
||||||
if rewards is None:
|
|
||||||
ax.text(
|
|
||||||
0.5, 0.5, f"Episode {episode_idx}\nNo data available",
|
|
||||||
ha="center", va="center", transform=ax.transAxes,
|
|
||||||
fontsize=12, bbox=dict(boxstyle="round,pad=0.3", facecolor="lightcoral", alpha=0.7)
|
|
||||||
)
|
|
||||||
ax.set_title(f"Episode {episode_idx} - Error", fontsize=12, pad=10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Plot predicted rewards
|
|
||||||
time_steps = range(len(rewards))
|
|
||||||
ax.plot(
|
|
||||||
time_steps, rewards, "b-", linewidth=2.5, marker="o", markersize=5,
|
|
||||||
label="Predicted Reward", alpha=0.8
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add expected progress line (ground truth for ReWiND)
|
|
||||||
expected_progress = np.linspace(0, 1, len(rewards))
|
|
||||||
ax.plot(
|
|
||||||
time_steps, expected_progress, "orange", linestyle="--", linewidth=2.5,
|
|
||||||
label="Expected Progress (0→1)", alpha=0.8
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compute VOC-S (Value-Order Correlation for Success)
|
|
||||||
frame_indices = np.arange(1, len(rewards) + 1)
|
|
||||||
correlation, p_value = spearmanr(frame_indices, rewards)
|
|
||||||
if np.isnan(correlation):
|
|
||||||
correlation = 0.0
|
|
||||||
|
|
||||||
eval_metrics["voc_s_scores"].append(correlation)
|
|
||||||
eval_metrics["episode_lengths"].append(len(rewards))
|
|
||||||
eval_metrics["reward_ranges"].append((rewards.min(), rewards.max()))
|
|
||||||
eval_metrics["languages"].append(language)
|
|
||||||
|
|
||||||
# Format title with language (truncated) and VOC-S
|
|
||||||
title_lang = language[:35] + "..." if len(language) > 35 else language
|
|
||||||
title = f'Episode {episode_idx}\n"{title_lang}"\nVOC-S: {correlation:.3f}'
|
|
||||||
ax.set_title(title, fontsize=10, pad=15)
|
|
||||||
|
|
||||||
ax.set_xlabel("Frame Index", fontsize=10)
|
|
||||||
ax.set_ylabel("Reward", fontsize=10)
|
|
||||||
ax.legend(fontsize=8, loc='upper left')
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# Color-coded trend indicator
|
|
||||||
if correlation > 0.3:
|
|
||||||
trend_text = "↗ Strong+"
|
|
||||||
trend_color = "darkgreen"
|
|
||||||
elif correlation > 0.1:
|
|
||||||
trend_text = "↗ Weak+"
|
|
||||||
trend_color = "green"
|
|
||||||
elif correlation < -0.3:
|
|
||||||
trend_text = "↘ Strong-"
|
|
||||||
trend_color = "darkred"
|
|
||||||
elif correlation < -0.1:
|
|
||||||
trend_text = "↘ Weak-"
|
|
||||||
trend_color = "red"
|
|
||||||
else:
|
|
||||||
trend_text = "→ Flat"
|
|
||||||
trend_color = "gray"
|
|
||||||
|
|
||||||
ax.text(
|
|
||||||
0.02, 0.98, trend_text, transform=ax.transAxes,
|
|
||||||
verticalalignment="top", fontsize=9, fontweight="bold",
|
|
||||||
bbox=dict(boxstyle="round,pad=0.3", facecolor=trend_color, alpha=0.2),
|
|
||||||
color=trend_color
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add reward range info
|
|
||||||
ax.text(
|
|
||||||
0.98, 0.02, f"Range: [{rewards.min():.3f}, {rewards.max():.3f}]",
|
|
||||||
transform=ax.transAxes, ha="right", va="bottom", fontsize=8,
|
|
||||||
bbox=dict(boxstyle="round,pad=0.2", facecolor="lightblue", alpha=0.5)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add overall title
|
|
||||||
step_text = f" - Step {step}" if step is not None else ""
|
|
||||||
fig.suptitle(
|
|
||||||
f"RLearN Reward Evaluation{step_text}\n"
|
|
||||||
f"Mean VOC-S: {np.mean(eval_metrics['voc_s_scores']):.3f} | "
|
|
||||||
f"Episodes: {len([s for s in eval_metrics['voc_s_scores'] if s != 0])}/9",
|
|
||||||
fontsize=16, y=0.95
|
|
||||||
)
|
|
||||||
|
|
||||||
plt.tight_layout()
|
|
||||||
plt.subplots_adjust(top=0.90) # Make room for suptitle
|
|
||||||
|
|
||||||
# Save the figure
|
|
||||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
plt.savefig(save_path, dpi=150, bbox_inches='tight', facecolor='white')
|
|
||||||
plt.close() # Close to free memory
|
|
||||||
|
|
||||||
# Calculate summary metrics
|
|
||||||
valid_scores = [s for s in eval_metrics["voc_s_scores"] if s != 0]
|
|
||||||
summary = {
|
|
||||||
"mean_voc_s": np.mean(valid_scores) if valid_scores else 0.0,
|
|
||||||
"std_voc_s": np.std(valid_scores) if valid_scores else 0.0,
|
|
||||||
"num_valid_episodes": len(valid_scores),
|
|
||||||
"total_episodes": len(episode_indices),
|
|
||||||
"mean_episode_length": np.mean(eval_metrics["episode_lengths"]) if eval_metrics["episode_lengths"] else 0,
|
|
||||||
"individual_scores": eval_metrics["voc_s_scores"],
|
|
||||||
"episode_languages": eval_metrics["languages"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def create_comparison_visualization(
|
|
||||||
self,
|
|
||||||
episode_indices: list[int],
|
|
||||||
save_path: Path,
|
|
||||||
step: int | None = None,
|
|
||||||
max_frames: int = 64,
|
|
||||||
mismatch_templates: list[str] | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Create correct vs incorrect language comparison visualization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
episode_indices: List of episode indices to compare (up to 6)
|
|
||||||
save_path: Path to save the visualization image
|
|
||||||
step: Training step (for title)
|
|
||||||
max_frames: Maximum frames per episode to process
|
|
||||||
mismatch_templates: Custom mismatch templates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with detection metrics
|
|
||||||
"""
|
|
||||||
if mismatch_templates is None:
|
|
||||||
mismatch_templates = [
|
|
||||||
"kick the ball", "clean the sink", "dance in place",
|
|
||||||
"wave your hand", "jump up and down", "do nothing"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Limit to 6 episodes for 2x3 grid
|
|
||||||
episode_indices = episode_indices[:6]
|
|
||||||
n_episodes = len(episode_indices)
|
|
||||||
|
|
||||||
# Create figure with 2x3 subplots
|
|
||||||
fig, axes = plt.subplots(2, 3, figsize=(18, 12))
|
|
||||||
axes = axes.flatten()
|
|
||||||
|
|
||||||
detection_results = {
|
|
||||||
"correct_finals": [],
|
|
||||||
"incorrect_finals": [],
|
|
||||||
"detection_successes": [],
|
|
||||||
"episode_info": []
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, episode_idx in enumerate(episode_indices):
|
|
||||||
if i >= 6: # Limit to 6 subplots
|
|
||||||
break
|
|
||||||
|
|
||||||
ax = axes[i]
|
|
||||||
|
|
||||||
# Get episode data with correct language
|
|
||||||
frames, correct_language, correct_rewards, episode_length = self.get_episode_data(episode_idx, max_frames)
|
|
||||||
|
|
||||||
if correct_rewards is None:
|
|
||||||
ax.text(
|
|
||||||
0.5, 0.5, f"Episode {episode_idx}\nNo data available",
|
|
||||||
ha="center", va="center", transform=ax.transAxes
|
|
||||||
)
|
|
||||||
ax.set_title(f"Episode {episode_idx} - Error")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Generate incorrect language and predict
|
|
||||||
incorrect_language = mismatch_templates[i % len(mismatch_templates)]
|
|
||||||
incorrect_rewards = self._predict_episode_rewards(frames, incorrect_language)
|
|
||||||
|
|
||||||
# Plot both reward curves
|
|
||||||
time_steps = range(len(correct_rewards))
|
|
||||||
ax.plot(
|
|
||||||
time_steps, correct_rewards, "g-", linewidth=2.5, marker="o", markersize=4,
|
|
||||||
label=f"Correct: '{correct_language[:25]}...'" if len(correct_language) > 25 else f"Correct: '{correct_language}'"
|
|
||||||
)
|
|
||||||
ax.plot(
|
|
||||||
time_steps, incorrect_rewards, "r-", linewidth=2.5, marker="s", markersize=4,
|
|
||||||
label=f"Incorrect: '{incorrect_language}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate detection success
|
|
||||||
final_correct = correct_rewards[-1]
|
|
||||||
final_incorrect = incorrect_rewards[-1]
|
|
||||||
detection_success = final_correct > final_incorrect
|
|
||||||
|
|
||||||
detection_results["correct_finals"].append(final_correct)
|
|
||||||
detection_results["incorrect_finals"].append(final_incorrect)
|
|
||||||
detection_results["detection_successes"].append(detection_success)
|
|
||||||
detection_results["episode_info"].append({
|
|
||||||
"episode_idx": episode_idx,
|
|
||||||
"correct_language": correct_language,
|
|
||||||
"incorrect_language": incorrect_language,
|
|
||||||
"final_correct": final_correct,
|
|
||||||
"final_incorrect": final_incorrect
|
|
||||||
})
|
|
||||||
|
|
||||||
# Color-coded title based on detection success
|
|
||||||
success_indicator = "✓" if detection_success else "✗"
|
|
||||||
title_color = "darkgreen" if detection_success else "darkred"
|
|
||||||
ax.set_title(
|
|
||||||
f"Episode {episode_idx} {success_indicator}\nΔ: {final_correct - final_incorrect:.3f}",
|
|
||||||
color=title_color, fontweight="bold", fontsize=11
|
|
||||||
)
|
|
||||||
|
|
||||||
ax.set_xlabel("Frame Index")
|
|
||||||
ax.set_ylabel("Reward")
|
|
||||||
ax.legend(fontsize=8, loc='upper left')
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# Add final reward values as text
|
|
||||||
ax.text(
|
|
||||||
0.98, 0.02,
|
|
||||||
f"Final: C={final_correct:.3f}, I={final_incorrect:.3f}",
|
|
||||||
transform=ax.transAxes, ha="right", va="bottom", fontsize=9,
|
|
||||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow", alpha=0.7)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hide unused subplots
|
|
||||||
for i in range(n_episodes, 6):
|
|
||||||
axes[i].axis('off')
|
|
||||||
|
|
||||||
# Calculate summary metrics
|
|
||||||
detection_accuracy = np.mean(detection_results["detection_successes"]) if detection_results["detection_successes"] else 0.0
|
|
||||||
mean_correct = np.mean(detection_results["correct_finals"]) if detection_results["correct_finals"] else 0.0
|
|
||||||
mean_incorrect = np.mean(detection_results["incorrect_finals"]) if detection_results["incorrect_finals"] else 0.0
|
|
||||||
|
|
||||||
# Add overall title
|
|
||||||
step_text = f" - Step {step}" if step is not None else ""
|
|
||||||
fig.suptitle(
|
|
||||||
f"RLearN Language Detection{step_text}\n"
|
|
||||||
f"Accuracy: {detection_accuracy:.1%} | Mean Δ: {mean_correct - mean_incorrect:.3f} | "
|
|
||||||
f"Success: {sum(detection_results['detection_successes'])}/{len(detection_results['detection_successes'])}",
|
|
||||||
fontsize=16, y=0.95
|
|
||||||
)
|
|
||||||
|
|
||||||
plt.tight_layout()
|
|
||||||
plt.subplots_adjust(top=0.90)
|
|
||||||
|
|
||||||
# Save the figure
|
|
||||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
plt.savefig(save_path, dpi=150, bbox_inches='tight', facecolor='white')
|
|
||||||
plt.close()
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"detection_accuracy": detection_accuracy,
|
|
||||||
"mean_correct_final": mean_correct,
|
|
||||||
"mean_incorrect_final": mean_incorrect,
|
|
||||||
"separation_score": mean_correct - mean_incorrect,
|
|
||||||
"num_episodes": len(detection_results["detection_successes"]),
|
|
||||||
"individual_results": detection_results["episode_info"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
def select_evaluation_episodes(dataset, num_episodes: int = 9, seed: int = 42) -> list[int]:
|
|
||||||
"""
|
|
||||||
Select a diverse set of episodes for evaluation holdout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dataset: LeRobot dataset instance
|
|
||||||
num_episodes: Number of episodes to select
|
|
||||||
seed: Random seed for reproducibility
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of episode indices
|
|
||||||
"""
|
|
||||||
np.random.seed(seed)
|
|
||||||
|
|
||||||
total_episodes = dataset.num_episodes
|
|
||||||
if num_episodes >= total_episodes:
|
|
||||||
return list(range(total_episodes))
|
|
||||||
|
|
||||||
# Select random episodes
|
|
||||||
episode_indices = np.random.choice(total_episodes, num_episodes, replace=False).tolist()
|
|
||||||
|
|
||||||
return sorted(episode_indices)
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
# Copyright 2025 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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Evaluation metrics for RLearn (Video-Language Conditioned Reward Model).
|
|
||||||
|
|
||||||
Key metrics:
|
|
||||||
1. VOC-S (Value-Order Correlation for Success): Spearman correlation between frame indices and predicted rewards
|
|
||||||
2. Success vs Failure Detection: Model's ability to distinguish between correct and incorrect language conditions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
|
||||||
from scipy.stats import spearmanr
|
|
||||||
from torch import Tensor
|
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
from lerobot.constants import OBS_IMAGES, OBS_LANGUAGE
|
|
||||||
|
|
||||||
|
|
||||||
def compute_voc_s(
|
|
||||||
predicted_rewards: list[np.ndarray], use_interquartile_mean: bool = True
|
|
||||||
) -> dict[str, float]:
|
|
||||||
"""
|
|
||||||
Compute Value-Order Correlation for Success (VOC-S).
|
|
||||||
|
|
||||||
Measures whether per-frame rewards increase as successful execution unfolds.
|
|
||||||
For each episode, computes Spearman correlation between frame indices [1..T]
|
|
||||||
and predicted rewards [r1..rT].
|
|
||||||
|
|
||||||
Args:
|
|
||||||
predicted_rewards: List of reward arrays, one per episode. Each array has shape (T,)
|
|
||||||
use_interquartile_mean: If True, use IQM instead of mean for aggregation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with VOC-S metrics:
|
|
||||||
- voc_s_mean: Mean Spearman correlation across episodes
|
|
||||||
- voc_s_std: Standard deviation of correlations
|
|
||||||
- voc_s_iqm: Interquartile mean (if use_interquartile_mean=True)
|
|
||||||
- num_episodes: Number of episodes evaluated
|
|
||||||
- correlations: Individual correlations per episode
|
|
||||||
"""
|
|
||||||
if not predicted_rewards:
|
|
||||||
return {"voc_s_mean": 0.0, "voc_s_std": 0.0, "voc_s_iqm": 0.0, "num_episodes": 0, "correlations": []}
|
|
||||||
|
|
||||||
correlations = []
|
|
||||||
|
|
||||||
for episode_rewards in predicted_rewards:
|
|
||||||
if len(episode_rewards) < 2:
|
|
||||||
# Need at least 2 points for correlation
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Frame indices: [1, 2, ..., T]
|
|
||||||
frame_indices = np.arange(1, len(episode_rewards) + 1)
|
|
||||||
|
|
||||||
# Compute Spearman correlation
|
|
||||||
try:
|
|
||||||
correlation, p_value = spearmanr(frame_indices, episode_rewards)
|
|
||||||
|
|
||||||
# Handle NaN correlations (e.g., all rewards are identical)
|
|
||||||
if np.isnan(correlation):
|
|
||||||
correlation = 0.0
|
|
||||||
|
|
||||||
correlations.append(correlation)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
warnings.warn(f"Failed to compute correlation for episode: {e}")
|
|
||||||
correlations.append(0.0)
|
|
||||||
|
|
||||||
if not correlations:
|
|
||||||
return {"voc_s_mean": 0.0, "voc_s_std": 0.0, "voc_s_iqm": 0.0, "num_episodes": 0, "correlations": []}
|
|
||||||
|
|
||||||
correlations = np.array(correlations)
|
|
||||||
|
|
||||||
# Compute statistics
|
|
||||||
voc_s_mean = float(np.mean(correlations))
|
|
||||||
voc_s_std = float(np.std(correlations))
|
|
||||||
|
|
||||||
# Interquartile mean: mean of values between 25th and 75th percentiles
|
|
||||||
if use_interquartile_mean and len(correlations) >= 4:
|
|
||||||
q25, q75 = np.percentile(correlations, [25, 75])
|
|
||||||
iqm_mask = (correlations >= q25) & (correlations <= q75)
|
|
||||||
voc_s_iqm = float(np.mean(correlations[iqm_mask]))
|
|
||||||
else:
|
|
||||||
voc_s_iqm = voc_s_mean
|
|
||||||
|
|
||||||
return {
|
|
||||||
"voc_s_mean": voc_s_mean,
|
|
||||||
"voc_s_std": voc_s_std,
|
|
||||||
"voc_s_iqm": voc_s_iqm,
|
|
||||||
"num_episodes": len(correlations),
|
|
||||||
"correlations": correlations.tolist(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compute_success_failure_detection(
|
|
||||||
correct_rewards: list[np.ndarray], incorrect_rewards: list[np.ndarray], threshold_percentile: float = 50.0
|
|
||||||
) -> dict[str, float]:
|
|
||||||
"""
|
|
||||||
Compute success vs failure detection accuracy.
|
|
||||||
|
|
||||||
Tests the model's ability to distinguish between correct and incorrect language conditions.
|
|
||||||
For each episode, compares final reward under correct vs incorrect language instruction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
correct_rewards: List of reward arrays for episodes with correct language
|
|
||||||
incorrect_rewards: List of reward arrays for episodes with incorrect/mismatched language
|
|
||||||
threshold_percentile: Percentile of correct rewards to use as threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with detection metrics:
|
|
||||||
- detection_accuracy: Fraction of episodes where correct > incorrect
|
|
||||||
- mean_correct_final: Mean final reward for correct language
|
|
||||||
- mean_incorrect_final: Mean final reward for incorrect language
|
|
||||||
- separation_score: (mean_correct - mean_incorrect) / (std_correct + std_incorrect)
|
|
||||||
- num_pairs: Number of episode pairs evaluated
|
|
||||||
"""
|
|
||||||
if len(correct_rewards) != len(incorrect_rewards):
|
|
||||||
raise ValueError("Must have same number of correct and incorrect reward sequences")
|
|
||||||
|
|
||||||
if not correct_rewards:
|
|
||||||
return {
|
|
||||||
"detection_accuracy": 0.0,
|
|
||||||
"mean_correct_final": 0.0,
|
|
||||||
"mean_incorrect_final": 0.0,
|
|
||||||
"separation_score": 0.0,
|
|
||||||
"num_pairs": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract final rewards (last timestep of each episode)
|
|
||||||
correct_finals = []
|
|
||||||
incorrect_finals = []
|
|
||||||
|
|
||||||
for correct_ep, incorrect_ep in zip(correct_rewards, incorrect_rewards, strict=False):
|
|
||||||
if len(correct_ep) > 0 and len(incorrect_ep) > 0:
|
|
||||||
correct_finals.append(correct_ep[-1]) # Final reward
|
|
||||||
incorrect_finals.append(incorrect_ep[-1]) # Final reward
|
|
||||||
|
|
||||||
if not correct_finals:
|
|
||||||
return {
|
|
||||||
"detection_accuracy": 0.0,
|
|
||||||
"mean_correct_final": 0.0,
|
|
||||||
"mean_incorrect_final": 0.0,
|
|
||||||
"separation_score": 0.0,
|
|
||||||
"num_pairs": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
correct_finals = np.array(correct_finals)
|
|
||||||
incorrect_finals = np.array(incorrect_finals)
|
|
||||||
|
|
||||||
# Detection accuracy: fraction where correct > incorrect
|
|
||||||
detection_accuracy = float(np.mean(correct_finals > incorrect_finals))
|
|
||||||
|
|
||||||
# Statistics
|
|
||||||
mean_correct = float(np.mean(correct_finals))
|
|
||||||
mean_incorrect = float(np.mean(incorrect_finals))
|
|
||||||
std_correct = float(np.std(correct_finals))
|
|
||||||
std_incorrect = float(np.std(incorrect_finals))
|
|
||||||
|
|
||||||
# Separation score: normalized difference (clamp to prevent extreme values)
|
|
||||||
denominator = std_correct + std_incorrect
|
|
||||||
if denominator > 1e-6: # Prevent division by very small numbers
|
|
||||||
separation_score = (mean_correct - mean_incorrect) / denominator
|
|
||||||
# Clamp to reasonable range
|
|
||||||
separation_score = np.clip(separation_score, -100.0, 100.0)
|
|
||||||
else:
|
|
||||||
separation_score = 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"detection_accuracy": detection_accuracy,
|
|
||||||
"mean_correct_final": mean_correct,
|
|
||||||
"mean_incorrect_final": mean_incorrect,
|
|
||||||
"separation_score": float(separation_score),
|
|
||||||
"num_pairs": len(correct_finals),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_mismatched_languages(
|
|
||||||
original_languages: list[str], mismatch_templates: list[str] | None = None
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Generate mismatched language instructions for failure detection evaluation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original_languages: List of original task descriptions
|
|
||||||
mismatch_templates: Custom mismatch templates. If None, uses defaults.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of mismatched language instructions
|
|
||||||
"""
|
|
||||||
if mismatch_templates is None:
|
|
||||||
mismatch_templates = ["kick the ball", "walk to the red shoes", "wave", "do nothing"]
|
|
||||||
|
|
||||||
# For each original language, pick a random mismatch
|
|
||||||
mismatched = []
|
|
||||||
np.random.seed(42) # For reproducibility
|
|
||||||
|
|
||||||
for i, orig_lang in enumerate(original_languages):
|
|
||||||
# Use modulo to cycle through mismatches if we have more episodes than templates
|
|
||||||
mismatch_idx = i % len(mismatch_templates)
|
|
||||||
mismatched.append(mismatch_templates[mismatch_idx])
|
|
||||||
|
|
||||||
return mismatched
|
|
||||||
|
|
||||||
|
|
||||||
class RLearnEvaluator:
|
|
||||||
"""
|
|
||||||
Comprehensive evaluator for RLearN reward models.
|
|
||||||
|
|
||||||
Provides methods to evaluate VOC-S and success/failure detection on datasets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, model, device: str = "cuda"):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
model: RLearN model instance
|
|
||||||
device: Device to run evaluation on
|
|
||||||
"""
|
|
||||||
self.model = model
|
|
||||||
self.device = device
|
|
||||||
self.model.eval()
|
|
||||||
|
|
||||||
@torch.no_grad()
|
|
||||||
def predict_episode_rewards(self, frames: Tensor, language: str, batch_size: int = 16) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Predict rewards for a single episode using proper temporal sequences.
|
|
||||||
|
|
||||||
Note: With ReWiND loss, the model predicts progress values (0-1) across episodes,
|
|
||||||
which serve as dense reward signals for policy learning.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frames: Video frames tensor of shape (T, C, H, W)
|
|
||||||
language: Language instruction string
|
|
||||||
batch_size: Maximum number of temporal sequences to process at once
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Predicted progress/rewards array of shape (T,) with values typically in [0, 1]
|
|
||||||
"""
|
|
||||||
T = frames.shape[0]
|
|
||||||
max_seq_len = self.model.config.max_seq_len
|
|
||||||
|
|
||||||
# Preprocess frames to match model expectations
|
|
||||||
processed_frames = self._preprocess_frames(frames)
|
|
||||||
|
|
||||||
# Create temporal sequences for each frame
|
|
||||||
# For frame i, we want frames [i-max_seq_len+1, ..., i-1, i]
|
|
||||||
temporal_sequences = []
|
|
||||||
|
|
||||||
for i in range(T):
|
|
||||||
# Create sequence ending at frame i
|
|
||||||
seq_frames = []
|
|
||||||
for j in range(max(0, i - max_seq_len + 1), i + 1):
|
|
||||||
# Use frame j if available, otherwise repeat the first available frame
|
|
||||||
frame_idx = max(0, min(j, T - 1))
|
|
||||||
seq_frames.append(processed_frames[frame_idx])
|
|
||||||
|
|
||||||
# Pad sequence to max_seq_len by repeating the first frame if needed
|
|
||||||
while len(seq_frames) < max_seq_len:
|
|
||||||
seq_frames.insert(0, seq_frames[0]) # Prepend first frame
|
|
||||||
|
|
||||||
# Take only the last max_seq_len frames if we have too many
|
|
||||||
seq_frames = seq_frames[-max_seq_len:]
|
|
||||||
|
|
||||||
temporal_sequences.append(torch.stack(seq_frames)) # (max_seq_len, C, H, W)
|
|
||||||
|
|
||||||
# Stack all temporal sequences: (T, max_seq_len, C, H, W)
|
|
||||||
all_sequences = torch.stack(temporal_sequences)
|
|
||||||
|
|
||||||
# Process in batches
|
|
||||||
rewards = []
|
|
||||||
for i in range(0, T, batch_size):
|
|
||||||
end_idx = min(i + batch_size, T)
|
|
||||||
batch_sequences = all_sequences[i:end_idx].to(self.device) # (B, max_seq_len, C, H, W)
|
|
||||||
|
|
||||||
# Create batch for model
|
|
||||||
batch = {
|
|
||||||
OBS_IMAGES: batch_sequences, # (B, T, C, H, W) format expected by model
|
|
||||||
OBS_LANGUAGE: [language] * batch_sequences.shape[0],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Predict rewards - model returns (B, T') but we want the last timestep for each sequence
|
|
||||||
values = self.model.predict_rewards(batch) # (B, T')
|
|
||||||
|
|
||||||
# Take the last timestep prediction for each sequence (represents current frame reward)
|
|
||||||
if values.dim() == 2:
|
|
||||||
batch_rewards = values[:, -1].cpu().numpy() # (B,) - last timestep
|
|
||||||
else:
|
|
||||||
batch_rewards = values.cpu().numpy() # (B,) - already single timestep
|
|
||||||
|
|
||||||
rewards.extend(batch_rewards)
|
|
||||||
|
|
||||||
return np.array(rewards[:T]) # Ensure exact length
|
|
||||||
|
|
||||||
def _preprocess_frames(self, frames: Tensor) -> Tensor:
|
|
||||||
"""
|
|
||||||
Preprocess frames to match model expectations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frames: Input frames tensor of shape (T, C, H, W)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Preprocessed frames tensor of shape (T, C, H', W')
|
|
||||||
"""
|
|
||||||
import torch.nn.functional as F
|
|
||||||
|
|
||||||
T, C, H, W = frames.shape
|
|
||||||
|
|
||||||
# Expected input size for DINO v3 is 224x224
|
|
||||||
target_size = 224
|
|
||||||
|
|
||||||
# Resize frames if needed
|
|
||||||
if H != target_size or W != target_size:
|
|
||||||
# Resize using bilinear interpolation
|
|
||||||
frames = F.interpolate(
|
|
||||||
frames, size=(target_size, target_size), mode="bilinear", align_corners=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize to [0, 1] if needed
|
|
||||||
if frames.dtype == torch.uint8:
|
|
||||||
frames = frames.float() / 255.0
|
|
||||||
|
|
||||||
# Ensure values are in [0, 1] range
|
|
||||||
frames = torch.clamp(frames, 0.0, 1.0)
|
|
||||||
|
|
||||||
return frames
|
|
||||||
|
|
||||||
def evaluate_voc_s(
|
|
||||||
self, dataset, num_episodes: int = 100, use_interquartile_mean: bool = True
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Evaluate VOC-S on a dataset.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dataset: LeRobot dataset instance
|
|
||||||
num_episodes: Number of episodes to evaluate (randomly sampled)
|
|
||||||
use_interquartile_mean: Whether to compute IQM
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
VOC-S evaluation results
|
|
||||||
"""
|
|
||||||
print(f"Evaluating VOC-S on {num_episodes} episodes...")
|
|
||||||
|
|
||||||
# Sample episodes
|
|
||||||
total_episodes = dataset.num_episodes
|
|
||||||
if num_episodes >= total_episodes:
|
|
||||||
episode_indices = list(range(total_episodes))
|
|
||||||
else:
|
|
||||||
np.random.seed(42)
|
|
||||||
episode_indices = np.random.choice(total_episodes, num_episodes, replace=False)
|
|
||||||
|
|
||||||
predicted_rewards = []
|
|
||||||
|
|
||||||
for ep_idx in tqdm(episode_indices, desc="Computing VOC-S"):
|
|
||||||
try:
|
|
||||||
# Get episode data
|
|
||||||
ep_start = dataset.episode_data_index["from"][ep_idx].item()
|
|
||||||
ep_end = dataset.episode_data_index["to"][ep_idx].item()
|
|
||||||
episode_length = ep_end - ep_start
|
|
||||||
|
|
||||||
# Get frames and language for this episode
|
|
||||||
frames = []
|
|
||||||
language = None
|
|
||||||
|
|
||||||
for frame_idx in range(episode_length):
|
|
||||||
global_idx = ep_start + frame_idx
|
|
||||||
frame_data = dataset[global_idx]
|
|
||||||
|
|
||||||
# Extract image (assuming single camera for now)
|
|
||||||
if OBS_IMAGES in frame_data:
|
|
||||||
img = frame_data[OBS_IMAGES]
|
|
||||||
else:
|
|
||||||
# Try to find image key
|
|
||||||
img_keys = [k for k in frame_data.keys() if "image" in k.lower()]
|
|
||||||
if img_keys:
|
|
||||||
img = frame_data[img_keys[0]]
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Convert to tensor if needed
|
|
||||||
if isinstance(img, np.ndarray):
|
|
||||||
img = torch.from_numpy(img)
|
|
||||||
|
|
||||||
# Ensure CHW format
|
|
||||||
if len(img.shape) == 3 and img.shape[-1] in [1, 3, 4]:
|
|
||||||
img = img.permute(2, 0, 1) # HWC -> CHW
|
|
||||||
|
|
||||||
# Resize to expected input size (224x224 for DINO v3) BEFORE stacking
|
|
||||||
if img.shape[-2:] != (224, 224):
|
|
||||||
import torch.nn.functional as F
|
|
||||||
|
|
||||||
img = F.interpolate(
|
|
||||||
img.unsqueeze(0), size=(224, 224), mode="bilinear", align_corners=False
|
|
||||||
).squeeze(0)
|
|
||||||
|
|
||||||
# Normalize to [0, 1] if needed
|
|
||||||
if img.dtype == torch.uint8:
|
|
||||||
img = img.float() / 255.0
|
|
||||||
|
|
||||||
frames.append(img)
|
|
||||||
|
|
||||||
# Get language instruction
|
|
||||||
if language is None:
|
|
||||||
if OBS_LANGUAGE in frame_data:
|
|
||||||
language = frame_data[OBS_LANGUAGE]
|
|
||||||
if isinstance(language, list):
|
|
||||||
language = language[0]
|
|
||||||
elif "task" in frame_data:
|
|
||||||
language = frame_data["task"]
|
|
||||||
else:
|
|
||||||
language = "" # Default empty language
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Stack frames into video tensor
|
|
||||||
frames_tensor = torch.stack(frames) # (T, C, H, W)
|
|
||||||
|
|
||||||
# Predict rewards
|
|
||||||
episode_rewards = self.predict_episode_rewards(frames_tensor, language)
|
|
||||||
predicted_rewards.append(episode_rewards)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
warnings.warn(f"Failed to process episode {ep_idx}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Compute VOC-S
|
|
||||||
voc_results = compute_voc_s(predicted_rewards, use_interquartile_mean)
|
|
||||||
|
|
||||||
print("VOC-S Results:")
|
|
||||||
print(f" Mean correlation: {voc_results['voc_s_mean']:.4f}")
|
|
||||||
print(f" Std correlation: {voc_results['voc_s_std']:.4f}")
|
|
||||||
print(f" IQM correlation: {voc_results['voc_s_iqm']:.4f}")
|
|
||||||
print(f" Episodes evaluated: {voc_results['num_episodes']}")
|
|
||||||
|
|
||||||
return voc_results
|
|
||||||
|
|
||||||
def evaluate_success_failure_detection(
|
|
||||||
self, dataset, num_episodes: int = 100, mismatch_templates: list[str] | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Evaluate success vs failure detection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dataset: LeRobot dataset instance
|
|
||||||
num_episodes: Number of episodes to evaluate
|
|
||||||
mismatch_templates: Custom mismatch language templates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success/failure detection results
|
|
||||||
"""
|
|
||||||
print(f"Evaluating success/failure detection on {num_episodes} episodes...")
|
|
||||||
|
|
||||||
# Sample episodes
|
|
||||||
total_episodes = dataset.num_episodes
|
|
||||||
if num_episodes >= total_episodes:
|
|
||||||
episode_indices = list(range(total_episodes))
|
|
||||||
else:
|
|
||||||
np.random.seed(42)
|
|
||||||
episode_indices = np.random.choice(total_episodes, num_episodes, replace=False)
|
|
||||||
|
|
||||||
correct_rewards = []
|
|
||||||
incorrect_rewards = []
|
|
||||||
|
|
||||||
# Get original languages
|
|
||||||
original_languages = []
|
|
||||||
for ep_idx in episode_indices:
|
|
||||||
ep_start = dataset.episode_data_index["from"][ep_idx].item()
|
|
||||||
frame_data = dataset[ep_start]
|
|
||||||
|
|
||||||
if OBS_LANGUAGE in frame_data:
|
|
||||||
lang = frame_data[OBS_LANGUAGE]
|
|
||||||
if isinstance(lang, list):
|
|
||||||
lang = lang[0]
|
|
||||||
elif "task" in frame_data:
|
|
||||||
lang = frame_data["task"]
|
|
||||||
else:
|
|
||||||
lang = ""
|
|
||||||
|
|
||||||
original_languages.append(lang)
|
|
||||||
|
|
||||||
# Generate mismatched languages
|
|
||||||
mismatched_languages = generate_mismatched_languages(original_languages, mismatch_templates)
|
|
||||||
|
|
||||||
for i, ep_idx in enumerate(tqdm(episode_indices, desc="Computing detection metrics")):
|
|
||||||
try:
|
|
||||||
# Get episode frames (same as VOC-S evaluation)
|
|
||||||
ep_start = dataset.episode_data_index["from"][ep_idx].item()
|
|
||||||
ep_end = dataset.episode_data_index["to"][ep_idx].item()
|
|
||||||
episode_length = ep_end - ep_start
|
|
||||||
|
|
||||||
frames = []
|
|
||||||
for frame_idx in range(episode_length):
|
|
||||||
global_idx = ep_start + frame_idx
|
|
||||||
frame_data = dataset[global_idx]
|
|
||||||
|
|
||||||
# Extract image
|
|
||||||
if OBS_IMAGES in frame_data:
|
|
||||||
img = frame_data[OBS_IMAGES]
|
|
||||||
else:
|
|
||||||
img_keys = [k for k in frame_data.keys() if "image" in k.lower()]
|
|
||||||
if img_keys:
|
|
||||||
img = frame_data[img_keys[0]]
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if isinstance(img, np.ndarray):
|
|
||||||
img = torch.from_numpy(img)
|
|
||||||
|
|
||||||
if len(img.shape) == 3 and img.shape[-1] in [1, 3, 4]:
|
|
||||||
img = img.permute(2, 0, 1)
|
|
||||||
|
|
||||||
# Resize to expected input size (224x224 for DINO v3)
|
|
||||||
if img.shape[-2:] != (224, 224):
|
|
||||||
import torch.nn.functional as F
|
|
||||||
|
|
||||||
img = F.interpolate(
|
|
||||||
img.unsqueeze(0), size=(224, 224), mode="bilinear", align_corners=False
|
|
||||||
).squeeze(0)
|
|
||||||
|
|
||||||
# Normalize to [0, 1] if needed
|
|
||||||
if img.dtype == torch.uint8:
|
|
||||||
img = img.float() / 255.0
|
|
||||||
|
|
||||||
frames.append(img)
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
continue
|
|
||||||
|
|
||||||
frames_tensor = torch.stack(frames)
|
|
||||||
|
|
||||||
# Predict with correct language
|
|
||||||
correct_lang = original_languages[i]
|
|
||||||
correct_ep_rewards = self.predict_episode_rewards(frames_tensor, correct_lang)
|
|
||||||
|
|
||||||
# Predict with incorrect language
|
|
||||||
incorrect_lang = mismatched_languages[i]
|
|
||||||
incorrect_ep_rewards = self.predict_episode_rewards(frames_tensor, incorrect_lang)
|
|
||||||
|
|
||||||
correct_rewards.append(correct_ep_rewards)
|
|
||||||
incorrect_rewards.append(incorrect_ep_rewards)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
warnings.warn(f"Failed to process episode {ep_idx} for detection: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Compute detection metrics
|
|
||||||
detection_results = compute_success_failure_detection(correct_rewards, incorrect_rewards)
|
|
||||||
|
|
||||||
print("Success/Failure Detection Results:")
|
|
||||||
print(f" Detection accuracy: {detection_results['detection_accuracy']:.4f}")
|
|
||||||
print(f" Mean correct final reward: {detection_results['mean_correct_final']:.4f}")
|
|
||||||
print(f" Mean incorrect final reward: {detection_results['mean_incorrect_final']:.4f}")
|
|
||||||
print(f" Separation score: {detection_results['separation_score']:.4f}")
|
|
||||||
print(f" Episode pairs evaluated: {detection_results['num_pairs']}")
|
|
||||||
|
|
||||||
return detection_results
|
|
||||||
|
|
||||||
def evaluate_rewind_progress(
|
|
||||||
self, dataset, num_episodes: int = 100
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Evaluate ReWiND-specific progress properties.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Progress values are in [0, 1] range
|
|
||||||
2. Progress increases monotonically (or mostly)
|
|
||||||
3. First frames have low progress, last frames have high progress
|
|
||||||
"""
|
|
||||||
episodes = np.random.choice(len(dataset.meta.episodes), min(num_episodes, len(dataset.meta.episodes)), replace=False)
|
|
||||||
|
|
||||||
results = {
|
|
||||||
"progress_range_violations": 0,
|
|
||||||
"monotonicity_scores": [],
|
|
||||||
"start_progress_values": [],
|
|
||||||
"end_progress_values": [],
|
|
||||||
"episodes_evaluated": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for ep_idx in episodes:
|
|
||||||
try:
|
|
||||||
# Get episode data
|
|
||||||
ep_start = dataset.episode_data_index["from"][ep_idx].item()
|
|
||||||
ep_end = dataset.episode_data_index["to"][ep_idx].item()
|
|
||||||
|
|
||||||
# Sample some frames from episode
|
|
||||||
sample_indices = np.linspace(ep_start, ep_end-1, min(20, ep_end-ep_start), dtype=int)
|
|
||||||
|
|
||||||
frames = []
|
|
||||||
for idx in sample_indices:
|
|
||||||
item = dataset[idx]
|
|
||||||
if OBS_IMAGES in item:
|
|
||||||
frames.append(item[OBS_IMAGES])
|
|
||||||
elif OBS_IMAGE in item:
|
|
||||||
frames.append(item[OBS_IMAGE])
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if len(frames) < 2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
frames = torch.stack(frames)
|
|
||||||
language = dataset[ep_start].get("task", "")
|
|
||||||
|
|
||||||
# Predict rewards/progress
|
|
||||||
progress = self.predict_episode_rewards(frames, language)
|
|
||||||
|
|
||||||
# Check range violations
|
|
||||||
range_violations = np.sum((progress < 0) | (progress > 1))
|
|
||||||
results["progress_range_violations"] += range_violations
|
|
||||||
|
|
||||||
# Check monotonicity (should generally increase)
|
|
||||||
if len(progress) > 1:
|
|
||||||
diffs = np.diff(progress)
|
|
||||||
monotonicity = np.mean(diffs >= 0) # Fraction of non-decreasing steps
|
|
||||||
results["monotonicity_scores"].append(monotonicity)
|
|
||||||
|
|
||||||
# Record start/end values
|
|
||||||
results["start_progress_values"].append(progress[0])
|
|
||||||
results["end_progress_values"].append(progress[-1])
|
|
||||||
results["episodes_evaluated"] += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error evaluating episode {ep_idx}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Summarize results
|
|
||||||
if results["episodes_evaluated"] > 0:
|
|
||||||
results["mean_monotonicity"] = np.mean(results["monotonicity_scores"])
|
|
||||||
results["mean_start_progress"] = np.mean(results["start_progress_values"])
|
|
||||||
results["mean_end_progress"] = np.mean(results["end_progress_values"])
|
|
||||||
results["progress_increase"] = results["mean_end_progress"] - results["mean_start_progress"]
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def comprehensive_evaluation(
|
|
||||||
self,
|
|
||||||
dataset,
|
|
||||||
num_episodes: int = 100,
|
|
||||||
use_interquartile_mean: bool = True,
|
|
||||||
mismatch_templates: list[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Run comprehensive evaluation including both VOC-S and detection metrics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Combined evaluation results
|
|
||||||
"""
|
|
||||||
print("=" * 60)
|
|
||||||
print("COMPREHENSIVE RLEARN EVALUATION")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# VOC-S evaluation
|
|
||||||
voc_results = self.evaluate_voc_s(
|
|
||||||
dataset, num_episodes=num_episodes, use_interquartile_mean=use_interquartile_mean
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n" + "=" * 40)
|
|
||||||
|
|
||||||
# Success/failure detection
|
|
||||||
detection_results = self.evaluate_success_failure_detection(
|
|
||||||
dataset, num_episodes=num_episodes, mismatch_templates=mismatch_templates
|
|
||||||
)
|
|
||||||
|
|
||||||
# Combined results
|
|
||||||
results = {
|
|
||||||
"voc_s": voc_results,
|
|
||||||
"detection": detection_results,
|
|
||||||
"overall_score": (
|
|
||||||
voc_results["voc_s_iqm"] * 0.6 + detection_results["detection_accuracy"] * 0.4
|
|
||||||
), # Weighted combination
|
|
||||||
}
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(f"OVERALL EVALUATION SCORE: {results['overall_score']:.4f}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
return results
|
|
||||||
@@ -508,7 +508,7 @@ class RLearNPolicy(PreTrainedPolicy):
|
|||||||
all_progress = []
|
all_progress = []
|
||||||
|
|
||||||
# DEBUG: Log indexing details for first sample occasionally
|
# DEBUG: Log indexing details for first sample occasionally
|
||||||
debug_indexing = torch.rand(1).item() < 0.05 # 5% chance
|
debug_indexing = torch.rand(1).item() < 0.10 # 10% chance - increased for debugging
|
||||||
if debug_indexing:
|
if debug_indexing:
|
||||||
print(f"\n=== INDEXING DEBUG ===")
|
print(f"\n=== INDEXING DEBUG ===")
|
||||||
print(f"Delta indices: {delta_indices}")
|
print(f"Delta indices: {delta_indices}")
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ _ Open X-Embodiment (OXE)
|
|||||||
- Test rewind (evaluate) [x]
|
- Test rewind (evaluate) [x]
|
||||||
- benchmark siglip 2 vs this implementation forward pass, debug speed [x]
|
- benchmark siglip 2 vs this implementation forward pass, debug speed [x]
|
||||||
- use siglip 2 [x]
|
- use siglip 2 [x]
|
||||||
|
- Fix evaluation bug !!! []
|
||||||
|
- Fix sample episode padding bug !!! []
|
||||||
- Overfit on one episode []
|
- Overfit on one episode []
|
||||||
- Cleanup code? [] + enable language loss
|
- Cleanup code? [] + enable language loss
|
||||||
- Convert python -m lerobot.datasets.v21.convert_dataset_v20_to_v21 --repo-id=IPEC-COMMUNITY/bc_z_lerobot and train on 1 percent
|
- Convert python -m lerobot.datasets.v21.convert_dataset_v20_to_v21 --repo-id=IPEC-COMMUNITY/bc_z_lerobot and train on 1 percent
|
||||||
@@ -92,6 +94,7 @@ _ Open X-Embodiment (OXE)
|
|||||||
- Add other datasets from OXE metioned in rewind []
|
- Add other datasets from OXE metioned in rewind []
|
||||||
- Extend evaluation []
|
- Extend evaluation []
|
||||||
- Ablation for size vision encoder, language encoder, temporal head []
|
- Ablation for size vision encoder, language encoder, temporal head []
|
||||||
|
- Ablation one mlp head per frame or single mlp head []
|
||||||
- Add other datasets metnioned here []
|
- Add other datasets metnioned here []
|
||||||
- How can we improve spatial aware learning? solve issue of Contrastive learning and position []
|
- How can we improve spatial aware learning? solve issue of Contrastive learning and position []
|
||||||
|
|
||||||
|
|||||||
@@ -213,26 +213,7 @@ def train(cfg: TrainPipelineConfig):
|
|||||||
episode_data_index=episode_data_index,
|
episode_data_index=episode_data_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup RLearN evaluation visualizations if enabled
|
|
||||||
eval_visualizer = None
|
|
||||||
eval_holdout_episodes = None
|
|
||||||
if (getattr(cfg.policy, "type", None) == "rlearn" and
|
|
||||||
getattr(cfg.policy, "enable_eval_visualizations", False)):
|
|
||||||
|
|
||||||
try:
|
|
||||||
from lerobot.policies.rlearn.eval_visualizer import RLearNEvalVisualizer, select_evaluation_episodes
|
|
||||||
|
|
||||||
logging.info("Setting up RLearN evaluation visualizations")
|
|
||||||
eval_visualizer = RLearNEvalVisualizer(policy, dataset, device=str(device))
|
|
||||||
eval_holdout_episodes = select_evaluation_episodes(
|
|
||||||
dataset,
|
|
||||||
num_episodes=getattr(cfg.policy, "eval_holdout_episodes", 9),
|
|
||||||
seed=getattr(cfg.policy, "eval_visualization_seed", 42)
|
|
||||||
)
|
|
||||||
logging.info(f"Selected {len(eval_holdout_episodes)} holdout episodes for evaluation: {eval_holdout_episodes}")
|
|
||||||
except ImportError as e:
|
|
||||||
logging.warning(f"Could not setup RLearN evaluation visualizations: {e}")
|
|
||||||
eval_visualizer = None
|
|
||||||
|
|
||||||
preprocessor, postprocessor = make_processor(
|
preprocessor, postprocessor = make_processor(
|
||||||
policy_cfg=cfg.policy, pretrained_path=cfg.policy.pretrained_path, dataset_stats=dataset.meta.stats
|
policy_cfg=cfg.policy, pretrained_path=cfg.policy.pretrained_path, dataset_stats=dataset.meta.stats
|
||||||
@@ -408,8 +389,7 @@ def train(cfg: TrainPipelineConfig):
|
|||||||
is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0
|
is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0
|
||||||
is_saving_step = step % cfg.save_freq == 0 or step == cfg.steps
|
is_saving_step = step % cfg.save_freq == 0 or step == cfg.steps
|
||||||
is_eval_step = cfg.eval_freq > 0 and step % cfg.eval_freq == 0
|
is_eval_step = cfg.eval_freq > 0 and step % cfg.eval_freq == 0
|
||||||
is_eval_viz_step = (eval_visualizer is not None and
|
|
||||||
step % getattr(cfg.policy, "eval_visualization_freq", 1000) == 0)
|
|
||||||
|
|
||||||
if is_log_step:
|
if is_log_step:
|
||||||
logging.info(train_tracker)
|
logging.info(train_tracker)
|
||||||
@@ -461,86 +441,7 @@ def train(cfg: TrainPipelineConfig):
|
|||||||
wandb_logger.log_dict(wandb_log_dict, step, mode="eval")
|
wandb_logger.log_dict(wandb_log_dict, step, mode="eval")
|
||||||
wandb_logger.log_video(eval_info["video_paths"][0], step, mode="eval")
|
wandb_logger.log_video(eval_info["video_paths"][0], step, mode="eval")
|
||||||
|
|
||||||
# RLearN evaluation visualizations
|
|
||||||
if is_eval_viz_step:
|
|
||||||
logging.info(f"Creating RLearN evaluation visualizations at step {step}")
|
|
||||||
try:
|
|
||||||
with torch.no_grad():
|
|
||||||
policy.eval()
|
|
||||||
|
|
||||||
# Create evaluation visualizations directory
|
|
||||||
eval_viz_dir = cfg.output_dir / "eval_visualizations"
|
|
||||||
eval_viz_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create reward prediction visualization (3x3 grid)
|
|
||||||
reward_viz_path = eval_viz_dir / f"reward_predictions_step_{step:06d}.png"
|
|
||||||
reward_metrics = eval_visualizer.create_episode_grid_visualization(
|
|
||||||
episode_indices=eval_holdout_episodes,
|
|
||||||
save_path=reward_viz_path,
|
|
||||||
step=step,
|
|
||||||
max_frames=getattr(cfg.policy, "eval_max_frames", 128)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log metrics
|
|
||||||
eval_viz_metrics = {
|
|
||||||
"eval_viz/mean_voc_s": reward_metrics["mean_voc_s"],
|
|
||||||
"eval_viz/std_voc_s": reward_metrics["std_voc_s"],
|
|
||||||
"eval_viz/valid_episodes": reward_metrics["num_valid_episodes"],
|
|
||||||
"eval_viz/total_episodes": reward_metrics["total_episodes"],
|
|
||||||
"eval_viz/mean_episode_length": reward_metrics["mean_episode_length"],
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"RLearN Evaluation Results at Step {step}:")
|
|
||||||
logging.info(f" Mean VOC-S: {reward_metrics['mean_voc_s']:.4f} (±{reward_metrics['std_voc_s']:.4f})")
|
|
||||||
logging.info(f" Valid Episodes: {reward_metrics['num_valid_episodes']}/{reward_metrics['total_episodes']}")
|
|
||||||
logging.info(f" Mean Episode Length: {reward_metrics['mean_episode_length']:.1f}")
|
|
||||||
logging.info(f" Visualizations saved to: {eval_viz_dir}")
|
|
||||||
|
|
||||||
if wandb_logger:
|
|
||||||
wandb_logger.log_dict(eval_viz_metrics, step, mode="eval_viz")
|
|
||||||
|
|
||||||
# Log the visualization image both as regular image and as artifact
|
|
||||||
try:
|
|
||||||
import wandb
|
|
||||||
|
|
||||||
# Log as regular image for immediate viewing in wandb UI
|
|
||||||
wandb_logger.wandb_run.log({
|
|
||||||
f"eval_viz/reward_predictions_step_{step}": wandb.Image(str(reward_viz_path)),
|
|
||||||
}, step=step)
|
|
||||||
|
|
||||||
# Create and upload artifact with reward prediction visualization
|
|
||||||
artifact_name = f"rlearn_reward_predictions_step_{step:06d}"
|
|
||||||
artifact = wandb.Artifact(
|
|
||||||
name=artifact_name,
|
|
||||||
type="reward_prediction_visualization",
|
|
||||||
description=f"RLearN reward prediction visualization at training step {step}",
|
|
||||||
metadata={
|
|
||||||
"step": step,
|
|
||||||
"mean_voc_s": reward_metrics["mean_voc_s"],
|
|
||||||
"std_voc_s": reward_metrics["std_voc_s"],
|
|
||||||
"valid_episodes": reward_metrics["num_valid_episodes"],
|
|
||||||
"total_episodes": reward_metrics["total_episodes"],
|
|
||||||
"mean_episode_length": reward_metrics["mean_episode_length"],
|
|
||||||
"holdout_episodes": eval_holdout_episodes,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add reward prediction visualization to the artifact
|
|
||||||
artifact.add_file(str(reward_viz_path), name="reward_predictions.png")
|
|
||||||
|
|
||||||
# Upload the artifact
|
|
||||||
wandb_logger.wandb_run.log_artifact(artifact)
|
|
||||||
|
|
||||||
logging.info(f"Uploaded wandb artifact: {artifact_name}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Could not log visualization image to wandb: {e}")
|
|
||||||
|
|
||||||
policy.train() # Return to training mode
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error during RLearN evaluation visualization: {e}")
|
|
||||||
# Continue training even if evaluation fails
|
|
||||||
|
|
||||||
if eval_env:
|
if eval_env:
|
||||||
eval_env.close()
|
eval_env.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user