mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-07 10:01:56 +00:00
feat(pi05): implement Classifier-Free Guidance (CFG) inference
Add dual-path denoising with configurable cfg_beta scale for language- conditioned action generation. When cfg_beta > 1.0, VLM prefills both conditioned and unconditional prompts, and action expert velocities are interpolated via v = v_uncond + β*(v_cond - v_uncond).
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Tests for PI05 Classifier-Free Guidance (CFG) inference."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("transformers", reason="transformers is required for PI05")
|
||||
|
||||
import torch # noqa: E402
|
||||
|
||||
from lerobot.configs.types import FeatureType, PolicyFeature # noqa: E402
|
||||
from lerobot.policies.pi05 import PI05Config, make_pi05_pre_post_processors # noqa: E402
|
||||
from lerobot.processor.converters import create_transition # noqa: E402
|
||||
from lerobot.processor.rendered_messages_to_task import RenderedMessagesToTaskStep # noqa: E402
|
||||
from lerobot.types import TransitionKey # noqa: E402
|
||||
from lerobot.utils.constants import ( # noqa: E402
|
||||
OBS_LANGUAGE_ATTENTION_MASK,
|
||||
OBS_LANGUAGE_TOKENS,
|
||||
OBS_LANGUAGE_UNCOND_ATTENTION_MASK,
|
||||
OBS_LANGUAGE_UNCOND_TOKENS,
|
||||
)
|
||||
|
||||
|
||||
class TestRenderedMessagesToTaskBaseTaskPreservation:
|
||||
"""Tests that RenderedMessagesToTaskStep preserves base_task for CFG."""
|
||||
|
||||
def test_preserves_string_base_task(self):
|
||||
transition = create_transition(
|
||||
complementary_data={
|
||||
"task": "pick up the cup",
|
||||
"messages": [
|
||||
{"role": "user", "content": "pick up the cup, Advantage: positive"},
|
||||
],
|
||||
}
|
||||
)
|
||||
step = RenderedMessagesToTaskStep()
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
assert data["base_task"] == "pick up the cup"
|
||||
assert data["task"] == "pick up the cup, Advantage: positive"
|
||||
|
||||
def test_preserves_list_base_task(self):
|
||||
transition = create_transition(
|
||||
complementary_data={
|
||||
"task": ["task1", "task2"],
|
||||
"messages": [
|
||||
{"role": "user", "content": "rendered with advantage"},
|
||||
],
|
||||
}
|
||||
)
|
||||
step = RenderedMessagesToTaskStep()
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
assert data["base_task"] == ["task1", "task2"]
|
||||
|
||||
def test_no_base_task_when_messages_absent(self):
|
||||
transition = create_transition(complementary_data={"task": "pick up the cup"})
|
||||
step = RenderedMessagesToTaskStep()
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
assert "base_task" not in data
|
||||
|
||||
|
||||
class TestPi05PrepareStateTokenizerCfg:
|
||||
"""Tests for Pi05PrepareStateTokenizerProcessorStep with cfg_enabled."""
|
||||
|
||||
def _make_transition(self, task, base_task=None):
|
||||
complementary_data = {"task": task}
|
||||
if base_task is not None:
|
||||
complementary_data["base_task"] = base_task
|
||||
return create_transition(
|
||||
observation={"observation.state": torch.zeros(1, 14)},
|
||||
complementary_data=complementary_data,
|
||||
)
|
||||
|
||||
def test_cfg_disabled_no_uncond_task(self):
|
||||
from lerobot.policies.pi05.processor_pi05 import Pi05PrepareStateTokenizerProcessorStep
|
||||
|
||||
step = Pi05PrepareStateTokenizerProcessorStep(max_state_dim=14, cfg_enabled=False)
|
||||
transition = self._make_transition(task=["pick up the cup, Advantage: positive"])
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
assert "uncond_task" not in data
|
||||
|
||||
def test_cfg_enabled_produces_uncond_task_from_base(self):
|
||||
from lerobot.policies.pi05.processor_pi05 import Pi05PrepareStateTokenizerProcessorStep
|
||||
|
||||
step = Pi05PrepareStateTokenizerProcessorStep(max_state_dim=14, cfg_enabled=True)
|
||||
transition = self._make_transition(
|
||||
task=["pick up the cup, Advantage: positive"],
|
||||
base_task=["pick up the cup"],
|
||||
)
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
assert "uncond_task" in data
|
||||
assert len(data["uncond_task"]) == 1
|
||||
# Unconditional prompt uses base_task (no advantage)
|
||||
assert "Advantage" not in data["uncond_task"][0]
|
||||
assert "pick up the cup" in data["uncond_task"][0]
|
||||
assert "State:" in data["uncond_task"][0]
|
||||
|
||||
def test_cfg_enabled_falls_back_to_task_when_no_base(self):
|
||||
from lerobot.policies.pi05.processor_pi05 import Pi05PrepareStateTokenizerProcessorStep
|
||||
|
||||
step = Pi05PrepareStateTokenizerProcessorStep(max_state_dim=14, cfg_enabled=True)
|
||||
transition = self._make_transition(task=["pick up the cup"])
|
||||
out = step(transition)
|
||||
data = out[TransitionKey.COMPLEMENTARY_DATA]
|
||||
|
||||
# Falls back to using task itself as unconditional
|
||||
assert "uncond_task" in data
|
||||
assert "pick up the cup" in data["uncond_task"][0]
|
||||
|
||||
|
||||
class TestCfgPipelineConstruction:
|
||||
"""Tests that the processor pipeline is constructed correctly for CFG."""
|
||||
|
||||
def _make_config(self, cfg_beta=1.0, recipe_path=None):
|
||||
config = PI05Config(
|
||||
max_action_dim=7,
|
||||
max_state_dim=14,
|
||||
cfg_beta=cfg_beta,
|
||||
recipe_path=recipe_path,
|
||||
device="cpu",
|
||||
)
|
||||
config.input_features = {
|
||||
"observation.state": PolicyFeature(type=FeatureType.STATE, shape=(14,)),
|
||||
"observation.images.base_0_rgb": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 224, 224)),
|
||||
}
|
||||
config.output_features = {
|
||||
"action": PolicyFeature(type=FeatureType.ACTION, shape=(7,)),
|
||||
}
|
||||
return config
|
||||
|
||||
def _make_dataset_stats(self):
|
||||
return {
|
||||
"observation.state": {
|
||||
"mean": torch.zeros(14),
|
||||
"std": torch.ones(14),
|
||||
"min": torch.zeros(14),
|
||||
"max": torch.ones(14),
|
||||
"q01": torch.zeros(14),
|
||||
"q99": torch.ones(14),
|
||||
},
|
||||
"action": {
|
||||
"mean": torch.zeros(7),
|
||||
"std": torch.ones(7),
|
||||
"min": torch.zeros(7),
|
||||
"max": torch.ones(7),
|
||||
"q01": torch.zeros(7),
|
||||
"q99": torch.ones(7),
|
||||
},
|
||||
"observation.images.base_0_rgb": {
|
||||
"mean": torch.zeros(3, 224, 224),
|
||||
"std": torch.ones(3, 224, 224),
|
||||
"q01": torch.zeros(3, 224, 224),
|
||||
"q99": torch.ones(3, 224, 224),
|
||||
},
|
||||
}
|
||||
|
||||
def test_no_uncond_tokenizer_when_cfg_disabled(self):
|
||||
from lerobot.processor import TokenizerProcessorStep
|
||||
|
||||
config = self._make_config(cfg_beta=1.0)
|
||||
preprocessor, _ = make_pi05_pre_post_processors(config, self._make_dataset_stats())
|
||||
|
||||
tokenizer_steps = [s for s in preprocessor.steps if isinstance(s, TokenizerProcessorStep)]
|
||||
assert len(tokenizer_steps) == 1
|
||||
|
||||
def test_uncond_tokenizer_added_when_cfg_enabled(self):
|
||||
from lerobot.processor import TokenizerProcessorStep
|
||||
|
||||
config = self._make_config(cfg_beta=2.0)
|
||||
preprocessor, _ = make_pi05_pre_post_processors(config, self._make_dataset_stats())
|
||||
|
||||
tokenizer_steps = [s for s in preprocessor.steps if isinstance(s, TokenizerProcessorStep)]
|
||||
assert len(tokenizer_steps) == 2
|
||||
|
||||
uncond_tokenizer = tokenizer_steps[1]
|
||||
assert uncond_tokenizer.task_key == "uncond_task"
|
||||
assert uncond_tokenizer.output_tokens_key == OBS_LANGUAGE_UNCOND_TOKENS
|
||||
assert uncond_tokenizer.output_mask_key == OBS_LANGUAGE_UNCOND_ATTENTION_MASK
|
||||
|
||||
def test_cfg_pipeline_produces_both_token_sets(self):
|
||||
config = self._make_config(cfg_beta=2.0)
|
||||
preprocessor, _ = make_pi05_pre_post_processors(config, self._make_dataset_stats())
|
||||
|
||||
batch = {
|
||||
"observation.state": torch.randn(14),
|
||||
"observation.images.base_0_rgb": torch.rand(3, 224, 224),
|
||||
"task": "pick up the cup",
|
||||
}
|
||||
processed = preprocessor(batch)
|
||||
|
||||
assert OBS_LANGUAGE_TOKENS in processed
|
||||
assert OBS_LANGUAGE_ATTENTION_MASK in processed
|
||||
assert OBS_LANGUAGE_UNCOND_TOKENS in processed
|
||||
assert OBS_LANGUAGE_UNCOND_ATTENTION_MASK in processed
|
||||
|
||||
# Both should be tensors with the same shape
|
||||
assert processed[OBS_LANGUAGE_TOKENS].shape == processed[OBS_LANGUAGE_UNCOND_TOKENS].shape
|
||||
assert (
|
||||
processed[OBS_LANGUAGE_ATTENTION_MASK].shape
|
||||
== processed[OBS_LANGUAGE_UNCOND_ATTENTION_MASK].shape
|
||||
)
|
||||
|
||||
def test_cfg_beta_1_no_uncond_tokens_in_output(self):
|
||||
config = self._make_config(cfg_beta=1.0)
|
||||
preprocessor, _ = make_pi05_pre_post_processors(config, self._make_dataset_stats())
|
||||
|
||||
batch = {
|
||||
"observation.state": torch.randn(14),
|
||||
"observation.images.base_0_rgb": torch.rand(3, 224, 224),
|
||||
"task": "pick up the cup",
|
||||
}
|
||||
processed = preprocessor(batch)
|
||||
|
||||
assert OBS_LANGUAGE_TOKENS in processed
|
||||
assert OBS_LANGUAGE_UNCOND_TOKENS not in processed
|
||||
Reference in New Issue
Block a user