Skip to content

loss: mask the repeat-last action padding out of the HPT loss - #649

Draft
RyanPCo wants to merge 1 commit into
ryanco/vision-backbonefrom
ryanco/loss-masking
Draft

RyanPCo wants to merge 1 commit into
ryanco/vision-backbonefrom
ryanco/loss-masking

Conversation

@RyanPCo

@RyanPCo RyanPCo commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Adds an opt-in mask so the HPT loss can skip the repeat-last action padding at episode and annotation tails. The dataset now emits the mask; the loss uses it only when use_pad_mask: true.

Behaviour changes

None by default. use_pad_mask is false in hpt_bc_mecka_6d_300M.yaml and hpt_bc_keypoints_wrist_300M.yaml, and every other HPT config takes the __init__ default of false. With it off, the mask is dropped from the batch and pad_mask stays all ones, which is exactly F.mse_loss.

Turning it on changes the loss: the repeat-last padding stops being supervised, so runs with and without it are not comparable. On the three pinned flagship episodes, 6.4–7.6 % of supervised action steps are repeat-last padding and 12.8–15.2 % of samples contain some. The layout-aware eval metrics would have to be masked in the same change, or train and val would measure different things.

What changed

  • Dataset: ZarrDataset emits action_pad_mask (1.0 per real frame, 0.0 per repeat-last pad) next to every horizoned action chunk. It honours the annotation-cutoff EOS clamp.
  • Interpolation: a new InterpolatePadMask transform carries the mask through the 30 → 100 upsampling on the interpolators' own grid. An output step is real only if both source frames it interpolates between are real. It is a no-op without the key and idempotent, so a mode that chains two builders (include_ee_pose) upsamples once. It is added to the eva and human cartesian and keypoint transform lists.
  • HPT: with use_pad_mask on, process_batch_for_training builds pad_mask (B, S, 1) from the dataset mask instead of ones, and raises if the mask length doesn't match the action horizon.
  • Loss: DenoisingPolicy.loss_fn takes the mask and averages the squared error over the valid steps only. An all-padding chunk contributes 0, not NaN.
  • Padding is still repeat-last and no tail samples are dropped.

Notes

  • It ships off to match openpi, which requests action_is_pad from LeRobotDataset but never uses it (Pi0.compute_loss averages over the whole horizon).
  • LeRobot's own policies do mask, but they take .mean() over all elements including the zeroed ones, so their loss shrinks as padding grows. This divides by the valid count instead, which keeps the gradient magnitude constant.

Tests

  • New tests/unit/test_loss_masking.py: the dataset mask (tail chunks, annotation cutoff, survival through bounds check and normalization), InterpolatePadMask (30 → 100, stride, no-op, idempotent), loss_fn against F.mse_loss, and the use_pad_mask flag in process_batch_for_training and the model yamls.
  • tests/unit/test_action_chunk_transforms.py: the eva and human transform-list snapshots now include InterpolatePadMask.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw

RyanPCo commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/649 September 16, 2026 17:22
@RyanPCo
RyanPCo changed the base branch from graphite-base/649 to ryanco/infra-port September 16, 2026 17:22
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/649 September 16, 2026 17:23
@RyanPCo
RyanPCo changed the base branch from graphite-base/649 to ryanco/infra-port September 16, 2026 17:23
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/649 September 16, 2026 17:24
@RyanPCo
RyanPCo changed the base branch from graphite-base/649 to ryanco/infra-port September 16, 2026 17:25
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/649 September 16, 2026 17:29
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from cc32016 to fc6258a Compare September 16, 2026 17:30
@RyanPCo
RyanPCo changed the base branch from graphite-base/649 to ryanco/vision-backbone September 16, 2026 17:30
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from e7468e3 to b086056 Compare September 17, 2026 14:52
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from fc6258a to ad2cc51 Compare September 17, 2026 14:52
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from f9a08b1 to 73fe194 Compare October 12, 2026 21:50
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from d343a85 to 2302dcf Compare October 12, 2026 23:19
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from 73fe194 to 11cbb10 Compare October 12, 2026 23:19
@RyanPCo RyanPCo mentioned this pull request Oct 19, 2026
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from 2302dcf to e680eb5 Compare October 19, 2026 19:41
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from 11cbb10 to 868598a Compare October 19, 2026 19:41
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from e680eb5 to cb1db1d Compare October 19, 2026 20:22
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from 868598a to 133be3f Compare October 19, 2026 20:22
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from cb1db1d to f6d6d51 Compare October 30, 2026 18:27
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from 133be3f to e9ac33c Compare October 30, 2026 18:27

