LeRobot Notes: The Basic Workflow

Liao Jiayi Liao Jiayi

Notes on LeRobot datasets, training, and policies.

Translated from Chinese with AI · Read the original

Dataset

I use the simple PushT dataset as an example: a robot pushes an object to a target shape and position in 2D video.

Batch Dataset

Print the dataset’s basic information:

dataset = LeRobotDataset("lerobot/pusht")
print(dataset)
# output
# LeRobotDataset({
# Repository ID: 'lerobot/pusht',
# Number of selected episodes: '206',
# Number of selected samples: '25650',
# Features: '['observation.image', 'observation.state', 'action', 'episode_index', 'frame_index', 'timestamp', 'next.reward', 'next.done', 'next.success', 'index', 'task_index']',
# })',

Key variables:

  • episode: A video segment, corresponding here to one task.
  • observation
    • image: A video frame with shape (H,W,C), 96x96x3, apparently from the robot’s viewpoint.
    • state: (x,y) coordinates.
  • action: Shape (2,), also x,y coordinates.
  • reward: Shape (1,), a scalar reward.

LeRobotDataset loads the data directly into memory.

Visualization

python src/lerobot/scripts/lerobot_dataset_viz.py \
--repo-id lerobot/pusht \
--episode-index 1 \
--display-compressed-images true

Result:

rerun

I will explore more usage patterns later.

Streaming Dataset

dataset = StreamingLeRobotDataset("lerobot/pusht")
for sample in dataset:
print(sample['action'])

Streaming Dataset uses PyTorch’s IterableDataset without loading everything into memory. The main method to inspect is __iter__(self).

def __iter__(self) -> Iterator[dict[str, torch.Tensor]]:
# ...
# buffer 内打散
buffer_indices_generator = self._iter_random_indices(rng, self.buffer_size)
# 拆成多个 shard, backtrack_dataset 是和 delta_timestamp 相关,需要拿到前后的若干条数据
idx_to_backtrack_dataset = {
idx: self._make_backtrackable_dataset(safe_shard(self.hf_dataset, idx, self.num_shards))
for idx in range(self.num_shards)
}
frames_buffer = []
while available_shards := list(idx_to_backtrack_dataset.keys()):
# 随便选一个 shard,拿到对应的 backtrack_dataset
shard_key = next(self._infinite_generator_over_elements(rng, available_shards))
backtrack_dataset = idx_to_backtrack_dataset[shard_key] # selects which shard to iterate on
try:
# make_frame 有可能会产生多个 frame,和 delta_timestamp 有关,下面再详细说说
for frame in self.make_frame(backtrack_dataset):
# 蓄水池采样,如果满了就丢出去第 i 个,把当前的 frame 补到 i-th 里
if len(frames_buffer) == self.buffer_size:
i = next(buffer_indices_generator) # samples a element from the buffer
yield frames_buffer[i]
frames_buffer[i] = frame
else:
frames_buffer.append(frame)
break # random shard sampled, switch shard
except (
RuntimeError,
StopIteration,
): # NOTE: StopIteration inside a generator throws a RuntimeError since python 3.7
del idx_to_backtrack_dataset[shard_key] # Remove exhausted shard, onto another shard

make_frame

The important case involves delta_timestamp. For example, [-1, -0.5, -0.20, 0] requests frames from one second, half a second, and 0.2 seconds ago, plus the current frame.

Processing converts delta timestamps into frame offsets using delta ts * fps. At 30 fps, -0.20*30=-6 means six frames earlier. After calculating indices, the result is assembled in _get_delta_frames. To reuse frames, backtrack_dataset uses this structure:

self._source: Iterator[T] = iter(iterable)
self._back_buf: deque[T] = deque(maxlen=history)
self._ahead_buf: deque[T] = deque(maxlen=lookahead) if lookahead > 0 else deque()
self._cursor: int = 0
  • Looking backward, with negative deltas: Normal iteration puts previously visited frames in _back_buf, ready for reuse.
  • Looking forward, with positive deltas: Continue reading _source into _ahead_buf without moving the cursor.

torch.cdist determines which actual video frames to load.

for frame, pts in zip(frames_batch.data, frames_batch.pts_seconds, strict=True):
loaded_frames.append(frame)
loaded_ts.append(pts.item())
if log_loaded_timestamps:
logging.info(f"Frame loaded at timestamp={pts:.4f}")
query_ts = torch.tensor(timestamps)
loaded_ts = torch.tensor(loaded_ts)
# compute distances between each query timestamp and loaded timestamps
dist = torch.cdist(query_ts[:, None], loaded_ts[:, None], p=1)
min_, argmin_ = dist.min(1)

