The AY-Robots guide page for training GR00T N1.7 on an SO-100 arm, showing the required GPU tier, dataset format and trainer defaults
GR00T N1.7SO-100Fine-tuningLeRobotVLA

How to Train GR00T N1.7 on Your Own SO-100 Dataset

AY-Robots ResearchAugust 23, 202628 min read

A tested walkthrough for fine-tuning NVIDIA GR00T N1.7 on an SO-100 LeRobot dataset: real flags, modality.json, the v2.1 requirement, what a run costs, and the traps.

NVIDIA ships a fine-tuning example for exactly the arm you probably own. Inside the Isaac-GR00T repository there is a folder called demo_data/cube_to_bowl_5: five episodes, 4,148 frames at 30 fps, already written as LeRobot v2.1, with a matching modality config under examples/SO100/. Its meta/info.json reports robot_type: so101_follower, which in LeRobot is the same config class as so100_follower. That is genuinely useful, because it means the reference path for GR00T N1.7 on a six-degree-of-freedom hobby arm is maintained by the people who wrote the model. It is not a humanoid demo scaled down, it is the same arm.

The bad news is the distance between I recorded 60 episodes and the arm does the task. There are about six places where this pipeline fails quietly rather than loudly, and four of them live in files most people never open: meta/modality.json, the Python data config, meta/relative_stats.json, and the dataset's own version string. This guide walks the manual route end to end with the real commands, then shows the same job as a form on AY-Robots. Everything below was checked against the Isaac-GR00T main branch as of 20 August 2026 (the n1.7-release line) and lerobot 0.6.1, published to PyPI on 3 August 2026. Upstream moves fast, and where a flag was renamed this article says so.

What you need to know before you start

  • GR00T N1.7 fine-tuning wants 40 GB or more of VRAM. NVIDIA recommends H100 or L40 nodes. A 24 GB RTX 4090 will not do this job, though it will train SmolVLA and ACT.
  • The dataset must be LeRobot v2 (v2.0 or v2.1) plus a GR00T-specific meta/modality.json. A LeRobot v3.0 dataset does not load and has to be converted down.
  • The entry point is gr00t/experiment/launch_finetune.py, a tyro CLI. It has no --seed flag, so runs are not bit-for-bit reproducible.
  • For a custom arm the embodiment tag is NEW_EMBODIMENT, and that tag makes --modality-config-path mandatory.
  • The shipped SO-100 recipe predicts arm joints as RELATIVE deltas and the gripper as an ABSOLUTE target. Getting that pairing backwards is a silent failure, not an error.
  • On AY-Robots the same job is a form: 20000 steps, batch 32, learning rate 1e-4, roughly 4 to 12 USD on the A100 80 GB or H100 tier.

What GR00T N1.7 actually is

GR00T N1.7 is a vision-language-action model with the dual-system layout described in the original GR00T N1 paper: a vision-language module that reads the cameras and the instruction, and a diffusion transformer that turns that into a chunk of continuous motor commands. N1.7 replaced the first half. The Eagle backbone from N1.6 is gone, swapped for nvidia/Cosmos-Reason2-2B on a Qwen3-VL architecture, and the model was pretrained on roughly 20,000 hours of egocentric human video on top of the robot data. NVIDIA's own write-up puts the figure at 20,854 hours and reports that going from 1k to 20k hours more than doubles average task completion.

The second half changed too, in ways that matter for your run. The action head dropped from 32 diffusion layers to 16, the predicted action chunk grew from 16 steps to 40, and the maximum state and action width went from 29 to 132. Those three numbers come from the changelog in the repository README; NVIDIA's own launch post still describes System 1 as a 32-layer DiT, so where the two disagree, trust the repo you are about to clone. Actions are expressed by default in a relative end-effector space, deltas from the current pose rather than absolute targets, which is what lets manipulation priors learned from human video transfer into robot control at all. The head itself is a flow-matching diffusion transformer, the same family as Pi0.5 but with a different backbone in front of it. If you are still on the previous generation, N1.7 against N1.5 covers whether the upgrade justifies redoing your pipeline.

PropertyValueWhere it comes from
Parameters3,000,000,000Hugging Face model card
Vision-language backbonenvidia/Cosmos-Reason2-2B (Qwen3-VL), gated on Hugging Facerepo README
Action headFlow-matching diffusion transformer, 16 layers (N1.6 had 32)repo README
Predicted action horizon40 steps for the base checkpoint (N1.6 had 16)getting_started/policy.md and repo README
Max state and action width132 (N1.6 had 29)repo README
Code licenseApache 2.0Isaac-GR00T repository
Weights licenseNVIDIA Open Model License Agreementmodel card
Latency, H100 80 GB, PyTorch eager, 4 denoising steps, 1 camera85.8 ms end to end, 11.7 Hzmodel card timing table
Same hardware, TensorRT full pipeline27.9 ms end to end, 35.9 Hzmodel card timing table
Latency AY-Robots quotes for its served GR00T N1.7152 ms per action stepAY-Robots policy catalog

Those last three rows explain most of the disappointment people report. The headline 27.9 ms is a TensorRT engine on an H100 with one camera and four denoising steps. Plain PyTorch on the same card is 85.8 ms, and the model card puts the gap at 3.08x. Neither number includes a serving layer, a second camera or a network hop. The 152 ms per action step AY-Robots quotes for its served GR00T N1.7 is the figure with serving in the loop, and a public-internet round trip sits on top of that. More on this at the end. For the numbers next to other models, GR00T N1.7 against Pi0.5 and GR00T N1.7 against SmolVLA lay them out side by side.

The AY-Robots model page for GR00T N1.7 showing parameter count, GPU tier, inference latency and the model's stated strengths and limits
The /policies/groot-n1-7 page carries the same spec strip you would otherwise assemble by hand from the model card and the repo README.

What the run needs before you type anything

RequirementFine-tuningInference
VRAM, NVIDIA guidance40 GB or more, H100 or L40 recommended16 GB or more, an RTX 4090 works
Python and CUDA on dGPU3.12 and CUDA 12.83.12 and CUDA 12.8
Video backendtorchcodec 0.8.0, FFmpeg 4 to 7 onlysame
Dataset formatLeRobot v2 plus meta/modality.jsonnot applicable
Hugging Face accessapproved for nvidia/Cosmos-Reason2-2Bsame
Other toolinggit-lfs and uvuv
AY-Robots GPU tier for the groot1.7 trainerA100 80 GB or H100 80 GBpod provisioned automatically
The gated backbone will stop you on the first run

Every GR00T checkpoint, including the base nvidia/GR00T-N1.7-3B, loads nvidia/Cosmos-Reason2-2B on first use, and that repository is gated. The README states the failure exactly: model loading fails with a GatedRepoError / 401 Client Error. What it does not mention is when that happens, which is after you have rented the card and the run has started. Request access on the model page, then run uv run huggingface-cli login or export HF_TOKEN before you rent anything.

Step 0: the episodes themselves

Everything below assumes you already have recorded episodes. If you do not, that is the real first step and it is the one that decides how good the result can be, because imitation learning cannot recover information that is not in the data. Calibrate both arms first, then drive the follower with a leader arm while lerobot-record writes the parquet files and the camera streams. If calibration is off, the joint values in your dataset describe a slightly different robot than the one that will later execute the policy, and no amount of training fixes that.

bash
lerobot-record \
    --robot.type=so100_follower \
    --robot.port=/dev/ttyACM0 \
    --robot.id=my_follower_arm \
    --robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}" \
    --teleop.type=so100_leader \
    --teleop.port=/dev/ttyACM1 \
    --teleop.id=my_leader_arm \
    --dataset.repo_id=${HF_USER}/cube-into-bowl \
    --dataset.num_episodes=60 \
    --dataset.single_task="put the cube in the yellow bowl" \
    --display_data=true
lerobot-record on a current LeRobot. Episode length defaults to 60 s and reset time to 60 s. so100_follower and so101_follower are both registered against the same LeRobot config class, which is why the Isaac-GR00T SO100 example uses the so101 names; either works on an SO-100. The camera names you choose here (front, wrist) are the names that have to reappear in modality.json.

LeRobot's own advice is to record at least 50 episodes with about 10 per object location, keep the cameras fixed, and keep the grasping behaviour consistent. Add variation later, not at the start. The rule of thumb worth remembering: if you could not do the task yourself from the camera images alone, the policy cannot either. For the arm-specific setup, SO-100 getting started and the SO-100 LeRobot page cover ports, calibration and camera indices. On AY-Robots you can also do this over the internet from the browser using teleoperation and record straight from the session.

Step 1: the dataset has to be LeRobot v2.1

This is the single most common wall. LeRobot's current CODEBASE_VERSION on main is v3.0, so anything you record today with a current toolchain comes out as v3.0. GR00T's loader expects v2. The repository is explicit about why: many upstream datasets such as DROID, LIBERO and Bridge are published in v2, and native support for both is planned but not shipped. So the conversion is on you, and it runs in its own virtualenv for a concrete reason: scripts/lerobot_conversion carries its own pyproject that asks for Python 3.10 or 3.11 and pins lerobot to one git commit, while Isaac-GR00T itself requires Python 3.12. Install the converter from the repository root and you get the gr00t package instead, which is the mistake its README warns about. If you are new to the format, the LeRobot dataset glossary entry explains what is actually inside one.

bash
# from the Isaac-GR00T repo root
cd scripts/lerobot_conversion
uv venv
source .venv/bin/activate
uv pip install -e . --verbose

# pulls the dataset from the Hub and writes a v2.1 copy into the default cache
python convert_v3_to_v2.py --repo-id <your-hf-user>/<your-dataset>

# or, back in the repo root, keep it next to the SO-100 example
uv run --project scripts/lerobot_conversion \
  python scripts/lerobot_conversion/convert_v3_to_v2.py \
  --repo-id <your-hf-user>/<your-dataset> \
  --root examples/SO100/my_dataset_lerobot
