Skip to content

fix(action): let callers request the action-normalizer forward clamp - #190

Merged
lfengad merged 1 commit into
mainfrom
fix/droid-normalizer-forward-clamp
Aug 13, 2026
Merged

fix(action): let callers request the action-normalizer forward clamp#190
lfengad merged 1 commit into
mainfrom
fix/droid-normalizer-forward-clamp

Conversation

@pengcuo

@pengcuo pengcuo commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

#182 made action_normalization work for DROID, but built the normalizer with the apply_forward_clamp=False default, so the dataset does not clamp into [-1, 1].

The cookbooks did clamp while they normalized by hand (NVIDIA/cosmos#314). NVIDIA/cosmos#317 moved them onto the dataset, and the clamp went away with the hand-written code.

This threads apply_forward_clamp through BaseActionLeRobotDataset and DROIDLeRobotDataset so the caller names the intent, the way #314 did at its construction site:

DROIDMergedLeRobotDataset(
    root=..., action_normalization="quantile_rot", apply_forward_clamp=True,
)

No default behavior change

The default stays False, matching resolve_action_normalization, so every existing caller keeps its current behavior:

construction forward_clamp
no flag passed None (identical to main)
apply_forward_clamp=True (-1.0, 1.0)
action_normalization=None no normalizer

On DROIDLeRobotDataset the parameter is appended rather than placed next to action_normalization: that signature is positional-or-keyword, so inserting mid-list would shift tolerance_s, viewpoint, use_success_only and eight more for any positional caller. BaseActionLeRobotDataset.__init__ is keyword-only, so the placement there is free.

Why it matters

Measured on the bundled droid_lerobot_example — 706 stride-1 windows, 112,960 action scalars — clamped vs unclamped:

scalars outside q01/q99 1,253 (1.1092%)
windows with ≥1 outlier 155/706 (21.95%)
non-overlapping chunks hit 11/45 (24.4%)
max |unclamped| 1.7818
max |difference| 0.78180099

87% of those outliers are on the rot6d channels, led by R21 (496) and R20 (336). The gripper never exceeds the range — its q01/q99 is exactly [0, 1]. Nothing raises on this path: the values stay plausible floats through the transform, the serving JSON and the model.

The rot6d diagonal channels are the ones with headroom to get much worse. R00/R11 have a q01/q99 half-width of ~7.7e-4, so they saturate at roughly 3.2° of per-frame rotation; at 15 fps, 10°/frame normalizes to about -19 unclamped. This fixture never rotates that fast — its worst case is 1.78 — so that figure is arithmetic from the stats, not a measurement.

Verification

With apply_forward_clamp=True the dataset reproduces #314 exactly on the bundled fixture:

  • all 706/706 windows identical, including the 155 where the clamp engages, max|diff| = 0.0000000000
  • the two normalizers also agree in offset, scale, forward_clamp, forward_clamp_mask and type, so they are the same function on every input, not just the measured ones

The first five chunks — the ones the cookbooks use — contain no outliers at all, so a five-chunk comparison is bit-identical either way and cannot distinguish clamped from unclamped.

Existing action tests (43) pass.

Follow-up

NVIDIA/cosmos will pass the flag from the four DROID forward-dynamics notebooks once this lands; that PR is blocked on this one.

🤖 Generated with Claude Code

#182 made `action_normalization` work for DROID but built the normalizer
with the `apply_forward_clamp=False` default, so the dataset does not clamp
into [-1, 1]. The cookbooks did clamp while they normalized by hand
(NVIDIA/cosmos#314); NVIDIA/cosmos#317 moved them onto the dataset and the
clamp silently went away.

This threads `apply_forward_clamp` through `BaseActionLeRobotDataset` and
`DROIDLeRobotDataset` so the caller names the intent, the way #314 did at
its construction site, rather than inheriting it from a default:

    DROIDMergedLeRobotDataset(
        root=..., action_normalization="quantile_rot", apply_forward_clamp=True,
    )

The default stays False, matching `resolve_action_normalization`, so no
existing caller changes behavior. On `DROIDLeRobotDataset` the parameter is
appended rather than placed next to `action_normalization`: that signature is
positional-or-keyword, so inserting mid-list would shift every later argument
for positional callers.

Measured on the bundled `droid_lerobot_example` (706 stride-1 windows,
112,960 action scalars), clamped vs unclamped:

  scalars outside q01/q99         1,253  (1.1092%)
  windows with >=1 outlier          155/706  (21.95%)
  non-overlapping chunks hit         11/45   (24.4%)
  max |unclamped|                  1.7818
  max |difference|                 0.78180099

87% of those outliers are on the rot6d channels; the gripper never exceeds
the range, its q01/q99 being exactly [0, 1]. The first five chunks -- the
ones the cookbooks use -- contain no outliers at all, which is why the
existing five-chunk comparison is bit-identical either way and cannot
distinguish the two.

With `apply_forward_clamp=True` the dataset reproduces #314 exactly: all
706 windows identical, including the 155 where the clamp engages
(max|diff| = 0.0000000000). The two normalizers also agree in offset,
scale, forward_clamp, forward_clamp_mask and type, so they are the same
function on every input, not just the measured ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lfengad
lfengad merged commit 16315e7 into main Aug 13, 2026
9 checks passed
@lfengad
lfengad deleted the fix/droid-normalizer-forward-clamp branch August 13, 2026 07:11
pengcuo added a commit to NVIDIA/cosmos that referenced this pull request Aug 13, 2026
> **Blocked on NVIDIA/cosmos-framework#190.** Passing
`apply_forward_clamp` before that parameter exists raises `TypeError`.

Two fixes to the four DROID forward-dynamics notebooks, both regressions
from #317. `4 files changed, 8 insertions(+), 2 deletions(-)`.

## 1. Restore two lines dropped from
`run_fd_with_cosmos_framework.ipynb`

#317 removed the last source line of each of the two cells it edited:

- `return records` — end of `create_record_from_dataset`
- `print(droid_fd_input_path.read_text())` — end of the DROID plan cell

Without the return, `create_record_from_dataset` yields `None` and both
call sites fail:

```python
droid_records = create_record_from_dataset(droid_dataset, num_chunks, chunk_length)
droid_fd_input_path.write_text("".join(json.dumps(r) + "\n" for r in droid_records))
# TypeError: 'NoneType' object is not iterable
```

The **UMI** cell shares the same helper and breaks identically, though
it has nothing to do with DROID action normalization. This notebook
cannot currently be run end to end on `main`.

### Why only this notebook

The two lost lines are exactly the two whose `source` entry had no
trailing newline. The cells edited in the other three notebooks all
ended with one and came through intact:

| notebook | cell | last line before | dropped |
|---|---|---|---|
| cosmos_framework | 12 | `    return records` | **yes** |
| cosmos_framework | 32 | `print(droid_fd_input_path.read_text())` |
**yes** |
| diffusers | 41 | `display(droid_first_frame)\n` | no |
| sglang | 15 | `print(robotics_fd_input_path.read_text())\n` | no |
| vllm_omni | 16 | `print(robotics_fd_input_path.read_text())\n` | no |

That is the signature of a `"".join(source).split("\n")[:-1]`
round-trip: when the last entry ends in `\n` the trailing element is an
empty string and slicing it off is harmless, otherwise it discards a
real line. Worth knowing for whatever tooling edits these notebooks — it
will do this again.

## 2. Ask the dataset to forward-clamp DROID actions

#314 built the normalizer by hand with `apply_forward_clamp=True`. #317
moved that onto the dataset, which builds it with the `False` default,
so the notebooks stopped clamping into `[-1, 1]`. Passing the flag
matches #314 again.

Measured on the bundled `droid_lerobot_example` — 706 stride-1 windows,
112,960 action scalars — clamped vs unclamped:

| | |
|---|---|
| scalars outside q01/q99 | 1,253 (1.1092%) |
| windows with ≥1 outlier | 155/706 (21.95%) |
| non-overlapping chunks hit | 11/45 (24.4%) |
| max \|unclamped\| | 1.7818 |
| max \|difference\| | 0.78180099 |

87% of those outliers are on the rot6d channels; the gripper never
exceeds the range, its q01/q99 being exactly `[0, 1]`.

The first five chunks — the ones these notebooks use — contain **no**
outliers at all. That is why a five-chunk comparison comes out
bit-identical either way and cannot distinguish clamped from unclamped,
which is what the existing verification measured.

## Verification

With the flag, the dataset reproduces #314 exactly on the bundled
fixture: all **706/706** windows identical, including the **155** where
the clamp engages, `max|diff| = 0.0000000000`. The two normalizers also
agree in `offset`, `scale`, `forward_clamp`, `forward_clamp_mask` and
type, so they are the same function on every input.

Structural checks on the notebooks: cell counts unchanged, every cell's
last source line unchanged, all code cells parse,
`create_record_from_dataset` returns `records` again.

All four notebook dataset paths were then constructed with their own
arguments and confirmed to build a clamping normalizer:

| notebook | construction | `forward_clamp` |
|---|---|---|
| cosmos_framework | `DROIDMergedLeRobotDataset(root, chunk_length,
...)` | `(-1.0, 1.0)` |
| sglang | `DROIDMergedLeRobotDataset(root, chunk_length, ...)` |
`(-1.0, 1.0)` |
| vllm_omni | `DROIDMergedLeRobotDataset(root, ...)` — no `chunk_length`
| `(-1.0, 1.0)` |
| diffusers | `DROIDLeRobotDataset(root, chunk_length,
use_success_only=True, ...)` | `(-1.0, 1.0)` |

`run_fd_with_diffusers.ipynb` is the one that differs: it uses
`DROIDLeRobotDataset` rather than the merged subclass, and that class
picks its feature layout from the directory basename, so it rejects
`droid_lerobot_example` outright with `ValueError: Unknown version`. It
was exercised by reproducing the release layout the notebook itself
builds — a `droid_plus_lerobot_640x360_20260412/success` symlink to the
bundled sample — which it accepts (706 windows, same episode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (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.

3 participants