Skip to content

rldb, algo, eval: leave an untracked hand out of losses, stats and bounds - #725

Draft
RyanPCo wants to merge 1 commit into
ryanco/lance-require-handsfrom
ryanco/hand-masks
Draft

RyanPCo wants to merge 1 commit into
ryanco/lance-require-handsfrom
ryanco/hand-masks

Conversation

@RyanPCo

@RyanPCo RyanPCo commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Samples with action_hand_mask / proprio_hand_valid (Lance leaves) now:

  • HPT/RDT (DenoisingPolicy) multiply the pad mask by the hand's channels of
    the bimanual layout, whatever use_pad_mask says; aux heads see the same flags.
  • Pi takes a masked mean over the converter's packed slots; slots the
    converter inserts (gripper zeros, padding) stay in, so an all-tracked batch
    gives today's loss.
  • Norm stats set untracked cells to NaN and use nan-aware stats per cell, in
    both the DataLoader and the per-episode cache paths; the bounds check skips
    those cells.
  • Proprio of an untracked hand reads as the dataset mean pose: the normalized
    mean, so 0 on normalized channels and the mean on rot6d pass-through ones.
  • HPT and Pi eval take an untracked hand's ground truth from the prediction,
    so it adds no error and the viz revert never sees the zero fill rotation.

https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw

RyanPCo commented Oct 22, 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 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 high, 3 low, 2 nit.

return self.loss_fn(pred, target, pad_mask=data.get("pad_mask"))
# Unlike the pad mask, always applied: an untracked hand's action channels
# hold fill values, not motion.
mask = _with_hand_mask(

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.

high (correctness): Under HPT this always gets None. HPT._robomimic_to_hpt_data never copies action_hand_mask from the batch into data, so HPT heads are still trained on the identity-pose fill of an untracked hand. Copy the key in _robomimic_to_hpt_data, and add a test that runs HPT's compute_loss with a left-only mask and checks that changing the right hand's target leaves the loss unchanged.

Comment thread egomimic/rldb/multi.py Outdated

HAND_FLAGS = {"action_keys": "action_hand_mask", "proprio_keys": "proprio_hand_valid"}
# Norm-sample key holding the hand_keep mask of norm key k, as f"{k}{KEEP_SUFFIX}".
KEEP_SUFFIX = "#keep"

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 (simplification): The hand-keep masks ride in collected under f"{k}#keep" keys, so every consumer has to know to pop them before treating collected as {norm_key: samples}. KEEP_SUFFIX is also imported into episode_norm_samples and written into its cache rows. Returning the masks as a separate keeps dict from _collect_norm_samples / collect, aligned with collected[k], would let you drop KEEP_SUFFIX and keep the dict type dict[str, list[np.ndarray]].

channels = hand_channel_mask(
hand_mask.to(actions.device, actions.dtype), actions.shape[-1]
)
if channels is 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 (duplication): This None-layout branch (return no mask if every hand is tracked, else raise 'action width ... has no bimanual layout ...') is duplicated verbatim in PI._packed_hand_mask. Consider a pose_utils helper (e.g. require_hand_channel_mask(hands, width)) used by both, so the rule and the message stay in one place.

return batch
return {
**batch,
ac_key: torch.where(keep[:, None, :] > 0.5, gt, pred.to(gt.device, gt.dtype)),

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): Copying predictions into the untracked GT channels zeroes their error, but MSE, Frechet and layout_metrics still average over those channels. Validation error is therefore deflated in proportion to how often a hand is untracked, and runs with different require_hands_in_frame settings are not comparable. Weight the metrics by the hand channel mask (masked error sum / mask sum), and use the fill only for the viz transforms.

Comment thread egomimic/rldb/multi.py Outdated

@staticmethod
def _compute_stats_for_array(X):
if np.isnan(X).any():

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): The NaN branch duplicates all nine stats, differing only in np.X vs np.nanX. Pick the ops once (e.g. mean, std, ... = (np.nanmean, ...) if np.isnan(X).any() else (np.mean, ...)) and build the dict a single time so a new stat cannot drift between the two paths.

Comment thread egomimic/rldb/multi.py
# An untracked hand's channels hold fill values; the loss and the
# stats leave them out, so they cannot reject a sample either.
keep = None
if any(f in data for f in HAND_FLAGS.values()):

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 (simplification): hand_keep already returns None without flags, so the any(f in data ...) pre-check is redundant. The torch.ones_like(arr, dtype=bool) fallback allocates a full mask per key per sample on every flagless (zarr) sample. Consider keeping keep=None and applying & keep only when it is set, e.g. bad = ~torch.isfinite(arr); if keep is not None: bad &= keep, and likewise for below/above.

…unds

Samples with action_hand_mask / proprio_hand_valid (Lance leaves) now:
- HPT/RDT (DenoisingPolicy) multiply the pad mask by the hand's channels of
  the bimanual layout, whatever use_pad_mask says; aux heads see the same flags.
- Pi takes a masked mean over the converter's packed slots; slots the
  converter inserts (gripper zeros, padding) stay in, so an all-tracked batch
  gives today's loss.
- Norm stats set untracked cells to NaN and use nan-aware stats per cell, in
  both the DataLoader and the per-episode cache paths; the bounds check skips
  those cells.
- Proprio of an untracked hand reads as the dataset mean pose: the normalized
  mean, so 0 on normalized channels and the mean on rot6d pass-through ones.
- HPT and Pi eval take an untracked hand's ground truth from the prediction,
  so it adds no error and the viz revert never sees the zero fill rotation.

Claude-Session: https://claude.ai/code/session_01Kt1E8T2x8kdV9akAvbJyVd
@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

  • HPT never passes action_hand_mask to DenoisingPolicy: _robomimic_to_hpt_data copies action_hand_mask. New test: through HPT data conversion, changing the right hand's target leaves the loss unchanged under a LEFT_ONLY mask.
  • Hand keep masks smuggled through collected under #keep suffix: _collect_norm_samples and episode_norm_samples.collect return a separate keeps dict, and collected's type is restored. KEEP_SUFFIX is now private to the episode cache file layout.
  • No-bimanual-layout rule duplicated in pi.py: Added pose_utils.require_hand_channel_mask, used by DenoisingPolicy and PI._packed_hand_mask.
  • Stats dict duplicated for NaN path: Chooses the nan* or plain ops once and builds the dict once.
  • Redundant flag pre-check and all-True mask per key: No more all-True mask; keep is applied only when set. The flag pre-check stays because MultiDatasets built without key_types (bounds-check tests) need it.

Partial

  • Filling untracked GT with predictions deflates validation metrics: per_tracked_hand divides the channel-mean metrics (MSE, L2, rotation error) by the batch's tracked-hand fraction in eval_hpt and eval_pi. Keys are kept so DDP log_dict stays in sync, but DTW and Frechet stay deflated. Added a test.

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