@RyanPCo RyanPCo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated adversarial review (read-only; findings were checked by a second skeptical pass before posting): 1 low, 3 nit.

Comment thread egomimic/algo/hpt.py
action_pad_mask = processed_batch[embodiment_id].pop(
"action_pad_mask", None
)
if self.use_pad_mask and action_pad_mask is not None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low (correctness): use_pad_mask only takes effect through DenoisingPolicy.compute_loss. PolicyHead.compute_loss (MLPPolicyHead / MultiBlockTransformerDecoder) still computes LOSS(pred_action, target_action) and ignores data["pad_mask"]. On a non-diffusion HPT config the flag is accepted and the mask is built and validated, but the padded tail is still fully supervised. Either apply the mask in PolicyHead.compute_loss or raise when use_pad_mask is set and the head is not a DenoisingPolicy.

stride=stride,
mode="xyzwxyz",
),
InterpolatePadMask(new_chunk_length=chunk_length, stride=stride),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (duplication): InterpolatePadMask(new_chunk_length=chunk_length, stride=stride) is pasted into five builders (eva.py x2, human.py x3). A future builder that forgets it only shows up as a horizon-mismatch ValueError once use_pad_mask is on. Since the transform is idempotent, consider appending it once in a shared helper, or after the builder in get_transform_list.

Comment thread egomimic/algo/hpt.py Outdated
device = processed_batch[embodiment_id][ac_key].device
processed_batch[embodiment_id]["pad_mask"] = torch.ones(
B, S, 1, device=device
# Dropped either way: downstream sees the same keys as before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (comments): "downstream sees the same keys as before" describes the diff. Say why instead, e.g. # action_pad_mask is consumed here; only pad_mask goes downstream.

Comment thread tests/unit/test_loss_masking.py Outdated
# ---------------------------------------------------------------------------


def _pad_mask_transform_cls():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (test-quality): InterpolatePadMask exists now, so _pad_mask_transform_cls ("so the module still collects before the transform exists") is leftover TDD scaffolding. Import it at module top and drop the helper.

ZarrDataset emits a real action_pad_mask (1.0 per real frame, 0.0 per
repeat-last pad, honouring the annotation-cutoff EOS clamp) next to every
horizoned action chunk. A new InterpolatePadMask transform carries it
through the 30 -> 100 upsampling on the interpolators' own grid: an output
step is real only if both source frames it interpolates between are real.

HPT gains a use_pad_mask algo key (default false, declared in the two flagship
model yamls; every other HPT config takes the __init__ default). With it on,
process_batch_for_training builds pad_mask (B, S, 1) from the dataset mask
instead of ones; DenoisingPolicy.loss_fn takes the mask and means the squared
error over the valid steps only. With it off nothing changes: the mask is
dropped from the batch and pad_mask stays all ones, which is exactly
F.mse_loss.

It ships off to match openpi, which asks LeRobotDataset for action_is_pad and
then ignores it (Pi0.compute_loss means over the whole horizon). LeRobot's own
policies do mask, but reduce with .mean() over all elements including the
zeroed ones, so their loss shrinks as padding grows; this divides by the valid
count instead, which keeps the gradient magnitude constant. Measured on the
three pinned flagship episodes, 6.4-7.6 % of supervised action steps are
repeat-last padding and 12.8-15.2 % of samples contain some, so turning it on
changes the loss: a run before and a run after are not comparable, and the
layout-aware eval metrics would have to be masked in the same change or train
and val would be measuring different things.

Padding is still repeat-last and no tail samples are dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBmhpVy7ZcMtsBJWh38cJp
@RyanPCo
RyanPCo force-pushed the ryanco/vision-backbone branch from f6d6d51 to 63e3374 Compare October 30, 2026 23:05
@RyanPCo
RyanPCo force-pushed the ryanco/loss-masking branch from e9ac33c to 19e047a Compare October 30, 2026 23:05
@RyanPCo

RyanPCo commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Review findings: status after the fixup pass. The fixes are squashed into this PR's commits, and the whole stack was force-pushed.

Fixed

  • use_pad_mask is silently a no-op for non-denoising HPT heads: HPT.init now raises ValueError when use_pad_mask is set without diffusion. No unit test: building a real HPT is heavy.
  • Comment references the previous behavior: Reworded as suggested.
  • TDD scaffolding left in the test module: InterpolatePadMask is imported at module top; helper deleted.

Not fixed

  • InterpolatePadMask hand-inserted into each builder: Nit, not cheap: a central append would have to thread each builder's chunk_length and stride through get_transform_list. The transform is idempotent and a no-op without a mask.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant