The AY-Robots tutorial for recording your first LeRobot dataset, the starting point for a dataset audit
LeRobotDataset QualitySO-100Data CurationImitation LearningDebugging

Finding Broken Episodes in a Robot Dataset Before You Train

AY-Robots ResearchAugust 23, 202616 min read

Audit a LeRobot dataset before you rent a GPU: find frozen video frames, swapped camera streams, joints clamped at a calibrated limit, and image-to-action timing skew.

Training is no longer the expensive part. A GR00T N1.7 job is 4 to 12 USD of rented A100 or H100 time; an ACT or SmolVLA run on a 24 GB card is 1 to 3 USD (see pricing). What costs real money is the two days afterwards, when the arm reaches for the cube, stops ten centimetres short, and you have no idea why.

Usually the answer was in the dataset, findable in twenty minutes with ffmpeg and a parquet reader. This is the audit to run on a LeRobot dataset before it goes near a GPU. Four defects recur in teleoperated recordings: frozen frames, swapped camera streams, joints parked at a calibrated limit, and timing skew between image and action.

What you need to know

  • The four defects are frozen frames, swapped cameras, saturated joints and timing skew. None raise an error at training time.
  • Nothing in lerobot 0.6.x scans your timestamp column at load. The tolerance_s check older guides describe was removed; the docstring promising it is stale.
  • LeRobot clips every servo reading into the calibrated range before normalising, so a joint driven past its limit becomes a flat line at -100, +100, 0 or 100.
  • Detect that clamp as a run of consecutive frames at the limit, not a share near it. On a public SO-101 dataset the naive test flags 49 of 50 episodes; the run test flags one.

Four defects and what each one does to the policy

Each defect has one signature in the data and another in the behaviour of the trained policy.

DefectSignature in the dataWhat the policy doesCheap detector
Frozen framesOne camera repeats an image while the joints moveIgnores that camera, or stalls when the scene stops matchingffmpeg freezedetect
Swapped streamsWrist and overhead views in each other's columnsWorks in the setup you recorded last, fails after replugging USBContact sheet, motion energy
Joint at a limitA column holds exactly -100, +100, 0 or 100 for a runGripper never closes, or a joint stops early and the arm sagsLongest run at a limit
Image-to-action skewGaps drift off 1/fps, or the image predates its actionLags the scene, hesitates, overshoots on fast motionTimestamp diff histogram
Versions this describes

Checked against lerobot on main on 23 August 2026, whose pyproject.toml reads version = "0.6.2". That version is not on PyPI: the newest release is 0.6.1, so pip install lerobot==0.6.2 fails. Two pieces of drift: the v3.0 docs page still writes meta/tasks.jsonl while the code default is meta/tasks.parquet, and its install line still pins a pre-release commit zip. Read meta/info.json, not a tree diagram.

Know the layout before you grep it

An audit is a set of file reads, so you need to know which file holds what. LeRobotDataset v3.0 shipped in lerobot 0.4.0: many episodes are concatenated into shared files, and boundaries are resolved through metadata. See the docs on datasets.

text
your-dataset/
  meta/
    info.json      # features, dtypes, shapes, fps, codebase_version, path templates
    stats.json     # dataset-wide min/max/mean/std/count per feature (quantiles only
                   #   if the writer was recent enough to compute them)
    tasks.parquet  # task strings mapped to integer task ids
    episodes/
      chunk-000/file-000.parquet   # per episode: length, tasks, the row range into
                                   #   data/, a timestamp range per video, and stats
  data/
    chunk-000/file-000.parquet     # frame rows, MANY episodes per file
  videos/
    observation.images.front/chunk-000/file-000.mp4   # MANY episodes per file
v3.0 per the path constants in lerobot main; chunks hold up to 1000 files, data caps at 100 MB per file and video at 200 MB. On v2.x the same content lives in one parquet and one MP4 per episode, with meta/episodes.jsonl, meta/episodes_stats.jsonl and meta/tasks.jsonl beside them.
The version trap that eats an afternoon