The converter takes --repo-id, an optional --root, and --force-conversion, which deletes any existing local snapshot and re-downloads it. It writes codebase_version: v2.1 into meta/info.json.
The conversion overwrites in place

If the v3.0 dataset already exists locally, the script builds the v2.1 layout beside it and then swaps: the original is moved to a sibling folder with the version appended, <name>_v3.0, and the converted copy takes the original path. (The script's own docstring calls that folder _v30; the code appends the version string, so what you actually get is _v3.0.) Second surprise: the output always lands under <root>/<repo-id>, so --root examples/SO100/my_dataset_lerobot gives you examples/SO100/my_dataset_lerobot/<your-hf-user>/<your-dataset>, and that longer path is the one --dataset-path wants later. When a training job rejects your dataset for version reasons, the dataset rejected as v3 page lists the exact symptoms.

The structure GR00T wants after conversion is the classic v2 layout: meta/info.json, meta/episodes.jsonl, meta/tasks.jsonl, parquet files under data/chunk-000/, MP4 files under videos/chunk-000/observation.images./, and one extra file that standard LeRobot does not have. That extra file is where most of the remaining failures live.

Step 2: modality.json, the six numbers that decide everything

In a LeRobot dataset the robot state and the action are stored as flat float32 arrays. For an SO-100 both have shape [6]: five arm joints and a gripper. The demo dataset names them shoulder_pan.pos, shoulder_lift.pos, elbow_flex.pos, wrist_flex.pos, wrist_roll.pos, gripper.pos, but those names live in info.json and nothing in the parquet file says which index is which. meta/modality.json supplies that mapping, and GR00T will not train without it. Here is the one the repository ships for the SO-100, verbatim.

json
{
  "state": {
    "single_arm": {
      "start": 0,
      "end": 5
    },
    "gripper": {
      "start": 5,
      "end": 6
    }
  },
  "action": {
    "single_arm": {
      "start": 0,
      "end": 5
    },
    "gripper": {
      "start": 5,
      "end": 6
    }
  },
  "video": {
    "front": {
      "original_key": "observation.images.front"
    },
    "wrist": {
      "original_key": "observation.images.wrist"
    }
  },
  "annotation": {
    "human.task_description": {
      "original_key": "task_index"
    }
  }
}
examples/SO100/modality.json. Indices are zero-based and follow Python slicing, so single_arm is [0:5] and gripper is [5:6].

Copy it into your converted dataset at meta/modality.json and rename the video keys to whatever your cameras are actually called. If you recorded with a single overhead camera named top, then original_key is observation.images.top and the friendly name is whatever your data config will reference. The two have to agree, and neither of them checks the other for you. The language annotation is worse, because the same key has to appear in three places.

LayerFileSO-100 form used in the repo
Parquet columndata/chunk-*/episode_*.parquetannotation.human.task_description
modality.json keymeta/modality.json, under "annotation", without the annotation. prefixhuman.task_description
modality_keys in the data configyour so100_config.pyannotation.human.task_description
Why the language key trips people

The segments after annotation. are chosen by whoever authored the dataset. The SO-100 demo data uses annotation.human.task_description; LIBERO and SimplerEnv use annotation.human.action.task_description. Both are valid. If you copied a config from a LIBERO example and pointed it at your own SO-100 recording, the language channel resolves to nothing and the model trains on an empty instruction. Loss still falls. The policy still does something. It just ignores what you told it to do.

Step 3: the data config, relative arm and absolute gripper

The modality config is a Python file rather than JSON, because it also decides how each action group is represented. This is the part of the N1.7 workflow that did not exist in the same form in N1.5, and the part worth reading twice. The shipped SO-100 config predicts the five arm joints as RELATIVE deltas from the current state and the gripper as an ABSOLUTE target position, because a binary open-or-closed signal behaves better as a target than as a delta.

python
from gr00t.configs.data.embodiment_configs import register_modality_config
from gr00t.data.embodiment_tags import EmbodimentTag
from gr00t.data.types import (
    ActionConfig, ActionFormat, ActionRepresentation, ActionType, ModalityConfig,
)

so100_config = {
    "video": ModalityConfig(
        delta_indices=[0],                       # current frame only
        modality_keys=["front", "wrist"],        # must match modality.json
    ),
    "state": ModalityConfig(
        delta_indices=[0],
        modality_keys=["single_arm", "gripper"],
    ),
    "action": ModalityConfig(
        delta_indices=list(range(0, 16)),        # predict 16 future steps
        modality_keys=["single_arm", "gripper"],
        action_configs=[
            ActionConfig(rep=ActionRepresentation.RELATIVE,   # arm joints
                         type=ActionType.NON_EEF,
                         format=ActionFormat.DEFAULT),
            ActionConfig(rep=ActionRepresentation.ABSOLUTE,   # gripper
                         type=ActionType.NON_EEF,
                         format=ActionFormat.DEFAULT),
        ],
    ),
    "language": ModalityConfig(
        delta_indices=[0],
        modality_keys=["annotation.human.task_description"],
    ),
}

register_modality_config(so100_config, embodiment_tag=EmbodimentTag.NEW_EMBODIMENT)
examples/SO100/so100_config.py, trimmed to the essentials. NON_EEF means joint space; EEF would expect a nine-dimensional vector of x, y, z plus a 6D rotation.

Two details here will cost you a day if you do not know them. First, action_configs is positional: the docs require the same length and the same order as modality_keys, and they are blunt about the consequence of getting it wrong, which is that the wrong representation is applied silently. Your gripper gets trained as a delta and your arm as an absolute target, and there is no error message. Second, register_modality_config asserts that the tag is not already registered, so a second NEW_EMBODIMENT config in the same Python process dies with Embodiment tag ... already registered. You cannot import two of these into one script. A third rule is enforced later, at deployment: the action delta_indices must be the contiguous range starting at zero. A sparse window such as [0, 4, 8] is rejected, because everything downstream indexes the predicted chunk linearly and would otherwise execute the wrong rows.

Change delta_indices and you must regenerate the stats

Normalization statistics, in particular meta/relative_stats.json, are computed for the horizon length you had when you generated them. Shorten the action horizon from 16 to 8 without regenerating and training dies with IndexError: boolean index did not match indexed array ... dimension is 8 but corresponding boolean dimension is 16. The fix is one command: python gr00t/data/stats.py --dataset-path <path> --embodiment-tag NEW_EMBODIMENT --modality-config-path examples/SO100/so100_config.py. Run it after any change to delta_indices.

Step 4: the environment

N1.7 moved the repository to uv and Python 3.12. The old conda plus pip install -e . path still exists in a collapsed section of the README, but it warns that GPU dependencies including flash-attn and TensorRT may need manual installation. Use uv unless you have a specific reason not to. On the flash-attn front, one detail saves confusion: you will see Installing flash-attn printed on every uv run. It is not rebuilding. uv is re-validating a URL-pinned wheel that is already cached, and it takes two or three seconds.

  1. 1
    Install git-lfs, then clone with submodules

    git-lfs is required, not optional. Without it the parquet files in demo_data/ come down as pointer stubs, and the demo run fails on a dataset that looks present in the file listing.

    bash
    sudo apt install git-lfs && git lfs install
    git clone --recurse-submodules https://github.com/NVIDIA/Isaac-GR00T
    cd Isaac-GR00T
  2. 2
    Install uv and sync the environment

    The default install pulls the GPU dependencies including flash-attn and TensorRT. On a fresh A100 or H100 image this is the longest single step, so do it before you start paying attention to anything else.

    bash
    curl -LsSf https://astral.sh/uv/install.sh | sh
    sudo apt-get update && sudo apt-get install -y ffmpeg
    uv sync --python 3.12
    uv run python -c "import gr00t; print('GR00T installed successfully')"
  3. 3
    Authenticate against Hugging Face

    Do this before the first training launch, not after it fails eight minutes in.

    bash
    uv run huggingface-cli login   # or: export HF_TOKEN=<your_token>
  4. 4
    Sanity-check on the shipped SO-100 demo data

    Before touching your own recording, run 2000 steps on demo_data/cube_to_bowl_5. It is five episodes, it finishes fast, and it proves the environment rather than your data. If this run fails, nothing you do to your dataset will help.

    bash
    CUDA_VISIBLE_DEVICES=0 uv run python \
        gr00t/experiment/launch_finetune.py \
        --base-model-path nvidia/GR00T-N1.7-3B \
        --dataset-path demo_data/cube_to_bowl_5 \
        --embodiment-tag NEW_EMBODIMENT \
        --modality-config-path examples/SO100/so100_config.py \
        --num-gpus 1 \
        --output-dir /tmp/test_finetune \
        --max-steps 2000 \
        --global-batch-size 32 \
        --dataloader-num-workers 4
Two environment traps that look like model bugs

FFmpeg 8. torchcodec 0.8.0 supports FFmpeg 4 to 7 only, and Ubuntu 25.10 and later ship version 8. The error is Could not load libtorchcodec, which reads like a broken install rather than a version conflict. Install an older runtime, for example conda install -c conda-forge 'ffmpeg<8', and put its libraries on LD_LIBRARY_PATH. CUDA_HOME is unset. Fine-tuning fails outright. Run bash scripts/deployment/dgpu/install_deps.sh once, or just export CUDA_HOME=/usr/local/cuda.

Step 5: the fine-tune command and what its flags really default to

Swap the demo dataset for yours and add the knobs you actually want. Below is the full form the repository uses in its own new-embodiment tutorial, including the augmentation and checkpointing flags that the short README example leaves out. This is fine-tuning in the narrow sense: the language backbone and the visual encoder stay frozen, and what trains is the projector and the diffusion action head.

bash
export NUM_GPUS=1
CUDA_VISIBLE_DEVICES=0 uv run python \
    gr00t/experiment/launch_finetune.py \
    --base-model-path nvidia/GR00T-N1.7-3B \
    --dataset-path ./my_dataset_lerobot \
    --embodiment-tag NEW_EMBODIMENT \
    --modality-config-path examples/SO100/so100_config.py \
    --num-gpus $NUM_GPUS \
    --output-dir /tmp/so100 \
    --save-total-limit 5 \
    --save-steps 2000 \
    --max-steps 20000 \
    --use-wandb \
    --global-batch-size 32 \
    --color-jitter-params brightness 0.3 contrast 0.4 saturation 0.5 hue 0.08 \
    --dataloader-num-workers 4
Single GPU. For eight cards, replace the launcher with uv run torchrun --nproc_per_node=8 --master_port=29500 and set --num-gpus 8. Use uv run torchrun, not bare torchrun, or you get the wrong environment.
FlagDefault in FinetuneConfigWhat it does
--global-batch-size64Total batch across all GPUs before gradient accumulation. The shipped examples use 32.
--learning-rate1e-4The same value AY-Robots sends for its groot1.7 trainer.
--max-steps10000Total optimizer steps. The examples/finetune.sh wrapper also defaults to 10000.
--gradient-accumulation-steps1Multiplies the effective batch. Values above 1 emit a warning telling you the accumulated size.
--save-steps and --save-total-limit1000 and 5Checkpoint frequency, and how many are kept. Older ones are deleted.
--weight-decay and --warmup-ratio1e-5 and 0.05Set explicitly by examples/finetune.sh as well.
--state-dropout-prob0.2 in the CLI, 0.8 in the model configRandomly drops proprioceptive state during training. Lower it if your task leans on state.
--tune-llm and --tune-visualFalse and FalseThe backbone stays frozen by default.
--tune-projector and --tune-diffusion-modelTrue and TrueThe projector and the diffusion action head are what actually train.
--use-percentilesTrueNormalize with q01 and q99 instead of raw min and max.
--dataloader-num-workers2The loader is CPU-based by design. The examples raise this to 4.
--seeddoes not existThere is no seed flag on this CLI.

That last row is not a typo. launch_finetune.py is a tyro CLI generated from a dataclass, and that dataclass has no seed field. The README separately notes 5 to 6 percent variance between runs caused by non-deterministic image augmentation. Two runs with identical flags will not produce identical checkpoints, which matters a lot when you are trying to decide whether a hyperparameter change helped or whether you got lucky. For comparison, lerobot's own trainer defaults to seed 1000, and the LeRobot GR00T recipe passes --seed=42 explicitly.

Validation is off by default, and the documented flag is not on this CLI

Fine-tuning runs with eval_strategy="no", so there is no validation loss curve at all. You get training loss and nothing else. The new-embodiment guide tells you to switch it on with --eval-strategy steps --eval-steps 500, but that flag does not exist on launch_finetune.py: the CLI is generated by tyro from the FinetuneConfig dataclass, and eval_strategy, eval_steps and eval_batch_size are fields of TrainingConfig instead. Their defaults there are "no", 500 and 2. To reach them, use the fuller entry point gr00t/experiment/launch_train.py, where the nested flag is --training.eval-strategy. Either way, a falling training loss on its own tells you very little about generalization, which is exactly the situation described on loss falls but the policy does nothing.

What a 20000-step run costs

GR00T N1.7 needs an 80 GB card, so the cost question has a narrow answer. On AY-Robots the groot1.7 trainer runs on the A100 80 GB or H100 80 GB tier, where a run takes 3 to 6 hours at 1.20 to 2.00 USD per hour on the spot market. That is roughly 4 to 12 USD for the default 20000-step job. The same task on SmolVLA or ACT lands on a 24 GB card at 0.30 to 0.60 USD per hour and 1 to 3 USD per run. That is the real trade-off: GR00T costs about four times as much per attempt, and you cannot run it on the 4090 under your desk.

ModelGPU tierTypical runTypical costMinimum episodes
GR00T N1.7A100 80 GB or H100 80 GB3 to 6 hours4 to 12 USD50
GR00T N1.5A100 80 GB or H100 80 GB3 to 6 hours4 to 12 USD50
Pi0.5A100 80 GB or H100 80 GB3 to 6 hours4 to 12 USD50
SmolVLARTX 4090 or any 24 GB card2 to 5 hours1 to 3 USD30
ACTRTX 4090 or any 24 GB card2 to 5 hours1 to 3 USD50

The 50-episode minimum is a floor, not a target. NVIDIA's own FAQ is more demanding: roughly 100 trajectories for a simple fixed-location pick and place, 500 or more for complex or multi-step scenes, and 100 to 500 for fine manipulation. If you are sitting at 20 episodes, spend the afternoon recording rather than the evening tuning. The data collection guide covers what separates a useful episode from a wasted one, record your first dataset is the short version, and SO-100 data collection is the arm-specific one.

The AY-Robots training guide for GR00T N1.7 on the SO-100, showing the spec strip with GPU tier, required dataset format and the trainer defaults
The /train/groot-n1-7-on-so-100 guide leads with the facts you would otherwise reconstruct by hand: GPU tier, dataset format, and the exact defaults the trainer sends.

Step 6: open-loop evaluation before you touch the arm

Do not put a fresh checkpoint on a physical arm to find out whether training worked. Run the open-loop evaluation first. It replays a recorded episode, asks the model for actions at each step, and plots prediction against ground truth with MSE and MAE. It costs nothing and it catches the mapping mistakes from steps 2 and 3.

bash
uv run python gr00t/eval/open_loop_eval.py \
    --dataset-path ./my_dataset_lerobot \
    --embodiment-tag NEW_EMBODIMENT \
    --model-path /tmp/so100/checkpoint-20000 \
    --traj-ids 0 \
    --execution-horizon 16 \
    --steps 400 \
    --modality-keys single_arm gripper
Plots land in /tmp/open_loop_eval/traj_<id>.jpeg unless you pass --save-plot-path. Defaults: --execution-horizon 16, --steps 200, --denoising-steps 4, --traj-ids 0.

The repository deliberately refuses to publish a target MSE for custom data, and that is the right call: the number depends on your action units, your task and your dataset size, so a threshold copied from someone else's arm means nothing. What is meaningful is the trend. Here is the reference run the repo documents on a single H100 with the five-episode demo dataset and 2000 steps.

CheckpointAverage MSE on traj 0Average MAE on traj 0
50087.55.63
100025.43.30
150013.22.18
200010.01.76

The shape is the signal, not the absolute values. Error should fall steadily as training steps accumulate. Averaged over all five training episodes rather than trajectory 0 alone, the repo's final checkpoint scored roughly 7.5 MSE and 1.5 MAE, so even the reference run reads differently depending on which episodes you average. Record your own baseline on the unmodified demo command before you change anything about your own data: if you cannot reproduce a known-good run, you cannot tell a setup mistake from a data problem. The repository also maps the common symptoms to causes, and every one of them is operational rather than a model bug.

SymptomLikely cause
MSE flat or rising across checkpointsLearning rate too low, or the data is not loading at all. Check --dataset-path and the dataloader workers.
Prediction curve is flat or constantmodality.json keys or --modality-config-path do not match. The action keys are not mapped.
MSE enormous, or NaN loss during trainingAction and state normalization. Verify meta/stats and that the action ranges are physically plausible.
Good on traj 0, poor on held-out episodesData scarcity, not a bug. Five demo episodes cannot generalize.

The other route: lerobot-train instead of Isaac-GR00T

The current LeRobot release, 0.6.1 on PyPI since 3 August 2026, offers a second and quite different way to fine-tune the same base weights. LeRobot exposes GR00T N1.7 as a policy type and trains it through its own lerobot-train entry point. Two things matter here. The LeRobot CLI is a set of console scripts, so anything you read that says python lerobot/scripts/train.py is stale and will not run. And LeRobot removed GR00T N1.5 support entirely, rejecting N1.5 checkpoints and configs with a migration note, so if you need N1.5 through LeRobot you have to pin lerobot==0.5.1, the last release that supports it, published 7 April 2026.

bash
pip install "lerobot[groot]" "lerobot[training]"
hf auth login

lerobot-train \
  --dataset.repo_id=$HF_USER/$DATASET_NAME \
  --dataset.image_transforms.enable=true \
  --policy.type=groot \
  --policy.device=cuda \
  --policy.base_model_path=nvidia/GR00T-N1.7-3B \
  --policy.embodiment_tag=new_embodiment \
  --policy.chunk_size=16 \
  --policy.n_action_steps=16 \
  --policy.use_relative_actions=true \
  --policy.relative_exclude_joints='["gripper"]' \
  --policy.use_bf16=true \
  --seed=42 \
  --batch_size=64 \
  --steps=20000 \
  --save_freq=5000 \
  --output_dir=$OUTPUT_DIR
The LeRobot-native GR00T N1.7 recipe. Note relative_exclude_joints: the gripper is excluded from relative actions, which is the same decision so100_config.py makes with ActionRepresentation.ABSOLUTE.
AspectIsaac-GR00T launch_finetune.pylerobot-train --policy.type=groot
Dataset versionLeRobot v2 only, conversion requiredNative LeRobot dataset, no downgrade
Modality mappingmeta/modality.json plus a Python data configno modality.json; behaviour set by --policy.* flags on the command line
Seedno seed flag at all--seed, LeRobot default 1000
Relative actionsper-key ActionConfig in the data config--policy.use_relative_actions plus --policy.relative_exclude_joints
Reference results publishedSO-100 open-loop MSE trend on demo dataLIBERO suites, 96.5 percent average across four suites
Deployment pathrun_gr00t_server.py plus eval_so100.py over ZMQlerobot-rollout, with real-time chunking (queue_threshold should stay at or below 5)
Running the fine-tune yourself
Advantages
  • Every flag is visible and changeable. You can unfreeze the visual encoder, move state_dropout_prob, or shorten the action horizon.
  • The open-loop plots are local files. Diffing checkpoint-5000 against checkpoint-20000 is a shell command.
  • You do not depend on any platform staying online, and the checkpoint sits on your disk in a standard format.
  • The repo's benchmark examples for LIBERO, SimplerEnv and DROID give you known-good runs to reproduce before you trust your own data.
Trade-offs
  • The environment is most of the work. FFmpeg version, CUDA_HOME, git-lfs, the gated backbone, torchcodec: none of these are model problems and every one of them stops the run.
  • The v3.0 to v2.1 conversion needs a separate virtualenv with its own install step, and it rewrites your dataset directory in place.
  • GPU rental starts billing when you start debugging, not when training starts, and nothing stops the instance when the run finishes.
  • No seed means no bit-for-bit reproducibility, on top of 5 to 6 percent run-to-run variance from augmentation alone.

Two ways to get the same checkpoint

You rent the GPU and you own every step. Realistically this is an afternoon the first time and twenty minutes every time after that.

  1. Record episodes with lerobot-record on the SO-100. You get a LeRobot v3.0 dataset.
  2. Convert it down to v2.1 with scripts/lerobot_conversion/convert_v3_to_v2.py in its own virtualenv.
  3. Write meta/modality.json and a Python modality config, registered under EmbodimentTag.NEW_EMBODIMENT.
  4. Rent an 80 GB card, clone with submodules, uv sync, authenticate against Hugging Face.
  5. Run launch_finetune.py, then open_loop_eval.py on several checkpoints, and compare the MSE trend before touching hardware.
  6. Pull the checkpoint off the machine before you destroy the instance, then build the serving path to the arm.
The step everyone forgets

Copy the checkpoint off the rented instance before you shut it down. --save-total-limit 5 also means older checkpoints are deleted as training proceeds, so the checkpoint you wanted at step 5000 may not exist any more at step 20000.

The AY-Robots training matrix with five policy models as rows and four robot arms as columns, each cell linking to a specific training guide
The /train matrix: five models against four arms. The GR00T N1.7 row also covers the SO-101, Koch v1.1 and LeKiwi.

Getting the checkpoint back onto the arm

Isaac-GR00T uses a server-client split over ZMQ. The policy runs on the GPU, and a thin client on the robot machine sends observations and receives action chunks. The SO-100 example is complete enough to copy: start run_gr00t_server.py with your checkpoint and --embodiment-tag NEW_EMBODIMENT, then run eval_so100.py on the robot side with the serial port, the robot id, the camera indices and the language instruction. The camera names in that command must match the friendly names from your modality.json, not the OS device numbers.

bash
# GPU side
uv run python gr00t/eval/run_gr00t_server.py \
  --model-path /tmp/so100/checkpoint-20000 \
  --embodiment-tag NEW_EMBODIMENT \
  --device cuda:0 \
  --host 0.0.0.0 --port 5555

# robot side, from gr00t/eval/real_robot/SO100
uv run --no-sync python eval_so100.py \
  --robot.type=so101_follower \
  --robot.port=/dev/ttyACM2 \
  --robot.id=orange_follower \
  --robot.cameras="{ front: {type: opencv, index_or_path: 6, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}" \
  --policy_host=localhost --policy_port=5555 \
  --lang_instruction="put the cube in the yellow bowl"
--execution-horizon controls how many of the predicted steps are executed before re-planning. It must be at most the policy's action_horizon, and that number is the length of the action delta_indices in your modality config, not the base model's. The shipped SO-100 config predicts 16, so 16 is your ceiling; the base nvidia/GR00T-N1.7-3B checkpoint is configured for 40, and policy.md says plainly that finetuned checkpoints may differ. Exceed it and you get a ValueError naming both numbers. 8 is the value the docs suggest for real-time deployment. The old flag name --action-horizon still works but warns.
7.4 V, not 12 V

While you are wiring the arm back up: the SO-100 runs Feetech STS3215 bus servos on a 7.4 V rail. Feeding them 12 V destroys them, and it is an easy mistake if you also own a LeKiwi, whose base runs at 12 V while its arm does not. Check the supply before the first power-up, not after the smoke. See the SO-100 hardware page and SO-100 against LeKiwi. If the arm powers up but nothing moves, servo not responding is the place to start.

Now the honest part about where the policy runs, because training and serving have different hardware stories. Fine-tuning wants 40 GB or more. Inference does not: the README puts it at 16 GB or more and names the RTX 4090 explicitly, so a card you already own can serve a checkpoint it could never have produced. What decides whether the policy feels responsive is not VRAM, it is where the server sits. On AY-Robots GR00T N1.7 is cloud-only, so the control loop pays a public-internet round trip on top of the 152 ms per action step, and only SmolVLA and ACT also run locally. For slow pick and place a remote pod is survivable. For anything reactive it is not: the policy becomes hesitant in a way that looks exactly like a training failure and is not one. ACT at 20 ms per action step is the model that tolerates the tightest loop, SmolVLA sits at 245 ms, and no amount of latency tuning buys back a round trip that has already been spent. Run your first policy walks through the serving side end to end.

What actually goes wrong

  • GatedRepoError on the first run. You have not been granted access to nvidia/Cosmos-Reason2-2B, or you did not authenticate. This happens after the GPU clock has already started.
  • Dataset rejected on load. Almost always a v3.0 dataset. Convert it down. See dataset rejected as v3.
  • IndexError about mismatched boolean dimensions. You changed delta_indices and did not regenerate the stats.
  • Out of memory at batch 32. Reduce --global-batch-size and raise --gradient-accumulation-steps, or reduce --num-shards-per-epoch, which the config explicitly suggests when VRAM is limited. See out of memory during training.
  • Loss falls, policy does nothing. There is no validation split by default, so a clean training curve proves very little. This page covers the diagnosis.
  • Works in your setup and nowhere else. Expected with a small dataset filmed under one lighting condition. NVIDIA recommends colour jitter augmentation plus 20 to 50 episodes across different lighting. More here.
  • Gripper never closes properly. Check that the gripper action is ABSOLUTE and the arm joints RELATIVE, in that order in action_configs. Gripper does not close lists the other causes.
  • A camera silently drops out mid-recording. The episode still saves and the video key still exists, which is why this one is nasty. Camera not detected covers it.

The whole index of failure modes lives at the fix pages. If you are choosing between models rather than debugging one, the policy comparison and the arena entry for GR00T N1.7 have benchmark numbers with sources attached, and ACT against GR00T N1.7 is the comparison most people actually need, because it is the choice between a model you can train on the card under your desk and one you have to rent an 80 GB node to fine-tune. For background on why these models behave the way they do, the VLA overview and the SO-100 complete guide are worth reading first. And if you do not own an arm yet, the live arm streams a physical SO-100 with no signup.

How many episodes do I need before fine-tuning GR00T N1.7 is worth it?

AY-Robots sets a hard floor of 50 episodes for the groot1.7 trainer. NVIDIA's own FAQ is more demanding: roughly 100 trajectories for a simple pick and place at a fixed location, 500 or more for complex or multi-step scenes, and 100 to 500 for fine manipulation. Below 50 you are almost always better off recording more data than tuning hyperparameters. If success plateaus after that, NVIDIA recommends HG-DAgger: run the policy, intervene when it fails, and add those corrections to the dataset.

Why does my dataset fail to load, and how do I tell which version it is?

Open meta/info.json and read codebase_version. LeRobot's current CODEBASE_VERSION on main is v3.0, so anything recorded with a recent toolchain is v3.0, and the GR00T loader expects v2. Convert with scripts/lerobot_conversion/convert_v3_to_v2.py from the Isaac-GR00T repo, which writes codebase_version: v2.1 into the converted dataset. The script runs in its own virtualenv because it needs a different lerobot version than GR00T pins.

Can I fine-tune GR00T N1.7 on an RTX 4090?

No. NVIDIA recommends 40 GB or more of VRAM for fine-tuning and names H100 or L40 nodes; other cards work but take much longer. A 4090 has 24 GB. AY-Robots only offers GR00T N1.7 on the A100 80 GB and H100 80 GB tier for the same reason. Inference is a different story: 16 GB is enough to serve the model, so a 4090 can run a policy it cannot train. If you want a VLA you can train on 24 GB, that is SmolVLA at about 450 M parameters or ACT at about 80 M.

Why do two runs with identical flags give different checkpoints?

Because launch_finetune.py has no seed. It is a tyro CLI generated from a dataclass that contains no seed field, so nothing pins the RNG. The repo separately notes 5 to 6 percent variance between runs caused by non-deterministic image augmentation. If reproducibility matters, use the LeRobot route instead: lerobot-train takes --seed and the published GR00T recipe passes --seed=42.

Should I use Isaac-GR00T or lerobot-train?

Use Isaac-GR00T if you want the reference implementation, per-key control over action representation, TensorRT export, or the benchmark examples to reproduce before trusting your own data. Use lerobot-train if your dataset is already LeRobot v3.0 and you would rather not convert it, if you want a seed, or if the rest of your stack is already LeRobot. Both fine-tune the same nvidia/GR00T-N1.7-3B weights. Note that LeRobot dropped GR00T N1.5 support entirely: N1.5 checkpoints are rejected with a migration note, and you have to pin lerobot==0.5.1 to keep using them.

Do I really need a wrist camera as well as a front camera?

The shipped SO-100 configuration uses both, and modality.json maps front and wrist as separate video keys. You can train with one camera, and the model card's latency table is measured with one camera, but the wrist view is what gives the policy usable information about the gripper at the moment of contact. If the gripper closes at the wrong time in your rollouts, a missing or badly aimed wrist camera is one of the first things to check.

Fine-tune GR00T N1.7 on your SO-100 without building the environment first

Pick model, dataset and hyperparameters in a form. The backend rents an A100 80 GB or H100 on the spot market, runs the trainer with batch 32, learning rate 1e-4 and 20000 steps, and writes checkpoints to object storage. Roughly 4 to 12 USD per run.

Open the GR00T N1.7 training guide

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started