mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06364ce436 | |||
| 741005d719 | |||
| 2e8345a5cc | |||
| ef88d4e52b | |||
| 64b23178d5 | |||
| f66e5128ec | |||
| 1e3a158e13 | |||
| dc0cee9c75 | |||
| 7e241bd630 | |||
| e867359d09 | |||
| f1efa588b8 | |||
| 3e37269dc6 | |||
| 8b2678318c | |||
| 6b56bf299b | |||
| b1ee35e637 | |||
| 6312be2d3f | |||
| d36f429a30 | |||
| bad0260a46 | |||
| adccdea1cf | |||
| 8135a8a8d1 | |||
| 2aba372b4e | |||
| c841a0c258 | |||
| a3ddba2454 | |||
| 99443a936d | |||
| 7a3298ea26 | |||
| 2d8f5f314e | |||
| 732a12108e | |||
| 29fcf057dc | |||
| 81db623b44 |
@@ -53,7 +53,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
|
||||
# Cancel in-flight runs for the same branch/PR.
|
||||
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
|
||||
# Sets up the environment variables
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest
|
||||
DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest
|
||||
|
||||
@@ -24,19 +24,24 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Triggers the workflow on push events to main for the docs folder
|
||||
# Triggers on pushes to main that touch the docs or the sources the API reference is generated from.
|
||||
# `src/**` is included because the API reference is built from docstrings via `[[autodoc]]`: without it,
|
||||
# published API pages would go stale as soon as a docstring changed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
# Triggers the workflow on pull request events targeting main for the docs folder
|
||||
# Same for pull requests, so a docstring change gets a preview build and a broken `[[autodoc]]` path
|
||||
# fails the PR rather than main.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
@@ -59,12 +64,21 @@ jobs:
|
||||
with:
|
||||
commit_sha: ${{ github.sha }}
|
||||
package: lerobot
|
||||
# doc-builder ships a mock-deps registry entry for lerobot, so the reusable workflow takes its
|
||||
# "light install" path: `pip install ./lerobot --no-deps` plus a handful of real dependencies.
|
||||
# That is not enough to import lerobot — draccus runs `register_subclass` at import time and
|
||||
# `processor/converters.py` calls `functools.singledispatch.register(torch.Tensor)`, neither of
|
||||
# which works against a mock. Install the package for real before the build.
|
||||
pre_command: uv pip install "./lerobot[dataset]"
|
||||
# `--version main` is load-bearing: without `--not_python_module`, doc-builder falls back to
|
||||
# `lerobot.__version__` and only maps that to the default branch when it contains "dev". Our main
|
||||
# branch carries a release version (0.6.2), so omitting this would publish the main docs to
|
||||
# /lerobot/v0.6.2/ instead of /lerobot/main/ and disable notebook building.
|
||||
additional_args: >-
|
||||
--not_python_module
|
||||
${{
|
||||
(github.event_name == 'release' && format('--version {0}', github.event.release.tag_name)) ||
|
||||
(inputs.version != '' && format('--version {0}', inputs.version)) ||
|
||||
''
|
||||
'--version main'
|
||||
}}
|
||||
secrets:
|
||||
token: ${{ secrets.HUGGINGFACE_PUSH }}
|
||||
@@ -83,4 +97,6 @@ jobs:
|
||||
commit_sha: ${{ github.event.pull_request.head.sha }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
package: lerobot
|
||||
additional_args: --not_python_module
|
||||
# See the comment on build_main_docs. The PR workflow passes its own `--version pr_<n>`, so no
|
||||
# additional_args are needed here.
|
||||
pre_command: uv pip install "./lerobot[dataset]"
|
||||
|
||||
@@ -48,7 +48,7 @@ permissions:
|
||||
|
||||
# Sets up the environment variables
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
|
||||
# Ensures that only the latest commit for a PR or branch is built, canceling older runs.
|
||||
|
||||
@@ -37,7 +37,7 @@ permissions:
|
||||
|
||||
# Sets up the environment variables
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
|
||||
# Sets up the environment variables
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:latest-deps
|
||||
|
||||
|
||||
@@ -56,3 +56,41 @@ jobs:
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
with:
|
||||
extra_args: --all-files --show-diff-on-failure --color=always
|
||||
|
||||
# This job runs the examples in our docstrings and validates the doctest allowlist.
|
||||
# See docs/source/writing_docstrings.mdx for the standard these enforce.
|
||||
doc-checks:
|
||||
name: Run Documentation Checks (Doctests)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Examples that need a physical robot, a serial port or a Hub download are skipped by content.
|
||||
# Everything else has to actually run. See src/lerobot/utils/doctest_utils.py.
|
||||
SKIP_HARDWARE_DOCTEST: "1"
|
||||
SKIP_CUDA_DOCTEST: "1"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
version: "0.11.30"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra test --extra dataset
|
||||
|
||||
- name: Check the doctest list is sorted and its paths exist
|
||||
run: make check-doctest-list
|
||||
|
||||
- name: Check documented arguments match their signatures
|
||||
run: make check-docstrings
|
||||
|
||||
- name: Check docstring coverage has not regressed
|
||||
run: uv run --with interrogate interrogate --config=pyproject.toml
|
||||
|
||||
- name: Run doctests
|
||||
run: make doctest
|
||||
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
|
||||
# Sets up the environment variables
|
||||
env:
|
||||
UV_VERSION: "0.8.0"
|
||||
UV_VERSION: "0.11.30"
|
||||
PYTHON_VERSION: "3.12"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -19,8 +19,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
# Runs at 02:00
|
||||
# schedule:
|
||||
# - cron: "0 2 * * *"
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
env:
|
||||
CLOSE_ISSUE_MESSAGE: >
|
||||
@@ -31,7 +31,7 @@ env:
|
||||
Feel free to reopen if is still relevant, or to ping a collaborator if you have any questions.
|
||||
WARN_ISSUE_MESSAGE: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity (1 year). It will be closed if no further activity occurs.
|
||||
recent activity (1 year). It will be closed if no further activity occurs within 30 days.
|
||||
Any change, comment or update to this issue will reset this count.
|
||||
Thank you for your contributions.
|
||||
WARN_PR_MESSAGE: >
|
||||
@@ -61,8 +61,8 @@ jobs:
|
||||
exempt-pr-labels: never-stale
|
||||
days-before-issue-stale: 365
|
||||
days-before-issue-close: 30
|
||||
days-before-pr-stale: 365
|
||||
days-before-pr-close: 30
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
delete-branch: true
|
||||
close-issue-message: ${{ env.CLOSE_ISSUE_MESSAGE }}
|
||||
close-pr-message: ${{ env.CLOSE_PR_MESSAGE }}
|
||||
|
||||
+11
-2
@@ -67,7 +67,11 @@ repos:
|
||||
args: [--prose-wrap=preserve]
|
||||
# Jinja2 model-card templates use a .md extension but contain {% ... %} /
|
||||
# {{ ... }} tags that prettier's Markdown formatter mangles (e.g. table loops).
|
||||
exclude: ^src/lerobot/templates/.*\.md$
|
||||
#
|
||||
# docs/source/api/ holds the generated API reference. Its `[[autodoc]]` blocks restrict output
|
||||
# to an indented `- member` list, which prettier reads as a lazy paragraph continuation and
|
||||
# joins onto one line — silently turning a member list into part of the directive.
|
||||
exclude: ^(src/lerobot/templates/.*\.md|docs/source/api/.*\.mdx)$
|
||||
|
||||
##### Security #####
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
@@ -104,8 +108,13 @@ repos:
|
||||
# args: ["--docstring-style", "google", "-v", "2"]
|
||||
# exclude: ^tests/.*$
|
||||
|
||||
# interrogate runs in CI (quality.yml, doc-checks job) rather than here. Its 1.7.0 release still imports
|
||||
# the deprecated `py` package, which resolves against whatever `py` happens to be importable in
|
||||
# pre-commit's isolated env — on a machine with miniconda on the path that is a stray `py.py` and the
|
||||
# hook dies before it reads any config. The gate is the same either way; the CI step is just reliable.
|
||||
# - repo: https://github.com/econchick/interrogate
|
||||
# rev: 1.7.0
|
||||
# hooks:
|
||||
# - id: interrogate
|
||||
# args: ["-vv", "--config=pyproject.toml"]
|
||||
# args: ["--config=pyproject.toml"]
|
||||
# pass_filenames: false
|
||||
|
||||
@@ -50,6 +50,10 @@ To run checks manually on all files:
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Docstrings
|
||||
|
||||
The API reference is generated from the docstrings in `src/lerobot/`. If you add or change anything public, follow the [docstring standard](https://huggingface.co/docs/lerobot/writing_docstrings) — the format is parsed by the renderer and checked in CI.
|
||||
|
||||
### Running Tests
|
||||
|
||||
We use `pytest`. First, ensure you have test artifacts by installing **git-lfs**:
|
||||
|
||||
@@ -184,3 +184,29 @@ test-smolvla-ete-eval:
|
||||
# backend, so it does not require a real model checkpoint or GPU.
|
||||
annotation-e2e:
|
||||
uv run python -m tests.annotations.run_e2e_smoke
|
||||
|
||||
# Docstring & doctest checks. See docs/source/writing_docstrings.mdx for the standard these enforce.
|
||||
|
||||
# Run the examples in the docstrings listed in utils/documentation_tests.txt. Hardware and GPU examples are
|
||||
# skipped by content (see src/lerobot/utils/doctest_utils.py); CI sets both flags.
|
||||
doctest:
|
||||
@files=$$(grep -v '^\s*#' utils/documentation_tests.txt | grep -v '^\s*$$'); \
|
||||
if [ -z "$$files" ]; then \
|
||||
echo "utils/documentation_tests.txt lists no files; nothing to run."; \
|
||||
else \
|
||||
SKIP_HARDWARE_DOCTEST=1 uv run pytest --doctest-modules --no-header -q $$files; \
|
||||
fi
|
||||
|
||||
check-doctest-list:
|
||||
uv run python utils/check_doctest_list.py
|
||||
|
||||
fix-doctest-list:
|
||||
uv run python utils/check_doctest_list.py --fix_and_overwrite
|
||||
|
||||
check-docstrings:
|
||||
uv run python utils/check_docstrings.py
|
||||
uv run python utils/check_config_docstrings.py
|
||||
|
||||
fix-docstrings:
|
||||
uv run python utils/check_docstrings.py --fix_and_overwrite
|
||||
uv run python utils/check_doctest_list.py --fix_and_overwrite
|
||||
|
||||
@@ -128,6 +128,23 @@ lerobot-eval \
|
||||
|
||||
Learn how to implement your own simulation environment or benchmark and distribute it from the HF Hub by following the [EnvHub Documentation](https://huggingface.co/docs/lerobot/envhub).
|
||||
|
||||
### Third-Party Hardware
|
||||
|
||||
Beyond the natively supported hardware, the community maintains a growing ecosystem of plugins for other robots, teleoperators, cameras, and sensors - UFACTORY xArm, Universal Robots UR5e, Franka, AgileX Piper, Trossen WidowX, ARX5, I2RT YAM, GELLO, SpaceMouse, Meta Quest, ROS 2 bridges, tactile and depth cameras, and more.
|
||||
|
||||
Plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_robot_`, `lerobot_teleoperator_`, or `lerobot_camera_`. Install one and use the `type` it registers straight from the CLI:
|
||||
|
||||
```bash
|
||||
pip install lerobot_robot_<name> lerobot_teleoperator_<name>
|
||||
|
||||
lerobot-record \
|
||||
--robot.type=<robot_name> \
|
||||
--teleop.type=<teleoperator_name> \
|
||||
--dataset.repo_id=${HF_USER}/my-dataset
|
||||
```
|
||||
|
||||
Browse the full list in the [Third-Party Robots & Teleoperators](https://huggingface.co/docs/lerobot/main/third_party_robots) and [Third-Party Cameras & Sensors](https://huggingface.co/docs/lerobot/main/third_party_sensors) documentation.
|
||||
|
||||
## Resources
|
||||
|
||||
- **[Documentation](https://huggingface.co/docs/lerobot/index):** The complete guide to tutorials & API.
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
|
||||
"""Root conftest: makes doctest collection use LeRobot's parser.
|
||||
|
||||
This only affects `--doctest-modules` runs (see `make doctest`). The test suite itself is configured by
|
||||
`tests/conftest.py`.
|
||||
"""
|
||||
|
||||
import doctest
|
||||
|
||||
import _pytest.doctest
|
||||
|
||||
from lerobot.utils.doctest_utils import LeRobotDoctestModule, LeRobotDocTestParser
|
||||
|
||||
# Lets an example opt out of output comparison with `# doctest: +IGNORE_RESULT`, for calls whose output is
|
||||
# a progress bar or otherwise not reproducible.
|
||||
IGNORE_RESULT = doctest.register_optionflag("IGNORE_RESULT")
|
||||
|
||||
OutputChecker = doctest.OutputChecker
|
||||
|
||||
|
||||
class CustomOutputChecker(OutputChecker):
|
||||
"""An output checker that honours the `IGNORE_RESULT` flag."""
|
||||
|
||||
def check_output(self, want, got, optionflags):
|
||||
"""Return `True` when `IGNORE_RESULT` is set, otherwise defer to stdlib.
|
||||
|
||||
Args:
|
||||
want (`str`):
|
||||
The expected output.
|
||||
got (`str`):
|
||||
The actual output.
|
||||
optionflags (`int`):
|
||||
Bitmask of active doctest option flags.
|
||||
|
||||
Returns:
|
||||
`bool`: Whether the output is considered a match.
|
||||
"""
|
||||
if IGNORE_RESULT & optionflags:
|
||||
return True
|
||||
return OutputChecker.check_output(self, want, got, optionflags)
|
||||
|
||||
|
||||
# Reassigning these module attributes is how doctest behaviour is customised; mypy sees it as assigning to
|
||||
# a type, which is exactly what is intended here.
|
||||
doctest.OutputChecker = CustomOutputChecker # type: ignore[misc]
|
||||
_pytest.doctest.DoctestModule = LeRobotDoctestModule
|
||||
doctest.DocTestParser = LeRobotDocTestParser # type: ignore[misc]
|
||||
@@ -165,6 +165,8 @@
|
||||
title: OpenArm
|
||||
- local: rebot_b601
|
||||
title: reBot B601-DM
|
||||
- local: third_party_robots
|
||||
title: Third-Party Robots & Teleoperators
|
||||
title: "Robots"
|
||||
- sections:
|
||||
- local: phone_teleop
|
||||
@@ -175,6 +177,8 @@
|
||||
- sections:
|
||||
- local: cameras
|
||||
title: Cameras
|
||||
- local: third_party_sensors
|
||||
title: Third-Party Cameras & Sensors
|
||||
title: "Sensors"
|
||||
- sections:
|
||||
- local: notebooks
|
||||
@@ -187,6 +191,28 @@
|
||||
- sections:
|
||||
- local: contributing
|
||||
title: Contribute to LeRobot
|
||||
- local: writing_docstrings
|
||||
title: Writing docstrings
|
||||
- local: backwardcomp
|
||||
title: Backward compatibility
|
||||
title: "About"
|
||||
- sections:
|
||||
- local: api/robots
|
||||
title: Robots
|
||||
- local: api/teleoperators
|
||||
title: Teleoperators
|
||||
- local: api/cameras
|
||||
title: Cameras
|
||||
- local: api/motors
|
||||
title: Motors
|
||||
- local: api/datasets
|
||||
title: Datasets
|
||||
- local: api/policies
|
||||
title: Policies
|
||||
- local: api/processor
|
||||
title: Processors
|
||||
- local: api/envs
|
||||
title: Environments
|
||||
- local: api/configs
|
||||
title: Configuration
|
||||
title: "API Reference"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Cameras
|
||||
|
||||
Cameras supply the image observations a policy sees. Every backend — OpenCV, Intel RealSense, Reachy 2 —
|
||||
implements the [`Camera`] interface, so swapping hardware does not change the code that reads frames.
|
||||
|
||||
See the [Cameras guide](../cameras) for choosing and configuring a camera, and
|
||||
[Third-Party Cameras & Sensors](../third_party_sensors) for devices outside the core set.
|
||||
|
||||
## Camera
|
||||
|
||||
[[autodoc]] lerobot.cameras.Camera
|
||||
- connect
|
||||
- disconnect
|
||||
- read
|
||||
- async_read
|
||||
- find_cameras
|
||||
|
||||
## CameraConfig
|
||||
|
||||
[[autodoc]] lerobot.cameras.CameraConfig
|
||||
|
||||
## make_cameras_from_configs
|
||||
|
||||
[[autodoc]] lerobot.cameras.make_cameras_from_configs
|
||||
@@ -0,0 +1,27 @@
|
||||
# Configuration
|
||||
|
||||
LeRobot configuration is plain dataclasses parsed by [draccus](https://github.com/dlwh/draccus), so every
|
||||
field is settable from the CLI. [`TrainPipelineConfig`] is the top-level object for `lerobot-train`.
|
||||
|
||||
Polymorphic configs (policies, robots, environments) use `draccus.ChoiceRegistry`: a subclass registers
|
||||
itself with `@register_subclass("name")` and is then selectable by that name on the command line.
|
||||
|
||||
## TrainPipelineConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.train.TrainPipelineConfig
|
||||
|
||||
## PreTrainedConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.PreTrainedConfig
|
||||
|
||||
## DatasetConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.DatasetConfig
|
||||
|
||||
## EvalConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.EvalConfig
|
||||
|
||||
## WandBConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.WandBConfig
|
||||
@@ -0,0 +1,23 @@
|
||||
# Datasets
|
||||
|
||||
[`LeRobotDataset`] is the format every LeRobot script reads and writes. It is episode-aware, decodes video
|
||||
observations on the fly, and round-trips to the Hugging Face Hub.
|
||||
|
||||
See [Using LeRobotDataset](../lerobot-dataset-v3) for the format and the common operations,
|
||||
[Porting Large Datasets](../porting_datasets_v3) for migration, and [Tools](../tools) for the CLI.
|
||||
|
||||
## LeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.LeRobotDataset
|
||||
|
||||
## LeRobotDatasetMetadata
|
||||
|
||||
[[autodoc]] lerobot.datasets.LeRobotDatasetMetadata
|
||||
|
||||
## MultiLeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.MultiLeRobotDataset
|
||||
|
||||
## StreamingLeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.StreamingLeRobotDataset
|
||||
@@ -0,0 +1,19 @@
|
||||
# Environments
|
||||
|
||||
Simulation environments are configured through [`EnvConfig`] and built by [`make_env`]. Each subclass
|
||||
declares its `gym_kwargs` and how to construct the vectorised environments.
|
||||
|
||||
See [Environments from the Hub](../envhub) for using published environments and
|
||||
[Adding a New Benchmark](../adding_benchmarks) for contributing one.
|
||||
|
||||
## EnvConfig
|
||||
|
||||
[[autodoc]] lerobot.envs.EnvConfig
|
||||
|
||||
## make_env
|
||||
|
||||
[[autodoc]] lerobot.envs.make_env
|
||||
|
||||
## make_env_config
|
||||
|
||||
[[autodoc]] lerobot.envs.make_env_config
|
||||
@@ -0,0 +1,23 @@
|
||||
# Motors
|
||||
|
||||
`MotorsBus` is the low-level interface to a chain of servos on a serial bus. Robots use it to read positions
|
||||
and write goal positions; you rarely touch it directly unless you are adding hardware.
|
||||
|
||||
See [Bring Your Own Hardware](../integrate_hardware) for adding a new bus, and
|
||||
[Updating Feetech Firmware](../feetech) and [Damiao Motors and CAN Bus](../damiao) for device-specific notes.
|
||||
|
||||
## MotorsBus
|
||||
|
||||
[[autodoc]] lerobot.motors.motors_bus.MotorsBus
|
||||
|
||||
## Motor
|
||||
|
||||
[[autodoc]] lerobot.motors.Motor
|
||||
|
||||
## MotorCalibration
|
||||
|
||||
[[autodoc]] lerobot.motors.MotorCalibration
|
||||
|
||||
## MotorNormMode
|
||||
|
||||
[[autodoc]] lerobot.motors.MotorNormMode
|
||||
@@ -0,0 +1,20 @@
|
||||
# Policies
|
||||
|
||||
Every policy inherits [`PreTrainedPolicy`], which combines a `torch.nn.Module` with the Hub mixin, so any
|
||||
policy can be pushed to and loaded from the Hugging Face Hub with the same two calls.
|
||||
|
||||
Each policy has its own guide with training recipes and results — [ACT](../act), [SmolVLA](../smolvla),
|
||||
[π₀](../pi0), [π₀.₅](../pi05) and the rest are listed under Policies. To add one, see
|
||||
[Adding a Policy](../bring_your_own_policies).
|
||||
|
||||
## PreTrainedPolicy
|
||||
|
||||
[[autodoc]] lerobot.policies.pretrained.PreTrainedPolicy
|
||||
|
||||
## PreTrainedConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.PreTrainedConfig
|
||||
|
||||
## make_policy
|
||||
|
||||
[[autodoc]] lerobot.policies.factory.make_policy
|
||||
@@ -0,0 +1,255 @@
|
||||
# Processors
|
||||
|
||||
Processors are the data transformation layer between a robot, a dataset and a policy. A pipeline is a chain
|
||||
of [`ProcessorStep`]s; each step declares how it transforms both the data and the feature contract.
|
||||
|
||||
See [Introduction to Robot Processors](../introduction_processors) for the concepts,
|
||||
[Implement your own processor](../implement_your_own_processor) to write a step, and
|
||||
[Debug your processor pipeline](../debug_processor_pipeline) when a pipeline misbehaves.
|
||||
|
||||
## Core pipeline
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.DataProcessorPipeline
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.PolicyProcessorPipeline
|
||||
|
||||
`RobotProcessorPipeline` is a type alias for `DataProcessorPipeline[TInput, TOutput]`, used for the
|
||||
teleop-action, robot-action and robot-observation pipelines that don't go through a policy.
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ProcessorKwargs
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ProcessorStepRegistry
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ProcessorMigrationError
|
||||
|
||||
### Typed base steps
|
||||
|
||||
Each subclasses `ProcessorStep` to implement one part of an `EnvTransition` (observation, action, reward,
|
||||
etc.), leaving the rest of the transition untouched by default.
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ObservationProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ActionProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.RobotActionProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.PolicyActionProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.RewardProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.DoneProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.TruncatedProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.InfoProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ComplementaryDataProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.IdentityProcessorStep
|
||||
|
||||
## Factory functions
|
||||
|
||||
Build the canonical processor pipelines used by policies and robots.
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_processors
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_teleop_action_processor
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_robot_action_processor
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_robot_observation_processor
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.DefaultPolicyProcessorSteps
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_policy_processor_steps
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_policy_processor_pipelines
|
||||
|
||||
[[autodoc]] lerobot.processor.factory.make_default_pre_post_processors
|
||||
|
||||
## Batch dimension
|
||||
|
||||
[[autodoc]] lerobot.processor.batch_processor.AddBatchDimensionProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.batch_processor.AddBatchDimensionObservationStep
|
||||
|
||||
[[autodoc]] lerobot.processor.batch_processor.AddBatchDimensionActionStep
|
||||
|
||||
[[autodoc]] lerobot.processor.batch_processor.AddBatchDimensionComplementaryDataStep
|
||||
|
||||
## Device
|
||||
|
||||
[[autodoc]] lerobot.processor.device_processor.DeviceProcessorStep
|
||||
- all
|
||||
|
||||
## Normalization
|
||||
|
||||
[[autodoc]] lerobot.processor.normalize_processor.NormalizerProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.normalize_processor.UnnormalizerProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.normalize_processor.hotswap_stats
|
||||
|
||||
## Renaming
|
||||
|
||||
[[autodoc]] lerobot.processor.rename_processor.RenameObservationsProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.rename_processor.rename_stats
|
||||
|
||||
## Vanilla observation processing
|
||||
|
||||
Converts standard Gymnasium observations (`pixels`, `agent_pos`, `environment_state`) to the LeRobot format.
|
||||
|
||||
[[autodoc]] lerobot.processor.observation_processor.VanillaObservationProcessorStep
|
||||
- all
|
||||
|
||||
## Environment-specific observation processing
|
||||
|
||||
[[autodoc]] lerobot.processor.env_processor.LiberoProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.env_processor.IsaaclabArenaProcessorStep
|
||||
- all
|
||||
|
||||
## NumPy / PyTorch action conversion
|
||||
|
||||
[[autodoc]] lerobot.processor.gym_action_processor.Torch2NumpyActionProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.gym_action_processor.Numpy2TorchActionProcessorStep
|
||||
- all
|
||||
|
||||
## Delta and relative actions
|
||||
|
||||
[[autodoc]] lerobot.processor.delta_action_processor.MapTensorToDeltaActionDictStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.delta_action_processor.MapDeltaActionToRobotActionStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.relative_action_processor.RelativeActionsProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.relative_action_processor.AbsoluteActionsProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.relative_action_processor.to_relative_actions
|
||||
|
||||
[[autodoc]] lerobot.processor.relative_action_processor.to_absolute_actions
|
||||
|
||||
## Policy/robot action bridge
|
||||
|
||||
Converts between a robot's per-motor action dict and a policy's stacked action tensor.
|
||||
|
||||
[[autodoc]] lerobot.processor.policy_robot_bridge.RobotActionToPolicyActionProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.policy_robot_bridge.PolicyActionToRobotActionProcessorStep
|
||||
- all
|
||||
|
||||
## Tokenization
|
||||
|
||||
[[autodoc]] lerobot.processor.tokenizer_processor.TokenizerProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.tokenizer_processor.ActionTokenizerProcessorStep
|
||||
- all
|
||||
|
||||
## Language
|
||||
|
||||
[[autodoc]] lerobot.processor.newline_task_processor.NewLineTaskProcessorStep
|
||||
- all
|
||||
|
||||
`RenderMessagesStep` requires the `[dataset]` extra and is not re-exported from `lerobot.processor`; import
|
||||
it directly from `lerobot.processor.render_messages_processor`.
|
||||
|
||||
[[autodoc]] lerobot.processor.render_messages_processor.RenderMessagesStep
|
||||
- all
|
||||
|
||||
## Human-in-the-loop (HIL)
|
||||
|
||||
Steps supporting human-in-the-loop RL: teleop event/action bookkeeping, time limits, image preprocessing,
|
||||
the `gym-hil` adapter, gripper penalties, intervention handling, and a learned reward classifier.
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.HasTeleopEvents
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.AddTeleopActionAsComplimentaryDataStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.AddTeleopEventsAsInfoStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.ImageCropResizeProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.TimeLimitProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.GymHILAdapterProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.GripperPenaltyProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.InterventionActionProcessorStep
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.processor.hil_processor.RewardClassifierProcessorStep
|
||||
- all
|
||||
|
||||
## Transition converters
|
||||
|
||||
Convert between an `EnvTransition` and the raw dict formats used by robots, policies, and dataset batches.
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.create_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.identity_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.to_tensor
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.from_tensor_to_numpy
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.robot_action_to_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.robot_action_observation_to_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.observation_to_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.policy_action_to_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.batch_to_transition
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.transition_to_robot_action
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.transition_to_policy_action
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.transition_to_observation
|
||||
|
||||
[[autodoc]] lerobot.processor.converters.transition_to_batch
|
||||
|
||||
## Migrating legacy policies
|
||||
|
||||
Standalone script to migrate a pretrained policy with built-in normalization layers to the processor
|
||||
pipeline system. See the module docstring for CLI usage.
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.extract_normalization_stats
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.detect_features_and_norm_modes
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.remove_normalization_layers
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.clean_state_dict
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.load_state_dict_with_missing_key_handling
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.convert_features_to_policy_features
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.display_migration_summary_with_warnings
|
||||
|
||||
[[autodoc]] lerobot.processor.migrate_policy_normalization.load_model_from_hub
|
||||
@@ -0,0 +1,147 @@
|
||||
# Robots
|
||||
|
||||
Every robot in LeRobot implements the [`Robot`] interface: connect, read an observation, send an action,
|
||||
disconnect. Writing a policy or a recording script against that interface means it works with any supported
|
||||
arm without change.
|
||||
|
||||
This page is the generated reference. For wiring, calibration and first-run instructions, start with the
|
||||
hardware guides — [SO-101](../so101), [LeKiwi](../lekiwi), [Hope Jr](../hope_jr), [Reachy 2](../reachy2),
|
||||
[OpenArm](../openarm) — or [Imitation Learning for Robots](../il_robots) for the end-to-end workflow. To add
|
||||
a robot of your own, see [Bring Your Own Hardware](../integrate_hardware).
|
||||
|
||||
## Robot
|
||||
|
||||
The abstract base class. Subclasses implement every method below; the contract described here is what a
|
||||
policy or recording loop can rely on.
|
||||
|
||||
[[autodoc]] lerobot.robots.Robot
|
||||
- connect
|
||||
- disconnect
|
||||
- configure
|
||||
- calibrate
|
||||
- get_observation
|
||||
- send_action
|
||||
- observation_features
|
||||
- action_features
|
||||
- is_connected
|
||||
- is_calibrated
|
||||
|
||||
## RobotConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.RobotConfig
|
||||
|
||||
## make_robot_from_config
|
||||
|
||||
[[autodoc]] lerobot.robots.make_robot_from_config
|
||||
|
||||
## SO-100 and SO-101 followers
|
||||
|
||||
`SO100Follower` and `SO101Follower` are aliases of the same `SOFollower` class; the two arms differ in their
|
||||
configuration, not their control code. `SO100FollowerConfig` and `SO101FollowerConfig` are likewise aliases
|
||||
of `SOFollowerRobotConfig`.
|
||||
|
||||
[[autodoc]] lerobot.robots.so_follower.SOFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.so_follower.SOFollowerRobotConfig
|
||||
|
||||
## BiSOFollower
|
||||
|
||||
Two SO followers driven as one bimanual robot.
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollowerConfig
|
||||
|
||||
## KochFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.koch_follower.KochFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.koch_follower.KochFollowerConfig
|
||||
|
||||
## LeKiwi
|
||||
|
||||
`LeKiwi` runs on the robot itself. `LeKiwiClient` is the host-side proxy that talks to it over the network
|
||||
and presents the same [`Robot`] interface.
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwi
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClient
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClientConfig
|
||||
|
||||
## OpenArmFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollowerConfig
|
||||
|
||||
## BiOpenArmFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollowerConfig
|
||||
|
||||
## OmxFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.omx_follower.OmxFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.omx_follower.OmxFollowerConfig
|
||||
|
||||
## Reachy2Robot
|
||||
|
||||
[[autodoc]] lerobot.robots.reachy2.Reachy2Robot
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.reachy2.Reachy2RobotConfig
|
||||
|
||||
## UnitreeG1
|
||||
|
||||
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1Config
|
||||
|
||||
## Hope Jr
|
||||
|
||||
The Hope Jr humanoid is exposed as two independent robots, an arm and a hand.
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrArm
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrArmConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrHand
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrHandConfig
|
||||
|
||||
## RebotB601Follower
|
||||
|
||||
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601Follower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601FollowerRobotConfig
|
||||
|
||||
## BiRebotB601Follower
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601Follower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601FollowerConfig
|
||||
|
||||
## EarthRoverMiniPlus
|
||||
|
||||
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlus
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlusConfig
|
||||
@@ -0,0 +1,30 @@
|
||||
# Teleoperators
|
||||
|
||||
A teleoperator produces actions for a robot to follow — a leader arm, a gamepad, a keyboard, a phone. All of
|
||||
them implement the [`Teleoperator`] interface, so a recording script written against it works with any input
|
||||
device.
|
||||
|
||||
See [Phone teleoperation](../phone_teleop) and [Isaac Teleop](../isaac_teleop) for setup guides, and
|
||||
[Imitation Learning for Robots](../il_robots) for the recording workflow.
|
||||
|
||||
## Teleoperator
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.Teleoperator
|
||||
- connect
|
||||
- disconnect
|
||||
- configure
|
||||
- calibrate
|
||||
- get_action
|
||||
- send_feedback
|
||||
- action_features
|
||||
- feedback_features
|
||||
- is_connected
|
||||
- is_calibrated
|
||||
|
||||
## TeleoperatorConfig
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.TeleoperatorConfig
|
||||
|
||||
## make_teleoperator_from_config
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.make_teleoperator_from_config
|
||||
@@ -161,6 +161,16 @@ The methods called by the train/eval loops:
|
||||
|
||||
Batches are flat dictionaries keyed by the constants in [`lerobot.utils.constants`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/utils/constants.py): `OBS_STATE` (`observation.state.<motor>`), `OBS_IMAGES` (`observation.images.<camera>`), `OBS_LANGUAGE`, `ACTION`, etc. Reuse the constants — don't invent new prefixes.
|
||||
|
||||
If your model is large enough to warrant [sharded multi-GPU training](./multi_gpu_training#sharded-training-fsdp), also declare its FSDP wrap units — the repeated block classes sharding operates on:
|
||||
|
||||
```python
|
||||
class MyPolicy(PreTrainedPolicy):
|
||||
...
|
||||
_fsdp_wrap_modules = ["MyTransformerBlock"]
|
||||
```
|
||||
|
||||
With this one declaration, `--parallelism.dp_shard=N` works out of the box for your policy (users can still override it with `--accelerator.fsdp.wrap_modules`). Without any wrap source, sharded runs fail at startup by design.
|
||||
|
||||
### Processor functions
|
||||
|
||||
LeRobot uses `PolicyProcessorPipeline`s to normalize inputs and de-normalize outputs around your policy. For a concrete reference, see [`processor_act.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/act/processor_act.py) or [`processor_diffusion.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/diffusion/processor_diffusion.py).
|
||||
@@ -300,7 +310,7 @@ The file names are load-bearing: the factory does lazy imports by name, and the
|
||||
Two places need to know about your policy. All by name.
|
||||
|
||||
1. **`policies/__init__.py`** — re-export `MyPolicyConfig` and add it to `__all__`. This import is what registers your policy: `@PreTrainedConfig.register_subclass("my_policy")` runs, and from then on the factory resolves everything by convention. **Don't** re-export the modeling class; it loads lazily through the factory (so `import lerobot` stays fast).
|
||||
2. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what `push_model_to_hub` renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
|
||||
2. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what the end-of-training publisher renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
|
||||
|
||||
Mirror an existing policy that's structurally similar to yours; the diff is small.
|
||||
|
||||
@@ -344,7 +354,7 @@ A new policy is much easier to review — and far more useful — when it ships
|
||||
|
||||
**Pick at least one in-tree benchmark.** LeRobot ships sim benchmarks with per-benchmark Docker images (LIBERO, LIBERO-plus, Meta-World, RoboTwin 2.0, RoboCasa365, RoboCerebra, RoboMME, VLABench and more). Pick the one that matches your policy's modality — VLAs usually go to LIBERO or VLABench; image-only BC to LIBERO or Meta-World. The full list lives under [Benchmarks](./libero) in the docs sidebar.
|
||||
|
||||
**Push the checkpoint & processors** to the Hub under `lerobot/<policy>_<benchmark>` (or your namespace if you don't have write access; a maintainer can mirror it). Use `PreTrainedPolicy.push_model_to_hub` so the repo gets `config.json`, `model.safetensors`, and a model card.
|
||||
**Push the checkpoint & processors** to the Hub under `lerobot/<policy>_<benchmark>` (or your namespace if you don't have write access; a maintainer can mirror it). The easiest way is training with `--policy.repo_id=<namespace>/<repo>` and `--policy.push_to_hub=true`: `lerobot-train` publishes the model, both processors, and a model card at the end of the run. To publish an existing checkpoint after the fact, upload its `pretrained_model/` directory (e.g. `huggingface-cli upload`), or use `lerobot-convert-dcp --push_to_hub=...` for sharded-format checkpoints.
|
||||
|
||||
**Report results in your policy's MDX**, with the exact `lerobot-eval` command and hardware so anyone can re-run:
|
||||
|
||||
|
||||
+218
-11
@@ -23,18 +23,18 @@ The broader EVO1 project may include additional training scripts and dataset too
|
||||
2. Install EVO1 dependencies:
|
||||
|
||||
```bash
|
||||
pip install -e ".[evo1]"
|
||||
pip install -e ".[training,evo1]"
|
||||
```
|
||||
|
||||
For LIBERO evaluation, install the LIBERO extra as well:
|
||||
For LIBERO training and evaluation, install the LIBERO extra as well:
|
||||
|
||||
```bash
|
||||
pip install -e ".[evo1,libero]"
|
||||
pip install -e ".[training,evo1,libero]"
|
||||
```
|
||||
|
||||
3. Install a `flash-attn` wheel only if it is compatible with your Python, PyTorch, CUDA, and GPU stack. EVO1 falls back to standard attention when `flash_attn` is not available.
|
||||
|
||||
EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run may download the configured VLM checkpoint unless `policy.vlm_model_name` points to a local model directory.
|
||||
EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run downloads the configured VLM checkpoint and later runs reuse it from the Hugging Face cache.
|
||||
|
||||
## Data Requirements
|
||||
|
||||
@@ -92,7 +92,7 @@ lerobot-train \
|
||||
|
||||
### Stage 2
|
||||
|
||||
Stage 2 finetunes the VLM branches and action head. A common workflow starts from a Stage 1 checkpoint:
|
||||
Stage 2 loads the Stage 1 policy, but starts a fresh optimizer and scheduler:
|
||||
|
||||
```bash
|
||||
lerobot-train \
|
||||
@@ -152,16 +152,154 @@ lerobot-rollout \
|
||||
|
||||
### LIBERO Evaluation
|
||||
|
||||
> [!NOTE]
|
||||
> Benchmark results for a `lerobot`-hosted LIBERO checkpoint trained with this implementation
|
||||
> will be added once training completes.
|
||||
#### Reference result
|
||||
|
||||
The official EVO1 LIBERO rollout protocol uses the raw LIBERO camera feature names
|
||||
> [!NOTE]
|
||||
> The released Stage-2 checkpoint passed clean-download and rollout verification:
|
||||
> [`zuoxingdong/evo1_libero`](https://huggingface.co/zuoxingdong/evo1_libero), revision
|
||||
> [`515921f4a2c1d3f3ad523721eafa26fdf2af315b`](https://huggingface.co/zuoxingdong/evo1_libero/commit/515921f4a2c1d3f3ad523721eafa26fdf2af315b).
|
||||
> The clean-download evaluation used LeRobot revision
|
||||
> [`e40b58a8dfa9e7b86918c374791599d070518d11`](https://github.com/huggingface/lerobot/commit/e40b58a8dfa9e7b86918c374791599d070518d11).
|
||||
|
||||
The single-run Stage-2 checkpoint at step 70,000 produced:
|
||||
|
||||
| Suite | Successful episodes | Episodes | Success rate |
|
||||
| -------------- | ------------------: | --------: | -----------: |
|
||||
| LIBERO Spatial | 485 | 500 | 97.0% |
|
||||
| LIBERO Object | 496 | 500 | 99.2% |
|
||||
| LIBERO Goal | 483 | 500 | 96.6% |
|
||||
| LIBERO-10 | 469 | 500 | 93.8% |
|
||||
| **Overall** | **1,933** | **2,000** | **96.65%** |
|
||||
|
||||
These results use one trained checkpoint and evaluation seed `1000`; they are not a multi-seed
|
||||
mean or confidence estimate.
|
||||
|
||||
#### Reference training recipe
|
||||
|
||||
The released checkpoint records the complete resolved Stage-2 configuration in
|
||||
[`train_config.json`](https://huggingface.co/zuoxingdong/evo1_libero/blob/515921f4a2c1d3f3ad523721eafa26fdf2af315b/train_config.json).
|
||||
The measured run used two H100 GPUs with two DDP processes and batch 64 per process, giving global batch 128. Both stages used the same topology. The base VLM came from revision
|
||||
`014c0583a0d4bedf29fbe2dbff4f865eb998e171` of `OpenGVLab/InternVL3-1B-hf`.
|
||||
The released artifact does not record the exact LeRobot training commit or its original dependency lock,
|
||||
so the commands below reproduce the recorded configuration and topology from a current checkout rather
|
||||
than reconstructing the software environment bit for bit.
|
||||
|
||||
From a LeRobot source checkout, install the locked dependencies and download that exact VLM revision:
|
||||
|
||||
```bash
|
||||
uv sync --locked --extra training --extra evo1 --extra libero
|
||||
VLM_DIR=$(uv run hf download OpenGVLab/InternVL3-1B-hf \
|
||||
--revision=014c0583a0d4bedf29fbe2dbff4f865eb998e171)
|
||||
```
|
||||
|
||||
Stage 1 freezes the VLM and trains the action head for 5,000 steps:
|
||||
|
||||
```bash
|
||||
uv run accelerate launch --num_processes=2 -m lerobot.scripts.lerobot_train \
|
||||
--dataset.repo_id=lerobot/libero \
|
||||
--dataset.revision=a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4 \
|
||||
--dataset.video_backend=torchcodec \
|
||||
--dataset.return_uint8=true \
|
||||
--dataset.image_transforms.enable=true \
|
||||
--dataset.use_imagenet_stats=true \
|
||||
--dataset.eval_split=0.0 \
|
||||
--policy.type=evo1 \
|
||||
--policy.training_stage=stage1 \
|
||||
--policy.apply_training_stage_defaults=true \
|
||||
--policy.vlm_model_name="${VLM_DIR}" \
|
||||
--policy.vlm_num_layers=14 \
|
||||
--policy.vlm_dtype=bfloat16 \
|
||||
--policy.device=cuda \
|
||||
--policy.use_amp=true \
|
||||
--policy.use_flash_attn=true \
|
||||
--policy.enable_gradient_checkpointing=true \
|
||||
--policy.gradient_checkpointing_use_reentrant=false \
|
||||
--policy.image_resolution='[448,448]' \
|
||||
--policy.chunk_size=50 \
|
||||
--policy.n_action_steps=50 \
|
||||
--policy.max_state_dim=24 \
|
||||
--policy.max_action_dim=24 \
|
||||
--policy.dropout=0.2 \
|
||||
--policy.optimizer_lr=1e-5 \
|
||||
--policy.optimizer_weight_decay=1e-3 \
|
||||
--policy.optimizer_grad_clip_norm=1.0 \
|
||||
--policy.scheduler_warmup_steps=1000 \
|
||||
--policy.push_to_hub=false \
|
||||
--use_policy_training_preset=true \
|
||||
--batch_size=64 \
|
||||
--steps=5000 \
|
||||
--save_checkpoint=true \
|
||||
--save_checkpoint_to_hub=false \
|
||||
--save_freq=2500 \
|
||||
--log_freq=10 \
|
||||
--env_eval_freq=0 \
|
||||
--num_workers=4 \
|
||||
--prefetch_factor=2 \
|
||||
--persistent_workers=true \
|
||||
--seed=1000 \
|
||||
--wandb.enable=false \
|
||||
--output_dir=./outputs/evo1-libero-stage1-g128-5k
|
||||
```
|
||||
|
||||
Stage 2 loads the Stage-1 policy but starts a fresh optimizer and scheduler. It trains for 80,000 steps;
|
||||
the reported checkpoint is the save at step 70,000:
|
||||
|
||||
```bash
|
||||
uv run accelerate launch --num_processes=2 -m lerobot.scripts.lerobot_train \
|
||||
--dataset.repo_id=lerobot/libero \
|
||||
--dataset.revision=a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4 \
|
||||
--dataset.video_backend=torchcodec \
|
||||
--dataset.return_uint8=true \
|
||||
--dataset.image_transforms.enable=true \
|
||||
--dataset.use_imagenet_stats=true \
|
||||
--dataset.eval_split=0.0 \
|
||||
--policy.path=./outputs/evo1-libero-stage1-g128-5k/checkpoints/005000/pretrained_model \
|
||||
--policy.training_stage=stage2 \
|
||||
--policy.apply_training_stage_defaults=true \
|
||||
--policy.vlm_model_name="${VLM_DIR}" \
|
||||
--policy.vlm_num_layers=14 \
|
||||
--policy.vlm_dtype=float32 \
|
||||
--policy.device=cuda \
|
||||
--policy.use_amp=true \
|
||||
--policy.use_flash_attn=true \
|
||||
--policy.enable_gradient_checkpointing=true \
|
||||
--policy.gradient_checkpointing_use_reentrant=false \
|
||||
--policy.image_resolution='[448,448]' \
|
||||
--policy.chunk_size=50 \
|
||||
--policy.n_action_steps=50 \
|
||||
--policy.max_state_dim=24 \
|
||||
--policy.max_action_dim=24 \
|
||||
--policy.dropout=0.2 \
|
||||
--policy.optimizer_lr=1e-5 \
|
||||
--policy.optimizer_weight_decay=1e-3 \
|
||||
--policy.optimizer_grad_clip_norm=1.0 \
|
||||
--policy.scheduler_warmup_steps=1000 \
|
||||
--policy.push_to_hub=false \
|
||||
--use_policy_training_preset=true \
|
||||
--batch_size=64 \
|
||||
--steps=80000 \
|
||||
--resume=false \
|
||||
--save_checkpoint=true \
|
||||
--save_checkpoint_to_hub=false \
|
||||
--save_freq=10000 \
|
||||
--log_freq=10 \
|
||||
--env_eval_freq=0 \
|
||||
--num_workers=4 \
|
||||
--prefetch_factor=2 \
|
||||
--persistent_workers=true \
|
||||
--seed=1000 \
|
||||
--wandb.enable=false \
|
||||
--output_dir=./outputs/evo1-libero-stage2-g128-80k
|
||||
```
|
||||
|
||||
#### Author-format evaluation profile
|
||||
|
||||
The author-format EVO1 LIBERO profile uses the raw LIBERO camera feature names
|
||||
(`observation.images.agentview_image` and `observation.images.robot0_eye_in_hand_image`), replans every
|
||||
14 actions, and binarizes the gripper command before stepping the simulator. The EVO1 policy postprocessor
|
||||
can crop the padded 24D action back to the 7D LIBERO action space and apply that gripper binarization. To
|
||||
evaluate a LIBERO checkpoint under the same one-episode-per-task setting, keep the raw camera names instead
|
||||
of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
|
||||
evaluate an author-format checkpoint under the same one-episode-per-task setting, keep the raw camera names
|
||||
instead of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
|
||||
|
||||
```bash
|
||||
lerobot-eval \
|
||||
@@ -181,6 +319,75 @@ lerobot-eval \
|
||||
--eval.n_episodes=1
|
||||
```
|
||||
|
||||
#### Native `lerobot/libero` v3 profile
|
||||
|
||||
Revision `a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4` stores camera features as `image` and
|
||||
`image2`. This example evaluates all ten LIBERO Object tasks, launching each task in a fresh process:
|
||||
|
||||
```bash
|
||||
export MUJOCO_GL=egl
|
||||
export PYOPENGL_PLATFORM=egl
|
||||
|
||||
suite=libero_object
|
||||
horizon=280
|
||||
for task_id in {0..9}; do
|
||||
lerobot-eval \
|
||||
--policy.path=zuoxingdong/evo1_libero \
|
||||
--policy.pretrained_revision=515921f4a2c1d3f3ad523721eafa26fdf2af315b \
|
||||
--policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
|
||||
--policy.device=cuda \
|
||||
--policy.use_amp=true \
|
||||
--policy.vlm_dtype=bfloat16 \
|
||||
--policy.use_flash_attn=false \
|
||||
--policy.enable_gradient_checkpointing=false \
|
||||
--policy.vlm_num_layers=14 \
|
||||
--policy.image_resolution='[448,448]' \
|
||||
--policy.max_text_length=1024 \
|
||||
--policy.chunk_size=50 \
|
||||
--policy.n_action_steps=14 \
|
||||
--policy.max_state_dim=24 \
|
||||
--policy.max_action_dim=24 \
|
||||
--policy.num_inference_timesteps=32 \
|
||||
--policy.postprocess_action_dim=7 \
|
||||
--policy.binarize_gripper=true \
|
||||
--policy.gripper_threshold=0.0 \
|
||||
--policy.gripper_below_threshold_value=-1.0 \
|
||||
--policy.gripper_above_threshold_value=1.0 \
|
||||
--env.type=libero \
|
||||
--env.task="${suite}" \
|
||||
--env.task_ids="[${task_id}]" \
|
||||
--env.camera_name=agentview_image,robot0_eye_in_hand_image \
|
||||
--env.camera_name_mapping="{agentview_image: image, robot0_eye_in_hand_image: image2}" \
|
||||
--env.control_mode=relative \
|
||||
--env.obs_type=pixels_agent_pos \
|
||||
--env.observation_width=448 \
|
||||
--env.observation_height=448 \
|
||||
--env.init_states=true \
|
||||
--env.episode_length="${horizon}" \
|
||||
--env.render_mode=rgb_array \
|
||||
--env.max_parallel_tasks=1 \
|
||||
--eval.n_episodes=50 \
|
||||
--eval.batch_size=1 \
|
||||
--eval.use_async_envs=false \
|
||||
--eval.recording=false \
|
||||
--seed=1000 \
|
||||
--output_dir="./outputs/evo1-libero-stage2-70k-eval/${suite}/task-${task_id}" \
|
||||
--job_name="evo1-libero-stage2-70k-${suite}-task-${task_id}"
|
||||
done
|
||||
```
|
||||
|
||||
Run all ten task IDs for each suite with these horizons:
|
||||
|
||||
| `env.task` | `env.episode_length` |
|
||||
| ---------------- | -------------------: |
|
||||
| `libero_spatial` | `280` |
|
||||
| `libero_object` | `280` |
|
||||
| `libero_goal` | `300` |
|
||||
| `libero_10` | `520` |
|
||||
|
||||
Set `suite` and `horizon` for each row. This gives 500 episodes per suite and 2,000 episodes overall, while
|
||||
the loop's fresh process per task matches the measured RNG-reset topology.
|
||||
|
||||
## References
|
||||
|
||||
- [EVO1 repository](https://github.com/MINT-SJTU/Evo-1)
|
||||
|
||||
@@ -30,7 +30,7 @@ The goal: lower the barrier to entry for robotics, so that everyone can contribu
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="../../media/readme/robots_control_video.webp" width="640px" alt="Reachy 2 Demo">
|
||||
<img src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/robots_control_video.webp" width="640px" alt="Reachy 2 Demo">
|
||||
</div>
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -108,6 +108,7 @@ own binding plus a matching image block, e.g.
|
||||
|
||||
```yaml
|
||||
ask_vqa_top:
|
||||
route: vqa
|
||||
bindings:
|
||||
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.top)"
|
||||
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.top)"
|
||||
@@ -127,7 +128,9 @@ ask_vqa_top:
|
||||
}
|
||||
```
|
||||
|
||||
Add one such sub-recipe per camera the dataset records.
|
||||
Add one such sub-recipe per camera the dataset records. The explicit
|
||||
`route: vqa` marker makes a matching sparse VQA annotation take precedence
|
||||
over normal weighted blend selection; component names are purely descriptive.
|
||||
|
||||
## Layer 3 — training format
|
||||
|
||||
@@ -141,7 +144,20 @@ sample["target_message_indices"]
|
||||
|
||||
The renderer does not apply a tokenizer chat template. Policy processors decide how to serialize the messages for their backbone, which keeps the same dataset usable across SmolVLA, Pi0.5, and any future VLM that expects OpenAI-style chat messages.
|
||||
|
||||
## Blends
|
||||
|
||||
Blend recipes select one weighted sub-recipe deterministically from the sample index.
|
||||
`recipes/subtask_mem.yaml` trains the compact core blend — high-level subtask prediction, low-level execution, and memory. `recipes/subtask_mem_vqa_speech.yaml` is the fuller variant that also adds VQA and spoken interjection responses.
|
||||
|
||||
`recipes/subtask_joint.yaml` demonstrates joint sequence training rather than a
|
||||
weighted blend. For the same sample, its assistant subtask is supervised with
|
||||
text cross-entropy on the `low_level` stream while action prediction remains
|
||||
active, matching the joint setup from the π0.5 paper. Enable
|
||||
`--policy.joint_subtask_conditioning=true` to use that subtask conditioning at inference.
|
||||
|
||||
## Graceful absence
|
||||
|
||||
If both language columns are missing, `None`, or empty, `RenderMessagesStep` is a no-op.
|
||||
If an event-scoped branch is selected on a frame without the required event row, rendering returns `None`, allowing a loader to retry another sample.
|
||||
If both language columns are missing, `None`, or empty, `RenderMessagesStep` uses
|
||||
the task string as low-level supervision when available and otherwise leaves the
|
||||
sample unchanged. For an annotated sample, if no recipe branch applies and no
|
||||
task fallback exists, rendering returns `None`, allowing a loader to retry another sample.
|
||||
|
||||
@@ -142,6 +142,22 @@ repo_id = "yaak-ai/L2D-v3"
|
||||
dataset = StreamingLeRobotDataset(repo_id) # streams directly from the Hub
|
||||
```
|
||||
|
||||
Datasets stored in an [HF Storage Bucket](https://huggingface.co/docs/hub/storage-buckets) (`hf://buckets/`) can be streamed the same way by passing `repo_type="bucket"`:
|
||||
|
||||
```python
|
||||
dataset = StreamingLeRobotDataset("my-org/my-bucket", repo_type="bucket")
|
||||
```
|
||||
|
||||
Both options are available in `lerobot-train` through `--dataset.streaming=true`, and `--dataset.repo_type=bucket` to stream from a bucket instead of a Hub dataset repo:
|
||||
|
||||
```bash
|
||||
lerobot-train \
|
||||
--dataset.repo_id=my-org/my-bucket \
|
||||
--dataset.repo_type=bucket \
|
||||
--dataset.streaming=true \
|
||||
...
|
||||
```
|
||||
|
||||
<div style="display:flex; justify-content:center; gap:12px; flex-wrap:wrap;">
|
||||
<figure style="margin:0; text-align:center;">
|
||||
<img
|
||||
|
||||
@@ -92,6 +92,20 @@ LIBERO supports two control modes — `relative` (default) and `absolute`. Diffe
|
||||
--env.control_mode=relative # or "absolute"
|
||||
```
|
||||
|
||||
### Reset performance
|
||||
|
||||
By default, LeRobot preserves LIBERO's hard-reset behavior. With fixed initial
|
||||
states enabled, you can opt into soft resets to skip rebuilding the simulator
|
||||
model and renderer on every episode:
|
||||
|
||||
```bash
|
||||
--env.init_states=true --env.hard_reset=false
|
||||
```
|
||||
|
||||
Soft resets are faster but are not bit-identical to hard resets after the
|
||||
environment's settling steps, so camera observations and policy results may
|
||||
differ slightly. Use hard resets when reproducing benchmark results.
|
||||
|
||||
### Policy inputs and outputs
|
||||
|
||||
**Observations:**
|
||||
|
||||
@@ -134,6 +134,20 @@ LIBERO-plus supports two control modes — `relative` (default) and `absolute`.
|
||||
--env.control_mode=relative # or "absolute"
|
||||
```
|
||||
|
||||
### Reset performance
|
||||
|
||||
By default, LeRobot preserves LIBERO's hard-reset behavior. With fixed initial
|
||||
states enabled, you can opt into soft resets to skip rebuilding the simulator
|
||||
model and renderer on every episode:
|
||||
|
||||
```bash
|
||||
--env.init_states=true --env.hard_reset=false
|
||||
```
|
||||
|
||||
Soft resets are faster but are not bit-identical to hard resets after the
|
||||
environment's settling steps, so camera observations and policy results may
|
||||
differ slightly. Use hard resets when reproducing benchmark results.
|
||||
|
||||
### Policy inputs and outputs
|
||||
|
||||
**Observations:**
|
||||
|
||||
+114
-118
@@ -1,28 +1,29 @@
|
||||
# Multi-GPU Training
|
||||
|
||||
This guide shows you how to train policies on multiple GPUs using [Hugging Face Accelerate](https://huggingface.co/docs/accelerate).
|
||||
LeRobot trains on multiple GPUs through [Hugging Face Accelerate](https://huggingface.co/docs/accelerate). Three data-parallel layouts are supported:
|
||||
|
||||
| Layout | What it does | Config |
|
||||
| -------- | ------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **DDP** | Replicates the full model on every GPU | default on any multi-GPU launch |
|
||||
| **FSDP** | Shards parameters, gradients, and optimizer state across GPUs | `--parallelism.dp_shard=N` |
|
||||
| **HSDP** | Shards within groups of GPUs, replicates across groups | `--parallelism.dp_replicate=R --parallelism.dp_shard=S` |
|
||||
|
||||
## Installation
|
||||
|
||||
`accelerate` is included in the `training` extra. Install it with:
|
||||
`accelerate` is included in the `training` extra:
|
||||
|
||||
```bash
|
||||
pip install 'lerobot[training]'
|
||||
```
|
||||
|
||||
## Training with Multiple GPUs
|
||||
## Launching
|
||||
|
||||
You can launch training in two ways:
|
||||
Distributed training can be launched through both `torchrun` and `accelerate launch`. Accelerate is used as a plain launcher: it does not manage the training configuration, and every distributed training setting lives in LeRobot's own config system.
|
||||
|
||||
### Option 1: Without config (specify parameters directly)
|
||||
|
||||
You can specify all parameters directly in the command without running `accelerate config`:
|
||||
With `torchrun`:
|
||||
|
||||
```bash
|
||||
accelerate launch \
|
||||
--multi_gpu \
|
||||
--num_processes=2 \
|
||||
$(which lerobot-train) \
|
||||
torchrun --nproc-per-node=2 $(which lerobot-train) \
|
||||
--dataset.repo_id=${HF_USER}/my_dataset \
|
||||
--policy.type=act \
|
||||
--policy.repo_id=${HF_USER}/my_trained_policy \
|
||||
@@ -31,32 +32,10 @@ accelerate launch \
|
||||
--wandb.enable=true
|
||||
```
|
||||
|
||||
**Key accelerate parameters:**
|
||||
|
||||
- `--multi_gpu`: Enable multi-GPU training
|
||||
- `--num_processes=2`: Number of GPUs to use
|
||||
- `--mixed_precision=fp16`: Use fp16 mixed precision (or `bf16` if supported)
|
||||
|
||||
### Option 2: Using accelerate config
|
||||
|
||||
If you prefer to save your configuration, you can optionally configure accelerate for your hardware setup by running:
|
||||
With `accelerate launch` (as a plain launcher):
|
||||
|
||||
```bash
|
||||
accelerate config
|
||||
```
|
||||
|
||||
This interactive setup will ask you questions about your training environment (number of GPUs, mixed precision settings, etc.) and saves the configuration for future use. For a simple multi-GPU setup on a single machine, you can use these recommended settings:
|
||||
|
||||
- Compute environment: This machine
|
||||
- Number of machines: 1
|
||||
- Number of processes: (number of GPUs you want to use)
|
||||
- GPU ids to use: (leave empty to use all)
|
||||
- Mixed precision: fp16 or bf16 (recommended for faster training)
|
||||
|
||||
Then launch training with:
|
||||
|
||||
```bash
|
||||
accelerate launch $(which lerobot-train) \
|
||||
accelerate launch --num_processes=2 $(which lerobot-train) \
|
||||
--dataset.repo_id=${HF_USER}/my_dataset \
|
||||
--policy.type=act \
|
||||
--policy.repo_id=${HF_USER}/my_trained_policy \
|
||||
@@ -65,116 +44,133 @@ accelerate launch $(which lerobot-train) \
|
||||
--wandb.enable=true
|
||||
```
|
||||
|
||||
## How It Works
|
||||
With no `--parallelism.*` flags, a multi-process launch runs plain DDP. Multi-node runs use the standard `torchrun --nnodes/--node-rank/--rdzv-endpoint` flags (or `accelerate launch --num_machines/--machine_rank/--main_process_ip`).
|
||||
|
||||
When you launch training with accelerate:
|
||||
> [!WARNING]
|
||||
> Accelerate's YAML config files (`accelerate launch --config_file some.yaml`, `accelerate config`) are not supported. They configure the engine through environment variables, bypassing LeRobot's configuration system, so `train_config.json` would no longer describe the settings a run actually used. `lerobot-train` therefore refuses to start when [accelerate environment variables](https://huggingface.co/docs/accelerate/usage_guides/fsdp) are set. Put the settings in `--parallelism.*` / `--accelerator.*` flags instead, or set `LEROBOT_ALLOW_ACCELERATE_ENV=1` to acknowledge the override and proceed anyway.
|
||||
|
||||
1. **Automatic detection**: LeRobot automatically detects if it's running under accelerate
|
||||
2. **Data distribution**: Your batch is automatically split across GPUs
|
||||
3. **Gradient synchronization**: Gradients are synchronized across GPUs during backpropagation
|
||||
4. **Single process logging**: Only the main process logs to wandb and saves checkpoints
|
||||
## Batch semantics, learning rate, and steps
|
||||
|
||||
## Learning Rate and Training Steps Scaling
|
||||
Each of the `dp_replicate × dp_shard` data-parallel workers loads its own `--batch_size` micro-batch every step, so one training step consumes `batch_size × dp_world_size` samples, and `× gradient_accumulation_steps` of those go into each optimizer update:
|
||||
|
||||
**Important:** LeRobot does **NOT** automatically scale learning rates or training steps based on the number of GPUs. This gives you full control over your training hyperparameters.
|
||||
|
||||
### Why No Automatic Scaling?
|
||||
|
||||
Many distributed training frameworks automatically scale the learning rate by the number of GPUs (e.g., `lr = base_lr × num_gpus`).
|
||||
However, LeRobot keeps the learning rate exactly as you specify it.
|
||||
|
||||
### When and How to Scale
|
||||
|
||||
If you want to scale your hyperparameters when using multiple GPUs, you should do it manually:
|
||||
|
||||
**Learning Rate Scaling:**
|
||||
|
||||
```bash
|
||||
# Example: 2 GPUs with linear LR scaling
|
||||
# Base LR: 1e-4, with 2 GPUs -> 2e-4
|
||||
accelerate launch --num_processes=2 $(which lerobot-train) \
|
||||
--optimizer.lr=2e-4 \
|
||||
--dataset.repo_id=lerobot/pusht \
|
||||
--policy.type=act
|
||||
```
|
||||
effective_batch_size = batch_size × dp_world_size × gradient_accumulation_steps
|
||||
```
|
||||
|
||||
**Training Steps Scaling:**
|
||||
The training banner prints this factorization at startup. `--steps` counts loop steps (micro-batches per worker), not optimizer updates.
|
||||
|
||||
Since the effective batch size `bs` increases with multiple GPUs (batch_size × num_gpus), you may want to reduce the number of training steps proportionally:
|
||||
Gradient accumulation is a first-class flag:
|
||||
|
||||
```bash
|
||||
# Example: 2 GPUs with effective batch size 2x larger
|
||||
# Original: batch_size=8, steps=100000
|
||||
# With 2 GPUs: batch_size=8 (16 in total), steps=50000
|
||||
accelerate launch --num_processes=2 $(which lerobot-train) \
|
||||
--batch_size=8 \
|
||||
--steps=50000 \
|
||||
--dataset.repo_id=lerobot/pusht \
|
||||
--policy.type=act
|
||||
torchrun --nproc-per-node=2 $(which lerobot-train) \
|
||||
--batch_size=8 --accelerator.gradient_accumulation.steps=4 ...
|
||||
```
|
||||
|
||||
## Training Large Models with FSDP
|
||||
**LeRobot does not auto-scale the learning rate or the number of steps** when the effective batch size grows. If you scale out and want equivalent training, please adjust manually, e.g. with 2 GPUs: double `--optimizer.lr` (linear scaling), or halve `--steps`.
|
||||
|
||||
DDP replicates the full model on every GPU, so a model that doesn't fit on one GPU won't fit under
|
||||
DDP either. For large models, use **FSDP** (Fully Sharded Data Parallel), which shards parameters,
|
||||
gradients, and optimizer state across GPUs. See the [accelerate FSDP guide](https://huggingface.co/docs/accelerate/usage_guides/fsdp) for background.
|
||||
## Sharded training (FSDP)
|
||||
|
||||
An example on how to launch LeRobot training with FSDP across 4 GPUs (1 machine):
|
||||
If a model is too large to train with DDP, shard it with FSDP2:
|
||||
|
||||
```bash
|
||||
accelerate launch --config_file fsdp.yaml --num_processes=4 $(which lerobot-train) \
|
||||
torchrun --nproc-per-node=4 $(which lerobot-train) \
|
||||
--dataset.repo_id=${HF_USER}/my_dataset \
|
||||
--policy.type=<your_policy> \
|
||||
--parallelism.dp_shard=4 \
|
||||
--accelerator.mixed_precision=bf16 \
|
||||
--output_dir=outputs/train/my_policy_fsdp
|
||||
```
|
||||
|
||||
A minimal `fsdp.yaml` (FSDP1; shards params/grads/optimizer — ZeRO-3-equivalent):
|
||||
`--parallelism.dp_shard=-1` shards over however many processes the launcher started.
|
||||
|
||||
```yaml
|
||||
compute_environment: LOCAL_MACHINE
|
||||
distributed_type: FSDP
|
||||
mixed_precision: bf16
|
||||
num_machines: 1
|
||||
num_processes: 4
|
||||
fsdp_config:
|
||||
fsdp_version: 1
|
||||
fsdp_sharding_strategy: FULL_SHARD # params + grads + optimizer (ZeRO-3)
|
||||
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
|
||||
fsdp_transformer_layer_cls_to_wrap: <YourTransformerBlock> # repeated block class to shard
|
||||
fsdp_use_orig_params: true # required: optimizer is built pre-prepare
|
||||
fsdp_state_dict_type: FULL_STATE_DICT
|
||||
### Wrap units
|
||||
|
||||
FSDP shards the model in units (typically the repeated transformer block) and gathers one unit at a time during forward/backward. Policies declare their wrap units via `_fsdp_wrap_modules` on the policy class. For example, ACT declares `["ACTEncoderLayer", "ACTDecoderLayer"]` and FastWAM declares `["MoTLayer"]`. For a policy without a `_fsdp_wrap_modules` declaration, pass one of the flags below. You can specify the module class name explicitly, or use a size-based policy instead:
|
||||
|
||||
```bash
|
||||
--accelerator.fsdp.wrap_modules='["MyTransformerBlock"]' # explicit class names
|
||||
--accelerator.fsdp.min_num_params=1000000 # or: wrap every submodule above 1M params
|
||||
```
|
||||
|
||||
Set `fsdp_transformer_layer_cls_to_wrap` to your model's repeated transformer-block class so each
|
||||
block is sharded as its own unit. `fsdp_use_orig_params: true` is required because LeRobot builds the
|
||||
optimizer before `accelerator.prepare()`.
|
||||
If a policy doesn't declare `_fsdp_wrap_modules` and no `--accelerator.fsdp.wrap_modules` or `--accelerator.fsdp.min_num_params` is passed, the run fails at startup rather than silently wrapping only the root module (which would forfeit all sharding memory savings).
|
||||
|
||||
### FSDP checkpoints
|
||||
Other sharding settings:
|
||||
|
||||
LeRobot gathers the full state dict across all ranks and the main process writes it as a single
|
||||
`model.safetensors`, loadable as usual with `Policy.from_pretrained(...)`. Two things to look out for:
|
||||
- `--accelerator.fsdp.reshard_after_forward`: whether to keep each unit's parameters resident after forward.
|
||||
- `--accelerator.fsdp.cpu_offload`: keeps parameters, gradients and optimizer states on CPU.
|
||||
- `--accelerator.fsdp.ignored_modules`: a regex of module paths to keep unsharded.
|
||||
|
||||
- **Checkpoints store fp32 weights.** Under mixed precision (`bf16`/`fp16`) FSDP keeps an fp32 master
|
||||
copy, and the checkpoint saves it (~2× the bf16 size on disk) so training can resume consistently
|
||||
with the fp32 optimizer state; `from_pretrained` casts back to the policy dtype on load. FSDP-specific
|
||||
caveat: an fp32 checkpoint is materialized in full precision on the target device _before_ casting,
|
||||
so loading it for inference on a tight GPU can OOM even when the bf16 model would fit — load on CPU
|
||||
first, or cast `model.safetensors` to the deployment dtype offline.
|
||||
- The sharded optimizer state is gathered into a full (world-size-independent) state dict and saved
|
||||
alongside the model in the same `optimizer_state.safetensors` / `optimizer_param_groups.json`
|
||||
format as single-GPU training, so **resume-from-checkpoint is supported** with `--resume=true`.
|
||||
Resume reshards both the model and the optimizer state to the _current_ FSDP topology, so you can
|
||||
resume an FSDP checkpoint on a different number of GPUs. Note that the data sampler is only
|
||||
sample-exact when the world size and batch size match the original run (a warning is logged
|
||||
otherwise); the optimizer/model state itself is unaffected.
|
||||
### HSDP
|
||||
|
||||
Hybrid Sharded Data Parallel: parameters, gradients and optimizer states are sharded across `dp_shard` ranks, and that sharding is replicated `dp_replicate` times. Parameter all-gathers and gradient reduce-scatters stay inside a shard group; only the all-reduce that synchronizes the replicas crosses between groups. The two degrees must multiply to the world size:
|
||||
|
||||
```bash
|
||||
# 16 GPUs = 2 nodes × 8: shard within each node, replicate across nodes
|
||||
torchrun --nnodes=2 --nproc-per-node=8 ... $(which lerobot-train) \
|
||||
--parallelism.dp_replicate=2 --parallelism.dp_shard=8 ...
|
||||
```
|
||||
|
||||
## Checkpoints
|
||||
|
||||
Every checkpoint contains a `pretrained_model/` directory and a `training_state/` directory:
|
||||
|
||||
```text
|
||||
005000/ # the training step at that checkpoint
|
||||
├── pretrained_model/
|
||||
│ ├── config.json # policy config
|
||||
│ ├── train_config.json # the full training config
|
||||
│ ├── model.safetensors # full weights (checkpoint_format ∈ {safetensors, safetensors_dcp}, or any non-sharded run)
|
||||
│ ├── pytorch_model_fsdp_0/ # DCP weight shards (checkpoint_format ∈ {dcp, safetensors_dcp})
|
||||
│ ├── policy_preprocessor.json # preprocessor config (when the run has a preprocessor)
|
||||
│ ├── policy_preprocessor_step_*.safetensors # state of the stateful preprocessor steps
|
||||
│ ├── policy_postprocessor.json # postprocessor config (when the run has a postprocessor)
|
||||
│ └── policy_postprocessor_step_*.safetensors # state of the stateful postprocessor steps
|
||||
└── training_state/
|
||||
├── training_step.json # step counter, topology, and batch semantics
|
||||
├── rng_state.safetensors # rng states
|
||||
├── scheduler_state.json # scheduler state (when the run has a scheduler)
|
||||
├── optimizer_state.safetensors # full optimizer state (non-sharded runs)
|
||||
├── optimizer_param_groups.json # optimizer param groups (non-sharded runs)
|
||||
└── optimizer_0/ # DCP optimizer shards (sharded runs)
|
||||
```
|
||||
|
||||
During single-GPU or DDP training, the pipeline serializes each state dict into a single file: `model.safetensors` for the model and `optimizer_state.safetensors` for the optimizer.
|
||||
|
||||
During sharded training, the optimizer state is saved as DCP shards under `training_state/optimizer_0/`, and the layout of the model under `pretrained_model/` can be configured through `--checkpoint_format`:
|
||||
|
||||
| `--checkpoint_format` | Weights artifact | Use when |
|
||||
| ------------------------- | -------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `safetensors` _(default)_ | single `model.safetensors` only | you want every checkpoint immediately loadable with `from_pretrained` |
|
||||
| `dcp` | `pytorch_model_fsdp_0/` shard directory only | gathering the full weights makes saves and resumes too slow |
|
||||
| `safetensors_dcp` | both | you want fast resume _and_ immediately loadable checkpoints |
|
||||
|
||||
Two things to know about gathered (`safetensors`) checkpoints from sharded runs:
|
||||
|
||||
- **They store fp32 weights.** Under mixed precision training, FSDP keeps an fp32 master copy, and the checkpoint saves the master copy to make sure training resumes consistently.
|
||||
- The gather is collective (all ranks participate) but only the main process writes.
|
||||
|
||||
### Converting DCP checkpoints
|
||||
|
||||
`lerobot-convert-dcp` merges a DCP shard directory into a regular `model.safetensors`, offline and without GPUs:
|
||||
|
||||
```bash
|
||||
lerobot-convert-dcp --checkpoint_dir=outputs/train/run/checkpoints/005000
|
||||
lerobot-convert-dcp --checkpoint_dir=... --delete_dcp=true --push_to_hub=${HF_USER}/my_policy
|
||||
```
|
||||
|
||||
`--push_to_hub` publishes the converted directory as a model repo.
|
||||
|
||||
### Resuming
|
||||
|
||||
Resume with `--resume=true --config_path=.../checkpoints/last/pretrained_model/train_config.json`. Resuming from a DCP checkpoint supports resharding the model and optimizer state to the _current_ topology, which means you can resume with a different `dp_replicate/dp_shard` split. The data sampler can always resume at the right epoch and offset, but is only _sample-exact_ when the world size and batch size match the original run (a warning is logged otherwise).
|
||||
|
||||
> [!NOTE]
|
||||
> FSDP checkpoints written by LeRobot 0.6.x and earlier used a different on-disk layout (a gathered full optimizer state) and **cannot be resumed**.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `--policy.use_amp` flag in `lerobot-train` is only used when **not** running with accelerate. When using accelerate, mixed precision is controlled by accelerate's configuration.
|
||||
- Training logs, checkpoints, and hub uploads are only done by the main process to avoid conflicts. Non-main processes have console logging disabled to prevent duplicate output.
|
||||
- The effective batch size is `batch_size × num_gpus`. If you use 4 GPUs with `--batch_size=8`, your effective batch size is 32.
|
||||
- Learning rate scheduling is handled correctly across multiple processes—LeRobot sets `step_scheduler_with_optimizer=False` to prevent accelerate from adjusting scheduler steps based on the number of processes.
|
||||
- When saving or pushing models, LeRobot automatically unwraps the model from accelerate's distributed wrapper to ensure compatibility.
|
||||
- WandB integration automatically initializes only on the main process, preventing multiple runs from being created.
|
||||
- Checkpoint saves and end-of-training publishes are collective (every rank enters them). Gathered weights, sidecar files and Hub uploads are written by the main process alone.
|
||||
- Metrics are reduced across ranks before logging: losses are averaged, and `samples/s` reports cluster-wide throughput.
|
||||
- Learning-rate scheduling is stepped once per training step regardless of the number of processes (`step_scheduler_with_optimizer=False` is baked in).
|
||||
|
||||
For more advanced configurations and troubleshooting, see the [Accelerate documentation](https://huggingface.co/docs/accelerate). If you want to learn more about how to train on a large number of GPUs, checkout this awesome guide: [Ultrascale Playbook](https://huggingface.co/spaces/nanotron/ultrascale-playbook).
|
||||
For background on the underlying machinery, see the [Accelerate FSDP guide](https://huggingface.co/docs/accelerate/usage_guides/fsdp). To go deeper on large-scale training, check out the [Ultrascale Playbook](https://huggingface.co/spaces/nanotron/ultrascale-playbook).
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# Third-Party Robots & Teleoperators
|
||||
|
||||
The LeRobot ecosystem extends far beyond its officially supported hardware. Thanks to LeRobot's plugin architecture, the community has built integrations for a wide range of robot arms and teleoperation devices — from industrial manipulators to affordable hobbyist platforms, VR headsets, haptic devices, and full arm-plus-teleoperator kits. This page showcases community-maintained integrations you can use for teleoperation, data collection, and policy deployment.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These projects are developed and maintained by third parties. Please refer to each repository for installation instructions, hardware requirements, and support.
|
||||
|
||||
Drop-in plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_robot_` or `lerobot_teleoperator_`. Once installed, reference the `type` the plugin registers (see its README — it may differ from the package name) directly from any LeRobot command:
|
||||
|
||||
```bash
|
||||
pip install lerobot_robot_<name> lerobot_teleoperator_<name>
|
||||
|
||||
lerobot-record \
|
||||
--robot.type=<robot_name> \
|
||||
--teleop.type=<teleoperator_name> \
|
||||
--dataset.repo_id=${HF_USER}/my-dataset \
|
||||
--dataset.num_episodes=5
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> ⚠️ marks projects that are forks/extensions of LeRobot. They may require custom setup rather than working with an unmodified install. All other entries are drop-in plugins.
|
||||
|
||||
## Industrial & Collaborative Arms
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/SpesRobotics/lerobot-robot-xarm">lerobot-robot-xarm</a></td>
|
||||
<td>Plugin for the xArm collaborative arm series from <a href="https://www.ufactory.cc/">UFACTORY</a>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/lebai-robotics/lerobot_lebai">lerobot_lebai</a></td>
|
||||
<td>Plugin for the six-axis collaborative arms from <a href="https://lebai.ltd/en/">Lebai</a>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/wengmister/LeFranX">LeFranX</a> ⚠️</td>
|
||||
<td>LeRobot extension for the <a href="https://franka.de/">Franka</a> research arm, paired with the <a href="https://www.robotera.com/">RobotEra XHand</a> hand for VR teleoperation.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### Universal Robots UR5e
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/yechen056/UR5e-LeRobot">UR5e-LeRobot</a> ⚠️</td>
|
||||
<td>LeRobot extension for the <a href="https://www.universal-robots.com/">Universal Robots UR5e</a>, with single-arm and bimanual support.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/scy-v/lerobot_ur5e_auto">lerobot_ur5e_auto</a> ⚠️</td>
|
||||
<td>LeRobot extension for a mobile <a href="https://www.universal-robots.com/">Universal Robots UR5e</a>, adding automated recording at scale with minimal supervision.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_robot_ur5e</a></td>
|
||||
<td>Plugin for the <a href="https://www.universal-robots.com/">Universal Robots UR5e</a> with a <a href="https://robotiq.com/">Robotiq</a> gripper, over RTDE control.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Research & Learning Arms
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/TrossenRobotics/lerobot_trossen">lerobot_trossen</a></td>
|
||||
<td>Plugin for the WidowX and ALOHA-style arms from <a href="https://www.trossenrobotics.com/">Trossen Robotics</a>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### AgileX Piper
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/AgRoboticsResearch/lerobot_robot_piper">lerobot_robot_piper (AgRobotics Research)</a></td>
|
||||
<td>Plugin for the <a href="https://global.agilex.ai/">AgileX Piper</a> arm.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/WeGo-Robotics/lerobot_robot_piper">lerobot_robot_piper (WeGo Robotics)</a></td>
|
||||
<td>Plugin for the <a href="https://global.agilex.ai/">AgileX Piper</a> arm, with multi-arm teleoperation, safety limits, and GUI tools.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Affordable & Hobbyist Arms
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/servodevelop/fashionstar-lerobot-robot-cello">fashionstar-lerobot-robot-cello</a></td>
|
||||
<td>Plugin for the StarAI Cello 6+1 degrees of freedom robot arm from <a href="https://fashionstar.com.hk/">FashionStar</a>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/servodevelop/fashionstar-lerobot-robot-viola">fashionstar-lerobot-robot-viola</a></td>
|
||||
<td>Plugin for the compact StarAI Viola 6+1 degrees of freedom robot arm from <a href="https://fashionstar.com.hk/">FashionStar</a>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Service, Mobile & Utility Robots
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/ugo-plus/lerobot-robot-ugo-pro">lerobot-robot-ugo-pro</a></td>
|
||||
<td>Plugin for the ugo Pro dual-arm service robot from <a href="https://ugo.plus/products/ugo-pro/">ugo</a>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/zuoxingdong/lerobot_robot_lekiwi_pincopen">lerobot_robot_lekiwi_pincopen</a></td>
|
||||
<td>Plugin for a LeKiwi mobile manipulator with a <a href="https://github.com/pollen-robotics/PincOpen">PincOpen</a> gripper.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/KillingJacky/lerobot-robot-dummy">lerobot-robot-dummy</a></td>
|
||||
<td>Plugin simulating a robot for recording without hardware. Useful for debugging !</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Teleoperators
|
||||
|
||||
### VR & Motion Controllers
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/SpesRobotics/lerobot-teleoperator-teleop">lerobot-teleoperator-teleop</a></td>
|
||||
<td>Plugin turning a phone or VR headset into a teleoperator via <a href="https://immersiveweb.dev">WebXR</a>, wrapping the open-source <a href="https://github.com/SpesRobotics/teleop"><code>teleop</code></a> library.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/Jas000n/lerobot-teleoperator-spacemouse">lerobot-teleoperator-spacemouse</a></td>
|
||||
<td>Plugin for the <a href="https://3dconnexion.com/">3Dconnexion SpaceMouse</a>, with inverse kinematics for SO-ARMS robots.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/Dream-Machines-Robotics/vr-teleop-kit">vr-teleop-kit</a></td>
|
||||
<td>Plugin teleoperating arms from a <a href="https://www.meta.com/quest/">Meta Quest</a> (WebXR), relying on URDF descriptions for inverse kinematics.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/xensedyl/lerobot-teleoperator-pico4">lerobot-teleoperator-pico4</a></td>
|
||||
<td>Plugin for the <a href="https://www.picoxr.com/">PICO 4</a> VR headset, with a companion controller-free <a href="https://github.com/xensedyl/lerobot-teleoperator-pico4-hand">hand-tracking variant</a>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Leader Arms
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_teleoperator_gello</a></td>
|
||||
<td>Plugin for the 7 degrees of freedom <a href="https://wuphilipp.github.io/gello_site/">GELLO</a> teleoperator.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/uynitsuj/lerobot_teleoperator_yamactiveleader">lerobot_teleoperator_yamactiveleader</a></td>
|
||||
<td>Plugin for the active YAM teleoperator from <a href="https://i2rt.com/">I2RT</a>, a bilateral force-feedback arm.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/charlie8612/lerobot_teleoperator_omy">lerobot_teleoperator_omy</a></td>
|
||||
<td>Plugin for the OMY-L100 6 degrees of freedom teleoperator from <a href="https://www.robotis.com/">ROBOTIS</a>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://pypi.org/project/lerobot-teleoperator-pipermate/">lerobot-teleoperator-pipermate</a></td>
|
||||
<td>Plugin for the PiperMate teleoperator (<a href="https://fashionstar.com.hk/">FashionStar</a> UART servos), driving the <a href="https://global.agilex.ai/">AgileX Piper</a> arm.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Haptic Devices
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/chohh7391/lerobot_teleoperator_inverse3">lerobot_teleoperator_inverse3</a></td>
|
||||
<td>Plugin for the <a href="https://www.haply.co/">Haply Inverse3</a> haptic device, adding force-feedback teleoperation.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/hzhz112/lerobot_teleoperator_omega7">lerobot_teleoperator_omega7</a></td>
|
||||
<td>Plugin for the <a href="https://www.forcedimension.com/">Force Dimension omega.7</a> haptic device, adding force-feedback teleoperation.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Networked & Remote
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://pypi.org/project/lerobot-teleoperator-livekit/">lerobot-teleoperator-livekit</a></td>
|
||||
<td>Plugin receiving teleoperation commands over a <a href="https://livekit.io/">LiveKit</a> Portal (WebRTC) for remote control.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Full Kits (Robot + Teleoperator)
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/villekuosmanen/lerobot-arx5">lerobot-arx5</a></td>
|
||||
<td>Plugin for the <a href="https://www.arx-x.com/">ARX5</a> arm: <a href="https://pypi.org/project/lerobot-robot-arx5/"><code>lerobot-arx5</code></a> robot arm with its <a href="https://pypi.org/project/lerobot-teleoperator-arx5/"><code>lerobot-teleoperator-arx5</code></a> teleoperator arm.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/robertorobotics/Nextis-AIRA-3D">Nextis-AIRA-3D</a></td>
|
||||
<td>Plugin for the 7 degrees of freedom arm from <a href="https://www.nextis.tech">Nextis</a>: robot arm <code>aira_follower</code> and teleoperator arm <code>aira_leader</code>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/pravsels/lerobot_yam">lerobot_yam</a></td>
|
||||
<td>Plugin suite for the YAM arm from <a href="https://i2rt.com/">I2RT</a>: robot arm <code>yam_follower</code> and teleoperator arm <code>yam_leader</code>.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/robot-learning-co/trlc-dk1">trlc-dk1</a></td>
|
||||
<td>Plugin for the development kit from <a href="https://www.robot-learning.co/">The Robot Learning Company</a>: single and bimanual arms follower/teleoperator types.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers">hex_lerobot_drivers</a></td>
|
||||
<td>Plugin suite for <a href="https://hexfellow.com/">HEXFELLOW</a> devices: robots, teleoperators, and cameras (see <a href="./third_party_sensors">Cameras & Sensors</a>).</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/Hiwonder-official/lerobot-robot-nexarm-follower">lerobot-robot-nexarm-follower</a></td>
|
||||
<td>Plugin for the NexArm from <a href="https://www.hiwonder.com/">Hiwonder</a>: the <a href="https://github.com/Hiwonder-official/lerobot-robot-nexarm-follower">robot arm</a> and its matching <a href="https://github.com/Hiwonder-official/lerobot-teleoperator-nexarm-leader">teleoperator arm</a>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## ROS 2 Bridges
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/ngres/leros2">leros2</a></td>
|
||||
<td>Plugin bridging ROS 2 topics and actions to LeRobot robots and teleoperators.</td>
|
||||
</tr>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/ROBOTIS-GIT/lerobot_robot_ros2_zenoh">lerobot_robot_ros2_zenoh</a></td>
|
||||
<td>Plugin bridging ROS 2 robots to LeRobot over <a href="https://zenoh.io">Zenoh</a> pub/sub transport.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Contributing
|
||||
|
||||
Built your own LeRobot hardware integration? The plugin system makes it straightforward to add new robots and teleoperators — check out the [Bring Your Own Hardware](./integrate_hardware) guide to get started, and share your project with the community!
|
||||
@@ -0,0 +1,99 @@
|
||||
# Third-Party Cameras & Sensors
|
||||
|
||||
The LeRobot ecosystem extends far beyond its natively supported cameras (OpenCV, Intel RealSense, ZMQ, Reachy 2). Thanks to LeRobot's plugin architecture, the community has built drop-in camera and sensor integrations — from depth cameras to vision-based tactile sensors. This page showcases community-maintained camera and sensor integrations you can use for teleoperation, data collection, and policy deployment.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These projects are developed and maintained by third parties. Please refer to each repository for installation instructions, hardware requirements, and support.
|
||||
|
||||
Drop-in plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_camera_`. Once installed, reference the camera `type` the plugin registers (see its README — it may differ from the package name) directly from any LeRobot command:
|
||||
|
||||
```bash
|
||||
pip install lerobot_camera_<name>
|
||||
|
||||
lerobot-record \
|
||||
--robot.type=so101_follower \
|
||||
--robot.port=/dev/ttyACM0 \
|
||||
--robot.cameras="{ front: {type: <name>, width: 640, height: 480, fps: 30} }" \
|
||||
--dataset.repo_id=${HF_USER}/my-dataset \
|
||||
--dataset.num_episodes=5
|
||||
```
|
||||
|
||||
## Tactile Sensors
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/xensedyl/lerobot-camera-xense">lerobot-camera-xense</a></td>
|
||||
<td>Plugin for <a href="https://www.xenserobotics.com/">Xense</a> vision-based tactile sensors, exposing rectified/difference images, depth, and 2D markers.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Depth Cameras
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers/tree/main/lerobot_camera_berxel">lerobot_camera_berxel</a></td>
|
||||
<td>Plugin for the <a href="https://www.berxel.com/">Berxel</a> depth camera, part of the broader <a href="https://hexfellow.com/">HEXFELLOW</a> driver suite.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Networked Cameras
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_camera_zmq</a></td>
|
||||
<td>Plugin streaming <a href="https://www.stereolabs.com/">Stereolabs ZED</a> and USB camera frames from a Raspberry Pi over the network.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Virtual Cameras
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
|
||||
<colgroup>
|
||||
<col width="30%" />
|
||||
<col width="70%" />
|
||||
</colgroup>
|
||||
<thead style="border:0">
|
||||
<tr style="border:0"><th>Project</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border:0">
|
||||
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers/tree/main/lerobot_camera_dummy">lerobot_camera_dummy</a></td>
|
||||
<td>Plugin simulating a camera for recording without hardware. Useful for debugging !</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Contributing
|
||||
|
||||
Built your own LeRobot camera or sensor integration? Package it as an installable `lerobot_camera_<name>` plugin and it will be auto-discovered by the LeRobot CLI — see the [Bring Your Own Hardware](./integrate_hardware) guide and the [Cameras](./cameras) reference to get started, then share your project with the community!
|
||||
@@ -40,3 +40,15 @@ lerobot-eval \
|
||||
```
|
||||
|
||||
However, in most cases, presence of an accelerator is detected automatically and `policy.device` parameter can be omitted from CLI commands.
|
||||
|
||||
## Mixed precision
|
||||
|
||||
Training precision is owned by `--accelerator.mixed_precision`, which accepts `no` (default) and `bf16`:
|
||||
|
||||
```bash
|
||||
lerobot-train \
|
||||
--policy.type=act \
|
||||
--accelerator.mixed_precision=bf16 ...
|
||||
```
|
||||
|
||||
`bf16` requires an accelerator that supports it.
|
||||
|
||||
+12
-44
@@ -30,19 +30,10 @@ Only Qwen + the action head are used. The world model is not needed at inference
|
||||
|
||||
Available presets via `action_model_type`:
|
||||
|
||||
| Preset | Heads | Head dim |
|
||||
| ------- | ----- | -------- |
|
||||
| `DiT-B` | 12 | 64 |
|
||||
| `DiT-L` | 32 | 48 |
|
||||
|
||||
The preset only sets the attention geometry, and each entry can be overridden by
|
||||
`action_num_heads` / `action_attention_head_dim`. Two widths follow from it:
|
||||
|
||||
- the DiT's **internal** width is `heads x head_dim` (768 for `DiT-B`), derived rather than configured;
|
||||
- the DiT's **output** width, and the width of the action-decoder and state-encoder MLPs, is
|
||||
`action_hidden_size` (default 1024).
|
||||
|
||||
So `DiT-B` runs a 768-wide transformer that projects to 1024. The two are independent.
|
||||
| Preset | Hidden dim | Heads | Head dim |
|
||||
| ------- | ---------- | ----- | -------- |
|
||||
| `DiT-B` | 768 | 12 | 64 |
|
||||
| `DiT-L` | 1536 | 32 | 48 |
|
||||
|
||||
### World model details
|
||||
|
||||
@@ -83,27 +74,10 @@ Key parameters in `VLAJEPAConfig`:
|
||||
| `num_inference_timesteps` | 4 | Euler integration steps for action denoising |
|
||||
| `freeze_qwen` | `False` | Freeze the Qwen3-VL backbone and only train the action head |
|
||||
| `reinit_modules` | `None` | Key prefixes allowed to be randomly re-initialised on load (for cross-embodiment transfer, see [Fine-tuning on a different embodiment](#fine-tuning-on-a-different-embodiment)) |
|
||||
| `resize_images_to` | `None` | `(height, width)` every camera frame is resized to before the Qwen3-VL vision tower. `None` keeps the native resolution, and Qwen3-VL's patch count grows with it, so a 720x1280 camera can exhaust GPU memory. The published checkpoints use `[224, 224]` |
|
||||
| `gripper_dim` | 6 | Index of the gripper dimension in the action vector. Ignored when `gripper_joint_names` matches a dataset action name |
|
||||
| `gripper_joint_names` | `["gripper"]` | Action-dimension names identifying the gripper; the matched index wins over `gripper_dim` |
|
||||
| `gripper_threshold` | 0.5 | Threshold used by `pre_snap_gripper_action` and `binarize_gripper_action`. Note `binarize` runs *after* unnormalization, so this is compared against the gripper's physical value |
|
||||
| `pre_snap_gripper_action` | `False` | Snap the gripper dim to {0, 1} before unnormalization. LIBERO-specific, see below |
|
||||
| `binarize_gripper_action` | `False` | Binarize the gripper dim to {-1, 1} after unnormalization. LIBERO-specific, see below |
|
||||
| `clip_normalized_actions` | `True` | Clip normalized actions to [-1, 1] before unnormalizing. Only applied when `ACTION` uses `MIN_MAX`; ignored (with a warning) under `MEAN_STD`, where it would truncate at 1 sigma |
|
||||
| `world_model_num_views` | `None` | Camera views the world-model predictor is built for. Baked into checkpoint shapes. `None` falls back to `jepa_tubelet_size`, which is what the published checkpoints encode |
|
||||
|
||||
<Tip warning={true}>
|
||||
|
||||
`pre_snap_gripper_action` and `binarize_gripper_action` are a port of the starVLA LIBERO eval
|
||||
loop and are only correct for LIBERO's action convention. `pre_snap` writes {0, 1} into
|
||||
*normalized* space, the unnormalizer maps those to the midpoint and the max, and `binarize` then
|
||||
compares that **physical** value against `gripper_threshold` (0.5). For a gripper measured in
|
||||
degrees, mm or [0, 100], both values land above the threshold and the commanded gripper becomes a
|
||||
constant. They default to `False` for that reason; enable them only for LIBERO-style setups, and
|
||||
set `gripper_threshold` in the gripper's own units if you do. The processor factory warns when the
|
||||
dataset stats show the range cannot work.
|
||||
|
||||
</Tip>
|
||||
| `gripper_dim` | 6 | Index of the gripper dimension in the action vector (e.g. 6 for a 7-DoF arm with gripper as the last joint) |
|
||||
| `gripper_threshold` | 0.5 | Threshold used by `pre_snap_gripper_action` and `binarize_gripper_action` to binarize the gripper dimension |
|
||||
| `pre_snap_gripper_action` | `True` | Snap the gripper dim to {0, 1} before unnormalization. Set to `False` for robots without a binary gripper |
|
||||
| `binarize_gripper_action` | `True` | Binarize the gripper dim to {-1, 1} after unnormalization. Set to `False` for robots without a binary gripper |
|
||||
|
||||
---
|
||||
|
||||
@@ -213,20 +187,14 @@ lerobot-eval \
|
||||
|
||||
## Fine-tuning on datasets with a different number of cameras
|
||||
|
||||
The pretrained world model predictor was trained with `embed_dim = world_model_num_views × 1024`, i.e. two camera views.
|
||||
|
||||
<Tip>
|
||||
|
||||
This view count used to be read from `jepa_tubelet_size`, which also names the JEPA encoder's *temporal* tubelet size. `world_model_num_views` is the field for it now; leaving it at `None` falls back to `jepa_tubelet_size` so the published checkpoints keep loading unchanged.
|
||||
|
||||
</Tip>
|
||||
The pretrained world model predictor was trained with `embed_dim = jepa_tubelet_size × 1024` (default `jepa_tubelet_size=2`).
|
||||
|
||||
**Default behaviour — view padding / trimming (no action required)**
|
||||
|
||||
When fine-tuning from `VLA-JEPA-Pretrain` the model automatically adjusts the number of views fed to the world model to match `world_model_num_views`:
|
||||
When fine-tuning from `VLA-JEPA-Pretrain` the model automatically adjusts the number of views fed to the world model to match `jepa_tubelet_size`:
|
||||
|
||||
- **Single-view datasets (e.g. BridgeV2):** the single-view latent is duplicated to produce a two-view world-model input, preserving the JEPA self-supervised signal without any weight mismatch.
|
||||
- **>2-view datasets (e.g. DROID with 3 views):** all views are passed to the Qwen backbone (for richer context), but only the first `world_model_num_views` views (one wrist + one third-person, following the configured view order) are used for the world model.
|
||||
- **>2-view datasets (e.g. DROID with 3 views):** all views are passed to the Qwen backbone (for richer context), but only the first `jepa_tubelet_size` views (one wrist + one third-person, following the configured view order) are used for the world model.
|
||||
|
||||
**Option 1 — Disable the world model**
|
||||
|
||||
@@ -242,7 +210,7 @@ lerobot-train \
|
||||
|
||||
**Option 2 — Reinitialize the predictor input projection**
|
||||
|
||||
If you want to change `world_model_num_views` to a value other than 2, load the checkpoint with `strict=False` and reinitialize `model.video_predictor.predictor_embed` for the new `embed_dim`. All other predictor block weights (attention, MLP, norm, output projection) are camera-count-agnostic and can be reused from the pretrained checkpoint.
|
||||
If you want to change `jepa_tubelet_size` to a value other than 2, load the checkpoint with `strict=False` and reinitialize `model.video_predictor.predictor_embed` for the new `embed_dim`. All other predictor block weights (attention, MLP, norm, output projection) are camera-count-agnostic and can be reused from the pretrained checkpoint.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Writing docstrings
|
||||
|
||||
LeRobot's API reference is generated directly from the docstrings in `src/lerobot/`. A docstring is not a
|
||||
comment — it is the published documentation for that object, and the format below is what the renderer and
|
||||
the CI checks parse.
|
||||
|
||||
This page is the contract. If you are adding or editing anything public in `src/lerobot/`, follow it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **An undocumented public method is an invisible one.** `[[autodoc]]` silently skips members that have no
|
||||
> docstring — no warning, no error, it simply does not appear on the rendered page. Coverage and
|
||||
> API-reference completeness are the same problem.
|
||||
|
||||
## The format in one example
|
||||
|
||||
Google section headers, Hugging Face type formatting. Both, not one or the other.
|
||||
|
||||
````python
|
||||
def send_action(self, action: RobotAction, rate_hz: float = 30.0) -> RobotAction:
|
||||
"""Command the robot to move to a target joint configuration.
|
||||
|
||||
Values are clipped by the configured maximum relative target before reaching the motors, so the
|
||||
returned action may differ from the requested one.
|
||||
|
||||
Args:
|
||||
action (`dict[str, float]`):
|
||||
Target values keyed by motor name, e.g. `{"shoulder_pan.pos": 0.0}`. Keys must match the
|
||||
robot's action features.
|
||||
rate_hz (`float`, *optional*, defaults to `30.0`):
|
||||
Control loop frequency.
|
||||
|
||||
Returns:
|
||||
`dict[str, float]`: The action actually written to the motors after safety clipping.
|
||||
|
||||
Raises:
|
||||
DeviceNotConnectedError: If the robot has not been connected.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
|
||||
>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0")) # doctest: +SKIP
|
||||
>>> robot.connect() # doctest: +SKIP
|
||||
>>> robot.send_action({"shoulder_pan.pos": 0.0}) # doctest: +SKIP
|
||||
```
|
||||
"""
|
||||
````
|
||||
|
||||
Cross-references are omitted from the examples on this page — see [Cross-references](#cross-references) for
|
||||
their syntax and why they cannot be shown inside a code block.
|
||||
|
||||
## Rules
|
||||
|
||||
### Sections
|
||||
|
||||
`Args:` · `Returns:` · `Raises:` · `Yields:` · `Example:` · `Note:`
|
||||
|
||||
In that order. No other section headers. A one-line summary comes first, then an optional free-form
|
||||
description, then the sections.
|
||||
|
||||
### The `Args:` line is machine-parsed
|
||||
|
||||
```
|
||||
name (`type`, *optional*, defaults to `X`):
|
||||
Description, indented on its own line.
|
||||
```
|
||||
|
||||
The `*optional*, defaults to` clause is **checked against the real signature default** by
|
||||
`make check-docstrings`. It is not decorative — if you write a default that has drifted from the code, CI
|
||||
fails. Omit the clause entirely for required parameters:
|
||||
|
||||
```python
|
||||
Args:
|
||||
port (`str`):
|
||||
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
|
||||
max_relative_target (`float | dict[str, float]`, *optional*):
|
||||
Caps the magnitude of the relative positional target vector. `None` disables clipping.
|
||||
use_degrees (`bool`, *optional*, defaults to `True`):
|
||||
Keep `True` for backward compatibility with existing policies and datasets.
|
||||
```
|
||||
|
||||
Types go in backticks. Use `*optional*` with no `defaults to` when the default is `None` or is otherwise not
|
||||
worth restating.
|
||||
|
||||
### `Returns:` is type-first
|
||||
|
||||
One indented line, type first, then a colon, then the description:
|
||||
|
||||
```python
|
||||
Returns:
|
||||
`dict[str, float]`: The action actually written to the motors after safety clipping.
|
||||
```
|
||||
|
||||
`Yields:` takes the same shape.
|
||||
|
||||
### `**Attributes**:`, never `Attributes:`
|
||||
|
||||
doc-builder parses a bare `Attributes:` as a **synonym for `Parameters:`**, so your attributes get rendered
|
||||
as constructor arguments. This is silent and wrong. Whenever the attributes differ from the constructor
|
||||
parameters, use the bold form with a `--` separator:
|
||||
|
||||
```python
|
||||
class Robot(abc.ABC):
|
||||
"""The base abstract class for all LeRobot-compatible robots.
|
||||
|
||||
**Attributes**:
|
||||
- **config_class** (`type[RobotConfig]`) -- The expected configuration class for this robot.
|
||||
- **name** (`str`) -- The unique robot name used to identify this robot type.
|
||||
"""
|
||||
```
|
||||
|
||||
Note `--`, not `:`.
|
||||
|
||||
### Cross-references
|
||||
|
||||
Use doc-builder's bracket syntax: a square-bracketed backtick-quoted path. **Sphinx roles (`:pymeth:`,
|
||||
`:pyattr:`) are not supported** and render as literal text on the page.
|
||||
|
||||
| Want | Write |
|
||||
| ---------------------------- | ----------------------------------- |
|
||||
| Class in the main package | [`Robot`] |
|
||||
| Method, show the full path | [`Robot.connect`] |
|
||||
| Method, show the bare name | [`~Robot.connect`] |
|
||||
| Nested path | [`~robots.Robot.connect`] |
|
||||
| Object in another HF library | [`~accelerate.Accelerator`] |
|
||||
|
||||
The `~` strips the path from the **link text only**; the link still resolves to the full path.
|
||||
|
||||
> [!NOTE]
|
||||
> doc-builder resolves this syntax everywhere in a page — including inside fenced code blocks. That is why
|
||||
> the docstring examples on this page use plain prose instead of cross-references: a code block containing
|
||||
> one would render the resolved link rather than the syntax you need to type. In your own docstrings, use
|
||||
> cross-references freely; this restriction only affects documentation _about_ the syntax.
|
||||
|
||||
### Callouts
|
||||
|
||||
Use GitHub-style blockquotes:
|
||||
|
||||
```markdown
|
||||
> [!TIP]
|
||||
> Call this once at startup — it takes about two seconds.
|
||||
|
||||
> [!WARNING]
|
||||
> Torque is disabled on disconnect. The arm will drop if it is holding a load.
|
||||
```
|
||||
|
||||
The `<Tip>` component is legacy per doc-builder; don't add new ones.
|
||||
|
||||
### Examples must be fenced
|
||||
|
||||
An example lives inside a fenced ` ```python ` block containing `>>> `. The fence is what makes it render
|
||||
as a code block, and it is what the doctest preprocessor's regex looks for:
|
||||
|
||||
````python
|
||||
Example:
|
||||
```python
|
||||
>>> from lerobot.robots.so_follower import SO101FollowerConfig
|
||||
>>> cfg = SO101FollowerConfig(port="/dev/ttyACM0")
|
||||
>>> cfg.use_degrees
|
||||
True
|
||||
```
|
||||
````
|
||||
|
||||
> [!WARNING]
|
||||
> An unfenced `>>>` is still collected — doctest finds prompts anywhere in a docstring. What you lose is the
|
||||
> rendering, so it shows up as a wall of prose on the page. Every example needs the fence.
|
||||
|
||||
Every example either executes in CI or carries `# doctest: +SKIP`. Anything that touches hardware, a GPU, or
|
||||
downloads from the Hub gets `+SKIP`:
|
||||
|
||||
````python
|
||||
Example:
|
||||
```python
|
||||
>>> robot.connect() # doctest: +SKIP
|
||||
>>> policy = ACTPolicy.from_pretrained("lerobot/act_aloha_sim_transfer_cube_human") # doctest: +SKIP
|
||||
```
|
||||
````
|
||||
|
||||
Add files containing runnable examples to `utils/documentation_tests.txt`.
|
||||
|
||||
Put examples on the three to five genuine entry points of a module. Examples on trivial accessors are noise.
|
||||
|
||||
## Three patterns you will hit constantly
|
||||
|
||||
### Config dataclasses
|
||||
|
||||
Configuration fields are historically documented with `#` comments above each field. **doc-builder cannot
|
||||
see inline comments** — such a class renders with every field listed and not a single description. Move them
|
||||
into an `Args:` block on the class docstring:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SOFollowerConfig:
|
||||
"""Configuration for SO-family follower arms.
|
||||
|
||||
Args:
|
||||
port (`str`):
|
||||
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
|
||||
max_relative_target (`float | dict[str, float]`, *optional*):
|
||||
Caps the magnitude of the relative positional target vector. A scalar applies to all motors;
|
||||
a dict maps motor name to a per-motor cap. `None` disables clipping.
|
||||
use_degrees (`bool`, *optional*, defaults to `True`):
|
||||
Keep `True` for backward compatibility with existing policies and datasets.
|
||||
"""
|
||||
|
||||
port: str
|
||||
max_relative_target: float | dict[str, float] | None = None
|
||||
use_degrees: bool = True
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **doc-builder does not inherit docstrings from base classes.** LeRobot's registered config classes are
|
||||
> often thin multiple-inheritance shims:
|
||||
>
|
||||
> ```python
|
||||
> @RobotConfig.register_subclass("so101_follower")
|
||||
> @dataclass
|
||||
> class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
|
||||
> pass
|
||||
> ```
|
||||
>
|
||||
> That class renders **every** field — including the ones it inherits — with no descriptions at all, no
|
||||
> matter how well the bases are documented. The `Args:` block must live on the concrete class that
|
||||
> `[[autodoc]]` names, and it must cover inherited fields too.
|
||||
|
||||
### Base class, then concrete subclass
|
||||
|
||||
The abstract base carries the canonical contract. Subclasses document only what deviates — port semantics,
|
||||
calibration quirks, motor layout, supported feature keys. Do not copy the base contract into every subclass.
|
||||
|
||||
`Robot`, `Teleoperator`, `Camera`, `MotorsBus`, `ProcessorStep`, and `PreTrainedPolicy` all follow this
|
||||
shape.
|
||||
|
||||
### Module-level aliases
|
||||
|
||||
Several public names are aliases rather than distinct classes:
|
||||
|
||||
```python
|
||||
SO100FollowerConfig = SOFollowerRobotConfig
|
||||
SO101FollowerConfig = SOFollowerRobotConfig
|
||||
```
|
||||
|
||||
`[[autodoc]]` resolves the alias and renders the **canonical** class name, so a `## SO101FollowerConfig`
|
||||
heading will show `class lerobot.robots.so_follower.SOFollowerRobotConfig` in the body. Document the
|
||||
canonical class once, and mention the aliases in the page's prose rather than giving each alias its own
|
||||
autodoc block.
|
||||
|
||||
## What not to document
|
||||
|
||||
- **Private members.** Anything starting with `_` is not part of the public API.
|
||||
- **The type annotation restated as prose.** `port (`str`): A string.` adds nothing. Say what it is for.
|
||||
- **Vendored upstream code.** `src/lerobot/policies/molmoact2/molmoact2_hf_model/` is vendored from
|
||||
`transformers` and already carries upstream-style docstrings. Leave it alone — restyling it only creates
|
||||
conflicts on the next sync. It is excluded from the API reference and from the docstring checks.
|
||||
|
||||
## How this is enforced
|
||||
|
||||
| Check | What it catches |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `make check-docstrings` | An `Args:` entry that doesn't match the signature; a documented default that has drifted from the real one |
|
||||
| `make doctest` | Examples that no longer run |
|
||||
| `make check-doctest-list` | Stale or unsorted entries in `utils/documentation_tests.txt` |
|
||||
| `ruff` (`D` rules) | Google-convention style violations |
|
||||
| `interrogate` | Docstring coverage falling below the current threshold |
|
||||
| doc-builder | A `[[autodoc]]` path that points at something that doesn't exist — this breaks the docs build |
|
||||
|
||||
Run them together before opening a PR:
|
||||
|
||||
```bash
|
||||
make check-docstrings && make doctest && pre-commit run --all-files
|
||||
```
|
||||
|
||||
Then render the page and actually look at it:
|
||||
|
||||
```bash
|
||||
doc-builder build lerobot docs/source/ --build_dir /tmp/doc-build
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Every public member you touched has a docstring.
|
||||
- [ ] Every `Args:` entry matches the signature, including the `*optional*, defaults to` clause.
|
||||
- [ ] `Returns:` is type-first on one indented line.
|
||||
- [ ] No bare `Attributes:` — use `**Attributes**:` with `--` separators.
|
||||
- [ ] No Sphinx roles — cross-references use [`~module.Class.method`].
|
||||
- [ ] Examples are inside a fenced ` ```python ` block, and either run in CI or carry `# doctest: +SKIP`.
|
||||
- [ ] Config dataclass fields are in an `Args:` block on the concrete class, not `#` comments.
|
||||
- [ ] The rendered page has been eyeballed.
|
||||
+83
-20
@@ -25,7 +25,7 @@ discord = "https://discord.gg/s3KuuzsPFb"
|
||||
|
||||
[project]
|
||||
name = "lerobot"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
|
||||
dynamic = ["readme"]
|
||||
license = { text = "Apache-2.0" }
|
||||
@@ -87,7 +87,7 @@ dependencies = [
|
||||
|
||||
# Build tools (required by opencv-python-headless on some platforms)
|
||||
"cmake>=3.29.0.1,<4.2.0",
|
||||
"setuptools>=71.0.0,<81.0.0",
|
||||
"setuptools>=71.0.0,<82.0.0", # torch 2.11 requires setuptools<82; a higher cap makes the resolver downgrade torch
|
||||
]
|
||||
|
||||
# Optional dependencies
|
||||
@@ -261,7 +261,7 @@ annotations = [
|
||||
# Development
|
||||
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools>=1.73.1,<2.0.0", "mypy>=1.19.1", "ruff>=0.14.1", "lerobot[notebook]"]
|
||||
notebook = ["jupyter>=1.0.0,<2.0.0", "ipykernel>=6.0.0,<7.0.0"]
|
||||
test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
|
||||
test = ["pytest>=8.1.0,<10.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
|
||||
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
|
||||
|
||||
# Simulation
|
||||
@@ -346,6 +346,7 @@ lerobot-record="lerobot.scripts.lerobot_record:main"
|
||||
lerobot-replay="lerobot.scripts.lerobot_replay:main"
|
||||
lerobot-setup-motors="lerobot.scripts.lerobot_setup_motors:main"
|
||||
lerobot-teleoperate="lerobot.scripts.lerobot_teleoperate:main"
|
||||
lerobot-convert-dcp="lerobot.scripts.lerobot_convert_dcp:main"
|
||||
lerobot-eval="lerobot.scripts.lerobot_eval:main"
|
||||
lerobot-train="lerobot.scripts.lerobot_train:main"
|
||||
lerobot-train-tokenizer="lerobot.scripts.lerobot_train_tokenizer:main"
|
||||
@@ -400,19 +401,63 @@ exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
|
||||
# N: pep8-naming
|
||||
# TODO: Uncomment rules when ready to use
|
||||
select = [
|
||||
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM" #, "A", "S", "D", "RUF"
|
||||
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM", "D" #, "A", "S", "RUF"
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Line too long
|
||||
"T201", # Print statement found
|
||||
"T203", # Pprint statement found
|
||||
"B008", # Perform function call in argument defaults
|
||||
# D100/D104: module- and package-level docstrings. The API reference is generated from class and
|
||||
# function docstrings; a banner at the top of every file and every __init__.py would not appear on any
|
||||
# rendered page. Coverage of the things that do get rendered is enforced by interrogate instead.
|
||||
"D100",
|
||||
"D104",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "E402"]
|
||||
"__init__.py" = ["F401", "F403", "E402", "D104"]
|
||||
# E402: conditional-import guards (TYPE_CHECKING / is_package_available) must precede the imports they protect
|
||||
"src/lerobot/scripts/convert_dataset_v21_to_v30.py" = ["E402"]
|
||||
|
||||
# D (pydocstyle) is enabled globally, but only holds for code that has been converted to the docstring
|
||||
# standard in docs/source/writing_docstrings.mdx. Every module below is still on the old style; each entry
|
||||
# is deleted as that module is converted, and this block can be removed once it is empty.
|
||||
#
|
||||
# Not part of the API reference and not planned for conversion: tests, examples, benchmarks, templates,
|
||||
# CI helper scripts and the packaging shim.
|
||||
"tests/**" = ["D"]
|
||||
"examples/**" = ["D"]
|
||||
"benchmarks/**" = ["D"]
|
||||
"scripts/**" = ["D"]
|
||||
"setup.py" = ["D"]
|
||||
"src/lerobot/templates/**" = ["D"]
|
||||
# Vendored from transformers; keeps its upstream docstring style so syncs stay clean.
|
||||
"src/lerobot/policies/molmoact2/molmoact2_hf_model/**" = ["D"]
|
||||
# Awaiting conversion, one PR per module.
|
||||
"src/lerobot/annotations/**" = ["D"]
|
||||
"src/lerobot/async_inference/**" = ["D"]
|
||||
"src/lerobot/cameras/**" = ["D"]
|
||||
"src/lerobot/common/**" = ["D"]
|
||||
"src/lerobot/configs/**" = ["D"]
|
||||
"src/lerobot/data_processing/**" = ["D"]
|
||||
"src/lerobot/datasets/**" = ["D"]
|
||||
"src/lerobot/distributed/**" = ["D"]
|
||||
"src/lerobot/envs/**" = ["D"]
|
||||
"src/lerobot/jobs/**" = ["D"]
|
||||
"src/lerobot/model/**" = ["D"]
|
||||
"src/lerobot/motors/**" = ["D"]
|
||||
"src/lerobot/optim/**" = ["D"]
|
||||
"src/lerobot/policies/**" = ["D"]
|
||||
"src/lerobot/rewards/**" = ["D"]
|
||||
"src/lerobot/rl/**" = ["D"]
|
||||
"src/lerobot/rollout/**" = ["D"]
|
||||
"src/lerobot/scripts/**" = ["D"]
|
||||
"src/lerobot/teleoperators/**" = ["D"]
|
||||
"src/lerobot/transforms/**" = ["D"]
|
||||
"src/lerobot/transport/**" = ["D"]
|
||||
"src/lerobot/utils/**" = ["D"]
|
||||
"src/lerobot/lerobot_types.py" = ["D"]
|
||||
[tool.ruff.lint.isort]
|
||||
combine-as-imports = true
|
||||
known-first-party = ["lerobot"]
|
||||
@@ -456,25 +501,34 @@ default.extend-ignore-identifiers-re = [
|
||||
"seperated_timestep",
|
||||
]
|
||||
|
||||
# TODO: Uncomment when ready to use
|
||||
# [tool.interrogate]
|
||||
# ignore-init-module = true
|
||||
# ignore-init-method = true
|
||||
# ignore-nested-functions = false
|
||||
# ignore-magic = false
|
||||
# ignore-semiprivate = false
|
||||
# ignore-private = false
|
||||
# ignore-property-decorators = false
|
||||
# ignore-module = false
|
||||
# ignore-setters = false
|
||||
# fail-under = 80
|
||||
# output-format = "term-missing"
|
||||
# color = true
|
||||
# paths = ["src/lerobot"]
|
||||
# Docstring coverage gate. `fail-under` is a RATCHET, not a target: it is set just below the currently
|
||||
# measured coverage so it passes today, and is raised in the same PR that documents a module. Never set it
|
||||
# to a value that fails on main. The destination is 100; see docs/source/writing_docstrings.mdx.
|
||||
[tool.interrogate]
|
||||
ignore-init-module = true
|
||||
ignore-init-method = true
|
||||
ignore-nested-functions = false
|
||||
ignore-magic = false
|
||||
ignore-semiprivate = false
|
||||
ignore-private = false
|
||||
ignore-property-decorators = false
|
||||
ignore-module = false
|
||||
ignore-setters = false
|
||||
fail-under = 55
|
||||
output-format = "term-missing"
|
||||
color = true
|
||||
paths = ["src/lerobot"]
|
||||
exclude = ["src/lerobot/policies/molmoact2/molmoact2_hf_model"]
|
||||
|
||||
# TODO: Enable mypy gradually module by module across multiple PRs
|
||||
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"multigpu: distributed tests needing 2-4 GPUs (CI: docker_publish.yml lane)",
|
||||
"multigpu_heavy: 8-GPU sweeps and soak tests; never run in CI",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
@@ -521,6 +575,15 @@ disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "lerobot.distributed.*"
|
||||
ignore_errors = false
|
||||
|
||||
# extra strictness for the distributed engine
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "lerobot.optim.*"
|
||||
ignore_errors = false
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
LeRobot -- PyTorch library for real-world robotics.
|
||||
"""LeRobot -- PyTorch library for real-world robotics.
|
||||
|
||||
Provides datasets, pretrained policies, and tools for training, evaluation,
|
||||
data collection, and robot control. Integrates with Hugging Face Hub for
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# 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.
|
||||
"""To enable `lerobot.__version__`"""
|
||||
"""To enable `lerobot.__version__`."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ class Camera(abc.ABC):
|
||||
- Connection/disconnection
|
||||
- Frame capture (sync/async/latest)
|
||||
|
||||
Attributes:
|
||||
fps (int | None): Configured frames per second
|
||||
width (int | None): Frame width in pixels
|
||||
height (int | None): Frame height in pixels
|
||||
**Attributes**:
|
||||
- **fps** (`int | None`) -- Configured frames per second.
|
||||
- **width** (`int | None`) -- Frame width in pixels.
|
||||
- **height** (`int | None`) -- Frame height in pixels.
|
||||
"""
|
||||
|
||||
def __init__(self, config: CameraConfig):
|
||||
|
||||
@@ -40,17 +40,20 @@ class OpenCVCameraConfig(CameraConfig):
|
||||
OpenCVCameraConfig(0, 30, 1280, 720, fourcc="YUYV") # With YUYV format
|
||||
```
|
||||
|
||||
Attributes:
|
||||
index_or_path: Either an integer representing the camera device index,
|
||||
or a Path object pointing to a video file.
|
||||
fps: Requested frames per second for the color stream.
|
||||
width: Requested frame width in pixels for the color stream.
|
||||
height: Requested frame height in pixels for the color stream.
|
||||
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
|
||||
warmup_s: Time reading frames before returning from connect (in seconds)
|
||||
fourcc: FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults to None (auto-detect).
|
||||
backend: OpenCV backend identifier (https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html). Defaults to ANY.
|
||||
**Attributes**:
|
||||
- **index_or_path** (`int | Path`) -- Either an integer representing the camera device index, or a
|
||||
Path object pointing to a video file.
|
||||
- **fps** -- Requested frames per second for the color stream.
|
||||
- **width** -- Requested frame width in pixels for the color stream.
|
||||
- **height** -- Requested frame height in pixels for the color stream.
|
||||
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
- **rotation** (`Cv2Rotation`) -- Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no
|
||||
rotation.
|
||||
- **warmup_s** (`int`) -- Time reading frames before returning from connect (in seconds)
|
||||
- **fourcc** (`str | None`) -- FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults
|
||||
to None (auto-detect).
|
||||
- **backend** (`Cv2Backends`) -- OpenCV backend identifier
|
||||
(https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html). Defaults to ANY.
|
||||
|
||||
Note:
|
||||
- Only 3-channel color output (RGB/BGR) is currently supported.
|
||||
|
||||
@@ -43,16 +43,16 @@ class Reachy2CameraConfig(CameraConfig):
|
||||
) # Left teleop camera, 640x480 @ 30FPS
|
||||
```
|
||||
|
||||
Attributes:
|
||||
name: Name of the camera device. Can be "teleop" or "depth".
|
||||
image_type: Type of image stream. For "teleop" camera, can be "left" or "right".
|
||||
For "depth" camera, can be "rgb" or "depth". (depth is not supported yet)
|
||||
fps: Requested frames per second for the color stream. Not configurable for Reachy 2 cameras.
|
||||
width: Requested frame width in pixels for the color stream.
|
||||
height: Requested frame height in pixels for the color stream.
|
||||
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
ip_address: IP address of the robot. Defaults to "localhost".
|
||||
port: Port number for the camera server. Defaults to 50065.
|
||||
**Attributes**:
|
||||
- **name** (`str`) -- Name of the camera device. Can be "teleop" or "depth".
|
||||
- **image_type** (`str`) -- Type of image stream. For "teleop" camera, can be "left" or "right". For
|
||||
"depth" camera, can be "rgb" or "depth". (depth is not supported yet)
|
||||
- **fps** -- Requested frames per second for the color stream. Not configurable for Reachy 2 cameras.
|
||||
- **width** -- Requested frame width in pixels for the color stream.
|
||||
- **height** -- Requested frame height in pixels for the color stream.
|
||||
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
- **ip_address** (`str | None`) -- IP address of the robot. Defaults to "localhost".
|
||||
- **port** (`int`) -- Port number for the camera server. Defaults to 50065.
|
||||
|
||||
Note:
|
||||
- Only 3-channel color output (RGB/BGR) is currently supported.
|
||||
|
||||
@@ -109,6 +109,11 @@ class RealSenseCamera(Camera):
|
||||
```
|
||||
"""
|
||||
|
||||
# Maximum number of warmup attempts made by connect(). A failed attempt is first
|
||||
# retried with a plain pipeline stop/start, which is usually enough to recover the
|
||||
# stream; a USB hardware reset is performed before the final attempt as a last resort.
|
||||
_MAX_CONNECT_ATTEMPTS = 3
|
||||
|
||||
def __init__(self, config: RealSenseCameraConfig):
|
||||
"""
|
||||
Initializes the RealSenseCamera instance.
|
||||
@@ -173,6 +178,76 @@ class RealSenseCamera(Camera):
|
||||
"""Checks if the camera pipeline is started and streams are active."""
|
||||
return self.rs_pipeline is not None and self.rs_profile is not None
|
||||
|
||||
def _hardware_reset(self, wait_s: float = 5.0) -> None:
|
||||
"""Issue a USB hardware reset to recover an unresponsive device (common on D405)."""
|
||||
context = rs.context()
|
||||
for device in context.query_devices():
|
||||
if device.get_info(rs.camera_info.serial_number) == self.serial_number:
|
||||
logger.info(f"{self} performing hardware reset.")
|
||||
device.hardware_reset()
|
||||
time.sleep(wait_s)
|
||||
return
|
||||
logger.warning(f"{self} device not found for hardware reset, skipping.")
|
||||
|
||||
def _open_pipeline(self) -> None:
|
||||
"""Initializes the RealSense pipeline, starts it, and starts the background read thread.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration is invalid, a requested sensor option is unsupported,
|
||||
or a requested sensor value is invalid.
|
||||
ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all.
|
||||
RuntimeError: If the pipeline starts but fails to apply requested settings.
|
||||
"""
|
||||
rs_pipeline = rs.pipeline()
|
||||
rs_config = rs.config()
|
||||
self._configure_rs_pipeline_config(rs_config)
|
||||
|
||||
try:
|
||||
rs_profile = rs_pipeline.start(rs_config)
|
||||
except RuntimeError as e:
|
||||
raise ConnectionError(
|
||||
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
|
||||
) from e
|
||||
|
||||
self.rs_pipeline = rs_pipeline
|
||||
self.rs_profile = rs_profile
|
||||
|
||||
try:
|
||||
self._configure_capture_settings()
|
||||
self._configure_sensor_options()
|
||||
self._start_read_thread()
|
||||
except BaseException:
|
||||
self._release_after_failed_setup()
|
||||
raise
|
||||
|
||||
def _run_warmup(self) -> None:
|
||||
"""Blocks until at least one valid frame has been captured by the background thread.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If no frame arrives before ``warmup_s`` elapses.
|
||||
"""
|
||||
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
|
||||
self.warmup_s = max(self.warmup_s, 1)
|
||||
|
||||
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.warmup_s:
|
||||
warmup_read(timeout_ms=self.warmup_s * 1000)
|
||||
time.sleep(0.1)
|
||||
with self.frame_lock:
|
||||
if (self.use_rgb and self.latest_color_frame is None) or (
|
||||
self.use_depth and self.latest_depth_frame is None
|
||||
):
|
||||
raise ConnectionError(f"{self} failed to capture frames during warmup.")
|
||||
|
||||
def _release_after_failed_setup(self) -> None:
|
||||
"""Releases the device handle and restores auto-detected settings after a failed attempt."""
|
||||
try:
|
||||
self._cleanup_resources()
|
||||
except Exception:
|
||||
logger.exception(f"Failed to fully clean up {self} after connect() failed.")
|
||||
self._reset_connection_settings()
|
||||
|
||||
@check_if_already_connected
|
||||
def connect(self, warmup: bool = True) -> None:
|
||||
"""
|
||||
@@ -181,58 +256,53 @@ class RealSenseCamera(Camera):
|
||||
Initializes the RealSense pipeline, configures the required streams (color
|
||||
and optionally depth), starts the pipeline, and validates the actual stream settings.
|
||||
|
||||
If the pipeline starts but no frames arrive during warmup, retries up to
|
||||
``_MAX_CONNECT_ATTEMPTS`` times, performing a USB hardware reset before the
|
||||
final attempt.
|
||||
|
||||
Args:
|
||||
warmup (bool): If True, waits at connect() time until at least one valid frame
|
||||
has been captured by the background thread. Defaults to True.
|
||||
|
||||
Raises:
|
||||
DeviceAlreadyConnectedError: If the camera is already connected.
|
||||
ValueError: If the configuration is invalid, a requested sensor option is unsupported,
|
||||
or a requested sensor value is invalid.
|
||||
ValueError: If the configuration is invalid (e.g., missing serial/name, name not unique).
|
||||
ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all.
|
||||
RuntimeError: If the pipeline starts but fails to apply requested settings.
|
||||
"""
|
||||
|
||||
self.rs_pipeline = rs.pipeline()
|
||||
rs_config = rs.config()
|
||||
self._configure_rs_pipeline_config(rs_config)
|
||||
if not warmup:
|
||||
self._open_pipeline()
|
||||
logger.info(f"{self} connected.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.rs_profile = self.rs_pipeline.start(rs_config)
|
||||
except RuntimeError as e:
|
||||
self.rs_profile = None
|
||||
self.rs_pipeline = None
|
||||
raise ConnectionError(
|
||||
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
|
||||
) from e
|
||||
last_error: Exception | None = None
|
||||
|
||||
try:
|
||||
self._configure_capture_settings()
|
||||
self._configure_sensor_options()
|
||||
self._start_read_thread()
|
||||
for attempt in range(1, self._MAX_CONNECT_ATTEMPTS + 1):
|
||||
if attempt == self._MAX_CONNECT_ATTEMPTS:
|
||||
self._hardware_reset()
|
||||
|
||||
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
|
||||
self.warmup_s = max(self.warmup_s, 1)
|
||||
self._open_pipeline()
|
||||
|
||||
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < self.warmup_s:
|
||||
warmup_read(timeout_ms=self.warmup_s * 1000)
|
||||
time.sleep(0.1)
|
||||
with self.frame_lock:
|
||||
if (self.use_rgb and self.latest_color_frame is None) or (
|
||||
self.use_depth and self.latest_depth_frame is None
|
||||
):
|
||||
raise ConnectionError(f"{self} failed to capture frames during warmup.")
|
||||
except BaseException:
|
||||
connected = False
|
||||
try:
|
||||
self._cleanup_resources()
|
||||
except Exception:
|
||||
logger.exception(f"Failed to fully clean up {self} after connect() failed.")
|
||||
self._reset_connection_settings()
|
||||
raise
|
||||
self._run_warmup()
|
||||
connected = True
|
||||
except (TimeoutError, ConnectionError) as e:
|
||||
last_error = e
|
||||
finally:
|
||||
if not connected:
|
||||
self._release_after_failed_setup()
|
||||
|
||||
logger.info(f"{self} connected.")
|
||||
if connected:
|
||||
logger.info(f"{self} connected.")
|
||||
return
|
||||
|
||||
logger.warning(f"{self} warmup failed (attempt {attempt}/{self._MAX_CONNECT_ATTEMPTS}).")
|
||||
|
||||
raise ConnectionError(
|
||||
f"{self} failed to capture frames after {self._MAX_CONNECT_ATTEMPTS} attempts."
|
||||
) from last_error
|
||||
|
||||
@staticmethod
|
||||
def find_cameras() -> list[dict[str, Any]]:
|
||||
@@ -629,6 +699,9 @@ class RealSenseCamera(Camera):
|
||||
capture_time = time.perf_counter()
|
||||
|
||||
with self.frame_lock:
|
||||
# Under the lock, so a late frame cannot resurrect the buffer _stop_read_thread() cleared.
|
||||
if stop_event.is_set():
|
||||
break
|
||||
if self.use_rgb:
|
||||
self.latest_color_frame = processed_color_frame
|
||||
if self.use_depth:
|
||||
@@ -839,4 +912,5 @@ class RealSenseCamera(Camera):
|
||||
)
|
||||
|
||||
self._cleanup_resources()
|
||||
|
||||
logger.info(f"{self} disconnected.")
|
||||
|
||||
@@ -36,27 +36,28 @@ class RealSenseCameraConfig(CameraConfig):
|
||||
RealSenseCameraConfig("0123456789", 30, 640, 480, rotation=Cv2Rotation.ROTATE_90) # With 90° rotation
|
||||
```
|
||||
|
||||
Attributes:
|
||||
fps: Requested frames per second for the color stream.
|
||||
width: Requested frame width in pixels for the color stream.
|
||||
height: Requested frame height in pixels for the color stream.
|
||||
serial_number_or_name: Unique serial number or human-readable name to identify the camera.
|
||||
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
use_rgb: Whether to enable the color stream. Defaults to True.
|
||||
use_depth: Whether to enable depth stream. Defaults to False.
|
||||
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
|
||||
warmup_s: Time reading frames before returning from connect (in seconds)
|
||||
exposure: Manual exposure value for the color sensor. When set, auto-exposure is
|
||||
disabled and this fixed value is used. Valid ranges are camera-model specific
|
||||
and reported if the value is rejected. Defaults to None (leave unchanged).
|
||||
gain: Manual gain value for the color sensor. When set, auto-exposure is disabled
|
||||
and this fixed gain is used, which also freezes exposure at its current value
|
||||
when no exposure is configured. Valid ranges are camera-model specific and
|
||||
reported if the value is rejected. Defaults to None (leave unchanged).
|
||||
white_balance: Manual white balance value for the color sensor. When set, auto
|
||||
white balance is disabled and this fixed value is used. Valid ranges are
|
||||
camera-model specific and reported if the value is rejected. Defaults to None
|
||||
(leave unchanged).
|
||||
**Attributes**:
|
||||
- **fps** -- Requested frames per second for the color stream.
|
||||
- **width** -- Requested frame width in pixels for the color stream.
|
||||
- **height** -- Requested frame height in pixels for the color stream.
|
||||
- **serial_number_or_name** (`str`) -- Unique serial number or human-readable name to identify the
|
||||
camera.
|
||||
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
|
||||
- **use_rgb** (`bool`) -- Whether to enable the color stream. Defaults to True.
|
||||
- **use_depth** (`bool`) -- Whether to enable depth stream. Defaults to False.
|
||||
- **rotation** (`Cv2Rotation`) -- Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no
|
||||
rotation.
|
||||
- **warmup_s** (`int`) -- Time reading frames before returning from connect (in seconds)
|
||||
- **exposure** (`int | None`) -- Manual exposure value for the color sensor. When set, auto-exposure
|
||||
is disabled and this fixed value is used. Valid ranges are camera-model specific and reported if the
|
||||
value is rejected. Defaults to None (leave unchanged).
|
||||
- **gain** (`int | None`) -- Manual gain value for the color sensor. When set, auto-exposure is
|
||||
disabled and this fixed gain is used, which also freezes exposure at its current value when no
|
||||
exposure is configured. Valid ranges are camera-model specific and reported if the value is
|
||||
rejected. Defaults to None (leave unchanged).
|
||||
- **white_balance** (`int | None`) -- Manual white balance value for the color sensor. When set, auto
|
||||
white balance is disabled and this fixed value is used. Valid ranges are camera-model specific and
|
||||
reported if the value is rejected. Defaults to None (leave unchanged).
|
||||
|
||||
Note:
|
||||
- Either name or serial_number must be specified.
|
||||
|
||||
@@ -102,6 +102,7 @@ class ImageServer:
|
||||
fps=self.fps,
|
||||
width=shape[1],
|
||||
height=shape[0],
|
||||
fourcc=cfg.get("fourcc", "MJPG"),
|
||||
color_mode=ColorMode.RGB,
|
||||
)
|
||||
camera = OpenCVCamera(cam_config)
|
||||
|
||||
+604
-174
@@ -13,16 +13,41 @@
|
||||
# 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 pathlib import Path
|
||||
"""Training-output persistence: checkpoints, two-phase resume, and hub publishing.
|
||||
|
||||
from huggingface_hub import HfApi, snapshot_download
|
||||
Rank discipline: every function here that can
|
||||
contain a collective is documented as such and must run on ALL ranks; rank-0-only file writes
|
||||
sit under one grouped ``is_main_process()`` gate per contiguous region, placed below all
|
||||
collectives. The leaf save/load helpers carry no rank gates of their own — the exception is
|
||||
``PreTrainedPolicy._save_pretrained``, whose gate is internal because its collective gather and
|
||||
its writes live in the same method.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch.distributed as dist
|
||||
from huggingface_hub import HfApi, ModelCard, ModelCardData, snapshot_download
|
||||
from torch.optim import Optimizer
|
||||
from torch.optim.lr_scheduler import LRScheduler
|
||||
|
||||
from lerobot.__version__ import __version__
|
||||
from lerobot.configs.policies import PreTrainedConfig
|
||||
from lerobot.configs.rewards import RewardModelConfig
|
||||
from lerobot.configs.train import TrainPipelineConfig
|
||||
from lerobot.distributed.checkpoint import (
|
||||
is_sharded_module,
|
||||
load_sharded_model,
|
||||
load_sharded_optimizer,
|
||||
save_sharded_model,
|
||||
save_sharded_optimizer,
|
||||
)
|
||||
from lerobot.distributed.utils import is_main_process
|
||||
from lerobot.optim import (
|
||||
load_optimizer_state,
|
||||
load_optimizer_state_dict,
|
||||
load_scheduler_state,
|
||||
save_optimizer_state,
|
||||
save_scheduler_state,
|
||||
@@ -40,14 +65,39 @@ from lerobot.utils.hub import find_latest_hub_checkpoint
|
||||
from lerobot.utils.io_utils import load_json, write_json
|
||||
from lerobot.utils.random_utils import load_rng_state, save_rng_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from accelerate import Accelerator
|
||||
|
||||
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||
from lerobot.rewards.pretrained import PreTrainedRewardModel
|
||||
|
||||
|
||||
def get_step_identifier(step: int, total_steps: int) -> str:
|
||||
"""Format a step number as the zero-padded identifier used for checkpoint directory names.
|
||||
|
||||
Args:
|
||||
step (int): The training step to format.
|
||||
total_steps (int): The total number of training steps; sets the padding width
|
||||
(minimum 6 digits).
|
||||
|
||||
Returns:
|
||||
str: The zero-padded step identifier, e.g. `"005000"`.
|
||||
"""
|
||||
num_digits = max(6, len(str(total_steps)))
|
||||
return f"{step:0{num_digits}d}"
|
||||
|
||||
|
||||
def get_step_checkpoint_dir(output_dir: Path, total_steps: int, step: int) -> Path:
|
||||
"""Returns the checkpoint sub-directory corresponding to the step number."""
|
||||
"""Returns the checkpoint sub-directory corresponding to the step number.
|
||||
|
||||
Args:
|
||||
output_dir (Path): The training run's output directory.
|
||||
total_steps (int): The total number of training steps; sets the identifier padding.
|
||||
step (int): The training step of the checkpoint.
|
||||
|
||||
Returns:
|
||||
Path: The checkpoint step directory, `output_dir/checkpoints/<step-identifier>`.
|
||||
"""
|
||||
step_identifier = get_step_identifier(step, total_steps)
|
||||
return output_dir / CHECKPOINTS_DIR / step_identifier
|
||||
|
||||
@@ -63,37 +113,15 @@ def should_save_checkpoint(step: int, save_freq: int, total_steps: int) -> bool:
|
||||
return (save_freq > 0 and step % save_freq == 0) or step == total_steps
|
||||
|
||||
|
||||
def save_training_step(
|
||||
step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None
|
||||
) -> None:
|
||||
state: dict = {"step": step}
|
||||
# num_processes and batch_size are recorded so a resumed run can detect a changed world size or
|
||||
# batch size: the sampler's resume offset is computed from the (num_processes, batch_size) that
|
||||
# produced `step`, since both scale how many sampler positions a step consumes (see
|
||||
# compute_sampler_state).
|
||||
if num_processes is not None:
|
||||
state["num_processes"] = num_processes
|
||||
if batch_size is not None:
|
||||
state["batch_size"] = batch_size
|
||||
write_json(state, save_dir / TRAINING_STEP)
|
||||
def update_last_checkpoint(checkpoint_dir: Path) -> None:
|
||||
"""Point the `last` symlink in the checkpoints directory at the given checkpoint.
|
||||
|
||||
Any existing `last` symlink is replaced. The link target is relative to the checkpoints
|
||||
directory, so the tree stays valid when the run directory is moved.
|
||||
|
||||
def load_training_step(save_dir: Path) -> int:
|
||||
training_step = load_json(save_dir / TRAINING_STEP)
|
||||
return training_step["step"]
|
||||
|
||||
|
||||
def load_training_num_processes(checkpoint_dir: Path) -> int | None:
|
||||
"""World size recorded at checkpoint time, or None for checkpoints written before it was stored."""
|
||||
return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("num_processes")
|
||||
|
||||
|
||||
def load_training_batch_size(checkpoint_dir: Path) -> int | None:
|
||||
"""Per-process batch size recorded at checkpoint time, or None for older checkpoints."""
|
||||
return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("batch_size")
|
||||
|
||||
|
||||
def update_last_checkpoint(checkpoint_dir: Path) -> Path:
|
||||
Args:
|
||||
checkpoint_dir (Path): The checkpoint step directory the `last` link should target.
|
||||
"""
|
||||
last_checkpoint_dir = checkpoint_dir.parent / LAST_CHECKPOINT_LINK
|
||||
if last_checkpoint_dir.is_symlink():
|
||||
last_checkpoint_dir.unlink()
|
||||
@@ -101,6 +129,68 @@ def update_last_checkpoint(checkpoint_dir: Path) -> Path:
|
||||
last_checkpoint_dir.symlink_to(relative_target)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# training_step.json
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def save_training_metadata(step: int, save_dir: Path, cfg: TrainPipelineConfig) -> None:
|
||||
"""Record the step counter plus everything a resume needs to reason about topology changes.
|
||||
|
||||
`step` counts loop iterations (= micro-batches), so
|
||||
the sampler resume offset is `step x batch_size x dp_world_size` with no grad-accum factor.
|
||||
`grad_accum_steps` and the parallelism snapshot are recorded so a resume can warn precisely
|
||||
when the optimizer-update cadence or the sharding topology changed.
|
||||
|
||||
Args:
|
||||
step (int): The training step (micro-batch counter) to record.
|
||||
save_dir (Path): The `training_state/` directory to write `training_step.json` into.
|
||||
cfg (TrainPipelineConfig): The training config whose batch size, gradient-accumulation,
|
||||
and parallelism settings are snapshotted alongside the step.
|
||||
"""
|
||||
state: dict[str, Any] = {
|
||||
"step": step,
|
||||
"dp_world_size": cfg.parallelism.dp_world_size,
|
||||
"batch_size": cfg.batch_size,
|
||||
"grad_accum_steps": cfg.accelerator.gradient_accumulation.steps,
|
||||
"parallelism": {
|
||||
"dp_replicate": cfg.parallelism.dp_replicate,
|
||||
"dp_shard": cfg.parallelism.dp_shard,
|
||||
"ring_degree": cfg.parallelism.context_parallel.ring_degree,
|
||||
"ulysses_degree": cfg.parallelism.context_parallel.ulysses_degree,
|
||||
},
|
||||
}
|
||||
write_json(state, save_dir / TRAINING_STEP)
|
||||
|
||||
|
||||
def load_training_metadata(training_state_dir: Path) -> dict[str, Any]:
|
||||
"""Read everything `save_training_metadata` recorded, in a single pass.
|
||||
|
||||
Every key is always present: fields a checkpoint predates come back as None, so a caller
|
||||
reading `metadata["batch_size"]` gets a KeyError on a typo rather than a silent None.
|
||||
|
||||
Args:
|
||||
training_state_dir (Path): The checkpoint's `training_state/` directory.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: `step` plus the `dp_world_size`, `batch_size`, `grad_accum_steps` and
|
||||
`parallelism` snapshot recorded alongside it (None where not recorded).
|
||||
"""
|
||||
state = load_json(training_state_dir / TRAINING_STEP)
|
||||
return {
|
||||
"step": int(state["step"]),
|
||||
"dp_world_size": state.get("dp_world_size", state.get("num_processes")),
|
||||
"batch_size": state.get("batch_size"),
|
||||
"grad_accum_steps": state.get("grad_accum_steps"),
|
||||
"parallelism": state.get("parallelism"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Checkpoint save
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def save_checkpoint(
|
||||
checkpoint_dir: Path,
|
||||
step: int,
|
||||
@@ -110,192 +200,301 @@ def save_checkpoint(
|
||||
scheduler: LRScheduler | None = None,
|
||||
preprocessor: PolicyProcessorPipeline | None = None,
|
||||
postprocessor: PolicyProcessorPipeline | None = None,
|
||||
num_processes: int | None = None,
|
||||
batch_size: int | None = None,
|
||||
model_state_dict: dict | None = None,
|
||||
optim_state_dict: dict | None = None,
|
||||
accelerator: "Accelerator | None" = None,
|
||||
) -> None:
|
||||
"""This function creates the following directory structure:
|
||||
|
||||
005000/ # training step at checkpoint
|
||||
├── pretrained_model/
|
||||
│ ├── config.json # policy config
|
||||
│ ├── model.safetensors # policy weights
|
||||
│ ├── model.safetensors # policy weights (checkpoint_format ∈ {safetensors, safetensors_dcp}, or any non-sharded run)
|
||||
│ ├── pytorch_model_fsdp_0/ # DCP model shards (checkpoint_format ∈ {dcp, safetensors_dcp})
|
||||
│ ├── train_config.json # train config
|
||||
│ ├── processor.json # processor config (if preprocessor provided)
|
||||
│ └── step_*.safetensors # processor state files (if any)
|
||||
│ ├── policy_preprocessor.json # preprocessor config (if preprocessor provided)
|
||||
│ ├── policy_preprocessor_step_*.safetensors # state of the stateful preprocessor steps
|
||||
│ ├── policy_postprocessor.json # postprocessor config (if postprocessor provided)
|
||||
│ └── policy_postprocessor_step_*.safetensors # state of the stateful postprocessor steps
|
||||
└── training_state/
|
||||
├── optimizer_param_groups.json # optimizer param groups
|
||||
├── optimizer_state.safetensors # optimizer state
|
||||
├── optimizer_param_groups.json # optimizer param groups (non-sharded runs)
|
||||
├── optimizer_state.safetensors # optimizer state (non-sharded runs)
|
||||
├── optimizer_0/ # DCP optimizer shards (sharded runs)
|
||||
├── rng_state.safetensors # rng states
|
||||
├── scheduler_state.json # scheduler state
|
||||
└── training_step.json # training step
|
||||
├── scheduler_state.json # scheduler state (if scheduler provided)
|
||||
└── training_step.json # training step + dp_world_size/batch_size/grad_accum + topology
|
||||
|
||||
Collective: MUST be called on every rank. Rank-0-only writes are gated internally, so the
|
||||
call site needs no rank branches.
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The training config used for this run.
|
||||
checkpoint_dir (Path): The checkpoint step directory to write (e.g. `.../checkpoints/005000`).
|
||||
step (int): The training step at that checkpoint.
|
||||
cfg (TrainPipelineConfig): The training config used for this run.
|
||||
policy (PreTrainedPolicy): The policy to save.
|
||||
optimizer (Optimizer | None, optional): The optimizer to save the state from. Defaults to None.
|
||||
optimizer (Optimizer): The optimizer to save the state from.
|
||||
scheduler (LRScheduler | None, optional): The scheduler to save the state from. Defaults to None.
|
||||
preprocessor: The preprocessor/pipeline to save. Defaults to None.
|
||||
postprocessor: The postprocessor/pipeline to save. Defaults to None.
|
||||
num_processes (int | None, optional): Distributed world size to record for sample-exact
|
||||
resume. Defaults to None (not recorded).
|
||||
batch_size (int | None, optional): Per-process batch size to record for sample-exact
|
||||
resume. Defaults to None (not recorded).
|
||||
model_state_dict: Pre-gathered full (unsharded) model state dict. Required under FSDP,
|
||||
where `policy.state_dict()` would return sharded tensors; the caller gathers it via a
|
||||
cross-rank collective and passes it here so rank 0 can write it directly. It holds
|
||||
FSDP's fp32 master weights and is saved as-is (the loader casts to the policy dtype on
|
||||
read). When None (DDP / single-GPU), the model is saved the normal way. Defaults to None.
|
||||
optim_state_dict: Pre-gathered full (unsharded) optimizer state dict. Required under FSDP
|
||||
(gathered alongside `model_state_dict` via `gather_fsdp_state_dicts`); saved in the same
|
||||
safetensors format as the single-GPU path. When None, `optimizer.state_dict()` is used.
|
||||
preprocessor (PolicyProcessorPipeline | None, optional): The preprocessor/pipeline to save.
|
||||
Defaults to None.
|
||||
postprocessor (PolicyProcessorPipeline | None, optional): The postprocessor/pipeline to save.
|
||||
Defaults to None.
|
||||
accelerator (Accelerator | None, optional): The accelerator the policy was prepared with;
|
||||
used to unwrap the model and required on sharded runs, where it owns the DCP save
|
||||
channels. Defaults to None (plain single-process saves).
|
||||
"""
|
||||
pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR
|
||||
policy.save_pretrained(pretrained_dir, state_dict=model_state_dict)
|
||||
cfg.save_pretrained(pretrained_dir)
|
||||
fmt = cfg.checkpoint_format
|
||||
policy_to_save = accelerator.unwrap_model(policy) if accelerator is not None else policy
|
||||
sharded = is_sharded_module(policy_to_save)
|
||||
|
||||
# -- model artifact(s): the two collective-capable calls ----------------------------------
|
||||
if cfg.peft is not None:
|
||||
# When using PEFT, policy.save_pretrained will only write the adapter weights + config, not the
|
||||
# policy config which we need for loading the model. In this case we'll write it ourselves.
|
||||
policy.config.save_pretrained(pretrained_dir)
|
||||
if preprocessor is not None:
|
||||
preprocessor.save_pretrained(pretrained_dir)
|
||||
if postprocessor is not None:
|
||||
postprocessor.save_pretrained(pretrained_dir)
|
||||
# PeftModel.save_pretrained is an external API with no internal rank gate, and the
|
||||
# adapters are replicated (PEFT x sharded is rejected at validation): main rank writes.
|
||||
if is_main_process():
|
||||
policy_to_save.save_pretrained(pretrained_dir)
|
||||
elif fmt.wants_safetensors or not sharded:
|
||||
# Collective when sharded (full gather); writes happen on the main process only in all
|
||||
# multi-rank layouts (the gate lives inside _save_pretrained, next to its collective gather).
|
||||
policy_to_save.save_pretrained(pretrained_dir)
|
||||
if fmt.wants_dcp and sharded:
|
||||
save_sharded_model(accelerator, policy_to_save, pretrained_dir)
|
||||
|
||||
# -- sidecar configs: ONE gate for the whole contiguous rank-0-only region ----------------
|
||||
if is_main_process():
|
||||
if fmt.wants_dcp and not fmt.wants_safetensors:
|
||||
# save_pretrained did not run: keep the DCP-only checkpoint self-describing.
|
||||
policy_to_save.config.save_pretrained(pretrained_dir)
|
||||
cfg.save_pretrained(pretrained_dir)
|
||||
if cfg.peft is not None:
|
||||
# PEFT's save_pretrained writes only adapter weights + config; the policy config
|
||||
# needed to reload the base model is written explicitly.
|
||||
policy_to_save.config.save_pretrained(pretrained_dir)
|
||||
if preprocessor is not None:
|
||||
preprocessor.save_pretrained(pretrained_dir)
|
||||
if postprocessor is not None:
|
||||
postprocessor.save_pretrained(pretrained_dir)
|
||||
|
||||
save_training_state(
|
||||
checkpoint_dir,
|
||||
step,
|
||||
optimizer,
|
||||
scheduler,
|
||||
num_processes=num_processes,
|
||||
batch_size=batch_size,
|
||||
optim_state_dict=optim_state_dict,
|
||||
checkpoint_dir, step, cfg, optimizer, scheduler, accelerator, sharded=sharded, model=policy_to_save
|
||||
)
|
||||
if accelerator is not None:
|
||||
accelerator.wait_for_everyone()
|
||||
|
||||
|
||||
def save_training_state(
|
||||
checkpoint_dir: Path,
|
||||
train_step: int,
|
||||
optimizer: Optimizer | None = None,
|
||||
step: int,
|
||||
cfg: TrainPipelineConfig,
|
||||
optimizer: Optimizer | dict[str, Optimizer] | None = None,
|
||||
scheduler: LRScheduler | None = None,
|
||||
num_processes: int | None = None,
|
||||
batch_size: int | None = None,
|
||||
optim_state_dict: dict | None = None,
|
||||
accelerator: "Accelerator | None" = None,
|
||||
*,
|
||||
sharded: bool = False,
|
||||
model: PreTrainedPolicy | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Saves the training step, optimizer state, scheduler state, and rng state.
|
||||
"""Write training_state/. Collective under sharding: call on every rank.
|
||||
|
||||
Args:
|
||||
save_dir (Path): The directory to save artifacts to.
|
||||
train_step (int): Current training step.
|
||||
optimizer (Optimizer | None, optional): The optimizer from which to save the state_dict.
|
||||
checkpoint_dir (Path): The checkpoint step directory; `training_state/` is created inside it.
|
||||
step (int): The training step at that checkpoint.
|
||||
cfg (TrainPipelineConfig): The training config used for this run (its topology and
|
||||
accumulation settings are recorded in `training_step.json`).
|
||||
optimizer (Optimizer | dict[str, Optimizer] | None, optional): The optimizer(s) to save
|
||||
the state from. Defaults to None.
|
||||
scheduler (LRScheduler | None, optional): The scheduler to save the state from.
|
||||
Defaults to None.
|
||||
scheduler (LRScheduler | None, optional): The scheduler from which to save the state_dict.
|
||||
Defaults to None.
|
||||
num_processes (int | None, optional): Distributed world size to record. Defaults to None.
|
||||
batch_size (int | None, optional): Per-process batch size to record. Defaults to None.
|
||||
optim_state_dict: Pre-gathered full optimizer state dict (for FSDP). Saved instead of
|
||||
`optimizer.state_dict()` when provided. Defaults to None.
|
||||
accelerator (Accelerator | None, optional): Required when `sharded` is True — it owns
|
||||
the DCP optimizer save channel. Defaults to None.
|
||||
sharded (bool): The model's sharding state, computed once in `save_checkpoint` and
|
||||
threaded here so the two sites cannot disagree. Defaults to False.
|
||||
model (PreTrainedPolicy | None, optional): Required only for the sharded optimizer
|
||||
channel: torch's optimizer DCP APIs are model-coupled (the state dict is keyed by
|
||||
model FQNs), so accelerate's `save_fsdp_optimizer` needs the sharded module
|
||||
alongside the optimizer. Defaults to None.
|
||||
"""
|
||||
save_dir = checkpoint_dir / TRAINING_STATE_DIR
|
||||
# All ranks: the directory must exist before the DCP optimizer collective writes into it
|
||||
# (exist_ok makes the concurrent mkdir race-free on shared filesystems).
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
save_training_step(train_step, save_dir, num_processes=num_processes, batch_size=batch_size)
|
||||
save_rng_state(save_dir)
|
||||
if optimizer is not None:
|
||||
save_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
|
||||
if scheduler is not None:
|
||||
save_scheduler_state(scheduler, save_dir)
|
||||
|
||||
if optimizer is not None and sharded:
|
||||
if accelerator is None or model is None:
|
||||
raise ValueError("Saving a sharded optimizer state requires the accelerator and model.")
|
||||
# Collective — all ranks write their DCP shards into optimizer_0/.
|
||||
save_sharded_optimizer(accelerator, optimizer, model, save_dir)
|
||||
|
||||
if is_main_process(): # ONE grouped gate for the whole rank-0-only region
|
||||
save_training_metadata(step, save_dir, cfg)
|
||||
save_rng_state(save_dir)
|
||||
if scheduler is not None:
|
||||
save_scheduler_state(scheduler, save_dir)
|
||||
if optimizer is not None and not sharded:
|
||||
save_optimizer_state(optimizer, save_dir)
|
||||
|
||||
|
||||
def load_training_state(
|
||||
checkpoint_dir: Path, optimizer: Optimizer, scheduler: LRScheduler | None, load_optimizer: bool = True
|
||||
) -> tuple[int, Optimizer, LRScheduler | None]:
|
||||
"""
|
||||
Loads the training step, optimizer state, scheduler state, and rng state.
|
||||
This is used to resume a training run.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Two-phase resume
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resume_before_prepare(cfg: TrainPipelineConfig) -> int:
|
||||
"""Phase 1 — before `accelerator.prepare()`: restore RNG and return the step counter.
|
||||
|
||||
Pure loaders only. The sampler resume offset is *derived* from the returned step inside the
|
||||
dataloader factory, and everything bound to sharded objects (model DCP shards, optimizer,
|
||||
scheduler) loads in `resume_after_prepare`.
|
||||
|
||||
Args:
|
||||
checkpoint_dir (Path): The checkpoint directory. Should contain a 'training_state' dir.
|
||||
optimizer (Optimizer): The optimizer to load the state_dict to.
|
||||
scheduler (LRScheduler | None): The scheduler to load the state_dict to (can be None).
|
||||
load_optimizer (bool, optional): Whether to load the optimizer state from disk. Defaults to
|
||||
True. Set to False under FSDP, where the sharded optimizer state must be loaded after
|
||||
`accelerator.prepare()` via `load_fsdp_optimizer_state` (the optimizer is returned
|
||||
untouched here).
|
||||
cfg (TrainPipelineConfig): The resumed training config; `cfg.checkpoint_path` locates
|
||||
the checkpoint to restore from.
|
||||
|
||||
Returns:
|
||||
int: The training step recorded in the checkpoint (micro-batch counter).
|
||||
|
||||
Raises:
|
||||
NotADirectoryError: If 'checkpoint_dir' doesn't contain a 'training_state' dir
|
||||
|
||||
Returns:
|
||||
tuple[int, Optimizer, LRScheduler | None]: training step, optimizer and scheduler with their
|
||||
state_dict loaded.
|
||||
NotADirectoryError: If the checkpoint has no `training_state/` directory.
|
||||
ValueError: If the resumed topology crosses the sharded/non-sharded boundary relative
|
||||
to the one recorded in the checkpoint.
|
||||
"""
|
||||
training_state_dir = checkpoint_dir / TRAINING_STATE_DIR
|
||||
training_state_dir = cfg.checkpoint_path / TRAINING_STATE_DIR
|
||||
if not training_state_dir.is_dir():
|
||||
raise NotADirectoryError(training_state_dir)
|
||||
|
||||
metadata = load_training_metadata(training_state_dir)
|
||||
_guard_resume_changes(cfg, metadata)
|
||||
load_rng_state(training_state_dir)
|
||||
step = load_training_step(training_state_dir)
|
||||
if load_optimizer:
|
||||
optimizer = load_optimizer_state(optimizer, training_state_dir)
|
||||
return metadata["step"]
|
||||
|
||||
|
||||
def _guard_resume_changes(cfg: TrainPipelineConfig, metadata: dict[str, Any]) -> None:
|
||||
"""Check the resumed run settings against the ones recorded in the checkpoint.
|
||||
|
||||
Two tiers, both driven by the checkpoint's recorded parallelism snapshot:
|
||||
|
||||
- **Hard error** when the resume crosses the sharded/non-sharded boundary in either
|
||||
direction: the checkpoint's training-state artifacts only support resuming on the same
|
||||
kind of topology (resharding works across sizes, not across kinds). Checkpoints without
|
||||
a recorded snapshot skip this check.
|
||||
- **One warning** naming every other recorded setting that differs — those changes are
|
||||
legal (DCP reshards weights and optimizer state across topologies and the sampler offset
|
||||
adapts), but a changed ``grad_accum_steps`` shifts the optimizer-update cadence, so the
|
||||
resume says precisely what differs. The sampler-exactness warnings
|
||||
(``dp_world_size``/``batch_size``) live with the sampler math in the dataloader factory.
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The resumed training config, compared against the settings
|
||||
recorded in the checkpoint.
|
||||
metadata (dict[str, Any]): The checkpoint's recorded training metadata, as returned by
|
||||
`load_training_metadata`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the checkpoint records a sharded topology and the resumed run is
|
||||
non-sharded, or vice versa.
|
||||
"""
|
||||
snapshot = metadata["parallelism"]
|
||||
|
||||
if snapshot is not None:
|
||||
recorded_sharded = (
|
||||
snapshot.get("dp_shard", 1) != 1
|
||||
or snapshot.get("ring_degree", 1) * snapshot.get("ulysses_degree", 1) > 1
|
||||
)
|
||||
if recorded_sharded != cfg.parallelism.is_sharded:
|
||||
raise ValueError(
|
||||
f"Cannot resume: the checkpoint was written with a "
|
||||
f"{'sharded' if recorded_sharded else 'non-sharded'} topology "
|
||||
f"(dp_replicate={snapshot.get('dp_replicate')}, dp_shard={snapshot.get('dp_shard')}) "
|
||||
f"but this run is {'sharded' if cfg.parallelism.is_sharded else 'non-sharded'} "
|
||||
f"(dp_replicate={cfg.parallelism.dp_replicate}, dp_shard={cfg.parallelism.dp_shard})."
|
||||
)
|
||||
|
||||
recorded = {
|
||||
"grad_accum_steps": (
|
||||
metadata["grad_accum_steps"],
|
||||
cfg.accelerator.gradient_accumulation.steps,
|
||||
),
|
||||
}
|
||||
if snapshot is not None:
|
||||
recorded.update(
|
||||
{
|
||||
"dp_replicate": (snapshot.get("dp_replicate"), cfg.parallelism.dp_replicate),
|
||||
"dp_shard": (snapshot.get("dp_shard"), cfg.parallelism.dp_shard),
|
||||
"ring_degree": (
|
||||
snapshot.get("ring_degree"),
|
||||
cfg.parallelism.context_parallel.ring_degree,
|
||||
),
|
||||
"ulysses_degree": (
|
||||
snapshot.get("ulysses_degree"),
|
||||
cfg.parallelism.context_parallel.ulysses_degree,
|
||||
),
|
||||
}
|
||||
)
|
||||
changed = [f"{key}: {was} -> {now}" for key, (was, now) in recorded.items() if was not in (None, now)]
|
||||
if changed and is_main_process():
|
||||
logging.warning(
|
||||
"Resuming with settings that differ from the checkpoint: " + "; ".join(changed) + ". "
|
||||
"Topology changes reshard safely via DCP; a changed grad_accum_steps shifts the "
|
||||
"optimizer-update cadence (the step counter keeps counting micro-batches)."
|
||||
)
|
||||
|
||||
|
||||
def resume_after_prepare(
|
||||
cfg: TrainPipelineConfig,
|
||||
accelerator: "Accelerator",
|
||||
policy: PreTrainedPolicy,
|
||||
optimizer: Optimizer | dict[str, Optimizer],
|
||||
scheduler: LRScheduler | None,
|
||||
) -> None:
|
||||
"""Phase 2 — after `accelerator.prepare()`: model (DCP) -> optimizer -> scheduler.
|
||||
|
||||
Collective under sharding: call on every rank. The model-weight source follows the
|
||||
checkpoint's own recorded `checkpoint_format` (on resume, `cfg` was parsed from the
|
||||
checkpoint's train_config.json): DCP-bearing formats load shards here into the prepared
|
||||
model (whose construction skipped the safetensors load); the safetensors format was already
|
||||
loaded by `from_pretrained` before sharding — no model step here.
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The resumed training config; `cfg.checkpoint_path` locates
|
||||
the checkpoint and `cfg.checkpoint_format` selects the model-weight source.
|
||||
accelerator (Accelerator): The accelerator the policy was prepared with; it unwraps the
|
||||
model and owns the DCP load channels.
|
||||
policy (PreTrainedPolicy): The prepared (possibly sharded) policy to load weights into.
|
||||
optimizer (Optimizer | dict[str, Optimizer]): The prepared optimizer(s) to restore.
|
||||
scheduler (LRScheduler | None): The scheduler to restore, or None if the run has none.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the checkpoint format declares DCP model shards but the shard
|
||||
directory is missing (e.g. it was pruned before upload).
|
||||
"""
|
||||
checkpoint_dir = cfg.checkpoint_path
|
||||
pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR
|
||||
training_state_dir = checkpoint_dir / TRAINING_STATE_DIR
|
||||
unwrapped = accelerator.unwrap_model(policy)
|
||||
sharded = is_sharded_module(unwrapped)
|
||||
|
||||
if cfg.checkpoint_format.wants_dcp:
|
||||
from accelerate.utils.constants import FSDP_MODEL_NAME
|
||||
|
||||
dcp_dir = pretrained_dir / f"{FSDP_MODEL_NAME}_0"
|
||||
if not dcp_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"checkpoint_format={cfg.checkpoint_format.value} declares DCP model shards, "
|
||||
f"but {dcp_dir} is missing. If the shards were pruned, convert what remains "
|
||||
"with `lerobot-convert-dcp` or resume from a safetensors checkpoint."
|
||||
)
|
||||
load_sharded_model(accelerator, unwrapped, pretrained_dir)
|
||||
|
||||
if sharded:
|
||||
# Requires the prepared optimizer: FSDP2's prepare rebinds param groups to DTensors but
|
||||
# never migrates optimizer.state — DCP reshards it here (works across topology changes).
|
||||
load_sharded_optimizer(accelerator, optimizer, unwrapped, training_state_dir)
|
||||
else:
|
||||
load_optimizer_state(optimizer, training_state_dir)
|
||||
|
||||
if scheduler is not None:
|
||||
scheduler = load_scheduler_state(scheduler, training_state_dir)
|
||||
|
||||
return step, optimizer, scheduler
|
||||
load_scheduler_state(scheduler, training_state_dir)
|
||||
|
||||
|
||||
def gather_fsdp_state_dicts(model, optimizer) -> tuple[dict, dict]:
|
||||
"""Gather the full (unsharded) model and optimizer state dicts under FSDP.
|
||||
|
||||
`model.state_dict()` and `FSDP.optim_state_dict(...)` are cross-rank collectives, so this must be
|
||||
called on *every* rank with the prepared (FSDP-wrapped) `model` and `optimizer`. With
|
||||
`rank0_only=True` and `offload_to_cpu=True`, every rank runs the all-gather but only rank 0
|
||||
materializes the full dicts (the others get empty dicts) and they are kept on CPU to bound GPU
|
||||
memory. The returned optimizer state dict is keyed by parameter FQNs and is world-size
|
||||
independent; `load_fsdp_optimizer_state` reshards it on resume.
|
||||
|
||||
Returns:
|
||||
(model_state_dict, optim_state_dict): full dicts on rank 0, empty dicts on other ranks.
|
||||
"""
|
||||
from torch.distributed.fsdp import (
|
||||
FullOptimStateDictConfig,
|
||||
FullStateDictConfig,
|
||||
FullyShardedDataParallel as FSDP, # noqa F401
|
||||
StateDictType,
|
||||
)
|
||||
|
||||
state_cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
||||
optim_cfg = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
||||
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
|
||||
model_state_dict = model.state_dict()
|
||||
optim_state_dict = FSDP.optim_state_dict(model, optimizer)
|
||||
return model_state_dict, optim_state_dict
|
||||
|
||||
|
||||
def load_fsdp_optimizer_state(model, optimizer, checkpoint_dir: Path) -> None:
|
||||
"""Load the FSDP optimizer state (saved as safetensors) and reshard it into the optimizer.
|
||||
|
||||
This is a cross-rank collective and must be called on every rank *after* `accelerator.prepare()`
|
||||
with the prepared (FSDP-wrapped) `model` and `optimizer`. The saved state is the full,
|
||||
world-size-independent optimizer state (keyed by parameter FQNs); `FSDP.optim_state_dict_to_load`
|
||||
reshards it to the current FSDP topology, so resume on a different number of GPUs works.
|
||||
"""
|
||||
from torch.distributed.fsdp import (
|
||||
FullOptimStateDictConfig,
|
||||
FullStateDictConfig,
|
||||
FullyShardedDataParallel as FSDP, # noqa F401
|
||||
StateDictType,
|
||||
)
|
||||
|
||||
# Every rank reads the same full state from the (shared) checkpoint dir, so rank0_only=False.
|
||||
full_osd = load_optimizer_state_dict(checkpoint_dir / TRAINING_STATE_DIR)
|
||||
state_cfg = FullStateDictConfig(rank0_only=False)
|
||||
optim_cfg = FullOptimStateDictConfig(rank0_only=False)
|
||||
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
|
||||
sharded_osd = FSDP.optim_state_dict_to_load(model=model, optim=optimizer, optim_state_dict=full_osd)
|
||||
optimizer.load_state_dict(sharded_osd)
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Hub: checkpoint push (resume artifact) and publishing (distribution artifact)
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def push_checkpoint_to_hub(
|
||||
@@ -311,6 +510,16 @@ def push_checkpoint_to_hub(
|
||||
The model repo is created idempotently, and the commit is tagged with the
|
||||
checkpoint step so a checkpoint can be recovered with
|
||||
--policy.pretrained_revision=<step> instead of a commit sha.
|
||||
|
||||
The directory is uploaded verbatim — including DCP shards under the DCP formats: this tree
|
||||
exists for *resume*, not distribution, and `resolve_resume_checkpoint` downloads it back
|
||||
symmetrically.
|
||||
|
||||
Args:
|
||||
checkpoint_dir (Path): The local checkpoint step directory to upload.
|
||||
repo_id (str): The Hub model repo to push to (created idempotently if missing).
|
||||
private (bool | None): Whether a newly created repo should be private. Defaults to
|
||||
None (public unless the organization's default is private).
|
||||
"""
|
||||
api = HfApi()
|
||||
api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
|
||||
@@ -338,6 +547,16 @@ def resolve_resume_checkpoint(repo_id: str, output_dir: Path) -> Path:
|
||||
into `output_dir/checkpoints/<step>/`, recreate the local `last` symlink, and return that local
|
||||
checkpoint dir. Used to resume training from the Hub on a machine (or HF Jobs pod) that does not
|
||||
have the original local run dir.
|
||||
|
||||
Args:
|
||||
repo_id (str): The Hub model repo holding `checkpoints/<step>/` subtrees.
|
||||
output_dir (Path): The local run directory to download the checkpoint into.
|
||||
|
||||
Returns:
|
||||
Path: The local checkpoint step directory, `output_dir/checkpoints/<step>`.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the repo contains no checkpoints under `checkpoints/`.
|
||||
"""
|
||||
latest = find_latest_hub_checkpoint(repo_id)
|
||||
if latest is None:
|
||||
@@ -354,3 +573,214 @@ def resolve_resume_checkpoint(repo_id: str, output_dir: Path) -> Path:
|
||||
checkpoint_dir = output_dir / latest
|
||||
update_last_checkpoint(checkpoint_dir)
|
||||
return checkpoint_dir
|
||||
|
||||
|
||||
def publish_trained_model(
|
||||
cfg: TrainPipelineConfig,
|
||||
model: "PreTrainedPolicy | PreTrainedRewardModel",
|
||||
preprocessor: PolicyProcessorPipeline | None,
|
||||
postprocessor: PolicyProcessorPipeline | None,
|
||||
dataset_meta: "LeRobotDatasetMetadata | None",
|
||||
*,
|
||||
peft_model: Any | None = None,
|
||||
) -> None:
|
||||
"""Publish the complete training bundle as a distributable model repo.
|
||||
|
||||
Collective-safe: call on ALL ranks — the model commit gathers sharded weights through
|
||||
`save_pretrained`; uploads happen on the main process only (gated inside
|
||||
`HubMixin.push_to_hub` and here). Commits, in order: (1) the model (skipped for PEFT —
|
||||
adapters replace full weights), (2) the preprocessor, (3) the postprocessor, (4) the bundle
|
||||
sidecar: README.md model card + train_config.json (+ adapter weights and the wrapped
|
||||
policy's config in the PEFT case). Every commit uploads a freshly assembled directory, so
|
||||
a published repo carries only the distributable artifacts.
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The training config; saved as `train_config.json` and used
|
||||
to render the model card.
|
||||
model (PreTrainedPolicy | PreTrainedRewardModel): The trained model to publish; its
|
||||
config supplies the target repo id, visibility, license, and tags.
|
||||
preprocessor (PolicyProcessorPipeline | None): The preprocessor pipeline to publish
|
||||
alongside the model, if any.
|
||||
postprocessor (PolicyProcessorPipeline | None): The postprocessor pipeline to publish
|
||||
alongside the model, if any.
|
||||
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata for the model card, if
|
||||
available.
|
||||
peft_model (Any | None): The PEFT wrapper when training adapters; its adapter weights
|
||||
replace the full model weights in the published repo. Defaults to None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the model config carries no repo id (`--policy.repo_id`).
|
||||
"""
|
||||
model_cfg = model.config
|
||||
repo_id = model_cfg.repo_id
|
||||
if not repo_id:
|
||||
raise ValueError("Publishing requires a repo id (--policy.repo_id).")
|
||||
ignore = ["*.tmp", "*.log"]
|
||||
|
||||
if peft_model is None:
|
||||
# Calls are made on the exact objects that own each method (never through PEFT's
|
||||
# attribute forwarding), so the peft branch below never touches this path.
|
||||
model.push_to_hub(repo_id, private=model_cfg.private, ignore_patterns=ignore)
|
||||
if preprocessor is not None:
|
||||
preprocessor.push_to_hub(repo_id, private=model_cfg.private)
|
||||
if postprocessor is not None:
|
||||
postprocessor.push_to_hub(repo_id, private=model_cfg.private)
|
||||
|
||||
if is_main_process():
|
||||
api = HfApi()
|
||||
repo_id = api.create_repo(repo_id=repo_id, private=model_cfg.private, exist_ok=True).repo_id
|
||||
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
||||
saved_path = Path(tmp) / repo_id
|
||||
saved_path.mkdir(parents=True, exist_ok=True)
|
||||
if peft_model is not None:
|
||||
peft_model.save_pretrained(saved_path) # adapter weights + adapter config
|
||||
model.config.save_pretrained(saved_path) # PEFT cannot write the policy config
|
||||
card = generate_model_card(model_cfg, cfg=cfg, dataset_meta=dataset_meta)
|
||||
card.save(str(saved_path / "README.md"))
|
||||
cfg.save_pretrained(saved_path) # train_config.json
|
||||
commit_info = api.upload_folder(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
folder_path=saved_path,
|
||||
commit_message="Upload model card and train config",
|
||||
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
|
||||
ignore_patterns=ignore,
|
||||
)
|
||||
# Contract: lerobot.jobs.hf.submit_to_hf watches for this exact "Model pushed to <url>"
|
||||
# line to end a remote run early. Keep the wording and URL format in sync.
|
||||
logging.info(f"Model pushed to {commit_info.repo_url.url}")
|
||||
|
||||
if dist.is_initialized():
|
||||
dist.barrier()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Model card
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
_BASE_MODEL_MAPPING = {
|
||||
"smolvla": "lerobot/smolvla_base",
|
||||
"pi0": "lerobot/pi0_base",
|
||||
"pi05": "lerobot/pi05_base",
|
||||
"pi0_fast": "lerobot/pi0fast-base",
|
||||
"xvla": "lerobot/xvla-base",
|
||||
}
|
||||
|
||||
|
||||
def build_card_context(
|
||||
cfg: TrainPipelineConfig | None,
|
||||
dataset_meta: "LeRobotDatasetMetadata | None",
|
||||
input_features: dict | None,
|
||||
output_features: dict | None,
|
||||
) -> dict:
|
||||
"""Collect optional data for the model-card template.
|
||||
|
||||
Returns plain values only (no Markdown) — the template in
|
||||
``lerobot/templates/lerobot_modelcard_template.md`` decides how and whether to show
|
||||
each one. Everything is best-effort: anything unavailable is left empty/None and the
|
||||
template simply skips that section, so this never breaks a Hub push.
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig | None): The training config supplying the training section,
|
||||
if available.
|
||||
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata supplying the dataset,
|
||||
robot-type, and camera sections, if available.
|
||||
input_features (dict | None): The policy's input feature declarations, if any.
|
||||
output_features (dict | None): The policy's output feature declarations, if any.
|
||||
|
||||
Returns:
|
||||
dict: Template context with `training`, `input_features`, `output_features`,
|
||||
`dataset`, `robot_type`, and `cameras` entries; unavailable pieces stay
|
||||
empty/None.
|
||||
"""
|
||||
context = {
|
||||
"training": None,
|
||||
"input_features": input_features or {},
|
||||
"output_features": output_features or {},
|
||||
"dataset": None,
|
||||
"robot_type": None,
|
||||
"cameras": [],
|
||||
}
|
||||
|
||||
if cfg is not None:
|
||||
optimizer = getattr(cfg, "optimizer", None)
|
||||
context["training"] = {
|
||||
"steps": cfg.steps,
|
||||
"batch_size": cfg.batch_size,
|
||||
"seed": cfg.seed,
|
||||
"optimizer": getattr(optimizer, "type", None) if optimizer else None,
|
||||
"lr": getattr(optimizer, "lr", None) if optimizer else None,
|
||||
"lerobot_version": __version__,
|
||||
}
|
||||
|
||||
if dataset_meta is not None:
|
||||
context["dataset"] = {
|
||||
"repo_id": dataset_meta.repo_id,
|
||||
"episodes": dataset_meta.total_episodes,
|
||||
"frames": dataset_meta.total_frames,
|
||||
"fps": dataset_meta.fps,
|
||||
"tasks": [str(task) for task in dataset_meta.tasks.index],
|
||||
}
|
||||
context["robot_type"] = dataset_meta.robot_type
|
||||
context["cameras"] = [key.split(".")[-1] for key in dataset_meta.camera_keys]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def generate_model_card(
|
||||
model_cfg: PreTrainedConfig | RewardModelConfig,
|
||||
cfg: TrainPipelineConfig | None = None,
|
||||
dataset_meta: "LeRobotDatasetMetadata | None" = None,
|
||||
) -> ModelCard:
|
||||
"""Render the LeRobot model card for a trained policy or reward model.
|
||||
|
||||
A free function on purpose: every template variable comes from arguments — the model
|
||||
config, the training config, and the dataset metadata — none from a live model, so a card
|
||||
can also be rendered from a checkpoint's `config.json` alone (see `lerobot-convert-dcp`).
|
||||
The config type selects the template: reward models get the reward-model card, policies the
|
||||
policy card with the training/dataset sections.
|
||||
|
||||
Args:
|
||||
model_cfg (PreTrainedConfig | RewardModelConfig): The model config providing type,
|
||||
license, tags, repo id, and — for policies — the feature declarations.
|
||||
cfg (TrainPipelineConfig | None, optional): The training config for the training and
|
||||
dataset card sections. Defaults to None.
|
||||
dataset_meta (LeRobotDatasetMetadata | None, optional): Dataset metadata for the
|
||||
dataset card sections. Defaults to None.
|
||||
|
||||
Returns:
|
||||
ModelCard: The rendered and validated LeRobot model card.
|
||||
"""
|
||||
model_type = model_cfg.type
|
||||
base_model = _BASE_MODEL_MAPPING.get(model_type)
|
||||
|
||||
if isinstance(model_cfg, RewardModelConfig):
|
||||
tags = {"robotics", "lerobot", "reward-model", model_type}
|
||||
template_card = (
|
||||
files("lerobot.templates")
|
||||
.joinpath("lerobot_rewardmodel_modelcard_template.md")
|
||||
.read_text("utf-8")
|
||||
)
|
||||
context: dict[str, Any] = {} # the reward template renders from card_data alone
|
||||
else:
|
||||
tags = {"robotics", "lerobot", model_type}
|
||||
template_card = (
|
||||
files("lerobot.templates").joinpath("lerobot_modelcard_template.md").read_text("utf-8")
|
||||
)
|
||||
context = build_card_context(cfg, dataset_meta, model_cfg.input_features, model_cfg.output_features)
|
||||
# Used by the template to pre-fill commands and the "Fine-tuned from" line.
|
||||
context["policy_repo_id"] = model_cfg.repo_id
|
||||
context["base_model"] = base_model
|
||||
|
||||
card_data = ModelCardData(
|
||||
license=model_cfg.license or "apache-2.0",
|
||||
library_name="lerobot",
|
||||
pipeline_tag="robotics",
|
||||
tags=list(tags.union(model_cfg.tags or [])),
|
||||
model_name=model_type,
|
||||
datasets=cfg.dataset.repo_id if cfg is not None else None,
|
||||
base_model=base_model,
|
||||
)
|
||||
card = ModelCard.from_template(card_data, template_str=template_card, **context)
|
||||
card.validate()
|
||||
return card
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/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.
|
||||
"""Execution-runtime configuration: everything handed to (or applied by) the `Accelerator`.
|
||||
|
||||
Each sub-config mirrors the plain-typed subset of the corresponding accelerate object and
|
||||
builds it at runtime (the way ``OptimizerConfig.build()`` constructs a ``torch.optim.Optimizer``),
|
||||
so the whole tree round-trips through the CLI and ``train_config.json`` and parsing a config
|
||||
never imports accelerate.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lerobot.configs.parallelism import ParallelismConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from accelerate import Accelerator
|
||||
from accelerate.utils import (
|
||||
DistributedDataParallelKwargs,
|
||||
FullyShardedDataParallelPlugin,
|
||||
GradientAccumulationPlugin,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FSDPConfig:
|
||||
"""Mirror of the `FullyShardedDataParallelPlugin` subset LeRobot supports (FSDP2 only).
|
||||
|
||||
Exactly one wrap policy applies: `wrap_modules` (module *class names* forming the FSDP
|
||||
units — and, later, the activation-checkpointing units) or `min_num_params` (size-based).
|
||||
When both are None, the policy's own `_fsdp_wrap_modules` declaration is used; a run where
|
||||
no wrap source exists at all fails loudly rather than silently wrapping only the root.
|
||||
"""
|
||||
|
||||
reshard_after_forward: bool = True
|
||||
wrap_modules: list[str] | None = None
|
||||
min_num_params: int | None = None
|
||||
cpu_offload: bool = False
|
||||
# Regex matched against module FQNs to exclude their parameters from sharding.
|
||||
ignored_modules: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the wrap-policy fields.
|
||||
|
||||
Raises:
|
||||
ValueError: If both ``wrap_modules`` and ``min_num_params`` are set (they are
|
||||
mutually exclusive wrap policies), or if ``min_num_params`` is < 1.
|
||||
"""
|
||||
if self.wrap_modules is not None and self.min_num_params is not None:
|
||||
raise ValueError(
|
||||
"fsdp.wrap_modules and fsdp.min_num_params are mutually exclusive wrap policies."
|
||||
)
|
||||
if self.min_num_params is not None and self.min_num_params < 1:
|
||||
raise ValueError(f"fsdp.min_num_params must be >= 1, got {self.min_num_params}.")
|
||||
|
||||
def build_plugin(self) -> "FullyShardedDataParallelPlugin":
|
||||
"""Build the FSDP2 plugin for `Accelerator(fsdp_plugin=...)`.
|
||||
|
||||
Returns:
|
||||
FullyShardedDataParallelPlugin: FSDP2 (`fsdp_version=2`) plugin carrying the
|
||||
mirrored wrap policy, resharding, CPU-offload, and ignored-modules settings.
|
||||
"""
|
||||
from accelerate.utils import FullyShardedDataParallelPlugin
|
||||
|
||||
use_size_policy = self.min_num_params is not None
|
||||
return FullyShardedDataParallelPlugin(
|
||||
fsdp_version=2,
|
||||
reshard_after_forward=self.reshard_after_forward,
|
||||
auto_wrap_policy="size_based_wrap" if use_size_policy else "transformer_based_wrap",
|
||||
# May legitimately still be None here: the policy-declared default is applied right
|
||||
# before `accelerator.prepare()` (see lerobot.distributed.factory.set_fsdp_wrap_modules).
|
||||
transformer_cls_names_to_wrap=list(self.wrap_modules) if self.wrap_modules else None,
|
||||
min_num_params=self.min_num_params,
|
||||
cpu_offload=self.cpu_offload,
|
||||
ignored_modules=self.ignored_modules,
|
||||
# state_dict_type stays at the FSDP2 default (SHARDED_STATE_DICT) and is never
|
||||
# switched: full gathers go through torch's state-dict API, which does not consult
|
||||
# the plugin. activation_checkpointing stays False: AC is LeRobot-owned.
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DDPConfig:
|
||||
"""Mirror of the `DistributedDataParallelKwargs` subset LeRobot exposes."""
|
||||
|
||||
# Today's in-script default, kept for models with conditional computation.
|
||||
find_unused_parameters: bool = True
|
||||
gradient_as_bucket_view: bool = False
|
||||
static_graph: bool = False
|
||||
|
||||
def build_kwargs_handler(self) -> "DistributedDataParallelKwargs":
|
||||
"""Build the DDP kwargs handler for `Accelerator(kwargs_handlers=[...])`.
|
||||
|
||||
Returns:
|
||||
DistributedDataParallelKwargs: Handler carrying the mirrored DDP fields, applied
|
||||
by accelerate when it wraps the model in `DistributedDataParallel`.
|
||||
"""
|
||||
from accelerate.utils import DistributedDataParallelKwargs
|
||||
|
||||
return DistributedDataParallelKwargs(
|
||||
find_unused_parameters=self.find_unused_parameters,
|
||||
gradient_as_bucket_view=self.gradient_as_bucket_view,
|
||||
static_graph=self.static_graph,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GradientAccumulationConfig:
|
||||
"""Mirror of the `GradientAccumulationPlugin` subset LeRobot supports.
|
||||
|
||||
Only the step count is a knob. ``sync_with_dataloader`` is pinned to False by
|
||||
:meth:`build_plugin`: the training loop cycles a finite dataloader, so accelerate's default
|
||||
of syncing at every dataloader end would force an optimizer step at every dataset epoch
|
||||
boundary instead of every ``steps`` micro-batches.
|
||||
"""
|
||||
|
||||
steps: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the accumulation step count.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``steps`` is < 1.
|
||||
"""
|
||||
if self.steps < 1:
|
||||
raise ValueError(f"gradient_accumulation.steps must be >= 1, got {self.steps}.")
|
||||
|
||||
def build_plugin(self) -> "GradientAccumulationPlugin":
|
||||
"""Build the plugin for `Accelerator(gradient_accumulation_plugin=...)`.
|
||||
|
||||
A named plugin argument, not a `kwargs_handlers` entry: accelerate consumes this object
|
||||
through its dedicated constructor parameter — the `KwargsHandler` base class only lends
|
||||
it `to_kwargs()`, so the consumption site, not the inheritance, decides its role.
|
||||
|
||||
Returns:
|
||||
GradientAccumulationPlugin: Carrying the mirrored step count, with
|
||||
``sync_with_dataloader=False`` pinned (see the class docstring).
|
||||
"""
|
||||
from accelerate.utils import GradientAccumulationPlugin
|
||||
|
||||
return GradientAccumulationPlugin(num_steps=self.steps, sync_with_dataloader=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileConfig:
|
||||
"""torch.compile knobs — a configured placeholder: wiring lands in a later round.
|
||||
|
||||
The setup-order contract it will follow is already fixed: compile applies
|
||||
after CP dispatch install and activation checkpointing, before `fully_shard`, regionally
|
||||
(per wrap unit) — the only combination proven with FSDP2.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
backend: str = "inductor"
|
||||
mode: str | None = None
|
||||
regional: bool = True
|
||||
|
||||
|
||||
class ActivationCheckpointingMode(str, Enum):
|
||||
NONE = "none"
|
||||
FULL = "full"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivationCheckpointingConfig:
|
||||
"""Activation-checkpointing knobs — a configured placeholder: wiring lands in a later round.
|
||||
|
||||
AC units will coincide with the FSDP wrap units (one declaration drives both), applied
|
||||
before torch.compile and `fully_shard` (the same ordering contract as CompileConfig).
|
||||
"""
|
||||
|
||||
mode: ActivationCheckpointingMode = ActivationCheckpointingMode.NONE
|
||||
|
||||
|
||||
@dataclass
|
||||
class AcceleratorConfig:
|
||||
"""Builds the `Accelerator` — the runtime counterpart of the `parallelism` topology.
|
||||
|
||||
`mixed_precision` selects accelerate-native AMP for DDP/single-GPU runs and the FSDP2
|
||||
`MixedPrecisionPolicy` for sharded runs (accelerate derives it). Sharded runs support
|
||||
"no" and "bf16" only; fp16's GradScaler-over-DTensor path is unverified and fails fast
|
||||
at config validation.
|
||||
"""
|
||||
|
||||
mixed_precision: str = "no"
|
||||
gradient_accumulation: GradientAccumulationConfig = field(default_factory=GradientAccumulationConfig)
|
||||
fsdp: FSDPConfig = field(default_factory=FSDPConfig)
|
||||
ddp: DDPConfig = field(default_factory=DDPConfig)
|
||||
compile: CompileConfig = field(default_factory=CompileConfig)
|
||||
activation_checkpointing: ActivationCheckpointingConfig = field(
|
||||
default_factory=ActivationCheckpointingConfig
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the accelerate-facing scalar fields.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``mixed_precision`` is not one of ``"no"``, ``"fp16"``, ``"bf16"``.
|
||||
"""
|
||||
if self.mixed_precision not in ("no", "fp16", "bf16"):
|
||||
raise ValueError(
|
||||
f"mixed_precision must be one of 'no', 'fp16', 'bf16', got {self.mixed_precision!r}."
|
||||
)
|
||||
|
||||
def build(self, parallelism: ParallelismConfig, *, cpu: bool = False) -> "Accelerator":
|
||||
"""Translate the mirrored fields into a ready `Accelerator` (call once per process).
|
||||
|
||||
`parallelism` must already be resolved against the world size. The degradation matrix
|
||||
is encoded here and nowhere else: sharded -> FSDP2 (+HSDP via the accelerate
|
||||
`ParallelismConfig` mesh), replicated-only -> DDP kwargs, single process -> plain.
|
||||
|
||||
Args:
|
||||
parallelism (ParallelismConfig): The resolved process topology; selects which
|
||||
accelerate path (FSDP2 mesh, DDP kwargs handler, or plain) is configured.
|
||||
cpu (bool): Force CPU execution even when CUDA is available. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Accelerator: The configured accelerate entry point for this process.
|
||||
"""
|
||||
from accelerate import Accelerator
|
||||
|
||||
kwargs: dict = {
|
||||
# LeRobot steps its scheduler manually once per training step; accelerate must not
|
||||
# rescale scheduler stepping by num_processes.
|
||||
"step_scheduler_with_optimizer": False,
|
||||
"gradient_accumulation_plugin": self.gradient_accumulation.build_plugin(),
|
||||
"mixed_precision": self.mixed_precision,
|
||||
"cpu": cpu,
|
||||
}
|
||||
if parallelism.is_sharded:
|
||||
kwargs["fsdp_plugin"] = self.fsdp.build_plugin()
|
||||
kwargs["parallelism_config"] = _accelerate_parallelism_config(parallelism)
|
||||
elif parallelism.is_replicated_only:
|
||||
kwargs["kwargs_handlers"] = [self.ddp.build_kwargs_handler()]
|
||||
return Accelerator(**kwargs)
|
||||
|
||||
|
||||
def _accelerate_parallelism_config(parallelism: ParallelismConfig) -> object:
|
||||
"""LeRobot topology -> accelerate `ParallelismConfig`.
|
||||
|
||||
CP is declared honestly (`cp_size = ring x ulysses`) so accelerate builds the canonical
|
||||
mesh, folds CP into the FSDP shard group (`dp_shard_cp`), and duplicates batches within CP
|
||||
groups. The ring/ulysses sub-structure stays private to `lerobot.distributed.ParallelDims`.
|
||||
|
||||
Args:
|
||||
parallelism (ParallelismConfig): The resolved LeRobot topology to translate.
|
||||
|
||||
Returns:
|
||||
object: The accelerate `ParallelismConfig` mirroring `dp_replicate`, `dp_shard`, and
|
||||
the collapsed `cp_size` (annotated as `object` so importing this module never
|
||||
imports accelerate).
|
||||
"""
|
||||
from accelerate.parallelism_config import ParallelismConfig as AccelerateParallelismConfig
|
||||
|
||||
return AccelerateParallelismConfig(
|
||||
dp_replicate_size=parallelism.dp_replicate,
|
||||
dp_shard_size=parallelism.dp_shard,
|
||||
cp_size=parallelism.cp_size,
|
||||
)
|
||||
@@ -14,6 +14,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lerobot.transforms import ImageTransformsConfig
|
||||
@@ -21,6 +22,8 @@ from lerobot.utils.import_utils import get_safe_default_video_backend
|
||||
|
||||
from .video import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasetConfig:
|
||||
@@ -29,10 +32,15 @@ class DatasetConfig:
|
||||
# "dataset_index" into the returned item. The index mapping is made according to the order in which the
|
||||
# datasets are provided.
|
||||
repo_id: str
|
||||
# Hub repository type: "dataset" (default) or "bucket" for an HF Storage Bucket streamed over
|
||||
# hf://buckets/. Buckets are streaming-only, so "bucket" requires streaming=true.
|
||||
repo_type: str = "dataset"
|
||||
# Root directory for a concrete local dataset tree (e.g. 'dataset/path'). If None, local datasets are
|
||||
# looked up under $HF_LEROBOT_HOME/repo_id and Hub downloads use a revision-safe cache under $HF_LEROBOT_HOME/hub.
|
||||
root: str | None = None
|
||||
episodes: list[int] | None = None
|
||||
# Episode indices to drop (e.g. corrupt or heterogeneous ones). Applied on top of `episodes`.
|
||||
exclude_episodes: list[int] | None = None
|
||||
image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig)
|
||||
revision: str | None = None
|
||||
use_imagenet_stats: bool = True
|
||||
@@ -48,6 +56,16 @@ class DatasetConfig:
|
||||
eval_split: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.repo_type not in ("dataset", "bucket"):
|
||||
raise ValueError(f"repo_type must be 'dataset' or 'bucket', got {self.repo_type!r}")
|
||||
if self.repo_type == "bucket" and not self.streaming:
|
||||
raise ValueError(
|
||||
"repo_type='bucket' is streaming-only: set streaming=true to train from an HF Storage Bucket."
|
||||
)
|
||||
if self.repo_type == "bucket" and self.eval_split != 0.0:
|
||||
raise ValueError(
|
||||
"eval_split requires map-style datasets and is not supported with repo_type='bucket'."
|
||||
)
|
||||
if self.depth_output_unit not in (DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT):
|
||||
raise ValueError(
|
||||
f"depth_output_unit must be '{DEPTH_METER_UNIT}' or '{DEPTH_MILLIMETER_UNIT}', got {self.depth_output_unit!r}"
|
||||
@@ -62,6 +80,14 @@ class DatasetConfig:
|
||||
if len(self.episodes) != len(set(self.episodes)):
|
||||
duplicates = sorted({ep for ep in self.episodes if self.episodes.count(ep) > 1})
|
||||
raise ValueError(f"Episode indices contain duplicates: {duplicates}")
|
||||
if self.exclude_episodes is not None:
|
||||
negative_episodes = [episode for episode in self.exclude_episodes if episode < 0]
|
||||
if negative_episodes:
|
||||
logger.warning(
|
||||
"Ignoring negative exclude_episodes entries: %s",
|
||||
negative_episodes,
|
||||
)
|
||||
self.exclude_episodes = [episode for episode in self.exclude_episodes if episode >= 0]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -93,9 +119,6 @@ class EvalConfig:
|
||||
recording_repo_id: str | None = None
|
||||
# Whether the pushed recording repositories should be private.
|
||||
recording_private: bool = False
|
||||
# Whether to save the policy's imagined/predicted video (world-model policies only) as mp4s.
|
||||
# Requests intermediate predictions from the policy each step; policies that produce none are unaffected.
|
||||
save_predicted_video: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.recording_repo_id is not None and not self.recording:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/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.
|
||||
"""Declarative process topology for distributed training and inference.
|
||||
|
||||
The mesh convention (canonical row-major rank layout, outermost first)::
|
||||
|
||||
(dp_replicate, dp_shard, ring, ulysses)
|
||||
|
||||
- ``dp_replicate x dp_shard`` is the data-parallel world: HSDP replicates over
|
||||
``dp_replicate`` and shards parameters over ``dp_shard``. FSDP2's actual shard
|
||||
group folds context parallelism in (``dp_shard x ring x ulysses``), matching
|
||||
accelerate's ``dp_shard_cp`` flattening and torchtitan's ``fsdp`` axis.
|
||||
- ``ring`` is the outer and ``ulysses`` the inner context-parallel dim
|
||||
(diffusers convention: ulysses all-to-all exchanges run over adjacent, typically
|
||||
NVLink-connected ranks).
|
||||
- ``cfg_parallel`` (classifier-free-guidance parallelism) is a branch-parallel,
|
||||
inference-only dim that sits between dp and the sequence dims. It never
|
||||
affects weight sharding or checkpoints.
|
||||
|
||||
This module is pure configuration: plain-typed dataclasses that draccus can
|
||||
round-trip through the CLI and ``train_config.json``. Runtime objects (device
|
||||
meshes, process groups) live in :mod:`lerobot.distributed`.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextParallelConfig:
|
||||
"""Ring x Ulysses context parallelism (sequence parallelism for attention).
|
||||
|
||||
Both degrees are configured placeholders in this release: the CP engine is not implemented
|
||||
yet, and enabling either degree > 1 fails fast at config validation. The fields exist now so
|
||||
that the CLI surface, checkpoint metadata, and mesh math are stable when the engine lands.
|
||||
"""
|
||||
|
||||
ring_degree: int = 1
|
||||
ulysses_degree: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the declared context-parallel degrees.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``ring_degree`` or ``ulysses_degree`` is < 1.
|
||||
"""
|
||||
if self.ring_degree < 1 or self.ulysses_degree < 1:
|
||||
raise ValueError(
|
||||
f"Context-parallel degrees must be >= 1, got ring_degree={self.ring_degree}, "
|
||||
f"ulysses_degree={self.ulysses_degree}."
|
||||
)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Total number of ranks a full sequence is sharded across."""
|
||||
return self.ring_degree * self.ulysses_degree
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelismConfig:
|
||||
"""Degrees of every parallelism dim. Invariant: their product equals the world size.
|
||||
|
||||
Degradations are expressed purely through the degrees (no mode flags):
|
||||
|
||||
- single process: all degrees 1;
|
||||
- DDP: ``dp_replicate == world_size`` (auto-filled when every sharding field is left at its
|
||||
default — plain ``torchrun`` keeps today's out-of-the-box behavior);
|
||||
- FSDP: ``dp_shard > 1`` (or ``-1`` to fill the remaining world into the shard dim);
|
||||
- HSDP: ``dp_replicate > 1`` and ``dp_shard > 1``.
|
||||
|
||||
``resolve()`` turns the declared degrees into concrete ones once the world size is known and
|
||||
is the single place the world-size equation is enforced. It is called by
|
||||
:func:`lerobot.distributed.factory.make_accelerator`; the config is inert until then.
|
||||
"""
|
||||
|
||||
dp_replicate: int = 1
|
||||
# -1 is an explicit opt-in sentinel: shard over world_size // (dp_replicate * cp).
|
||||
dp_shard: int = 1
|
||||
context_parallel: ContextParallelConfig = field(default_factory=ContextParallelConfig)
|
||||
# Classifier-free-guidance parallelism — inference-only (cosmos/vllm-omni precedent:
|
||||
# cond/uncond branches on different ranks). Reserved for the serving round; training
|
||||
# validates it to 1. Meaningful values are 1 or 2 (Cosmos3 has two CFG branches).
|
||||
cfg_parallel: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the declared degrees (world-size-independent checks only).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``dp_replicate`` is < 1, ``dp_shard`` is neither >= 1 nor the
|
||||
``-1`` infer sentinel, or ``cfg_parallel`` is not 1 or 2.
|
||||
"""
|
||||
if self.dp_replicate < 1:
|
||||
raise ValueError(f"dp_replicate must be >= 1, got {self.dp_replicate}.")
|
||||
if self.dp_shard < 1 and self.dp_shard != -1:
|
||||
raise ValueError(f"dp_shard must be >= 1, or -1 to infer, got {self.dp_shard}.")
|
||||
if self.cfg_parallel not in (1, 2):
|
||||
raise ValueError(f"cfg_parallel must be 1 or 2, got {self.cfg_parallel}.")
|
||||
|
||||
@property
|
||||
def cp_size(self) -> int:
|
||||
"""Total context-parallel size (``ring_degree * ulysses_degree``)."""
|
||||
return self.context_parallel.size
|
||||
|
||||
@property
|
||||
def is_sharded(self) -> bool:
|
||||
"""True when the run uses FSDP2 (parameters sharded); selects the sharded engine path."""
|
||||
return self.dp_shard != 1 or self.cp_size > 1
|
||||
|
||||
@property
|
||||
def is_replicated_only(self) -> bool:
|
||||
"""True for plain DDP (weights replicated, no sharding)."""
|
||||
return not self.is_sharded and self.dp_replicate > 1
|
||||
|
||||
@property
|
||||
def dp_world_size(self) -> int:
|
||||
"""Number of distinct data-parallel workers (batches are sharded this many ways).
|
||||
|
||||
Returns:
|
||||
int: ``dp_replicate * dp_shard``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If accessed while ``dp_shard`` is still the ``-1`` sentinel, i.e.
|
||||
before :meth:`resolve` has bound the degrees to a world size.
|
||||
"""
|
||||
if self.dp_shard == -1:
|
||||
raise RuntimeError("dp_world_size is undefined before resolve() fills dp_shard=-1.")
|
||||
return self.dp_replicate * self.dp_shard
|
||||
|
||||
def resolve(self, world_size: int) -> None:
|
||||
"""Bind the declared degrees to a concrete world size (idempotent).
|
||||
|
||||
Fills the ``dp_shard=-1`` sentinel, auto-fills ``dp_replicate`` for the DDP degradation,
|
||||
and enforces ``dp_replicate * dp_shard * cp == world_size`` with every degree echoed on
|
||||
failure.
|
||||
|
||||
Args:
|
||||
world_size (int): Total number of launched processes (torchrun's ``WORLD_SIZE``).
|
||||
|
||||
Raises:
|
||||
ValueError: If a context-parallel degree is > 1 (the CP engine is not implemented
|
||||
yet), if ``dp_shard=-1`` cannot be inferred because ``world_size`` is not
|
||||
divisible by ``dp_replicate * cp``, or if the resolved degrees do not multiply
|
||||
to ``world_size``.
|
||||
"""
|
||||
if self.cp_size > 1:
|
||||
raise ValueError(
|
||||
"Context parallelism is not implemented yet: ring_degree and ulysses_degree "
|
||||
"must be 1. The fields are reserved for the CP engine round."
|
||||
)
|
||||
if self.is_sharded:
|
||||
if self.dp_shard == -1:
|
||||
self.dp_shard, remainder = divmod(world_size, self.dp_replicate * self.cp_size)
|
||||
if remainder or self.dp_shard < 1:
|
||||
raise ValueError(
|
||||
f"Cannot infer dp_shard: world_size={world_size} is not divisible by "
|
||||
f"dp_replicate={self.dp_replicate} * cp={self.cp_size}."
|
||||
)
|
||||
elif self.dp_replicate == 1:
|
||||
# Untouched config on a multi-process launch: fill the DDP degradation.
|
||||
self.dp_replicate = world_size
|
||||
total = self.dp_replicate * self.dp_shard * self.cp_size
|
||||
if total != world_size:
|
||||
raise ValueError(
|
||||
f"Parallelism degrees do not multiply to the world size: dp_replicate="
|
||||
f"{self.dp_replicate} * dp_shard={self.dp_shard} * ring="
|
||||
f"{self.context_parallel.ring_degree} * ulysses="
|
||||
f"{self.context_parallel.ulysses_degree} = {total} != WORLD_SIZE={world_size}."
|
||||
)
|
||||
|
||||
|
||||
def world_size_from_env() -> int:
|
||||
"""World size as set by torchrun (or 1 outside distributed launches).
|
||||
|
||||
Returns:
|
||||
int: The ``WORLD_SIZE`` environment variable, or 1 when unset.
|
||||
"""
|
||||
return int(os.environ.get("WORLD_SIZE", "1"))
|
||||
@@ -23,6 +23,7 @@ from typing import Any, Literal, get_args
|
||||
|
||||
MessageRole = Literal["user", "assistant", "system", "tool"]
|
||||
MessageStream = Literal["high_level", "low_level"]
|
||||
RecipeRoute = Literal["vqa"]
|
||||
|
||||
DEFAULT_BINDINGS = {
|
||||
"subtask": "active_at(t, style=subtask)",
|
||||
@@ -40,6 +41,7 @@ discovery (here) and rendered-message substitution (in ``language_render``)."""
|
||||
|
||||
_VALID_ROLES = frozenset(get_args(MessageRole))
|
||||
_VALID_STREAMS = frozenset(get_args(MessageStream))
|
||||
_VALID_ROUTES = frozenset(get_args(RecipeRoute))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,7 +80,7 @@ class MessageTurn:
|
||||
raise ValueError(f"Unsupported message stream: {self.stream!r}")
|
||||
if self.content is None and self.tool_calls_from is None:
|
||||
raise ValueError("MessageTurn.content is required unless tool_calls_from is set.")
|
||||
if self.content is not None and not isinstance(self.content, (str, list)):
|
||||
if self.content is not None and not isinstance(self.content, str | list):
|
||||
raise TypeError("MessageTurn.content must be a string, a list of HF-style blocks, or None.")
|
||||
if isinstance(self.content, list):
|
||||
for block in self.content:
|
||||
@@ -99,13 +101,16 @@ class TrainingRecipe:
|
||||
|
||||
A recipe is either a *message recipe* (``messages`` plus optional
|
||||
``bindings``) or a *blend recipe* (``blend`` mapping names to weighted
|
||||
sub-recipes). ``weight`` is only meaningful inside a blend.
|
||||
sub-recipes). ``weight`` and ``route`` are only meaningful inside a blend;
|
||||
``route: vqa`` gives sparse VQA annotations priority over normal weighted
|
||||
selection.
|
||||
"""
|
||||
|
||||
messages: list[MessageTurn] | None = None
|
||||
bindings: dict[str, str] | None = None
|
||||
blend: dict[str, TrainingRecipe] | None = None
|
||||
weight: float | None = None
|
||||
route: RecipeRoute | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate that exactly one of ``messages`` or ``blend`` is set."""
|
||||
@@ -113,6 +118,10 @@ class TrainingRecipe:
|
||||
raise ValueError("TrainingRecipe must set only one of messages or blend.")
|
||||
if self.messages is None and self.blend is None:
|
||||
raise ValueError("TrainingRecipe must set one of messages or blend.")
|
||||
if self.route is not None and self.route not in _VALID_ROUTES:
|
||||
raise ValueError(f"Unsupported recipe route: {self.route!r}")
|
||||
if self.blend is not None and self.route is not None:
|
||||
raise ValueError("TrainingRecipe.route may only be set on a message recipe inside a blend.")
|
||||
|
||||
if self.messages is not None:
|
||||
self._validate_message_recipe()
|
||||
@@ -147,8 +156,9 @@ class TrainingRecipe:
|
||||
return cls.from_dict(data)
|
||||
|
||||
def _validate_message_recipe(self) -> None:
|
||||
"""Ensure every templated binding is known and at least one turn is a target."""
|
||||
assert self.messages is not None
|
||||
"""Validate bindings and require text or low-level action supervision."""
|
||||
if self.messages is None:
|
||||
raise ValueError("Cannot validate a message recipe without messages.")
|
||||
known_bindings = set(DEFAULT_BINDINGS) | set(self.bindings or {}) | {"task"}
|
||||
|
||||
for turn in self.messages:
|
||||
@@ -156,12 +166,19 @@ class TrainingRecipe:
|
||||
if missing:
|
||||
raise ValueError(f"MessageTurn references unknown binding(s): {sorted(missing)}")
|
||||
|
||||
if not any(turn.target for turn in self.messages):
|
||||
raise ValueError("Message recipes must contain at least one target turn.")
|
||||
has_target = any(turn.target for turn in self.messages)
|
||||
has_low_level = any(turn.stream == "low_level" for turn in self.messages)
|
||||
if not (has_target or has_low_level):
|
||||
raise ValueError(
|
||||
"Message recipes must contain at least one supervised turn — "
|
||||
"either ``target: true`` (text CE) or ``stream: low_level`` "
|
||||
"(flow/action loss)."
|
||||
)
|
||||
|
||||
def _validate_blend_recipe(self) -> None:
|
||||
"""Ensure each blend component is a non-empty, weighted message recipe."""
|
||||
assert self.blend is not None
|
||||
if self.blend is None:
|
||||
raise ValueError("Cannot validate a blend recipe without blend components.")
|
||||
if not self.blend:
|
||||
raise ValueError("Blend recipes must contain at least one component.")
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Predicts subtasks from tasks and trains subtask-conditioned action flow without memory or plans.
|
||||
# Requires `subtask` annotations; samples with missing `if_present` bindings do not render.
|
||||
|
||||
blend:
|
||||
|
||||
high_level_subtask:
|
||||
weight: 0.30
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
|
||||
|
||||
low_level_execution:
|
||||
weight: 0.70
|
||||
messages:
|
||||
# The low-level stream trains action flow on the generated or annotated subtask.
|
||||
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Paper-style joint sequence (pi0.5 §IV-B): one sample supervises the subtask
|
||||
# text with CE and, because the assistant turn is part of the prefix, conditions
|
||||
# the FAST and flow action losses on the same annotated subtask in one forward.
|
||||
# The supervised span is attended causally; the action losses see task + subtask.
|
||||
#
|
||||
# Pair with `--policy.joint_subtask_conditioning=true` at inference so the flow
|
||||
# prefix reproduces this layout (task turn with state + causal generated subtask).
|
||||
# Samples without a `subtask` annotation fall back to a plain task-prompt
|
||||
# low-level sample via `if_present`.
|
||||
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: low_level}
|
||||
- {role: assistant, content: "${subtask}", stream: low_level, target: true, if_present: subtask}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Trains subtask prediction, subtask-conditioned action flow, and memory updates without plans.
|
||||
# Requires `subtask` and `memory`; missing `if_present` bindings skip the affected sub-recipe.
|
||||
|
||||
blend:
|
||||
|
||||
high_level_subtask:
|
||||
weight: 0.25
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
|
||||
|
||||
low_level_execution:
|
||||
weight: 0.60
|
||||
messages:
|
||||
# The low-level stream trains action flow on the generated or annotated subtask.
|
||||
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
|
||||
|
||||
memory_update:
|
||||
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
|
||||
# Inference controls update timing through `subtask_change` events.
|
||||
weight: 0.15
|
||||
bindings:
|
||||
prior_memory: "nth_prev(style=memory, offset=1)"
|
||||
current_memory: "active_at(t, style=memory)"
|
||||
completed_subtask: "nth_prev(style=subtask, offset=1)"
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
|
||||
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
|
||||
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Adds memory, spoken interjection responses, and camera-grounded VQA to subtask/action training.
|
||||
# Missing optional annotations skip only their sub-recipe; `say` tool calls tokenize as `<say>...</say>`.
|
||||
|
||||
blend:
|
||||
|
||||
high_level_subtask:
|
||||
weight: 0.25
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
|
||||
|
||||
low_level_execution:
|
||||
weight: 0.40
|
||||
messages:
|
||||
# The low-level stream trains action flow on the generated or annotated subtask.
|
||||
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
|
||||
|
||||
memory_update:
|
||||
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
|
||||
# Inference controls update timing through `subtask_change` events.
|
||||
weight: 0.10
|
||||
bindings:
|
||||
prior_memory: "nth_prev(style=memory, offset=1)"
|
||||
current_memory: "active_at(t, style=memory)"
|
||||
completed_subtask: "nth_prev(style=subtask, offset=1)"
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
|
||||
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
|
||||
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
|
||||
|
||||
user_interjection_response:
|
||||
weight: 0.10
|
||||
bindings:
|
||||
interjection: "emitted_at(t, style=interjection)"
|
||||
speech: "emitted_at(t, role=assistant, tool_name=say)"
|
||||
messages:
|
||||
- {role: user, content: "${task}", stream: high_level}
|
||||
- {role: user, content: "${interjection}", stream: high_level, if_present: interjection}
|
||||
# The assistant target is a `say` tool call flattened to a `<say>...</say>` marker.
|
||||
- {role: assistant, stream: high_level, target: true, if_present: speech, tool_calls_from: speech}
|
||||
|
||||
# Each camera uses a separate VQA sub-recipe for view-specific binding.
|
||||
ask_vqa_top:
|
||||
weight: 0.075
|
||||
route: vqa
|
||||
bindings:
|
||||
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.front)"
|
||||
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.front)"
|
||||
messages:
|
||||
- role: user
|
||||
stream: high_level
|
||||
if_present: vqa_query
|
||||
content:
|
||||
- {type: image, feature: observation.images.front}
|
||||
- {type: text, text: "${vqa_query}"}
|
||||
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
|
||||
|
||||
ask_vqa_wrist:
|
||||
weight: 0.075
|
||||
route: vqa
|
||||
bindings:
|
||||
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.wrist)"
|
||||
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.wrist)"
|
||||
messages:
|
||||
- role: user
|
||||
stream: high_level
|
||||
if_present: vqa_query
|
||||
content:
|
||||
- {type: image, feature: observation.images.wrist}
|
||||
- {type: text, text: "${vqa_query}"}
|
||||
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
|
||||
@@ -18,6 +18,7 @@ import multiprocessing
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -26,6 +27,8 @@ from huggingface_hub import hf_hub_download
|
||||
from huggingface_hub.errors import HfHubHTTPError
|
||||
|
||||
from lerobot import envs
|
||||
from lerobot.configs.accelerator import AcceleratorConfig, ActivationCheckpointingMode
|
||||
from lerobot.configs.parallelism import ParallelismConfig
|
||||
from lerobot.optim import LRSchedulerConfig, OptimizerConfig
|
||||
from lerobot.utils.constants import PRETRAINED_MODEL_DIR
|
||||
from lerobot.utils.hub import HubMixin, find_latest_hub_checkpoint
|
||||
@@ -39,6 +42,34 @@ from .rewards import RewardModelConfig
|
||||
TRAIN_CONFIG_NAME = "train_config.json"
|
||||
|
||||
|
||||
class CheckpointFormat(str, Enum):
|
||||
"""Model-artifact format inside training checkpoints.
|
||||
|
||||
Selects only the *model* artifact; the training_state layout is format-independent (the
|
||||
optimizer channel is always DCP under sharded runs, safetensors+json otherwise).
|
||||
|
||||
- SAFETENSORS (default): a full `model.safetensors` — maximum compatibility, one gather per
|
||||
save under sharding.
|
||||
- DCP: sharded `pytorch_model_fsdp_0/*.distcp` only — fastest save/resume; convert with
|
||||
`lerobot-convert-dcp` before distributing.
|
||||
- SAFETENSORS_AND_DCP: both artifacts, written independently.
|
||||
"""
|
||||
|
||||
SAFETENSORS = "safetensors"
|
||||
DCP = "dcp"
|
||||
SAFETENSORS_AND_DCP = "safetensors_dcp"
|
||||
|
||||
@property
|
||||
def wants_safetensors(self) -> bool:
|
||||
"""True when a full `model.safetensors` artifact should be written."""
|
||||
return self in (CheckpointFormat.SAFETENSORS, CheckpointFormat.SAFETENSORS_AND_DCP)
|
||||
|
||||
@property
|
||||
def wants_dcp(self) -> bool:
|
||||
"""True when sharded DCP model shards (`pytorch_model_fsdp_0/`) should be written."""
|
||||
return self in (CheckpointFormat.DCP, CheckpointFormat.SAFETENSORS_AND_DCP)
|
||||
|
||||
|
||||
def _migrate_legacy_rabc_fields(config: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return migrated payload for legacy RA-BC fields, or None when no migration is needed."""
|
||||
legacy_fields = (
|
||||
@@ -121,9 +152,16 @@ class TrainPipelineConfig(HubMixin):
|
||||
# Checkpoint is saved every `save_freq` training iterations and after the last training step.
|
||||
# A non-positive value disables periodic saving, keeping only the final checkpoint.
|
||||
save_freq: int = 20_000
|
||||
# Model-artifact format inside checkpoints; non-default values require a sharded run.
|
||||
checkpoint_format: CheckpointFormat = CheckpointFormat.SAFETENSORS
|
||||
use_policy_training_preset: bool = True
|
||||
optimizer: OptimizerConfig | None = None
|
||||
scheduler: LRSchedulerConfig | None = None
|
||||
# Process topology: dp_replicate / dp_shard (HSDP) and context-parallel degree placeholders.
|
||||
parallelism: ParallelismConfig = field(default_factory=ParallelismConfig)
|
||||
# Execution runtime handed to the Accelerator: mixed precision, gradient accumulation,
|
||||
# FSDP/DDP tuning knobs, compile & activation-checkpointing placeholders.
|
||||
accelerator: AcceleratorConfig = field(default_factory=AcceleratorConfig)
|
||||
eval: EvalConfig = field(default_factory=EvalConfig)
|
||||
wandb: WandBConfig = field(default_factory=WandBConfig)
|
||||
peft: PeftConfig | None = None
|
||||
@@ -291,6 +329,60 @@ class TrainPipelineConfig(HubMixin):
|
||||
if self.save_checkpoint_to_hub and not (self.policy is not None and self.policy.repo_id):
|
||||
raise ValueError("save_checkpoint_to_hub requires --policy.repo_id.")
|
||||
|
||||
self._validate_distributed()
|
||||
|
||||
def _validate_distributed(self) -> None:
|
||||
"""Fail-fasts for the distributed-training scope.
|
||||
|
||||
Raises:
|
||||
ValueError: If the config requests anything outside the verified scope: context
|
||||
parallelism or CFG parallelism (reserved placeholders), the compile or
|
||||
activation-checkpointing placeholders, a DCP checkpoint format on a
|
||||
non-sharded run, or — under sharded training — fp16 mixed precision, PEFT,
|
||||
reward-model training, in-training environment evaluation, or multi-optimizer
|
||||
configs.
|
||||
"""
|
||||
if self.parallelism.cp_size > 1:
|
||||
raise ValueError(
|
||||
"Context parallelism is not implemented yet: --parallelism.context_parallel.* "
|
||||
"degrees must be 1 (reserved for the CP engine round)."
|
||||
)
|
||||
if self.parallelism.cfg_parallel != 1:
|
||||
raise ValueError(
|
||||
"CFG parallelism is inference-only and must be 1 for training "
|
||||
"(cfg_parallel is reserved for the serving round)."
|
||||
)
|
||||
if self.accelerator.compile.enabled:
|
||||
raise ValueError("--accelerator.compile is a placeholder and not wired yet.")
|
||||
if self.accelerator.activation_checkpointing.mode is not ActivationCheckpointingMode.NONE:
|
||||
raise ValueError("--accelerator.activation_checkpointing is a placeholder and not wired yet.")
|
||||
if self.checkpoint_format is not CheckpointFormat.SAFETENSORS and not self.parallelism.is_sharded:
|
||||
raise ValueError(
|
||||
f"checkpoint_format={self.checkpoint_format.value} requires a sharded run "
|
||||
"(--parallelism.dp_shard != 1); non-sharded checkpoints are always safetensors."
|
||||
)
|
||||
if self.parallelism.is_sharded:
|
||||
if self.accelerator.mixed_precision == "fp16":
|
||||
raise ValueError(
|
||||
"fp16 is not supported under sharded training (GradScaler over DTensor "
|
||||
"gradients is unverified); use bf16 or full precision."
|
||||
)
|
||||
if self.peft is not None:
|
||||
raise ValueError("PEFT is not supported under sharded training yet.")
|
||||
if self.is_reward_model_training:
|
||||
raise ValueError(
|
||||
"Reward-model training is not supported under sharded training yet "
|
||||
"(reward models declare no FSDP wrap units and have no sharded save path)."
|
||||
)
|
||||
if self.env is not None and self.env_eval_freq > 0:
|
||||
raise ValueError(
|
||||
"In-training environment evaluation is not supported under sharded training "
|
||||
"(a rank-0-only rollout of a sharded model deadlocks on collectives); set "
|
||||
"--env_eval_freq=0 and evaluate with lerobot-eval on saved checkpoints."
|
||||
)
|
||||
if self.optimizer is not None and self.optimizer.builds_multiple_optimizers:
|
||||
raise ValueError("Multi-optimizer configs are not supported under sharded training.")
|
||||
|
||||
@classmethod
|
||||
def __get_path_fields__(cls) -> list[str]:
|
||||
"""Keys for draccus pretrained-path loading."""
|
||||
|
||||
@@ -76,7 +76,7 @@ import torch
|
||||
from pydantic import BaseModel, Field
|
||||
from transformers import AutoProcessor, Qwen3VLMoeForConditionalGeneration
|
||||
|
||||
from lerobot.datasets import LeRobotDataset
|
||||
from lerobot.datasets import LeRobotDataset, resolve_episode_indices
|
||||
|
||||
|
||||
# Pydantic Models for SARM Subtask Annotation
|
||||
@@ -1049,7 +1049,10 @@ def main():
|
||||
torch_dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
|
||||
|
||||
# Determine episodes
|
||||
episode_indices = args.episodes or list(range(dataset.meta.total_episodes))
|
||||
resolved_episodes = resolve_episode_indices(args.episodes, dataset.meta.total_episodes)
|
||||
episode_indices = (
|
||||
resolved_episodes if resolved_episodes is not None else list(range(dataset.meta.total_episodes))
|
||||
)
|
||||
|
||||
existing_annotations = load_annotations_from_dataset(dataset.root, prefix="sparse")
|
||||
if args.skip_existing:
|
||||
|
||||
@@ -52,7 +52,7 @@ from .pipeline_features import aggregate_pipeline_dataset_features, create_initi
|
||||
from .pyav_utils import check_video_encoder_parameters_pyav, detect_available_encoders_pyav
|
||||
from .sampler import EpisodeAwareSampler, compute_sampler_state
|
||||
from .streaming_dataset import StreamingLeRobotDataset
|
||||
from .utils import DEFAULT_EPISODES_PATH, create_lerobot_dataset_card
|
||||
from .utils import DEFAULT_EPISODES_PATH, create_lerobot_dataset_card, resolve_episode_indices
|
||||
from .video_utils import VideoEncodingManager
|
||||
|
||||
# NOTE: Low-level I/O functions (cast_stats_to_numpy, get_parquet_file_size_in_mb, etc.)
|
||||
@@ -97,6 +97,7 @@ __all__ = [
|
||||
"reencode_dataset",
|
||||
"remove_feature",
|
||||
"resolve_delta_timestamps",
|
||||
"resolve_episode_indices",
|
||||
"safe_stop_image_writer",
|
||||
"split_dataset",
|
||||
"write_stats",
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import datasets
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import tqdm
|
||||
|
||||
@@ -303,6 +304,46 @@ def update_meta_data(
|
||||
df["dataset_to_index"] = df["dataset_to_index"] + dst_meta.info.total_frames
|
||||
df["episode_index"] = df["episode_index"] + dst_meta.info.total_episodes
|
||||
|
||||
# Per-episode stats still describe the pre-merge values of the bookkeeping columns
|
||||
# reindexed above. index/episode_index shift by a constant; task_index is relabeled,
|
||||
# so recompute it from the episode's (stable) task strings via the unified tasks table.
|
||||
shift_stat_keys = ("min", "max", "mean", "q01", "q10", "q50", "q90", "q99")
|
||||
for name, offset in (
|
||||
("episode_index", dst_meta.info.total_episodes),
|
||||
("index", dst_meta.info.total_frames),
|
||||
):
|
||||
for stat in shift_stat_keys:
|
||||
col = f"stats/{name}/{stat}"
|
||||
if col in df.columns:
|
||||
df[col] = df[col] + offset
|
||||
|
||||
if any(c.startswith("stats/task_index/") for c in df.columns):
|
||||
quantiles = {"q01": 0.01, "q10": 0.10, "q50": 0.50, "q90": 0.90, "q99": 0.99}
|
||||
ids_per_row = [
|
||||
np.array([dst_meta.tasks.loc[t, "task_index"] for t in tasks], dtype=np.float64)
|
||||
for tasks in df["tasks"]
|
||||
]
|
||||
|
||||
def _task_stat(ids, stat):
|
||||
if stat == "min":
|
||||
return ids.min()
|
||||
if stat == "max":
|
||||
return ids.max()
|
||||
if stat == "std":
|
||||
return ids.std()
|
||||
if stat in quantiles:
|
||||
return np.quantile(ids, quantiles[stat])
|
||||
return ids.mean()
|
||||
|
||||
for stat in ("min", "max", "mean", "std", *quantiles):
|
||||
col = f"stats/task_index/{stat}"
|
||||
if col in df.columns:
|
||||
# np.full_like preserves each cell container and dtype so the parquet schema is unchanged.
|
||||
df[col] = [
|
||||
np.full_like(orig, _task_stat(ids, stat))
|
||||
for orig, ids in zip(df[col], ids_per_row, strict=True)
|
||||
]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from .io_utils import (
|
||||
hf_transform_to_torch,
|
||||
load_nested_dataset,
|
||||
)
|
||||
from .utils import resolve_episode_indices
|
||||
from .video_utils import decode_video_frames
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ class DatasetReader:
|
||||
"""
|
||||
self._meta = meta
|
||||
self.root = root
|
||||
self.episodes = episodes
|
||||
self.episodes = resolve_episode_indices(episodes, meta.total_episodes)
|
||||
self._tolerance_s = tolerance_s
|
||||
self._video_backend = video_backend
|
||||
if image_transforms is not None and not callable(image_transforms):
|
||||
@@ -163,10 +164,34 @@ class DatasetReader:
|
||||
def _load_hf_dataset(self) -> datasets.Dataset:
|
||||
"""hf_dataset contains all the observations, states, actions, rewards, etc."""
|
||||
features = get_hf_features_from_features(self._meta.features)
|
||||
self._validate_language_columns_declared(features)
|
||||
hf_dataset = load_nested_dataset(self.root / "data", features=features, episodes=self.episodes)
|
||||
hf_dataset.set_transform(hf_transform_to_torch)
|
||||
return hf_dataset
|
||||
|
||||
def _validate_language_columns_declared(self, features: datasets.Features) -> None:
|
||||
"""Require language columns stored in Parquet to be declared in metadata."""
|
||||
# Leave empty datasets to fail through the normal loading path.
|
||||
try:
|
||||
sample = next((self.root / "data").glob("*/*.parquet"))
|
||||
except StopIteration:
|
||||
return
|
||||
|
||||
from pyarrow import parquet as _pq # noqa: PLC0415
|
||||
|
||||
# LeRobot shards are schema-uniform, so one schema represents the dataset.
|
||||
schema_names = set(_pq.read_schema(sample).names)
|
||||
from .language import LANGUAGE_COLUMNS # noqa: PLC0415
|
||||
|
||||
missing = sorted(set(LANGUAGE_COLUMNS) & schema_names - set(features))
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Dataset Parquet files contain language feature(s) missing from metadata: {missing}. "
|
||||
"Metadata must describe the stored data; add the entries returned by "
|
||||
"lerobot.datasets.language.language_feature_info() to meta/info.json['features'] "
|
||||
"or rerun the annotation pipeline's metadata synchronization."
|
||||
)
|
||||
|
||||
def _check_cached_episodes_sufficient(self) -> bool:
|
||||
"""Check if the cached dataset contains all requested episodes and their video files."""
|
||||
if self.hf_dataset is None or len(self.hf_dataset) == 0:
|
||||
|
||||
@@ -29,6 +29,7 @@ from .dataset_metadata import LeRobotDatasetMetadata
|
||||
from .lerobot_dataset import LeRobotDataset
|
||||
from .multi_dataset import MultiLeRobotDataset
|
||||
from .streaming_dataset import StreamingLeRobotDataset
|
||||
from .utils import resolve_episode_indices
|
||||
|
||||
|
||||
def resolve_delta_timestamps(
|
||||
@@ -84,14 +85,24 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
|
||||
|
||||
if isinstance(cfg.dataset.repo_id, str):
|
||||
ds_meta = LeRobotDatasetMetadata(
|
||||
cfg.dataset.repo_id, root=cfg.dataset.root, revision=cfg.dataset.revision
|
||||
cfg.dataset.repo_id,
|
||||
root=cfg.dataset.root,
|
||||
revision=cfg.dataset.revision,
|
||||
repo_type=cfg.dataset.repo_type,
|
||||
)
|
||||
delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, ds_meta)
|
||||
episodes = resolve_episode_indices(
|
||||
cfg.dataset.episodes, ds_meta.total_episodes, cfg.dataset.exclude_episodes
|
||||
)
|
||||
if not cfg.dataset.streaming:
|
||||
if cfg.dataset.repo_type == "bucket":
|
||||
raise ValueError(
|
||||
"repo_type='bucket' is streaming-only: set dataset.streaming=true to train from an HF Storage Bucket."
|
||||
)
|
||||
dataset = LeRobotDataset(
|
||||
cfg.dataset.repo_id,
|
||||
root=cfg.dataset.root,
|
||||
episodes=cfg.dataset.episodes,
|
||||
episodes=episodes,
|
||||
delta_timestamps=delta_timestamps,
|
||||
image_transforms=image_transforms,
|
||||
revision=cfg.dataset.revision,
|
||||
@@ -104,13 +115,14 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
|
||||
dataset = StreamingLeRobotDataset(
|
||||
cfg.dataset.repo_id,
|
||||
root=cfg.dataset.root,
|
||||
episodes=cfg.dataset.episodes,
|
||||
episodes=episodes,
|
||||
delta_timestamps=delta_timestamps,
|
||||
image_transforms=image_transforms,
|
||||
revision=cfg.dataset.revision,
|
||||
max_num_shards=cfg.num_workers,
|
||||
tolerance_s=cfg.tolerance_s,
|
||||
return_uint8=True,
|
||||
repo_type=cfg.dataset.repo_type,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError("The MultiLeRobotDataset isn't supported for now.")
|
||||
|
||||
@@ -162,14 +162,32 @@ def render_sample(
|
||||
task: str | None = None,
|
||||
dataset_ctx: Any | None = None,
|
||||
) -> RenderedMessages | None:
|
||||
"""Render the chat-style messages for a single dataset sample.
|
||||
"""Render recipe-defined messages and supervision for one dataset sample.
|
||||
|
||||
Resolves the recipe's bindings against ``persistent`` and ``events`` rows
|
||||
at frame timestamp ``t``, then expands the recipe's message templates.
|
||||
Returns ``None`` if the resolved sample contains no target message.
|
||||
Resolves bindings against ``persistent`` and ``events`` at frame timestamp
|
||||
``t``. Blend recipes first route matching sparse VQA annotations, then use
|
||||
deterministic weighted selection for the remaining samples. Returns
|
||||
``None`` when the selected recipe provides no text or low-level action
|
||||
supervision for this sample.
|
||||
"""
|
||||
persistent_rows = _normalize_rows(persistent or [])
|
||||
event_rows = _normalize_rows(events or [])
|
||||
|
||||
# Route sparse VQA frames to a matching view-specific component before weighted selection.
|
||||
# This avoids dropping annotated frames or selecting VQA without annotations.
|
||||
if recipe.blend is not None:
|
||||
vqa_rendered = _render_vqa_if_present(
|
||||
recipe,
|
||||
persistent=persistent_rows,
|
||||
events=event_rows,
|
||||
t=t,
|
||||
sample_idx=sample_idx,
|
||||
task=task,
|
||||
dataset_ctx=dataset_ctx,
|
||||
)
|
||||
if vqa_rendered is not None:
|
||||
return vqa_rendered
|
||||
|
||||
selected_recipe = _select_recipe(recipe, sample_idx)
|
||||
bindings = _resolve_bindings(
|
||||
selected_recipe,
|
||||
@@ -183,6 +201,58 @@ def render_sample(
|
||||
return _render_message_recipe(selected_recipe, bindings)
|
||||
|
||||
|
||||
def _render_vqa_if_present(
|
||||
recipe: TrainingRecipe,
|
||||
*,
|
||||
persistent: Sequence[LanguageRow],
|
||||
events: Sequence[LanguageRow],
|
||||
t: float,
|
||||
sample_idx: int,
|
||||
task: str | None,
|
||||
dataset_ctx: Any | None,
|
||||
) -> RenderedMessages | None:
|
||||
"""Render a matching VQA component, or return ``None`` for normal selection.
|
||||
|
||||
Multiple matching views are selected deterministically by relative weight.
|
||||
"""
|
||||
if recipe.blend is None:
|
||||
return None
|
||||
renderable: list[tuple[float, RenderedMessages]] = []
|
||||
for component in recipe.blend.values():
|
||||
if component.route != "vqa":
|
||||
continue
|
||||
bindings = _resolve_bindings(
|
||||
component,
|
||||
persistent=persistent,
|
||||
events=events,
|
||||
t=t,
|
||||
sample_idx=sample_idx,
|
||||
task=task,
|
||||
dataset_ctx=dataset_ctx,
|
||||
)
|
||||
rendered = _render_message_recipe(component, bindings)
|
||||
if rendered is not None:
|
||||
if component.weight is None:
|
||||
raise ValueError("Routed VQA blend components must define a weight.")
|
||||
renderable.append((component.weight, rendered))
|
||||
|
||||
if not renderable:
|
||||
return None
|
||||
if len(renderable) == 1:
|
||||
return renderable[0][1]
|
||||
|
||||
# Choose among matching cameras by their validated positive relative weights.
|
||||
total = sum(weight for weight, _ in renderable)
|
||||
digest = hashlib.blake2b(f"vqa:{sample_idx}".encode(), digest_size=8).digest()
|
||||
draw = int.from_bytes(digest, "big") / 2**64 * total
|
||||
cumulative = 0.0
|
||||
for weight, rendered in renderable:
|
||||
cumulative += weight
|
||||
if draw < cumulative:
|
||||
return rendered
|
||||
return renderable[-1][1]
|
||||
|
||||
|
||||
def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
|
||||
"""Pick a deterministic blend component for ``sample_idx`` (or return ``recipe``)."""
|
||||
if recipe.blend is None:
|
||||
@@ -201,7 +271,8 @@ def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
|
||||
cumulative += component.weight or 0.0
|
||||
if draw < cumulative:
|
||||
return component
|
||||
assert last_component is not None
|
||||
if last_component is None:
|
||||
raise ValueError("Blend recipes must contain at least one component.")
|
||||
return last_component
|
||||
|
||||
|
||||
@@ -321,7 +392,8 @@ def _render_message_recipe(
|
||||
bindings: dict[str, LanguageRow | str | None],
|
||||
) -> RenderedMessages | None:
|
||||
"""Expand ``recipe.messages`` into rendered chat messages using ``bindings``."""
|
||||
assert recipe.messages is not None
|
||||
if recipe.messages is None:
|
||||
raise ValueError("Cannot render a blend recipe as a message recipe.")
|
||||
messages: list[dict[str, Any]] = []
|
||||
streams: list[str | None] = []
|
||||
target_indices: list[int] = []
|
||||
@@ -346,7 +418,9 @@ def _render_message_recipe(
|
||||
if turn.target:
|
||||
target_indices.append(message_idx)
|
||||
|
||||
if not target_indices:
|
||||
# Keep samples with either text targets or low-level action supervision.
|
||||
has_low_level = any(stream == "low_level" for stream in streams)
|
||||
if not target_indices and not has_low_level:
|
||||
return None
|
||||
|
||||
rendered = {
|
||||
@@ -403,14 +477,12 @@ def _validate_rendered(rendered: RenderedMessages) -> None:
|
||||
|
||||
if len(streams) != len(messages):
|
||||
raise ValueError("message_streams must be aligned with messages.")
|
||||
if not target_indices:
|
||||
raise ValueError("Rendered samples must contain at least one target message.")
|
||||
# Require text or low-level action supervision.
|
||||
if not target_indices and not any(s == "low_level" for s in streams):
|
||||
raise ValueError("Rendered samples must contain a target message or a low_level-stream message.")
|
||||
for idx in target_indices:
|
||||
if idx < 0 or idx >= len(messages):
|
||||
raise ValueError(f"Target message index {idx} is out of bounds.")
|
||||
# ``stream`` is enforced non-None at MessageTurn construction time
|
||||
# (see ``MessageTurn.__post_init__``), so a missing stream here would
|
||||
# mean the dataclass invariant was bypassed; no need to re-check.
|
||||
|
||||
|
||||
def _nth_relative(
|
||||
|
||||
@@ -18,6 +18,7 @@ import dataclasses
|
||||
import importlib.resources
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -98,6 +99,47 @@ VIDEO_DIR = "videos"
|
||||
|
||||
CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}"
|
||||
IMAGE_FILE_PATTERN = "frame-{frame_index:06d}.png"
|
||||
|
||||
|
||||
def resolve_episode_indices(
|
||||
episodes: Sequence[int] | None,
|
||||
total_episodes: int,
|
||||
exclude_episodes: Sequence[int] | None = None,
|
||||
) -> list[int] | None:
|
||||
"""Resolve an optional episode allowlist and exclusion list against dataset bounds.
|
||||
|
||||
``None`` is preserved when no filtering is requested so callers can retain
|
||||
their native "all episodes" fast path. Invalid indices are ignored with a
|
||||
warning, and the input order is preserved.
|
||||
"""
|
||||
if total_episodes < 0:
|
||||
raise ValueError(f"total_episodes must be non-negative, got {total_episodes}")
|
||||
|
||||
if episodes is None and not exclude_episodes:
|
||||
return None
|
||||
|
||||
candidates = list(range(total_episodes)) if episodes is None else list(episodes)
|
||||
invalid = [episode for episode in candidates if not 0 <= episode < total_episodes]
|
||||
if invalid:
|
||||
logger.warning(
|
||||
"Ignoring episode indices outside the dataset range [0, %d): %s",
|
||||
total_episodes,
|
||||
invalid,
|
||||
)
|
||||
candidates = [episode for episode in candidates if 0 <= episode < total_episodes]
|
||||
|
||||
excluded = set(exclude_episodes or [])
|
||||
invalid_excluded = sorted(episode for episode in excluded if not 0 <= episode < total_episodes)
|
||||
if invalid_excluded:
|
||||
logger.warning(
|
||||
"Ignoring excluded episode indices outside the dataset range [0, %d): %s",
|
||||
total_episodes,
|
||||
invalid_excluded,
|
||||
)
|
||||
excluded = {episode for episode in excluded if 0 <= episode < total_episodes}
|
||||
return [episode for episode in candidates if episode not in excluded]
|
||||
|
||||
|
||||
DEPTH_FILE_PATTERN = "frame-{frame_index:06d}.tiff"
|
||||
DEFAULT_TASKS_PATH = "meta/tasks.parquet"
|
||||
DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/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.
|
||||
"""Distributed-training runtime for LeRobot.
|
||||
|
||||
This package owns everything that turns the declarative topology in
|
||||
:class:`lerobot.configs.parallelism.ParallelismConfig` into a running engine:
|
||||
mesh math (:class:`~lerobot.distributed.parallel_dims.ParallelDims`), the
|
||||
`Accelerator` factory (:func:`~lerobot.distributed.factory.make_accelerator`),
|
||||
sharding-aware checkpoint helpers, and small rank utilities.
|
||||
|
||||
Setup-order contract (normative):
|
||||
CP dispatch install -> activation checkpointing -> torch.compile ->
|
||||
``fully_shard``/DDP (via ``accelerator.prepare``) -> optimizer rebind.
|
||||
Only the last two steps are active today; CP/AC/compile are configured
|
||||
placeholders wired in later rounds.
|
||||
"""
|
||||
|
||||
from .factory import guard_against_env_interference, make_accelerator, set_fsdp_wrap_modules
|
||||
from .parallel_dims import ParallelDims
|
||||
from .utils import finalize_sharded_policy, is_main_process, strip_accelerate_cp_hooks
|
||||
|
||||
__all__ = [
|
||||
"ParallelDims",
|
||||
"finalize_sharded_policy",
|
||||
"guard_against_env_interference",
|
||||
"is_main_process",
|
||||
"make_accelerator",
|
||||
"set_fsdp_wrap_modules",
|
||||
"strip_accelerate_cp_hooks",
|
||||
]
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/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.
|
||||
"""Sharding-aware checkpoint primitives.
|
||||
|
||||
Two artifact channels with distinct owners:
|
||||
|
||||
- the **distributable** ``model.safetensors``: produced by ``PreTrainedPolicy.save_pretrained``
|
||||
through :func:`full_model_state_dict` — a collective full gather when the model is sharded;
|
||||
- the **resume** channel (sharded runs): torch DCP directories written/read through accelerate's
|
||||
``save/load_fsdp_model`` and ``save/load_fsdp_optimizer`` (``pytorch_model_fsdp_0/`` and
|
||||
``optimizer_0/``, names imported from accelerate constants), which reshard on load across
|
||||
topology changes.
|
||||
|
||||
Every function that touches sharded state is a collective and must run on ALL ranks.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from accelerate import Accelerator
|
||||
|
||||
|
||||
def is_sharded_module(module: nn.Module) -> bool:
|
||||
"""True when `fully_shard` owns this module's parameters (FSDP2's in-place class swap).
|
||||
|
||||
Args:
|
||||
module (nn.Module): The module to inspect (a torch.compile wrapper is looked through
|
||||
via `_orig_mod`).
|
||||
|
||||
Returns:
|
||||
bool: True when the module (or its compiled `_orig_mod`) is an `FSDPModule`.
|
||||
"""
|
||||
from torch.distributed.fsdp import FSDPModule
|
||||
|
||||
if isinstance(module, FSDPModule):
|
||||
return True
|
||||
# torch.compile wraps the sharded module; mirror accelerate's `_orig_mod` check.
|
||||
orig_mod = getattr(module, "_orig_mod", None)
|
||||
return orig_mod is not None and isinstance(orig_mod, FSDPModule)
|
||||
|
||||
|
||||
def full_model_state_dict(module: nn.Module) -> dict[str, torch.Tensor]:
|
||||
"""The module's full (unsharded) state dict, however its parameters are laid out.
|
||||
|
||||
Sharded modules gather through torch's DCP state-dict API: a COLLECTIVE that must run on
|
||||
every rank; with ``cpu_offload=True`` the full dict materializes on the main rank only and
|
||||
every other rank receives a literal ``{}`` (runtime-verified — a
|
||||
rank-0-gated call deadlocks). Plain modules return ``module.state_dict()`` on every rank.
|
||||
|
||||
Args:
|
||||
module (nn.Module): The (possibly sharded) module to read the state dict from.
|
||||
|
||||
Returns:
|
||||
dict[str, torch.Tensor]: The full state dict — on the main rank only (``{}``
|
||||
elsewhere) when the module is sharded, on every rank otherwise.
|
||||
"""
|
||||
if not is_sharded_module(module):
|
||||
return module.state_dict()
|
||||
|
||||
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
|
||||
|
||||
return get_model_state_dict(module, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
|
||||
|
||||
|
||||
def _fsdp_plugin(accelerator: "Accelerator") -> object:
|
||||
"""The accelerator's FSDP plugin, required by every DCP save/load helper below.
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator that prepared the sharded model.
|
||||
|
||||
Returns:
|
||||
object: The FSDP plugin held by `accelerator.state`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the accelerator was not configured with an FSDP plugin.
|
||||
"""
|
||||
plugin = getattr(accelerator.state, "fsdp_plugin", None)
|
||||
if plugin is None:
|
||||
raise RuntimeError("Sharded checkpointing requires an FSDP-prepared Accelerator.")
|
||||
return plugin
|
||||
|
||||
|
||||
def save_sharded_model(accelerator: "Accelerator", model: nn.Module, output_dir: Path) -> None:
|
||||
"""Write the DCP model shards (`pytorch_model_fsdp_0/`). Collective: call on all ranks.
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator that prepared the sharded model.
|
||||
model (nn.Module): The prepared (sharded) model to save.
|
||||
output_dir (Path): The directory the shard subdirectory is created in.
|
||||
"""
|
||||
from accelerate.utils import save_fsdp_model
|
||||
|
||||
# accelerate 1.14's DCP helpers do string containment checks on the path:
|
||||
# always hand them str, never Path.
|
||||
save_fsdp_model(_fsdp_plugin(accelerator), accelerator, model, str(output_dir))
|
||||
|
||||
|
||||
def load_sharded_model(accelerator: "Accelerator", model: nn.Module, input_dir: Path) -> None:
|
||||
"""Load DCP model shards into the prepared (sharded) model. Collective: call on all ranks.
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator that prepared the sharded model.
|
||||
model (nn.Module): The prepared (sharded) model to load into.
|
||||
input_dir (Path): The directory containing the `pytorch_model_fsdp_0/` shard
|
||||
subdirectory.
|
||||
"""
|
||||
from accelerate.utils import load_fsdp_model
|
||||
from accelerate.utils.constants import FSDP_MODEL_NAME
|
||||
|
||||
# Pass the exact shard directory: accelerate's load resolves it with a substring check
|
||||
# ("pytorch_model_fsdp" in the path -> use as-is), which misfires on run paths that happen
|
||||
# to contain the marker; the exact dir makes the check deterministic.
|
||||
load_fsdp_model(_fsdp_plugin(accelerator), accelerator, model, str(input_dir / f"{FSDP_MODEL_NAME}_0"))
|
||||
|
||||
|
||||
def save_sharded_optimizer(
|
||||
accelerator: "Accelerator", optimizer: torch.optim.Optimizer, model: nn.Module, output_dir: Path
|
||||
) -> None:
|
||||
"""Write the DCP optimizer shards (`optimizer_0/`). Collective: call on all ranks.
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator that prepared the model and optimizer.
|
||||
optimizer (torch.optim.Optimizer): The prepared optimizer to save the state from.
|
||||
model (nn.Module): The prepared (sharded) model the optimizer state is keyed by.
|
||||
output_dir (Path): The directory the shard subdirectory is created in.
|
||||
"""
|
||||
from accelerate.utils import save_fsdp_optimizer
|
||||
|
||||
save_fsdp_optimizer(_fsdp_plugin(accelerator), accelerator, optimizer, model, str(output_dir))
|
||||
|
||||
|
||||
def load_sharded_optimizer(
|
||||
accelerator: "Accelerator", optimizer: torch.optim.Optimizer, model: nn.Module, input_dir: Path
|
||||
) -> None:
|
||||
"""Load DCP optimizer shards into the prepared optimizer. Collective: call on all ranks.
|
||||
|
||||
Must run AFTER ``accelerator.prepare()``: FSDP2's prepare rebinds the optimizer's param
|
||||
groups to sharded DTensors but never migrates ``optimizer.state`` — the resharding load is
|
||||
the only correct way to restore it.
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator that prepared the model and optimizer.
|
||||
optimizer (torch.optim.Optimizer): The prepared optimizer to restore the state into.
|
||||
model (nn.Module): The prepared (sharded) model the optimizer state is keyed by.
|
||||
input_dir (Path): The directory containing the `optimizer_0/` shard subdirectory.
|
||||
"""
|
||||
from accelerate.utils import load_fsdp_optimizer
|
||||
from accelerate.utils.constants import OPTIMIZER_NAME
|
||||
|
||||
# Exact shard directory for the same reason as load_sharded_model: accelerate's substring
|
||||
# check ("optimizer" in the path) would misread e.g. --job_name=optimizer_sweep run paths.
|
||||
load_fsdp_optimizer(
|
||||
_fsdp_plugin(accelerator), accelerator, optimizer, model, str(input_dir / f"{OPTIMIZER_NAME}_0")
|
||||
)
|
||||
|
||||
|
||||
def dcp_to_safetensors(dcp_dir: Path, output_dir: Path, *, delete_dcp: bool = False) -> Path:
|
||||
"""Merge a DCP shard directory into a single `model.safetensors` (offline, single process).
|
||||
|
||||
Thin wrapper over `accelerate.utils.merge_fsdp_weights`, which loads the shards without a
|
||||
process group, writes safetensors directly, and — when asked — removes the merged shard
|
||||
directory itself, only on the main process and only once the merge has succeeded.
|
||||
|
||||
Args:
|
||||
dcp_dir (Path): The DCP shard directory to merge (e.g. `.../pytorch_model_fsdp_0`).
|
||||
output_dir (Path): The directory the merged `model.safetensors` is written into.
|
||||
delete_dcp (bool): Whether to remove the shard directory once it has been merged.
|
||||
Defaults to False.
|
||||
|
||||
Returns:
|
||||
Path: The written `model.safetensors` file's path.
|
||||
"""
|
||||
from accelerate.utils import merge_fsdp_weights
|
||||
|
||||
merge_fsdp_weights(
|
||||
str(dcp_dir), str(output_dir), safe_serialization=True, remove_checkpoint_dir=delete_dcp
|
||||
)
|
||||
return output_dir / "model.safetensors"
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/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.
|
||||
"""The `Accelerator` factory — the only place accelerate gets configured.
|
||||
|
||||
`torchrun` is the launcher; every accelerate parameter comes from `TrainPipelineConfig`
|
||||
(`cfg.parallelism` + `cfg.accelerator`) so a run is reproducible from its `train_config.json`
|
||||
alone. `accelerate launch` without a `--config_file` remains equivalent (it only sets rendezvous
|
||||
env vars in that mode); the yaml flow is superseded.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lerobot.configs.parallelism import world_size_from_env
|
||||
from lerobot.configs.train import TrainPipelineConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from accelerate import Accelerator
|
||||
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy
|
||||
|
||||
# Env vars through which `accelerate launch --config_file` (or a stray shell) would configure
|
||||
# accelerate behind the config system's back. Plugin `__post_init__`s read these silently as
|
||||
# field fallbacks (ACCELERATE_DYNAMO_* enables torch.compile through the default
|
||||
# TorchDynamoPlugin; ACCELERATE_GRADIENT_ACCUMULATION_STEPS overrides the explicitly passed
|
||||
# value inside Accelerator.__init__), which would make train_config.json lie about what ran.
|
||||
_ACCELERATE_ENV_PREFIXES = ("FSDP_", "PARALLELISM_CONFIG_", "ACCELERATE_DYNAMO_")
|
||||
_ACCELERATE_ENV_VARS = (
|
||||
"ACCELERATE_USE_FSDP",
|
||||
"ACCELERATE_USE_PARALLELISM_CONFIG",
|
||||
"ACCELERATE_GRADIENT_ACCUMULATION_STEPS",
|
||||
)
|
||||
_ENV_OVERRIDE = "LEROBOT_ALLOW_ACCELERATE_ENV"
|
||||
|
||||
|
||||
def guard_against_env_interference() -> None:
|
||||
"""Hard-error when accelerate-configuring env vars are set.
|
||||
|
||||
A silently env-overridden "reproducible" config is worse than a stop: users migrating from
|
||||
the old `accelerate launch --config_file fsdp.yaml` flow get a precise error instead of a
|
||||
config that lies. Set LEROBOT_ALLOW_ACCELERATE_ENV=1 to acknowledge and proceed.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any accelerate-configuring environment variable is set and the
|
||||
LEROBOT_ALLOW_ACCELERATE_ENV override is not.
|
||||
"""
|
||||
if os.environ.get(_ENV_OVERRIDE):
|
||||
return
|
||||
offending = sorted(
|
||||
name
|
||||
for name in os.environ
|
||||
if name in _ACCELERATE_ENV_VARS or name.startswith(_ACCELERATE_ENV_PREFIXES)
|
||||
)
|
||||
if offending:
|
||||
raise RuntimeError(
|
||||
f"Accelerate-configuring environment variables are set: {', '.join(offending)}. "
|
||||
"LeRobot manages accelerate exclusively through TrainPipelineConfig "
|
||||
"(--parallelism.* / --accelerator.*); launch with plain torchrun and remove these "
|
||||
"variables (the `accelerate launch --config_file` flow is superseded), or set "
|
||||
f"{_ENV_OVERRIDE}=1 to acknowledge that they may override your config."
|
||||
)
|
||||
|
||||
|
||||
def make_accelerator(cfg: TrainPipelineConfig) -> "Accelerator":
|
||||
"""Resolve the topology against the launched world and build the `Accelerator`.
|
||||
|
||||
Must run once per process, before any other component needs the device or the process
|
||||
group (`Accelerator.__init__` initializes both and builds the device mesh).
|
||||
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The full training config; `cfg.parallelism` is resolved in
|
||||
place against the launched world size and `cfg.accelerator` builds the result.
|
||||
|
||||
Returns:
|
||||
Accelerator: The configured accelerator, with device and process group initialized.
|
||||
|
||||
Raises:
|
||||
ValueError: If `cfg.checkpoint_format` requires DCP but the topology resolved to a
|
||||
non-sharded run.
|
||||
"""
|
||||
guard_against_env_interference()
|
||||
cfg.parallelism.resolve(world_size_from_env())
|
||||
# The parse-time format check ran against the declared degrees, where the dp_shard=-1
|
||||
# sentinel counts as sharded; it may resolve to an unsharded run (e.g. -1 at world size 1).
|
||||
# Re-check against the concrete degrees so the recorded format never lies about the
|
||||
# artifacts a checkpoint will actually contain.
|
||||
if cfg.checkpoint_format.wants_dcp and not cfg.parallelism.is_sharded:
|
||||
raise ValueError(
|
||||
f"checkpoint_format={cfg.checkpoint_format.value} requires a sharded run, but the "
|
||||
f"topology resolved to a non-sharded one (dp_replicate={cfg.parallelism.dp_replicate}, "
|
||||
f"dp_shard={cfg.parallelism.dp_shard}); non-sharded checkpoints are always safetensors."
|
||||
)
|
||||
return cfg.accelerator.build(
|
||||
cfg.parallelism,
|
||||
cpu=cfg.trainable_config.device == "cpu",
|
||||
)
|
||||
|
||||
|
||||
def set_fsdp_wrap_modules(accelerator: "Accelerator", policy: "PreTrainedPolicy") -> None:
|
||||
"""Resolve the FSDP wrap-unit class names onto the plugin before `accelerator.prepare()`.
|
||||
|
||||
Resolution order: user override (`--accelerator.fsdp.wrap_modules`, already on the plugin)
|
||||
-> the policy's `_fsdp_wrap_modules` declaration -> hard error. Root-only wrapping — the
|
||||
silent default when no wrap source exists — is never accepted: it quietly forfeits all
|
||||
sharding memory savings.
|
||||
|
||||
No-op for the size-based policy (`--accelerator.fsdp.min_num_params`), which needs no class
|
||||
names, and for non-sharded runs (no fsdp plugin).
|
||||
|
||||
Args:
|
||||
accelerator (Accelerator): The accelerator whose FSDP plugin receives the wrap-unit
|
||||
class names.
|
||||
policy (PreTrainedPolicy): The trainable whose class may declare `_fsdp_wrap_modules`.
|
||||
|
||||
Raises:
|
||||
ValueError: If sharded class-based wrapping is configured but neither a user override
|
||||
nor a policy declaration supplies wrap-unit class names.
|
||||
"""
|
||||
plugin = getattr(accelerator.state, "fsdp_plugin", None)
|
||||
if plugin is None or plugin.min_num_params:
|
||||
return
|
||||
if plugin.transformer_cls_names_to_wrap: # user override, set at build time
|
||||
return
|
||||
# getattr, not attribute access: non-policy trainables (no `_fsdp_wrap_modules` attribute)
|
||||
# must reach the actionable error below, not an AttributeError.
|
||||
declared = getattr(type(policy), "_fsdp_wrap_modules", None)
|
||||
if not declared:
|
||||
raise ValueError(
|
||||
f"Policy '{type(policy).__name__}' declares no FSDP wrap units. Sharded training "
|
||||
"requires wrap-unit class names: set --accelerator.fsdp.wrap_modules='[\"MyBlock\"]' "
|
||||
"(or --accelerator.fsdp.min_num_params for a size-based policy), or declare "
|
||||
"`_fsdp_wrap_modules` on the policy class."
|
||||
)
|
||||
plugin.transformer_cls_names_to_wrap = list(declared)
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/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.
|
||||
"""Runtime mesh math derived from the declarative :class:`ParallelismConfig`.
|
||||
|
||||
`ParallelDims` is the training script's single source of truth for topology-derived numbers
|
||||
(data-parallel world size and rank, sample accounting inputs) and — once the CP engine lands —
|
||||
the owner of LeRobot's private ``(dp_replicate, dp_shard, ring, ulysses)`` mesh. It is a runtime
|
||||
object and is never serialized (the config it derives from is what lands in
|
||||
``train_config.json``).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch.distributed as dist
|
||||
|
||||
from lerobot.configs.parallelism import ParallelismConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParallelDims:
|
||||
"""Concrete parallelism degrees bound to a world size (canonical row-major rank layout)."""
|
||||
|
||||
dp_replicate: int
|
||||
dp_shard: int
|
||||
ring: int
|
||||
ulysses: int
|
||||
world_size: int
|
||||
device_type: str
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: ParallelismConfig, world_size: int, device_type: str) -> "ParallelDims":
|
||||
"""Bind a *resolved* config to the actual runtime world size (cross-checked here).
|
||||
|
||||
Args:
|
||||
cfg (ParallelismConfig): The declarative topology, already resolved via
|
||||
`ParallelismConfig.resolve(world_size)`.
|
||||
world_size (int): The launched world size the declared degrees must multiply to.
|
||||
device_type (str): The accelerator device type backing the mesh (e.g. "cuda").
|
||||
|
||||
Returns:
|
||||
ParallelDims: The concrete parallelism degrees bound to this world.
|
||||
|
||||
Raises:
|
||||
ValueError: If the config is unresolved (`dp_shard == -1`) or its degrees do not
|
||||
multiply to `world_size`.
|
||||
"""
|
||||
total = cfg.dp_replicate * cfg.dp_shard * cfg.cp_size
|
||||
if cfg.dp_shard == -1 or total != world_size:
|
||||
raise ValueError(
|
||||
f"ParallelismConfig is not resolved against this world: dp_replicate="
|
||||
f"{cfg.dp_replicate} * dp_shard={cfg.dp_shard} * cp={cfg.cp_size} != "
|
||||
f"world_size={world_size}. Call ParallelismConfig.resolve(world_size) first "
|
||||
"(make_accelerator does this)."
|
||||
)
|
||||
return cls(
|
||||
dp_replicate=cfg.dp_replicate,
|
||||
dp_shard=cfg.dp_shard,
|
||||
ring=cfg.context_parallel.ring_degree,
|
||||
ulysses=cfg.context_parallel.ulysses_degree,
|
||||
world_size=world_size,
|
||||
device_type=device_type,
|
||||
)
|
||||
|
||||
@property
|
||||
def cp_size(self) -> int:
|
||||
"""Total context-parallel degree (`ring * ulysses`)."""
|
||||
return self.ring * self.ulysses
|
||||
|
||||
@property
|
||||
def is_sharded(self) -> bool:
|
||||
"""Whether parameters are sharded (`dp_shard > 1` or any context parallelism)."""
|
||||
return self.dp_shard > 1 or self.cp_size > 1
|
||||
|
||||
@property
|
||||
def dp_world_size(self) -> int:
|
||||
"""Number of distinct data-parallel workers — the divisor for all sample accounting."""
|
||||
return self.dp_replicate * self.dp_shard
|
||||
|
||||
@property
|
||||
def dp_rank(self) -> int:
|
||||
"""This process's data-parallel coordinate (CP peers share one dp_rank).
|
||||
|
||||
With the canonical row-major layout and (ring, ulysses) innermost, CP peers are
|
||||
contiguous global ranks, so the dp coordinate is the integer quotient by cp_size —
|
||||
the same arithmetic accelerate's mesh-aware dataloader applies.
|
||||
"""
|
||||
global_rank = dist.get_rank() if dist.is_initialized() else 0
|
||||
return global_rank // self.cp_size
|
||||
|
||||
def cp_mesh(self) -> None:
|
||||
"""Private (ring, ulysses) mesh for the CP engine — reserved for the CP round.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always — context parallelism is not implemented yet.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Context parallelism is not implemented yet; ParallelDims.cp_mesh is reserved for "
|
||||
"the CP engine round (a private mesh aligned with accelerate's cp block)."
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/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.
|
||||
"""Rank utilities and post-`prepare()` sharding finalization."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch.distributed as dist
|
||||
from torch import nn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lerobot.distributed.parallel_dims import ParallelDims
|
||||
|
||||
|
||||
def is_main_process() -> bool:
|
||||
"""True on the process that owns rank-0-only side effects (file writes, uploads, logging).
|
||||
|
||||
Torch-native on purpose: persistence code must not depend on an `Accelerator` handle —
|
||||
`_save_pretrained` and the hub publishers run in contexts that have none. Outside
|
||||
distributed runs every process is the main process.
|
||||
|
||||
Returns:
|
||||
bool: True when this process is rank 0 or no process group is initialized.
|
||||
"""
|
||||
return not dist.is_initialized() or dist.get_rank() == 0
|
||||
|
||||
|
||||
def strip_accelerate_cp_hooks(model: nn.Module) -> int:
|
||||
"""Remove accelerate's context-parallel forward-pre-hooks from every module.
|
||||
|
||||
When `cp_size > 1` is declared, `accelerator.prepare()` unconditionally attaches hooks that
|
||||
silently replace any `attention_mask` kwarg of `*self_attn` modules with `is_causal=True`
|
||||
(`accelerate.big_modeling._attach_context_parallel_hooks`) — mask corruption for policies
|
||||
with non-causal attention. LeRobot implements CP itself and never enters accelerate's CP
|
||||
context, so these hooks are pure hazard. Deterministically identified by their defining
|
||||
module; a version canary pins that identity.
|
||||
|
||||
Args:
|
||||
model (nn.Module): The prepared model to strip the hooks from (all submodules are
|
||||
visited).
|
||||
|
||||
Returns:
|
||||
int: The number of hooks removed.
|
||||
"""
|
||||
removed = 0
|
||||
for module in model.modules():
|
||||
for hook_id, hook in list(module._forward_pre_hooks.items()):
|
||||
if getattr(hook, "__module__", None) == "accelerate.big_modeling":
|
||||
del module._forward_pre_hooks[hook_id]
|
||||
module._forward_pre_hooks_with_kwargs.pop(hook_id, None)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def finalize_sharded_policy(policy: nn.Module, parallel_dims: "ParallelDims") -> None:
|
||||
"""Sharding correctness protocol, applied once, immediately after `accelerator.prepare()`.
|
||||
|
||||
1. Strip accelerate's CP mask hooks (only attached when cp > 1 was declared).
|
||||
2. Register the policy's non-`forward` entry points (`_fsdp_forward_methods`) so FSDP2
|
||||
unshards parameters around `select_action` & co. — without this, any inference-style
|
||||
call on a sharded policy crashes on mixed Tensor/DTensor.
|
||||
|
||||
No-op for DDP/single-process runs.
|
||||
|
||||
Args:
|
||||
policy (nn.Module): The policy as returned by `accelerator.prepare()`.
|
||||
parallel_dims (ParallelDims): The run's resolved topology; decides whether the protocol
|
||||
applies.
|
||||
"""
|
||||
if not parallel_dims.is_sharded:
|
||||
return
|
||||
if parallel_dims.cp_size > 1:
|
||||
removed = strip_accelerate_cp_hooks(policy)
|
||||
logging.info("Stripped %d accelerate context-parallel attention-mask hooks.", removed)
|
||||
|
||||
from torch.distributed.fsdp import FSDPModule, register_fsdp_forward_method
|
||||
|
||||
if isinstance(policy, FSDPModule):
|
||||
for method_name in getattr(type(policy), "_fsdp_forward_methods", ()):
|
||||
if callable(getattr(policy, method_name, None)):
|
||||
register_fsdp_forward_method(policy, method_name)
|
||||
@@ -328,6 +328,7 @@ class LiberoEnv(EnvConfig):
|
||||
render_mode: str = "rgb_array"
|
||||
camera_name: str = "agentview_image,robot0_eye_in_hand_image"
|
||||
init_states: bool = True
|
||||
hard_reset: bool = True
|
||||
camera_name_mapping: dict[str, str] | None = None
|
||||
observation_height: int = 360
|
||||
observation_width: int = 360
|
||||
@@ -356,6 +357,8 @@ class LiberoEnv(EnvConfig):
|
||||
def __post_init__(self):
|
||||
if self.fps <= 0:
|
||||
raise ValueError(f"fps must be positive, got {self.fps}")
|
||||
if not self.hard_reset and not self.init_states:
|
||||
raise ValueError("hard_reset=False requires init_states=True")
|
||||
|
||||
if self.obs_type == "pixels":
|
||||
self.features[LIBERO_KEY_PIXELS_AGENTVIEW] = PolicyFeature(
|
||||
@@ -416,6 +419,7 @@ class LiberoEnv(EnvConfig):
|
||||
"observation_height": self.observation_height,
|
||||
"observation_width": self.observation_width,
|
||||
"control_freq": self.fps,
|
||||
"hard_reset": self.hard_reset,
|
||||
}
|
||||
if self.task_ids is not None:
|
||||
kwargs["task_ids"] = self.task_ids
|
||||
|
||||
@@ -128,10 +128,13 @@ class LiberoEnv(gym.Env):
|
||||
control_freq: int = 20,
|
||||
control_mode: str = "relative",
|
||||
is_libero_plus: bool = False,
|
||||
hard_reset: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
if control_freq <= 0:
|
||||
raise ValueError(f"control_freq must be positive, got {control_freq}")
|
||||
if not hard_reset and not init_states:
|
||||
raise ValueError("hard_reset=False requires init_states=True")
|
||||
self.task_id = task_id
|
||||
self.is_libero_plus = is_libero_plus
|
||||
self.obs_type = obs_type
|
||||
@@ -158,6 +161,7 @@ class LiberoEnv(gym.Env):
|
||||
self.camera_name_mapping = camera_name_mapping
|
||||
self.num_steps_wait = num_steps_wait
|
||||
self.control_freq = control_freq
|
||||
self.hard_reset = hard_reset
|
||||
self.episode_index = episode_index
|
||||
self.episode_length = episode_length
|
||||
# Load once and keep
|
||||
@@ -265,6 +269,9 @@ class LiberoEnv(gym.Env):
|
||||
camera_heights=self.observation_height,
|
||||
camera_widths=self.observation_width,
|
||||
control_freq=self.control_freq,
|
||||
# Soft resets skip LIBERO's model and renderer rebuild. They are opt-in
|
||||
# because settle steps can make their observations differ from hard resets.
|
||||
hard_reset=self.hard_reset,
|
||||
)
|
||||
env.reset()
|
||||
self._env = env
|
||||
@@ -377,8 +384,9 @@ class LiberoEnv(gym.Env):
|
||||
}
|
||||
)
|
||||
observation = self._format_raw_obs(raw_obs)
|
||||
if terminated:
|
||||
self.reset()
|
||||
# Return the terminal observation unchanged. The caller owns resetting after
|
||||
# termination; vector envs created below use NEXT_STEP autoreset. Resetting here
|
||||
# would therefore reset twice and skip an initial state.
|
||||
truncated = False
|
||||
return observation, reward, terminated, truncated, info
|
||||
|
||||
@@ -476,6 +484,7 @@ def create_libero_envs(
|
||||
print(f"Restricting to task_ids={task_ids_filter}")
|
||||
|
||||
is_async = env_cls is gym.vector.AsyncVectorEnv
|
||||
is_sync = env_cls is gym.vector.SyncVectorEnv
|
||||
|
||||
out: dict[str, dict[int, Any]] = defaultdict(dict)
|
||||
for suite_name in suite_names:
|
||||
@@ -512,6 +521,10 @@ def create_libero_envs(
|
||||
cached_act_space = lazy.action_space
|
||||
cached_metadata = lazy.metadata
|
||||
out[suite_name][tid] = lazy
|
||||
elif is_sync:
|
||||
out[suite_name][tid] = gym.vector.SyncVectorEnv(
|
||||
fns, autoreset_mode=gym.vector.AutoresetMode.NEXT_STEP
|
||||
)
|
||||
else:
|
||||
out[suite_name][tid] = env_cls(fns)
|
||||
print(f"Built vec env | suite={suite_name} | task_id={tid} | n_envs={n_envs}")
|
||||
|
||||
@@ -177,6 +177,76 @@ def _sub_env_has_attr(env: gym.vector.VectorEnv, attr: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Passed in `reset(options=...)` by `rollout()` to mark the start of a new rollout.
|
||||
# FreezeAfterEpisodeEnd thaws only on this, so Gymnasium's argument-less autoreset
|
||||
# cannot be mistaken for a genuine new episode.
|
||||
NEW_ROLLOUT_OPTION = "lerobot_new_rollout"
|
||||
|
||||
|
||||
class FreezeAfterEpisodeEnd(gym.Wrapper):
|
||||
"""Stop doing simulator work once a sub-env's episode has ended.
|
||||
|
||||
`rollout()` runs `while not np.all(done)` with `done` latched, so a sub-env that
|
||||
terminates early keeps being stepped -- physics and offscreen rendering included --
|
||||
until the slowest sub-env in the batch finishes. The batch runs for
|
||||
`max(episode_lengths)` iterations to complete work that only needs
|
||||
`mean(episode_lengths)`.
|
||||
|
||||
This caches the terminal transition and replays it for any further `step()` or
|
||||
autoreset, so a finished sub-env costs nothing. The rollout already ignores those
|
||||
transitions.
|
||||
|
||||
The freeze survives Gymnasium's autoreset deliberately. Under
|
||||
`AutoresetMode.NEXT_STEP` the vector env resets a terminated sub-env on the
|
||||
following step and runs it through an entire extra episode that the rollout
|
||||
discards, because `done` stays latched. Absorbing that reset is most of the saving.
|
||||
|
||||
Only an explicit reset carrying `NEW_ROLLOUT_OPTION` thaws it, so the signal is
|
||||
explicit rather than inferred: Gymnasium's autoreset calls `reset()` with no
|
||||
arguments, but so would a caller passing `seeds=None`, and confusing the two would
|
||||
strand an env frozen for a whole rollout.
|
||||
|
||||
`AutoresetMode.DISABLED` is not an alternative here — Gymnasium asserts that no
|
||||
terminated env is ever stepped in that mode, so the wrapper is never reached.
|
||||
"""
|
||||
|
||||
def __init__(self, env: gym.Env):
|
||||
super().__init__(env)
|
||||
self._frozen: tuple | None = None
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
if self._frozen is not None and not (options or {}).get(NEW_ROLLOUT_OPTION):
|
||||
# Gymnasium's autoreset for a sub-env the rollout has already finished with.
|
||||
# Replay the terminal observation instead of rebuilding the simulation.
|
||||
obs, _, _, _, info = self._frozen
|
||||
return obs, info
|
||||
self._frozen = None
|
||||
return self.env.reset(seed=seed, options=options)
|
||||
|
||||
def step(self, action):
|
||||
if self._frozen is not None:
|
||||
return self._frozen
|
||||
obs, reward, terminated, truncated, info = self.env.step(action)
|
||||
if terminated or truncated:
|
||||
# Zero the reward on replay so a frozen sub-env cannot inflate a return if a
|
||||
# caller sums rewards over the padded tail.
|
||||
self._frozen = (obs, 0.0, terminated, truncated, info)
|
||||
return obs, reward, terminated, truncated, info
|
||||
|
||||
@property
|
||||
def is_frozen(self) -> bool:
|
||||
return self._frozen is not None
|
||||
|
||||
|
||||
def freeze_after_episode_end(env_fn: Callable[[], gym.Env]) -> Callable[[], gym.Env]:
|
||||
"""Wrap an env factory so the built env freezes once its episode ends."""
|
||||
|
||||
def _fn() -> gym.Env:
|
||||
return FreezeAfterEpisodeEnd(env_fn())
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
class _LazyAsyncVectorEnv:
|
||||
"""Defers AsyncVectorEnv creation until first use.
|
||||
|
||||
@@ -212,7 +282,12 @@ class _LazyAsyncVectorEnv:
|
||||
|
||||
def _ensure(self) -> None:
|
||||
if self._env is None:
|
||||
self._env = gym.vector.AsyncVectorEnv(self._env_fns, context="forkserver", shared_memory=True)
|
||||
self._env = gym.vector.AsyncVectorEnv(
|
||||
[freeze_after_episode_end(fn) for fn in self._env_fns],
|
||||
context="forkserver",
|
||||
shared_memory=True,
|
||||
autoreset_mode=gym.vector.AutoresetMode.NEXT_STEP,
|
||||
)
|
||||
|
||||
@property
|
||||
def unwrapped(self):
|
||||
|
||||
@@ -432,7 +432,7 @@ def submit_to_hf(cfg: TrainPipelineConfig) -> None:
|
||||
|
||||
# Finish as soon as the model is pushed, rather than waiting out the platform's
|
||||
# post-run finalization before the job stage flips to COMPLETED. This matches the
|
||||
# exact log line emitted by PreTrainedPolicy.push_model_to_hub — the two must stay
|
||||
# exact log line emitted by lerobot.common.train_utils.publish_trained_model — the two must stay
|
||||
# in sync. If it ever stops matching we just fall back to stage-based completion
|
||||
# (~30s slower), so the contract is an optimization, not a correctness requirement.
|
||||
success_marker = f"Model pushed to https://huggingface.co/{repo_id}"
|
||||
|
||||
@@ -314,11 +314,16 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
To find the port, you can run our utility script:
|
||||
```bash
|
||||
lerobot-find-port.py
|
||||
>>> Finding all available ports for the MotorsBus.
|
||||
>>> ["/dev/tty.usbmodem575E0032081", "/dev/tty.usbmodem575E0031751"]
|
||||
>>> Remove the usb cable from your MotorsBus and press Enter when done.
|
||||
>>> The port of this MotorsBus is /dev/tty.usbmodem575E0031751.
|
||||
>>> Reconnect the usb cable.
|
||||
```
|
||||
|
||||
which prints:
|
||||
|
||||
```
|
||||
Finding all available ports for the MotorsBus.
|
||||
["/dev/tty.usbmodem575E0032081", "/dev/tty.usbmodem575E0031751"]
|
||||
Remove the usb cable from your MotorsBus and press Enter when done.
|
||||
The port of this MotorsBus is /dev/tty.usbmodem575E0031751.
|
||||
Reconnect the usb cable.
|
||||
```
|
||||
|
||||
Example of usage for 1 Feetech sts3215 motor connected to the bus:
|
||||
@@ -595,7 +600,7 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
ID, and finally programs the bus' default baud-rate.
|
||||
|
||||
Args:
|
||||
motor (str): Key of the motor in :pyattr:`motors`.
|
||||
motor (str): Key of the motor in `motors`.
|
||||
initial_baudrate (int | None, optional): Current baud-rate (skips scanning when provided).
|
||||
Defaults to None.
|
||||
initial_id (int | None, optional): Current ID (skips scanning when provided). Defaults to None.
|
||||
@@ -666,7 +671,7 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
"""Enable torque on selected motors.
|
||||
|
||||
Args:
|
||||
motors (int | str | list[str] | None, optional): Same semantics as :pymeth:`disable_torque`.
|
||||
motors (int | str | list[str] | None, optional): Same semantics as [`~motors.motors_bus.MotorsBus.disable_torque`].
|
||||
Defaults to `None`.
|
||||
num_retry (int, optional): Number of additional retry attempts on communication failure.
|
||||
Defaults to 0.
|
||||
@@ -679,10 +684,12 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
|
||||
This helper is useful to temporarily disable torque when configuring motors.
|
||||
|
||||
Examples:
|
||||
>>> with bus.torque_disabled():
|
||||
Example:
|
||||
```python
|
||||
>>> with bus.torque_disabled(): # doctest: +SKIP
|
||||
... # Safe operations here
|
||||
... pass
|
||||
```
|
||||
"""
|
||||
self.disable_torque(motors)
|
||||
try:
|
||||
@@ -695,7 +702,7 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
|
||||
Args:
|
||||
timeout_ms (int | None, optional): Timeout in *milliseconds*. If `None` (default) the method falls
|
||||
back to :pyattr:`default_timeout`.
|
||||
back to `default_timeout`.
|
||||
"""
|
||||
timeout_ms = timeout_ms if timeout_ms is not None else self.default_timeout
|
||||
self.port_handler.setPacketTimeoutMillis(timeout_ms)
|
||||
@@ -746,8 +753,8 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
|
||||
Args:
|
||||
calibration_dict (dict[str, MotorCalibration]): Calibration obtained from
|
||||
:pymeth:`read_calibration` or crafted by the user.
|
||||
cache (bool, optional): Save the calibration to :pyattr:`calibration`. Defaults to True.
|
||||
[`~motors.motors_bus.MotorsBus.read_calibration`] or crafted by the user.
|
||||
cache (bool, optional): Save the calibration to `calibration`. Defaults to True.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -755,7 +762,7 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
"""Restore factory calibration for the selected motors.
|
||||
|
||||
Homing offset is set to ``0`` and min/max position limits are set to the full usable range.
|
||||
The in-memory :pyattr:`calibration` is cleared.
|
||||
The in-memory `calibration` is cleared.
|
||||
|
||||
Args:
|
||||
motors (NameOrID | Sequence[NameOrID] | None, optional): Selection of motors. `None` (default)
|
||||
@@ -1069,9 +1076,9 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
) -> None:
|
||||
"""Write a value to a single motor's register.
|
||||
|
||||
Contrary to :pymeth:`sync_write`, this expects a response status packet emitted by the motor, which
|
||||
Contrary to [`~motors.motors_bus.MotorsBus.sync_write`], this expects a response status packet emitted by the motor, which
|
||||
provides a guarantee that the value was written to the register successfully. In consequence, it is
|
||||
slower than :pymeth:`sync_write` but it is more reliable. It should typically be used when configuring
|
||||
slower than [`~motors.motors_bus.MotorsBus.sync_write`] but it is more reliable. It should typically be used when configuring
|
||||
motors.
|
||||
|
||||
Args:
|
||||
@@ -1228,8 +1235,8 @@ class SerialMotorsBus(MotorsBusBase):
|
||||
) -> None:
|
||||
"""Write the same register on multiple motors.
|
||||
|
||||
Contrary to :pymeth:`write`, this *does not* expects a response status packet emitted by the motor, which
|
||||
can allow for lost packets. It is faster than :pymeth:`write` and should typically be used when
|
||||
Contrary to [`~motors.motors_bus.MotorsBus.write`], this *does not* expects a response status packet emitted by the motor, which
|
||||
can allow for lost packets. It is faster than [`~motors.motors_bus.MotorsBus.write`] and should typically be used when
|
||||
frequency matters and losing some packets is acceptable (e.g. teleoperation loops).
|
||||
|
||||
Args:
|
||||
|
||||
@@ -20,7 +20,6 @@ from .optimizers import (
|
||||
SGDConfig as SGDConfig,
|
||||
XVLAAdamWConfig as XVLAAdamWConfig,
|
||||
load_optimizer_state,
|
||||
load_optimizer_state_dict,
|
||||
save_optimizer_state,
|
||||
)
|
||||
from .schedulers import (
|
||||
@@ -51,7 +50,6 @@ __all__ = [
|
||||
"VQBeTSchedulerConfig",
|
||||
# State management
|
||||
"load_optimizer_state",
|
||||
"load_optimizer_state_dict",
|
||||
"load_scheduler_state",
|
||||
"save_optimizer_state",
|
||||
"save_scheduler_state",
|
||||
|
||||
@@ -27,7 +27,7 @@ from lerobot.utils.constants import (
|
||||
OPTIMIZER_PARAM_GROUPS,
|
||||
OPTIMIZER_STATE,
|
||||
)
|
||||
from lerobot.utils.io_utils import deserialize_json_into_object, load_json, write_json
|
||||
from lerobot.utils.io_utils import deserialize_json_into_object, write_json
|
||||
from lerobot.utils.utils import flatten_dict, unflatten_dict
|
||||
|
||||
# Type alias for parameters accepted by optimizer build() methods.
|
||||
@@ -52,6 +52,11 @@ class OptimizerConfig(draccus.ChoiceRegistry, abc.ABC):
|
||||
def type(self) -> str:
|
||||
return self.get_choice_name(self.__class__)
|
||||
|
||||
@property
|
||||
def builds_multiple_optimizers(self) -> bool:
|
||||
"""True when build() returns a dict of optimizers (unsupported under sharded training)."""
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def default_choice_name(cls) -> str | None:
|
||||
return "adam"
|
||||
@@ -245,6 +250,10 @@ class MultiAdamConfig(OptimizerConfig):
|
||||
grad_clip_norm: float = 10.0
|
||||
optimizer_groups: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def builds_multiple_optimizers(self) -> bool:
|
||||
return True
|
||||
|
||||
def build(self, params: OptimizerParams) -> dict[str, torch.optim.Optimizer]:
|
||||
"""Build multiple Adam optimizers.
|
||||
|
||||
@@ -283,35 +292,27 @@ class MultiAdamConfig(OptimizerConfig):
|
||||
def save_optimizer_state(
|
||||
optimizer: torch.optim.Optimizer | dict[str, torch.optim.Optimizer],
|
||||
save_dir: Path,
|
||||
optim_state_dict: dict | None = None,
|
||||
) -> None:
|
||||
"""Save optimizer state to disk.
|
||||
"""Save optimizer state to disk (non-sharded runs; sharded runs use the DCP channel).
|
||||
|
||||
Args:
|
||||
optimizer: Either a single optimizer or a dictionary of optimizers.
|
||||
save_dir: Directory to save the optimizer state.
|
||||
optim_state_dict: Pre-gathered optimizer state dict (for FSDP, where the sharded state must
|
||||
be gathered across ranks first). If provided, it is saved directly instead of calling
|
||||
``optimizer.state_dict()``. Only supported for a single optimizer. Defaults to None.
|
||||
"""
|
||||
if isinstance(optimizer, dict):
|
||||
# Handle dictionary of optimizers
|
||||
if optim_state_dict is not None:
|
||||
raise ValueError("optim_state_dict is not supported for a dict of optimizers")
|
||||
for name, opt in optimizer.items():
|
||||
optimizer_dir = save_dir / name
|
||||
optimizer_dir.mkdir(exist_ok=True, parents=True)
|
||||
_save_single_optimizer_state(opt, optimizer_dir)
|
||||
else:
|
||||
# Handle single optimizer
|
||||
_save_single_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
|
||||
_save_single_optimizer_state(optimizer, save_dir)
|
||||
|
||||
|
||||
def _save_single_optimizer_state(
|
||||
optimizer: torch.optim.Optimizer, save_dir: Path, optim_state_dict: dict | None = None
|
||||
) -> None:
|
||||
def _save_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Path) -> None:
|
||||
"""Save a single optimizer's state to disk."""
|
||||
state = dict(optim_state_dict) if optim_state_dict is not None else optimizer.state_dict()
|
||||
state = optimizer.state_dict()
|
||||
param_groups = state.pop("param_groups")
|
||||
flat_state = flatten_dict(state)
|
||||
save_file(flat_state, save_dir / OPTIMIZER_STATE)
|
||||
@@ -365,19 +366,3 @@ def _load_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Pat
|
||||
|
||||
optimizer.load_state_dict(loaded_state_dict)
|
||||
return optimizer
|
||||
|
||||
|
||||
def load_optimizer_state_dict(save_dir: Path) -> dict:
|
||||
"""Read a saved optimizer state dict (safetensors + json) back into a plain dict.
|
||||
|
||||
Unlike `load_optimizer_state`, this does not load into an optimizer and preserves the original
|
||||
``state`` keys verbatim (e.g. FSDP parameter FQNs, which are not integer-castable). It is used by
|
||||
the FSDP resume path, where the full state must be resharded via `FSDP.optim_state_dict_to_load`
|
||||
before being loaded into the (sharded) optimizer.
|
||||
"""
|
||||
flat_state = load_file(save_dir / OPTIMIZER_STATE)
|
||||
state = unflatten_dict(flat_state)
|
||||
return {
|
||||
"state": state.get("state", {}),
|
||||
"param_groups": load_json(save_dir / OPTIMIZER_PARAM_GROUPS),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ class ACTPolicy(PreTrainedPolicy):
|
||||
|
||||
config_class = ACTConfig
|
||||
name = "act"
|
||||
# FSDP2 wrap units: one unit per transformer layer of both stacks.
|
||||
_fsdp_wrap_modules = ["ACTEncoderLayer", "ACTDecoderLayer"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -31,10 +31,8 @@ from lerobot.envs import EnvConfig, env_to_policy_features
|
||||
from lerobot.lerobot_types import PolicyAction
|
||||
from lerobot.processor import (
|
||||
AbsoluteActionsProcessorStep,
|
||||
NormalizerProcessorStep,
|
||||
PolicyProcessorPipeline,
|
||||
RelativeActionsProcessorStep,
|
||||
UnnormalizerProcessorStep,
|
||||
batch_to_transition,
|
||||
policy_action_to_transition,
|
||||
transition_to_batch,
|
||||
@@ -78,52 +76,6 @@ def _reconnect_relative_absolute_steps(
|
||||
step.relative_step = relative_step
|
||||
|
||||
|
||||
def _ensure_relative_actions(
|
||||
preprocessor: PolicyProcessorPipeline, postprocessor: PolicyProcessorPipeline, policy_cfg
|
||||
) -> None:
|
||||
"""Enable (or inject) the relative/absolute action steps in a loaded pipeline.
|
||||
|
||||
When loading from a pretrained checkpoint, the saved processor is authoritative. If the base
|
||||
predates the relative-action feature (e.g. FastWAM/LingBot bases) its pipeline has no
|
||||
RelativeActionsProcessorStep, so lerobot-train's override cannot enable one — those override
|
||||
keys are popped before `from_pretrained` (else it raises) and we reconstruct the steps here.
|
||||
Bases that DO ship the (disabled) steps (e.g. pi0/pi05) are simply flipped on. No-op unless
|
||||
``policy_cfg.use_relative_actions`` is set, so non-relative runs are untouched.
|
||||
"""
|
||||
if not getattr(policy_cfg, "use_relative_actions", False):
|
||||
return
|
||||
|
||||
exclude_joints = list(getattr(policy_cfg, "relative_exclude_joints", []) or [])
|
||||
action_names = getattr(policy_cfg, "action_feature_names", None)
|
||||
|
||||
pre_steps = list(preprocessor.steps)
|
||||
relative_step = next((s for s in pre_steps if isinstance(s, RelativeActionsProcessorStep)), None)
|
||||
if relative_step is None:
|
||||
relative_step = RelativeActionsProcessorStep(
|
||||
enabled=True, exclude_joints=exclude_joints, action_names=action_names
|
||||
)
|
||||
# Insert right before the normalizer (raw -> relative -> normalize); fall back to the front.
|
||||
idx = next((i for i, s in enumerate(pre_steps) if isinstance(s, NormalizerProcessorStep)), 0)
|
||||
pre_steps.insert(idx, relative_step)
|
||||
preprocessor.steps = pre_steps
|
||||
else:
|
||||
relative_step.enabled = True
|
||||
relative_step.exclude_joints = exclude_joints
|
||||
relative_step.action_names = action_names
|
||||
|
||||
post_steps = list(postprocessor.steps)
|
||||
absolute_step = next((s for s in post_steps if isinstance(s, AbsoluteActionsProcessorStep)), None)
|
||||
if absolute_step is None:
|
||||
absolute_step = AbsoluteActionsProcessorStep(enabled=True, relative_step=relative_step)
|
||||
# Insert right after the unnormalizer (unnormalize -> absolute); fall back to the front.
|
||||
idx = next((i for i, s in enumerate(post_steps) if isinstance(s, UnnormalizerProcessorStep)), -1)
|
||||
post_steps.insert(idx + 1, absolute_step)
|
||||
postprocessor.steps = post_steps
|
||||
else:
|
||||
absolute_step.enabled = True
|
||||
absolute_step.relative_step = relative_step
|
||||
|
||||
|
||||
def get_policy_class(name: str) -> type[PreTrainedPolicy]:
|
||||
"""
|
||||
Retrieves a policy class by its registered name.
|
||||
@@ -179,12 +131,16 @@ class ProcessorConfigKwargs(TypedDict, total=False):
|
||||
This provides type hints for the optional arguments passed to `make_pre_post_processors`,
|
||||
improving code clarity and enabling static analysis.
|
||||
|
||||
Attributes:
|
||||
preprocessor_config_filename: The filename for the preprocessor configuration.
|
||||
postprocessor_config_filename: The filename for the postprocessor configuration.
|
||||
preprocessor_overrides: A dictionary of overrides for the preprocessor configuration.
|
||||
postprocessor_overrides: A dictionary of overrides for the postprocessor configuration.
|
||||
dataset_stats: Dataset statistics for normalization.
|
||||
**Attributes**:
|
||||
- **preprocessor_config_filename** (`str | None`) -- The filename for the preprocessor configuration.
|
||||
- **postprocessor_config_filename** (`str | None`) -- The filename for the postprocessor
|
||||
configuration.
|
||||
- **preprocessor_overrides** (`dict[str, Any] | None`) -- A dictionary of overrides for the
|
||||
preprocessor configuration.
|
||||
- **postprocessor_overrides** (`dict[str, Any] | None`) -- A dictionary of overrides for the
|
||||
postprocessor configuration.
|
||||
- **dataset_stats** (`dict[str, dict[str, torch.Tensor]] | None`) -- Dataset statistics for
|
||||
normalization.
|
||||
"""
|
||||
|
||||
preprocessor_config_filename: str | None
|
||||
@@ -245,20 +201,12 @@ def make_pre_post_processors(
|
||||
),
|
||||
)
|
||||
|
||||
# The relative/absolute override keys only match if the saved base already contains those
|
||||
# steps (e.g. pi0/pi05). For bases that predate the feature (FastWAM/LingBot) they would
|
||||
# raise "Override keys ... do not match any step". Pop them here and let
|
||||
# _ensure_relative_actions() enable-or-inject the steps after loading (handles both cases).
|
||||
pre_overrides = dict(kwargs.get("preprocessor_overrides") or {})
|
||||
post_overrides = dict(kwargs.get("postprocessor_overrides") or {})
|
||||
pre_overrides.pop("relative_actions_processor", None)
|
||||
post_overrides.pop("absolute_actions_processor", None)
|
||||
preprocessor = PolicyProcessorPipeline.from_pretrained(
|
||||
pretrained_model_name_or_path=pretrained_path,
|
||||
config_filename=kwargs.get(
|
||||
"preprocessor_config_filename", f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json"
|
||||
),
|
||||
overrides=pre_overrides,
|
||||
overrides=kwargs.get("preprocessor_overrides", {}),
|
||||
to_transition=batch_to_transition,
|
||||
to_output=transition_to_batch,
|
||||
revision=pretrained_revision,
|
||||
@@ -268,12 +216,11 @@ def make_pre_post_processors(
|
||||
config_filename=kwargs.get(
|
||||
"postprocessor_config_filename", f"{POLICY_POSTPROCESSOR_DEFAULT_NAME}.json"
|
||||
),
|
||||
overrides=post_overrides,
|
||||
overrides=kwargs.get("postprocessor_overrides", {}),
|
||||
to_transition=policy_action_to_transition,
|
||||
to_output=transition_to_policy_action,
|
||||
revision=pretrained_revision,
|
||||
)
|
||||
_ensure_relative_actions(preprocessor, postprocessor, policy_cfg)
|
||||
_reconnect_relative_absolute_steps(preprocessor, postprocessor)
|
||||
if isinstance(policy_cfg, Evo1Config):
|
||||
from .evo1.processor_evo1 import reconcile_evo1_processors
|
||||
@@ -299,6 +246,7 @@ def make_policy(
|
||||
ds_meta: LeRobotDatasetMetadata | None = None,
|
||||
env_cfg: EnvConfig | None = None,
|
||||
rename_map: dict[str, str] | None = None,
|
||||
defer_weight_load: bool = False,
|
||||
) -> PreTrainedPolicy:
|
||||
"""
|
||||
Instantiate a policy model.
|
||||
@@ -309,22 +257,27 @@ def make_policy(
|
||||
can either initialize a new policy from scratch or load a pretrained one.
|
||||
|
||||
Args:
|
||||
cfg: The configuration for the policy to be created. If `cfg.pretrained_path` is
|
||||
set, the policy will be loaded with weights from that path.
|
||||
ds_meta: Dataset metadata used to infer feature shapes and types. Also provides
|
||||
statistics for normalization layers.
|
||||
env_cfg: Environment configuration used to infer feature shapes and types.
|
||||
One of `ds_meta` or `env_cfg` must be provided.
|
||||
rename_map: Optional mapping of dataset or environment feature keys to match
|
||||
expected policy feature names (e.g., `"left"` → `"camera1"`).
|
||||
cfg (PreTrainedConfig): The configuration for the policy to be created. If
|
||||
`cfg.pretrained_path` is set, the policy will be loaded with weights from that path.
|
||||
ds_meta (LeRobotDatasetMetadata | None): Dataset metadata used to infer feature shapes and
|
||||
types. Also provides statistics for normalization layers.
|
||||
env_cfg (EnvConfig | None): Environment configuration used to infer feature shapes and
|
||||
types. One of `ds_meta` or `env_cfg` must be provided.
|
||||
rename_map (dict[str, str] | None): Optional mapping of dataset or environment feature
|
||||
keys to match expected policy feature names (e.g., `"left"` → `"camera1"`).
|
||||
defer_weight_load (bool): Build the exact policy `from_pretrained` would build — same
|
||||
config resolution, same stats-derived buffers, same device placement and eval mode —
|
||||
but skip the safetensors weight load. Used when resuming from a DCP checkpoint, whose
|
||||
sharded weights stream in after `accelerator.prepare()` (the distributed checkpoint
|
||||
engine overwrites the random init).
|
||||
|
||||
Returns:
|
||||
An instantiated and device-placed policy model.
|
||||
PreTrainedPolicy: An instantiated and device-placed policy model.
|
||||
|
||||
Raises:
|
||||
ValueError: If both or neither of `ds_meta` and `env_cfg` are provided.
|
||||
NotImplementedError: If attempting to use an unsupported policy-backend
|
||||
combination (e.g., VQBeT with 'mps').
|
||||
NotImplementedError: If attempting to use an unsupported policy-backend combination
|
||||
(e.g., VQBeT with 'mps').
|
||||
"""
|
||||
if bool(ds_meta) == bool(env_cfg):
|
||||
raise ValueError("Either one of a dataset metadata or a sim env must be provided.")
|
||||
@@ -389,11 +342,18 @@ def make_policy(
|
||||
)
|
||||
|
||||
if cfg.pretrained_path and not cfg.use_peft:
|
||||
# Load a pretrained policy and override the config if needed (for example, if there are inference-time
|
||||
# hyperparameters that we want to vary).
|
||||
kwargs["pretrained_name_or_path"] = cfg.pretrained_path
|
||||
kwargs["revision"] = cfg.pretrained_revision
|
||||
policy = policy_cls.from_pretrained(**kwargs)
|
||||
if defer_weight_load:
|
||||
# Same construction path as from_pretrained (config already resolved from the
|
||||
# checkpoint by the caller; dataset_stats/dataset_meta kwargs identical), minus the
|
||||
# weight load — parity by construction.
|
||||
policy = policy_cls(**kwargs)
|
||||
policy.eval()
|
||||
else:
|
||||
# Load a pretrained policy and override the config if needed (for example, if there
|
||||
# are inference-time hyperparameters that we want to vary).
|
||||
kwargs["pretrained_name_or_path"] = cfg.pretrained_path
|
||||
kwargs["revision"] = cfg.pretrained_revision
|
||||
policy = policy_cls.from_pretrained(**kwargs)
|
||||
elif cfg.pretrained_path and cfg.use_peft:
|
||||
# Load a pretrained PEFT model on top of the policy. The pretrained path points to the folder/repo
|
||||
# of the adapter and the adapter's config contains the path to the base policy. So we need the
|
||||
|
||||
@@ -27,8 +27,6 @@ from lerobot.configs import (
|
||||
from lerobot.optim import AdamWConfig
|
||||
from lerobot.utils.constants import ACTION, OBS_STATE
|
||||
|
||||
from ..rtc.configuration_rtc import RTCConfig
|
||||
|
||||
WAN22_MODEL_ID = "Wan-AI/Wan2.2-TI2V-5B"
|
||||
WAN22_DIFFUSERS_MODEL_ID = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
|
||||
FASTWAM_BASE_MODEL_ID = "lerobot/fastwam_base"
|
||||
@@ -190,25 +188,12 @@ class FastWAMConfig(PreTrainedConfig):
|
||||
action_video_freq_ratio: int = 4
|
||||
image_size: tuple[int, int] = (224, 448)
|
||||
context_len: int = 128
|
||||
|
||||
# Relative actions: converts absolute actions to relative (action -= state) during
|
||||
# preprocessing, and reverses it at postprocessing. Requires `proprio_dim` (OBS_STATE).
|
||||
use_relative_actions: bool = False
|
||||
# Joint names to keep absolute (not converted to relative). Empty list = all dims relative.
|
||||
relative_exclude_joints: list[str] = field(default_factory=lambda: ["gripper"])
|
||||
# Populated at runtime from dataset metadata by make_policy (used to build the exclude mask).
|
||||
action_feature_names: list[str] | None = None
|
||||
model_id: str = WAN22_MODEL_ID
|
||||
tokenizer_model_id: str = WAN_T5_TOKENIZER_ID
|
||||
text_encoder_model_id: str = WAN22_DIFFUSERS_MODEL_ID
|
||||
base_model_id: str | None = FASTWAM_BASE_MODEL_ID
|
||||
tokenizer_max_len: int = 128
|
||||
load_text_encoder: bool = True
|
||||
# Device for the frozen ~11GB UMT5-XXL text encoder. `None` keeps it on the main
|
||||
# policy `device` (default). Set to e.g. "cpu" to keep it off the GPU and save VRAM;
|
||||
# prompts are then encoded on that device and the resulting embeddings moved to the
|
||||
# policy device. Trades GPU memory for slower (CPU) text encoding.
|
||||
text_encoder_device: str | None = None
|
||||
mot_checkpoint_mixed_attn: bool = False
|
||||
torch_dtype: str = "bfloat16"
|
||||
prompt_template: str = (
|
||||
@@ -216,11 +201,6 @@ class FastWAMConfig(PreTrainedConfig):
|
||||
)
|
||||
num_inference_steps: int = 10
|
||||
inference_seed: int | None = 42
|
||||
# Real-Time Chunking (RTC): async chunk generation with prefix guidance so a new chunk
|
||||
# inpaints smoothly onto the still-executing tail of the previous one. `None` disables it
|
||||
# (default synchronous inference). Consumed by `RTCInferenceEngine`, which calls
|
||||
# `predict_action_chunk(..., inference_delay=, prev_chunk_left_over=)`.
|
||||
rtc_config: RTCConfig | None = None
|
||||
rand_device: str = "cpu"
|
||||
text_cfg_scale: float = 1.0
|
||||
negative_prompt: str = ""
|
||||
@@ -299,10 +279,6 @@ class FastWAMConfig(PreTrainedConfig):
|
||||
finally:
|
||||
self.pretrained_path = pretrained_path
|
||||
|
||||
@property
|
||||
def chunk_size(self) -> int:
|
||||
return self.action_horizon
|
||||
|
||||
def get_optimizer_preset(self) -> AdamWConfig:
|
||||
return AdamWConfig(lr=self.optimizer_lr, weight_decay=self.optimizer_weight_decay)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import torch
|
||||
from torch import Tensor
|
||||
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy
|
||||
from lerobot.policies.rtc.modeling_rtc import RTCProcessor
|
||||
from lerobot.utils.constants import OBS_STATE
|
||||
from lerobot.utils.import_utils import require_package
|
||||
|
||||
@@ -55,6 +54,9 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
|
||||
config_class = FastWAMConfig
|
||||
name = "fastwam"
|
||||
# FSDP2 wrap units: MoTLayer is the single FSDP owner of each layer's expert blocks
|
||||
# (the blocks are re-parented onto it precisely so sharding has one boundary to hook).
|
||||
_fsdp_wrap_modules = ["MoTLayer"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -86,29 +88,8 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
for layer in mot.layers:
|
||||
if "video" in layer.blocks:
|
||||
layer.blocks["video"].requires_grad_(False)
|
||||
self.init_rtc_processor()
|
||||
self.reset()
|
||||
|
||||
def init_rtc_processor(self) -> None:
|
||||
"""Attach a Real-Time Chunking processor to the core model when configured.
|
||||
|
||||
Mirrors the PI0/PI05 pattern: the policy owns the `RTCProcessor` and hands it to
|
||||
the core `FastWAM` model, which consults it inside `infer_action`'s denoising loop.
|
||||
Must stay public and named exactly `init_rtc_processor`: the rollout loader
|
||||
(`lerobot.rollout.context`) sets `policy.config.rtc_config = cfg.inference.rtc` and
|
||||
then calls `policy.init_rtc_processor()` to (re)build the processor after load, so
|
||||
`--inference.type=rtc` alone is enough to enable guidance — no separate policy-side
|
||||
`rtc_config` needed. A private/renamed method would be silently skipped (guidance
|
||||
off), degrading RTC to unguided async chunk-swapping.
|
||||
"""
|
||||
self.rtc_processor = None
|
||||
if self.config.rtc_config is not None:
|
||||
self.rtc_processor = RTCProcessor(self.config.rtc_config)
|
||||
self.model.rtc_processor = self.rtc_processor
|
||||
|
||||
def _rtc_enabled(self) -> bool:
|
||||
return self.config.rtc_config is not None and self.config.rtc_config.enabled
|
||||
|
||||
@classmethod
|
||||
def _load_as_safetensor(cls, model, model_file: str, map_location: str, strict: bool):
|
||||
"""Shape-aware load that supports cross-embodiment fine-tuning.
|
||||
@@ -172,24 +153,6 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
|
||||
def reset(self) -> None:
|
||||
self._action_queue: deque[Tensor] = deque([], maxlen=self.config.n_action_steps)
|
||||
# Per-episode text-embedding cache (mirrors LingBot-VA's `_prompt_embeds`). The task
|
||||
# is fixed for an episode, so the ~11GB UMT5 encoder runs once on the first chunk and
|
||||
# the resulting context is reused for every subsequent chunk. Cleared here on reset so
|
||||
# a new episode's (possibly different) task is re-encoded. Proprio is still appended
|
||||
# fresh each chunk downstream, so only the text-only context is cached.
|
||||
self._cached_prompt: Any = None
|
||||
self._cached_context: Tensor | None = None
|
||||
self._cached_context_mask: Tensor | None = None
|
||||
|
||||
def _encode_prompt_cached(self, prompt: Any) -> tuple[Tensor, Tensor]:
|
||||
"""Encode `prompt` to `(context, context_mask)`, reusing the cache when the prompt is
|
||||
unchanged so UMT5 runs at most once per episode (per distinct task)."""
|
||||
if self._cached_context is None or self._cached_prompt != prompt:
|
||||
context, context_mask = self.model.encode_prompt(prompt)
|
||||
self._cached_prompt = prompt
|
||||
self._cached_context = context
|
||||
self._cached_context_mask = context_mask
|
||||
return self._cached_context, self._cached_context_mask
|
||||
|
||||
def _batch_to_training_sample(self, batch: dict[str, Tensor]) -> dict[str, Tensor]:
|
||||
"""Adapt a standard LeRobot batch to the FastWAM-native sample that
|
||||
@@ -223,9 +186,7 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
sample["proprio"] = state.unsqueeze(1) if state.ndim == 2 else state
|
||||
return sample
|
||||
|
||||
def forward(
|
||||
self, batch: dict[str, Tensor], reduction: str = "mean"
|
||||
) -> tuple[Tensor, dict[str, Any]]:
|
||||
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict[str, Any]]:
|
||||
"""Compute FastWAM training loss for a LeRobot batch.
|
||||
|
||||
Args:
|
||||
@@ -233,42 +194,24 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
(`video`, `action`, `context`, `context_mask`) or LeRobot keys
|
||||
that can be adapted (`observation.images.*`, `observation.state`,
|
||||
`action`, `action_is_pad`).
|
||||
reduction (str): "mean" returns the scalar loss (default, backward
|
||||
compatible); "none" returns per-sample losses of shape (batch_size,)
|
||||
for sample weighting (RA-BC).
|
||||
|
||||
Returns:
|
||||
tuple[Tensor, dict[str, Any]]: The loss to backprop (scalar for "mean",
|
||||
per-sample (B,) for "none"), and a dict of logging metrics (e.g.
|
||||
`loss_video`, `loss_action`) — the `(loss, output_dict)` contract the
|
||||
LeRobot training loop expects.
|
||||
tuple[Tensor, dict[str, Any]]: The scalar loss to backprop, and a dict of
|
||||
logging metrics (e.g. `loss_video`, `loss_action`) — the `(loss, output_dict)`
|
||||
contract the LeRobot training loop expects.
|
||||
"""
|
||||
|
||||
sample = self._batch_to_training_sample(batch)
|
||||
loss, metrics = self.model.training_loss(sample, reduction=reduction)
|
||||
loss, metrics = self.model.training_loss(sample)
|
||||
return loss, dict(metrics or {})
|
||||
|
||||
@torch.no_grad()
|
||||
def predict_action_chunk(
|
||||
self,
|
||||
batch: dict[str, Tensor],
|
||||
inference_delay: int | None = None,
|
||||
prev_chunk_left_over: Tensor | None = None,
|
||||
execution_horizon: int | None = None,
|
||||
**_: Any,
|
||||
) -> Tensor:
|
||||
def predict_action_chunk(self, batch: dict[str, Tensor], **_: Any) -> Tensor:
|
||||
"""Predict a chunk of actions from the current FastWAM observation.
|
||||
|
||||
Args:
|
||||
batch (dict[str, Tensor]): Inference batch with `input_image` or
|
||||
image observation keys, plus `context/context_mask` or `prompt`.
|
||||
inference_delay (int | None): RTC — number of prefix steps assumed already
|
||||
executed by the time this chunk lands (from measured inference latency).
|
||||
prev_chunk_left_over (Tensor | None): RTC — the previous chunk's unexecuted
|
||||
action tail `[T_prev, action_dim]` in model space; guides denoising so the
|
||||
new chunk inpaints onto it. `None` (default) = plain synchronous inference.
|
||||
execution_horizon (int | None): RTC — override for the prefix-weight horizon;
|
||||
`None` falls back to `rtc_config.execution_horizon`.
|
||||
|
||||
Returns:
|
||||
Tensor: Action chunk with shape `[B, action_horizon, action_dim]`.
|
||||
@@ -276,20 +219,6 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
|
||||
self.eval()
|
||||
infer_kwargs = _batch_to_infer_kwargs(batch=batch, config=self.config)
|
||||
# Encode the task once per episode and reuse it (LingBot-VA parity): swap the raw
|
||||
# `prompt` for the cached `context`/`context_mask` so `infer_action` skips `encode_prompt`
|
||||
# and the text encoder isn't re-run every chunk. Skipped when the caller supplies its own
|
||||
# precomputed `context` (the two are mutually exclusive downstream).
|
||||
if infer_kwargs.get("context") is None and infer_kwargs.get("prompt") is not None:
|
||||
context, context_mask = self._encode_prompt_cached(infer_kwargs["prompt"])
|
||||
infer_kwargs["prompt"] = None
|
||||
infer_kwargs["context"] = context
|
||||
infer_kwargs["context_mask"] = context_mask
|
||||
# RTC guidance args flow straight to `infer_action`; they are inert unless an
|
||||
# RTCProcessor is attached, enabled, and `prev_chunk_left_over` is provided.
|
||||
infer_kwargs["inference_delay"] = inference_delay
|
||||
infer_kwargs["prev_chunk_left_over"] = prev_chunk_left_over
|
||||
infer_kwargs["execution_horizon"] = execution_horizon
|
||||
batch_size = _infer_kwargs_batch_size(infer_kwargs)
|
||||
if batch_size == 1:
|
||||
action = _action_from_model_output(self.model.infer_action(**infer_kwargs))
|
||||
@@ -334,10 +263,9 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
mixtures={"video": video_expert, "action": action_expert},
|
||||
mot_checkpoint_mixed_attn=config.mot_checkpoint_mixed_attn,
|
||||
)
|
||||
text_encoder_device = config.text_encoder_device or device
|
||||
text_encoder = (
|
||||
load_pretrained_wan_text_encoder(
|
||||
model_id=config.text_encoder_model_id, torch_dtype=dtype, device=text_encoder_device
|
||||
model_id=config.text_encoder_model_id, torch_dtype=dtype, device=device
|
||||
)
|
||||
if config.load_text_encoder
|
||||
else None
|
||||
@@ -348,7 +276,6 @@ class FastWAMPolicy(PreTrainedPolicy):
|
||||
mot=mot,
|
||||
vae=load_pretrained_wan_vae(torch_dtype=dtype, device=device),
|
||||
text_encoder=text_encoder,
|
||||
text_encoder_device=config.text_encoder_device,
|
||||
tokenizer=build_wan_tokenizer(
|
||||
model_id=config.tokenizer_model_id, tokenizer_max_len=config.tokenizer_max_len
|
||||
),
|
||||
|
||||
@@ -21,13 +21,10 @@ import torch
|
||||
|
||||
from lerobot.configs import PipelineFeatureType, PolicyFeature
|
||||
from lerobot.processor import (
|
||||
AbsoluteActionsProcessorStep,
|
||||
ActionProcessorStep,
|
||||
PolicyAction,
|
||||
PolicyProcessorPipeline,
|
||||
ProcessorStep,
|
||||
ProcessorStepRegistry,
|
||||
RelativeActionsProcessorStep,
|
||||
make_default_policy_processor_steps,
|
||||
make_policy_processor_pipelines,
|
||||
)
|
||||
@@ -101,25 +98,14 @@ def make_fastwam_pre_post_processors(
|
||||
|
||||
steps = make_default_policy_processor_steps(config, normalization_stats, normalizer_device=config.device)
|
||||
|
||||
# Shared relative-action step (OpenPI order: raw -> relative -> normalize -> model ->
|
||||
# unnormalize -> absolute). The SAME instance is passed to AbsoluteActionsProcessorStep
|
||||
# below so its cached raw state (set during preprocessing) flows to postprocessing.
|
||||
relative_step = RelativeActionsProcessorStep(
|
||||
enabled=config.use_relative_actions,
|
||||
exclude_joints=getattr(config, "relative_exclude_joints", []),
|
||||
action_names=getattr(config, "action_feature_names", None),
|
||||
)
|
||||
|
||||
input_steps: list[ProcessorStep] = [
|
||||
input_steps = [
|
||||
steps.rename_observations,
|
||||
steps.add_batch_dim,
|
||||
steps.to_device,
|
||||
relative_step,
|
||||
steps.normalize,
|
||||
]
|
||||
output_steps: list[ProcessorStep] = [
|
||||
output_steps = [
|
||||
steps.unnormalize,
|
||||
AbsoluteActionsProcessorStep(enabled=config.use_relative_actions, relative_step=relative_step),
|
||||
]
|
||||
if config.toggle_action_dimensions:
|
||||
output_steps.append(
|
||||
|
||||
@@ -839,7 +839,6 @@ class FastWAM(torch.nn.Module):
|
||||
text_dim: int | None = None,
|
||||
proprio_dim: int | None = None,
|
||||
device: str = "cpu",
|
||||
text_encoder_device: str | torch.device | None = None,
|
||||
torch_dtype: torch.dtype = torch.float32,
|
||||
video_train_shift: float = 5.0,
|
||||
video_infer_shift: float = 5.0,
|
||||
@@ -908,27 +907,12 @@ class FastWAM(torch.nn.Module):
|
||||
self.train_scheduler = self.train_video_scheduler
|
||||
self.infer_scheduler = self.infer_video_scheduler
|
||||
|
||||
# Optional Real-Time Chunking processor (set by the policy wrapper). When present and
|
||||
# enabled it guides the action denoising loop in `infer_action` so a freshly generated
|
||||
# chunk inpaints onto the previous chunk's unexecuted tail. Plain attribute (not an
|
||||
# nn.Module) — carries no parameters and stays out of state_dict / device moves.
|
||||
self.rtc_processor = None
|
||||
self.device = torch.device(device)
|
||||
# When pinned (e.g. "cpu"), the frozen text encoder stays on this device instead
|
||||
# of following the model onto the GPU — `_apply` skips it and `encode_prompt` runs
|
||||
# it here, moving embeddings back to `self.device`. `None` = follow `self.device`.
|
||||
self._text_encoder_device = (
|
||||
torch.device(text_encoder_device) if text_encoder_device is not None else None
|
||||
)
|
||||
self.torch_dtype = torch_dtype
|
||||
self.loss_lambda_video = float(loss_lambda_video)
|
||||
self.loss_lambda_action = float(loss_lambda_action)
|
||||
|
||||
self.to(self.device)
|
||||
# `self.to` above (via `_apply`) skips a pinned text encoder; make sure it actually
|
||||
# sits on the pinned device (it was loaded there, but this is a cheap safety net).
|
||||
if self.text_encoder is not None and self._text_encoder_device is not None:
|
||||
self.text_encoder._apply(lambda t: t.to(self._text_encoder_device))
|
||||
|
||||
@classmethod
|
||||
def from_wan22_pretrained(
|
||||
@@ -1019,8 +1003,7 @@ class FastWAM(torch.nn.Module):
|
||||
# while staying out of `state_dict()` / `parameters()`.
|
||||
super()._apply(fn, *args, **kwargs)
|
||||
self.vae._apply(fn)
|
||||
# A pinned text encoder (e.g. on CPU) must NOT follow device moves — leave it put.
|
||||
if self.text_encoder is not None and self._text_encoder_device is None:
|
||||
if self.text_encoder is not None:
|
||||
self.text_encoder._apply(fn)
|
||||
return self
|
||||
|
||||
@@ -1041,12 +1024,9 @@ class FastWAM(torch.nn.Module):
|
||||
"Prompt encoding requires loaded text encoder/tokenizer. "
|
||||
"Set `load_text_encoder=true` or provide precomputed `context/context_mask`."
|
||||
)
|
||||
# Run the encoder on its own device (may be pinned to CPU to save VRAM), then
|
||||
# move the resulting embeddings/mask to the model device for the DiT.
|
||||
te_device = self._text_encoder_device or self.device
|
||||
ids, mask = self.tokenizer(prompt, return_mask=True, add_special_tokens=True)
|
||||
ids = ids.to(te_device)
|
||||
mask = mask.to(te_device, dtype=torch.bool)
|
||||
ids = ids.to(self.device)
|
||||
mask = mask.to(self.device, dtype=torch.bool)
|
||||
prompt_emb = self.text_encoder(ids, mask)
|
||||
seq_lens = mask.gt(0).sum(dim=1).long()
|
||||
for i, v in enumerate(seq_lens):
|
||||
@@ -1054,7 +1034,7 @@ class FastWAM(torch.nn.Module):
|
||||
# Match FastWAM/Wan2.2 context semantics: padding embeddings are zeroed,
|
||||
# while cross-attention still sees a fixed-length context.
|
||||
mask = torch.ones_like(mask)
|
||||
return prompt_emb.to(device=self.device), mask.to(device=self.device)
|
||||
return prompt_emb.to(device=self.device), mask
|
||||
|
||||
def _append_proprio_to_context(
|
||||
self,
|
||||
@@ -1379,9 +1359,7 @@ class FastWAM(torch.nn.Module):
|
||||
pred_action = self.action_expert.post_dit(tokens_out["action"], action_pre)
|
||||
return pred_video, pred_action
|
||||
|
||||
def _compute_training_video_loss(
|
||||
self, inputs, pred_video, target_video, timestep_video, reduction: str = "mean"
|
||||
):
|
||||
def _compute_training_video_loss(self, inputs, pred_video, target_video, timestep_video):
|
||||
include_initial_video_step = inputs["first_frame_latents"] is None
|
||||
if inputs["first_frame_latents"] is not None:
|
||||
pred_video = pred_video[:, :, 1:]
|
||||
@@ -1396,13 +1374,9 @@ class FastWAM(torch.nn.Module):
|
||||
loss_video_per_sample.device,
|
||||
dtype=loss_video_per_sample.dtype,
|
||||
)
|
||||
weighted = loss_video_per_sample * video_weight
|
||||
# reduction="none" returns the per-sample vector (B,) for sample weighting (RA-BC).
|
||||
return weighted if reduction == "none" else weighted.mean()
|
||||
return (loss_video_per_sample * video_weight).mean()
|
||||
|
||||
def _compute_training_action_loss(
|
||||
self, inputs, pred_action, target_action, timestep_action, reduction: str = "mean"
|
||||
):
|
||||
def _compute_training_action_loss(self, inputs, pred_action, target_action, timestep_action):
|
||||
action_loss_token = functional.mse_loss(
|
||||
pred_action.float(), target_action.float(), reduction="none"
|
||||
).mean(dim=2)
|
||||
@@ -1419,11 +1393,9 @@ class FastWAM(torch.nn.Module):
|
||||
action_loss_per_sample.device,
|
||||
dtype=action_loss_per_sample.dtype,
|
||||
)
|
||||
weighted = action_loss_per_sample * action_weight
|
||||
# reduction="none" returns the per-sample vector (B,) for sample weighting (RA-BC).
|
||||
return weighted if reduction == "none" else weighted.mean()
|
||||
return (action_loss_per_sample * action_weight).mean()
|
||||
|
||||
def training_loss(self, sample, tiled: bool = False, reduction: str = "mean"):
|
||||
def training_loss(self, sample, tiled: bool = False):
|
||||
inputs = self.build_inputs(sample, tiled=tiled)
|
||||
targets = self._sample_training_targets(inputs)
|
||||
pred_video, pred_action = self._run_training_mot(inputs=inputs, targets=targets)
|
||||
@@ -1432,20 +1404,17 @@ class FastWAM(torch.nn.Module):
|
||||
pred_video=pred_video,
|
||||
target_video=targets["target_video"],
|
||||
timestep_video=targets["timestep_video"],
|
||||
reduction=reduction,
|
||||
)
|
||||
loss_action = self._compute_training_action_loss(
|
||||
inputs=inputs,
|
||||
pred_action=pred_action,
|
||||
target_action=targets["target_action"],
|
||||
timestep_action=targets["timestep_action"],
|
||||
reduction=reduction,
|
||||
)
|
||||
# With reduction="none" both terms are (B,), so loss_total is the per-sample loss (B,).
|
||||
loss_total = self.loss_lambda_video * loss_video + self.loss_lambda_action * loss_action
|
||||
loss_dict = {
|
||||
"loss_video": self.loss_lambda_video * float(loss_video.detach().mean().item()),
|
||||
"loss_action": self.loss_lambda_action * float(loss_action.detach().mean().item()),
|
||||
"loss_video": self.loss_lambda_video * float(loss_video.detach().item()),
|
||||
"loss_action": self.loss_lambda_action * float(loss_action.detach().item()),
|
||||
}
|
||||
return loss_total, loss_dict
|
||||
|
||||
@@ -1830,9 +1799,6 @@ class FastWAM(torch.nn.Module):
|
||||
seed: int | None = None,
|
||||
rand_device: str = "cpu",
|
||||
tiled: bool = False,
|
||||
inference_delay: int | None = None,
|
||||
prev_chunk_left_over: torch.Tensor | None = None,
|
||||
execution_horizon: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.eval()
|
||||
if str(getattr(self.video_expert, "video_attention_mask_mode", "")) != "first_frame_causal":
|
||||
@@ -1885,43 +1851,18 @@ class FastWAM(torch.nn.Module):
|
||||
dtype=latents_action.dtype,
|
||||
shift_override=sigma_shift,
|
||||
)
|
||||
rtc_active = (
|
||||
self.rtc_processor is not None
|
||||
and getattr(self.rtc_processor.rtc_config, "enabled", False)
|
||||
and prev_chunk_left_over is not None
|
||||
)
|
||||
num_train_timesteps = float(self.infer_action_scheduler.num_train_timesteps)
|
||||
for step_t_action, step_delta_action in zip(infer_timesteps_action, infer_deltas_action, strict=True):
|
||||
timestep_action = step_t_action.unsqueeze(0).to(dtype=latents_action.dtype, device=self.device)
|
||||
|
||||
def denoise(x_t, ts=timestep_action):
|
||||
return self._predict_action_noise_with_cache(
|
||||
latents_action=x_t,
|
||||
timestep_action=ts,
|
||||
context=context,
|
||||
context_mask=context_mask,
|
||||
video_kv_cache=video_kv_cache,
|
||||
attention_mask=attention_mask,
|
||||
video_seq_len=video_seq_len,
|
||||
)
|
||||
|
||||
if rtc_active:
|
||||
# `time` is the flow-matching noise level sigma in [0, 1]: FastWAM's model
|
||||
# predicts velocity v = noise - clean, so the clean-action estimate is
|
||||
# x1 = x_t - sigma * v — exactly RTC's `x1_t = x_t - time * v_t`.
|
||||
sigma = float(step_t_action.item()) / num_train_timesteps
|
||||
pred_action = self.rtc_processor.denoise_step(
|
||||
x_t=latents_action,
|
||||
prev_chunk_left_over=prev_chunk_left_over.to(
|
||||
device=latents_action.device, dtype=latents_action.dtype
|
||||
),
|
||||
inference_delay=inference_delay or 0,
|
||||
time=sigma,
|
||||
original_denoise_step_partial=denoise,
|
||||
execution_horizon=execution_horizon,
|
||||
)
|
||||
else:
|
||||
pred_action = denoise(latents_action)
|
||||
pred_action = self._predict_action_noise_with_cache(
|
||||
latents_action=latents_action,
|
||||
timestep_action=timestep_action,
|
||||
context=context,
|
||||
context_mask=context_mask,
|
||||
video_kv_cache=video_kv_cache,
|
||||
attention_mask=attention_mask,
|
||||
video_seq_len=video_seq_len,
|
||||
)
|
||||
|
||||
latents_action = self.infer_action_scheduler.step(pred_action, step_delta_action, latents_action)
|
||||
|
||||
|
||||
@@ -28,11 +28,7 @@ from dataclasses import dataclass, field
|
||||
from lerobot.configs.policies import PreTrainedConfig
|
||||
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
|
||||
from lerobot.optim.optimizers import AdamWConfig
|
||||
from lerobot.optim.schedulers import (
|
||||
ConstantWithWarmupSchedulerConfig,
|
||||
CosineAnnealingWithWarmupSchedulerConfig,
|
||||
LRSchedulerConfig,
|
||||
)
|
||||
from lerobot.optim.schedulers import ConstantWithWarmupSchedulerConfig, LRSchedulerConfig
|
||||
from lerobot.utils.constants import ACTION
|
||||
|
||||
|
||||
@@ -96,15 +92,6 @@ class LingBotVAConfig(PreTrainedConfig):
|
||||
# (un)normalization quantiles live in the checkpoint's ``policy_postprocessor.json``, not here.
|
||||
used_action_channel_ids: list[int] = field(default_factory=lambda: list(range(7)))
|
||||
|
||||
# Relative actions: converts absolute actions to relative (action -= state) during
|
||||
# preprocessing, and reverses it at postprocessing. Requires the dataset to provide
|
||||
# observation.state whose leading dims align 1:1 with the used action channels.
|
||||
use_relative_actions: bool = False
|
||||
# Joint names to keep absolute (not converted to relative). Empty list = all dims relative.
|
||||
relative_exclude_joints: list[str] = field(default_factory=lambda: ["gripper"])
|
||||
# Populated at runtime from dataset metadata by make_policy (used to build the exclude mask).
|
||||
action_feature_names: list[str] | None = None
|
||||
|
||||
# Opt-in: VAE-decode predicted video latents to ``self.last_predicted_frames`` for saving MP4s.
|
||||
save_predicted_video: bool = False
|
||||
|
||||
@@ -125,17 +112,6 @@ class LingBotVAConfig(PreTrainedConfig):
|
||||
optimizer_weight_decay: float = 1e-4
|
||||
optimizer_grad_clip_norm: float = 1.0
|
||||
scheduler_warmup_steps: int = 1000
|
||||
# Scheduler after warmup. "constant_with_warmup" (upstream default: warmup then flat peak LR)
|
||||
# or "cosine_annealing_with_warmup" (warmup then cosine anneal peak->0 over the remaining steps).
|
||||
# Cosine tightens the loss tail and often nudges final loss down; it does NOT reduce the
|
||||
# flow-matching estimator's step-to-step noise (that's metric variance, LR-independent).
|
||||
scheduler_type: str = "constant_with_warmup"
|
||||
# Probability of corrupting the action stream's conditioning (clean/context) tokens with
|
||||
# flow-matching noise during training, mirroring the video stream's noisy_cond_prob=0.5.
|
||||
# Upstream train.py hardcodes 0.0 for actions (never corrupted) with no exposed knob; this is
|
||||
# an experimental deviation to make the model more tolerant of imperfect action history
|
||||
# (e.g. clamp-induced drift between predicted and executed actions during rollout).
|
||||
action_noisy_cond_prob: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
@@ -174,32 +150,14 @@ class LingBotVAConfig(PreTrainedConfig):
|
||||
)
|
||||
|
||||
def get_scheduler_preset(self) -> LRSchedulerConfig | None:
|
||||
# Default (upstream): linear warmup then constant LR (warmup_constant_lambda).
|
||||
# Optionally cosine-anneal peak->0 over the remaining steps via scheduler_type.
|
||||
if self.scheduler_type == "cosine_annealing_with_warmup":
|
||||
return CosineAnnealingWithWarmupSchedulerConfig(num_warmup_steps=self.scheduler_warmup_steps)
|
||||
# Upstream uses a linear warmup followed by a constant LR (warmup_constant_lambda).
|
||||
return ConstantWithWarmupSchedulerConfig(num_warmup_steps=self.scheduler_warmup_steps)
|
||||
|
||||
@property
|
||||
def observation_delta_indices(self) -> list[int]:
|
||||
"""Observation frame deltas for the training clip, sized to what the VAE actually reads.
|
||||
|
||||
``diffusers``' ``AutoencoderKLWan._encode`` runs ``iter_ = 1 + (n - 1) // 4`` passes over
|
||||
``x[:, :, :1]`` then ``x[:, :, 1 + 4*(i-1) : 1 + 4*i]``, so it only ever consumes the first
|
||||
``4 * (iter_ - 1) + 1`` frames of an ``n``-frame clip. Asking for ``frame_chunk_size * 4``
|
||||
frames (the previous formula) therefore decoded 3 frames per sample that never reached the
|
||||
encoder: at ``frame_chunk_size=2`` the deltas were ``[0, 4, ..., 28]`` and only
|
||||
``[0, 4, 8, 12, 16]`` were used -- verified by ablation, scrambling the tail left the latents
|
||||
bit-identical.
|
||||
|
||||
Requesting exactly ``4 * (frame_chunk_size - 1) + 1`` frames yields the same
|
||||
``frame_chunk_size`` latent frames with every loaded frame used, and drops the wasted video
|
||||
decode. The stride is unchanged, so the frames that do reach the model are the same ones.
|
||||
"""
|
||||
temporal_downsample = 4
|
||||
stride = max(1, self.action_per_frame // temporal_downsample)
|
||||
num_frames = temporal_downsample * (self.frame_chunk_size - 1) + 1
|
||||
return [i * stride for i in range(num_frames)]
|
||||
return list(range(0, self.frame_chunk_size * temporal_downsample * stride, stride))
|
||||
|
||||
@property
|
||||
def action_delta_indices(self) -> list[int]:
|
||||
|
||||
@@ -38,7 +38,7 @@ import torch.nn.functional as F # noqa: N812
|
||||
from einops import rearrange
|
||||
from torch import Tensor
|
||||
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy, unpack_action_output
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy
|
||||
from lerobot.utils.constants import ACTION
|
||||
from lerobot.utils.import_utils import require_package
|
||||
|
||||
@@ -99,6 +99,8 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
# from ``config.wan_pretrained_path`` the first time inference runs.
|
||||
self._frozen: dict = {}
|
||||
|
||||
self.last_predicted_frames: Tensor | None = None
|
||||
self.last_predicted_latents: Tensor | None = None
|
||||
self.reset()
|
||||
|
||||
# Frozen-module lazy loading (VAE + UMT5 + tokenizer)
|
||||
@@ -168,6 +170,8 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
self._prompt: str | None = None
|
||||
self._prompt_embeds = None
|
||||
self._negative_prompt_embeds = None
|
||||
self.last_predicted_frames = None
|
||||
self.last_predicted_latents = None
|
||||
self._use_cfg = (cfg.guidance_scale > 1) or (cfg.action_guidance_scale > 1)
|
||||
# Two independent flow-matching schedulers (video latent + action streams).
|
||||
self._scheduler = FlowMatchScheduler(shift=cfg.snr_shift, sigma_min=0.0, extra_one_step=True)
|
||||
@@ -253,12 +257,8 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
"grid_id": grid_id,
|
||||
}
|
||||
|
||||
def _flow_matching_loss(self, input_dict, pred, reduction: str = "mean"):
|
||||
"""Dual-stream flow-matching loss (port of upstream ``Trainer.compute_loss``).
|
||||
|
||||
``reduction="mean"`` returns scalar (latent_loss, action_loss); ``"none"`` returns
|
||||
per-sample vectors of shape ``(B,)`` each (averaged over latent frames) for RA-BC.
|
||||
"""
|
||||
def _flow_matching_loss(self, input_dict, pred):
|
||||
"""Dual-stream flow-matching loss (port of upstream ``Trainer.compute_loss``)."""
|
||||
latent_pred, action_pred = pred
|
||||
ld, ad = input_dict["latent_dict"], input_dict["action_dict"]
|
||||
action_pred = rearrange(action_pred, "b (f n) c -> b c f n 1", f=ad["targets"].shape[-3])
|
||||
@@ -278,8 +278,7 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
latent_loss = (
|
||||
(latent_loss * lw[:, None, :, None, None]).permute(0, 2, 3, 4, 1).flatten(0, 1).flatten(1)
|
||||
)
|
||||
# per (batch*frame) mean over spatial/channel -> (B*F,)
|
||||
latent_loss = latent_loss.sum(dim=1) / (torch.ones_like(latent_loss).sum(dim=1) + 1e-6)
|
||||
latent_loss = (latent_loss.sum(dim=1) / (torch.ones_like(latent_loss).sum(dim=1) + 1e-6)).mean()
|
||||
|
||||
amask = ad["actions_mask"].float()
|
||||
action_loss = F.mse_loss(action_pred.float(), ad["targets"].float().detach(), reduction="none")
|
||||
@@ -287,14 +286,10 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
(action_loss * aw[:, None, :, None, None] * amask).permute(0, 2, 3, 4, 1).flatten(0, 1).flatten(1)
|
||||
)
|
||||
amask_f = amask.permute(0, 2, 3, 4, 1).flatten(0, 1).flatten(1)
|
||||
action_loss = action_loss.sum(dim=1) / (amask_f.sum(dim=1) + 1e-6)
|
||||
action_loss = (action_loss.sum(dim=1) / (amask_f.sum(dim=1) + 1e-6)).mean()
|
||||
return latent_loss, action_loss
|
||||
|
||||
if reduction == "none":
|
||||
# (B*F,) -> (B, F) -> (B,): per-sample losses for RA-BC weighting.
|
||||
return latent_loss.reshape(bn, fn).mean(dim=1), action_loss.reshape(bn, fn).mean(dim=1)
|
||||
return latent_loss.mean(), action_loss.mean()
|
||||
|
||||
def training_loss_from_streams(self, latents, actions, actions_mask, text_emb, reduction: str = "mean"):
|
||||
def training_loss_from_streams(self, latents, actions, actions_mask, text_emb):
|
||||
"""Core dual-stream training loss given prepared latents / actions / text embeddings.
|
||||
|
||||
``latents``: ``[B, in_channels, F, h, w]`` (normalized video latents).
|
||||
@@ -311,11 +306,7 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
latents, self._train_sched_latent, action_mask=None, action_mode=False, noisy_cond_prob=0.5
|
||||
)
|
||||
action_dict = self._add_noise_stream(
|
||||
actions,
|
||||
self._train_sched_action,
|
||||
action_mask=actions_mask,
|
||||
action_mode=True,
|
||||
noisy_cond_prob=self.config.action_noisy_cond_prob,
|
||||
actions, self._train_sched_action, action_mask=actions_mask, action_mode=True, noisy_cond_prob=0.0
|
||||
)
|
||||
latent_dict["text_emb"] = text_emb
|
||||
action_dict["text_emb"] = text_emb
|
||||
@@ -327,24 +318,20 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
"window_size": int(torch.randint(4, 65, (1,)).item()),
|
||||
}
|
||||
pred = self.transformer(input_dict, train_mode=True)
|
||||
latent_loss, action_loss = self._flow_matching_loss(input_dict, pred, reduction)
|
||||
# reduction="none": latent_loss/action_loss are (B,) -> loss is per-sample (B,).
|
||||
latent_loss, action_loss = self._flow_matching_loss(input_dict, pred)
|
||||
loss = latent_loss + action_loss
|
||||
return loss, {"latent_loss": latent_loss.mean().item(), "action_loss": action_loss.mean().item()}
|
||||
return loss, {"latent_loss": latent_loss.item(), "action_loss": action_loss.item()}
|
||||
|
||||
def forward(self, batch: dict[str, Tensor], reduction: str = "mean") -> tuple[Tensor, dict | None]:
|
||||
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict | None]:
|
||||
"""Training forward: dual-stream flow-matching loss.
|
||||
|
||||
Builds the (video-latent, action, text) training streams from a LeRobot batch
|
||||
(VAE-encoding the camera frames and UMT5-encoding the task), then runs the flow-matching
|
||||
dual-stream loss. Requires the policy to be built with ``attn_mode='flex'``.
|
||||
|
||||
``reduction="mean"`` returns the scalar loss (default); ``"none"`` returns per-sample
|
||||
losses of shape ``(B,)`` for sample weighting (RA-BC).
|
||||
"""
|
||||
self._ensure_frozen_modules()
|
||||
latents, actions, actions_mask, text_emb = self._build_training_streams(batch)
|
||||
return self.training_loss_from_streams(latents, actions, actions_mask, text_emb, reduction=reduction)
|
||||
return self.training_loss_from_streams(latents, actions, actions_mask, text_emb)
|
||||
|
||||
@torch.no_grad()
|
||||
def _build_training_streams(self, batch):
|
||||
@@ -413,31 +400,22 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
return torch.cat(per_cam, dim=-1).to(self.config.device)
|
||||
|
||||
@torch.no_grad()
|
||||
def select_action(
|
||||
self, batch: dict[str, Tensor], return_intermediate_predictions: bool = False, **kwargs
|
||||
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
|
||||
def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
|
||||
"""Return one action, refilling the chunk (and feeding back observed keyframes) as needed.
|
||||
|
||||
Mirrors the upstream LIBERO client loop (``evaluation/libero/client.py``): the first obs is
|
||||
the conditioning frame; every observation produced afterwards is buffered as a keyframe and,
|
||||
once the chunk's actions are exhausted, the buffered frames + executed actions are fed back
|
||||
into the KV cache before the next chunk is predicted.
|
||||
|
||||
When ``return_intermediate_predictions=True`` returns ``(action, predictions)``. Predictions
|
||||
are produced only on the ticks that predict a fresh chunk (first tick and each chunk refill);
|
||||
on the intermediate ticks that just pop a cached action, ``predictions`` is an empty dict.
|
||||
"""
|
||||
self.eval()
|
||||
self._ensure_frozen_modules()
|
||||
self._maybe_init_prompt(batch)
|
||||
|
||||
predictions: dict[str, Tensor] = {}
|
||||
if not self._started:
|
||||
# First call: this observation conditions the first chunk (it is *not* a keyframe).
|
||||
self._started = True
|
||||
actions, predictions = unpack_action_output(
|
||||
self.predict_action_chunk(batch, return_intermediate_predictions=return_intermediate_predictions)
|
||||
) # [B, chunk_size, n_used]
|
||||
actions = self.predict_action_chunk(batch) # [B, chunk_size, n_used]
|
||||
self._action_queue.extend(actions.transpose(0, 1)) # [chunk_size, B, n_used]
|
||||
self._obs_buffer = []
|
||||
self._exec_step = 0
|
||||
@@ -449,31 +427,17 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
if len(self._action_queue) == 0:
|
||||
# All actions for the current chunk have been executed; feed the observed
|
||||
# keyframes + executed actions back and predict the next chunk.
|
||||
actions, predictions = unpack_action_output(
|
||||
self.predict_action_chunk(
|
||||
None, return_intermediate_predictions=return_intermediate_predictions
|
||||
)
|
||||
)
|
||||
actions = self.predict_action_chunk(None)
|
||||
self._action_queue.extend(actions.transpose(0, 1))
|
||||
self._exec_step = 0
|
||||
|
||||
self._prev_j = self._exec_step % self.config.action_per_frame
|
||||
self._exec_step += 1
|
||||
action = self._action_queue.popleft()
|
||||
if return_intermediate_predictions:
|
||||
return action, predictions
|
||||
return action
|
||||
return self._action_queue.popleft()
|
||||
|
||||
@torch.no_grad()
|
||||
def predict_action_chunk(
|
||||
self, batch: dict[str, Tensor], return_intermediate_predictions: bool = False, **kwargs
|
||||
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
|
||||
"""Run one autoregressive chunk and return actions ``[B, chunk_size, n_used]`` (normalized).
|
||||
|
||||
When ``return_intermediate_predictions=True`` returns ``(actions, predictions)`` where
|
||||
``predictions`` holds this chunk's VAE-decoded imagined video under ``"images.predicted"``
|
||||
(``[T, H, W, 3]`` uint8 on CPU).
|
||||
"""
|
||||
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
|
||||
"""Run one autoregressive chunk and return actions ``[B, chunk_size, n_used]`` (normalized)."""
|
||||
self.eval()
|
||||
self._ensure_frozen_modules()
|
||||
self._maybe_init_prompt(batch)
|
||||
@@ -495,6 +459,12 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
# actions: [B, action_dim, F, action_per_frame, 1] (model-normalized). Keep for KV feedback.
|
||||
self._executed_actions = actions
|
||||
|
||||
if self.config.save_predicted_video:
|
||||
# Match upstream LingBot-VA visualization: collect chunk latents and decode the
|
||||
# concatenated latent sequence once after the rollout finishes.
|
||||
self.last_predicted_frames = None
|
||||
self.last_predicted_latents = latents.detach().to("cpu")
|
||||
|
||||
# On the first chunk, frame 0 is the conditioning frame (already "known"): the upstream
|
||||
# LIBERO client skips it (start_idx=1), so we drop the first frame's actions here.
|
||||
used = self.config.used_action_channel_ids
|
||||
@@ -503,15 +473,7 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
a = a[:, :, 1:] # drop frame 0 -> (F-1) frames of actions
|
||||
a = a.squeeze(-1).flatten(2) # [B, n_used, n_steps]
|
||||
a = a.transpose(1, 2).contiguous() # [B, n_steps, n_used]
|
||||
a = a.to(torch.float32)
|
||||
|
||||
if return_intermediate_predictions:
|
||||
# Decode this chunk's imagined video for visualization / eval. Per-chunk decode (the VAE
|
||||
# has no streaming decoder) may differ slightly at chunk boundaries from a single decode
|
||||
# over the whole concatenated latent sequence; acceptable for monitoring/inspection.
|
||||
frames = self._decode_predicted_video(latents) # [T, H, W, 3] uint8, CPU
|
||||
return a, {"images.predicted": frames}
|
||||
return a
|
||||
return a.to(torch.float32)
|
||||
|
||||
# Prompt / text encoding
|
||||
def _maybe_init_prompt(self, batch):
|
||||
@@ -872,6 +834,11 @@ class LingBotVAPolicy(PreTrainedPolicy):
|
||||
return actions, latents
|
||||
|
||||
# Predicted-video decoding (opt-in)
|
||||
@torch.no_grad()
|
||||
def decode_predicted_latents(self, latents) -> Tensor:
|
||||
"""Decode a concatenated predicted-latent sequence into ``[T, H, W, 3]`` uint8 frames."""
|
||||
return self._decode_predicted_video(latents)
|
||||
|
||||
@torch.no_grad()
|
||||
def _decode_predicted_video(self, latents) -> Tensor:
|
||||
"""VAE-decode predicted latents into a uint8 frame stack ``[T, H, W, 3]`` on CPU."""
|
||||
|
||||
@@ -25,11 +25,9 @@ import torch
|
||||
|
||||
from lerobot.configs.types import FeatureType, NormalizationMode
|
||||
from lerobot.processor import (
|
||||
AbsoluteActionsProcessorStep,
|
||||
PolicyAction,
|
||||
PolicyProcessorPipeline,
|
||||
ProcessorStep,
|
||||
RelativeActionsProcessorStep,
|
||||
UnnormalizerProcessorStep,
|
||||
make_default_policy_processor_steps,
|
||||
make_policy_processor_pipelines,
|
||||
@@ -49,33 +47,20 @@ def make_lingbot_va_pre_post_processors(
|
||||
|
||||
steps = make_default_policy_processor_steps(config, dataset_stats)
|
||||
|
||||
# Shared relative-action step (OpenPI order: raw -> relative -> normalize -> model ->
|
||||
# unnormalize -> absolute). The SAME instance is passed to AbsoluteActionsProcessorStep
|
||||
# below so its cached raw state (set during preprocessing) flows to postprocessing.
|
||||
relative_step = RelativeActionsProcessorStep(
|
||||
enabled=config.use_relative_actions,
|
||||
exclude_joints=getattr(config, "relative_exclude_joints", []),
|
||||
action_names=getattr(config, "action_feature_names", None),
|
||||
)
|
||||
|
||||
input_steps: list[ProcessorStep] = [
|
||||
steps.rename_observations,
|
||||
steps.add_batch_dim,
|
||||
relative_step,
|
||||
steps.normalize,
|
||||
steps.to_device,
|
||||
]
|
||||
|
||||
# Unnormalize actions back to physical units. Config-driven norm_map (was hardcoded QUANTILES)
|
||||
# so it stays symmetric with the preprocessor's NormalizerProcessorStep — required for
|
||||
# use_relative_actions with ACTION=IDENTITY (and unchanged for QUANTILES runs).
|
||||
# Unnormalize actions from [-1, 1] to physical units (QUANTILES) using q01/q99 restored from the checkpoint.
|
||||
output_steps: list[ProcessorStep] = [
|
||||
UnnormalizerProcessorStep(
|
||||
features=config.output_features,
|
||||
norm_map=config.normalization_mapping,
|
||||
norm_map={FeatureType.ACTION: NormalizationMode.QUANTILES},
|
||||
stats=dataset_stats,
|
||||
),
|
||||
AbsoluteActionsProcessorStep(enabled=config.use_relative_actions, relative_step=relative_step),
|
||||
steps.to_cpu,
|
||||
]
|
||||
|
||||
|
||||
@@ -604,6 +604,12 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Optimized autoregressive decoding for FAST tokens using KV Caching.
|
||||
|
||||
Greedy decoding stops once every sequence emits the end-of-action marker. The
|
||||
returned tensor keeps its fixed shape, with positions not generated after the
|
||||
batch-wide stop left zero-filled. Stochastic decoding always runs to
|
||||
``max_decoding_steps`` so early stopping does not change the RNG state used by
|
||||
subsequent calls.
|
||||
"""
|
||||
if max_decoding_steps is None:
|
||||
max_decoding_steps = self.config.max_action_tokens
|
||||
@@ -612,6 +618,12 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
|
||||
device = tokens.device
|
||||
lm_head = self.paligemma_with_expert.paligemma.lm_head
|
||||
|
||||
# detokenize_actions() cuts at the first "|", so greedy decoding can stop once
|
||||
# every sequence has emitted it. Keep stochastic decoding unchanged because
|
||||
# skipping multinomial calls would shift the RNG state for subsequent calls.
|
||||
end_of_action_token_id = self._paligemma_tokenizer.convert_tokens_to_ids("|")
|
||||
finished = torch.zeros(bsize, dtype=torch.bool, device=device) if temperature == 0 else None
|
||||
|
||||
# --- 1. PREFILL PHASE ---
|
||||
# Process Images + Text Prompt + BOS token once to populate the KV cache.
|
||||
|
||||
@@ -663,6 +675,10 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
|
||||
# Initialize storage for generated tokens
|
||||
generated_action_tokens = torch.zeros((bsize, max_decoding_steps), dtype=torch.long, device=device)
|
||||
generated_action_tokens[:, 0] = next_token.squeeze(-1)
|
||||
if finished is not None:
|
||||
finished |= next_token.squeeze(-1) == end_of_action_token_id
|
||||
if bool(finished.all()):
|
||||
return generated_action_tokens
|
||||
|
||||
# Track valid tokens mask (0 for pad, 1 for valid)
|
||||
# We need this to tell the new token what it can attend to (images + text + past actions)
|
||||
@@ -713,6 +729,11 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
|
||||
|
||||
generated_action_tokens[:, t] = next_token.squeeze(-1)
|
||||
|
||||
if finished is not None:
|
||||
finished |= next_token.squeeze(-1) == end_of_action_token_id
|
||||
if bool(finished.all()):
|
||||
break
|
||||
|
||||
return generated_action_tokens
|
||||
|
||||
|
||||
|
||||
@@ -18,20 +18,17 @@ import builtins
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
from importlib.resources import files
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, TypedDict, TypeVar, Unpack
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, TypeVar, Unpack
|
||||
|
||||
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download, save_torch_state_dict
|
||||
from huggingface_hub import hf_hub_download, save_torch_state_dict
|
||||
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
|
||||
from huggingface_hub.errors import HfHubHTTPError
|
||||
from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor
|
||||
from safetensors.torch import load_model as load_model_as_safetensor
|
||||
from torch import Tensor, nn
|
||||
|
||||
from lerobot.__version__ import __version__
|
||||
from lerobot.configs import PreTrainedConfig
|
||||
from lerobot.configs.train import TrainPipelineConfig
|
||||
from lerobot.utils.device_utils import resolve_safetensors_device
|
||||
from lerobot.utils.hub import HubMixin
|
||||
from lerobot.utils.import_utils import _peft_available, require_package
|
||||
@@ -46,72 +43,18 @@ else:
|
||||
get_peft_model = None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lerobot.configs.train import TrainPipelineConfig
|
||||
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||
|
||||
T = TypeVar("T", bound="PreTrainedPolicy")
|
||||
|
||||
|
||||
def _build_card_context(
|
||||
cfg: TrainPipelineConfig | None,
|
||||
dataset_meta: LeRobotDatasetMetadata | None,
|
||||
input_features: dict | None,
|
||||
output_features: dict | None,
|
||||
) -> dict:
|
||||
"""Collect optional data for the model-card template.
|
||||
|
||||
Returns plain values only (no Markdown) — the template in
|
||||
``lerobot/templates/lerobot_modelcard_template.md`` decides how and whether to show
|
||||
each one. Everything is best-effort: anything unavailable is left empty/None and the
|
||||
template simply skips that section, so this never breaks a Hub push.
|
||||
"""
|
||||
context = {
|
||||
"training": None,
|
||||
"input_features": input_features or {},
|
||||
"output_features": output_features or {},
|
||||
"dataset": None,
|
||||
"robot_type": None,
|
||||
"cameras": [],
|
||||
}
|
||||
|
||||
if cfg is not None:
|
||||
optimizer = getattr(cfg, "optimizer", None)
|
||||
context["training"] = {
|
||||
"steps": cfg.steps,
|
||||
"batch_size": cfg.batch_size,
|
||||
"seed": cfg.seed,
|
||||
"optimizer": getattr(optimizer, "type", None) if optimizer else None,
|
||||
"lr": getattr(optimizer, "lr", None) if optimizer else None,
|
||||
"lerobot_version": __version__,
|
||||
}
|
||||
|
||||
if dataset_meta is not None:
|
||||
context["dataset"] = {
|
||||
"repo_id": dataset_meta.repo_id,
|
||||
"episodes": dataset_meta.total_episodes,
|
||||
"frames": dataset_meta.total_frames,
|
||||
"fps": dataset_meta.fps,
|
||||
"tasks": [str(task) for task in dataset_meta.tasks.index],
|
||||
}
|
||||
context["robot_type"] = dataset_meta.robot_type
|
||||
context["cameras"] = [key.split(".")[-1] for key in dataset_meta.camera_keys]
|
||||
|
||||
return context
|
||||
# Pinned far above any policy's total size so save_torch_state_dict always emits exactly one
|
||||
# `model.safetensors` (no shards, no index) — a constant, not a computed byte count.
|
||||
_SINGLE_FILE_SHARD_SIZE = "1TB"
|
||||
|
||||
|
||||
class ActionSelectKwargs(TypedDict, total=False):
|
||||
noise: Tensor | None
|
||||
return_intermediate_predictions: bool
|
||||
|
||||
|
||||
def unpack_action_output(out: Tensor | tuple[Tensor, dict[str, Tensor]]) -> tuple[Tensor, dict[str, Tensor]]:
|
||||
"""Normalize a ``select_action`` / ``predict_action_chunk`` return to ``(action, predictions)``.
|
||||
|
||||
These methods return a bare action ``Tensor`` by default, or a ``(action, predictions)`` tuple when
|
||||
called with ``return_intermediate_predictions=True``. A bare tensor becomes ``(tensor, {})``.
|
||||
"""
|
||||
if isinstance(out, tuple):
|
||||
return out[0], out[1]
|
||||
return out, {}
|
||||
|
||||
|
||||
class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
@@ -122,6 +65,22 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
config_class: None
|
||||
name: None
|
||||
|
||||
# --- declarative parallelism/acceleration surface ----------------------------------------
|
||||
# Module CLASS names forming the FSDP2 wrap units (and, once wired, the activation-
|
||||
# checkpointing units). Resolved onto the accelerate plugin right before
|
||||
# `accelerator.prepare()` by `lerobot.distributed.set_fsdp_wrap_modules`; sharded training
|
||||
# with no wrap source anywhere fails loudly instead of silently wrapping only the root.
|
||||
_fsdp_wrap_modules: ClassVar[list[str] | None] = None
|
||||
# Non-`forward` entry points that must trigger FSDP2 unshard/reshard hooks when called on a
|
||||
# sharded policy (registered post-prepare via `torch.distributed.fsdp
|
||||
# .register_fsdp_forward_method`); calling them unregistered crashes on mixed Tensor/DTensor.
|
||||
_fsdp_forward_methods: ClassVar[tuple[str, ...]] = ("select_action", "predict_action_chunk")
|
||||
# Capability gate for the (future) activation-checkpointing wiring.
|
||||
supports_gradient_checkpointing: ClassVar[bool] = False
|
||||
# Declarative context-parallel plan (diffusers `ContextParallelModelPlan` semantics:
|
||||
# module FQN -> sequence split/gather spec). Reserved for the CP engine round.
|
||||
_cp_plan: ClassVar[dict[str, Any] | None] = None
|
||||
|
||||
def __init__(self, config: PreTrainedConfig, *inputs, **kwargs):
|
||||
super().__init__()
|
||||
if not isinstance(config, PreTrainedConfig):
|
||||
@@ -139,43 +98,33 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
if not getattr(cls, "name", None):
|
||||
raise TypeError(f"Class {cls.__name__} must define 'name'")
|
||||
|
||||
def save_pretrained(
|
||||
self,
|
||||
save_directory: str | Path,
|
||||
*,
|
||||
state_dict: dict[str, Tensor] | None = None,
|
||||
repo_id: str | None = None,
|
||||
push_to_hub: bool = False,
|
||||
card_kwargs: dict | None = None,
|
||||
**push_to_hub_kwargs,
|
||||
) -> str | None:
|
||||
"""Save the policy to a directory (and optionally push to the Hub).
|
||||
def _save_pretrained(self, save_directory: Path) -> None:
|
||||
"""Serialize this policy's parameters (and config) into `save_directory`.
|
||||
|
||||
Overrides `HubMixin.save_pretrained` to add a `state_dict` argument (mirroring
|
||||
`transformers.PreTrainedModel.save_pretrained`). Under FSDP, `self.state_dict()` would
|
||||
return sharded tensors, so the caller gathers the full state dict via a cross-rank
|
||||
collective and passes it here for `_save_pretrained` to write directly.
|
||||
Sharding is handled internally: under FSDP2 the full state dict is gathered through a
|
||||
COLLECTIVE, so when the policy is sharded this method (via `save_pretrained`) must be
|
||||
called on EVERY rank — a rank-0-gated call deadlocks. File writes happen on the main
|
||||
process only, in all layouts (single, DDP, sharded).
|
||||
|
||||
Args:
|
||||
save_directory (Path): Target directory for the policy config (`config.json`) and the
|
||||
safetensors weight file(s).
|
||||
"""
|
||||
save_directory = Path(save_directory)
|
||||
save_directory.mkdir(parents=True, exist_ok=True)
|
||||
self._save_pretrained(save_directory, state_dict=state_dict)
|
||||
if push_to_hub:
|
||||
if repo_id is None:
|
||||
repo_id = save_directory.name
|
||||
return self.push_to_hub(repo_id=repo_id, card_kwargs=card_kwargs, **push_to_hub_kwargs)
|
||||
return None
|
||||
# Lazy imports: the persistence layer pulls in lerobot.distributed only when saving.
|
||||
from lerobot.distributed.checkpoint import full_model_state_dict, is_sharded_module
|
||||
from lerobot.distributed.utils import is_main_process
|
||||
|
||||
def _save_pretrained(self, save_directory: Path, state_dict: dict[str, Tensor] | None = None) -> None:
|
||||
self.config._save_pretrained(save_directory)
|
||||
model_to_save = self.module if hasattr(self, "module") else self
|
||||
if state_dict is None:
|
||||
save_model_as_safetensor(model_to_save, str(save_directory / SAFETENSORS_SINGLE_FILE))
|
||||
if is_sharded_module(model_to_save):
|
||||
logging.info("Gathering the full state dict from all ranks (sharded policy).")
|
||||
state_dict = full_model_state_dict(model_to_save) # collective when sharded; {} off-main
|
||||
if not state_dict or not is_main_process():
|
||||
# Sharded: the gather materializes on the main rank only (emptiness check).
|
||||
# Non-sharded multi-rank (DDP): every rank holds a full dict — the explicit rank
|
||||
# gate prevents N ranks racing on the same files. Single process: never taken.
|
||||
return
|
||||
# A pre-gathered (e.g. FSDP full) state dict was supplied: write it directly.
|
||||
# `save_torch_state_dict` discards shared-tensor duplicates just like `save_model` does;
|
||||
# pin `max_shard_size` above the total size so the output stays a single `model.safetensors`
|
||||
total_bytes = sum(t.numel() * t.element_size() for t in state_dict.values())
|
||||
save_torch_state_dict(state_dict, str(save_directory), max_shard_size=max(total_bytes, 1))
|
||||
self.config._save_pretrained(save_directory)
|
||||
save_torch_state_dict(state_dict, str(save_directory), max_shard_size=_SINGLE_FILE_SHARD_SIZE)
|
||||
|
||||
@classmethod
|
||||
def from_pretrained(
|
||||
@@ -280,34 +229,20 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def predict_action_chunk(
|
||||
self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]
|
||||
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
|
||||
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor:
|
||||
"""Returns the action chunk (for action chunking policies) for a given observation, potentially in batch mode.
|
||||
|
||||
Child classes using action chunking should use this method within `select_action` to form the action chunk
|
||||
cached for selection.
|
||||
|
||||
By default returns just the action `Tensor`. If `return_intermediate_predictions=True`,
|
||||
returns `(action, predictions)` where `predictions` is a (possibly empty) `dict[str, Tensor]`
|
||||
of additional model predictions a policy may expose (e.g. world-model predicted frames).
|
||||
Policies that produce nothing extra may ignore the kwarg.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def select_action(
|
||||
self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]
|
||||
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
|
||||
def select_action(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor:
|
||||
"""Return one action to run in the environment (potentially in batch mode).
|
||||
|
||||
When the model uses a history of observations, or outputs a sequence of actions, this method deals
|
||||
with caching.
|
||||
|
||||
By default returns just the action `Tensor`. If `return_intermediate_predictions=True`,
|
||||
returns `(action, predictions)` where `predictions` is a (possibly empty) `dict[str, Tensor]`
|
||||
of additional model predictions a policy may expose (e.g. world-model predicted frames).
|
||||
Policies that produce nothing extra may ignore the kwarg.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -317,92 +252,39 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
peft_model=None,
|
||||
state_dict: dict[str, Tensor] | None = None,
|
||||
dataset_meta: LeRobotDatasetMetadata | None = None,
|
||||
):
|
||||
api = HfApi()
|
||||
repo_id = api.create_repo(
|
||||
repo_id=self.config.repo_id, private=self.config.private, exist_ok=True
|
||||
).repo_id
|
||||
) -> None:
|
||||
"""Publish this policy to the Hub.
|
||||
|
||||
# Push the files to the repo in a single commit
|
||||
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
||||
saved_path = Path(tmp) / repo_id
|
||||
Deprecated: use :func:`lerobot.common.train_utils.publish_trained_model` instead, which
|
||||
also publishes the pre/post-processors alongside the model.
|
||||
|
||||
if peft_model is not None:
|
||||
# Since PEFT just forwards calls to `push_model_to_hub`, `self` is not the PeftModel wrapper
|
||||
# but the actual policy which is why we need the PEFT model passed to us to save the adapter.
|
||||
# That also means that we need to store the policy config ourselves since PEFT can't.
|
||||
peft_model.save_pretrained(saved_path)
|
||||
self.config.save_pretrained(saved_path)
|
||||
else:
|
||||
# Calls _save_pretrained and stores model tensors
|
||||
self.save_pretrained(saved_path, state_dict=state_dict)
|
||||
Args:
|
||||
cfg (TrainPipelineConfig): The training config; saved as `train_config.json` and
|
||||
used to render the model card.
|
||||
peft_model: The PEFT wrapper when training adapters, whose weights replace the full
|
||||
model weights in the published repo. Defaults to None.
|
||||
state_dict (dict[str, Tensor] | None): Ignored; weights are now gathered internally
|
||||
when the policy is sharded. Defaults to None.
|
||||
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata for the model card,
|
||||
if available. Defaults to None.
|
||||
"""
|
||||
from lerobot.common.train_utils import publish_trained_model
|
||||
|
||||
card = self.generate_model_card(
|
||||
cfg.dataset.repo_id,
|
||||
self.config.type,
|
||||
self.config.license,
|
||||
self.config.tags,
|
||||
cfg=cfg,
|
||||
dataset_meta=dataset_meta,
|
||||
warnings.warn(
|
||||
"PreTrainedPolicy.push_model_to_hub is deprecated and will be removed in a future "
|
||||
"version. Use lerobot.common.train_utils.publish_trained_model(cfg, model, "
|
||||
"preprocessor, postprocessor, dataset_meta) instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if state_dict is not None:
|
||||
warnings.warn(
|
||||
"The `state_dict` argument is ignored: sharded weights are gathered internally "
|
||||
"when the policy is saved.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
card.save(str(saved_path / "README.md"))
|
||||
|
||||
cfg.save_pretrained(saved_path) # Calls _save_pretrained and stores train config
|
||||
|
||||
commit_info = api.upload_folder(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
folder_path=saved_path,
|
||||
commit_message="Upload policy weights, train config and readme",
|
||||
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
|
||||
ignore_patterns=["*.tmp", "*.log"],
|
||||
)
|
||||
|
||||
# Contract: lerobot.jobs.hf.submit_to_hf watches for this exact
|
||||
# "Model pushed to <url>" line to end a remote run early. Keep the wording
|
||||
# and URL format in sync (it falls back to status polling if they drift).
|
||||
logging.info(f"Model pushed to {commit_info.repo_url.url}")
|
||||
|
||||
def generate_model_card(
|
||||
self,
|
||||
dataset_repo_id: str,
|
||||
model_type: str,
|
||||
license: str | None,
|
||||
tags: list[str] | None,
|
||||
cfg: TrainPipelineConfig | None = None,
|
||||
dataset_meta: LeRobotDatasetMetadata | None = None,
|
||||
) -> ModelCard:
|
||||
base_model_mapping = {
|
||||
"smolvla": "lerobot/smolvla_base",
|
||||
"pi0": "lerobot/pi0_base",
|
||||
"pi05": "lerobot/pi05_base",
|
||||
"pi0_fast": "lerobot/pi0fast-base",
|
||||
"xvla": "lerobot/xvla-base",
|
||||
}
|
||||
|
||||
card_data = ModelCardData(
|
||||
license=license or "apache-2.0",
|
||||
library_name="lerobot",
|
||||
pipeline_tag="robotics",
|
||||
tags=list(set(tags or []).union({"robotics", "lerobot", model_type})),
|
||||
model_name=model_type,
|
||||
datasets=dataset_repo_id,
|
||||
base_model=base_model_mapping.get(model_type),
|
||||
)
|
||||
|
||||
context = _build_card_context(
|
||||
cfg, dataset_meta, self.config.input_features, self.config.output_features
|
||||
)
|
||||
# Used by the template to pre-fill commands and the "Fine-tuned from" line.
|
||||
context["policy_repo_id"] = getattr(self.config, "repo_id", None)
|
||||
context["base_model"] = base_model_mapping.get(model_type)
|
||||
|
||||
template_card = (
|
||||
files("lerobot.templates").joinpath("lerobot_modelcard_template.md").read_text(encoding="utf-8")
|
||||
)
|
||||
card = ModelCard.from_template(card_data, template_str=template_card, **context)
|
||||
card.validate()
|
||||
return card
|
||||
publish_trained_model(cfg, self, None, None, dataset_meta, peft_model=peft_model)
|
||||
|
||||
def wrap_with_peft(
|
||||
self,
|
||||
|
||||
@@ -46,10 +46,11 @@ class ActionQueue:
|
||||
Args:
|
||||
cfg (RTCConfig): Configuration for Real-Time Chunking behavior.
|
||||
|
||||
Attributes:
|
||||
queue (Tensor | None): Processed actions for robot rollout (time_steps, action_dim).
|
||||
original_queue (Tensor | None): Original actions for RTC computation (time_steps, action_dim).
|
||||
last_index (int): Current consumption index in the queue.
|
||||
**Attributes**:
|
||||
- **queue** (`Tensor | None`) -- Processed actions for robot rollout (time_steps, action_dim).
|
||||
- **original_queue** (`Tensor | None`) -- Original actions for RTC computation (time_steps,
|
||||
action_dim).
|
||||
- **last_index** (`int`) -- Current consumption index in the queue.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: RTCConfig):
|
||||
@@ -236,21 +237,11 @@ class ActionQueue:
|
||||
if action_index_before_inference is not None:
|
||||
indexes_diff = max(0, self.last_index - action_index_before_inference)
|
||||
if indexes_diff != real_delay:
|
||||
# The latency estimate (`real_delay`) and the number of actions the robot
|
||||
# actually consumed during inference (`indexes_diff`) disagree. This happens
|
||||
# when the queue starved (robot idle) or on the first chunk (nothing consumed
|
||||
# yet). Discarding `real_delay` here would drop actions the arm never executed
|
||||
# and splice the queue `real_delay` steps ahead of the physical pose — a hard
|
||||
# jump/slam, worst on slow policies where `real_delay` is large. Never discard
|
||||
# more than was actually consumed.
|
||||
resolved = min(real_delay, indexes_diff)
|
||||
logger.warning(
|
||||
"Indexes diff != real delay (indexes_diff=%d, real_delay=%d); "
|
||||
"clamping discard to %d to avoid a queue-splice jump.",
|
||||
"Indexes diff is not equal to real delay. indexes_diff=%d, real_delay=%d",
|
||||
indexes_diff,
|
||||
real_delay,
|
||||
resolved,
|
||||
)
|
||||
return resolved
|
||||
return real_delay
|
||||
|
||||
return effective_delay
|
||||
|
||||
@@ -27,19 +27,19 @@ from torch import Tensor
|
||||
class DebugStep:
|
||||
"""Container for debug information from a single denoising step.
|
||||
|
||||
Attributes:
|
||||
step_idx (int): Step index/counter.
|
||||
x_t (Tensor | None): Current latent/state tensor.
|
||||
v_t (Tensor | None): Velocity from denoiser.
|
||||
x1_t (Tensor | None): Denoised prediction (x_t - time * v_t).
|
||||
correction (Tensor | None): Correction gradient tensor.
|
||||
err (Tensor | None): Weighted error term.
|
||||
weights (Tensor | None): Prefix attention weights.
|
||||
guidance_weight (float | Tensor | None): Applied guidance weight.
|
||||
time (float | Tensor | None): Time parameter.
|
||||
inference_delay (int | None): Inference delay parameter.
|
||||
execution_horizon (int | None): Execution horizon parameter.
|
||||
metadata (dict[str, Any]): Additional metadata.
|
||||
**Attributes**:
|
||||
- **step_idx** (`int`) -- Step index/counter.
|
||||
- **x_t** (`Tensor | None`) -- Current latent/state tensor.
|
||||
- **v_t** (`Tensor | None`) -- Velocity from denoiser.
|
||||
- **x1_t** (`Tensor | None`) -- Denoised prediction (x_t - time * v_t).
|
||||
- **correction** (`Tensor | None`) -- Correction gradient tensor.
|
||||
- **err** (`Tensor | None`) -- Weighted error term.
|
||||
- **weights** (`Tensor | None`) -- Prefix attention weights.
|
||||
- **guidance_weight** (`float | Tensor | None`) -- Applied guidance weight.
|
||||
- **time** (`float | Tensor | None`) -- Time parameter.
|
||||
- **inference_delay** (`int | None`) -- Inference delay parameter.
|
||||
- **execution_horizon** (`int | None`) -- Execution horizon parameter.
|
||||
- **metadata** (`dict[str, Any]`) -- Additional metadata.
|
||||
"""
|
||||
|
||||
step_idx: int = 0
|
||||
|
||||
@@ -189,20 +189,15 @@ class DiT(ModelMixin, ConfigMixin):
|
||||
|
||||
@dataclass
|
||||
class ActionModelPreset:
|
||||
"""Default head geometry per `action_model_type`.
|
||||
|
||||
Only the attention geometry is preset; the DiT's width comes from
|
||||
`config.action_hidden_size`, so there is deliberately no `hidden_size` here.
|
||||
"""
|
||||
|
||||
hidden_size: int
|
||||
attention_head_dim: int
|
||||
num_attention_heads: int
|
||||
|
||||
|
||||
DIT_PRESETS = {
|
||||
"DiT-B": ActionModelPreset(attention_head_dim=64, num_attention_heads=12),
|
||||
"DiT-L": ActionModelPreset(attention_head_dim=48, num_attention_heads=32),
|
||||
"DiT-test": ActionModelPreset(attention_head_dim=8, num_attention_heads=2),
|
||||
"DiT-B": ActionModelPreset(hidden_size=768, attention_head_dim=64, num_attention_heads=12),
|
||||
"DiT-L": ActionModelPreset(hidden_size=1536, attention_head_dim=48, num_attention_heads=32),
|
||||
"DiT-test": ActionModelPreset(hidden_size=16, attention_head_dim=8, num_attention_heads=2),
|
||||
}
|
||||
|
||||
|
||||
@@ -253,10 +248,7 @@ class VLAJEPAActionHead(nn.Module):
|
||||
)
|
||||
self.future_tokens = nn.Embedding(config.num_embodied_action_tokens_per_instruction, inner_dim)
|
||||
self.position_embedding = nn.Embedding(
|
||||
max(
|
||||
config.action_max_seq_len,
|
||||
config.chunk_size + config.num_action_tokens_per_timestep + 4,
|
||||
),
|
||||
max(1024, config.chunk_size + config.num_action_tokens_per_timestep + 4),
|
||||
inner_dim,
|
||||
)
|
||||
self.beta_dist = Beta(config.action_noise_beta_alpha, config.action_noise_beta_beta)
|
||||
@@ -267,15 +259,11 @@ class VLAJEPAActionHead(nn.Module):
|
||||
|
||||
def _build_inputs(
|
||||
self,
|
||||
conditioning_tokens: torch.Tensor,
|
||||
actions: torch.Tensor,
|
||||
state: torch.Tensor | None,
|
||||
timesteps: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Build the DiT's own token sequence: [state?, future queries, noisy actions].
|
||||
|
||||
The conditioning tokens are not part of this sequence; they reach the DiT as
|
||||
`encoder_hidden_states` through cross-attention.
|
||||
"""
|
||||
action_features = self.action_encoder(actions, timesteps)
|
||||
pos_ids = torch.arange(action_features.shape[1], device=actions.device)
|
||||
action_features = action_features + self.position_embedding(pos_ids)[None]
|
||||
@@ -294,7 +282,6 @@ class VLAJEPAActionHead(nn.Module):
|
||||
actions: torch.Tensor,
|
||||
state: torch.Tensor | None = None,
|
||||
action_is_pad: torch.Tensor | None = None,
|
||||
reduction: str = "mean",
|
||||
) -> torch.Tensor:
|
||||
noise = torch.randn_like(actions)
|
||||
t = self.sample_time(actions.shape[0], actions.device, actions.dtype)
|
||||
@@ -302,7 +289,7 @@ class VLAJEPAActionHead(nn.Module):
|
||||
velocity = actions - noise
|
||||
t_discretized = (t * self.config.action_num_timestep_buckets).long()
|
||||
|
||||
hidden_states = self._build_inputs(noisy_actions, state, t_discretized)
|
||||
hidden_states = self._build_inputs(conditioning_tokens, noisy_actions, state, t_discretized)
|
||||
pred = self.model(
|
||||
hidden_states=hidden_states,
|
||||
encoder_hidden_states=conditioning_tokens,
|
||||
@@ -315,10 +302,6 @@ class VLAJEPAActionHead(nn.Module):
|
||||
|
||||
loss = F.mse_loss(pred_actions, velocity, reduction="none") # [B, T, action_dim]
|
||||
valid_mask = ~action_is_pad.unsqueeze(-1) # [B, T, 1]
|
||||
if reduction == "none":
|
||||
# Per-sample loss (B,) for sample weighting (RA-BC): mask-average over T and action_dim.
|
||||
per_sample_valid = valid_mask.sum(dim=(1, 2)) * loss.shape[-1] # [B]
|
||||
return (loss * valid_mask).sum(dim=(1, 2)) / per_sample_valid.clamp_min(1)
|
||||
num_valid = valid_mask.sum() * loss.shape[-1]
|
||||
return (loss * valid_mask).sum() / num_valid.clamp_min(1)
|
||||
|
||||
@@ -343,7 +326,7 @@ class VLAJEPAActionHead(nn.Module):
|
||||
timesteps = torch.full(
|
||||
(batch_size,), t_value, device=conditioning_tokens.device, dtype=torch.long
|
||||
)
|
||||
hidden_states = self._build_inputs(actions, state, timesteps)
|
||||
hidden_states = self._build_inputs(conditioning_tokens, actions, state, timesteps)
|
||||
pred = self.model(
|
||||
hidden_states=hidden_states,
|
||||
encoder_hidden_states=conditioning_tokens,
|
||||
|
||||
@@ -58,14 +58,6 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
action_dim: int = 7
|
||||
state_dim: int = 8
|
||||
|
||||
# Relative actions: converts absolute actions to relative (action -= state) during
|
||||
# preprocessing, and reverses it at postprocessing. Requires `state_dim` (OBS_STATE).
|
||||
use_relative_actions: bool = False
|
||||
# Joint names to keep absolute (not converted to relative). Empty list = all dims relative.
|
||||
relative_exclude_joints: list[str] = field(default_factory=lambda: ["gripper"])
|
||||
# Populated at runtime from dataset metadata by make_policy (used to build the exclude mask).
|
||||
action_feature_names: list[str] | None = None
|
||||
|
||||
num_action_tokens_per_timestep: int = 8
|
||||
num_embodied_action_tokens_per_instruction: int = 32
|
||||
num_inference_timesteps: int = 4
|
||||
@@ -80,12 +72,8 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
action_noise_beta_alpha: float = 1.5
|
||||
action_noise_beta_beta: float = 1.0
|
||||
action_noise_s: float = 0.999
|
||||
# Size of the action head's learned position-embedding table. Kept at 1024 to match the
|
||||
# published checkpoints; only raise it if `chunk_size` approaches that.
|
||||
action_max_seq_len: int = 1024
|
||||
# Unused. Retained because the published checkpoints serialize it and draccus rejects
|
||||
# config.json keys that the dataclass no longer declares.
|
||||
num_target_vision_tokens: int = 32
|
||||
action_max_seq_len: int = 1024
|
||||
|
||||
# total video frames loaded per sample
|
||||
num_video_frames: int = 8
|
||||
@@ -94,34 +82,15 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
predictor_mlp_ratio: float = 4.0
|
||||
predictor_dropout: float = 0.0
|
||||
world_model_loss_weight: float = 0.1
|
||||
# Temporal tubelet size of the JEPA encoder (e.g. 2 for vjepa2-vitl-fpc64-256). When the
|
||||
# world model is enabled the encoder's own `config.tubelet_size` is authoritative and this
|
||||
# is only used for the `num_video_frames` sanity check below.
|
||||
jepa_tubelet_size: int = 2
|
||||
# Number of camera views the world model's predictor is built for; its embedding width is
|
||||
# `encoder_hidden_size * world_model_num_views`, so it is baked into the checkpoint shapes.
|
||||
# Views beyond this are trimmed and missing ones are padded with the first view.
|
||||
# `None` falls back to `jepa_tubelet_size`, which is what the published checkpoints
|
||||
# (trained before the two meanings were separated) actually encode.
|
||||
world_model_num_views: int | None = None
|
||||
jepa_tubelet_size: int = 2 # must match the encoder (e.g. 2 for vjepa2-vitl-fpc64-256)
|
||||
repeated_diffusion_steps: int = 8 # independent noise draws per batch item (CogACT-style)
|
||||
|
||||
resize_images_to: tuple[int, int] | None = None
|
||||
# Gripper post-processing, ported from the starVLA LIBERO eval loop. OFF by default
|
||||
# because it is only correct for LIBERO's action convention: `pre_snap` writes {0, 1}
|
||||
# into normalized space and `binarize` then thresholds the *unnormalized* value at
|
||||
# `gripper_threshold`, so a gripper whose physical range is not roughly [0, 1] (degrees,
|
||||
# mm, [0, 100]) gets pinned to a constant. Enable them only for LIBERO-style setups.
|
||||
binarize_gripper_action: bool = False
|
||||
pre_snap_gripper_action: bool = False
|
||||
binarize_gripper_action: bool = True
|
||||
pre_snap_gripper_action: bool = True
|
||||
clip_normalized_actions: bool = True
|
||||
# Index of the gripper in the action vector. Prefer leaving this at its default and
|
||||
# setting `gripper_joint_names`, which resolves the index from dataset metadata.
|
||||
gripper_dim: int = 6
|
||||
gripper_threshold: float = 0.5
|
||||
# Action-dimension names identifying the gripper. When these match `action_feature_names`,
|
||||
# the resolved index wins over `gripper_dim`.
|
||||
gripper_joint_names: list[str] = field(default_factory=lambda: ["gripper"])
|
||||
torch_dtype: str = "bfloat16"
|
||||
|
||||
optimizer_lr: float = 1e-4
|
||||
@@ -146,27 +115,6 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
f"({self.jepa_tubelet_size}) to have at least one context and one GT temporal position."
|
||||
)
|
||||
|
||||
@property
|
||||
def num_world_model_views(self) -> int:
|
||||
"""Camera views the world model predictor is built for (see `world_model_num_views`)."""
|
||||
return self.world_model_num_views or self.jepa_tubelet_size
|
||||
|
||||
@property
|
||||
def resolved_gripper_dim(self) -> int:
|
||||
"""Gripper index, resolved from `action_feature_names` when possible.
|
||||
|
||||
Falls back to the raw `gripper_dim` when dataset metadata is unavailable (for example
|
||||
when a saved processor pipeline is rebuilt without a dataset attached).
|
||||
"""
|
||||
if not self.action_feature_names or not self.gripper_joint_names:
|
||||
return self.gripper_dim
|
||||
wanted = [name.lower() for name in self.gripper_joint_names if name]
|
||||
for index, name in enumerate(self.action_feature_names):
|
||||
lowered = str(name).lower()
|
||||
if any(token == lowered or token in lowered for token in wanted):
|
||||
return index
|
||||
return self.gripper_dim
|
||||
|
||||
def validate_features(self) -> None:
|
||||
if not self.image_features:
|
||||
raise ValueError("VLAJEPA requires at least one visual input feature.")
|
||||
@@ -175,16 +123,6 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
self.action_dim = self.action_feature.shape[0]
|
||||
if self.robot_state_feature is not None:
|
||||
self.state_dim = self.robot_state_feature.shape[0]
|
||||
# The gripper steps silently no-op when the index is out of range, which reads as
|
||||
# "binarization ran" while nothing happened. Fail loudly at construction instead.
|
||||
if self.pre_snap_gripper_action or self.binarize_gripper_action:
|
||||
gripper_dim = self.resolved_gripper_dim
|
||||
if gripper_dim >= self.action_dim:
|
||||
raise ValueError(
|
||||
f"`gripper_dim` ({gripper_dim}) is out of range for a {self.action_dim}-dim "
|
||||
f"action. Set `gripper_dim`/`gripper_joint_names` to the real gripper index, "
|
||||
f"or disable `pre_snap_gripper_action`/`binarize_gripper_action`."
|
||||
)
|
||||
|
||||
def set_dataset_feature_metadata(self, dataset_features: dict[str, Any]) -> None:
|
||||
"""Add `observation.state` to `input_features` if missing, so it gets normalized."""
|
||||
@@ -212,18 +150,9 @@ class VLAJEPAConfig(PreTrainedConfig):
|
||||
|
||||
@property
|
||||
def observation_delta_indices(self) -> list[int]:
|
||||
# Only the world model consumes frames past index 0, so without it asking for the full
|
||||
# window would decode `num_video_frames` frames per camera per sample and drop them.
|
||||
if not self.enable_world_model:
|
||||
return [0]
|
||||
# matches original repo's observation_indices=list(range(video_horizon)) when the chunk
|
||||
# fits within video_horizon frames. When chunk_size is longer (e.g. folding's 30-step
|
||||
# chunk vs 8 video frames), spread the frames evenly across the chunk instead of
|
||||
# clustering them at the start, so the world model sees dynamics over the whole horizon.
|
||||
if self.num_video_frames >= self.chunk_size:
|
||||
return list(range(self.num_video_frames))
|
||||
stride = (self.chunk_size - 1) // (self.num_video_frames - 1)
|
||||
return [i * stride for i in range(self.num_video_frames)]
|
||||
# load video_horizon frames starting from current timestep: [t, t+1, ..., t+video_horizon-1]
|
||||
# matches original repo's observation_indices=list(range(video_horizon))
|
||||
return list(range(self.num_video_frames))
|
||||
|
||||
@property
|
||||
def action_delta_indices(self) -> list[int]:
|
||||
|
||||
@@ -17,17 +17,17 @@ from __future__ import annotations
|
||||
import logging
|
||||
from collections import deque
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F # noqa: N812
|
||||
from safetensors.torch import load_file
|
||||
from torch import Tensor, nn
|
||||
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy, T
|
||||
from lerobot.policies.utils import log_model_loading_keys, populate_queues
|
||||
from lerobot.policies.utils import populate_queues
|
||||
from lerobot.utils.constants import ACTION, OBS_STATE
|
||||
from lerobot.utils.device_utils import is_amp_available, resolve_safetensors_device
|
||||
from lerobot.utils.device_utils import is_amp_available
|
||||
from lerobot.utils.import_utils import _transformers_available, require_package
|
||||
|
||||
if TYPE_CHECKING or _transformers_available:
|
||||
@@ -51,11 +51,6 @@ def _get_autocast_context(device_type: str, dtype: torch.dtype = torch.bfloat16)
|
||||
"""
|
||||
if not is_amp_available(device_type):
|
||||
return nullcontext()
|
||||
if device_type == "cpu" and dtype not in (torch.bfloat16, torch.float16):
|
||||
# CPU autocast does not implement float32 (used here to force the action head back to
|
||||
# full precision). `torch.autocast` accepts it, then warns and disables itself on *every*
|
||||
# call, so short-circuit to keep the same behavior without the per-forward log spam.
|
||||
return nullcontext()
|
||||
if device_type == "cuda" and dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
|
||||
dtype = torch.float16
|
||||
return torch.autocast(device_type=device_type, dtype=dtype)
|
||||
@@ -112,7 +107,7 @@ class VLAJEPAModel(nn.Module):
|
||||
torch_dtype=self.qwen._get_torch_dtype(config.torch_dtype),
|
||||
)
|
||||
self.video_processor = AutoVideoProcessor.from_pretrained(config.jepa_encoder_name)
|
||||
num_views = config.num_world_model_views
|
||||
num_views = config.jepa_tubelet_size
|
||||
tubelet_size = self.video_encoder.config.tubelet_size
|
||||
image_size = getattr(self.video_encoder.config, "image_size", None)
|
||||
if image_size is None:
|
||||
@@ -130,7 +125,6 @@ class VLAJEPAModel(nn.Module):
|
||||
num_heads=config.predictor_num_heads,
|
||||
mlp_ratio=config.predictor_mlp_ratio,
|
||||
num_action_tokens_per_step=config.num_action_tokens_per_timestep,
|
||||
dropout=config.predictor_dropout,
|
||||
)
|
||||
else:
|
||||
self.video_encoder = None
|
||||
@@ -165,13 +159,6 @@ class VLAJEPAModel(nn.Module):
|
||||
`output_hidden_states=True` is post-norm (tied to `last_hidden_state` via
|
||||
`@capture_outputs`). A forward hook on `language_model.layers[-1]` recovers
|
||||
the correct pre-RMSNorm state, matching the training-time representation.
|
||||
|
||||
Calls the inner `Qwen3VLModel` rather than the `Qwen3VLForConditionalGeneration`
|
||||
wrapper: only the hooked hidden state is used, and the wrapper's forward ends in
|
||||
`lm_head(hidden_states[:, slice(None), :])` because `logits_to_keep` defaults to 0,
|
||||
so it would build full-sequence logits over the 151936-token vocab and discard them
|
||||
(~3.4 GB in bf16 at batch 8). The wrapper stays as `self.qwen.model` so `lm_head`
|
||||
keeps its checkpoint key; only this forward path skips it.
|
||||
"""
|
||||
captured: list[torch.Tensor] = []
|
||||
|
||||
@@ -182,7 +169,12 @@ class VLAJEPAModel(nn.Module):
|
||||
last_layer = self.qwen.model.model.language_model.layers[-1]
|
||||
handle = last_layer.register_forward_hook(_hook)
|
||||
try:
|
||||
self.qwen.model.model(**qwen_inputs)
|
||||
self.qwen.model(
|
||||
**qwen_inputs,
|
||||
output_hidden_states=False,
|
||||
output_attentions=False,
|
||||
return_dict=True,
|
||||
)
|
||||
finally:
|
||||
handle.remove()
|
||||
|
||||
@@ -192,7 +184,7 @@ class VLAJEPAModel(nn.Module):
|
||||
|
||||
def _encode_qwen(
|
||||
self, images: list[list[Tensor]], instructions: list[str], *, need_action_tokens: bool
|
||||
) -> tuple[Tensor, Tensor | None]:
|
||||
) -> tuple[Tensor, Tensor, Tensor | None]:
|
||||
"""Run Qwen and gather the embodied-action (and optionally action) token hidden states."""
|
||||
qwen_inputs = self.qwen.build_inputs(
|
||||
images=images,
|
||||
@@ -219,14 +211,10 @@ class VLAJEPAModel(nn.Module):
|
||||
)
|
||||
return embodied_action_tokens, action_tokens
|
||||
|
||||
def _world_model_loss(self, videos: Tensor, action_tokens: Tensor, reduction: str = "mean") -> Tensor:
|
||||
"""JEPA encode + predictor L1 loss. `videos` is [B, V, T, C, H, W] float in [0, 1].
|
||||
|
||||
`reduction="none"` returns a per-sample loss (B,) for sample weighting (RA-BC);
|
||||
"mean" returns the scalar loss.
|
||||
"""
|
||||
def _world_model_loss(self, videos: Tensor, action_tokens: Tensor) -> Tensor:
|
||||
"""JEPA encode + predictor L1 loss. `videos` is [B, V, T, C, H, W] float in [0, 1]."""
|
||||
# Match the world model's expected view count: pad with the first view, or trim extras.
|
||||
num_views = self.config.num_world_model_views
|
||||
num_views = self.config.jepa_tubelet_size
|
||||
if videos.shape[1] < num_views:
|
||||
missing = num_views - videos.shape[1]
|
||||
videos = torch.cat([videos, videos[:, :1].repeat(1, missing, 1, 1, 1, 1)], dim=1)
|
||||
@@ -245,24 +233,14 @@ class VLAJEPAModel(nn.Module):
|
||||
|
||||
with torch.no_grad():
|
||||
video_embeddings = self.video_encoder.get_vision_features(pixel_values_videos=video_pixels)
|
||||
# Merge views: [B*V, N, H] -> [B, N, V*H].
|
||||
# `flat` above flattens (B, V) row-major, so rows run view-fastest:
|
||||
# (s0v0, s0v1, ..., s1v0, ...). A `chunk(chunks=v, dim=0)` + `cat(dim=2)` would
|
||||
# instead assume view-slowest ordering and concatenate features belonging to
|
||||
# *different samples* — shape-valid, so it fails silently. Regroup on (B, V).
|
||||
n_tokens, hidden = video_embeddings.shape[1], video_embeddings.shape[2]
|
||||
video_embeddings = (
|
||||
video_embeddings.view(b, v, n_tokens, hidden)
|
||||
.permute(0, 2, 1, 3)
|
||||
.reshape(b, n_tokens, v * hidden)
|
||||
)
|
||||
# Merge views: [B*V, ...] -> [B, ..., V*embed_dim]
|
||||
video_embeddings = torch.cat(torch.chunk(video_embeddings, chunks=v, dim=0), dim=2)
|
||||
|
||||
tubelet_size = self.video_encoder.config.tubelet_size
|
||||
# num_video_frames raw frames → t_enc_total temporal positions after tubelet compression
|
||||
t_enc_total = self.config.num_video_frames // tubelet_size
|
||||
if t_enc_total < 2:
|
||||
zero_shape = (video_embeddings.shape[0],) if reduction == "none" else ()
|
||||
return torch.zeros(zero_shape, device=video_embeddings.device)
|
||||
return torch.zeros((), device=video_embeddings.device)
|
||||
|
||||
# Shift-by-one JEPA split: input_states = positions 0..T-2, gt_states = positions 1..T-1
|
||||
t_enc_ctx = t_enc_total - 1
|
||||
@@ -278,10 +256,6 @@ class VLAJEPAModel(nn.Module):
|
||||
predicted_states = self.video_predictor(
|
||||
input_states.float(), action_tokens[:, :expected_actions].float()
|
||||
)
|
||||
if reduction == "none":
|
||||
# Per-sample loss (B,): mean over all non-batch dims (tokens, feature).
|
||||
l = F.l1_loss(predicted_states, gt_states.float(), reduction="none")
|
||||
return l.mean(dim=tuple(range(1, l.ndim)))
|
||||
return F.l1_loss(predicted_states, gt_states.float(), reduction="mean")
|
||||
|
||||
def _action_loss(
|
||||
@@ -290,27 +264,17 @@ class VLAJEPAModel(nn.Module):
|
||||
actions: Tensor,
|
||||
state: Tensor | None,
|
||||
action_is_pad: Tensor | None,
|
||||
reduction: str = "mean",
|
||||
) -> Tensor:
|
||||
"""Flow-matching action-head loss, repeated over `repeated_diffusion_steps`.
|
||||
|
||||
`reduction="none"` returns a per-sample loss (B,) — the `repeated_diffusion_steps`
|
||||
independent noise draws are averaged back per original sample — for RA-BC weighting.
|
||||
"""
|
||||
"""Flow-matching action-head loss, repeated over `repeated_diffusion_steps`."""
|
||||
device_type = next(self.parameters()).device.type
|
||||
with _get_autocast_context(device_type, torch.float32):
|
||||
r = self.config.repeated_diffusion_steps
|
||||
horizon = self.config.chunk_size
|
||||
b = embodied_action_tokens.shape[0]
|
||||
actions_target = actions[:, -horizon:, :].to(torch.float32).repeat(r, 1, 1)
|
||||
embodied = embodied_action_tokens.repeat(r, 1, 1)
|
||||
state_rep = state.to(embodied_action_tokens.dtype).repeat(r, 1, 1) if state is not None else None
|
||||
pad_rep = action_is_pad[:, -horizon:].repeat(r, 1) if action_is_pad is not None else None
|
||||
loss = self.action_model(embodied, actions_target, state_rep, pad_rep, reduction=reduction)
|
||||
if reduction == "none":
|
||||
# `.repeat(r, 1, 1)` tiles as [rep0(b0..b_{B-1}), rep1(...), ...] → (r, B); mean over reps.
|
||||
return loss.view(r, b).mean(dim=0)
|
||||
return loss
|
||||
return self.action_model(embodied, actions_target, state_rep, pad_rep)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -320,29 +284,21 @@ class VLAJEPAModel(nn.Module):
|
||||
actions: Tensor | None = None,
|
||||
state: Tensor | None = None,
|
||||
action_is_pad: Tensor | None = None,
|
||||
reduction: str = "mean",
|
||||
) -> dict[str, Tensor]:
|
||||
"""Native forward: Qwen encode → optional world-model loss → optional action-head loss.
|
||||
|
||||
`reduction="none"` makes both loss terms per-sample (B,) for RA-BC weighting; "mean"
|
||||
returns scalar losses.
|
||||
"""
|
||||
"""Native forward: Qwen encode → optional world-model loss → optional action-head loss."""
|
||||
embodied_action_tokens, action_tokens = self._encode_qwen(
|
||||
images, instructions, need_action_tokens=self.config.enable_world_model
|
||||
)
|
||||
|
||||
if self.config.enable_world_model and videos is not None:
|
||||
wm_loss = self._world_model_loss(videos, action_tokens, reduction=reduction)
|
||||
wm_loss = self._world_model_loss(videos, action_tokens)
|
||||
else:
|
||||
zero_shape = (embodied_action_tokens.shape[0],) if reduction == "none" else ()
|
||||
wm_loss = torch.zeros(zero_shape, device=embodied_action_tokens.device)
|
||||
wm_loss = torch.zeros((), device=embodied_action_tokens.device)
|
||||
|
||||
if actions is None:
|
||||
return {"wm_loss": wm_loss}
|
||||
|
||||
action_loss = self._action_loss(
|
||||
embodied_action_tokens, actions, state, action_is_pad, reduction=reduction
|
||||
)
|
||||
action_loss = self._action_loss(embodied_action_tokens, actions, state, action_is_pad)
|
||||
return {"action_loss": action_loss, "wm_loss": wm_loss * self.config.world_model_loss_weight}
|
||||
|
||||
# ---- Native predict_action (follows original VLA_JEPA.predict_action) ----
|
||||
@@ -428,19 +384,12 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
||||
batch_size = batch[image_keys[0]].shape[0]
|
||||
|
||||
# Current-frame image per view ([B, C, H, W]); regroup per sample for Qwen messages.
|
||||
# Resize to config.resize_images_to (as predict_action does) so training and inference feed
|
||||
# Qwen the same resolution. Critical for memory: native camera frames (e.g. 720x1280) would
|
||||
# otherwise blow up the Qwen3-VL vision-tower attention (patch count grows with resolution).
|
||||
resize_hw = tuple(self.config.resize_images_to) if self.config.resize_images_to else None
|
||||
frames = []
|
||||
for key in image_keys:
|
||||
t = batch[key]
|
||||
if t.ndim == 5: # [B, T, C, H, W] -> current observation (delta=0)
|
||||
t = t[:, 0]
|
||||
px = self.model.qwen.to_pixel_values(t) # [B, C, H, W]
|
||||
if resize_hw is not None and tuple(px.shape[-2:]) != resize_hw:
|
||||
px = F.interpolate(px.float(), size=resize_hw, mode="area")
|
||||
frames.append(px)
|
||||
frames.append(self.model.qwen.to_pixel_values(t))
|
||||
images = [[frame[b] for frame in frames] for b in range(batch_size)]
|
||||
|
||||
tasks = batch.get("task")
|
||||
@@ -456,26 +405,7 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
||||
# Videos [B, V, T, C, H, W] - only assembled during training when the world model consumes them.
|
||||
if self.model.config.enable_world_model and training:
|
||||
views = [batch[k].unsqueeze(1) if batch[k].ndim == 4 else batch[k] for k in image_keys]
|
||||
# The world model consumes a SINGLE stacked [B, V, T, C, H, W] tensor, so all camera
|
||||
# views must share a spatial size. Cameras can differ (e.g. base 480x640 vs wrist
|
||||
# 720x1280), so resize each view to a common size before stacking — config.resize_images_to
|
||||
# if set (same target predict_action uses), else the first view's size (a no-op when all
|
||||
# views already match, preserving behavior for single-resolution datasets). The vjepa video
|
||||
# processor does the final resize to the encoder resolution downstream.
|
||||
cfg = self.model.config
|
||||
target_hw = tuple(cfg.resize_images_to) if cfg.resize_images_to else tuple(views[0].shape[-2:])
|
||||
resized = []
|
||||
for v in views:
|
||||
if tuple(v.shape[-2:]) != target_hw:
|
||||
b, t, c = v.shape[0], v.shape[1], v.shape[2]
|
||||
v = F.interpolate(
|
||||
v.reshape(b * t, c, v.shape[3], v.shape[4]).float(),
|
||||
size=target_hw,
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
).reshape(b, t, c, target_hw[0], target_hw[1])
|
||||
resized.append(v)
|
||||
inputs["videos"] = self.model.qwen.to_pixel_values(torch.stack(resized, dim=1))
|
||||
inputs["videos"] = self.model.qwen.to_pixel_values(torch.stack(views, dim=1))
|
||||
|
||||
actions = batch.get(ACTION)
|
||||
if actions is not None:
|
||||
@@ -494,17 +424,15 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
||||
|
||||
# ---- LeRobot Policy Interface ----
|
||||
|
||||
def forward(self, batch: dict[str, Tensor], reduction: str = "mean") -> tuple[Tensor, dict]:
|
||||
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict]:
|
||||
"""LeRobot train forward: convert → native forward → aggregate losses."""
|
||||
native_output = self.model.forward(
|
||||
**self._prepare_model_inputs(batch, training=True), reduction=reduction
|
||||
)
|
||||
native_output = self.model.forward(**self._prepare_model_inputs(batch, training=True))
|
||||
|
||||
ref = next(iter(native_output.values()))
|
||||
zero = torch.zeros_like(ref)
|
||||
zero = torch.zeros((), device=ref.device, dtype=ref.dtype)
|
||||
total_loss = native_output.get("action_loss", zero) + native_output.get("wm_loss", zero)
|
||||
logs = {k: v.detach().mean().item() for k, v in native_output.items()}
|
||||
logs["loss"] = total_loss.detach().mean().item()
|
||||
logs = {k: v.detach().item() for k, v in native_output.items()}
|
||||
logs["loss"] = total_loss.detach().item()
|
||||
return total_loss, logs
|
||||
|
||||
def get_optim_params(self) -> dict:
|
||||
@@ -530,16 +458,23 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
||||
self._queues[ACTION].extend(actions.transpose(0, 1)[: self.config.n_action_steps])
|
||||
return self._queues[ACTION].popleft()
|
||||
|
||||
@classmethod
|
||||
def from_pretrained(
|
||||
cls: type[T],
|
||||
pretrained_name_or_path: str | Path,
|
||||
**kwargs,
|
||||
):
|
||||
return super().from_pretrained(pretrained_name_or_path, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _load_as_safetensor(cls, model: T, model_file: str, map_location: str, strict: bool) -> T:
|
||||
reinit_prefixes = model.config.reinit_modules
|
||||
if not reinit_prefixes:
|
||||
return super()._load_as_safetensor(model, model_file, map_location, strict)
|
||||
|
||||
# `resolve_safetensors_device` is what keeps every rank from materializing the whole
|
||||
# checkpoint on GPU 0: safetensors maps the bare string "cuda" to cuda:0 regardless of
|
||||
# torch.cuda.current_device(), and `config.device` is exactly that bare string.
|
||||
state_dict = load_file(model_file, device=resolve_safetensors_device(map_location))
|
||||
from safetensors.torch import load_file
|
||||
|
||||
state_dict = load_file(model_file, device=map_location)
|
||||
current = model.state_dict()
|
||||
|
||||
reinitialized: list[str] = []
|
||||
@@ -563,17 +498,8 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
||||
f"(randomly re-initialised):\n " + "\n ".join(reinitialized)
|
||||
)
|
||||
|
||||
# Deliberately non-strict: the reinitialized tensors above are *expected* to be missing.
|
||||
# `strict` still has to mean something, so enforce it on everything else.
|
||||
from lerobot.policies.utils import log_model_loading_keys
|
||||
|
||||
missing_keys, unexpected_keys = model.load_state_dict(filtered, strict=False)
|
||||
if strict:
|
||||
reinit_keys = {entry.split(":", 1)[0] for entry in reinitialized}
|
||||
unaccounted = [k for k in missing_keys if k not in reinit_keys]
|
||||
if unaccounted or unexpected_keys:
|
||||
raise RuntimeError(
|
||||
f"Error(s) in loading state_dict for {type(model).__name__} with strict=True: "
|
||||
f"missing keys not covered by `reinit_modules` {unaccounted}, "
|
||||
f"unexpected keys {list(unexpected_keys)}."
|
||||
)
|
||||
log_model_loading_keys(missing_keys, unexpected_keys)
|
||||
return model
|
||||
|
||||
@@ -14,93 +14,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F # noqa: N812
|
||||
|
||||
from lerobot.configs import PipelineFeatureType, PolicyFeature
|
||||
from lerobot.configs.types import NormalizationMode
|
||||
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig
|
||||
from lerobot.processor import (
|
||||
AbsoluteActionsProcessorStep,
|
||||
EnvTransition,
|
||||
ObservationProcessorStep,
|
||||
PolicyAction,
|
||||
PolicyProcessorPipeline,
|
||||
ProcessorStep,
|
||||
ProcessorStepRegistry,
|
||||
RelativeActionsProcessorStep,
|
||||
TransitionKey,
|
||||
UnnormalizerProcessorStep,
|
||||
make_default_policy_processor_steps,
|
||||
make_policy_processor_pipelines,
|
||||
)
|
||||
from lerobot.utils.constants import ACTION
|
||||
|
||||
|
||||
@ProcessorStepRegistry.register(name="vla_jepa_image_prep")
|
||||
class ImagePrepProcessorStep(ObservationProcessorStep):
|
||||
"""Prepares image observations for the VLA-JEPA model: float cast, 1->3 channel expand, resize.
|
||||
|
||||
This makes explicit (in the serialized pipeline) the image prep the model used to do
|
||||
internally. The model keeps the same operations as idempotent guards, so:
|
||||
- checkpoints saved WITHOUT this step (older uploads) are unaffected — the model still
|
||||
does the prep;
|
||||
- checkpoints saved WITH this step get it done here, and the model-side guards no-op.
|
||||
|
||||
Mirrors `Qwen3VLInterface.to_pixel_values` + the `F.interpolate(mode="area")` resize in
|
||||
`VLAJEPAPolicy._prepare_model_inputs`/`predict_action`. Deliberately does NOT clamp (the
|
||||
model path doesn't), so values stay bit-identical. Handles [C,H,W], [B,C,H,W]/[T,C,H,W]
|
||||
and [B,T,C,H,W] image tensors.
|
||||
"""
|
||||
|
||||
def __init__(self, resize_to: tuple[int, int] | None = None, expand_channels: bool = True):
|
||||
self.resize_to = tuple(resize_to) if resize_to is not None else None
|
||||
self.expand_channels = expand_channels
|
||||
|
||||
def observation(self, observation: dict) -> dict:
|
||||
new_observation = dict(observation)
|
||||
for key in observation:
|
||||
if "image" not in key:
|
||||
continue
|
||||
image = observation[key].float()
|
||||
if self.expand_channels and image.shape[-3] == 1:
|
||||
repeats = [1] * image.ndim
|
||||
repeats[-3] = 3
|
||||
image = image.repeat(*repeats)
|
||||
if self.resize_to is not None and tuple(image.shape[-2:]) != self.resize_to:
|
||||
device = image.device
|
||||
# NOTE: no "area" kernel on mps; resize on cpu then move back.
|
||||
if device.type == "mps":
|
||||
image = image.cpu()
|
||||
lead = image.shape[:-3]
|
||||
c, h, w = image.shape[-3:]
|
||||
flat = image.reshape(-1, c, h, w)
|
||||
flat = F.interpolate(flat, size=self.resize_to, mode="area")
|
||||
image = flat.reshape(*lead, c, *self.resize_to).to(device)
|
||||
new_observation[key] = image
|
||||
return new_observation
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
return {
|
||||
"resize_to": list(self.resize_to) if self.resize_to is not None else None,
|
||||
"expand_channels": self.expand_channels,
|
||||
}
|
||||
|
||||
def transform_features(self, features):
|
||||
for key in features[PipelineFeatureType.OBSERVATION]:
|
||||
if "image" not in key:
|
||||
continue
|
||||
feat = features[PipelineFeatureType.OBSERVATION][key]
|
||||
# Match `to_pixel_values`: only a single channel is expanded to 3.
|
||||
nb_channel = 3 if (self.expand_channels and feat.shape[0] == 1) else feat.shape[0]
|
||||
spatial = self.resize_to if self.resize_to is not None else tuple(feat.shape[1:])
|
||||
features[PipelineFeatureType.OBSERVATION][key] = PolicyFeature(
|
||||
type=feat.type, shape=(nb_channel, *spatial)
|
||||
)
|
||||
return features
|
||||
|
||||
|
||||
@ProcessorStepRegistry.register(name="vla_jepa_clip_actions")
|
||||
@@ -142,11 +71,6 @@ class PreSnapGripperProcessorStep(ProcessorStep):
|
||||
transition[TransitionKey.ACTION] = a
|
||||
return transition
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
# Without this the base class serializes `{}` and a reloaded pipeline silently reverts
|
||||
# to the class defaults, discarding whatever the training config set.
|
||||
return {"gripper_dim": self.gripper_dim, "threshold": self.threshold}
|
||||
|
||||
def transform_features(self, features):
|
||||
return features
|
||||
|
||||
@@ -157,13 +81,6 @@ class BinarizeGripperProcessorStep(ProcessorStep):
|
||||
|
||||
Maps continuous value to {-1, 1}: > threshold → -1, <= threshold → 1 (matches starVLA convention).
|
||||
Only applied when action has more dimensions than gripper_dim.
|
||||
|
||||
WARNING: this step runs *below* the unnormalizer, so `threshold` is compared against the
|
||||
gripper's **physical** value while its default of 0.5 comes from the model's [0, 1]/±1
|
||||
convention. It is only meaningful when the gripper's physical range is roughly [0, 1].
|
||||
For a gripper in degrees, mm or [0, 100], every unnormalized value exceeds 0.5 and the
|
||||
output collapses to the constant -1. `make_vla_jepa_pre_post_processors` warns when the
|
||||
dataset stats say that is the case.
|
||||
"""
|
||||
|
||||
def __init__(self, gripper_dim: int = 6, threshold: float = 0.5):
|
||||
@@ -179,57 +96,10 @@ class BinarizeGripperProcessorStep(ProcessorStep):
|
||||
transition[TransitionKey.ACTION] = a
|
||||
return transition
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
# See PreSnapGripperProcessorStep.get_config: `{}` would reload as the class defaults.
|
||||
return {"gripper_dim": self.gripper_dim, "threshold": self.threshold}
|
||||
|
||||
def transform_features(self, features):
|
||||
return features
|
||||
|
||||
|
||||
def _warn_if_gripper_steps_are_misconfigured(
|
||||
config: VLAJEPAConfig,
|
||||
gripper_dim: int,
|
||||
dataset_stats: dict[str, dict[str, torch.Tensor]] | None,
|
||||
) -> None:
|
||||
"""Warn when the gripper post-steps would pin the gripper to a constant.
|
||||
|
||||
`BinarizeGripperProcessorStep` thresholds the *unnormalized* gripper at
|
||||
`gripper_threshold` (default 0.5, a number from the model's [0, 1] convention). When the
|
||||
dataset says the gripper's physical range sits well above that, every unnormalized value
|
||||
lands on the same side of the threshold and the commanded gripper never moves. The stats
|
||||
needed to detect that are already here, so say so rather than letting it look like it worked.
|
||||
"""
|
||||
if not (config.pre_snap_gripper_action or config.binarize_gripper_action):
|
||||
return
|
||||
action_stats = (dataset_stats or {}).get(ACTION)
|
||||
if not action_stats or "min" not in action_stats or "max" not in action_stats:
|
||||
return
|
||||
try:
|
||||
low = float(action_stats["min"][gripper_dim])
|
||||
high = float(action_stats["max"][gripper_dim])
|
||||
except (IndexError, TypeError, ValueError):
|
||||
return
|
||||
threshold = config.gripper_threshold
|
||||
# `pre_snap` writes {0, 1} in normalized space, which unnormalizes to the midpoint and the
|
||||
# max. Both landing on the same side of the threshold means a constant output.
|
||||
midpoint = (low + high) / 2.0
|
||||
if (midpoint > threshold) == (high > threshold):
|
||||
name = (
|
||||
config.action_feature_names[gripper_dim]
|
||||
if config.action_feature_names and gripper_dim < len(config.action_feature_names)
|
||||
else f"dim {gripper_dim}"
|
||||
)
|
||||
logging.warning(
|
||||
f"vla_jepa gripper post-processing looks misconfigured: action {name} has a physical "
|
||||
f"range of [{low:.3g}, {high:.3g}], and `gripper_threshold={threshold}` is compared "
|
||||
f"against that unnormalized value. Both {midpoint:.3g} and {high:.3g} fall on the same "
|
||||
f"side of it, so the commanded gripper will be constant. Set `gripper_threshold` in "
|
||||
f"the gripper's own units, or set `pre_snap_gripper_action=false` and "
|
||||
f"`binarize_gripper_action=false` (the defaults) unless you are running LIBERO."
|
||||
)
|
||||
|
||||
|
||||
def make_vla_jepa_pre_post_processors(
|
||||
config: VLAJEPAConfig,
|
||||
dataset_stats: dict[str, dict[str, torch.Tensor]] | None = None,
|
||||
@@ -239,47 +109,18 @@ def make_vla_jepa_pre_post_processors(
|
||||
]:
|
||||
features = {**config.input_features, **config.output_features}
|
||||
steps = make_default_policy_processor_steps(config, dataset_stats)
|
||||
|
||||
# Shared relative-action step (OpenPI order: raw -> relative -> normalize -> model ->
|
||||
# unnormalize -> absolute). The SAME instance is passed to AbsoluteActionsProcessorStep
|
||||
# below so its cached raw state (set during preprocessing) flows to postprocessing.
|
||||
relative_step = RelativeActionsProcessorStep(
|
||||
enabled=config.use_relative_actions,
|
||||
exclude_joints=getattr(config, "relative_exclude_joints", []),
|
||||
action_names=getattr(config, "action_feature_names", None),
|
||||
)
|
||||
|
||||
input_steps = [
|
||||
steps.rename_observations,
|
||||
steps.add_batch_dim,
|
||||
steps.to_device,
|
||||
ImagePrepProcessorStep(
|
||||
resize_to=tuple(config.resize_images_to) if config.resize_images_to else None,
|
||||
),
|
||||
relative_step,
|
||||
steps.normalize,
|
||||
]
|
||||
gripper_dim = config.resolved_gripper_dim
|
||||
_warn_if_gripper_steps_are_misconfigured(config, gripper_dim, dataset_stats)
|
||||
|
||||
output_steps: list[ProcessorStep] = []
|
||||
if config.clip_normalized_actions:
|
||||
# Clipping to [-1, 1] is a range assertion under MIN_MAX, but under MEAN_STD the same
|
||||
# clamp truncates every action beyond 1 sigma. That shows up as a hesitant, low-amplitude
|
||||
# policy with no error anywhere, so refuse to add the step instead of honoring the flag.
|
||||
action_norm_mode = config.normalization_mapping.get("ACTION")
|
||||
if action_norm_mode == NormalizationMode.MIN_MAX:
|
||||
output_steps.append(ClipActionsProcessorStep())
|
||||
else:
|
||||
logging.warning(
|
||||
f"`clip_normalized_actions=True` is ignored: it clips normalized actions to "
|
||||
f"[-1, 1], which is only a no-op bound under MIN_MAX, but ACTION uses "
|
||||
f"{getattr(action_norm_mode, 'value', action_norm_mode)}. Under MEAN_STD this "
|
||||
f"would clamp every action to 1 sigma."
|
||||
)
|
||||
output_steps.append(ClipActionsProcessorStep())
|
||||
if config.pre_snap_gripper_action:
|
||||
output_steps.append(
|
||||
PreSnapGripperProcessorStep(gripper_dim=gripper_dim, threshold=config.gripper_threshold)
|
||||
PreSnapGripperProcessorStep(gripper_dim=config.gripper_dim, threshold=config.gripper_threshold)
|
||||
)
|
||||
# NOTE: unlike the default policy unnormalizer (output features only), VLA-JEPA
|
||||
# unnormalizes over BOTH input and output features.
|
||||
@@ -290,14 +131,9 @@ def make_vla_jepa_pre_post_processors(
|
||||
stats=dataset_stats,
|
||||
)
|
||||
)
|
||||
# Reverse the relative conversion on the unnormalized action, before gripper binarization.
|
||||
# gripper is kept absolute by relative_exclude_joints, so the two steps touch disjoint dims.
|
||||
output_steps.append(
|
||||
AbsoluteActionsProcessorStep(enabled=config.use_relative_actions, relative_step=relative_step)
|
||||
)
|
||||
if config.binarize_gripper_action:
|
||||
output_steps.append(
|
||||
BinarizeGripperProcessorStep(gripper_dim=gripper_dim, threshold=config.gripper_threshold)
|
||||
BinarizeGripperProcessorStep(gripper_dim=config.gripper_dim, threshold=config.gripper_threshold)
|
||||
)
|
||||
output_steps.append(steps.to_cpu)
|
||||
return make_policy_processor_pipelines(input_steps=input_steps, output_steps=output_steps)
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -71,21 +70,7 @@ class Qwen3VLInterface(torch.nn.Module):
|
||||
tokenizer.add_tokens([embodied_action_token], special_tokens=True)
|
||||
embodied_action_token_id = tokenizer.convert_tokens_to_ids(embodied_action_token)
|
||||
|
||||
# Qwen3-VL-2B ships 151936 embedding rows for a 151669-token vocab, i.e. 267 spare
|
||||
# rows, so `chunk_size * 4 + 1` added tokens fit without a resize up to chunk_size=66.
|
||||
# Past that, resizing changes `embed_tokens` and `lm_head` shapes, and the checkpoint
|
||||
# will no longer load against a model built with a smaller chunk_size (or vice versa)
|
||||
# unless those prefixes are listed in `reinit_modules`. Silent until now.
|
||||
current_rows = self.model.get_input_embeddings().weight.size(0)
|
||||
if current_rows < len(tokenizer):
|
||||
logging.warning(
|
||||
f"chunk_size={self.config.chunk_size} needs {max_action_tokens + 1} added tokens, "
|
||||
f"which exceeds the {current_rows} embedding rows of {self.config.qwen_model_name}. "
|
||||
f"Resizing to {len(tokenizer)} rows changes the shapes of "
|
||||
f"`model.qwen.model.model.language_model.embed_tokens` and "
|
||||
f"`model.qwen.model.lm_head`, so this model will not load from a checkpoint trained "
|
||||
f"with a different chunk_size unless those prefixes are in `reinit_modules`."
|
||||
)
|
||||
if self.model.get_input_embeddings().weight.size(0) < len(tokenizer):
|
||||
self.model.resize_token_embeddings(len(tokenizer))
|
||||
return action_tokens, action_token_ids, embodied_action_token_id
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ class ACRoPEAttention(nn.Module):
|
||||
self.head_dim = dim // num_heads
|
||||
self.scale = qk_scale or self.head_dim**-0.5
|
||||
self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
|
||||
self.attn_drop_prob = attn_drop
|
||||
self.attn_drop = nn.Dropout(attn_drop)
|
||||
self.proj = nn.Linear(dim, dim)
|
||||
self.proj_drop_prob = proj_drop
|
||||
self.proj_drop = nn.Dropout(proj_drop)
|
||||
self.use_sdpa = use_sdpa
|
||||
self.d_dim = int(2 * ((self.head_dim // 3) // 2))
|
||||
@@ -231,15 +231,8 @@ class ACRoPEAttention(nn.Module):
|
||||
v = merge(v, action_v)
|
||||
|
||||
if attn_mask is not None or self.use_sdpa:
|
||||
# Attention dropout (not projection dropout), and only while training — SDPA does
|
||||
# not consult `self.training` on its own the way `nn.Dropout` does.
|
||||
x = F.scaled_dot_product_attention(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
dropout_p=self.attn_drop_prob if self.training else 0.0,
|
||||
is_causal=self.is_causal,
|
||||
attn_mask=attn_mask,
|
||||
q, k, v, dropout_p=self.proj_drop_prob, is_causal=self.is_causal, attn_mask=attn_mask
|
||||
)
|
||||
else:
|
||||
attn = (q @ k.transpose(-2, -1)) * self.scale
|
||||
@@ -333,7 +326,6 @@ class ActionConditionedVideoPredictor(nn.Module):
|
||||
num_heads: int,
|
||||
mlp_ratio: float,
|
||||
num_action_tokens_per_step: int,
|
||||
dropout: float = 0.0,
|
||||
use_extrinsics: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -341,14 +333,8 @@ class ActionConditionedVideoPredictor(nn.Module):
|
||||
self.use_extrinsics = use_extrinsics
|
||||
self.predictor_embed = nn.Linear(embed_dim, predictor_embed_dim, bias=True)
|
||||
self.action_encoder = nn.Linear(action_embed_dim, predictor_embed_dim, bias=True)
|
||||
# `extrinsics_encoder` only feeds the `use_extrinsics` branch of `forward`. Building it
|
||||
# unconditionally left ~2.1M parameters that never received a gradient (and were only
|
||||
# tolerated by the trainer's global `find_unused_parameters=True`). There was a
|
||||
# `state_encoder` here too, which nothing ever called. Checkpoints written before this
|
||||
# change carry both; they surface as unexpected keys and are ignored on load.
|
||||
self.extrinsics_encoder = (
|
||||
nn.Linear(action_embed_dim - 1, predictor_embed_dim, bias=True) if use_extrinsics else None
|
||||
)
|
||||
self.state_encoder = nn.Linear(action_embed_dim, predictor_embed_dim, bias=True)
|
||||
self.extrinsics_encoder = nn.Linear(action_embed_dim - 1, predictor_embed_dim, bias=True)
|
||||
|
||||
self.img_height, self.img_width = img_size
|
||||
self.patch_size = patch_size
|
||||
@@ -364,8 +350,8 @@ class ActionConditionedVideoPredictor(nn.Module):
|
||||
num_heads=num_heads,
|
||||
mlp_ratio=mlp_ratio,
|
||||
qkv_bias=True,
|
||||
drop=dropout,
|
||||
attn_drop=dropout,
|
||||
drop=0.0,
|
||||
attn_drop=0.0,
|
||||
drop_path=0.0,
|
||||
norm_layer=lambda dim: nn.LayerNorm(dim, eps=1e-6),
|
||||
grid_size=self.grid_height,
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script defines processor steps for adding a batch dimension to various components of an environment transition.
|
||||
"""This script defines processor steps for adding a batch dimension to various components of an environment transition.
|
||||
|
||||
These steps are designed to process actions, observations, and complementary data, making them suitable for batch processing by adding a leading dimension. This is a common requirement before feeding data into a neural network model.
|
||||
"""
|
||||
@@ -41,15 +40,13 @@ from .pipeline import (
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="to_batch_processor_action")
|
||||
class AddBatchDimensionActionStep(PolicyActionProcessorStep):
|
||||
"""
|
||||
Processor step to add a batch dimension to a 1D tensor action.
|
||||
"""Processor step to add a batch dimension to a 1D tensor action.
|
||||
|
||||
This is useful for creating a batch of size 1 from a single action sample.
|
||||
"""
|
||||
|
||||
def action(self, action: PolicyAction) -> PolicyAction:
|
||||
"""
|
||||
Adds a batch dimension to the action if it's a 1D tensor.
|
||||
"""Adds a batch dimension to the action if it's a 1D tensor.
|
||||
|
||||
Args:
|
||||
action: The action tensor.
|
||||
@@ -64,8 +61,7 @@ class AddBatchDimensionActionStep(PolicyActionProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Returns the input features unchanged.
|
||||
"""Returns the input features unchanged.
|
||||
|
||||
Adding a batch dimension does not alter the feature definition.
|
||||
|
||||
@@ -81,8 +77,7 @@ class AddBatchDimensionActionStep(PolicyActionProcessorStep):
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="to_batch_processor_observation")
|
||||
class AddBatchDimensionObservationStep(ObservationProcessorStep):
|
||||
"""
|
||||
Processor step to add a batch dimension to observations.
|
||||
"""Processor step to add a batch dimension to observations.
|
||||
|
||||
It handles different types of observations:
|
||||
- State vectors (1D tensors).
|
||||
@@ -91,8 +86,7 @@ class AddBatchDimensionObservationStep(ObservationProcessorStep):
|
||||
"""
|
||||
|
||||
def observation(self, observation: dict[str, Tensor]) -> dict[str, Tensor]:
|
||||
"""
|
||||
Adds a batch dimension to tensor-based observations in the observation dictionary.
|
||||
"""Adds a batch dimension to tensor-based observations in the observation dictionary.
|
||||
|
||||
Args:
|
||||
observation: The observation dictionary.
|
||||
@@ -122,8 +116,7 @@ class AddBatchDimensionObservationStep(ObservationProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Returns the input features unchanged.
|
||||
"""Returns the input features unchanged.
|
||||
|
||||
Adding a batch dimension does not alter the feature definition.
|
||||
|
||||
@@ -139,8 +132,7 @@ class AddBatchDimensionObservationStep(ObservationProcessorStep):
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="to_batch_processor_complementary_data")
|
||||
class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
|
||||
"""
|
||||
Processor step to add a batch dimension to complementary data fields.
|
||||
"""Processor step to add a batch dimension to complementary data fields.
|
||||
|
||||
Handles specific keys like 'task', 'index', and 'task_index' to make them batched.
|
||||
- 'task' (str) is wrapped in a list.
|
||||
@@ -148,8 +140,7 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
|
||||
"""
|
||||
|
||||
def complementary_data(self, complementary_data: dict) -> dict:
|
||||
"""
|
||||
Adds a batch dimension to specific fields in the complementary data dictionary.
|
||||
"""Adds a batch dimension to specific fields in the complementary data dictionary.
|
||||
|
||||
Args:
|
||||
complementary_data: The complementary data dictionary.
|
||||
@@ -175,9 +166,6 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
|
||||
if isinstance(task_index_value, Tensor) and task_index_value.dim() == 0:
|
||||
complementary_data["task_index"] = task_index_value.unsqueeze(0)
|
||||
|
||||
complementary_data.pop("language_persistent", None)
|
||||
complementary_data.pop("language_events", None)
|
||||
|
||||
if "messages" in complementary_data:
|
||||
messages = complementary_data["messages"]
|
||||
if isinstance(messages, list) and (not messages or isinstance(messages[0], dict)):
|
||||
@@ -197,8 +185,7 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Returns the input features unchanged.
|
||||
"""Returns the input features unchanged.
|
||||
|
||||
Adding a batch dimension does not alter the feature definition.
|
||||
|
||||
@@ -214,16 +201,17 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="to_batch_processor")
|
||||
class AddBatchDimensionProcessorStep(ProcessorStep):
|
||||
"""
|
||||
A composite processor step that adds a batch dimension to the entire environment transition.
|
||||
"""A composite processor step that adds a batch dimension to the entire environment transition.
|
||||
|
||||
This step combines individual processors for actions, observations, and complementary data
|
||||
to create a batched transition (batch size 1) from a single-instance transition.
|
||||
|
||||
Attributes:
|
||||
to_batch_action_processor: Processor for the action component.
|
||||
to_batch_observation_processor: Processor for the observation component.
|
||||
to_batch_complementary_data_processor: Processor for the complementary data component.
|
||||
**Attributes**:
|
||||
- **to_batch_action_processor** (`AddBatchDimensionActionStep`) -- Processor for the action component.
|
||||
- **to_batch_observation_processor** (`AddBatchDimensionObservationStep`) -- Processor for the
|
||||
observation component.
|
||||
- **to_batch_complementary_data_processor** (`AddBatchDimensionComplementaryDataStep`) -- Processor
|
||||
for the complementary data component.
|
||||
"""
|
||||
|
||||
to_batch_action_processor: AddBatchDimensionActionStep = field(
|
||||
@@ -237,8 +225,7 @@ class AddBatchDimensionProcessorStep(ProcessorStep):
|
||||
)
|
||||
|
||||
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
||||
"""
|
||||
Applies the batching process to all relevant parts of an environment transition.
|
||||
"""Applies the batching process to all relevant parts of an environment transition.
|
||||
|
||||
Args:
|
||||
transition: The environment transition to process.
|
||||
@@ -257,8 +244,7 @@ class AddBatchDimensionProcessorStep(ProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Returns the input features unchanged.
|
||||
"""Returns the input features unchanged.
|
||||
|
||||
Adding a batch dimension does not alter the feature definition.
|
||||
|
||||
|
||||
@@ -34,16 +34,16 @@ def to_tensor(
|
||||
dtype: torch.dtype | None = torch.float32,
|
||||
device: torch.device | str | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Convert various data types to PyTorch tensors with configurable options.
|
||||
"""Convert various data types to PyTorch tensors with configurable options.
|
||||
|
||||
This is a unified tensor conversion function using single dispatch to handle
|
||||
different input types appropriately.
|
||||
|
||||
Args:
|
||||
value: Input value to convert (tensor, array, scalar, sequence, etc.).
|
||||
dtype: Target tensor dtype. If None, preserves original dtype.
|
||||
device: Target device for the tensor.
|
||||
value (`Any`): Input value to convert (tensor, array, scalar, sequence, etc.).
|
||||
dtype (`torch.dtype | None`, *optional*, defaults to `torch.float32`): Target tensor dtype. If
|
||||
None, preserves original dtype.
|
||||
device (`torch.device | str | None`, *optional*): Target device for the tensor.
|
||||
|
||||
Returns:
|
||||
A PyTorch tensor.
|
||||
@@ -137,13 +137,12 @@ def _(value: dict, *, device=None, **kwargs) -> dict:
|
||||
|
||||
|
||||
def from_tensor_to_numpy(x: torch.Tensor | Any) -> np.ndarray | float | int | Any:
|
||||
"""
|
||||
Convert a PyTorch tensor to a numpy array or scalar if applicable.
|
||||
"""Convert a PyTorch tensor to a numpy array or scalar if applicable.
|
||||
|
||||
If the input is not a tensor, it is returned unchanged.
|
||||
|
||||
Args:
|
||||
x: The input, which can be a tensor or any other type.
|
||||
x (`torch.Tensor | Any`): The input, which can be a tensor or any other type.
|
||||
|
||||
Returns:
|
||||
A numpy array, a scalar, or the original input.
|
||||
@@ -188,17 +187,16 @@ def create_transition(
|
||||
info: dict[str, Any] | None = None,
|
||||
complementary_data: dict[str, Any] | None = None,
|
||||
) -> EnvTransition:
|
||||
"""
|
||||
Create an `EnvTransition` dictionary with sensible defaults.
|
||||
"""Create an `EnvTransition` dictionary with sensible defaults.
|
||||
|
||||
Args:
|
||||
observation: Observation dictionary.
|
||||
action: Action dictionary.
|
||||
reward: Scalar reward value.
|
||||
done: Episode termination flag.
|
||||
truncated: Episode truncation flag.
|
||||
info: Additional info dictionary.
|
||||
complementary_data: Complementary data dictionary.
|
||||
observation (`RobotObservation | None`, *optional*): Observation dictionary.
|
||||
action (`PolicyAction | RobotAction | None`, *optional*): Action dictionary.
|
||||
reward (`float`, *optional*, defaults to 0.0): Scalar reward value.
|
||||
done (`bool`, *optional*, defaults to `False`): Episode termination flag.
|
||||
truncated (`bool`, *optional*, defaults to `False`): Episode truncation flag.
|
||||
info (`dict[str, Any] | None`, *optional*): Additional info dictionary.
|
||||
complementary_data (`dict[str, Any] | None`, *optional*): Complementary data dictionary.
|
||||
|
||||
Returns:
|
||||
A complete `EnvTransition` dictionary.
|
||||
@@ -217,15 +215,15 @@ def create_transition(
|
||||
def robot_action_observation_to_transition(
|
||||
action_observation: tuple[RobotAction, RobotObservation],
|
||||
) -> EnvTransition:
|
||||
"""
|
||||
Convert a raw robot action and observation dictionary into a standardized `EnvTransition`.
|
||||
"""Convert a raw robot action and observation dictionary into a standardized `EnvTransition`.
|
||||
|
||||
Args:
|
||||
action: The raw action dictionary from a teleoperation device or controller.
|
||||
observation: The raw observation dictionary from the environment.
|
||||
action_observation (`tuple[RobotAction, RobotObservation]`): A `(action, observation)` tuple, where
|
||||
`action` is the raw action dictionary from a teleoperation device or controller, and
|
||||
`observation` is the raw observation dictionary from the environment.
|
||||
|
||||
Returns:
|
||||
An `EnvTransition` containing the formatted observation.
|
||||
An `EnvTransition` containing the formatted action and observation.
|
||||
"""
|
||||
if not isinstance(action_observation, tuple):
|
||||
raise ValueError("action_observation should be a tuple type with an action and observation")
|
||||
@@ -242,11 +240,10 @@ def robot_action_observation_to_transition(
|
||||
|
||||
|
||||
def robot_action_to_transition(action: RobotAction) -> EnvTransition:
|
||||
"""
|
||||
Convert a raw robot action dictionary into a standardized `EnvTransition`.
|
||||
"""Convert a raw robot action dictionary into a standardized `EnvTransition`.
|
||||
|
||||
Args:
|
||||
action: The raw action dictionary from a teleoperation device or controller.
|
||||
action (`RobotAction`): The raw action dictionary from a teleoperation device or controller.
|
||||
|
||||
Returns:
|
||||
An `EnvTransition` containing the formatted action.
|
||||
@@ -257,11 +254,10 @@ def robot_action_to_transition(action: RobotAction) -> EnvTransition:
|
||||
|
||||
|
||||
def observation_to_transition(observation: RobotObservation) -> EnvTransition:
|
||||
"""
|
||||
Convert a raw robot observation dictionary into a standardized `EnvTransition`.
|
||||
"""Convert a raw robot observation dictionary into a standardized `EnvTransition`.
|
||||
|
||||
Args:
|
||||
observation: The raw observation dictionary from the environment.
|
||||
observation (`RobotObservation`): The raw observation dictionary from the environment.
|
||||
|
||||
Returns:
|
||||
An `EnvTransition` containing the formatted observation.
|
||||
@@ -272,14 +268,13 @@ def observation_to_transition(observation: RobotObservation) -> EnvTransition:
|
||||
|
||||
|
||||
def transition_to_robot_action(transition: EnvTransition) -> RobotAction:
|
||||
"""
|
||||
Extract a raw robot action dictionary for a robot from an `EnvTransition`.
|
||||
"""Extract a raw robot action dictionary for a robot from an `EnvTransition`.
|
||||
|
||||
This function searches for keys in the format "action.*.pos" or "action.*.vel"
|
||||
and converts them into a flat dictionary suitable for sending to a robot controller.
|
||||
|
||||
Args:
|
||||
transition: The `EnvTransition` containing the action.
|
||||
transition (`EnvTransition`): The `EnvTransition` containing the action.
|
||||
|
||||
Returns:
|
||||
A dictionary representing the raw robot action.
|
||||
@@ -294,8 +289,16 @@ def transition_to_robot_action(transition: EnvTransition) -> RobotAction:
|
||||
|
||||
|
||||
def transition_to_policy_action(transition: EnvTransition) -> PolicyAction:
|
||||
"""
|
||||
Convert an `EnvTransition` to a `PolicyAction`.
|
||||
"""Convert an `EnvTransition` to a `PolicyAction`.
|
||||
|
||||
Args:
|
||||
transition (`EnvTransition`): The `EnvTransition` containing the action.
|
||||
|
||||
Returns:
|
||||
The extracted `PolicyAction`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `transition` is not a dict, or its action is not a `PolicyAction`.
|
||||
"""
|
||||
if not isinstance(transition, dict):
|
||||
raise ValueError(f"Transition should be a EnvTransition type (dict) got {type(transition)}")
|
||||
@@ -307,8 +310,16 @@ def transition_to_policy_action(transition: EnvTransition) -> PolicyAction:
|
||||
|
||||
|
||||
def transition_to_observation(transition: EnvTransition) -> RobotObservation:
|
||||
"""
|
||||
Convert an `EnvTransition` to a `RobotObservation`.
|
||||
"""Convert an `EnvTransition` to a `RobotObservation`.
|
||||
|
||||
Args:
|
||||
transition (`EnvTransition`): The `EnvTransition` containing the observation.
|
||||
|
||||
Returns:
|
||||
The extracted `RobotObservation`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `transition` is not a dict, or its observation is not a dict.
|
||||
"""
|
||||
if not isinstance(transition, dict):
|
||||
raise ValueError(f"Transition should be a EnvTransition type (dict) got {type(transition)}")
|
||||
@@ -320,8 +331,16 @@ def transition_to_observation(transition: EnvTransition) -> RobotObservation:
|
||||
|
||||
|
||||
def policy_action_to_transition(action: PolicyAction) -> EnvTransition:
|
||||
"""
|
||||
Convert a `PolicyAction` to an `EnvTransition`.
|
||||
"""Convert a `PolicyAction` to an `EnvTransition`.
|
||||
|
||||
Args:
|
||||
action (`PolicyAction`): The `PolicyAction` to wrap.
|
||||
|
||||
Returns:
|
||||
An `EnvTransition` containing the formatted action.
|
||||
|
||||
Raises:
|
||||
ValueError: If `action` is not a `PolicyAction`.
|
||||
"""
|
||||
if not isinstance(action, PolicyAction):
|
||||
raise ValueError(f"Action should be a PolicyAction type got {type(action)}")
|
||||
@@ -329,14 +348,13 @@ def policy_action_to_transition(action: PolicyAction) -> EnvTransition:
|
||||
|
||||
|
||||
def batch_to_transition(batch: dict[str, Any]) -> EnvTransition:
|
||||
"""
|
||||
Convert a batch dictionary from a dataset/dataloader into an `EnvTransition`.
|
||||
"""Convert a batch dictionary from a dataset/dataloader into an `EnvTransition`.
|
||||
|
||||
This function maps recognized keys from a batch to the `EnvTransition` structure,
|
||||
filling in missing keys with sensible defaults.
|
||||
|
||||
Args:
|
||||
batch: A batch dictionary.
|
||||
batch (`dict[str, Any]`): A batch dictionary.
|
||||
|
||||
Returns:
|
||||
An `EnvTransition` dictionary.
|
||||
@@ -344,7 +362,6 @@ def batch_to_transition(batch: dict[str, Any]) -> EnvTransition:
|
||||
Raises:
|
||||
ValueError: If the input is not a dictionary.
|
||||
"""
|
||||
|
||||
# Validate input type.
|
||||
if not isinstance(batch, dict):
|
||||
raise ValueError(f"EnvTransition must be a dictionary. Got {type(batch).__name__}")
|
||||
@@ -369,13 +386,12 @@ def batch_to_transition(batch: dict[str, Any]) -> EnvTransition:
|
||||
|
||||
|
||||
def transition_to_batch(transition: EnvTransition) -> dict[str, Any]:
|
||||
"""
|
||||
Convert an `EnvTransition` back to the canonical batch format used in LeRobot.
|
||||
"""Convert an `EnvTransition` back to the canonical batch format used in LeRobot.
|
||||
|
||||
This is the inverse of `batch_to_transition`.
|
||||
|
||||
Args:
|
||||
transition: The `EnvTransition` to convert.
|
||||
transition (`EnvTransition`): The `EnvTransition` to convert.
|
||||
|
||||
Returns:
|
||||
A batch dictionary with canonical LeRobot field names.
|
||||
@@ -405,13 +421,12 @@ def transition_to_batch(transition: EnvTransition) -> dict[str, Any]:
|
||||
|
||||
|
||||
def identity_transition(transition: EnvTransition) -> EnvTransition:
|
||||
"""
|
||||
An identity function for transitions, returning the input unchanged.
|
||||
"""An identity function for transitions, returning the input unchanged.
|
||||
|
||||
Useful as a default or placeholder in processing pipelines.
|
||||
|
||||
Args:
|
||||
tr: An `EnvTransition`.
|
||||
transition (`EnvTransition`): An `EnvTransition`.
|
||||
|
||||
Returns:
|
||||
The same `EnvTransition`.
|
||||
|
||||
@@ -25,21 +25,31 @@ from .pipeline import ActionProcessorStep, ProcessorStepRegistry, RobotActionPro
|
||||
@ProcessorStepRegistry.register("map_tensor_to_delta_action_dict")
|
||||
@dataclass
|
||||
class MapTensorToDeltaActionDictStep(ActionProcessorStep):
|
||||
"""
|
||||
Maps a flat action tensor from a policy to a structured delta action dictionary.
|
||||
"""Maps a flat action tensor from a policy to a structured delta action dictionary.
|
||||
|
||||
This step is typically used after a policy outputs a continuous action vector.
|
||||
It decomposes the vector into named components for delta movements of the
|
||||
end-effector (x, y, z) and optionally the gripper.
|
||||
|
||||
Attributes:
|
||||
use_gripper: If True, assumes the 4th element of the tensor is the
|
||||
gripper action.
|
||||
**Attributes**:
|
||||
- **use_gripper** (`bool`) -- If True, assumes the 4th element of the tensor is the gripper action.
|
||||
"""
|
||||
|
||||
use_gripper: bool = True
|
||||
|
||||
def action(self, action: PolicyAction) -> RobotAction:
|
||||
"""Split a flat policy action tensor into a named delta-movement dict.
|
||||
|
||||
Args:
|
||||
action: A `PolicyAction` tensor of at least 3 elements (x, y, z), plus a 4th gripper
|
||||
element if `use_gripper` is `True`.
|
||||
|
||||
Returns:
|
||||
A dict with `delta_x`/`delta_y`/`delta_z` (and `gripper`, if enabled).
|
||||
|
||||
Raises:
|
||||
ValueError: If `action` is not a `PolicyAction`.
|
||||
"""
|
||||
if not isinstance(action, PolicyAction):
|
||||
raise ValueError("Only PolicyAction is supported for this processor")
|
||||
|
||||
@@ -59,6 +69,7 @@ class MapTensorToDeltaActionDictStep(ActionProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""See [`~processor.ProcessorStep.transform_features`]. Adds the `delta_x`/`delta_y`/`delta_z` (and `gripper`) action features."""
|
||||
for axis in ["x", "y", "z"]:
|
||||
features[PipelineFeatureType.ACTION][f"delta_{axis}"] = PolicyFeature(
|
||||
type=FeatureType.ACTION, shape=(1,)
|
||||
@@ -74,17 +85,16 @@ class MapTensorToDeltaActionDictStep(ActionProcessorStep):
|
||||
@ProcessorStepRegistry.register("map_delta_action_to_robot_action")
|
||||
@dataclass
|
||||
class MapDeltaActionToRobotActionStep(RobotActionProcessorStep):
|
||||
"""
|
||||
Maps delta actions from teleoperators to robot target actions for inverse kinematics.
|
||||
"""Maps delta actions from teleoperators to robot target actions for inverse kinematics.
|
||||
|
||||
This step converts a dictionary of delta movements (e.g., from a gamepad)
|
||||
into a target action format that includes an "enabled" flag and target
|
||||
end-effector positions. It also handles scaling and noise filtering.
|
||||
|
||||
Attributes:
|
||||
position_scale: A factor to scale the delta position inputs.
|
||||
noise_threshold: The magnitude below which delta inputs are considered noise
|
||||
and do not trigger an "enabled" state.
|
||||
**Attributes**:
|
||||
- **position_scale** (`float`) -- A factor to scale the delta position inputs.
|
||||
- **noise_threshold** (`float`) -- The magnitude below which delta inputs are considered noise and do
|
||||
not trigger an "enabled" state.
|
||||
"""
|
||||
|
||||
# Scale factors for delta movements
|
||||
@@ -92,6 +102,15 @@ class MapDeltaActionToRobotActionStep(RobotActionProcessorStep):
|
||||
noise_threshold: float = 1e-3 # 1 mm threshold to filter out noise
|
||||
|
||||
def action(self, action: RobotAction) -> RobotAction:
|
||||
"""Convert a delta-movement dict into a robot target-action dict for inverse kinematics.
|
||||
|
||||
Args:
|
||||
action: A dict with `delta_x`/`delta_y`/`delta_z` and `gripper` keys.
|
||||
|
||||
Returns:
|
||||
A dict with `enabled`, scaled `target_x`/`target_y`/`target_z`, zeroed `target_wx`/`target_wy`/
|
||||
`target_wz` (rotation isn't supported by delta teleoperators), and `gripper_vel`.
|
||||
"""
|
||||
# NOTE (maractingi): Action can be a dict from the teleop_devices or a tensor from the policy
|
||||
# TODO (maractingi): changing this target_xyz naming convention from the teleop_devices
|
||||
delta_x = action.pop("delta_x")
|
||||
@@ -132,6 +151,7 @@ class MapDeltaActionToRobotActionStep(RobotActionProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""See [`~processor.ProcessorStep.transform_features`]. Replaces the delta-action features with robot target-action features."""
|
||||
for axis in ["x", "y", "z"]:
|
||||
features[PipelineFeatureType.ACTION].pop(f"delta_{axis}", None)
|
||||
features[PipelineFeatureType.ACTION].pop("gripper", None)
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script defines a processor step for moving environment transition data to a specific torch device and casting
|
||||
its floating-point precision.
|
||||
"""This script defines a processor step for moving environment transition data to a specific torch device.
|
||||
|
||||
It also optionally casts data to a specified floating-point precision.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -34,16 +34,15 @@ from .pipeline import ProcessorStep, ProcessorStepRegistry
|
||||
@ProcessorStepRegistry.register("device_processor")
|
||||
@dataclass
|
||||
class DeviceProcessorStep(ProcessorStep):
|
||||
"""
|
||||
Processor step to move all tensors within an `EnvTransition` to a specified device and optionally cast their
|
||||
floating-point data type.
|
||||
"""Processor step to move all tensors within an `EnvTransition` to a specified device.
|
||||
|
||||
This is crucial for preparing data for model training or inference on hardware like GPUs.
|
||||
Optionally casts their floating-point data type too. This is crucial for preparing data for model
|
||||
training or inference on hardware like GPUs.
|
||||
|
||||
Attributes:
|
||||
device: The target device for tensors (e.g., "cpu", "cuda", "cuda:0").
|
||||
float_dtype: The target floating-point dtype as a string (e.g., "float32", "float16", "bfloat16").
|
||||
If None, the dtype is not changed.
|
||||
**Attributes**:
|
||||
- **device** (`str`) -- The target device for tensors (e.g., "cpu", "cuda", "cuda:0").
|
||||
- **float_dtype** (`str | None`) -- The target floating-point dtype as a string (e.g., "float32",
|
||||
"float16", "bfloat16"). If None, the dtype is not changed.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
@@ -60,8 +59,7 @@ class DeviceProcessorStep(ProcessorStep):
|
||||
}
|
||||
|
||||
def __post_init__(self):
|
||||
"""
|
||||
Initializes the processor by converting string configurations to torch objects.
|
||||
"""Initializes the processor by converting string configurations to torch objects.
|
||||
|
||||
This method sets up the `torch.device`, determines if transfers can be non-blocking, and validates the
|
||||
`float_dtype` string, converting it to a `torch.dtype` object.
|
||||
@@ -82,8 +80,7 @@ class DeviceProcessorStep(ProcessorStep):
|
||||
self._target_float_dtype = None
|
||||
|
||||
def _process_tensor(self, tensor: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Moves a single tensor to the target device and casts its dtype.
|
||||
"""Moves a single tensor to the target device and casts its dtype.
|
||||
|
||||
Handles multi-GPU scenarios by not moving a tensor if it's already on a different CUDA device than
|
||||
the target, which is useful when using frameworks like Accelerate.
|
||||
@@ -120,8 +117,7 @@ class DeviceProcessorStep(ProcessorStep):
|
||||
return tensor
|
||||
|
||||
def __call__(self, transition: EnvTransition) -> EnvTransition:
|
||||
"""
|
||||
Applies device and dtype conversion to all tensors in an environment transition.
|
||||
"""Applies device and dtype conversion to all tensors in an environment transition.
|
||||
|
||||
It iterates through the transition, finds all `torch.Tensor` objects (including those nested in
|
||||
dictionaries like `observation`), and processes them.
|
||||
@@ -169,8 +165,7 @@ class DeviceProcessorStep(ProcessorStep):
|
||||
return new_transition
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
"""
|
||||
Returns the serializable configuration of the processor.
|
||||
"""Returns the serializable configuration of the processor.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the device and float_dtype settings.
|
||||
@@ -180,8 +175,7 @@ class DeviceProcessorStep(ProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Returns the input features unchanged.
|
||||
"""Returns the input features unchanged.
|
||||
|
||||
Device and dtype transformations do not alter the fundamental definition of the features (e.g., shape).
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ from .pipeline import ObservationProcessorStep, ProcessorStepRegistry
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="libero_processor")
|
||||
class LiberoProcessorStep(ObservationProcessorStep):
|
||||
"""
|
||||
Processes LIBERO observations into the LeRobot format.
|
||||
"""Processes LIBERO observations into the LeRobot format.
|
||||
|
||||
This step handles the specific observation structure from LIBERO environments,
|
||||
which includes nested robot_state dictionaries and image observations.
|
||||
@@ -47,9 +46,7 @@ class LiberoProcessorStep(ObservationProcessorStep):
|
||||
"""
|
||||
|
||||
def _process_observation(self, observation):
|
||||
"""
|
||||
Processes both image and robot_state observations from LIBERO.
|
||||
"""
|
||||
"""Processes both image and robot_state observations from LIBERO."""
|
||||
processed_obs = observation.copy()
|
||||
for key in list(processed_obs.keys()):
|
||||
if key.startswith(f"{OBS_IMAGES}."):
|
||||
@@ -85,9 +82,7 @@ class LiberoProcessorStep(ObservationProcessorStep):
|
||||
def transform_features(
|
||||
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
|
||||
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
|
||||
"""
|
||||
Transforms feature keys from the LIBERO format to the LeRobot standard.
|
||||
"""
|
||||
"""Transforms feature keys from the LIBERO format to the LeRobot standard."""
|
||||
new_features: dict[PipelineFeatureType, dict[str, PolicyFeature]] = {}
|
||||
|
||||
# copy over non-STATE features
|
||||
@@ -109,11 +104,12 @@ class LiberoProcessorStep(ObservationProcessorStep):
|
||||
return new_features
|
||||
|
||||
def observation(self, observation):
|
||||
"""See [`~processor.ObservationProcessorStep.observation`]. Delegates to `_process_observation`."""
|
||||
return self._process_observation(observation)
|
||||
|
||||
def _quat2axisangle(self, quat: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Convert batched quaternions to axis-angle format.
|
||||
"""Convert batched quaternions to axis-angle format.
|
||||
|
||||
Only accepts torch tensors of shape (B, 4).
|
||||
|
||||
Args:
|
||||
@@ -126,7 +122,6 @@ class LiberoProcessorStep(ObservationProcessorStep):
|
||||
TypeError: if input is not a torch tensor
|
||||
ValueError: if shape is not (B, 4)
|
||||
"""
|
||||
|
||||
if not isinstance(quat, torch.Tensor):
|
||||
raise TypeError(f"_quat2axisangle expected a torch.Tensor, got {type(quat)}")
|
||||
|
||||
@@ -156,8 +151,7 @@ class LiberoProcessorStep(ObservationProcessorStep):
|
||||
@dataclass
|
||||
@ProcessorStepRegistry.register(name="isaaclab_arena_processor")
|
||||
class IsaaclabArenaProcessorStep(ObservationProcessorStep):
|
||||
"""
|
||||
Processes IsaacLab Arena observations into LeRobot format.
|
||||
"""Processes IsaacLab Arena observations into LeRobot format.
|
||||
|
||||
**State Processing:**
|
||||
- Extracts state components from obs["policy"] based on `state_keys`.
|
||||
@@ -176,9 +170,7 @@ class IsaaclabArenaProcessorStep(ObservationProcessorStep):
|
||||
camera_keys: tuple[str, ...]
|
||||
|
||||
def _process_observation(self, observation):
|
||||
"""
|
||||
Processes both image and policy state observations from IsaacLab Arena.
|
||||
"""
|
||||
"""Processes both image and policy state observations from IsaacLab Arena."""
|
||||
processed_obs = {}
|
||||
|
||||
if f"{OBS_STR}.camera_obs" in observation:
|
||||
@@ -225,4 +217,5 @@ class IsaaclabArenaProcessorStep(ObservationProcessorStep):
|
||||
return features
|
||||
|
||||
def observation(self, observation):
|
||||
"""See [`~processor.ObservationProcessorStep.observation`]. Delegates to `_process_observation`."""
|
||||
return self._process_observation(observation)
|
||||
|
||||
@@ -46,6 +46,11 @@ from .rename_processor import RenameObservationsProcessorStep
|
||||
def make_default_teleop_action_processor() -> RobotProcessorPipeline[
|
||||
tuple[RobotAction, RobotObservation], RobotAction
|
||||
]:
|
||||
"""Build a no-op teleoperator-action pipeline (an `IdentityProcessorStep`).
|
||||
|
||||
Returns:
|
||||
`RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction]`: The pipeline.
|
||||
"""
|
||||
teleop_action_processor = RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction](
|
||||
steps=[IdentityProcessorStep()],
|
||||
to_transition=robot_action_observation_to_transition,
|
||||
@@ -57,6 +62,11 @@ def make_default_teleop_action_processor() -> RobotProcessorPipeline[
|
||||
def make_default_robot_action_processor() -> RobotProcessorPipeline[
|
||||
tuple[RobotAction, RobotObservation], RobotAction
|
||||
]:
|
||||
"""Build a no-op robot-action pipeline (an `IdentityProcessorStep`).
|
||||
|
||||
Returns:
|
||||
`RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction]`: The pipeline.
|
||||
"""
|
||||
robot_action_processor = RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction](
|
||||
steps=[IdentityProcessorStep()],
|
||||
to_transition=robot_action_observation_to_transition,
|
||||
@@ -66,6 +76,11 @@ def make_default_robot_action_processor() -> RobotProcessorPipeline[
|
||||
|
||||
|
||||
def make_default_robot_observation_processor() -> RobotProcessorPipeline[RobotObservation, RobotObservation]:
|
||||
"""Build a no-op robot-observation pipeline (an `IdentityProcessorStep`).
|
||||
|
||||
Returns:
|
||||
`RobotProcessorPipeline[RobotObservation, RobotObservation]`: The pipeline.
|
||||
"""
|
||||
robot_observation_processor = RobotProcessorPipeline[RobotObservation, RobotObservation](
|
||||
steps=[IdentityProcessorStep()],
|
||||
to_transition=observation_to_transition,
|
||||
@@ -75,6 +90,11 @@ def make_default_robot_observation_processor() -> RobotProcessorPipeline[RobotOb
|
||||
|
||||
|
||||
def make_default_processors():
|
||||
"""Build the three no-op default processors: teleop-action, robot-action, and robot-observation.
|
||||
|
||||
Returns:
|
||||
A `(teleop_action_processor, robot_action_processor, robot_observation_processor)` tuple.
|
||||
"""
|
||||
teleop_action_processor = make_default_teleop_action_processor()
|
||||
robot_action_processor = make_default_robot_action_processor()
|
||||
robot_observation_processor = make_default_robot_observation_processor()
|
||||
@@ -106,11 +126,13 @@ def make_default_policy_processor_steps(
|
||||
"""Construct the canonical policy processor steps from a policy config.
|
||||
|
||||
Args:
|
||||
config: A `PreTrainedConfig` providing `device`, `input_features`,
|
||||
config (`PreTrainedConfig`): A `PreTrainedConfig` providing `device`, `input_features`,
|
||||
`output_features` and `normalization_mapping`.
|
||||
dataset_stats: Dataset statistics used for (un)normalization.
|
||||
normalizer_device: Device passed to `NormalizerProcessorStep` (some policies pin
|
||||
their normalization stats to the policy device; most leave it unset).
|
||||
dataset_stats (`dict[str, dict[str, torch.Tensor]] | None`, *optional*): Dataset statistics used
|
||||
for (un)normalization.
|
||||
normalizer_device (`torch.device | str | None`, *optional*): Device passed to
|
||||
`NormalizerProcessorStep` (some policies pin their normalization stats to the policy device;
|
||||
most leave it unset).
|
||||
"""
|
||||
return DefaultPolicyProcessorSteps(
|
||||
rename_observations=RenameObservationsProcessorStep(rename_map={}),
|
||||
@@ -164,8 +186,9 @@ def make_default_pre_post_processors(
|
||||
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
|
||||
PolicyProcessorPipeline[PolicyAction, PolicyAction],
|
||||
]:
|
||||
"""The pure-scaffold policy pipeline pair: Rename -> Batch -> Device -> Normalize,
|
||||
and Unnormalize -> Device(cpu). Policies with custom steps or a different step order
|
||||
"""The pure-scaffold policy pipeline pair: Rename -> Batch -> Device -> Normalize.
|
||||
|
||||
And Unnormalize -> Device(cpu). Policies with custom steps or a different step order
|
||||
compose `make_default_policy_processor_steps` themselves instead.
|
||||
"""
|
||||
s = make_default_policy_processor_steps(config, dataset_stats, normalizer_device=normalizer_device)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user