Skip to content

Commit bbda321

Browse files
fwd4claudelfengadychao-nvidia
authored
Add DROID action-policy SFT recipe (Cosmos3-Nano, joint_pos) (NVIDIA#24)
## Summary Adds a **DROID action-policy SFT recipe** for `nvidia/Cosmos3-Nano`, mirroring the internal `droid_lerobot_8b` policy run, so users can post-train the action-generation + action heads on DROID (LeRobot v3.0) data. ## What's included - **`data/vfm/action/datasets/droid_lerobot_dataset.py`** — DROID LeRobot dataset: compact columnar load + episode-aware windowing (replaces an eager full-table materialization), plus `joint_pos` (8D: 7 joints + gripper) and `use_state` support. - **`data/vfm/action/datasets/action_sft_dataset.py`** (new) — `get_action_droid_sft_dataset(...)` wrapping the dataset through `ActionTransformPipeline`. - **`configs/.../action/posttrain_config/action_policy_droid_nano.py`** (new) — registered `action_policy_droid_nano` experiment (Cosmos3-Nano / 8B MoT): optimizer trains gen+action heads (5× LR on action heads), `LambdaLinear` schedule, count-based batch, res480, `encode_exact_durations=[33]` (chunk 32 → 33 frames). - **`checkpoint/dcp.py`** — EMA warm-start: when `keys_to_skip_loading` excludes `net_ema.`, initialize `net_ema = net` from the base weights so EMA starts from the init rather than zeros. - **`examples/toml/sft_config/action_policy_droid_{nano,repro}.toml`** — 1-GPU smoke + scaled (res480) configs. - **`examples/launch_sft_action_policy_droid.sh`** + **`docs/action_policy_droid_posttraining.md`** — runnable launcher and walkthrough. ## Validation End-to-end on H200: - **1 node / 8×H200** — dry-run + training at res480, `max_samples_per_batch=32` (64 OOMs at 139 GiB; internal used 128 on GB200). - **2 nodes / 16 ranks** — HSDP `shard 8 × replicate 2`, `TRAIN_EXIT=0`. - Recipe faithful to internal `droid_lerobot_8b`: lr 1e-4 / betas / wd, 5× action-head LR, `LambdaLinear`, shift `{256:3,480:5,720:10}`, `concat_view`, `chunk_length=32`. ## Notes - Count-based batch (`max_samples_per_batch`, `max_sequence_length=None`) lives in the experiment Python — TOML cannot express `null`, and the loader only overrides keys present in the TOML. - Base checkpoint: convert `nvidia/Cosmos3-Nano` → DCP and pass via `BASE_CHECKPOINT_PATH`; action heads init fresh (skipped on load). --------- Signed-off-by: Hao Liang <haolia@nvidia.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: lfengad <liangf@nvidia.com> Co-authored-by: Yu-Wei Chao <82182961+ychao-nvidia@users.noreply.github.com>
1 parent 55c6276 commit bbda321

9 files changed

Lines changed: 750 additions & 28 deletions

File tree

cosmos_framework/checkpoint/dcp.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,20 @@ def load(
539539
"Ensure the model has net_ema submodule."
540540
)
541541
_state_dict[sd_key] = _state_dict[key_ema]
542+
elif warm_start and any(str(s).startswith("net_ema") for s in self.keys_to_skip_loading):
543+
# Only when net_ema.* is explicitly skipped on load (e.g. an HF->DCP
544+
# init from convert_model_to_dcp that has only net.*): the skipped
545+
# net_ema.* keep build_net() construction values (random init when
546+
# vlm_config.pretrained_weights.enabled=False), which would seed EMA
547+
# from random weights -> copy net.* -> net_ema.* so EMA starts from the
548+
# freshly-loaded init. When net_ema.* IS loaded (e.g. a training DCP
549+
# that carries a trained EMA), do NOT clobber it.
550+
log.info("Warm start: net_ema. skipped on load -> resetting net_ema = net.")
551+
for sd_key in list(_state_dict.keys()):
552+
if sd_key.startswith("net."):
553+
key_ema = "net_ema." + sd_key.removeprefix("net.")
554+
if key_ema in _state_dict:
555+
_state_dict[key_ema] = _state_dict[sd_key]
542556
results = _model_wrapper.load_state_dict(_state_dict)
543557
if results is not None:
544558
if len(results.missing_keys) > 0:

cosmos_framework/configs/base/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,5 @@ def make_config() -> Config:
9898
# Register shipped experiments explicitly.
9999
import cosmos_framework.configs.base.experiment.sft.vision_sft_nano # noqa: F401
100100
import cosmos_framework.configs.base.experiment.sft.vision_sft_super # noqa: F401
101+
import cosmos_framework.configs.base.experiment.action.posttrain_config.action_policy_droid_nano # noqa: F401
101102
return c
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: OpenMDW-1.1
3+
4+
"""``action_policy_droid_nano`` — Cosmos3-Nano DROID action policy SFT recipe.
5+
6+
Mirrors the vision SFT stack (PackingDataLoader + RankPartitionedDataLoader),
7+
but feeds the DROID action dataset (``joint_pos`` 8D + ``use_state``, raw/
8+
un-normalized — same as the internal ``droid_lerobot_8b_policy`` run) through
9+
``ActionTransformPipeline``, and trains the generation + action heads from the
10+
public ``nvidia/Cosmos3-Nano`` base.
11+
12+
Usage (1 node, 8 GPU)::
13+
14+
DROID_ROOT=/path/to/droid_lerobot_640x360/success \\
15+
BASE_CHECKPOINT_PATH=<Cosmos3-Nano DCP dir> \\
16+
WAN_VAE_PATH=<Wan2.2_VAE.pth> \\
17+
torchrun --nproc_per_node=8 -m cosmos_framework.scripts.train \\
18+
--sft-toml examples/toml/sft_config/action_policy_droid_repro.toml
19+
"""
20+
21+
import copy
22+
23+
from hydra.core.config_store import ConfigStore
24+
25+
from cosmos_framework.utils.lazy_config import LazyCall as L
26+
from cosmos_framework.utils.lazy_config import LazyDict
27+
28+
from cosmos_framework.configs.base.experiment.sft.models.nano_model_config import NANO_MODEL_CONFIG
29+
from cosmos_framework.data.vfm.joint_dataloader import (
30+
PackingDataLoader,
31+
RankPartitionedDataLoader,
32+
)
33+
from cosmos_framework.data.vfm.action.datasets.action_sft_dataset import get_action_droid_sft_dataset
34+
35+
cs = ConfigStore.instance()
36+
37+
38+
action_policy_droid_nano = LazyDict(
39+
dict(
40+
defaults=[
41+
{"override /model": "mot_fsdp"},
42+
{"override /data_train": None},
43+
{"override /data_val": None},
44+
# Match internal droid_lerobot_8b_policy: apex FusedAdam with fp32
45+
# master_weights + eps 1e-8. adamw + fused + eps 1e-6 (bf16, no fp32
46+
# master) under-steps the small 5x-lr action heads and leaves the action
47+
# loss on a noisy high plateau; an exact-match forward/optimizer test
48+
# confirmed the convergence gap was the optimizer, not the model.
49+
{"override /optimizer": "fusedadamw"},
50+
{"override /scheduler": "lambdalinear"}, # matches internal droid_lerobot_8b (was lambdacosine)
51+
{"override /checkpoint": "s3"},
52+
{
53+
"override /callbacks": [
54+
"basic",
55+
"optimization",
56+
"job_monitor",
57+
]
58+
},
59+
{"override /ema": "power"},
60+
{"override /tokenizer": "wan2pt2_tokenizer"},
61+
{"override /sound_tokenizer": None},
62+
{"override /cluster": None},
63+
{"override /vlm_config": None},
64+
{"override /ckpt_type": "dcp"},
65+
"_self_",
66+
],
67+
job=dict(
68+
project="cosmos3",
69+
group="action_sft",
70+
name="action_policy_droid_nano",
71+
wandb_mode="disabled",
72+
),
73+
model=dict(
74+
config=copy.deepcopy(NANO_MODEL_CONFIG), # action_gen=True, max_action_dim=64
75+
),
76+
optimizer=dict(
77+
betas=[0.9, 0.99],
78+
eps=1.0e-08,
79+
fused=True, # popped by build_optimizer for FusedAdam (fused by construction)
80+
# Generation + action heads (mirrors internal droid_lerobot_8b_policy).
81+
keys_to_select=[
82+
"moe_gen",
83+
"time_embedder",
84+
"vae2llm",
85+
"llm2vae",
86+
"action2llm",
87+
"llm2action",
88+
"action_modality_embed",
89+
],
90+
lr=2.0e-04, # matches internal droid_lerobot_8b_policy submit (--lr 2e-4)
91+
lr_multipliers={
92+
"action2llm": 5.0,
93+
"llm2action": 5.0,
94+
"action_modality_embed": 5.0,
95+
},
96+
optimizer_type="FusedAdam",
97+
weight_decay=0.05,
98+
),
99+
scheduler=dict(
100+
lr_scheduler_type="LambdaLinear", # matches internal droid_lerobot_8b (was LambdaCosine)
101+
cycle_lengths=[100], # smoke: 100 iters (real run sets via TOML)
102+
f_max=[0.4],
103+
f_min=[0.0],
104+
f_start=[0.0],
105+
verbosity_interval=0,
106+
warm_up_steps=[0],
107+
),
108+
trainer=dict(
109+
distributed_parallelism="fsdp",
110+
grad_accum_iter=1,
111+
logging_iter=1,
112+
max_iter=100, # smoke
113+
max_val_iter=None,
114+
run_validation=False,
115+
run_validation_on_start=False,
116+
save_zero_checkpoint=False,
117+
seed=42,
118+
timeout_period=999999999,
119+
validation_iter=100,
120+
compile_config=dict(recompile_limit=8, use_duck_shape=False),
121+
cudnn=dict(benchmark=True, deterministic=False),
122+
ddp=dict(broadcast_buffers=True, find_unused_parameters=False, static_graph=True),
123+
grad_scaler_args=dict(enabled=False),
124+
callbacks=dict(
125+
dataloader_speed=dict(every_n=100, save_s3=False, step_size=1),
126+
device_monitor=dict(
127+
every_n=200, log_memory_detail=True, save_s3=False, step_size=1, upload_every_n_mul=5
128+
),
129+
grad_clip=dict(clip_norm=1.0, force_finite=True), # matches internal make_8b
130+
heart_beat=dict(every_n=200, save_s3=False, step_size=1, update_interval_in_minute=20),
131+
iter_speed=dict(every_n=1, hit_thres=50, save_s3=False, save_s3_every_log_n=500),
132+
low_precision=dict(update_iter=1),
133+
manual_gc=dict(every_n=5, gc_level=1, warm_up=1),
134+
param_count=dict(save_s3=False),
135+
skip_nan_step=dict(max_consecutive_nan=100),
136+
training_stats=dict(log_freq=100),
137+
),
138+
),
139+
checkpoint=dict(
140+
broadcast_via_filesystem=False,
141+
dcp_async_mode_enabled=False,
142+
enable_gcs_patch_in_boto3=True,
143+
keys_not_to_resume=[],
144+
# Skip net_ema. (→ EMA warm-start copies net→net_ema, see dcp.py) AND the
145+
# action heads, so they init fresh from the base — matches internal
146+
# make_8b _DEFAULT_KEYS_TO_SKIP (Cosmos3-Nano's action heads are not
147+
# DROID-policy-trained).
148+
keys_to_skip_loading=[
149+
"net_ema.",
150+
"action2llm",
151+
"llm2action",
152+
"action_modality_embed",
153+
"action_pos_embed",
154+
],
155+
load_ema_to_reg=False,
156+
load_path="???", # Cosmos3-Nano DCP dir; supply via TOML/env
157+
load_training_state=False,
158+
only_load_scheduler_state=False,
159+
save_iter=100,
160+
strict_resume=False, # base init: tolerate key set differences
161+
verbose=True,
162+
hf_export=dict(
163+
enabled=False,
164+
export_every_n=1,
165+
hf_repo_id=None,
166+
upload_to_object_store=dict(bucket="", credentials="", enabled=False),
167+
),
168+
jit=dict(device="cuda", dtype="bfloat16", enabled=False, input_shape=None, strict=True),
169+
load_from_object_store=dict(bucket="", credentials="", enabled=False),
170+
save_to_object_store=dict(bucket="", credentials="", enabled=False),
171+
),
172+
dataloader_train=L(PackingDataLoader)(
173+
audio_sample_rate=48000,
174+
dataset_name="action_droid",
175+
max_samples_per_batch=128, # count-based batch (matches internal res480 8B)
176+
max_sequence_length=None, # None disables token packing (TOML can't express null)
177+
patch_spatial=2,
178+
sound_latent_fps=0,
179+
tokenizer_spatial_compression_factor=16,
180+
tokenizer_temporal_compression_factor=4,
181+
dataloader=L(RankPartitionedDataLoader)(
182+
batch_size=1,
183+
in_order=False,
184+
num_workers=4,
185+
persistent_workers=True,
186+
pin_memory=True,
187+
prefetch_factor=4,
188+
sampler=None,
189+
datasets=dict(
190+
droid=dict(
191+
ratio=1,
192+
dataset=L(get_action_droid_sft_dataset)(
193+
root="${oc.env:DROID_ROOT}",
194+
fps=15.0,
195+
chunk_length=32,
196+
action_space="joint_pos",
197+
use_state=True,
198+
use_image_augmentation=True, # SR boost (random crop+rescale + color jitter)
199+
# Keep-ranges window filter (drops idle/non-task frames). Off by default;
200+
# the launcher sets use_filter_dict=True + filter_dict_path for internal parity.
201+
use_filter_dict=False,
202+
filter_dict_path=None,
203+
action_normalization=None,
204+
viewpoint="concat_view", # wrist 480p (top) + L/R shoulder 320x180 (bottom)
205+
resolution="480", # 640x360 data @ 480p (matches internal res480 run)
206+
max_action_dim="${model.config.max_action_dim}",
207+
cfg_dropout_rate=0.1,
208+
tokenizer_config="${model.config.vlm_config.tokenizer}",
209+
),
210+
),
211+
),
212+
),
213+
),
214+
dataloader_val=None,
215+
upload_reproducible_setup=False,
216+
),
217+
flags={"allow_objects": True},
218+
)
219+
220+
221+
# chunk_length=32 → 33 observation frames; pin the VAE encode duration to match
222+
# (internal used [17] for chunk_length=16). Set post-construction so it lands on
223+
# the deep-copied NANO_MODEL_CONFIG.tokenizer.
224+
action_policy_droid_nano["model"]["config"]["tokenizer"]["encode_exact_durations"] = [33]
225+
226+
227+
for _item in [action_policy_droid_nano]:
228+
_name = [k for k, v in globals().items() if v is _item][0]
229+
cs.store(group="experiment", package="_global_", name=_name, node=_item)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: OpenMDW-1.1
3+
4+
"""Map-style action SFT dataset: ``DROIDLeRobotDataset`` → ``ActionTransformPipeline``.
5+
6+
The base ``DROIDLeRobotDataset.__getitem__`` returns the raw sample
7+
(``video``/``action``/``ai_caption``/``viewpoint``/``mode``/``domain_id``/
8+
``idle_frames``). The model expects each sample to be passed through
9+
``ActionTransformPipeline`` (spatial resize/pad, text tokenization, action
10+
padding to ``max_action_dim``, and ``sequence_plan`` construction). This thin
11+
wrapper composes the two so the experiment can hand a single map-style dataset
12+
to ``RankPartitionedDataLoader`` (mirroring how the vision recipe uses
13+
``get_sft_dataset``).
14+
"""
15+
from __future__ import annotations
16+
17+
from typing import Any
18+
19+
from torch.utils.data import Dataset
20+
21+
from cosmos_framework.data.vfm.action.datasets.droid_lerobot_dataset import DROIDLeRobotDataset
22+
from cosmos_framework.data.vfm.action.transforms import ActionTransformPipeline
23+
24+
25+
class ActionSFTDataset(Dataset):
26+
"""Wraps a map-style action dataset and applies ``ActionTransformPipeline`` per sample."""
27+
28+
def __init__(self, dataset: Dataset, transform: ActionTransformPipeline, resolution: str | int | None):
29+
super().__init__()
30+
self._dataset = dataset
31+
self._transform = transform
32+
self._resolution = resolution
33+
34+
def __len__(self) -> int:
35+
return len(self._dataset)
36+
37+
def __getitem__(self, idx: int) -> dict[str, Any]:
38+
return self._transform(self._dataset[idx], self._resolution)
39+
40+
41+
def get_action_droid_sft_dataset(
42+
*,
43+
root: str,
44+
fps: float = 15.0,
45+
chunk_length: int = 32,
46+
action_space: str = "joint_pos",
47+
use_state: bool = True,
48+
action_normalization: str | None = None,
49+
viewpoint: str = "concat_view",
50+
use_image_augmentation: bool = False,
51+
use_filter_dict: bool = False,
52+
filter_dict_path: str | None = None,
53+
resolution: str | int = "256",
54+
max_action_dim: int = 64,
55+
tokenizer_config: dict | None = None,
56+
cfg_dropout_rate: float = 0.1,
57+
append_viewpoint_info: bool = True,
58+
append_duration_fps_timestamps: bool = True,
59+
append_resolution_info: bool = True,
60+
append_idle_frames: bool = False,
61+
) -> ActionSFTDataset:
62+
"""Build the DROID action SFT dataset (joint_pos 8D by default), matching the
63+
internal ``droid_lerobot_8b_policy`` data: ``action_space='joint_pos'`` +
64+
``use_state`` (8D, raw/un-normalized), concat_view, chunk_length 32."""
65+
dataset = DROIDLeRobotDataset(
66+
root=root,
67+
fps=fps,
68+
chunk_length=chunk_length,
69+
viewpoint=viewpoint,
70+
action_space=action_space,
71+
use_state=use_state,
72+
action_normalization=action_normalization,
73+
use_image_augmentation=use_image_augmentation,
74+
use_filter_dict=use_filter_dict,
75+
filter_dict_path=filter_dict_path,
76+
)
77+
transform = ActionTransformPipeline(
78+
tokenizer_config=tokenizer_config,
79+
cfg_dropout_rate=cfg_dropout_rate,
80+
max_action_dim=max_action_dim,
81+
append_viewpoint_info=append_viewpoint_info,
82+
append_duration_fps_timestamps=append_duration_fps_timestamps,
83+
append_resolution_info=append_resolution_info,
84+
append_idle_frames=append_idle_frames,
85+
)
86+
return ActionSFTDataset(dataset, transform, resolution)

0 commit comments

Comments
 (0)