GR00T N1.7 and N1.5 on this platform want LeRobot v2.0 or v2.1. NVIDIA says the same upstream: GR00T uses v2 because many upstream datasets (DROID, LIBERO, Bridge) are published in v2. Hand a v3.0 dataset to the GR00T loader and it crashes, and the message will not say "wrong dataset version". Convert down first; see dataset rejected as v3. Pi0.5, SmolVLA and ACT take v3.0.

bash
# v2.1 -> v3.0, exactly as the v3.0 docs page writes it
python -m lerobot.scripts.convert_dataset_v21_to_v30 --repo-id=<HF_USER/DATASET_ID>

# v3.0 -> v2 for GR00T: the downgrade lives in the Isaac-GR00T repo
python scripts/lerobot_conversion/convert_v3_to_v2.py --help

Defect 1: frozen frames

A USB camera that loses its stream usually does not throw. It hands back the last decoded frame while the loop keeps running. LeRobot is explicit: a background thread stores a frame plus a capture timestamp, and read_latest, which is what the SO-100 follower calls to build an observation, raises only when the buffered frame is older than max_age_ms, default 500 ms. At 30 fps that is fifteen stale frames passing without complaint.

The consequence is specific. If your wrist camera is what tells the model when to close the end effector, frozen wrist frames teach it that gripper closure is not predictable from that camera, so it leans on proprioception. The policy reaches correctly and grips at the wrong moment.

bash
# freezedetect compares the mean absolute difference of all components against a
# noise floor. Defaults: noise n=-60dB (a 0.001 difference ratio), duration d=2 s.
# It logs, and sets, lavfi.freezedetect.freeze_start / freeze_duration / freeze_end.
ffmpeg -hide_banner -i videos/observation.images.wrist/chunk-000/file-000.mp4 \
  -vf freezedetect=n=-60dB:d=0.5 -map 0:v:0 -f null - 2>&1 | grep freezedetect

# mpdecimate marks near-duplicates of the previous frame.
# Defaults: hi=64*12, lo=64*5, frac=0.33. Count the DROPS, not every line that
# mentions drop_count: at -loglevel debug the filter prints one line per frame.
ffmpeg -hide_banner -i videos/observation.images.wrist/chunk-000/file-000.mp4 \
  -vf mpdecimate -loglevel debug -f null - 2>&1 | grep -c ' drop pts:'
Use d=0.5 rather than the 2 second default: half a second of frozen wrist view already matters at 30 fps. A codec can manufacture the symptom, since long-GOP video makes different frames decode to near-identical pixels, so decode a flagged range to PNG before deleting.

Defect 2: cameras that swapped places

This produces the most confusing bug reports: the dataset is internally consistent and only wrong relative to the arm. The OpenCVCamera docstring says the quiet part out loud, that camera indices can be unstable across reboots or port changes, especially on Linux, where lerobot enumerates by globbing /dev/video* in name order. A reboot or a different hub puts the wrist stream into the column labelled front, and a block of episodes is mislabelled.

  1. 1
    Pin the devices before recording again

    lerobot-find-cameras enumerates what is attached and writes a sample image per device. On Linux, prefer a by-id path over an integer index.

    bash
    lerobot-find-cameras opencv          # enumerates devices, saves one sample image each
    ls -l /dev/v4l/by-id/                # pass these stable paths as index_or_path
  2. 2
    Dump a contact sheet

    One thumbnail every 20 seconds of shard, tiled 8x8 into a PNG per camera. If episodes 40 to 61 carry the wrist view in the front column, scrolling shows it.

    bash
    for f in videos/observation.images.front/chunk-*/file-*.mp4; do
      ffmpeg -hide_banner -loglevel error -i "$f" \
        -vf "fps=1/20,scale=160:-1,tile=8x8" -frames:v 1 "sheet_$(basename $f .mp4).png"
    done
  3. 3
    Confirm numerically

    Not the msad filter: it compares two videos to each other, needs matching resolution and pixel format, and says nothing about which moves more. Measure motion energy inside each stream.

    bash
    # Mean luminance of the frame-to-frame difference, per stream. A wrist camera
    # moves with the arm, an overhead camera does not, so the wrist number is much
    # larger. If that ordering flips for a block of episodes, the streams swapped.
    for v in front.mp4 wrist.mp4; do
      printf "%-12s " "$v"
      ffmpeg -hide_banner -i "$v" \
        -vf "tblend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG" \
        -f null - 2>&1 | awk -F= '/YAVG/{s+=$2;n++} END{printf "mean %.3f over %d frames\n", s/n, n}'
    done
  4. 4
    Relabel or delete, not half of each

    If the swap is a clean block and both cameras share resolution and geometry, renaming columns is legitimate. If the fields of view differ, delete it.

    bash
    lerobot-edit-dataset --repo_id you/your-dataset \
      --new_repo_id you/your-dataset-clean \
      --operation.type delete_episodes \
      --operation.episode_indices "[40, 41, 42, 43]" 
The AY-Robots tutorial page for recording your first LeRobot dataset, showing the recording workflow
The recording tutorial at /learn/record-your-first-dataset. Naming cameras consistently at record time is the only real fix for the swap.

A missing camera is a different failure, camera not detected. The walkthrough is record your first dataset; what makes a session worth keeping is in how to collect high-quality VLA training data.

Defect 3: joints parked at their calibrated limit

The behaviour here is deliberate, and it hides the problem. Calibration on an SO-100 writes four numbers per motor: drive_mode, homing_offset, range_min, range_max. Normalising a servo reading clamps first.

python
# src/lerobot/motors/motors_bus.py, _normalize()
bounded_val = min(max_, max(min_, val))
if self.motors[motor].norm_mode is MotorNormMode.RANGE_M100_100:
    norm = (((bounded_val - min_) / (max_ - min_)) * 200) - 100
    normalized_values[id_] = -norm if drive_mode else norm
elif self.motors[motor].norm_mode is MotorNormMode.RANGE_0_100:
    norm = ((bounded_val - min_) / (max_ - min_)) * 100
    normalized_values[id_] = 100 - norm if drive_mode else norm
elif self.motors[motor].norm_mode is MotorNormMode.DEGREES:
    mid = (min_ + max_) / 2
    max_res = self.model_resolution_table[self._id_to_model(id_)] - 1
    normalized_values[id_] = (val - mid) * 360 / max_res   # note: val, not bounded_val
The reading is clamped into the calibrated range, then mapped, and nothing is logged when the clamp fires. Note the DEGREES branch: it uses the raw value, so degrees mode does not clamp.

In robots/so_follower/so_follower.py the five arm joints get RANGE_M100_100 and the gripper RANGE_0_100, unless you set use_degrees. If calibration used a narrower range than the arm can reach, or the leader was pushed past the follower's range during teleoperation, the column becomes a flat line at exactly -100, +100, 0 or 100. That line is what the model learns as the correct action for the whole stretch.

Detect the clamp as a run, not as a threshold

The obvious test, flagging a joint that spends some share of an episode near its extreme, does not work, and real data shows why. The public lerobot/svla_so101_pickplace dataset is 50 episodes, 11939 frames, 30 fps, v3.0. Its elbow_flex column sits within 0.5 of the +100 limit in 29.1 percent of frames, flagging 49 of 50 episodes. Almost all of that is servo quantisation near the top of travel: values step 99.09, 99.18, 99.27, which is motion, not a clamp. Only 0.49 percent read exactly 100.0.

python
import pyarrow.parquet as pq
import numpy as np, json, glob

info    = json.load(open("meta/info.json"))
fps     = info["fps"]
names   = info["features"]["action"]["names"]
MIN_RUN = int(0.5 * fps)          # half a second held against a stop

def longest_run_at_limit(col, lo, hi, eps=1e-3):
    at = (col <= lo + eps) | (col >= hi - eps)
    best = cur = 0
    for pinned in at:
        cur = cur + 1 if pinned else 0
        best = max(best, cur)
    return best