Training

Example of running policy training:

Terminal window
python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id lerobot/pusht \
--policy.type act \
--policy.push_to_hub False \
--steps 50 \
--num_workers 0 \
--policy.device cpu

The main operation in lerobot_train.py:

# preprocessor 和 postprocessor
preprocessor, postprocessor = make_pre_post_processors(
policy_cfg=cfg.policy,
pretrained_path=cfg.policy.pretrained_path,
**processor_kwargs,
**postprocessor_kwargs,
)
logging.info(f"preprocessor: {preprocessor}, postprocessor: {postprocessor}")
# ...
# accelerator 根据加速器(如 deepspeed)等的封装,初始化几个关键对象
policy, optimizer, dataloader, lr_scheduler = accelerator.prepare(
policy, optimizer, dataloader, lr_scheduler
)
print(f"policy: {policy}, optimizer: {optimizer}, dataloader: {dataloader}, lr_scheduler: {lr_scheduler}")
# ...
for _ in range(step, cfg.steps):
start_time = time.perf_counter()
# dl_iter 是上面讲到的 dataloader
batch = next(dl_iter)
batch = preprocessor(batch)
train_tracker.dataloading_s = time.perf_counter() - start_time
train_tracker, output_dict = update_policy(
train_tracker,
policy,
batch,
optimizer,
cfg.optimizer.grad_clip_norm,
accelerator=accelerator,
lr_scheduler=lr_scheduler,
rabc_weights_provider=rabc_weights,
)

Preprocessor and Postprocessor

Each model can define its own configuration. ACT, for example, defines its model inputs in processor_act.py:

input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=dataset_stats,
device=config.device,
),
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features, norm_map=config.normalization_mapping, stats=dataset_stats
),
DeviceProcessorStep(device="cpu"),
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)

update_policy

The workflow resembles ordinary PyTorch training. A policy inherits from nn.Module and includes training, evaluation, and action output. For ACT:

For the Action Chunk Transformer architecture, see https://arxiv.org/abs/2304.13705. Its main ideas include:

  • Predict multiple steps for imitation learning rather than a single step, using the Transformer architecture to maintain smoothness.
  • During training, use a VAE encoder for observations/state; during inference, use the prior mean z=0. The VAE compresses inputs into a normally distributed latent space and samples from it. Using the prior’s mean and standard deviation at inference improves generalization.

ACT training inputs and outputs:

def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, tuple[Tensor, Tensor] | tuple[None, None]]:
"""A forward pass through the Action Chunking Transformer (with optional VAE encoder).
`batch` should have the following structure:
{
[robot_state_feature] (optional): (B, state_dim) batch of robot states.
[image_features]: (B, n_cameras, C, H, W) batch of images.
AND/OR
[env_state_feature]: (B, env_dim) batch of environment states.
[action_feature] (optional, only if training with VAE): (B, chunk_size, action dim) batch of actions.
}
Returns:
(B, chunk_size, action_dim) batch of action sequences
Tuple containing the latent PDF's parameters (mean, log(σ²)) both as (B, L) tensors where L is the
latent dimension.
"""

ACT’s Transformer decoder uses an all-zero matrix, adding positional encodings to obtain embeddings for cross-attention.

decoder_in = torch.zeros(
(self.config.chunk_size, batch_size, self.config.dim_model),
dtype=encoder_in_pos_embed.dtype,
device=encoder_in_pos_embed.device,
)
decoder_out = self.decoder(
decoder_in,
encoder_out,
encoder_pos_embed=encoder_in_pos_embed,
decoder_pos_embed=self.decoder_pos_embed.weight.unsqueeze(1),
)
#...
# 最后用 action_head 把 model output 转成有限维度的 action
# Final action regression head on the output of the transformer's decoder.
self.action_head = nn.Linear(config.dim_model, self.config.action_feature.shape[0])

Other Concepts Encountered

  • AE (Autoencoder): Maps high-dimensional inputs into lower dimensions.
  • VAE (Variational Autoencoder): Introduces a distribution to regularize embeddings in the lower-dimensional space.
  • VQ-VAE (Vector Quantized VAE): Uses a finite codebook, {e₁, e₂, …, e_K}.
  • KL divergence: Describes the difference between two probability distributions.