mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
957 lines
34 KiB
Python
957 lines
34 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2026 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
from torch import nn
|
|
|
|
from lerobot.configs.policies import PreTrainedConfig
|
|
from lerobot.configs.types import FeatureType, PolicyFeature
|
|
from lerobot.policies.factory import get_policy_class, make_policy_config, make_pre_post_processors
|
|
from lerobot.policies.g05.configuration_g05 import G05_CAMERA_PROFILES, G05_EMBODIMENT_MAPPINGS, G05Config
|
|
from lerobot.policies.g05.modeling_g05 import G05_RUNTIME_PREDICT_COT, G05NativeBackend, G05Policy
|
|
from lerobot.processor import PolicyProcessorPipeline
|
|
from lerobot.utils.constants import ACTION, OBS_STATE, POLICY_PREPROCESSOR_DEFAULT_NAME
|
|
|
|
|
|
class TinyG05Backend(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.proj = nn.Linear(20, 20)
|
|
self.last_samples = None
|
|
self.last_runtime_predict_cot = None
|
|
|
|
def predict_action(self, batch):
|
|
self.last_samples = batch["samples"]
|
|
self.last_runtime_predict_cot = batch[G05_RUNTIME_PREDICT_COT]
|
|
state = batch[OBS_STATE]
|
|
if state.ndim == 2:
|
|
state = state.unsqueeze(1)
|
|
step = self.proj(state[:, -1])
|
|
return {
|
|
ACTION: step.unsqueeze(1).expand(-1, 4, -1),
|
|
"ar_action": (step + 1).unsqueeze(1).expand(-1, 4, -1),
|
|
"cot_text": ["Subtask: move carefully"] * step.shape[0],
|
|
}
|
|
|
|
def forward(self, batch):
|
|
prediction = self.proj(batch[OBS_STATE][:, -1])
|
|
target = batch[ACTION][:, 0]
|
|
loss = torch.nn.functional.mse_loss(prediction, target)
|
|
return loss, {"fm_loss": loss.detach()}
|
|
|
|
|
|
class GroupedTinyG05Backend(TinyG05Backend):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.action_scale = nn.Parameter(torch.ones(()))
|
|
self.vision_scale = nn.Parameter(torch.ones(()))
|
|
self.optim_kwargs = None
|
|
|
|
def get_optim_param_groups(
|
|
self,
|
|
lr,
|
|
weight_decay,
|
|
apply_decay_on_norm_and_bias=False,
|
|
backbone_lr_multiplier=1.0,
|
|
vision_lr_multiplier=1.0,
|
|
):
|
|
self.optim_kwargs = {
|
|
"lr": lr,
|
|
"weight_decay": weight_decay,
|
|
"apply_decay_on_norm_and_bias": apply_decay_on_norm_and_bias,
|
|
"backbone_lr_multiplier": backbone_lr_multiplier,
|
|
"vision_lr_multiplier": vision_lr_multiplier,
|
|
}
|
|
return [
|
|
{
|
|
"params": [self.proj.weight, self.proj.bias],
|
|
"lr": lr * backbone_lr_multiplier,
|
|
"weight_decay": weight_decay,
|
|
"name": "backbone_decay",
|
|
},
|
|
{
|
|
"params": [self.action_scale],
|
|
"lr": lr,
|
|
"weight_decay": 0.0,
|
|
"name": "action_no_decay",
|
|
},
|
|
{
|
|
"params": [self.vision_scale],
|
|
"lr": lr * backbone_lr_multiplier * vision_lr_multiplier,
|
|
"weight_decay": 0.0,
|
|
"name": "vision_no_decay",
|
|
},
|
|
]
|
|
|
|
|
|
class TinyLanguageTrainingBackend(G05NativeBackend):
|
|
def __init__(self, *, ce_weight: float, z_loss_scale: float = 0.0):
|
|
nn.Module.__init__(self)
|
|
self.model_config = {
|
|
"ar": {"ce_weight": ce_weight, "ce_z_loss_scale": z_loss_scale},
|
|
"continuous_action": False,
|
|
"discrete_action": True,
|
|
"predict_cot": True,
|
|
}
|
|
self.head = nn.Linear(2, 3, bias=False)
|
|
self.head.weight.data.copy_(
|
|
torch.tensor(
|
|
[
|
|
[1.0, -0.5],
|
|
[-0.25, 0.75],
|
|
[0.5, 0.25],
|
|
]
|
|
)
|
|
)
|
|
self.model = SimpleNamespace(vlm=SimpleNamespace(logits=self.head))
|
|
self.hidden = nn.Parameter(
|
|
torch.tensor(
|
|
[
|
|
[0.5, -0.5],
|
|
[1.0, 0.25],
|
|
[-0.25, 0.75],
|
|
]
|
|
)
|
|
)
|
|
self.processor = SimpleNamespace(
|
|
encode_train=lambda samples, device, action_codec: SimpleNamespace(
|
|
labels=torch.tensor([[-100, 0, 2]], device=device),
|
|
token_types=torch.zeros(1, 3, device=device),
|
|
split_index=3,
|
|
)
|
|
)
|
|
self.action_tokenizer = None
|
|
|
|
def _proprio(self, samples, device):
|
|
return torch.zeros(len(samples), 1, 2, device=device)
|
|
|
|
def _prefill(self, sequence, pixel_values, proprio):
|
|
return (
|
|
self.hidden.unsqueeze(0),
|
|
object(),
|
|
torch.zeros(3, 1, 3, dtype=torch.long),
|
|
)
|
|
|
|
|
|
def _features():
|
|
return {
|
|
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(7,)),
|
|
"observation.images.image": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 8, 8)),
|
|
"observation.images.wrist_image": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 8, 8)),
|
|
}
|
|
|
|
|
|
def _config(**kwargs):
|
|
normalization_mode = kwargs.pop("normalization_mode", "identity")
|
|
return G05Config(
|
|
checkpoint_profile="custom",
|
|
normalization_mode=normalization_mode,
|
|
input_features=_features(),
|
|
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(7,))},
|
|
chunk_size=4,
|
|
n_action_steps=kwargs.pop("n_action_steps", 4),
|
|
device="cpu",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _policy_batch(task: str = " Pick café cup\nverbatim "):
|
|
return {
|
|
OBS_STATE: torch.zeros(1, 1, 20),
|
|
ACTION: torch.zeros(1, 4, 20),
|
|
"observation.images.image": torch.zeros(1, 3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(1, 3, 8, 8),
|
|
"task": [task],
|
|
"proprio_dim_is_pad": torch.zeros(20, dtype=torch.bool),
|
|
}
|
|
|
|
|
|
def test_factory_wiring_is_lazy():
|
|
assert make_policy_config("g05", checkpoint_profile="custom").type == "g05"
|
|
assert get_policy_class("g05") is G05Policy
|
|
|
|
|
|
def test_system2_fm_only_builder_uses_exact_cot_template_without_action_tokens():
|
|
config = _config(
|
|
action_head="flow",
|
|
runtime_system="system2",
|
|
predict_cot=True,
|
|
discrete_action=False,
|
|
continuous_action=True,
|
|
return_continuous_action=True,
|
|
processor_metadata={
|
|
"samples_builder": {
|
|
"_target_": ("g05.data_processor.processor.samples_builder.SubtaskCoTBuilderFMOnly")
|
|
}
|
|
},
|
|
)
|
|
|
|
assert "<prompt_text_!>\n<EOC><atomic_task_text>|Action: <EOV><eos>" in config.prompt_template
|
|
assert "<action_action" not in config.prompt_template
|
|
|
|
|
|
def test_so101_runtime_pads_optional_left_wrist():
|
|
config = G05Config(
|
|
checkpoint_profile="g05-so101",
|
|
embodiment="so100",
|
|
action_head="flow",
|
|
runtime_system="system2",
|
|
predict_cot=True,
|
|
discrete_action=True,
|
|
continuous_action=True,
|
|
return_continuous_action=True,
|
|
policy_action_dim=20,
|
|
policy_state_dim=20,
|
|
raw_action_dim=6,
|
|
raw_state_dim=6,
|
|
chunk_size=32,
|
|
n_action_steps=16,
|
|
normalization_mode="identity",
|
|
camera_order=(
|
|
"observation.images.exterior",
|
|
"observation.images.wrist_left",
|
|
"observation.images.wrist_right",
|
|
),
|
|
camera_sizes={
|
|
"observation.images.exterior": (8, 8),
|
|
"observation.images.wrist_left": (8, 8),
|
|
"observation.images.wrist_right": (8, 8),
|
|
},
|
|
optional_camera_keys=("observation.images.wrist_left",),
|
|
input_features={
|
|
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(6,)),
|
|
"observation.images.exterior": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 8, 8)),
|
|
"observation.images.wrist_right": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 8, 8)),
|
|
},
|
|
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(6,))},
|
|
device="cpu",
|
|
)
|
|
preprocessor, _ = make_pre_post_processors(config)
|
|
|
|
processed = preprocessor(
|
|
{
|
|
OBS_STATE: torch.zeros(6),
|
|
"observation.images.exterior": torch.zeros(3, 8, 8, dtype=torch.uint8),
|
|
"observation.images.wrist_right": torch.zeros(3, 8, 8, dtype=torch.uint8),
|
|
"task": "pick up the cube",
|
|
}
|
|
)
|
|
|
|
assert processed["observation.images.wrist_left"].shape == (1, 3, 8, 8)
|
|
assert torch.all(processed["observation.images.wrist_left"] == -1)
|
|
assert not processed["action_dim_is_pad"][0, 10:16].any()
|
|
|
|
|
|
def test_libero_runtime_executes_ten_step_window_and_binarizes_gripper():
|
|
config = G05Config(
|
|
checkpoint_profile="custom",
|
|
embodiment="libero",
|
|
action_head="flow",
|
|
discrete_action=False,
|
|
continuous_action=True,
|
|
return_continuous_action=True,
|
|
chunk_size=32,
|
|
n_action_steps=10,
|
|
normalization_mode="identity",
|
|
libero_gripper_binarize=True,
|
|
)
|
|
_, postprocessor = make_pre_post_processors(config)
|
|
policy_action = torch.zeros(5, 20)
|
|
policy_action[:, 19] = torch.tensor([0.0, 0.5, 1.0, -0.2, 1.2])
|
|
|
|
env_action = postprocessor(policy_action)
|
|
|
|
torch.testing.assert_close(env_action[:, -1], torch.tensor([1.0, 1.0, -1.0, 1.0, -1.0]))
|
|
|
|
|
|
def test_select_action_discards_tail_beyond_execution_window():
|
|
config = _config(n_action_steps=2)
|
|
policy = G05Policy(config, backend=TinyG05Backend())
|
|
calls = 0
|
|
|
|
def predict_action_chunk(batch, **kwargs):
|
|
nonlocal calls
|
|
calls += 1
|
|
return torch.full((1, 4, 20), float(calls))
|
|
|
|
policy.predict_action_chunk = predict_action_chunk
|
|
batch = _policy_batch()
|
|
|
|
assert policy.select_action(batch)[0, 0].item() == 1
|
|
assert policy.select_action(batch)[0, 0].item() == 1
|
|
assert policy.select_action(batch)[0, 0].item() == 2
|
|
assert calls == 2
|
|
|
|
|
|
def test_libero_projection_mask_and_inverse_roundtrip():
|
|
config = _config()
|
|
preprocessor, postprocessor = make_pre_post_processors(config)
|
|
raw_action = torch.arange(7, dtype=torch.float32).repeat(4, 1)
|
|
batch = {
|
|
OBS_STATE: torch.arange(7, dtype=torch.float32),
|
|
ACTION: raw_action,
|
|
"observation.images.image": torch.zeros(3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(3, 8, 8),
|
|
"task": "test",
|
|
}
|
|
|
|
processed = preprocessor(batch)
|
|
assert processed[OBS_STATE].shape == (1, 20)
|
|
assert processed[ACTION].shape == (4, 20)
|
|
assert processed["action_dim_is_pad"].shape == (1, 20)
|
|
assert processed["action_dim_is_pad"].sum() == 13
|
|
assert torch.equal(processed["action_op_mask"], ~processed["action_dim_is_pad"])
|
|
assert processed["action_parts_meta"] == {
|
|
"left_control": 9,
|
|
"left_gripper": 1,
|
|
"right_control": 9,
|
|
"right_gripper": 1,
|
|
}
|
|
assert torch.equal(processed[ACTION][:, [10, 11, 12, 13, 14, 15, 19]], raw_action)
|
|
restored = postprocessor(processed[ACTION])
|
|
assert torch.equal(restored, raw_action)
|
|
|
|
|
|
def test_inference_without_ground_truth_action_still_emits_action_dimension_mask():
|
|
config = _config()
|
|
preprocessor, _ = make_pre_post_processors(config)
|
|
|
|
processed = preprocessor(
|
|
{
|
|
OBS_STATE: torch.arange(7, dtype=torch.float32),
|
|
"observation.images.image": torch.zeros(3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(3, 8, 8),
|
|
"task": "inference",
|
|
}
|
|
)
|
|
|
|
assert processed["action_dim_is_pad"].shape == (1, 20)
|
|
assert processed["action_dim_is_pad"].sum() == 13
|
|
|
|
|
|
def test_lerobot_libero_two_finger_state_matches_author_first_qpos_contract():
|
|
config = _config()
|
|
preprocessor, _ = make_pre_post_processors(config)
|
|
env_state = torch.arange(8, dtype=torch.float32)
|
|
|
|
processed = preprocessor(
|
|
{
|
|
OBS_STATE: env_state,
|
|
"observation.images.image": torch.zeros(3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(3, 8, 8),
|
|
"task": "libero env",
|
|
}
|
|
)
|
|
|
|
checkpoint_slots = G05_EMBODIMENT_MAPPINGS["libero"]["state"]
|
|
assert torch.equal(processed[OBS_STATE][0, list(checkpoint_slots)], env_state[:7])
|
|
|
|
|
|
def test_quantile_mode_refuses_minmax_substitution():
|
|
config = _config(normalization_mode="q01_q99")
|
|
stats = {
|
|
OBS_STATE: {"min": torch.zeros(7), "max": torch.ones(7)},
|
|
ACTION: {"min": torch.zeros(7), "max": torch.ones(7)},
|
|
}
|
|
with pytest.raises(ValueError, match="real q01/q99"):
|
|
make_pre_post_processors(config, dataset_stats=stats)
|
|
|
|
|
|
def test_checkpoint_normalization_clips_to_author_finite_range():
|
|
config = _config(normalization_mode="q01_q99", normalization_clip=(-5.0, 5.0))
|
|
stats = {
|
|
OBS_STATE: {"q01": torch.zeros(7), "q99": torch.ones(7)},
|
|
ACTION: {"q01": torch.zeros(4, 7), "q99": torch.ones(4, 7)},
|
|
}
|
|
preprocessor, _ = make_pre_post_processors(config, dataset_stats=stats)
|
|
|
|
processed = preprocessor(
|
|
{
|
|
OBS_STATE: torch.full((7,), -100.0),
|
|
ACTION: torch.full((4, 7), 100.0),
|
|
"observation.images.image": torch.zeros(3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(3, 8, 8),
|
|
"task": "clip",
|
|
}
|
|
)
|
|
|
|
assert processed[OBS_STATE].min() == -5
|
|
assert processed[ACTION].max() == 5
|
|
|
|
|
|
def test_stepwise_quantiles_constant_dimension_are_finite_and_serializable(tmp_path: Path):
|
|
config = _config(
|
|
normalization_mode="q01_q99",
|
|
use_stepwise_action_norm=True,
|
|
n_action_steps=2,
|
|
)
|
|
q01_action = torch.zeros(4, 7)
|
|
q99_action = torch.ones(4, 7)
|
|
q99_action[:, 2] = 0
|
|
stats = {
|
|
OBS_STATE: {"q01": torch.zeros(7), "q99": torch.ones(7)},
|
|
ACTION: {"q01": q01_action, "q99": q99_action},
|
|
}
|
|
preprocessor, postprocessor = make_pre_post_processors(config, dataset_stats=stats)
|
|
processed = preprocessor(
|
|
{
|
|
OBS_STATE: torch.zeros(7),
|
|
ACTION: torch.zeros(4, 7),
|
|
"observation.images.image": torch.zeros(3, 8, 8),
|
|
"observation.images.wrist_image": torch.zeros(3, 8, 8),
|
|
"task": "constant",
|
|
}
|
|
)
|
|
assert torch.isfinite(processed[ACTION]).all()
|
|
torch.testing.assert_close(postprocessor(processed[ACTION]), torch.zeros(4, 7))
|
|
|
|
step_q01 = torch.arange(4, dtype=torch.float32).view(4, 1).expand(4, 7)
|
|
step_stats = {
|
|
OBS_STATE: {"q01": torch.zeros(7), "q99": torch.ones(7)},
|
|
ACTION: {"q01": step_q01, "q99": step_q01 + 2},
|
|
}
|
|
_, stepwise_postprocessor = make_pre_post_processors(config, dataset_stats=step_stats)
|
|
normalized_action = torch.zeros(1, config.policy_action_dim)
|
|
torch.testing.assert_close(stepwise_postprocessor(normalized_action), torch.ones(1, 7))
|
|
torch.testing.assert_close(stepwise_postprocessor(normalized_action), torch.full((1, 7), 2.0))
|
|
torch.testing.assert_close(stepwise_postprocessor(normalized_action), torch.ones(1, 7))
|
|
stepwise_postprocessor.reset()
|
|
torch.testing.assert_close(stepwise_postprocessor(normalized_action), torch.ones(1, 7))
|
|
|
|
preprocessor.save_pretrained(tmp_path)
|
|
loaded = PolicyProcessorPipeline.from_pretrained(
|
|
tmp_path, config_filename=f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json"
|
|
)
|
|
assert [step.__class__.__name__ for step in loaded.steps] == [
|
|
step.__class__.__name__ for step in preprocessor.steps
|
|
]
|
|
|
|
|
|
def test_exact_raw_task_reaches_author_command_and_head_selection():
|
|
backend = TinyG05Backend()
|
|
policy = G05Policy(_config(), backend=backend)
|
|
raw_task = " 把 red cup 放到左边\nexactly as written "
|
|
|
|
action, metadata = policy.predict_action_chunk_with_runtime(_policy_batch(), task=raw_task)
|
|
|
|
assert backend.last_samples[0]["command"] == raw_task
|
|
assert backend.last_runtime_predict_cot is False
|
|
assert action.shape == (1, 4, 20)
|
|
assert "cot_text" not in metadata
|
|
|
|
|
|
def test_same_predict_cot_checkpoint_switches_prompt_and_backend_runtime_path():
|
|
backend = TinyG05Backend()
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system2"), backend=backend)
|
|
|
|
_, system1_metadata = policy.predict_action_chunk_with_runtime(
|
|
_policy_batch(),
|
|
task="pick",
|
|
system_mode="system1",
|
|
)
|
|
system1_sample = backend.last_samples[0]
|
|
assert backend.last_runtime_predict_cot is False
|
|
assert "prompt" not in system1_sample
|
|
assert "<atomic_task_text>" not in system1_sample["template"]
|
|
assert "cot_text" not in system1_metadata
|
|
|
|
_, system2_metadata = policy.predict_action_chunk_with_runtime(
|
|
_policy_batch(),
|
|
task="pick",
|
|
system_mode="system2",
|
|
)
|
|
system2_sample = backend.last_samples[0]
|
|
assert backend.last_runtime_predict_cot is True
|
|
assert system2_sample["prompt"] == "predict subtask"
|
|
assert "<atomic_task_text>" in system2_sample["template"]
|
|
assert system2_metadata["cot_text"] == ["Subtask: move carefully"]
|
|
|
|
|
|
def test_system1_config_disables_cot_on_predict_cot_checkpoint_without_override():
|
|
backend = TinyG05Backend()
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system1"), backend=backend)
|
|
|
|
_, metadata = policy.predict_action_chunk_with_runtime(_policy_batch(), task="pick")
|
|
|
|
assert backend.last_runtime_predict_cot is False
|
|
assert "<atomic_task_text>" not in backend.last_samples[0]["template"]
|
|
assert "cot_text" not in metadata
|
|
|
|
|
|
def test_native_backend_uses_per_call_cot_gate_instead_of_checkpoint_default():
|
|
class TinyNativeBackend(G05NativeBackend):
|
|
def __init__(self):
|
|
nn.Module.__init__(self)
|
|
self.model_config = {
|
|
"predict_cot": True,
|
|
"continuous_action": True,
|
|
"discrete_action": False,
|
|
"ar": {"max_new_tokens": 4},
|
|
}
|
|
self.processor = SimpleNamespace(
|
|
encode_inference=lambda samples, device: SimpleNamespace(
|
|
token_types=torch.zeros(len(samples), 1)
|
|
),
|
|
eov_token_id=2,
|
|
decode=lambda ids: "Subtask: pick",
|
|
)
|
|
self.generated = 0
|
|
|
|
def _prefill(self, sequence, pixel_values, proprio):
|
|
batch_size = len(proprio)
|
|
return (
|
|
torch.zeros(batch_size, 1, 4),
|
|
object(),
|
|
torch.zeros(3, batch_size, 1, dtype=torch.long),
|
|
)
|
|
|
|
def _generate_text(self, last_hidden, *, token_types, positions, cache, **kwargs):
|
|
self.generated += 1
|
|
generated = torch.tensor([[1, 2]] * last_hidden.shape[0])
|
|
return generated, cache, last_hidden, token_types, positions
|
|
|
|
def _infer_flow(self, *, token_types, **kwargs):
|
|
return torch.zeros(token_types.shape[0], 4, 20)
|
|
|
|
backend = TinyNativeBackend()
|
|
batch = {
|
|
"samples": [{"proprio": torch.zeros(1, 20)}],
|
|
"pixel_values": {"camera": torch.zeros(1, 1, 3, 8, 8)},
|
|
}
|
|
|
|
system1 = backend.predict_action({**batch, G05_RUNTIME_PREDICT_COT: False})
|
|
assert backend.generated == 0
|
|
assert "cot_text" not in system1
|
|
|
|
system2 = backend.predict_action({**batch, G05_RUNTIME_PREDICT_COT: True})
|
|
assert backend.generated == 1
|
|
assert system2["cot_text"] == ["Subtask: pick"]
|
|
|
|
|
|
def test_native_training_applies_ar_loss_config_and_reaches_language_head():
|
|
ce_weight = 0.25
|
|
z_loss_scale = 0.2
|
|
backend = TinyLanguageTrainingBackend(ce_weight=ce_weight, z_loss_scale=z_loss_scale)
|
|
batch = {
|
|
"samples": [{}],
|
|
"pixel_values": {"camera": torch.zeros(1, 1, 3, 2, 2)},
|
|
}
|
|
|
|
loss, metrics = backend(batch)
|
|
|
|
logits = backend.head(backend.hidden[:2])
|
|
labels = torch.tensor([0, 2])
|
|
expected = (
|
|
ce_weight
|
|
* (
|
|
torch.nn.functional.cross_entropy(logits, labels, reduction="none")
|
|
+ z_loss_scale * torch.logsumexp(logits, dim=-1).square()
|
|
).mean()
|
|
)
|
|
torch.testing.assert_close(loss, expected)
|
|
torch.testing.assert_close(metrics["ce_loss"], expected)
|
|
loss.backward()
|
|
language_head_grad = backend.head.weight.grad
|
|
assert language_head_grad is not None
|
|
assert torch.isfinite(language_head_grad).all()
|
|
assert language_head_grad.abs().sum() > 0
|
|
|
|
disabled = TinyLanguageTrainingBackend(ce_weight=0.0)
|
|
disabled_loss, disabled_metrics = disabled(batch)
|
|
assert disabled_loss.requires_grad
|
|
torch.testing.assert_close(disabled_loss, torch.zeros_like(disabled_loss))
|
|
torch.testing.assert_close(disabled_metrics["ce_loss"], torch.zeros_like(disabled_loss))
|
|
|
|
|
|
def test_author_action_payload_fills_required_tokenizer_metadata():
|
|
policy = G05Policy(_config(), backend=TinyG05Backend())
|
|
|
|
prepared = policy._prepare_author_batch(_policy_batch())
|
|
|
|
assert set(prepared["samples"][0]["action"]) == {
|
|
"value",
|
|
"action_dim_is_pad",
|
|
"action_op_mask",
|
|
"parts_meta",
|
|
}
|
|
|
|
|
|
def test_system2_training_target_is_forwarded_without_replacing_operator_task():
|
|
config = _config(predict_cot=True, runtime_system="system2")
|
|
policy = G05Policy(config, backend=TinyG05Backend())
|
|
batch = _policy_batch(" operator task\n")
|
|
batch["atomic_task"] = ["grasp the cup"]
|
|
|
|
prepared = policy._prepare_author_batch(batch)
|
|
|
|
assert prepared["samples"][0]["command"] == " operator task\n"
|
|
assert prepared["samples"][0]["atomic_task"] == "Subtask: grasp the cup"
|
|
|
|
|
|
def test_system2_recipe_subtask_target_selects_author_template():
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system2"), backend=TinyG05Backend())
|
|
batch = _policy_batch("operator task")
|
|
batch["messages"] = [
|
|
[
|
|
{"role": "user", "content": "operator task"},
|
|
{"role": "assistant", "content": "Subtask: grasp the cup"},
|
|
]
|
|
]
|
|
batch["target_message_indices"] = [[1]]
|
|
|
|
sample = policy._prepare_author_batch(batch)["samples"][0]
|
|
|
|
assert sample["command"] == "operator task"
|
|
assert sample["prompt"] == "predict subtask"
|
|
assert sample["atomic_task"] == "Subtask: grasp the cup"
|
|
assert "<EOC><atomic_task_text>|Action: <EOV><action_action>|<eos>" in sample["template"]
|
|
|
|
|
|
def test_system2_recipe_bbox_and_subtask_use_checkpoint_field_order():
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system2"), backend=TinyG05Backend())
|
|
batch = _policy_batch("operator task")
|
|
batch["messages"] = [
|
|
[
|
|
{"role": "user", "content": "operator task"},
|
|
{
|
|
"role": "assistant",
|
|
"content": (
|
|
'BBoxJSON: {"detections": [{"label": "cup", "bbox_format": "xyxy", '
|
|
'"bbox": [20, 10, 100, 50]}]}'
|
|
),
|
|
},
|
|
{"role": "assistant", "content": "Subtask: grasp the cup"},
|
|
]
|
|
]
|
|
batch["target_message_indices"] = [[1, 2]]
|
|
batch["g05_bbox_image_size"] = (100, 200)
|
|
|
|
sample = policy._prepare_author_batch(batch)["samples"][0]
|
|
|
|
assert sample["prompt"] == "predict bbox, subtask and action"
|
|
assert sample["bbox"] == "BBox: cup <loc0102><loc0102><loc0512><loc0512>"
|
|
assert sample["atomic_task"] == "Subtask: grasp the cup"
|
|
assert "<EOC><bbox_text>|<atomic_task_text>|Action:" in sample["template"]
|
|
|
|
|
|
def test_system2_recipe_no_cot_branch_uses_action_only_training_template():
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system2"), backend=TinyG05Backend())
|
|
batch = _policy_batch("operator task")
|
|
batch["messages"] = [[{"role": "user", "content": "operator task"}]]
|
|
batch["target_message_indices"] = [[]]
|
|
|
|
sample = policy._prepare_author_batch(batch)["samples"][0]
|
|
|
|
assert "prompt" not in sample
|
|
assert "atomic_task" not in sample
|
|
assert "<chat_assistant_prefix>Action: <EOV><EOC><action_action>|<eos>" in sample["template"]
|
|
|
|
|
|
def test_recipe_preprocessor_resolves_lerobot_subtask_and_bbox_annotations():
|
|
pytest.importorskip("datasets", reason="recipe rendering requires lerobot[dataset]")
|
|
config = _config(
|
|
predict_cot=True,
|
|
runtime_system="system2",
|
|
recipe_path="recipes/g05_bbox_subtask.yaml",
|
|
)
|
|
preprocessor, _ = make_pre_post_processors(config)
|
|
policy = G05Policy(config, backend=TinyG05Backend())
|
|
raw = {
|
|
OBS_STATE: torch.zeros(7),
|
|
ACTION: torch.zeros(4, 7),
|
|
"observation.images.image": torch.zeros(3, 100, 200, dtype=torch.uint8),
|
|
"observation.images.wrist_image": torch.zeros(3, 100, 200, dtype=torch.uint8),
|
|
"task": "operator task",
|
|
"timestamp": torch.tensor(0.0),
|
|
"language_persistent": [
|
|
{
|
|
"role": "assistant",
|
|
"content": "grasp the cup",
|
|
"style": "subtask",
|
|
"timestamp": 0.0,
|
|
"camera": None,
|
|
"tool_calls": None,
|
|
}
|
|
],
|
|
"language_events": [
|
|
{
|
|
"role": "assistant",
|
|
"content": (
|
|
'{"detections": [{"label": "cup", "bbox_format": "xyxy", "bbox": [20, 10, 100, 50]}]}'
|
|
),
|
|
"style": "vqa",
|
|
"camera": "observation.images.exterior",
|
|
"tool_calls": None,
|
|
}
|
|
],
|
|
}
|
|
|
|
processed = next(
|
|
candidate
|
|
for sample_index in range(100)
|
|
if (candidate := preprocessor({**raw, "index": torch.tensor(sample_index)}))["target_message_indices"]
|
|
== [[1, 2]]
|
|
)
|
|
sample = policy._prepare_author_batch(processed)["samples"][0]
|
|
|
|
assert "language_persistent" not in processed
|
|
assert "language_events" not in processed
|
|
assert sample["bbox"] == "BBox: cup <loc0102><loc0102><loc0512><loc0512>"
|
|
assert sample["atomic_task"] == "Subtask: grasp the cup"
|
|
assert "<EOC><bbox_text>|<atomic_task_text>|Action:" in sample["template"]
|
|
|
|
|
|
def test_author_inference_payload_synthesizes_required_dummy_action():
|
|
policy = G05Policy(_config(), backend=TinyG05Backend())
|
|
batch = _policy_batch()
|
|
del batch[ACTION]
|
|
|
|
prepared = policy._prepare_author_batch(batch)
|
|
|
|
assert prepared["samples"][0]["action"]["value"].shape == (4, 20)
|
|
|
|
|
|
def test_policy_to_moves_non_module_action_tokenizer_sidecar():
|
|
class TrackingTokenizer:
|
|
device = None
|
|
|
|
def to(self, device):
|
|
self.device = device
|
|
|
|
backend = TinyG05Backend()
|
|
backend.action_tokenizer = TrackingTokenizer()
|
|
policy = G05Policy(_config(), backend=backend).to("cpu")
|
|
|
|
assert backend.action_tokenizer.device == next(policy.parameters()).device
|
|
|
|
|
|
def test_author_inference_precision_preserves_declared_fp32_parameters():
|
|
class MixedPrecisionBackend(TinyG05Backend):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.bulk_weight = nn.Parameter(torch.ones(2))
|
|
self.precision_weight = nn.Parameter(torch.ones(2))
|
|
|
|
def apply_fp32_params(self):
|
|
self.precision_weight.data = self.precision_weight.data.float()
|
|
|
|
backend = MixedPrecisionBackend()
|
|
policy = G05Policy(_config(), backend=backend)
|
|
|
|
policy._apply_author_inference_precision()
|
|
|
|
assert backend.bulk_weight.dtype is torch.bfloat16
|
|
assert backend.precision_weight.dtype is torch.float32
|
|
|
|
|
|
def test_system2_precision_keeps_tied_lm_head_compatible_with_fp32_final_norm():
|
|
class CoTPrecisionBackend(TinyG05Backend):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.model = nn.Module()
|
|
self.model.vlm = nn.Module()
|
|
self.model.vlm.input_proj = nn.Embedding(8, 4)
|
|
self.model.vlm.output_proj = nn.Linear(4, 8, bias=False)
|
|
self.model.vlm.output_proj.weight = self.model.vlm.input_proj.weight
|
|
|
|
def apply_fp32_params(self):
|
|
pass
|
|
|
|
backend = CoTPrecisionBackend()
|
|
policy = G05Policy(_config(predict_cot=True, runtime_system="system2"), backend=backend)
|
|
|
|
policy._apply_author_inference_precision()
|
|
|
|
assert backend.model.vlm.output_proj.weight.dtype is torch.float32
|
|
assert backend.model.vlm.input_proj.weight is backend.model.vlm.output_proj.weight
|
|
|
|
|
|
def test_batch_two_preserves_each_raw_task_and_every_camera_slot():
|
|
backend = TinyG05Backend()
|
|
policy = G05Policy(_config(), backend=backend)
|
|
batch = _policy_batch()
|
|
batch[OBS_STATE] = batch[OBS_STATE].expand(2, -1, -1)
|
|
batch[ACTION] = batch[ACTION].expand(2, -1, -1)
|
|
batch["observation.images.image"] = batch["observation.images.image"].expand(2, -1, -1, -1)
|
|
batch["observation.images.wrist_image"] = batch["observation.images.wrist_image"].expand(2, -1, -1, -1)
|
|
batch["proprio_dim_is_pad"] = torch.zeros(2, 20, dtype=torch.bool)
|
|
batch["task"] = [" first\n", "第二个 task"]
|
|
|
|
action = policy.predict_action_chunk(batch)
|
|
|
|
assert action.shape == (2, 4, 20)
|
|
assert [sample["command"] for sample in backend.last_samples] == batch["task"]
|
|
assert all(sample["image0"] == (224, 224) for sample in backend.last_samples)
|
|
assert all(sample["image1"] == (224, 224) for sample in backend.last_samples)
|
|
|
|
|
|
def test_forward_backward_update_and_save_reload(tmp_path: Path):
|
|
policy = G05Policy(_config(), backend=TinyG05Backend())
|
|
optimizer = torch.optim.AdamW(policy.get_optim_params(), lr=1e-3)
|
|
loss, metrics = policy(_policy_batch("train"))
|
|
loss.backward()
|
|
grad_norm = torch.stack(
|
|
[parameter.grad.norm() for parameter in policy.parameters() if parameter.grad is not None]
|
|
).sum()
|
|
assert torch.isfinite(loss)
|
|
assert grad_norm > 0 and torch.isfinite(grad_norm)
|
|
optimizer.step()
|
|
assert metrics is not None and metrics["fm_loss"] >= 0
|
|
|
|
policy.save_pretrained(tmp_path)
|
|
reloaded = G05Policy.from_pretrained(
|
|
tmp_path, backend=TinyG05Backend(), local_files_only=True, strict=True
|
|
)
|
|
expected = policy.predict_action_chunk(_policy_batch("save"))
|
|
actual = reloaded.predict_action_chunk(_policy_batch("save"))
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
|
|
def test_training_forward_uses_policy_autocast_context(monkeypatch):
|
|
policy = G05Policy(_config(), backend=TinyG05Backend())
|
|
autocast_calls = []
|
|
|
|
class AutocastContext:
|
|
def __enter__(self):
|
|
return None
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
return False
|
|
|
|
def track_autocast(**kwargs):
|
|
autocast_calls.append(kwargs)
|
|
return AutocastContext()
|
|
|
|
monkeypatch.setattr(torch, "autocast", track_autocast)
|
|
|
|
policy(_policy_batch("train"))
|
|
|
|
assert autocast_calls == [{"device_type": "cpu", "dtype": torch.bfloat16, "enabled": False}]
|
|
|
|
|
|
def test_save_pretrained_copies_required_gated_sidecars_portably(tmp_path: Path):
|
|
source = tmp_path / "checkpoint"
|
|
processor = source / "hf_processor"
|
|
processor.mkdir(parents=True)
|
|
(processor / "tokenizer.json").write_text("{}")
|
|
tokenizer = source / "action_tokenizer.pt"
|
|
torch.save({"codec": "ActionCodec"}, tokenizer)
|
|
for name in ("LICENSE-G0.5", "NOTICE"):
|
|
(source / name).write_text("{}")
|
|
config = _config(
|
|
author_model_config={
|
|
"hf_processor_path": str(processor),
|
|
"AT_CONFIG": {"ckpt_dir": str(tokenizer)},
|
|
}
|
|
)
|
|
output = tmp_path / "saved"
|
|
|
|
G05Policy(config, backend=TinyG05Backend()).save_pretrained(output)
|
|
|
|
assert (output / "hf_processor" / "tokenizer.json").is_file()
|
|
assert (output / "action_tokenizer.pt").is_file()
|
|
assert (output / "LICENSE-G0.5").is_file()
|
|
loaded_config = PreTrainedConfig.from_pretrained(output)
|
|
assert isinstance(loaded_config, G05Config)
|
|
assert loaded_config.author_model_config["hf_processor_path"] == "hf_processor"
|
|
assert loaded_config.author_model_config["AT_CONFIG"]["ckpt_dir"] == "action_tokenizer.pt"
|
|
|
|
|
|
def test_tiny_fixed_batch_overfit_reduces_loss():
|
|
policy = G05Policy(_config(), backend=TinyG05Backend())
|
|
optimizer = torch.optim.AdamW(policy.get_optim_params(), lr=5e-2)
|
|
batch = _policy_batch("overfit")
|
|
initial = policy(batch)[0].item()
|
|
for _ in range(20):
|
|
optimizer.zero_grad()
|
|
loss, _ = policy(batch)
|
|
loss.backward()
|
|
optimizer.step()
|
|
final = policy(batch)[0].item()
|
|
assert final < initial * 0.25
|
|
|
|
|
|
def test_training_preset_uses_author_optimizer_parameter_groups():
|
|
config = _config(
|
|
optimizer_lr=2e-4,
|
|
optimizer_weight_decay=0.03,
|
|
optimizer_backbone_lr_multiplier=0.5,
|
|
optimizer_vision_lr_multiplier=0.2,
|
|
optimizer_apply_decay_on_norm_and_bias=True,
|
|
)
|
|
backend = GroupedTinyG05Backend()
|
|
policy = G05Policy(config, backend=backend)
|
|
|
|
optimizer = config.get_optimizer_preset().build(policy.get_optim_params())
|
|
|
|
assert backend.optim_kwargs == {
|
|
"lr": 2e-4,
|
|
"weight_decay": 0.03,
|
|
"apply_decay_on_norm_and_bias": True,
|
|
"backbone_lr_multiplier": 0.5,
|
|
"vision_lr_multiplier": 0.2,
|
|
}
|
|
assert [group["name"] for group in optimizer.param_groups] == [
|
|
"backbone_decay",
|
|
"action_no_decay",
|
|
"vision_no_decay",
|
|
]
|
|
assert [group["lr"] for group in optimizer.param_groups] == pytest.approx([1e-4, 2e-4, 2e-5])
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not os.environ.get("LEROBOT_G05_CHECKPOINT"),
|
|
reason="requires an accepted gated OpenGalaxea/G05 checkpoint and author CUDA environment",
|
|
)
|
|
def test_gated_checkpoint_loads_strictly():
|
|
checkpoint = Path(os.environ["LEROBOT_G05_CHECKPOINT"])
|
|
policy = G05Policy.from_pretrained(checkpoint, local_files_only=True, strict=True)
|
|
assert policy.config.source_checkpoint_revision
|
|
|
|
|
|
def test_project_stats_passes_dataset_count_through():
|
|
config = _config(normalization_mode="q01_q99")
|
|
stats = {
|
|
OBS_STATE: {"q01": torch.zeros(7), "q99": torch.ones(7), "count": torch.tensor([100])},
|
|
ACTION: {"q01": torch.zeros(7), "q99": torch.ones(7), "count": torch.tensor([100])},
|
|
}
|
|
|
|
make_pre_post_processors(config, dataset_stats=stats)
|
|
|
|
|
|
def test_named_embodiment_rebuilds_stale_camera_sizes():
|
|
config = G05Config(
|
|
checkpoint_profile="custom",
|
|
embodiment="robotwin",
|
|
raw_state_dim=14,
|
|
raw_action_dim=14,
|
|
camera_order=G05_CAMERA_PROFILES["robotwin"],
|
|
camera_sizes={
|
|
**dict.fromkeys(G05_CAMERA_PROFILES["robotwin"], (256, 256)),
|
|
"observation.images.stale_camera": (256, 256),
|
|
},
|
|
device="cpu",
|
|
)
|
|
|
|
assert set(config.camera_sizes) == set(G05_CAMERA_PROFILES["robotwin"])
|