for path in sorted(glob.glob("data/chunk-*/file-*.parquet")):
    t   = pq.read_table(path, columns=["episode_index", "action"])
    ep  = np.asarray(t["episode_index"])
    act = np.stack(t["action"].to_numpy(zero_copy_only=False))
    for e in np.unique(ep):
        a = act[ep == e]
        for j, name in enumerate(names):
            lo, hi = (0.0, 100.0) if name.startswith("gripper") else (-100.0, 100.0)
            run = longest_run_at_limit(a[:, j], lo, hi)
            if run >= MIN_RUN:
                print(f"ep {e:>4}  {name:<18} {run:>4} frames "
                      f"({run / fps:.1f} s) pinned at a calibrated stop")
On the dataset above this prints exactly one line: ep 49, elbow_flex.pos, 41 frames (1.4 s) pinned at a calibrated stop.
JointObserved rangeWithin 0.5 of a limitLongest run at a limit
shoulder_pan.pos-93.5 to 88.00.00 percent0 frames
shoulder_lift.pos-100.0 to 8.10.50 percent1 frame
elbow_flex.pos13.0 to 100.029.14 percent41 frames, 1.4 s
wrist_flex.pos33.5 to 99.50.00 percent0 frames
wrist_roll.pos-92.8 to -20.00.00 percent0 frames
gripper.pos0.0 to 33.00.44 percent1 frame
python
# Reuses names and act from the snippet above. How much of each
# calibrated range does the task actually use?
for j, name in enumerate(names):
    lo, hi = (0.0, 100.0) if name.startswith("gripper") else (-100.0, 100.0)
    c = act[:, j]
    print(f"{name:<18} [{c.min():>7.1f}, {c.max():>6.1f}]  "
          f"uses {(c.max() - c.min()) / (hi - lo):>4.0%} of the calibrated range")
Second cheap check. On the same dataset: shoulder_pan uses 91 percent of its calibrated range, shoulder_lift 54, elbow_flex 44, wrist_flex 33, wrist_roll 36, gripper 33. That last number is what to compare against when your policy will not close the gripper.
What meta/stats.json can and cannot settle

For joint columns the dataset-wide min and max are exact: per-episode stats cover every frame, and aggregation takes the min of the mins. Only image and video features are subsampled, via estimate_num_samples (100 samples up to about 460 frames, 177 at 1000, 1000 at 10000). But the aggregated quantiles are not quantiles: the code keeps the min of the lower and the max of the upper per-episode estimates, and says so in a comment. And min and max cannot tell you which episode was clamped.

The hardware fix is to recalibrate with the joint driven to its true mechanical stops; lerobot-find-joint-limits does that by teleoperating for a fixed window and recording the extremes reached. Also: SO-100 and SO-101 run Feetech STS3215 servos on 7.4 V and 12 V destroys them, so check your arm on the SO-100 page. For symptoms, see gripper does not close and joint stops early.

Defect 4: timing skew between image and action

Every row carries a timestamp, an observation and an action, and the contract is that they describe the same instant. Two things break it: spacing, when the control loop misses a slot and a gap opens that is not 1/fps, and capture latency, since the camera thread and the loop are separate. Neither is checked at load.

tolerance_s does not do what older guides say

The LeRobotDataset docstring still promises that tolerance_s (default 1e-4 s) is "used at the init of the dataset" to check that timestamps are 1/fps apart. In 0.6.x the function that did it is gone. Follow the value into datasets/dataset_reader.py and it does two things: validates delta_timestamps, and goes to the video decoder, which raises One or several query timestamps unexpectedly violate the tolerance when the nearest decoded frame is too far from the one requested. That surfaces mid-training, not at construction.

SymptomCauseHow to see itCost
Gaps not equal to 1/fpsThe control loop missed its slotNothing checks it at load; run the script belowVideo fetches fail mid-training
Loop slower than target fpsCamera fps, slow inference, CPU starvationlerobot-record logs a cadence summary and warnsFrames dropped, rate below target
Image older than its actionCamera capture latency, buffered framesNot checked; compare a fast motion to the joint traceA delayed mapping is learned
Cameras at different fpsMixed hardware in one rigRead each stream's fps from meta/info.jsonOne camera repeats frames

The recording script is talkative about row two. Its CycleTimer warns: Control loop is running slower (X Hz) than the target FPS (Y Hz). Dataset frames might be dropped and robot control might be unstable. Common causes are: 1) Camera FPS not keeping up 2) Policy inference taking too long 3) CPU starvation. If that appeared while recording, those episodes are suspect.

