mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 18:26:11 +00:00
fix(pi052): make fitted FAST checkpoints portable
Package fitted tokenizer artifacts with processor pipelines so pretrained PI052 checkpoints restore their saved recipe and normalization state without refitting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from lerobot.envs.robocasa import RoboCasaEnv, convert_action
|
||||
|
||||
|
||||
def test_robocasa_action_uses_openpi_checkpoint_order():
|
||||
action = np.arange(12, dtype=np.float32)
|
||||
|
||||
converted = convert_action(action)
|
||||
|
||||
np.testing.assert_array_equal(converted["action.end_effector_position"], [0, 1, 2])
|
||||
np.testing.assert_array_equal(converted["action.end_effector_rotation"], [3, 4, 5])
|
||||
np.testing.assert_array_equal(converted["action.gripper_close"], [6])
|
||||
np.testing.assert_array_equal(converted["action.base_motion"], [7, 8, 9, 10])
|
||||
np.testing.assert_array_equal(converted["action.control_mode"], [11])
|
||||
|
||||
|
||||
def test_robocasa_state_uses_openpi_checkpoint_order():
|
||||
env = object.__new__(RoboCasaEnv)
|
||||
env.obs_type = "pixels_agent_pos"
|
||||
env.camera_name = []
|
||||
raw_observation = {
|
||||
"state.end_effector_position_relative": np.arange(0, 3),
|
||||
"state.end_effector_rotation_relative": np.arange(3, 7),
|
||||
"state.base_position": np.arange(7, 10),
|
||||
"state.base_rotation": np.arange(10, 14),
|
||||
"state.gripper_qpos": np.arange(14, 16),
|
||||
}
|
||||
|
||||
observation = env._format_raw_obs(raw_observation)
|
||||
|
||||
np.testing.assert_array_equal(observation["agent_pos"], np.arange(16, dtype=np.float32))
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import asdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from lerobot.configs import FeatureType, NormalizationMode, PolicyFeature
|
||||
from lerobot.configs.recipe import MessageTurn, TrainingRecipe
|
||||
from lerobot.policies import make_pre_post_processors
|
||||
from lerobot.processor import ActionTokenizerProcessorStep, DataProcessorPipeline, NormalizerProcessorStep
|
||||
from lerobot.processor.converters import identity_transition
|
||||
from lerobot.processor.render_messages_processor import RenderMessagesStep
|
||||
from lerobot.utils.constants import ACTION
|
||||
|
||||
|
||||
class _ActionTokenizer:
|
||||
def __call__(self, actions):
|
||||
return np.asarray(actions).round().astype(np.int64)
|
||||
|
||||
def save_pretrained(self, path):
|
||||
path.mkdir(parents=True)
|
||||
(path / "processor_config.json").write_text('{"processor_class": "_ActionTokenizer"}\n')
|
||||
|
||||
|
||||
class _PaligemmaTokenizer:
|
||||
vocab_size = 4096
|
||||
bos_token_id = 2
|
||||
|
||||
def encode(self, text, **kwargs):
|
||||
return [10, 11] if text == "Action: " else [12]
|
||||
|
||||
|
||||
def _make_pipeline(action_tokenizer_path):
|
||||
recipe = TrainingRecipe(
|
||||
messages=[
|
||||
MessageTurn(role="user", content="${task}", stream="high_level"),
|
||||
MessageTurn(role="assistant", content="${subtask}", stream="low_level", target=True),
|
||||
]
|
||||
)
|
||||
stats = {ACTION: {"min": torch.tensor([-1.0, -2.0]), "max": torch.tensor([1.0, 2.0])}}
|
||||
normalizer = NormalizerProcessorStep(
|
||||
features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(2,))},
|
||||
norm_map={FeatureType.ACTION: NormalizationMode.MIN_MAX},
|
||||
stats=stats,
|
||||
)
|
||||
action_tokenizer = ActionTokenizerProcessorStep(
|
||||
action_tokenizer_name=str(action_tokenizer_path),
|
||||
max_action_tokens=16,
|
||||
fast_skip_tokens=128,
|
||||
)
|
||||
return DataProcessorPipeline(
|
||||
[normalizer, RenderMessagesStep(recipe), action_tokenizer],
|
||||
name="policy_preprocessor",
|
||||
to_transition=identity_transition,
|
||||
to_output=identity_transition,
|
||||
)
|
||||
|
||||
|
||||
def test_pi052_pipeline_embeds_and_loads_fitted_action_tokenizer(tmp_path, monkeypatch):
|
||||
original_cache = tmp_path / "original_fast_cache"
|
||||
original_cache.mkdir()
|
||||
tokenizer = _ActionTokenizer()
|
||||
monkeypatch.setattr(
|
||||
"lerobot.processor.tokenizer_processor.AutoProcessor.from_pretrained",
|
||||
lambda path, **kwargs: tokenizer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"lerobot.processor.tokenizer_processor.AutoTokenizer.from_pretrained",
|
||||
lambda *args, **kwargs: _PaligemmaTokenizer(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"lerobot.policies.pi052.fit_fast_tokenizer.fit_fast_tokenizer",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("FAST fitting must not run")),
|
||||
)
|
||||
|
||||
pipeline = _make_pipeline(original_cache)
|
||||
expected_tokens = pipeline.steps[-1]._tokenize_action(torch.tensor([[[0.2, 0.8]]]))[0]
|
||||
expected_recipe = asdict(pipeline.steps[1].recipe)
|
||||
expected_state = pipeline.steps[0].state_dict()
|
||||
checkpoint = tmp_path / "checkpoint"
|
||||
pipeline.save_pretrained(checkpoint)
|
||||
DataProcessorPipeline(
|
||||
[],
|
||||
name="policy_postprocessor",
|
||||
to_transition=identity_transition,
|
||||
to_output=identity_transition,
|
||||
).save_pretrained(checkpoint)
|
||||
|
||||
saved_config = json.loads((checkpoint / "policy_preprocessor.json").read_text())
|
||||
tokenizer_step = saved_config["steps"][2]
|
||||
assert tokenizer_step["config"]["action_tokenizer_name"] == "action_tokenizer"
|
||||
assert tokenizer_step["artifacts"] == {"action_tokenizer_name": "action_tokenizer"}
|
||||
assert (checkpoint / "action_tokenizer" / "processor_config.json").is_file()
|
||||
|
||||
shutil.rmtree(original_cache)
|
||||
loaded, _ = make_pre_post_processors(
|
||||
SimpleNamespace(type="pi052", auto_fit_fast_tokenizer=True),
|
||||
pretrained_path=str(checkpoint),
|
||||
dataset_repo_id="org/dataset-that-must-not-be-read",
|
||||
)
|
||||
|
||||
assert asdict(loaded.steps[1].recipe) == expected_recipe
|
||||
for key, tensor in expected_state.items():
|
||||
torch.testing.assert_close(loaded.steps[0].state_dict()[key], tensor)
|
||||
torch.testing.assert_close(
|
||||
loaded.steps[-1]._tokenize_action(torch.tensor([[[0.2, 0.8]]]))[0],
|
||||
expected_tokens,
|
||||
)
|
||||
|
||||
|
||||
def test_pi052_pipeline_rejects_missing_fitted_action_tokenizer(tmp_path, monkeypatch):
|
||||
tokenizer = _ActionTokenizer()
|
||||
monkeypatch.setattr(
|
||||
"lerobot.processor.tokenizer_processor.AutoProcessor.from_pretrained",
|
||||
lambda path, **kwargs: tokenizer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"lerobot.processor.tokenizer_processor.AutoTokenizer.from_pretrained",
|
||||
lambda *args, **kwargs: _PaligemmaTokenizer(),
|
||||
)
|
||||
|
||||
pipeline = _make_pipeline(tmp_path / "original_fast_cache")
|
||||
checkpoint = tmp_path / "checkpoint"
|
||||
pipeline.save_pretrained(checkpoint)
|
||||
shutil.rmtree(checkpoint / "action_tokenizer")
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Checkpoint artifacts are incomplete"):
|
||||
DataProcessorPipeline.from_pretrained(
|
||||
checkpoint,
|
||||
config_filename="policy_preprocessor.json",
|
||||
to_transition=identity_transition,
|
||||
to_output=identity_transition,
|
||||
)
|
||||
@@ -15,6 +15,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lerobot.policies.pi052.fit_fast_tokenizer import (
|
||||
_apply_relative_actions,
|
||||
@@ -22,6 +23,7 @@ from lerobot.policies.pi052.fit_fast_tokenizer import (
|
||||
_is_global_leader,
|
||||
_normalize_actions,
|
||||
_select_episode_indices,
|
||||
_validate_fast_reconstruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -90,3 +92,31 @@ def test_fast_tokenizer_relative_actions_match_training_transform():
|
||||
relative = _apply_relative_actions(actions, states, [True, False])
|
||||
|
||||
np.testing.assert_allclose(relative, [[[1.0, 10.0], [2.0, 11.0]]])
|
||||
|
||||
|
||||
class _RoundTripTokenizer:
|
||||
def __init__(self, offset: float = 0.0):
|
||||
self.offset = offset
|
||||
|
||||
def __call__(self, actions):
|
||||
return actions
|
||||
|
||||
def decode(self, tokens):
|
||||
return tokens + self.offset
|
||||
|
||||
|
||||
def test_fast_tokenizer_reconstruction_validation_reports_error():
|
||||
actions = np.arange(24, dtype=np.float32).reshape(2, 3, 4) / 24
|
||||
|
||||
report, decoded = _validate_fast_reconstruction(_RoundTripTokenizer(0.05), actions, 0.1, 0.1)
|
||||
|
||||
np.testing.assert_allclose(decoded, actions + 0.05)
|
||||
assert report["reconstruction_rmse"] == pytest.approx(0.05)
|
||||
assert report["max_dim_rmse"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_fast_tokenizer_reconstruction_validation_rejects_large_error():
|
||||
actions = np.arange(24, dtype=np.float32).reshape(2, 3, 4) / 24
|
||||
|
||||
with pytest.raises(RuntimeError, match="exceeds the configured limit"):
|
||||
_validate_fast_reconstruction(_RoundTripTokenizer(0.25), actions, 0.1, 0.2)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from scripts.backfill_pi052_action_tokenizer import (
|
||||
CHECKPOINT_DIRECTORIES,
|
||||
DEFAULT_REPOSITORIES,
|
||||
artifact_fingerprint,
|
||||
make_portable_preprocessor,
|
||||
)
|
||||
|
||||
|
||||
def test_atomic4_backfill_covers_every_repository_and_checkpoint():
|
||||
assert len(DEFAULT_REPOSITORIES) == 6
|
||||
assert CHECKPOINT_DIRECTORIES == (
|
||||
"",
|
||||
"checkpoints/003000/pretrained_model",
|
||||
"checkpoints/006000/pretrained_model",
|
||||
"checkpoints/009000/pretrained_model",
|
||||
"checkpoints/012000/pretrained_model",
|
||||
)
|
||||
|
||||
|
||||
def test_backfill_embeds_recipe_and_declares_relative_tokenizer():
|
||||
recipe = {"messages": [{"role": "user", "content": "${task}", "stream": "low_level"}]}
|
||||
preprocessor = {
|
||||
"name": "policy_preprocessor",
|
||||
"steps": [
|
||||
{
|
||||
"registry_name": "normalizer_processor",
|
||||
"config": {},
|
||||
"state_file": "normalizer.safetensors",
|
||||
},
|
||||
{"registry_name": "render_messages_processor", "config": {"recipe": recipe}},
|
||||
{
|
||||
"registry_name": "action_tokenizer_processor",
|
||||
"config": {"action_tokenizer_name": "/fsx/original/tokenizer"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
portable = make_portable_preprocessor(preprocessor)
|
||||
|
||||
assert portable["steps"][1]["config"]["recipe"] == recipe
|
||||
assert portable["steps"][2]["config"]["action_tokenizer_name"] == "action_tokenizer"
|
||||
assert portable["steps"][2]["artifacts"] == {"action_tokenizer_name": "action_tokenizer"}
|
||||
assert preprocessor["steps"][2]["config"]["action_tokenizer_name"].startswith("/fsx/")
|
||||
|
||||
|
||||
def test_artifact_fingerprint_includes_paths_and_contents():
|
||||
first = artifact_fingerprint([("a/file", b"same"), ("b/file", b"content")])
|
||||
|
||||
assert first == artifact_fingerprint([("b/file", b"content"), ("a/file", b"same")])
|
||||
assert first != artifact_fingerprint([("a/renamed", b"same"), ("b/file", b"content")])
|
||||
assert first != artifact_fingerprint([("a/file", b"changed"), ("b/file", b"content")])
|
||||
Reference in New Issue
Block a user