Skip to content

Align Normalizer zero-delta handling with LeRobot training collator#112

Merged
StarrickLiu merged 1 commit into
X-Square-Robot:mainfrom
Papaercold:main
Jul 21, 2026
Merged

Align Normalizer zero-delta handling with LeRobot training collator#112
StarrickLiu merged 1 commit into
X-Square-Robot:mainfrom
Papaercold:main

Conversation

@Papaercold

Copy link
Copy Markdown
Contributor

Summary

Fix a zero-delta normalization mismatch between the LeRobot training collator and the shared model normalizer.

During fine-tuning + evaluation, I observed a case where training loss looked normal, but evaluation produced all-NaN action vectors. I traced this to Normalizer.normalize_data() dividing by a zero normalization delta.

Problem

The LeRobot training collator already handles zero deltas safely in:

wall_x/data/backends/lerobot/loader.py:473-481
def _normalize(cls, action, min_stat, delta):
    """
    Normalize action data using min-max normalization.
    """
    delta = torch.where(delta == 0, torch.ones_like(delta), delta)
    x = (action - min_stat) / delta
    x = x * 2 - 1
    x = torch.clamp(x, -1, 1)
    return x

This training-side normalization is used for agent_pos here:

wall_x/data/backends/lerobot/loader.py:535-553

and for action here:

wall_x/data/backends/lerobot/loader.py:560-578

So during training, if a dimension has delta == 0, the collator replaces that delta with 1 before division.

However, the shared normalizer in:

wall_x/model/core/action/normalizer.py:284-295

previously divided directly by self.delta[dataset_name]. For constant action/state dimensions, the normalization stats can have:

q99[i] == q01[i]
delta[i] == 0

Then normalization can produce 0 / 0 -> NaN. torch.clamp() does not remove NaNs, so these values can propagate into evaluation/inference actions.

This explains the behavior I saw:

Training path: delta == 0 is guarded -> loss stays normal
Evaluation/inference path: delta == 0 can divide by zero -> action becomes NaN

Fix

This PR updates Normalizer.normalize_data() to use the same zero-delta guard as the LeRobot training collator:

def normalize_data(self, xs, dataset_names):
    new_xs = []
    dataset_names = [name for name in dataset_names if name != "x2_multimodal"]
    for x, dataset_name in zip(xs, dataset_names):
        delta = self.delta[dataset_name]
        delta = torch.where(delta == 0, torch.ones_like(delta), delta)
        x = (x - self.min[dataset_name]) / delta
        x = x * 2 - 1
        x = torch.clamp(x, -1, 1)
        new_xs.append(x)
    new_xs = torch.stack(new_xs)
    return new_xs

This does not change tensor shapes or nonzero-delta behavior. It only prevents division by zero for zero-range dimensions.

Validation

I reproduced the issue in a fine-tuning + evaluation workflow where predicted actions became all NaNs. After this change, the same workflow no longer produced all-NaN action vectors.

@StarrickLiu

Copy link
Copy Markdown
Collaborator

Thanks @Papaercold — this is a clean, correct fix and the diagnosis in #113 is spot on. The training-side LeRobot collator already guards delta == 0, but the shared Normalizer.normalize_data() divided by delta directly, so any constant dimension (q99 == q01delta == 0) produced 0 / 0 → NaN at eval/inference time while training stayed stable.

I confirmed the fix is also complete: normalize_data() is the only divide-by-delta path, and unnormalize_data() multiplies by delta (multiply-by-zero is fine), so this single guard closes the NaN source. It also matches the exact guard already used in the collator, keeping train and eval normalization consistent.

Merging. Thanks for the careful report and fix — much appreciated! 🙏

@StarrickLiu
StarrickLiu merged commit 6764e8f into X-Square-Robot:main Jul 21, 2026
1 check passed
StarrickLiu pushed a commit to StarrickLiu/wall-x that referenced this pull request Jul 22, 2026
Full regenerated snapshot of the public tree from the internal refactor_2604
export pipeline (delete + overwrite). Only the intended changes remain vs the
previous release (stale public_overlay drift was synced back first, so no
published hotfix / dependency bump is reverted):