python
import pyarrow.parquet as pq
import numpy as np, json, glob

fps = json.load(open("meta/info.json"))["fps"]

for path in sorted(glob.glob("data/chunk-*/file-*.parquet")):
    t  = pq.read_table(path, columns=["episode_index", "timestamp"])
    ep = np.asarray(t["episode_index"]); ts = np.asarray(t["timestamp"])
    for e in np.unique(ep):
        d = np.diff(ts[ep == e])
        bad = int((np.abs(d - 1.0 / fps) > 1e-4).sum())    # lerobot's tolerance_s
        if bad:
            print(f"ep {e:>4}  {bad:>5} gaps off 1/fps  "
                  f"median {np.median(d)*1e3:.2f} ms  worst {d.max()*1e3:.2f} ms")
The SO-101 dataset above prints nothing: all 50 episodes are spaced at exactly 1/30 s. Silence is the pass condition.

The twenty minute audit, start to finish

Cheapest checks first, because each can save you the next. These apply equally to files written by the desktop client, whose guide is at docs/client-guide.

  1. 1
    Read the header and check the episode gate

    Format version, fps, features, episode count. GR00T, Pi0.5 and ACT want 50 episodes here, SmolVLA 30; below that, nothing to audit.

    bash
    lerobot-edit-dataset --repo_id you/your-dataset --root . \
      --operation.type info --operation.show_features true
    
    python -c "import json;i=json.load(open('meta/info.json'));\
    print(i['codebase_version'], i['fps'], i['total_episodes'], i['total_frames'])" 
  2. 2
    Histogram the episode lengths

    The fastest signal in the audit. A two second episode is a mis-trigger; three times the median usually means a mid-take recovery. The reference dataset runs a median 230 frames.

    bash
    python - <<'EOF'
    import pyarrow.parquet as pq, numpy as np, glob
    L = []
    for p in sorted(glob.glob('meta/episodes/chunk-*/file-*.parquet')):
        L += list(np.asarray(pq.read_table(p)['length']))
    L = np.array(L); m = np.median(L)
    print('n', len(L), 'median', m, 'p05', np.percentile(L,5), 'p95', np.percentile(L,95))
    print('suspects:', np.where((L < 0.4*m) | (L > 2.5*m))[0])
    EOF
  3. 3
    Scan the video for freezes

    One pass per camera shard. Map freeze_start back to episodes with the from_timestamp fields in meta/episodes.

    bash
    for f in videos/*/chunk-*/file-*.mp4; do
      echo "== $f"
      ffmpeg -hide_banner -i "$f" -vf freezedetect=n=-60dB:d=0.5 \
        -map 0:v:0 -f null - 2>&1 | grep freeze_start
    done
  4. 4
    Look at what you flagged

    Never delete on a threshold alone. Watch each flagged episode against its joint traces.

    bash
    pip install 'lerobot[dataset_viz]'
    lerobot-dataset-viz --repo-id you/your-dataset --episode-index 47
    
    # headless: save the .rrd recording and pull it down
    lerobot-dataset-viz --repo-id you/your-dataset --episode-index 47 \
      --save 1 --output-dir /tmp/rrd
  5. 5
    Delete into a new repo id

    Pass --new_repo_id so the original survives; without it the operation edits in place.

    bash
    lerobot-edit-dataset \
      --repo_id you/your-dataset \
      --new_repo_id you/your-dataset-clean \
      --operation.type delete_episodes \
      --operation.episode_indices "[7, 12, 40, 41, 42, 43, 47, 88]" 
There is a browser version of step 4

Hugging Face's lerobot-dataset-visualizer is the same idea as a web app: synchronised video and charts, an episode-length histogram, an action-insights panel, a 3D URDF view supporting SO-100, SO-101 and OpenArm, and a filtering panel that flags low movement, jerky motion and outlier length, then exports the flagged ids as a CLI command. Hosted at the visualize_dataset Space.

Doing this by hand versus doing it on AY-Robots

The audit is not something this platform does for you. What changes is the loop around it: where the dataset comes from, whether the trainer accepts its format, and how fast you get from suspicion to a second run.

Everything above, on your machine, against a dataset from lerobot-record or the Hub. You need ffmpeg, pyarrow and lerobot.

bash
pip install "lerobot[dataset_viz]"
lerobot-info                    # python, torch, ffmpeg versions in one block

hf download you/your-dataset --repo-type dataset --local-dir ./ds

# --root is the folder holding meta/, data/, videos/. Without it the CLI looks
# in $HF_LEROBOT_HOME/<repo_id>, not in the directory you happen to be sitting in.
lerobot-edit-dataset --repo_id you/your-dataset --root ./ds \
  --operation.type info --operation.show_features true

# audit, then prune into a NEW repo id
lerobot-edit-dataset --repo_id you/your-dataset --root ./ds \
  --new_repo_id you/your-dataset-clean \
  --operation.type delete_episodes \
  --operation.episode_indices "[7, 12, 47]" \
  --push_to_hub true
Advantages
  • You see the actual bytes, the only way to be certain about a defect
  • Thresholds are yours to tune per task; no hosted tool has to agree
  • The scripts run in CI, so the audit becomes a gate, not a ritual
Trade-offs
  • You maintain the scripts, including v3.0 episode-offset arithmetic
  • ffmpeg passes over a large video shard are slow on a laptop
  • Nothing warns you when the dataset version and trainer disagree
The AY-Robots public dataset directory listing LeRobot-format datasets
The public dataset directory at /directory. Comparing your episode-length histogram against one someone else trained on is a cheap check.

What the published curation work found

Deleting episodes feels wasteful. The literature says it is not. Data quality has been a first-class variable in imitation learning since robomimic, which ran six offline algorithms on five simulated and three real tasks and found performance depended on demonstration quality and stopping criterion, not just quantity. Every method below needs a trained policy to score against, making them second-pass tools; the four defects above are findable first.

WorkYearWhat it measuresHeadline result
robomimic (Mandlekar et al.)2021Six offline algorithms, varying data qualityQuality and stopping criterion matter, not only size
Data Quality in IL (Belkhale et al.)2023Action divergence, transition diversityState diversity is not always beneficial
DemInf (Hejna et al.)2025Mutual information, states and actionsRanks demos unlabelled; 5 to 10 percent on RoboMimic
SCIZOR (Zhang et al.)2025Suboptimal and redundant state-action pairsAverage 15.4 percent gain across benchmarks
CUPID (Agia et al.)2025Influence of a demo on expected returnSOTA on RoboMimic with under 33 percent of the data
Pruning a small SO-100 dataset
Advantages
  • Removes frames that teach a wrong mapping, which extra data cannot cancel
  • Shortens each epoch, so a change shows its effect sooner at the same cost
Trade-offs
  • Minimum episode counts are hard gates: prune 55 to 40 and GR00T refuses it
  • Delete every awkward recovery and the policy can never perform one
Record the margin you will need

Pruning is normal and the minimums are hard gates, so record with headroom. For GR00T N1.7 (50 minimum) plan 65 to 70; for SmolVLA (30 minimum) plan 40. Extra takes cost minutes; being ten short costs a session.

After the prune: what to re-check

Deleting episodes writes a new dataset and re-aggregates the statistics the trainer normalises with, from the per-episode stats you kept. Reload once and print the ranges before queueing.

python
from lerobot.datasets import LeRobotDataset

ds = LeRobotDataset("you/your-dataset-clean")

# meta.info is a DatasetInfo dataclass, not a dict, so index it with attributes.
# The metadata object also exposes the common counters directly:
print(ds.meta.total_episodes, ds.meta.total_frames, ds.meta.fps)
print(ds.meta.stats["action"]["min"], ds.meta.stats["action"]["max"])

# q01/q10/q50/q90/q99 exist only if the dataset was written by a lerobot new
# enough to compute them. Ask before you index.
print(sorted(ds.meta.stats["action"].keys()))

# load a subset without rewriting anything, to A/B a suspicion quickly
sub = LeRobotDataset("you/your-dataset", episodes=[0, 1, 2, 3, 4])
print(len(sub))
The episodes argument takes an allowlist, so you can diagnose on a subset before committing to a prune.

Then run the cheapest training you have. ACT or SmolVLA on the 24 GB tier costs 1 to 3 USD and finishes in 2 to 5 hours, a reasonable canary even when the model you want is GR00T. ACT has no base model, so the run tells you about your data. Compare the five at /policies; guides such as GR00T N1.7 on SO-100 list the defaults each trainer sends.

Two things that will not reproduce

GR00T's fine-tuning entry point (launch_finetune.py in Isaac-GR00T, a tyro CLI over FinetuneConfig) exposes no seed, so two GR00T runs on the same clean dataset are not bit-for-bit identical; lerobot's default seed is 1000. To attribute a change to your prune rather than run-to-run variance, run the config twice.

The AY-Robots download page for the desktop client that records LeRobot-format datasets
The desktop client at /download. Recording consistently beats auditing inconsistently.

What an audit cannot tell you

A dataset can pass every check above and still be a bad dataset.

  • Task coverage. Thirty flawless episodes with the cube in one corner give a policy that only works in that corner, and no file check sees it.
  • Operator style drift. Two people recording, one from above and one from the side, gives data consistent per episode and bimodal overall.
  • Whether the demonstration was good. Reaching the goal through three corrections is a successful episode and a poor demonstration; that is what influence-function methods are for.
  • Whether the arm was calibrated correctly rather than consistently, and whether your model's inference latency fits the control loop.

The latency point is where remote inference breaks. Per-step action latency runs from 20 ms for ACT to 152 ms for GR00T N1.7, 165 for GR00T N1.5, 245 for SmolVLA and 485 for Pi0.5. Public-internet round trips on top turn a working policy into a hesitant one: remote inference suits slow pick-and-place, not fast reactive motion. Background is in vision-language-action models; the hardware side is the SO-100 complete guide.

The policy is misbehaving. Now what?

Frozen cameras, saturated joints and timing skew show up as behaviour, not errors. The failure-mode pages match the symptom on the arm to the cause in the data.

Open the failure-mode index
How many bad episodes before I re-record instead of pruning?

If more than about a fifth is flagged, the cause is systematic rather than episodic: a camera that drops on a schedule, a calibration that clips a joint, a loop that cannot hold the configured fps. Pruning a systematic defect leaves a smaller dataset that still contains it, so fix it and re-record. Below that, prune, but watch the minimums: 50 episodes for GR00T, Pi0.5 and ACT, 30 for SmolVLA.

Can I trust meta/stats.json for min and max checks?

For joint columns the dataset-wide min and max are exact: per-episode statistics for numeric features cover every frame, and aggregation takes the min of the mins. Only image and video features are subsampled. What you cannot do is read the aggregated quantiles as quantiles: lerobot keeps the min of the lower and max of the upper per-episode estimates, and its own comment calls the result bounds. Many datasets carry no quantile keys.

How do I tell a clamped joint from one that simply worked near its limit?

Count consecutive frames at exactly the limit. Servo readings near the top of travel are quantised, clustering just below it at 99.09, 99.18, 99.27 while still changing every frame; a clamp holds one identical value. On lerobot/svla_so101_pickplace this is the difference between flagging 49 of 50 episodes and flagging the one that is really clamped. Set the threshold at half a second times your fps.

My dataset loads but a training batch dies mentioning timestamps. Why?

In lerobot 0.6.x nothing verifies your timestamp column at construction; the older init-time check was removed, though the docstring still describes it. tolerance_s, default 1e-4 s, goes to the video decoder instead, which raises when the nearest decodable frame is too far from the timestamp requested. A dropped control cycle produces that gap. Run the script above to find the episode.

Is there a tool that does the whole audit automatically?

Not one covering all four. Hugging Face's lerobot-dataset-visualizer comes closest: its filtering panel flags low movement, jerky motion and outlier episode length, and exports the flagged ids as a CLI command. It does not detect frozen frames, swapped streams or joint saturation. DemInf, SCIZOR and CUPID score demonstration quality, a later question than whether the file is broken.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started