Fix GROOT relative action training stats

This commit is contained in:
Andy Wrenn
2026-06-21 03:10:53 -07:00
parent f25b97936e
commit 977e00a4e5
4 changed files with 367 additions and 25 deletions
+92 -14
View File
@@ -581,27 +581,31 @@ def _resolve_visual_modality_keys_from_dataset_meta(dataset_meta: Any | None) ->
return keys or None return keys or None
def _slice_stats_entry(stats: dict[str, Any], indices: list[int]) -> dict[str, list[float]]: def _slice_stats_entry(stats: dict[str, Any], indices: list[int]) -> dict[str, Any]:
if not indices: if not indices:
return {} return {}
max_index = max(indices) max_index = max(indices)
sliced: dict[str, list[float]] = {} sliced: dict[str, Any] = {}
for stat_name, value in stats.items(): for stat_name, value in stats.items():
tensor = torch.as_tensor(value, dtype=torch.float32).flatten() tensor = torch.as_tensor(value, dtype=torch.float32)
if tensor.numel() <= max_index: if tensor.ndim >= 2:
continue if tensor.shape[-1] <= max_index:
sliced[stat_name] = [float(tensor[index].item()) for index in indices] continue
sliced[stat_name] = tensor[..., indices].tolist()
else:
tensor = tensor.flatten()
if tensor.numel() <= max_index:
continue
sliced[stat_name] = [float(tensor[index].item()) for index in indices]
if "min" in sliced and "max" in sliced: if "min" in sliced and "max" in sliced:
min_arr = np.asarray(sliced["min"], dtype=np.float32)
max_arr = np.asarray(sliced["max"], dtype=np.float32)
if "mean" not in sliced: if "mean" not in sliced:
sliced["mean"] = [ sliced["mean"] = ((min_arr + max_arr) * 0.5).tolist()
(low + high) * 0.5 for low, high in zip(sliced["min"], sliced["max"], strict=True)
]
if "std" not in sliced: if "std" not in sliced:
sliced["std"] = [ sliced["std"] = (np.abs(max_arr - min_arr) * 0.5).tolist()
abs(high - low) * 0.5 for low, high in zip(sliced["min"], sliced["max"], strict=True)
]
return sliced return sliced
@@ -887,6 +891,7 @@ def make_groot_pre_post_processors(
clip_outliers=clip_outliers, clip_outliers=clip_outliers,
video_modality_keys=video_modality_keys, video_modality_keys=video_modality_keys,
raw_stats=checkpoint_assets.raw_stats if checkpoint_assets is not None else None, raw_stats=checkpoint_assets.raw_stats if checkpoint_assets is not None else None,
use_percentiles=checkpoint_assets.use_percentiles if checkpoint_assets is not None else False,
modality_config=checkpoint_assets.modality_config if checkpoint_assets is not None else None, modality_config=checkpoint_assets.modality_config if checkpoint_assets is not None else None,
) )
@@ -1179,6 +1184,7 @@ class GrootN17PackInputsStep(ProcessorStep):
normalize_min_max: bool = True normalize_min_max: bool = True
stats: dict[str, dict[str, Any]] | None = None stats: dict[str, dict[str, Any]] | None = None
clip_outliers: bool = True clip_outliers: bool = True
use_percentiles: bool = False
video_modality_keys: list[str] | None = None video_modality_keys: list[str] | None = None
raw_stats: dict[str, Any] | None = None raw_stats: dict[str, Any] | None = None
modality_config: dict[str, Any] | None = None modality_config: dict[str, Any] | None = None
@@ -1327,6 +1333,73 @@ class GrootN17PackInputsStep(ProcessorStep):
return converted return converted
def _normalize_action_groups_for_training(self, action: torch.Tensor) -> torch.Tensor | None:
if self.modality_config is None or self.raw_stats is None:
return None
action_config = self.modality_config.get("action", {})
if not isinstance(action_config, dict):
return None
action_keys = action_config.get("modality_keys", [])
action_configs = action_config.get("action_configs", [])
if not isinstance(action_keys, list) or not isinstance(action_configs, list):
return None
normalized_groups: list[torch.Tensor] = []
start_idx = 0
for idx, key in enumerate(action_keys):
if not isinstance(key, str):
continue
cfg = (
action_configs[idx]
if idx < len(action_configs) and isinstance(action_configs[idx], dict)
else {}
)
is_relative = config_value(cfg.get("rep")) == "relative"
stats_modality = "relative_action" if is_relative else "action"
key_stats = self.raw_stats.get(stats_modality, {}).get(key, {})
dim = stat_dim_from_entry(key_stats) if isinstance(key_stats, dict) else 0
if dim <= 0:
continue
end_idx = start_idx + dim
if end_idx > action.shape[-1]:
return None
min_v, max_v = _n1_7_decode_stats_for_action(
self.raw_stats,
key,
cfg,
use_relative_action=True,
use_percentiles=self.use_percentiles,
)
group = action[..., start_idx:end_idx]
min_t = torch.as_tensor(min_v, dtype=group.dtype, device=group.device)
max_t = torch.as_tensor(max_v, dtype=group.dtype, device=group.device)
if min_t.ndim == 1:
min_t = min_t.view(1, 1, -1)
max_t = max_t.view(1, 1, -1)
elif min_t.ndim == 2:
if group.shape[1] > min_t.shape[0]:
return None
min_t = min_t[: group.shape[1]].unsqueeze(0)
max_t = max_t[: group.shape[1]].unsqueeze(0)
else:
return None
denom = max_t - min_t
mask = denom != 0
safe_denom = torch.where(mask, denom, torch.ones_like(denom))
normalized = torch.where(mask, 2 * (group - min_t) / safe_denom - 1, torch.zeros_like(group))
if self.clip_outliers:
normalized = normalized.clamp(-1.0, 1.0)
normalized_groups.append(normalized)
start_idx = end_idx
if not normalized_groups or start_idx != action.shape[-1]:
return None
return torch.cat(normalized_groups, dim=-1)
def __call__(self, transition: EnvTransition) -> EnvTransition: def __call__(self, transition: EnvTransition) -> EnvTransition:
obs = transition.get(TransitionKey.OBSERVATION, {}) or {} obs = transition.get(TransitionKey.OBSERVATION, {}) or {}
comp = transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) or {} comp = transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) or {}
@@ -1441,8 +1514,12 @@ class GrootN17PackInputsStep(ProcessorStep):
if raw_state_for_action is not None: if raw_state_for_action is not None:
action = self._convert_relative_action_groups_for_training(action, raw_state_for_action) action = self._convert_relative_action_groups_for_training(action, raw_state_for_action)
if self.normalize_min_max: if self.normalize_min_max:
flat = _min_max_norm(action.reshape(bsz * horizon, dim), ACTION) normalized_action = self._normalize_action_groups_for_training(action)
action = flat.view(bsz, horizon, dim) if normalized_action is not None:
action = normalized_action
else:
flat = _min_max_norm(action.reshape(bsz * horizon, dim), ACTION)
action = flat.view(bsz, horizon, dim)
valid_dim = min(dim, self.max_action_dim) valid_dim = min(dim, self.max_action_dim)
valid_horizon = min(horizon, self.valid_action_horizon, self.action_horizon) valid_horizon = min(horizon, self.valid_action_horizon, self.action_horizon)
if dim < self.max_action_dim: if dim < self.max_action_dim:
@@ -1500,6 +1577,7 @@ class GrootN17PackInputsStep(ProcessorStep):
"embodiment_mapping": self.embodiment_mapping, "embodiment_mapping": self.embodiment_mapping,
"normalize_min_max": self.normalize_min_max, "normalize_min_max": self.normalize_min_max,
"clip_outliers": self.clip_outliers, "clip_outliers": self.clip_outliers,
"use_percentiles": self.use_percentiles,
"video_modality_keys": self.video_modality_keys, "video_modality_keys": self.video_modality_keys,
"raw_stats": self.raw_stats, "raw_stats": self.raw_stats,
"modality_config": self.modality_config, "modality_config": self.modality_config,
+7
View File
@@ -111,7 +111,14 @@ def has_modality_stats(stats: dict[str, dict[str, Any]] | None) -> bool:
def stat_dim_from_entry(entry: dict[str, Any]) -> int: def stat_dim_from_entry(entry: dict[str, Any]) -> int:
for stat_name in ("mean", "q01", "min", "max", "std"): for stat_name in ("mean", "q01", "min", "max", "std"):
value = entry.get(stat_name) value = entry.get(stat_name)
if isinstance(value, torch.Tensor):
return int(value.shape[-1]) if value.ndim > 0 else 1
if isinstance(value, np.ndarray):
return int(value.shape[-1]) if value.ndim > 0 else 1
if isinstance(value, list) and len(value) > 0: if isinstance(value, list) and len(value) > 0:
first = value[0]
if isinstance(first, (list, tuple)) and len(first) > 0:
return len(first)
return len(value) return len(value)
return 0 return 0
+85 -7
View File
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from accelerate import Accelerator from accelerate import Accelerator
import numpy as np
import torch import torch
from termcolor import colored from termcolor import colored
from torch.optim import Optimizer from torch.optim import Optimizer
@@ -228,6 +229,63 @@ def _unpadded_relative_action_vectors(relative_action: torch.Tensor, pad_mask: A
return relative_action.reshape(-1, relative_action.shape[-1]) return relative_action.reshape(-1, relative_action.shape[-1])
def _relative_action_chunks_by_horizon(
relative_action: torch.Tensor, pad_mask: Any | None
) -> list[list[np.ndarray]]:
"""Return per-horizon lists of valid relative action vectors."""
if relative_action.ndim == 2:
relative_action = relative_action.unsqueeze(0)
if relative_action.ndim != 3:
raise ValueError(
"Cannot compute horizon-preserving relative action statistics from "
f"shape {tuple(relative_action.shape)}."
)
batch_size, horizon, _action_dim = relative_action.shape
keep = torch.ones(batch_size, horizon, dtype=torch.bool)
if pad_mask is not None:
mask = torch.as_tensor(pad_mask, dtype=torch.bool).cpu()
if mask.ndim == 1 and batch_size == 1 and mask.numel() == horizon:
keep[0] = ~mask
elif mask.ndim == 2 and tuple(mask.shape) == (batch_size, horizon):
keep = ~mask
chunks: list[list[np.ndarray]] = [[] for _ in range(horizon)]
relative_np = relative_action.detach().cpu().numpy()
for batch_idx in range(batch_size):
for horizon_idx in range(horizon):
if keep[batch_idx, horizon_idx]:
chunks[horizon_idx].append(relative_np[batch_idx, horizon_idx])
return chunks
def _compute_horizon_relative_action_stats(chunks_by_horizon: list[list[np.ndarray]]) -> dict[str, np.ndarray]:
if not chunks_by_horizon or not any(chunks_by_horizon):
raise ValueError("Cannot compute relative action statistics without unpadded action vectors.")
stats: dict[str, list[np.ndarray]] = {key: [] for key in ("min", "max", "mean", "std", "q01", "q99")}
counts: list[int] = []
for horizon_idx, vectors in enumerate(chunks_by_horizon):
if len(vectors) < 2:
raise ValueError(
"Cannot compute horizon-preserving relative action statistics from fewer than 2 "
f"unpadded vectors at action timestep {horizon_idx}."
)
values = np.stack(vectors, axis=0).astype(np.float32)
stats["min"].append(np.min(values, axis=0))
stats["max"].append(np.max(values, axis=0))
stats["mean"].append(np.mean(values, axis=0))
stats["std"].append(np.std(values, axis=0))
stats["q01"].append(np.quantile(values, 0.01, axis=0).astype(np.float32))
stats["q99"].append(np.quantile(values, 0.99, axis=0).astype(np.float32))
counts.append(len(vectors))
computed = {key: np.stack(values, axis=0) for key, values in stats.items()}
computed["count"] = np.asarray(counts, dtype=np.int64)
return computed
def _iter_action_state_training_samples(dataset: Any): def _iter_action_state_training_samples(dataset: Any):
"""Yield action chunks, reference states, and action padding masks without decoding videos when possible.""" """Yield action chunks, reference states, and action padding masks without decoding videos when possible."""
@@ -271,6 +329,7 @@ def _make_relative_action_training_stats(
*, *,
exclude_joints: list[str] | None, exclude_joints: list[str] | None,
action_names: list[str] | None, action_names: list[str] | None,
preserve_action_horizon: bool = False,
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Return dataset stats whose action entry describes the relative action tensor used for training.""" """Return dataset stats whose action entry describes the relative action tensor used for training."""
@@ -296,6 +355,7 @@ def _make_relative_action_training_stats(
action_names=action_names, action_names=action_names,
) )
num_vectors = 0 num_vectors = 0
chunks_by_horizon: list[list[np.ndarray]] | None = None
for action_value, state_value, pad_mask in _iter_action_state_training_samples(dataset): for action_value, state_value, pad_mask in _iter_action_state_training_samples(dataset):
action = _to_float_tensor(action_value, key=ACTION) action = _to_float_tensor(action_value, key=ACTION)
@@ -316,19 +376,36 @@ def _make_relative_action_training_stats(
state_batch, state_batch,
relative_step._build_mask(action_batch.shape[-1]), relative_step._build_mask(action_batch.shape[-1]),
) )
vectors = _unpadded_relative_action_vectors(relative_action, pad_mask) if preserve_action_horizon:
if vectors.numel() == 0: sample_chunks = _relative_action_chunks_by_horizon(relative_action, pad_mask)
continue if chunks_by_horizon is None:
vector_count = int(vectors.reshape(-1, vectors.shape[-1]).shape[0]) chunks_by_horizon = [[] for _ in range(len(sample_chunks))]
running_stats.update(vectors.numpy()) if len(sample_chunks) != len(chunks_by_horizon):
num_vectors += vector_count raise ValueError(
"Cannot compute horizon-preserving relative action statistics from samples with "
f"different action horizons ({len(sample_chunks)} vs {len(chunks_by_horizon)})."
)
for horizon_idx, vectors in enumerate(sample_chunks):
chunks_by_horizon[horizon_idx].extend(vectors)
num_vectors += len(vectors)
else:
vectors = _unpadded_relative_action_vectors(relative_action, pad_mask)
if vectors.numel() == 0:
continue
vector_count = int(vectors.reshape(-1, vectors.shape[-1]).shape[0])
running_stats.update(vectors.numpy())
num_vectors += vector_count
if num_vectors < 2: if num_vectors < 2:
raise ValueError( raise ValueError(
"Cannot compute relative action statistics from fewer than 2 unpadded action vectors." "Cannot compute relative action statistics from fewer than 2 unpadded action vectors."
) )
stats[ACTION] = running_stats.get_statistics() stats[ACTION] = (
_compute_horizon_relative_action_stats(chunks_by_horizon or [])
if preserve_action_horizon
else running_stats.get_statistics()
)
return stats return stats
@@ -472,6 +549,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
dataset, dataset,
exclude_joints=getattr(active_cfg, "relative_exclude_joints", []), exclude_joints=getattr(active_cfg, "relative_exclude_joints", []),
action_names=_resolve_action_feature_names(dataset), action_names=_resolve_action_feature_names(dataset),
preserve_action_horizon=getattr(active_cfg, "type", None) == "groot",
) )
processor_kwargs = {} processor_kwargs = {}
+183 -4
View File
@@ -1872,10 +1872,11 @@ def test_groot_n1_7_relative_action_training_processors_save_native_grouped_stat
_RelativeStatsDataset(), _RelativeStatsDataset(),
exclude_joints=["gripper"], exclude_joints=["gripper"],
action_names=action_names, action_names=action_names,
preserve_action_horizon=True,
) )
expected_relative_action_stats = { expected_relative_action_stats = {
"min": torch.tensor([-2.0, -3.0, -4.0, -5.0, -6.0, 0.0]), "min": torch.tensor([-2.0, -3.0, -4.0, -5.0, -6.0, 1.0, 2.0, 3.0, 4.0, 5.0, 0.0]),
"max": torch.tensor([2.0, 3.0, 4.0, 5.0, 6.0, 100.0]), "max": torch.tensor([-1.0, -2.0, -3.0, -4.0, -5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 100.0]),
} }
preprocessor, postprocessor = make_groot_pre_post_processors( preprocessor, postprocessor = make_groot_pre_post_processors(
@@ -1899,7 +1900,10 @@ def test_groot_n1_7_relative_action_training_processors_save_native_grouped_stat
{"rep": "RELATIVE", "type": "NON_EEF", "format": "DEFAULT", "state_key": None}, {"rep": "RELATIVE", "type": "NON_EEF", "format": "DEFAULT", "state_key": None},
{"rep": "ABSOLUTE", "type": "NON_EEF", "format": "DEFAULT", "state_key": None}, {"rep": "ABSOLUTE", "type": "NON_EEF", "format": "DEFAULT", "state_key": None},
] ]
assert pack_config["raw_stats"]["relative_action"]["single_arm"]["min"] == [-2.0, -3.0, -4.0, -5.0, -6.0] assert pack_config["raw_stats"]["relative_action"]["single_arm"]["min"] == [
[-2.0, -3.0, -4.0, -5.0, -6.0],
[1.0, 2.0, 3.0, 4.0, 5.0],
]
assert pack_config["raw_stats"]["action"]["gripper"]["min"] == [0.0] assert pack_config["raw_stats"]["action"]["gripper"]["min"] == [0.0]
assert pack_config["raw_stats"]["action"]["gripper"]["max"] == [100.0] assert pack_config["raw_stats"]["action"]["gripper"]["max"] == [100.0]
@@ -1918,10 +1922,185 @@ def test_groot_n1_7_relative_action_training_processors_save_native_grouped_stat
) )
decode_config = decode_entry["config"] decode_config = decode_entry["config"]
assert decode_config["use_relative_action"] is True assert decode_config["use_relative_action"] is True
assert decode_config["raw_stats"]["relative_action"]["single_arm"]["max"] == [2.0, 3.0, 4.0, 5.0, 6.0] assert decode_config["raw_stats"]["relative_action"]["single_arm"]["max"] == [
[-1.0, -2.0, -3.0, -4.0, -5.0],
[2.0, 3.0, 4.0, 5.0, 6.0],
]
assert decode_config["raw_stats"]["action"]["gripper"]["max"] == [100.0] assert decode_config["raw_stats"]["action"]["gripper"]["max"] == [100.0]
def test_groot_n1_7_generated_relative_stats_match_oss_gr00t_reference_numbers():
input_features, output_features = _groot_features(state_dim=6, action_dim=6)
action_names = [
"shoulder_pan.pos",
"shoulder_lift.pos",
"elbow_flex.pos",
"wrist_flex.pos",
"wrist_roll.pos",
"gripper.pos",
]
config = GrootConfig(
input_features=input_features,
output_features=output_features,
device="cpu",
use_bf16=False,
action_decode_transform=None,
chunk_size=3,
n_action_steps=3,
use_relative_actions=True,
relative_exclude_joints=["gripper"],
)
absolute_dataset_stats = {
OBS_STATE: {
"min": torch.tensor([-20.0, -30.0, -40.0, -50.0, -60.0, 0.0]),
"max": torch.tensor([80.0, 70.0, 60.0, 50.0, 40.0, 100.0]),
"mean": torch.tensor([30.0, 20.0, 10.0, 0.0, -10.0, 50.0]),
"std": torch.tensor([10.0, 10.0, 10.0, 10.0, 10.0, 10.0]),
"q01": torch.tensor([-10.0, -20.0, -30.0, -40.0, -50.0, 10.0]),
"q99": torch.tensor([70.0, 60.0, 50.0, 40.0, 30.0, 90.0]),
},
ACTION: {
"min": torch.tensor([-5.0, -20.0, 0.0, -25.0, 10.0, 20.0]),
"max": torch.tensor([20.0, 30.0, 45.0, 60.0, 70.0, 90.0]),
"mean": torch.tensor([5.0, 5.0, 20.0, 20.0, 40.0, 55.0]),
"std": torch.tensor([5.0, 10.0, 10.0, 20.0, 20.0, 25.0]),
"q01": torch.tensor([-4.0, -19.0, 1.0, -24.0, 11.0, 20.0]),
"q99": torch.tensor([19.0, 29.0, 44.0, 59.0, 69.0, 90.0]),
},
}
state_a = torch.tensor([10.0, 20.0, 30.0, 40.0, 50.0, 25.0])
state_b = torch.tensor([0.0, -10.0, 10.0, -20.0, 20.0, 75.0])
action_a = torch.tensor(
[
[11.0, 22.0, 33.0, 44.0, 55.0, 20.0],
[12.0, 24.0, 36.0, 48.0, 60.0, 80.0],
[13.0, 26.0, 39.0, 52.0, 65.0, 90.0],
]
)
action_b = torch.tensor(
[
[-1.0, -8.0, 13.0, -16.0, 25.0, 30.0],
[-2.0, -6.0, 16.0, -12.0, 30.0, 40.0],
[-3.0, -4.0, 19.0, -8.0, 35.0, 50.0],
]
)
samples = [
{OBS_STATE: state_a, ACTION: action_a},
{OBS_STATE: state_b, ACTION: action_b},
]
class _Dataset:
meta = SimpleNamespace(
stats=absolute_dataset_stats,
features={ACTION: {"names": action_names}},
)
def __len__(self):
return len(samples)
def __getitem__(self, idx):
return samples[idx]
relative_dataset_stats = _make_relative_action_training_stats(
_Dataset(),
exclude_joints=["gripper"],
action_names=action_names,
preserve_action_horizon=True,
)
# Static reference values from OSS GR00T's JointActionChunk.relative_chunking +
# calculate_stats_for_key path: stats are computed per chunk timestep, not
# flattened over all timesteps.
oss_arm_min = torch.tensor(
[
[-1.0, 2.0, 3.0, 4.0, 5.0],
[-2.0, 4.0, 6.0, 8.0, 10.0],
[-3.0, 6.0, 9.0, 12.0, 15.0],
]
)
oss_arm_max = torch.tensor(
[
[1.0, 2.0, 3.0, 4.0, 5.0],
[2.0, 4.0, 6.0, 8.0, 10.0],
[3.0, 6.0, 9.0, 12.0, 15.0],
]
)
oss_arm_mean = torch.tensor(
[
[0.0, 2.0, 3.0, 4.0, 5.0],
[0.0, 4.0, 6.0, 8.0, 10.0],
[0.0, 6.0, 9.0, 12.0, 15.0],
]
)
oss_arm_std = torch.tensor(
[
[1.0, 0.0, 0.0, 0.0, 0.0],
[2.0, 0.0, 0.0, 0.0, 0.0],
[3.0, 0.0, 0.0, 0.0, 0.0],
]
)
oss_arm_q01 = torch.tensor(
[
[-0.98, 2.0, 3.0, 4.0, 5.0],
[-1.96, 4.0, 6.0, 8.0, 10.0],
[-2.94, 6.0, 9.0, 12.0, 15.0],
]
)
oss_arm_q99 = torch.tensor(
[
[0.98, 2.0, 3.0, 4.0, 5.0],
[1.96, 4.0, 6.0, 8.0, 10.0],
[2.94, 6.0, 9.0, 12.0, 15.0],
]
)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["min"][:, :5]), oss_arm_min)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["max"][:, :5]), oss_arm_max)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["mean"][:, :5]), oss_arm_mean)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["std"][:, :5]), oss_arm_std)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["q01"][:, :5]), oss_arm_q01)
torch.testing.assert_close(torch.as_tensor(relative_dataset_stats[ACTION]["q99"][:, :5]), oss_arm_q99)
preprocessor, postprocessor = make_groot_pre_post_processors(
config,
dataset_stats=relative_dataset_stats,
dataset_meta=_Dataset.meta,
)
pack_step = next(step for step in preprocessor.steps if isinstance(step, GrootN17PackInputsStep))
decode_step = next(step for step in postprocessor.steps if isinstance(step, GrootN17ActionDecodeStep))
assert pack_step.use_percentiles is True
torch.testing.assert_close(
torch.as_tensor(pack_step.raw_stats["relative_action"]["single_arm"]["min"]),
oss_arm_min,
)
torch.testing.assert_close(
torch.as_tensor(pack_step.raw_stats["relative_action"]["single_arm"]["q99"]),
oss_arm_q99,
)
assert pack_step.stats[ACTION]["min"] == pytest.approx([*oss_arm_min.flatten().tolist(), 20.0])
assert pack_step.stats[ACTION]["max"] == pytest.approx([*oss_arm_max.flatten().tolist(), 90.0])
packed = pack_step(
{
TransitionKey.OBSERVATION: {OBS_STATE: state_a.unsqueeze(0)},
TransitionKey.ACTION: action_a.unsqueeze(0),
TransitionKey.COMPLEMENTARY_DATA: {"task": ["Move the vial"]},
}
)
expected_normalized = torch.tensor(
[
[1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
[1.0, 0.0, 0.0, 0.0, 0.0, 5.0 / 7.0],
[1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
]
)
torch.testing.assert_close(packed[TransitionKey.ACTION][0, :3, :6], expected_normalized)
decoded = decode_step({TransitionKey.ACTION: packed[TransitionKey.ACTION]})
torch.testing.assert_close(decoded[TransitionKey.ACTION], action_a.unsqueeze(0), atol=1e-5, rtol=1e-5)
def test_groot_policy_selects_n1_7_model_class(monkeypatch): def test_groot_policy_selects_n1_7_model_class(monkeypatch):
from lerobot.policies.groot.groot_n1_7 import GR00TN17 from lerobot.policies.groot.groot_n1_7 import GR00TN17