- Bake MoE / rmsnorm / silu / m_rope CUDA kernels, compiled at install time;
  training MFU reaches internal parity (~53%).
- Fix the m_rope rope-convention (fp32 cos) so LIBERO eval runs on the vanilla
  model path.
- Replace the closed opt serving stack with an opt-free vanilla websocket
  serving path (WallXVanillaPolicy + launch_vanilla_serving).
- Ship wall_x/distributed; strip robotwin/vga; exclude non-LIBERO subsystems
  (rl / eval / jax_openpi / VGA data).
- DMuon support: libero_dmuon.yml + dmuon git dependency; recommended DMuon LRs
  (muon_lr=1e-4 / adamw_lr=5e-5) on all LIBERO configs.
- Keep the published X-Square-Robot#112 normalizer divide-by-zero guard; keep torch==2.10 /
  transformers==5.2 dependency pins and the full pyproject config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StarrickLiu pushed a commit to StarrickLiu/wall-x that referenced this pull request Jul 22, 2026
…bsocket serving, DMuon LIBERO configs

- Bake fused CUDA kernels (two_expert MoE / rmsnorm / silu / rope) into
  csrc/ with install-time CUDAExtension build (reaches internal-parity
  training MFU ~53%).
- Add minimal vanilla websocket serving (run_serving.sh + vendored harrix
  policy) validated bit-identical to the eval predict_batch path.
- DMuon LIBERO/lerobot configs use recommended LRs (muon_lr=1e-4,
  adamw_lr=5e-5) + ZeRO-2; add libero_dmuon.yml.
- Preserve published hotfixes (X-Square-Robot#110 arXiv links, X-Square-Robot#112 normalizer delta==0
  guard) and richer public scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StarrickLiu pushed a commit to StarrickLiu/wall-x that referenced this pull request Jul 22, 2026
…bsocket serving, DMuon LIBERO configs

- Bake fused CUDA kernels (two_expert MoE / rmsnorm / silu / rope) into
  csrc/ with install-time CUDAExtension build (internal-parity MFU ~53%).
- Ship the opt-free vanilla websocket serving stack (launch_vanilla_serving
  + WallXVanillaPolicy) validated bit-identical to the eval predict_batch
  path; run_serving.sh exposes the full vanilla CLI and rejects
  full-stack-only flags with guidance. Returns raw action chunks.
- DMuon LIBERO/lerobot configs use recommended LRs (muon_lr=1e-4,
  adamw_lr=5e-5) + ZeRO-2; add libero_dmuon.yml.
- Preserve published hotfixes (X-Square-Robot#110 arXiv links, X-Square-Robot#112 normalizer delta==0
  guard) and the richer public scripts (restored from oss2/published).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StarrickLiu pushed a commit to StarrickLiu/wall-x that referenced this pull request Jul 23, 2026
…rving, restructured-harrix vendoring, DMuon LIBERO configs

- Bake fused CUDA kernels (two_expert MoE / rmsnorm / silu / rope) into
  csrc/ with install-time CUDAExtension build (internal-parity MFU ~53%).
- Vendor the restructured harrix (e2e_infer / model_executors / serving.replica
  + handlers/monitoring) for the LIBERO eval + vanilla websocket serving; the
  vanilla policy path is validated bit-identical to the eval predict_batch.
- run_serving.sh exposes the full vanilla CLI and rejects full-stack-only flags
  with guidance; returns raw action chunks.
- DMuon LIBERO/lerobot configs use recommended LRs (muon_lr=1e-4,
  adamw_lr=5e-5) + ZeRO-2; add libero_dmuon.yml.
- Preserve published hotfixes (X-Square-Robot#110 arXiv links, X-Square-Robot#112 normalizer delta==0 guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants