Compare commits

..

8 Commits

Author SHA1 Message Date
Maxime Ellerbach a3755f07c5 fix(peft): allow fresh LoRA fine-tuning from a base-model checkpoint 2026-07-24 13:37:03 +00:00
Maxime Ellerbach 051b13573e fix(safetensors): expand bare "cuda" to current device for safetensors loads (#4042) 2026-07-17 10:44:20 +02:00
Pepijn 7de2e4c1ef Move annotation dependencies to module scope (#4040) 2026-07-16 18:35:32 +02:00
Nikodem Bartnik 8db50611c2 pin pip installs (#4041) 2026-07-16 16:55:13 +02:00
Maxime Ellerbach 92f96f33b3 Aggregate policy sub-losses through MetricsTracker (#4024) 2026-07-16 12:12:37 +02:00
Steven Palma d4b3ca569c refactor(hub): load safetensors directly on target device (#4012) 2026-07-16 10:49:59 +02:00
Steven Palma 3f2179f3b6 refactor(evo1): use transformers flash attention probe (#4013)
Co-authored-by: Martino Russi <77496684+nepyope@users.noreply.github.com>
2026-07-15 17:02:01 +02:00
Nikodem Bartnik 867b58cfb2 generate new readme (#4029)
Co-authored-by: Pepijn <138571049+pkooij@users.noreply.github.com>
2026-07-15 16:32:02 +02:00
25 changed files with 348 additions and 1619 deletions
-155
View File
@@ -1,155 +0,0 @@
"""Classify each sub-dataset: is it SO-100/101, and what joint encoding is it in?
Detection = robot_type string (recording-time signal) cross-checked against the
per-episode stats min/max (magnitude + exact-boundary saturation). Mismatches are
flagged as `ambiguous` for manual review rather than silently converted.
"""
import json
from pathlib import Path
import numpy as np
# so100/so101 (+ _follower/_bimanual), so_follower, and bimanual bi_so* (bi_so_follower,
# bi_so100_follower, ...; 12-dim).
SO_PREFIXES = ("so100", "so101", "so_", "bi_so")
SO_EXACT: set[str] = set()
# Robots that superficially look SO-like but are NOT in scope for the joint fix:
NEVER_FIX = {"koch", "koch_follower", "koch_bimanual", "moss", "moss_follower"}
RAD_MAX = 3.5 # |val| below this => radians
DEG_MIN = 105.0 # |val| above this => old-convention degrees
SAT_ATOL = 0.5 # closeness to +/-100 / 0 / 100 counted as normalization saturation
def is_so_robot_type(rt: str) -> bool:
"""True if the recorded ``robot_type`` denotes an in-scope SO-100/101 arm."""
return bool(rt) and (rt.startswith(SO_PREFIXES) or rt in SO_EXACT) and rt not in NEVER_FIX
SO_JOINTS = ("shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper")
def is_end_effector(info: dict) -> bool:
"""True if action/observation.state are task-space end-effector features (e.g. ``ee_x``,
``ee_roll``) rather than joint angles. Such datasets are out of scope for the joint fix."""
feats = info.get("features", {})
for key in ("action", "observation.state"):
names = [str(n).lower() for n in (feats.get(key, {}).get("names") or [])]
if any(n.startswith("ee_") or "end_effector" in n or "eef" in n for n in names):
return True
if {"x", "y", "z"} <= set(names):
return True
return False
def _leading_so_joints(names: list[str], dim: int) -> int:
"""Number of LEADING joints (a multiple of 6) that match the SO joint order in blocks of 6.
When names are absent, fall back to the full dim if it's already a multiple of 6, else 0."""
if not names:
return dim if dim and dim % 6 == 0 else 0
k = 0
while (k + 1) * 6 <= len(names) and all(SO_JOINTS[i] in names[k * 6 + i] for i in range(6)):
k += 1
return k * 6
def so_joint_count(info: dict, key: str) -> int:
"""Leading SO-arm joint count for one feature (``action`` / ``observation.state``). Trailing
non-SO columns (bbox, appended EE pose, ...) are excluded so only the genuine SO joints are
ever degrees-converted."""
feat = info.get("features", {}).get(key, {})
dim = (feat.get("shape") or [0])[0]
names = [str(n).lower() for n in (feat.get("names") or [])]
return _leading_so_joints(names, dim)
def is_mislabeled_so(info: dict) -> bool:
"""True when ``robot_type`` claims SO but no leading 6-DOF SO joint block can be substantiated
from action/observation.state (wrong dim, or names that don't match the SO set). When the first
6 joint names DO match, the SO block is honored (and processed) even if extra columns follow."""
return max(so_joint_count(info, "action"), so_joint_count(info, "observation.state")) == 0
def load_info(root: Path) -> dict:
return json.loads((Path(root) / "meta" / "info.json").read_text())
def _global_bounds(root: Path):
"""Per-joint global min/max over action (fallback observation.state), across episodes."""
lo = hi = None
key_used = None
with open(Path(root) / "meta" / "episodes_stats.jsonl") as f:
for line in f:
s = json.loads(line)["stats"]
key = "action" if "action" in s else ("observation.state" if "observation.state" in s else None)
if key is None:
continue
key_used = key
mn = np.asarray(s[key]["min"], dtype=float)
mx = np.asarray(s[key]["max"], dtype=float)
lo = mn if lo is None else np.minimum(lo, mn)
hi = mx if hi is None else np.maximum(hi, mx)
return lo, hi, key_used
def encoding_from_bounds(lo, hi, rt: str) -> dict:
"""Detect the SO-arm joint encoding from per-joint global min/max and the robot_type name.
Layout-agnostic (v2.1 episodes_stats or v3.0 stats.json both reduce to lo/hi here), so it is
the single source of truth for the degrees_old / degrees_new / normalized / radians decision.
"""
lo = np.asarray(lo, dtype=float)
hi = np.asarray(hi, dtype=float)
maxabs = float(np.nanmax(np.abs(np.concatenate([lo, hi]))))
# saturation on any arm joint (index != gripper) at +/-100, or gripper at 0/100
n = 6
sat = False
for a in range(len(hi) // n):
arm_hi, arm_lo = hi[a * n:a * n + n], lo[a * n:a * n + n]
joints_hi, joints_lo = arm_hi[:5], arm_lo[:5]
grip_hi, grip_lo = arm_hi[5], arm_lo[5]
sat |= bool(np.any(np.isclose(joints_hi, 100, atol=SAT_ATOL)) or
np.any(np.isclose(joints_lo, -100, atol=SAT_ATOL)) or
np.isclose(grip_hi, 100, atol=SAT_ATOL) or np.isclose(grip_lo, 0, atol=SAT_ATOL))
if maxabs <= RAD_MAX:
enc = "radians"
elif maxabs > DEG_MIN:
enc = "degrees_old"
elif sat:
enc = "normalized"
else:
enc = "degrees_new"
name_says_new = rt.endswith(("_follower", "_bimanual"))
ambiguous = (enc == "degrees_old" and name_says_new) or (enc in ("normalized", "degrees_new") and not name_says_new)
return {"encoding": enc, "maxabs": round(maxabs, 2), "saturates": sat, "ambiguous": ambiguous}
def classify(root) -> dict:
root = Path(root)
info = load_info(root)
rt = info.get("robot_type", "") or ""
dim = (info.get("features", {}).get("action", {}).get("shape") or [None])[0]
out = {"root": str(root), "robot_type": rt, "action_dim": dim,
"codebase_version": info.get("codebase_version"), "ambiguous": False}
if is_end_effector(info):
return {**out, "is_so": False, "encoding": "end_effector",
"note": "task-space end-effector features"}
if is_so_robot_type(rt) and is_mislabeled_so(info):
return {**out, "is_so": False, "encoding": "non_so", "mislabeled_so": True,
"note": "robot_type claims SO but joint dim/names don't match a 6-DOF SO arm"}
is_so = is_so_robot_type(rt)
if not is_so:
return {**out, "is_so": False, "encoding": "non_so"}
lo, hi, key_used = _global_bounds(root)
if lo is None:
return {**out, "is_so": True, "encoding": "unknown", "ambiguous": True,
"note": "no action/state stats found"}
n = so_joint_count(info, key_used) or len(hi) # ignore trailing non-joint columns
return {**out, "is_so": True, "stats_key": key_used, "so_dim": n,
**encoding_from_bounds(lo[:n], hi[:n], rt)}
-55
View File
@@ -1,55 +0,0 @@
"""Extract one sub-dataset from a LeRobotDataset monorepo into a standalone HF dataset repo.
Downloads only ``{src-repo}/{folder}/...`` (scoped listing, no whole-repo walk) and re-uploads
its contents at the ROOT of a NEW dataset repo, so the result is a self-contained LeRobotDataset.
python extract_dataset.py --folder wannrrr/etnai --dst-repo CarolinePascal/etnai
"""
import argparse
import shutil
import sys
from pathlib import Path
from huggingface_hub import HfApi
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_migration import download_subfolder # noqa: E402
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--folder", required=True, metavar="USER/DATASET",
help="Sub-dataset path within the source monorepo, e.g. 'wannrrr/etnai'.")
ap.add_argument("--dst-repo", required=True, metavar="ORG/NAME",
help="New standalone destination dataset repo (must differ from the source).")
ap.add_argument("--src-repo", default="lerobot/community_dataset_v3", metavar="ORG/NAME",
help="Source monorepo to pull the sub-dataset from.")
ap.add_argument("--work-dir", default="./extract_work", help="Local scratch directory.")
ap.add_argument("--private", action="store_true", help="Create the destination repo as private.")
args = ap.parse_args()
if args.dst_repo in (args.src_repo, args.folder):
ap.error("--dst-repo must be a new repo name, distinct from the source repo/folder.")
local = Path(args.work_dir) / args.folder
if local.parent.exists():
shutil.rmtree(local.parent, ignore_errors=True)
print(f"downloading {args.src_repo}/{args.folder} ...", file=sys.stderr)
download_subfolder(args.folder, args.work_dir, repo=args.src_repo)
if not (local / "meta" / "info.json").exists():
ap.error(f"'{args.folder}' is not a LeRobotDataset (no meta/info.json) in {args.src_repo}")
api = HfApi()
api.create_repo(args.dst_repo, repo_type="dataset", private=args.private, exist_ok=True)
print(f"uploading -> {args.dst_repo} ...", file=sys.stderr)
api.upload_folder(repo_id=args.dst_repo, repo_type="dataset", folder_path=str(local),
commit_message=f"Standalone copy of {args.folder} from {args.src_repo}")
shutil.rmtree(Path(args.work_dir) / args.folder.split("/")[0], ignore_errors=True)
print(f"done: https://huggingface.co/datasets/{args.dst_repo}", file=sys.stderr)
if __name__ == "__main__":
main()
-260
View File
@@ -1,260 +0,0 @@
"""Rewrite observation.state / action to degrees in a LOCAL v2.1 SO-arm dataset, then
regenerate meta/episodes_stats.jsonl (action & state only; other features preserved).
Run this BEFORE the stock v2.1->v3.0 converter so its stats aggregation stays correct.
"""
import json
from pathlib import Path
import numpy as np
import pandas as pd
import so_arm_frame
from classify import classify, load_info, so_joint_count
VALUE_COLS = ("observation.state", "action")
def _stack(col_values) -> np.ndarray:
return np.stack([np.asarray(v, dtype=np.float64) for v in col_values]) # (N, D)
def _set_robot_type(root: Path, robot_type: str) -> None:
info_path = root / "meta" / "info.json"
info = json.loads(info_path.read_text())
info["robot_type"] = robot_type
info_path.write_text(json.dumps(info, indent=4))
def _rewrite_parquet(root: Path, encoding: str, so_dims: dict) -> None:
for pq in sorted((root / "data").glob("*/*.parquet")):
df = pd.read_parquet(pq)
changed = False
for col in VALUE_COLS:
n = so_dims.get(col, 0)
if col in df.columns and n:
full = _stack(df[col].values) # (N, D)
full[:, :n] = so_arm_frame.to_degrees(full[:, :n], encoding, n_joints_per_arm=6)
df[col] = list(full.astype(np.float32))
changed = True
if changed:
df.to_parquet(pq, index=False)
def _regen_episode_stats(root: Path) -> None:
stats_path = root / "meta" / "episodes_stats.jsonl"
orig = {}
with open(stats_path) as f:
for line in f:
e = json.loads(line)
orig[e["episode_index"]] = e
for pq in sorted((root / "data").glob("*/*.parquet")):
df = pd.read_parquet(pq)
for ep in np.unique(df["episode_index"].values):
ep = int(ep)
sub = df[df["episode_index"] == ep]
entry = orig.get(ep)
if entry is None:
continue
for col in VALUE_COLS:
if col in sub.columns:
a = _stack(sub[col].values) # (n, D)
entry["stats"][col] = {
"min": a.min(0).tolist(), "max": a.max(0).tolist(),
"mean": a.mean(0).tolist(), "std": a.std(0).tolist(),
"count": [int(a.shape[0])],
}
with open(stats_path, "w") as f:
for ep in sorted(orig):
f.write(json.dumps(orig[ep]) + "\n")
def _read_jsonl(path: Path) -> list[dict]:
with open(path) as f:
return [json.loads(line) for line in f if line.strip()]
def _write_jsonl(path: Path, rows: list[dict]) -> None:
with open(path, "w") as f:
for r in rows:
f.write(json.dumps(r) + "\n")
def data_video_episode_mismatch(root) -> str | None:
"""Return a description when the data files and any camera's video files disagree on the
episode count (dataset can't be migrated, e.g. 'All cams dont have same number of episodes'),
else None. Datasets without videos never mismatch here."""
root = Path(root)
info = json.loads((root / "meta" / "info.json").read_text())
counts = {"data": len(list((root / "data").glob("*/episode_*.parquet")))}
for k, f in info.get("features", {}).items():
if f.get("dtype") == "video":
counts[k] = len(list((root / "videos").glob(f"*/{k}/episode_*.mp4")))
if len(counts) > 1 and len(set(counts.values())) > 1:
return f"data/video episode counts disagree: {counts}"
return None
def _file_ep_indices(root: Path, pattern: str) -> list[int]:
return sorted(int(p.stem.split("_")[-1]) for p in root.glob(pattern))
def reindex_episodes(root) -> str | None:
"""Compact non-contiguous episode indices to 0..N-1 when every source agrees on the set.
Some datasets (e.g. '*_clean' variants) had episodes deleted, leaving gaps in the episode
numbering (data, videos, and metadata all skip the same indices, e.g. {20, 37, 38, 39}). The
stock v2.1->v3.0 converter renumbers data/videos by sorted file order (0..N-1) but reads the
original gapped indices from episodes.jsonl, so the two disagree and it raises
"Number of episodes is not the same". When the data files, every camera's videos, and both
metadata files list the *exact same* episode index set, remap it to 0..N-1 everywhere so the
converter's positional alignment holds. Returns a note if remapped, else None (already
contiguous, or the sources disagree -> unsafe to touch)."""
root = Path(root)
info = json.loads((root / "meta" / "info.json").read_text())
ref = _file_ep_indices(root, "data/*/episode_*.parquet")
if not ref:
return None
sources = {"data": ref}
vkeys = [k for k, f in info.get("features", {}).items() if f.get("dtype") == "video"]
for k in vkeys:
sources[k] = _file_ep_indices(root, f"videos/*/{k}/episode_*.mp4")
eps = _read_jsonl(root / "meta" / "episodes.jsonl")
stats = _read_jsonl(root / "meta" / "episodes_stats.jsonl")
sources["episodes"] = sorted(e["episode_index"] for e in eps)
sources["episodes_stats"] = sorted(s["episode_index"] for s in stats)
if any(v != ref for v in sources.values()):
return None # sources disagree on the episode set -> not safe to reindex here
n = len(ref)
if ref == list(range(n)):
return None # already contiguous
remap = {old: new for new, old in enumerate(ref)}
# Data: rewrite episode_index (and rebuild the global 'index'), then rename the file. Ascending
# order is collision-free because new <= old for every episode.
running = 0
for old in ref:
matches = list((root / "data").glob(f"*/episode_{old:06d}.parquet"))
if not matches:
return None
pq = matches[0]
df = pd.read_parquet(pq)
if "episode_index" in df.columns:
df["episode_index"] = remap[old]
if "index" in df.columns:
df["index"] = np.arange(running, running + len(df), dtype=df["index"].dtype)
running += len(df)
df.to_parquet(pq, index=False)
dst = pq.with_name(f"episode_{remap[old]:06d}.parquet")
if dst != pq:
pq.rename(dst)
# Videos: rename per camera (ascending -> collision-free).
for k in vkeys:
for old in ref:
for mp4 in (root / "videos").glob(f"*/{k}/episode_{old:06d}.mp4"):
dst = mp4.with_name(f"episode_{remap[old]:06d}.mp4")
if dst != mp4:
mp4.rename(dst)
for e in eps:
e["episode_index"] = remap[e["episode_index"]]
for s in stats:
s["episode_index"] = remap[s["episode_index"]]
_write_jsonl(root / "meta" / "episodes.jsonl", sorted(eps, key=lambda e: e["episode_index"]))
_write_jsonl(root / "meta" / "episodes_stats.jsonl", sorted(stats, key=lambda s: s["episode_index"]))
info["total_episodes"] = n
info["total_frames"] = int(running)
if "total_videos" in info:
info["total_videos"] = n * len(vkeys)
info["splits"] = {"train": f"0:{n}"}
(root / "meta" / "info.json").write_text(json.dumps(info, indent=4))
return f"episode indices compacted to 0..{n - 1} (dropped gaps {sorted(set(range(ref[-1] + 1)) - set(ref))})"
def reconcile_episode_count(root) -> str | None:
"""When the data files and video files agree on an episode count N but the metadata lists a
different count, rewrite the metadata (episodes.jsonl, episodes_stats.jsonl, info.json) to N.
Only the safe direction is handled: trimming metadata that lists MORE episodes than actually
exist. If the data itself is non-contiguous, the videos disagree with the data, or the metadata
lists FEWER episodes than the data (which would require fabricating per-episode stats), nothing
is changed and the stock converter's mismatch error is left to surface. Returns a note on fix."""
root = Path(root)
info = json.loads((root / "meta" / "info.json").read_text())
data_idx = sorted(int(p.stem.split("_")[-1]) for p in (root / "data").glob("*/episode_*.parquet"))
n = len(data_idx)
if n == 0 or data_idx != list(range(n)):
return None
vkeys = [k for k, f in info.get("features", {}).items() if f.get("dtype") == "video"]
for k in vkeys:
if len(list((root / "videos").glob(f"*/{k}/episode_*.mp4"))) != n:
return None # data and videos disagree -> out of scope for this fix
eps_path = root / "meta" / "episodes.jsonl"
stats_path = root / "meta" / "episodes_stats.jsonl"
eps, stats = _read_jsonl(eps_path), _read_jsonl(stats_path)
if len(eps) == n and len(stats) == n:
return None
eps_keep = [e for e in eps if e.get("episode_index", -1) < n]
stats_keep = [s for s in stats if s.get("episode_index", -1) < n]
if len(eps_keep) != n or len(stats_keep) != n:
return None # metadata is missing episodes present in the data -> can't safely fabricate
dropped = max(len(eps), len(stats)) - n
_write_jsonl(eps_path, eps_keep)
_write_jsonl(stats_path, stats_keep)
info["total_episodes"] = n
info["total_frames"] = int(sum(e.get("length", 0) for e in eps_keep))
if "total_videos" in info:
info["total_videos"] = n * len(vkeys)
info["splits"] = {"train": f"0:{n}"}
(root / "meta" / "info.json").write_text(json.dumps(info, indent=4))
return f"metadata episode count reconciled to {n} (data & videos agree; dropped {dropped} stale meta entries)"
def fix_dataset_in_place(root) -> dict:
"""Returns the classification dict augmented with the action taken."""
root = Path(root)
cls = classify(root)
if cls.get("mislabeled_so"):
# robot_type claims SO but the joints prove otherwise (wrong dim or non-SO names).
# Relabel to 'unknown' and migrate structurally rather than degrees-converting on a
# false assumption; the joint values are left exactly as recorded.
_set_robot_type(root, "unknown")
return {**cls, "robot_type": "unknown", "converted": False,
"action": f"structural v2.1->v3.0 only; robot_type relabeled '{cls.get('robot_type')}'"
"->'unknown' (joints don't match a 6-DOF SO arm), joint values left unchanged"}
enc = cls.get("encoding")
if not cls.get("is_so") or enc in ("radians", "unknown", "non_so"):
reason = {
"non_so": "not an SO-100/101 dataset",
"radians": "SO-arm joints already in radians",
"unknown": "SO-arm but joint encoding could not be determined",
}.get(enc, "no joint conversion applicable")
return {**cls, "converted": False,
"action": f"structural v2.1->v3.0 only ({reason}); joint values left unchanged"}
if enc == "normalized" and not so_arm_frame.CANON_IS_CALIBRATED:
# Without per-robot calibration the un-normalization is an identity (placeholder
# spans == 100), so rewriting is pointless. Keep the normalized values as-is and let
# the dataset card flag them APPROXIMATE instead.
return {**cls, "converted": False,
"action": "structural v2.1->v3.0 only; joint values kept in normalized units "
"(-100..100 / 0..100), NOT converted to degrees (uncalibrated -> APPROXIMATE)"}
# drop stray files that would otherwise be uploaded
for junk in (root / "meta").glob("info.json.bak"):
junk.unlink()
info = load_info(root)
so_dims = {c: so_joint_count(info, c) for c in VALUE_COLS}
_rewrite_parquet(root, enc, so_dims)
_regen_episode_stats(root)
full_dims = {c: (info.get("features", {}).get(c, {}).get("shape") or [0])[0] for c in VALUE_COLS}
partial = any(0 < so_dims[c] < full_dims[c] for c in VALUE_COLS)
tail = " (leading SO joints only; trailing non-joint columns left unchanged)" if partial else ""
return {**cls, "converted": True,
"action": f"structural v2.1->v3.0 + joint values converted ({enc} -> degrees){tail}"}
-133
View File
@@ -1,133 +0,0 @@
"""Migrate the SO-100/101 datasets referenced by ``allenai/MolmoAct2-SO100_101-Dataset``.
That repo does NOT store the datasets themselves; it lists them. Each
``language_annotations/{user}/{dataset}/...`` folder name is the HF repo id of a *standalone*
LeRobotDataset. This script derives those repo ids, drops any already present in
``lerobot/community_dataset_v3`` (already migrated) and in the destination (resume), then runs
the exact same per-dataset pipeline as ``run_migration.py`` on each remaining standalone repo
(download whole repo -> SO-arm joint fix -> v2.1->v3.0 convert -> card -> upload -> cleanup).
python migrate_molmoact.py --dst-repo lerobot/community_dataset_v3 --work-dir ./molmo_work
Flags mirror run_migration.py: --only-classify, --no-push, --folder-name USER/DATASET [...],
--limit N, --reference-repo (the "already migrated" set to skip against).
"""
import argparse
import csv
import shutil
import sys
import traceback
from pathlib import Path
from huggingface_hub import HfApi
sys.path.insert(0, str(Path(__file__).resolve().parent))
from classify import classify # noqa: E402
from run_migration import already_done, list_datasets, migrate_one # noqa: E402
LIST_REPO = "allenai/MolmoAct2-SO100_101-Dataset"
REFERENCE_REPO = "lerobot/community_dataset_v3" # the "already migrated" set to skip against
ANNOTATIONS_PREFIX = "language_annotations/"
def list_molmoact_datasets(api: HfApi, repo: str = LIST_REPO) -> list[str]:
"""Standalone dataset repo ids (``{user}/{dataset}``) derived from the folder names under
``language_annotations/`` in the MolmoAct listing repo."""
files = api.list_repo_files(repo, repo_type="dataset")
return sorted({"/".join(f.split("/")[1:3]) for f in files
if f.startswith(ANNOTATIONS_PREFIX) and len(f.split("/")) >= 3})
def pending_datasets(api: HfApi, subs: list[str], dst_repo: str | None,
reference_repo: str, no_upload: bool, only_classify: bool) -> list[str]:
"""Drop ids already in the reference repo (already migrated) and, unless classify/no-push,
ids already in the destination repo (resume)."""
skip = set(list_datasets(api, reference_repo))
if not only_classify and not no_upload and dst_repo and dst_repo != reference_repo:
skip |= {p[: -len("/meta/info.json")] for p in api.list_repo_files(dst_repo, repo_type="dataset")
if p.endswith("/meta/info.json")}
return [s for s in subs if s not in skip]
def main():
ap = argparse.ArgumentParser(
description="Migrate the standalone SO-100/101 datasets listed by "
f"{LIST_REPO} to LeRobotDataset v3.0 (degrees), skipping any already present "
f"in --reference-repo. One dataset at a time (download -> fix -> convert -> "
"upload -> cleanup); resumable.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
ap.add_argument("--dst-repo", default=REFERENCE_REPO, metavar="ORG/NAME",
help="Destination HF dataset repo to push the converted v3.0 datasets to "
"(created if missing).")
ap.add_argument("--reference-repo", default=REFERENCE_REPO, metavar="ORG/NAME",
help="Repo whose datasets are considered already migrated and skipped.")
ap.add_argument("--work-dir", default="./molmo_work", metavar="DIR",
help="Local scratch directory (one dataset lives here at a time on a push run).")
ap.add_argument("--manifest", default="manifest_molmoact.csv", metavar="CSV",
help="CSV log appended to as datasets are processed. Reused across resumed runs.")
ap.add_argument("--limit", type=int, default=None, metavar="N",
help="Process only the first N pending datasets (alphabetical). Ignored with "
"--folder-name.")
ap.add_argument("--folder-name", nargs="+", default=None, metavar="USER/DATASET",
help="One or more specific standalone repo ids to process (must appear in the "
f"{LIST_REPO} listing).")
ap.add_argument("--only-classify", action="store_true",
help="Detect robot type + joint encoding and write the manifest only; no "
"download of data, convert, or push.")
ap.add_argument("--no-push", action="store_true",
help="Fix + convert locally but do NOT upload; output kept under --work-dir.")
args = ap.parse_args()
no_upload = args.no_push
api = HfApi()
all_ids = list_molmoact_datasets(api)
if args.folder_name:
wanted = {n.strip("/") for n in args.folder_name}
subs = [s for s in all_ids if s in wanted]
missing = wanted - set(subs)
if missing:
print(f"warning: not in {LIST_REPO} listing: {', '.join(sorted(missing))}", file=sys.stderr)
else:
subs = pending_datasets(api, all_ids, args.dst_repo, args.reference_repo, no_upload, args.only_classify)
if args.limit:
subs = subs[: args.limit]
print(f"{len(subs)} dataset(s) to process (of {len(all_ids)} listed)", file=sys.stderr)
if not subs:
return
if not args.only_classify and not no_upload:
api.create_repo(args.dst_repo, repo_type="dataset", exist_ok=True)
dst_files = set() if (args.only_classify or no_upload) else set(
api.list_repo_files(args.dst_repo, repo_type="dataset"))
first = not Path(args.manifest).exists()
with open(args.manifest, "a", newline="") as mf:
w = None
for i, sub in enumerate(subs):
try:
if args.only_classify:
from huggingface_hub import snapshot_download
local = Path(args.work_dir) / sub
snapshot_download(repo_id=sub, repo_type="dataset", local_dir=str(local),
allow_patterns=["meta/*"])
row = {"root": sub, **classify(local)}
shutil.rmtree(Path(args.work_dir) / sub.split("/")[0], ignore_errors=True)
elif not no_upload and already_done(api, args.dst_repo, sub, dst_files):
row = {"root": sub, "action": "skipped: already present in destination repo"}
else:
row = migrate_one(api, args.dst_repo, sub, args.work_dir, no_upload, standalone=True)
except Exception as e:
row = {"root": sub, "action": f"ERROR: {e}"}
traceback.print_exc()
if w is None:
w = csv.DictWriter(mf, fieldnames=sorted(
{"root", "robot_type", "is_so", "encoding", "action_dim",
"maxabs", "ambiguous", "action", "codebase_version", "note"}))
if first:
w.writeheader()
w.writerow({k: row.get(k) for k in w.fieldnames})
mf.flush()
print(f"[{i+1}/{len(subs)}] {sub}: {row.get('action')}", file=sys.stderr)
if __name__ == "__main__":
main()
-137
View File
@@ -1,137 +0,0 @@
"""Prune datasets from the migrated destination repo based on the run manifest(s).
Selects, from the manifest CSV(s) written by run_migration.py / slurm_migrate.py, the datasets to
remove and deletes their folders from the destination repo. Two independent filters:
--errored rows whose migration action starts with "ERROR:" (failed to convert; usually
absent from the repo, but any partial upload left behind is cleaned up).
--mislabeled-so rows whose robot_type claims SO but the dataset isn't a real 6-DOF SO arm
(action_dim not a multiple of 6, or a classification note saying so).
Only folders actually present in the destination repo are touched. Dry-run by default; pass --yes
to perform the deletions.
python prune_destination.py --manifest /fsx/$USER/cdv3_manifests/manifest_*.csv \
--dst-repo lerobot/community_dataset_v3 --errored --mislabeled-so # dry-run
python prune_destination.py --manifest manifest_*.csv --errored --mislabeled-so --yes
"""
import argparse
import sys
import time
import pandas as pd
from huggingface_hub import HfApi
from classify import is_so_robot_type
DST_REPO = "lerobot/community_dataset_v3"
def _is_errored(row) -> bool:
return str(row.get("action") or "").strip().upper().startswith("ERROR")
def _is_mislabeled_so(row) -> bool:
if "claims SO but" in str(row.get("note") or ""):
return True
if not is_so_robot_type(str(row.get("robot_type") or "")):
return False
try:
return int(float(row["action_dim"])) % 6 != 0
except (TypeError, ValueError, KeyError):
return False
def select(df: pd.DataFrame, present: set[str], errored: bool, mislabeled: bool) -> dict[str, str]:
"""Map each dataset root that is present in the repo AND matches an enabled filter to a reason.
'errored' takes precedence over 'mislabeled-so' when a row matches both."""
out: dict[str, str] = {}
for _, row in df.iterrows():
root = str(row.get("root") or "").strip()
if not root or root not in present or root in out:
continue
if errored and _is_errored(row):
out[root] = "errored"
elif mislabeled and _is_mislabeled_so(row):
out[root] = "mislabeled-so"
return out
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--manifest", nargs="+", required=True, metavar="CSV",
help="One or more manifest CSVs from the migration run (per-rank files ok).")
ap.add_argument("--dst-repo", default=DST_REPO, metavar="ORG/NAME",
help="Destination HF dataset repo to prune.")
ap.add_argument("--errored", action="store_true",
help="Delete datasets that errored during migration.")
ap.add_argument("--mislabeled-so", action="store_true",
help="Delete datasets labelled SO but not a real 6-DOF SO arm.")
ap.add_argument("--yes", action="store_true",
help="Actually delete. Without it, only prints what would be deleted (dry-run).")
ap.add_argument("--list-missing", action="store_true",
help="Report datasets in the manifest that are NOT present in the destination "
"repo (grouped by their migration action), then exit without deleting.")
ap.add_argument("--report", action="store_true",
help="List all three categories (errored, mislabeled-so, missing) without "
"deleting anything, then exit. '*' marks datasets absent from the repo.")
args = ap.parse_args()
if not (args.errored or args.mislabeled_so or args.list_missing or args.report):
ap.error("enable at least one of: --errored, --mislabeled-so, --list-missing, --report")
df = pd.concat([pd.read_csv(p) for p in args.manifest], ignore_index=True)
if "root" not in df.columns:
ap.error("manifest has no 'root' column; is this a run_migration.py manifest?")
df = df.drop_duplicates(subset="root", keep="last")
api = HfApi()
present = {p[: -len("/meta/info.json")]
for p in api.list_repo_files(args.dst_repo, repo_type="dataset")
if p.endswith("/meta/info.json")}
if args.report or args.list_missing:
def _dump(title, sub):
print(f"== {title} ({len(sub)}) ==")
for root in sorted(sub):
print(f" {' ' if root in present else '*'} {root}")
print()
missing = set(df.loc[~df["root"].isin(present), "root"])
if args.report:
_dump("errored", set(df.loc[df.apply(_is_errored, axis=1), "root"]))
_dump("mislabeled-so", set(df.loc[df.apply(_is_mislabeled_so, axis=1), "root"]))
_dump("missing from repo", missing)
print(f"{df['root'].nunique()} unique manifest rows, {len(present)} datasets in "
f"{args.dst_repo}. '*' = absent from repo.", file=sys.stderr)
return
to_delete = select(df, present, args.errored, args.mislabeled_so)
for root, reason in sorted(to_delete.items()):
print(f"{reason:14s} {root}")
print(f"\n{len(to_delete)} dataset(s) present in {args.dst_repo} match "
f"({df['root'].nunique()} rows in manifest, {len(present)} datasets in repo).", file=sys.stderr)
if not args.yes:
print("dry-run: nothing deleted. re-run with --yes to delete.", file=sys.stderr)
return
failed = []
for root, reason in sorted(to_delete.items()):
for attempt in range(1, 4): # transient Hub ReadTimeouts are common; retry with backoff
try:
api.delete_folder(path_in_repo=root, repo_id=args.dst_repo, repo_type="dataset",
commit_message=f"Prune {root} ({reason})")
print(f"deleted {root} ({reason})", file=sys.stderr)
break
except Exception as e:
if attempt == 3:
failed.append(root)
print(f"FAILED {root}: {e}", file=sys.stderr)
else:
time.sleep(2 ** attempt)
if failed:
print(f"\n{len(failed)} deletion(s) failed (likely transient); safe to re-run to retry: "
f"{failed}", file=sys.stderr)
if __name__ == "__main__":
main()
-300
View File
@@ -1,300 +0,0 @@
"""End-to-end migration of the community_dataset_v3 monorepo to v3.0 + SO-arm degrees.
For each `{user}/{dataset}` sub-dataset: stream-download it, fix SO-arm joint values
(if applicable), run the stock v2.1->v3.0 structural converter locally, upload the v3.0
result under the same path into a NEW repo, then delete the local copy. Resumable.
uv run python run_migration.py --dst-repo HuggingFaceVLA/community_dataset_v3_degrees \
--work-dir /big/disk/cdv3_work --manifest manifest.csv
Flags: --only-classify (just write manifest), --no-push (fix+convert locally, keep output,
no upload), --folder-name A [B ...] (target specific dataset folders), --limit N.
Uncalibrated `normalized` datasets keep their normalized joint units (flagged APPROXIMATE on
the card); paste fitted CANON ranges in so_arm_frame.py to convert them to degrees instead.
"""
import argparse, csv, json, shutil, sys, traceback
from pathlib import Path
from huggingface_hub import HfApi
import so_arm_frame
from classify import classify, is_end_effector, load_info
from fix_dataset import (
data_video_episode_mismatch,
fix_dataset_in_place,
reconcile_episode_count,
reindex_episodes,
)
SRC_REPO = "HuggingFaceVLA/community_dataset_v3"
NORMALIZED_TAG = "normalized" # card tag for datasets whose SO joints stay in normalized units
def download_subfolder(sub: str, work_dir: str, patterns: list[str] | None = None, repo: str = SRC_REPO) -> None:
"""Download only ``{repo}/{sub}/...`` into ``work_dir``.
``snapshot_download`` walks the entire repo tree (``list_repo_tree(recursive=True)``
with no path scope) before applying ``allow_patterns``. On this 791-dataset monorepo
that whole-repo enumeration is pathologically slow and looks like a hang. Listing the
scoped ``path_in_repo=sub`` subtree and fetching its files directly avoids it.
"""
from fnmatch import fnmatch
from huggingface_hub import hf_hub_download
from huggingface_hub.hf_api import RepoFile
api = HfApi()
for entry in api.list_repo_tree(repo, path_in_repo=sub, repo_type="dataset", recursive=True):
if not isinstance(entry, RepoFile):
continue
if patterns and not any(fnmatch(entry.path, pat) for pat in patterns):
continue
hf_hub_download(repo, filename=entry.path, repo_type="dataset", local_dir=work_dir)
def list_datasets(api: HfApi, repo: str) -> list[str]:
files = api.list_repo_files(repo, repo_type="dataset")
roots = {p[: -len("/meta/info.json")] for p in files if p.endswith("/meta/info.json")}
return sorted(roots)
def resolve_folders(api: HfApi, repo: str, names: list[str]) -> list[str]:
"""Expand each --folder-name into concrete dataset roots (folders that contain
meta/info.json). A name may be a full dataset path (returned as-is) or a namespace/prefix
like 'Beegbrain' (expanded to every dataset beneath it). Unknown names pass through so
they surface as a clear per-item error instead of a confusing FileNotFoundError."""
out: list[str] = []
for name in names:
name = name.strip("/")
try:
paths = [e.path for e in api.list_repo_tree(
repo, path_in_repo=name, recursive=True, repo_type="dataset")]
except Exception:
out.append(name) # let it fail loudly downstream
continue
roots = sorted({p[: -len("/meta/info.json")] for p in paths if p.endswith("/meta/info.json")})
out.extend(roots or [name])
seen: set[str] = set()
return [r for r in out if not (r in seen or seen.add(r))]
def already_done(api: HfApi, dst: str, sub: str, dst_files: set[str]) -> bool:
return f"{sub}/meta/info.json" in dst_files # present in target => skip (resume)
def _write_dataset_card(local: Path, sub: str, result: dict, standalone: bool = False) -> None:
"""Regenerate the sub-dataset's card the way LeRobot does (create_lerobot_dataset_card
from meta/info.json), then append a migration section documenting provenance and the
joint-encoding fix. When ``standalone`` is set, ``sub`` is itself the source dataset's HF
repo id (rather than a folder inside the ``SRC_REPO`` monorepo)."""
enc = result.get("encoding")
converted_degrees = bool(result.get("converted"))
approx = enc == "normalized" and not so_arm_frame.CANON_IS_CALIBRATED
enc_labels = {
"degrees_old": "legacy degrees (old community frame, pre-#777 convention)",
"degrees_new": "degrees (recorded with `use_degrees=True`)",
"normalized": "normalized units (joints -100..100, gripper 0..100)",
"radians": "radians",
"unknown": "undetermined",
}
joint_actions = {
"degrees_old": "per-joint offsets and axis directions corrected to the post-#777 frame (values stay in degrees)",
"degrees_new": "already in the post-#777 degrees frame; values unchanged",
"normalized": ("un-normalized to physical degrees using calibrated joint ranges"
if converted_degrees else
"left in normalized units (-100..100 joints, 0..100 gripper); NOT converted to degrees"),
"radians": "left unchanged (already in radians)",
"unknown": "left unchanged (encoding could not be determined)",
}
lines = [
"## Migration to LeRobotDataset v3.0",
"",
"Migrated to LeRobotDataset **v3.0**"
+ (" with SO-100/101 joint state/action mapped to the post-#777 physical frame (in degrees)."
if converted_degrees else "."),
"",
(f"- Source: [`{sub}`](https://huggingface.co/datasets/{sub})" if standalone else
f"- Source: [`{SRC_REPO}`](https://huggingface.co/datasets/{SRC_REPO}/tree/main/{sub}) (`{sub}`)"),
"- Codebase version: v2.1 -> v3.0",
]
if result.get("is_so"):
lines += [
f"- Original joint encoding: {enc_labels.get(enc, enc)}",
f"- Joint values: {joint_actions.get(enc, 'left unchanged')}",
f"- Robot type: `{result.get('robot_type')}`",
f"- Action dimension: {result.get('action_dim')}",
]
else:
lines += ["- Joint values: not applicable (not an SO-100/101 dataset)"]
if approx:
lines += ["", "> **Note:** per-robot calibration was unavailable, so joint state/action were "
"left in their original *normalized* units (-100..100 joints, 0..100 gripper) rather "
"than converted to physical degrees. Treat these joint values as APPROXIMATE."]
if result.get("ambiguous"):
lines += ["", "> **Note:** joint-encoding detection was flagged ambiguous; conversion used the "
"best-guess encoding above and may warrant manual review."]
section = "\n".join(lines) + "\n"
readme = local / "README.md"
try:
try:
from lerobot.datasets.utils import create_lerobot_dataset_card
except ImportError:
from lerobot.common.datasets.utils import create_lerobot_dataset_card
class _Info(dict): # satisfies both the dict and .to_dict() card variants
def to_dict(self):
return dict(self)
rt = result.get("robot_type") or None
tags = [rt] if rt else []
if enc == "normalized" and not converted_degrees:
tags.append(NORMALIZED_TAG)
card = create_lerobot_dataset_card(
tags=tags or None,
dataset_info=_Info(load_info(local)),
license="apache-2.0",
repo_id=sub,
)
card.text = card.text.rstrip() + "\n\n" + section
card.save(str(readme))
except Exception:
# LeRobot card generator unavailable at runtime: keep the standalone migration note.
if readme.exists():
readme.write_text(readme.read_text().rstrip() + "\n\n" + section)
else:
readme.write_text(f"# {sub}\n\n" + section)
def migrate_one(api, dst_repo, sub, work_dir, no_upload, src_repo: str = SRC_REPO,
standalone: bool = False) -> dict:
local = Path(work_dir) / sub
if local.parent.exists():
shutil.rmtree(local.parent, ignore_errors=True) # clean any partial
if standalone:
# ``sub`` is a self-contained HF dataset repo (not a monorepo folder): pull it whole.
from huggingface_hub import snapshot_download
snapshot_download(repo_id=sub, repo_type="dataset", local_dir=str(local))
else:
download_subfolder(sub, work_dir, repo=src_repo)
info = load_info(local)
if info.get("codebase_version") != "v2.1":
return {"root": sub, "action": f"skipped: source codebase is {info.get('codebase_version')} (expected v2.1)"}
if is_end_effector(info):
return {"root": sub, "robot_type": info.get("robot_type"),
"action": "skipped: end-effector (task-space) dataset, out of scope"}
mismatch = data_video_episode_mismatch(local)
if mismatch:
return {"root": sub, "robot_type": info.get("robot_type"),
"action": f"skipped: {mismatch}"}
result = fix_dataset_in_place(local) # SO-arm value fix (or structural_only)
reconciled = reconcile_episode_count(local) # align stale meta counts to data+video files
if reconciled:
result["action"] = f"{result['action']}; {reconciled}"
reindexed = reindex_episodes(local) # compact non-contiguous episode indices to 0..N-1
if reindexed:
result["action"] = f"{result['action']}; {reindexed}"
from lerobot.scripts.convert_dataset_v21_to_v30 import convert_dataset
convert_dataset(repo_id=sub, root=str(local), push_to_hub=False) # v2.1 -> v3.0, in place
_write_dataset_card(local, sub, result, standalone=standalone) # document conversion in the card
base = {k: result.get(k) for k in
("robot_type", "is_so", "encoding", "action_dim", "maxabs", "ambiguous", "action")}
if no_upload:
# keep the converted output on disk for inspection; do NOT delete or push
base["action"] = f"{base['action']}; not pushed (kept locally at {local})"
return {"root": sub, **base}
api.upload_folder(repo_id=dst_repo, repo_type="dataset", folder_path=str(local),
path_in_repo=sub, commit_message=f"Add {sub} (v3.0, {result['action']})")
shutil.rmtree(Path(work_dir) / sub.split("/")[0], ignore_errors=True) # drop after successful push
return {"root": sub, **base}
def main():
ap = argparse.ArgumentParser(
description="Migrate the HuggingFaceVLA/community_dataset_v3 monorepo to LeRobotDataset "
"v3.0, converting SO-100/101 joint state/action to physical degrees along "
"the way. Processes one sub-dataset at a time (download -> fix -> convert -> "
"upload -> cleanup) and is resumable.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
ap.add_argument("--dst-repo", default=None, metavar="ORG/NAME",
help="Destination HF dataset repo to push the converted v3.0 datasets to "
"(created if missing). Required unless --no-push or --only-classify.")
ap.add_argument("--work-dir", default="./cdv3_work", metavar="DIR",
help="Local scratch directory used to download, convert, and (unless pushing) "
"retain each sub-dataset. Only one dataset lives here at a time on a push run.")
ap.add_argument("--manifest", default="manifest.csv", metavar="CSV",
help="CSV log appended to as datasets are processed (robot_type, detected "
"encoding, action taken, errors). Reused across resumed runs.")
ap.add_argument("--limit", type=int, default=None, metavar="N",
help="Process only the first N sub-datasets (alphabetical). Ignored when "
"--folder-name is given. Useful for a quick end-to-end smoke test.")
ap.add_argument("--folder-name", nargs="+", default=None, metavar="USER/DATASET",
help=f"One or more folders WITHIN the {SRC_REPO} monorepo to process. Either a "
"full dataset path ('Beegbrain/draw_pixel_art') or a whole namespace "
"('Beegbrain'), which expands to every dataset under it. Skips the full "
"791-dataset listing.")
ap.add_argument("--only-classify", action="store_true",
help="Detect each dataset's robot type and joint encoding and write the "
"manifest, without downloading data, converting, or pushing. Run this "
"first to review scope (especially rows flagged ambiguous=True).")
ap.add_argument("--no-push", action="store_true",
help="Fix + convert locally but do NOT upload; the converted v3.0 output is "
"kept under --work-dir for inspection instead of being deleted.")
args = ap.parse_args()
no_upload = args.no_push
if not no_upload and not args.only_classify and not args.dst_repo:
ap.error("--dst-repo is required unless --no-push or --only-classify is set.")
api = HfApi()
if args.folder_name:
subs = resolve_folders(api, SRC_REPO, args.folder_name)
print(f"targeting {len(subs)} sub-dataset(s): {', '.join(subs)}", file=sys.stderr)
else:
subs = list_datasets(api, SRC_REPO)
if args.limit:
subs = subs[: args.limit]
print(f"{len(subs)} sub-datasets found", file=sys.stderr)
if not args.only_classify and not no_upload:
api.create_repo(args.dst_repo, repo_type="dataset", exist_ok=True)
dst_files = set() if (args.only_classify or no_upload) else set(
api.list_repo_files(args.dst_repo, repo_type="dataset"))
first = not Path(args.manifest).exists()
with open(args.manifest, "a", newline="") as mf:
w = None
for i, sub in enumerate(subs):
try:
if args.only_classify:
# classify without full download: fetch just the meta/ of this sub
download_subfolder(sub, args.work_dir, patterns=[f"{sub}/meta/*"])
row = {"root": sub, **classify(Path(args.work_dir) / sub)}
shutil.rmtree(Path(args.work_dir) / sub.split("/")[0], ignore_errors=True)
elif not no_upload and already_done(api, args.dst_repo, sub, dst_files):
row = {"root": sub, "action": "skipped: already present in destination repo"}
else:
row = migrate_one(api, args.dst_repo, sub, args.work_dir, no_upload)
except Exception as e:
row = {"root": sub, "action": f"ERROR: {e}"}
traceback.print_exc()
if w is None:
w = csv.DictWriter(mf, fieldnames=sorted(
{"root", "robot_type", "is_so", "encoding", "action_dim",
"maxabs", "ambiguous", "action", "codebase_version", "note"}))
if first:
w.writeheader()
w.writerow({k: row.get(k) for k in w.fieldnames})
mf.flush()
print(f"[{i+1}/{len(subs)}] {sub}: {row.get('action')}", file=sys.stderr)
if __name__ == "__main__":
main()
-285
View File
@@ -1,285 +0,0 @@
"""SLURM-distributed driver for run_migration.py.
Fans the ``community_dataset_v3`` -> v3.0 migration out across SLURM workers, mirroring the
datatrove pattern used by ``examples/dataset/slurm_recompute_stats.py``. This is a *map-only*
job: each worker owns a stride ``subs[rank::world_size]`` of the sub-datasets and runs the
exact same per-dataset pipeline as ``run_migration.py`` (download -> fix -> v2.1->v3.0 convert
-> upload -> cleanup). There is no aggregate step.
Resume is twofold and free: (1) each worker skips any sub-dataset already present in the
destination repo (``already_done``), and (2) datatrove skips ranks whose completion marker
exists. Re-run the identical command to mop up failures.
Example (numeric smoke test on one namespace, no SLURM):
python slurm_migrate.py --slurm 0 --workers 1 \
--dst-repo HuggingFaceVLA/community_dataset_v3_degrees \
--work-dir ./cdv3_work --manifest-dir ./cdv3_manifests \
--folder-name Beegbrain
Full run on the cluster:
python slurm_migrate.py \
--dst-repo HuggingFaceVLA/community_dataset_v3_degrees \
--work-dir /fsx/$USER/cdv3_work \
--manifest-dir /fsx/$USER/cdv3_manifests \
--logs-dir /fsx/$USER/logs/cdv3_migrate \
--workers 64 --partition hopper-cpu --qos normal \
--cpus-per-task 4 --mem-per-cpu 4G \
--env-command "source /fsx/$USER/venvs/lerobot/bin/activate; export HF_TOKEN=<token>"
IMPORTANT: workers must reach the internet (HF download + upload) and have a write-scoped
HF token (HF_TOKEN) in --env-command. Keep --workers modest (many concurrent commits to one
destination repo contend); rely on resume passes to clear transient upload failures.
"""
import argparse
from pathlib import Path
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
MIGRATION_DIR = str(Path(__file__).resolve().parent)
class MigrateShard(PipelineStep):
"""Each worker migrates its ``subs[rank::world_size]`` slice of sub-datasets."""
def __init__(
self,
subs,
dst_repo,
work_dir,
manifest_dir,
migration_dir,
no_push=False,
only_classify=False,
standalone=False,
):
super().__init__()
self.subs = subs
self.dst_repo = dst_repo
self.work_dir = work_dir
self.manifest_dir = manifest_dir
self.migration_dir = migration_dir
self.no_push = no_push
self.only_classify = only_classify
self.standalone = standalone
def run(self, data=None, rank: int = 0, world_size: int = 1):
# Pickled onto the worker: keep self-contained. The migration package dir must be on
# sys.path so ``run_migration`` and its siblings (classify/fix_dataset/so_arm_frame)
# import.
import csv
import logging
import shutil
import sys
import time
import traceback
from pathlib import Path
if self.migration_dir not in sys.path:
sys.path.insert(0, self.migration_dir)
from classify import classify
from huggingface_hub import HfApi
from run_migration import already_done, download_subfolder, migrate_one
from lerobot.utils.utils import init_logging
init_logging()
my_subs = self.subs[rank::world_size]
if not my_subs:
logging.info(f"Rank {rank}: no sub-datasets assigned")
return
logging.info(f"Rank {rank}: {len(my_subs)} / {len(self.subs)} sub-datasets")
# Per-rank scratch and manifest so workers never collide (migrate_one wipes
# work_dir/<namespace> around each dataset).
work_dir = str(Path(self.work_dir) / f"rank_{rank:05d}")
Path(work_dir).mkdir(parents=True, exist_ok=True)
Path(self.manifest_dir).mkdir(parents=True, exist_ok=True)
manifest = Path(self.manifest_dir) / f"manifest_{rank:05d}.csv"
api = HfApi()
# Resume prefetch. A single transient Hub read timeout here must NOT kill the whole
# rank (and skip its entire dataset slice), so retry with backoff and, as a last
# resort, fall back to an empty set (already-present datasets are re-checked per item
# and, for --source molmoact, were already filtered out on the submit node).
dst_files: set = set()
if not self.only_classify and not self.no_push:
for attempt in range(5):
try:
dst_files = set(api.list_repo_files(self.dst_repo, repo_type="dataset"))
break
except Exception as e:
if attempt == 4:
logging.warning(f"Rank {rank}: could not list {self.dst_repo} after 5 "
f"tries ({e}); proceeding without a resume set.")
else:
time.sleep(5 * (attempt + 1))
fieldnames = sorted(
{"root", "robot_type", "is_so", "encoding", "action_dim", "maxabs", "ambiguous", "action"}
)
write_header = not manifest.exists()
with open(manifest, "a", newline="") as mf:
w = csv.DictWriter(mf, fieldnames=fieldnames)
if write_header:
w.writeheader()
for i, sub in enumerate(my_subs):
try:
if self.only_classify:
if self.standalone:
from huggingface_hub import snapshot_download
snapshot_download(repo_id=sub, repo_type="dataset",
local_dir=str(Path(work_dir) / sub),
allow_patterns=["meta/*"])
else:
download_subfolder(sub, work_dir, patterns=[f"{sub}/meta/*"])
row = {"root": sub, **classify(Path(work_dir) / sub)}
shutil.rmtree(Path(work_dir) / sub.split("/")[0], ignore_errors=True)
elif not self.no_push and already_done(api, self.dst_repo, sub, dst_files):
row = {"root": sub, "action": "skipped: already present in destination repo"}
else:
row = migrate_one(api, self.dst_repo, sub, work_dir, self.no_push,
standalone=self.standalone)
except Exception as e:
row = {"root": sub, "action": f"ERROR: {e}"}
traceback.print_exc()
w.writerow({k: row.get(k) for k in fieldnames})
mf.flush()
logging.info(f"Rank {rank} [{i + 1}/{len(my_subs)}] {sub}: {row.get('action')}")
def _mem_gb(mem: str) -> int:
s = str(mem).strip().lower().rstrip("b").rstrip("g")
return int(float(s))
def _make_executor(pipeline, logs_dir, job_name, slurm, workers, time, partition, cpus, mem, qos, env_command, venv_path):
kwargs = {"pipeline": pipeline, "logging_dir": str(Path(logs_dir) / job_name)}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": workers,
"workers": workers,
"time": time,
"partition": partition,
"cpus_per_task": cpus,
"mem_per_cpu_gb": _mem_gb(mem),
"sbatch_args": {},
}
)
if qos:
kwargs["qos"] = qos
if venv_path:
kwargs["venv_path"] = venv_path
if env_command:
kwargs["env_command"] = env_command
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": workers, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def main():
import sys
if MIGRATION_DIR not in sys.path:
sys.path.insert(0, MIGRATION_DIR)
from huggingface_hub import HfApi
from migrate_molmoact import REFERENCE_REPO, list_molmoact_datasets, pending_datasets
from run_migration import SRC_REPO, list_datasets, resolve_folders
p = argparse.ArgumentParser(
description="SLURM-distributed migration to LeRobotDataset v3.0 (map-only). Source is "
"either the community_dataset_v3 monorepo or the standalone datasets listed "
"by allenai/MolmoAct2-SO100_101-Dataset.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--source", choices=("monorepo", "molmoact"), default="monorepo",
help="'monorepo': HuggingFaceVLA/community_dataset_v3 subfolders. "
"'molmoact': standalone datasets listed by allenai/MolmoAct2-SO100_101-Dataset.")
p.add_argument("--reference-repo", default=REFERENCE_REPO, metavar="ORG/NAME",
help="(--source molmoact) Repo whose datasets are already migrated and skipped.")
p.add_argument("--dst-repo", default=None, metavar="ORG/NAME", help="Destination HF dataset repo.")
p.add_argument("--work-dir", default="./cdv3_work", help="Scratch root; each rank gets a subdir.")
p.add_argument("--manifest-dir", default="./cdv3_manifests", help="Per-rank manifest CSVs land here.")
p.add_argument("--logs-dir", type=Path, default=Path("logs"), help="datatrove logs dir.")
p.add_argument("--job-name", default="cdv3_migrate", help="SLURM job name.")
p.add_argument("--workers", type=int, default=64, help="Number of parallel SLURM tasks.")
p.add_argument("--slurm", type=int, default=1, help="1 = submit via SLURM; 0 = run locally.")
p.add_argument("--partition", default=None, help="SLURM partition, e.g. 'hopper-cpu'.")
p.add_argument("--qos", default=None, help="SLURM QoS, e.g. 'normal'.")
p.add_argument("--cpus-per-task", type=int, default=4, help="CPUs per SLURM task.")
p.add_argument("--mem-per-cpu", default="4G", help="Memory per CPU, e.g. '4G'.")
p.add_argument("--time", default="24:00:00", help="Wall-clock limit per task.")
p.add_argument("--venv-path", default=None, help="venv activate script sourced on each worker.")
p.add_argument("--env-command", default=None, help="Raw shell snippet run before python (export HF_TOKEN, etc.).")
p.add_argument("--folder-name", nargs="+", default=None, help="Target specific folders/namespaces instead of all.")
p.add_argument("--limit", type=int, default=None, help="Only the first N sub-datasets (ignored with --folder-name).")
p.add_argument("--only-classify", action="store_true", help="Only classify + write manifest; no convert/upload.")
p.add_argument("--no-push", action="store_true", help="Fix + convert locally, keep output, do not upload.")
args = p.parse_args()
if not args.no_push and not args.only_classify and not args.dst_repo:
p.error("--dst-repo is required unless --no-push or --only-classify is set.")
api = HfApi()
standalone = args.source == "molmoact"
if standalone:
all_ids = list_molmoact_datasets(api)
if args.folder_name:
wanted = {n.strip("/") for n in args.folder_name}
subs = [s for s in all_ids if s in wanted]
else:
subs = pending_datasets(api, all_ids, args.dst_repo, args.reference_repo,
args.no_push, args.only_classify)
if args.limit:
subs = subs[: args.limit]
elif args.folder_name:
subs = resolve_folders(api, SRC_REPO, args.folder_name)
else:
subs = list_datasets(api, SRC_REPO)
if args.limit:
subs = subs[: args.limit]
print(f"{len(subs)} sub-datasets targeted", file=sys.stderr)
if not subs:
p.error("no sub-datasets resolved")
# Create the destination repo once on the submit node so workers don't race on it.
if not args.only_classify and not args.no_push:
api.create_repo(args.dst_repo, repo_type="dataset", exist_ok=True)
executor = _make_executor(
pipeline=[
MigrateShard(
subs,
args.dst_repo,
args.work_dir,
args.manifest_dir,
MIGRATION_DIR,
no_push=args.no_push,
only_classify=args.only_classify,
standalone=standalone,
)
],
logs_dir=args.logs_dir,
job_name=args.job_name,
slurm=args.slurm == 1,
workers=args.workers,
time=args.time,
partition=args.partition,
cpus=args.cpus_per_task,
mem=args.mem_per_cpu,
qos=args.qos,
env_command=args.env_command,
venv_path=args.venv_path,
)
executor.run()
if __name__ == "__main__":
main()
-69
View File
@@ -1,69 +0,0 @@
"""SO-100/101 joint-frame conversion to physical degrees (post-#777 convention).
Two calibration-free branches + one that needs an assumed canonical range:
* degrees_old (bare robot_type `so100`/`so101`, |vals|>~180): PR #3879 old->new
convention (sign flip shoulder_lift, +90 deg shoulder_lift/elbow_flex).
EXACT.
* degrees_new (`*_follower` recorded with use_degrees=True, not saturated): already
degrees. EXACT.
* normalized (`*_follower`, -100..100 joints / 0..100 gripper, saturates at bounds):
5 arm joints are mid-range-zero, only the SCALE is missing (per-robot
range_min/max not stored) -> use assumed canonical spans below. APPROXIMATE.
The gripper (0..100) is kept in its native frame, matching degrees_new.
* radians -> untouched.
Joint order per arm: shoulder_pan, shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper.
Bimanual (12-dim) tiles the 6-joint block twice.
"""
import numpy as np
JOINT_ORDER = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"]
# --- PR #3879 (degrees). old(community frame) <-> new(v3.0 / post-#777) frame. ---
SIGNS = np.array([1.0, -1.0, 1.0, 1.0, 1.0, 1.0], dtype=np.float64)
OFFSETS_DEG = np.array([0.0, 90.0, 90.0, 0.0, 0.0, 0.0], dtype=np.float64)
# --- Canonical per-joint spans (DEGREES) used ONLY to invert the -100..100 normalization of
# the 5 arm joints (RANGE_M100_100) when per-robot calibration is unavailable: normalized
# +/-100 -> +/-HALF_RANGE. The gripper (RANGE_0_100) is left in its native 0..100 frame in
# every SO dataset, so it needs no canonical span. THESE ARE PLACEHOLDERS — run
# calibrate_canonical_ranges.py and paste the fitted values here before a production run. ---
CANON_HALF_RANGE_DEG = np.array([100.0, 100.0, 100.0, 100.0, 100.0], dtype=np.float64) # 5 arm joints
CANON_IS_CALIBRATED = False # flipped to True once you paste fitted values
def _convert_arm(x: np.ndarray, encoding: str) -> np.ndarray:
"""x: (..., 6) for a single SO arm -> degrees (..., 6)."""
x = np.asarray(x, dtype=np.float64)
if encoding == "radians":
return x
if encoding == "degrees_old":
return SIGNS * (x - OFFSETS_DEG)
if encoding == "degrees_new":
return x
if encoding == "normalized":
new_deg = np.array(x, dtype=np.float64)
new_deg[..., :5] = (x[..., :5] / 100.0) * CANON_HALF_RANGE_DEG
# gripper is RANGE_0_100 in every SO dataset (including use_degrees=True / degrees_new),
# so it is already frame-consistent and must be left untouched, not remapped to +/-deg.
return new_deg
raise ValueError(f"unknown encoding: {encoding!r}")
def to_degrees(arr, encoding: str, n_joints_per_arm: int = 6) -> np.ndarray:
"""arr: (..., D) with D a multiple of 6. Returns float32 degrees, same shape."""
arr = np.asarray(arr, dtype=np.float64)
d = arr.shape[-1]
if d % n_joints_per_arm != 0:
raise ValueError(f"action/state dim {d} is not a multiple of {n_joints_per_arm}")
if encoding == "normalized" and not CANON_IS_CALIBRATED:
raise RuntimeError(
"CANON ranges are placeholders. Run calibrate_canonical_ranges.py and set "
"CANON_* + CANON_IS_CALIBRATED=True before converting 'normalized' datasets to degrees."
)
out = np.empty_like(arr)
for a in range(d // n_joints_per_arm):
sl = slice(a * n_joints_per_arm, (a + 1) * n_joints_per_arm)
out[..., sl] = _convert_arm(arr[..., sl], encoding)
return out.astype(np.float32)
-117
View File
@@ -1,117 +0,0 @@
"""Tag the migrated (v3.0) datasets whose SO-arm joints are still in normalized units.
Walks every `{user}/{dataset}` sub-dataset in the destination repo, re-classifies it from its
v3.0 metadata (meta/info.json + meta/stats.json), and adds the `normalized` card tag to any whose
joint state/action are in normalized units (-100..100 / 0..100) rather than physical degrees. These
are the datasets run_migration.py left un-converted (uncalibrated -> APPROXIMATE). Idempotent:
already-tagged datasets are skipped. Dry-run by default; pass --yes to actually push the edited card.
python tag_normalized.py --dst-repo lerobot/community_dataset_v3 # dry-run
python tag_normalized.py --dst-repo lerobot/community_dataset_v3 --yes
"""
import argparse
import json
import sys
import tempfile
import time
from pathlib import Path
import numpy as np
from huggingface_hub import DatasetCard, HfApi, hf_hub_download
from classify import (
encoding_from_bounds,
is_end_effector,
is_mislabeled_so,
is_so_robot_type,
load_info,
so_joint_count,
)
from run_migration import NORMALIZED_TAG
DST_REPO = "lerobot/community_dataset_v3"
def is_normalized(root: Path) -> bool:
"""True when the SO-arm joints of a v3.0 sub-dataset at ``root`` are still normalized."""
info = load_info(root)
rt = info.get("robot_type", "") or ""
if is_end_effector(info) or not is_so_robot_type(rt) or is_mislabeled_so(info):
return False
stats = json.loads((root / "meta" / "stats.json").read_text())
key = next((k for k in ("action", "observation.state") if k in stats), None)
if key is None:
return False
lo = np.asarray(stats[key]["min"], dtype=float)
hi = np.asarray(stats[key]["max"], dtype=float)
n = so_joint_count(info, key) or len(hi)
return encoding_from_bounds(lo[:n], hi[:n], rt)["encoding"] == "normalized"
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dst-repo", default=DST_REPO, metavar="ORG/NAME",
help="Destination HF dataset repo whose sub-datasets are inspected and tagged.")
ap.add_argument("--limit", type=int, default=None, metavar="N",
help="Inspect only the first N sub-datasets (alphabetical). Useful for a smoke test.")
ap.add_argument("--yes", action="store_true",
help="Actually push the tag. Without it, only prints what would be tagged (dry-run).")
args = ap.parse_args()
api = HfApi()
subs = sorted(p[: -len("/meta/info.json")]
for p in api.list_repo_files(args.dst_repo, repo_type="dataset")
if p.endswith("/meta/info.json"))
if args.limit:
subs = subs[: args.limit]
print(f"{len(subs)} sub-datasets in {args.dst_repo}", file=sys.stderr)
tagged, already, failed = [], [], []
for i, sub in enumerate(subs):
try:
with tempfile.TemporaryDirectory() as tmp:
for f in ("meta/info.json", "meta/stats.json"):
hf_hub_download(args.dst_repo, f"{sub}/{f}", repo_type="dataset", local_dir=tmp)
if not is_normalized(Path(tmp) / sub):
continue
readme = hf_hub_download(args.dst_repo, f"{sub}/README.md", repo_type="dataset", local_dir=tmp)
card = DatasetCard.load(readme)
card_tags = list(card.data.tags or [])
if NORMALIZED_TAG in card_tags:
already.append(sub)
continue
if not args.yes:
tagged.append(sub)
continue
card.data.tags = card_tags + [NORMALIZED_TAG]
card.save(readme)
for attempt in range(1, 4): # transient Hub ReadTimeouts are common; retry w/ backoff
try:
api.upload_file(path_or_fileobj=readme, path_in_repo=f"{sub}/README.md",
repo_id=args.dst_repo, repo_type="dataset",
commit_message=f"Tag {sub} '{NORMALIZED_TAG}'")
tagged.append(sub)
break
except Exception as e:
if attempt == 3:
failed.append(sub)
print(f"FAILED {sub}: {e}", file=sys.stderr)
else:
time.sleep(2 ** attempt)
except Exception as e:
failed.append(sub)
print(f"ERROR {sub}: {e}", file=sys.stderr)
print(f"[{i + 1}/{len(subs)}] {sub}", file=sys.stderr)
verb = "tagged" if args.yes else "would tag"
for sub in tagged:
print(f"{verb}: {sub}")
print(f"\n{len(tagged)} {verb} '{NORMALIZED_TAG}', {len(already)} already tagged, "
f"{len(failed)} failed.", file=sys.stderr)
if not args.yes and tagged:
print("dry-run: nothing pushed. re-run with --yes to apply.", file=sys.stderr)
if __name__ == "__main__":
main()
+12 -3
View File
@@ -111,17 +111,26 @@ Requirements:
- The block-causal masks use PyTorch **flex-attention**, so build the policy with
`--policy.attn_mode=flex` for training (the default `torch` SDPA is inference-only).
- The full 5B DiT does not fit a single 2432 GB GPU under AdamW; fine-tune with **LoRA**
(`--policy.use_peft=true`) and/or optimizer offload. `get_optim_params` returns only the
trainable (e.g. adapter) parameters; the VAE + UMT5 text encoder stay frozen.
(`--peft.method_type=LORA`) and/or optimizer offload. `get_optim_params` returns only the
trainable (e.g. adapter) parameters; the VAE + UMT5 text encoder stay frozen. Install the
`lerobot[peft]` extra to enable PEFT support (see the [PEFT training guide](./peft_training)).
```bash
lerobot-train \
--policy.path=lerobot/lingbot_va_libero_long --policy.attn_mode=flex \
--policy.use_peft=true \
--peft.method_type=LORA --peft.r=32 --peft.lora_alpha=32 \
--peft.target_modules='transformer\.blocks\.\d+\.attn[12]\.(to_q|to_v)' \
--dataset.repo_id=<your LeRobot-format dataset> \
--batch_size=1 --steps=... --output_dir=outputs/train/lingbot_va
```
Unlike SmolVLA / π₀, LingBot-VA does not ship built-in default LoRA targets, so you must pass
`--peft.target_modules` explicitly. Only `self.transformer` (the dual-stream Wan transformer) is
trainable; the example above adapts the query/value projections of both its self-attention
(`attn1`) and cross-attention (`attn2`) blocks — the standard LoRA target set. Broaden it (e.g.
add `to_k`/`to_out`, or the `ffn` layers) if you need a higher-capacity adapter. Passing
`--peft.method_type` implies PEFT, so `--policy.use_peft=true` is not required.
The dataset must provide camera clips (a temporal window per camera, VAE-encoded to
`frame_chunk_size` latent frames) and `frame_chunk_size * action_per_frame` action steps per item.
+7
View File
@@ -62,3 +62,10 @@ to the `--peft.full_training_modules` parameter:
The learning rate and the scheduled target learning rate can usually be scaled by a factor of 10 compared to the
learning rate used for full fine-tuning (e.g., 1e-4 normal, so 1e-3 using LoRA).
## Other policies
The same `--peft.*` flags work for any pre-trained policy. Some policies (SmolVLA, π₀, π₀.₅) ship
built-in default `target_modules`, so `--peft.method_type=LORA` is enough. Others do not, and will
ask you to pass `--peft.target_modules` explicitly — for example LingBot-VA, whose recommended
targets are documented in its [dedicated guide](./lingbot_va#training--fine-tuning).
+4 -1
View File
@@ -46,8 +46,11 @@ CMD = (
"apt-get update -qq && apt-get install -y -qq git ffmpeg && "
"pip install --no-deps "
"'lerobot @ git+https://github.com/huggingface/lerobot.git@main' && "
# Pins mirror pyproject.toml — unpinned installs pull av 18 / datasets 5 /
# draccus 0.11, which break lerobot at import time.
"pip install --upgrade-strategy only-if-needed "
"datasets pyarrow av jsonlines draccus gymnasium torchcodec mergedeep pyyaml-include toml typing-inspect "
"'datasets>=4.7.0,<5.0.0' 'pyarrow>=21.0.0,<30.0.0' 'av>=15.0.0,<16.0.0' 'draccus==0.10.0' "
"'pandas>=2.0.0,<3.0.0' jsonlines gymnasium torchcodec mergedeep pyyaml-include toml typing-inspect "
"openai && "
"export VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 && "
"export VLLM_VIDEO_BACKEND=pyav && "
+5
View File
@@ -221,6 +221,11 @@ class TrainPipelineConfig(HubMixin):
)
active_cfg = self.trainable_config
# Keep the policy-level `use_peft` flag in sync with the presence of a `--peft.*` config.
if self.peft is not None and self.policy is not None:
self.policy.use_peft = True
if self.rename_map and active_cfg.pretrained_path is None:
raise ValueError(
"`rename_map` requires a pretrained policy checkpoint. "
-14
View File
@@ -729,7 +729,6 @@ def concatenate_video_files(
stream_map[input_stream.index].time_base = input_stream.time_base
# Demux + remux packets (no re-encode)
last_dts: dict[int, int] = {}
for packet in input_container.demux():
# Skip packets from un-mapped streams
if packet.stream.index not in stream_map:
@@ -739,19 +738,6 @@ def concatenate_video_files(
if packet.dts is None:
continue
# Enforce strictly increasing DTS. Clips encoded with B-frames start at a negative DTS, so the
# concat demuxer can emit the first packet of a clip with a DTS equal to the last of the previous
# one; the MP4 muxer rejects duplicate/decreasing DTS with [Errno 22]. Nudge such packets forward.
prev_dts = last_dts.get(packet.stream.index)
if prev_dts is not None and packet.dts <= prev_dts:
shift = prev_dts + 1 - packet.dts
packet.dts += shift
if packet.pts is not None:
packet.pts += shift
if packet.pts is not None and packet.pts < packet.dts:
packet.pts = packet.dts
last_dts[packet.stream.index] = packet.dts
output_stream = stream_map[packet.stream.index]
packet.stream = output_stream
output_container.mux(packet)
@@ -27,9 +27,11 @@ from lerobot.utils.import_utils import _transformers_available, require_package
if TYPE_CHECKING or _transformers_available:
from transformers import AutoModel, AutoTokenizer
from transformers.utils import is_flash_attn_2_available
else:
AutoModel = None
AutoTokenizer = None
is_flash_attn_2_available = None
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
@@ -135,9 +137,13 @@ class InternVL3Embedder(nn.Module):
raise ValueError(f"Unsupported EVO1 vlm_dtype '{model_dtype}'") from exc
self.model_dtype = model_dtype
attn_implementation = "flash_attention_2" if (use_flash_attn and _flash_attn_available()) else "eager"
attn_implementation = (
"flash_attention_2" if (use_flash_attn and is_flash_attn_2_available()) else "eager"
)
if use_flash_attn and attn_implementation == "eager":
logger.warning("flash_attn is not installed. Falling back to eager attention.")
logger.warning(
"Flash Attention 2 is unavailable on this runtime. Falling back to eager attention."
)
self.model = AutoModel.from_pretrained(
model_name,
@@ -359,11 +365,3 @@ class InternVL3Embedder(nn.Module):
@property
def device(self) -> torch.device:
return next(self.model.parameters()).device
def _flash_attn_available() -> bool:
try:
import flash_attn # noqa: F401
except ModuleNotFoundError:
return False
return True
+45 -3
View File
@@ -513,6 +513,37 @@ def make_pre_post_processors(
return processors
def _has_peft_adapter_config(
pretrained_path: str,
revision: str | None = None,
) -> bool:
"""Return whether ``pretrained_path`` points to an existing PEFT adapter.
A PEFT adapter checkpoint always ships an ``adapter_config.json``.
A plain base-model checkpoint does not. This distinction lets us tell apart
two very different ``use_peft=True`` scenarios that both set ``pretrained_path``:
* loading/resuming a previously trained adapter (config lives at ``pretrained_path``)
* starting a *fresh* PEFT fine-tune on top of a base model
Works for both local directories and Hub repo ids.
"""
import os
adapter_config_name = "adapter_config.json"
if os.path.isdir(pretrained_path):
return os.path.isfile(os.path.join(pretrained_path, adapter_config_name))
from huggingface_hub import file_exists
from huggingface_hub.errors import HfHubHTTPError
try:
return file_exists(pretrained_path, adapter_config_name, revision=revision)
except (HfHubHTTPError, OSError):
return False
def make_policy(
cfg: PreTrainedConfig,
ds_meta: LeRobotDatasetMetadata | None = None,
@@ -607,13 +638,24 @@ def make_policy(
"the PEFT config parameters to be set. For training with PEFT, see `lerobot_train.py` on how to do that."
)
if cfg.pretrained_path and not cfg.use_peft:
# When `use_peft=True` and a checkpoint is given, the checkpoint can be one of two things:
# 1. A base model checkpoint (e.g., a pretrained policy) on which we want to start a fresh PEFT fine-tune.
# 2. A PEFT adapter checkpoint (e.g., a previously trained PEFT adapter)
# We distinguish between these two cases
load_existing_adapter = (
cfg.pretrained_path
and cfg.use_peft
and _has_peft_adapter_config(str(cfg.pretrained_path), cfg.pretrained_revision)
)
if cfg.pretrained_path and not load_existing_adapter:
# Load a pretrained policy and override the config if needed (for example, if there are inference-time
# hyperparameters that we want to vary).
# hyperparameters that we want to vary). This also covers starting a fresh PEFT fine-tune on top of a
# base model: the base weights are loaded here and `wrap_with_peft` builds the adapter afterwards.
kwargs["pretrained_name_or_path"] = cfg.pretrained_path
kwargs["revision"] = cfg.pretrained_revision
policy = policy_cls.from_pretrained(**kwargs)
elif cfg.pretrained_path and cfg.use_peft:
elif load_existing_adapter:
# Load a pretrained PEFT model on top of the policy. The pretrained path points to the folder/repo
# of the adapter and the adapter's config contains the path to the base policy. So we need the
# adapter config first, then load the correct policy and then apply PEFT.
+4 -21
View File
@@ -23,8 +23,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, TypedDict, TypeVar, Unpack
import packaging
import safetensors
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download, save_torch_state_dict
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError
@@ -34,6 +32,7 @@ from torch import Tensor, nn
from lerobot.__version__ import __version__
from lerobot.configs import PreTrainedConfig
from lerobot.configs.train import TrainPipelineConfig
from lerobot.utils.device_utils import resolve_safetensors_device
from lerobot.utils.hub import HubMixin
from .utils import log_model_loading_keys
@@ -221,26 +220,10 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
@classmethod
def _load_as_safetensor(cls, model: T, model_file: str, map_location: str, strict: bool) -> T:
# Create base kwargs
kwargs = {"strict": strict}
# Add device parameter for newer versions that support it
if packaging.version.parse(safetensors.__version__) >= packaging.version.parse("0.4.3"):
kwargs["device"] = map_location
# Load the model with appropriate kwargs
missing_keys, unexpected_keys = load_model_as_safetensor(model, model_file, **kwargs)
missing_keys, unexpected_keys = load_model_as_safetensor(
model, model_file, strict=strict, device=resolve_safetensors_device(map_location)
)
log_model_loading_keys(missing_keys, unexpected_keys)
# For older versions, manually move to device if needed
if "device" not in kwargs and map_location != "cpu":
logging.warning(
"Loading model weights on other devices than 'cpu' is not supported natively in your version of safetensors."
" This means that the model is loaded on 'cpu' first and then copied to the device."
" This leads to a slower loading time."
" Please update safetensors to version 0.4.3 or above for improved performance."
)
model.to(map_location)
return model
@abc.abstractmethod
+4 -21
View File
@@ -21,8 +21,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any, TypeVar
import packaging
import safetensors
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError
@@ -30,6 +28,7 @@ from safetensors.torch import load_model as load_model_as_safetensor, save_model
from torch import Tensor, nn
from lerobot.configs.rewards import RewardModelConfig
from lerobot.utils.device_utils import resolve_safetensors_device
from lerobot.utils.hub import HubMixin
if TYPE_CHECKING:
@@ -129,29 +128,13 @@ class PreTrainedRewardModel(nn.Module, HubMixin, abc.ABC):
@classmethod
def _load_as_safetensor(cls, model: T, model_file: str, map_location: str, strict: bool) -> T:
# Create base kwargs
kwargs = {"strict": strict}
# Add device parameter for newer versions that support it
if packaging.version.parse(safetensors.__version__) >= packaging.version.parse("0.4.3"):
kwargs["device"] = map_location
# Load the model with appropriate kwargs
missing_keys, unexpected_keys = load_model_as_safetensor(model, model_file, **kwargs)
missing_keys, unexpected_keys = load_model_as_safetensor(
model, model_file, strict=strict, device=resolve_safetensors_device(map_location)
)
if missing_keys:
logging.warning(f"Missing key(s) when loading model: {missing_keys}")
if unexpected_keys:
logging.warning(f"Unexpected key(s) when loading model: {unexpected_keys}")
# For older versions, manually move to device if needed
if "device" not in kwargs and map_location != "cpu":
logging.warning(
"Loading model weights on other devices than 'cpu' is not supported natively in your version of safetensors."
" This means that the model is loaded on 'cpu' first and then copied to the device."
" This leads to a slower loading time."
" Please update safetensors to version 0.4.3 or above for improved performance."
)
model.to(map_location)
return model
def get_optim_params(self):
+23 -25
View File
@@ -28,7 +28,12 @@ For distributed runs, see ``examples/annotations/run_hf_job.py``.
"""
import logging
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING
from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.errors import RevisionNotFoundError
from lerobot.annotations.steerable_pipeline.config import AnnotationPipelineConfig
from lerobot.annotations.steerable_pipeline.executor import Executor
@@ -42,6 +47,12 @@ from lerobot.annotations.steerable_pipeline.validator import StagingValidator
from lerobot.annotations.steerable_pipeline.vlm_client import make_vlm_client
from lerobot.annotations.steerable_pipeline.writer import LanguageColumnsWriter
from lerobot.configs import parser
from lerobot.utils.import_utils import _datasets_available, require_package
if TYPE_CHECKING or _datasets_available:
from lerobot.datasets.dataset_metadata import CODEBASE_VERSION
from lerobot.datasets.io_utils import load_info
from lerobot.datasets.utils import create_lerobot_dataset_card
logger = logging.getLogger(__name__)
@@ -50,8 +61,6 @@ def _resolve_root(cfg: AnnotationPipelineConfig) -> Path:
if cfg.root is not None:
return Path(cfg.root)
if cfg.repo_id is not None:
from huggingface_hub import snapshot_download
return Path(snapshot_download(repo_id=cfg.repo_id, repo_type="dataset"))
raise ValueError("Either --root or --repo_id must be provided.")
@@ -125,7 +134,7 @@ def _push_to_hub(root: Path, cfg: AnnotationPipelineConfig) -> None:
Pushes to ``cfg.new_repo_id`` when set, otherwise back to ``cfg.repo_id``.
"""
from huggingface_hub import HfApi # noqa: PLC0415
require_package("datasets", "dataset")
repo_id = cfg.new_repo_id or cfg.repo_id
commit_message = cfg.push_commit_message or "Add steerable annotations (lerobot-annotate)"
@@ -143,33 +152,26 @@ def _push_to_hub(root: Path, cfg: AnnotationPipelineConfig) -> None:
repo_id=repo_id,
repo_type="dataset",
commit_message=commit_message,
ignore_patterns=[".annotate_staging/**", "**/.DS_Store"],
# README.md is excluded because when pushing to ``new_repo_id`` the
# source card's links (e.g. the visualize badge) would keep pointing
# at the source dataset; a fresh card is generated below instead.
ignore_patterns=[".annotate_staging/**", "**/.DS_Store", "README.md"],
)
print(f"[lerobot-annotate] uploaded to https://huggingface.co/datasets/{repo_id}", flush=True)
dataset_info = load_info(root)
card = create_lerobot_dataset_card(dataset_info=dataset_info, license="apache-2.0", repo_id=repo_id)
card.push_to_hub(repo_id=repo_id, repo_type="dataset")
# Tag the upload with the codebase version. ``LeRobotDatasetMetadata``
# resolves the dataset revision via ``get_safe_version`` which scans
# for tags like ``v3.0``; without a tag it raises
# ``RevisionNotFoundError``. Read the version straight from the
# dataset's own ``meta/info.json`` so we tag whatever the writer
# actually wrote (no accidental drift if the codebase floor moves).
from lerobot.datasets.dataset_metadata import CODEBASE_VERSION # noqa: PLC0415
info_path = root / "meta" / "info.json"
version_tag = CODEBASE_VERSION
if info_path.exists():
try:
from lerobot.utils.io_utils import load_json # noqa: PLC0415
info = load_json(info_path)
ds_version = info.get("codebase_version")
if isinstance(ds_version, str) and ds_version.startswith("v"):
version_tag = ds_version
except Exception as exc: # noqa: BLE001
print(
f"[lerobot-annotate] could not read codebase_version from info.json ({exc}); falling back to {version_tag}",
flush=True,
)
version_tag = (
dataset_info.codebase_version if dataset_info.codebase_version.startswith("v") else CODEBASE_VERSION
)
revision = getattr(commit_info, "oid", None)
tag_kwargs = {
"repo_id": repo_id,
@@ -180,10 +182,6 @@ def _push_to_hub(root: Path, cfg: AnnotationPipelineConfig) -> None:
tag_kwargs["revision"] = revision
try:
from contextlib import suppress # noqa: PLC0415
from huggingface_hub.errors import RevisionNotFoundError # noqa: PLC0415
with suppress(RevisionNotFoundError):
api.delete_tag(repo_id, tag=version_tag, repo_type="dataset")
api.create_tag(**tag_kwargs)
+7 -3
View File
@@ -171,6 +171,9 @@ def update_policy(
train_metrics.update_s = time.perf_counter() - start_time
if torch.cuda.is_available():
train_metrics.gpu_mem_gb = torch.cuda.max_memory_allocated() / (1024**3)
# Aggregate the policy's scalar outputs for logging and rank-reduction across the log window.
if output_dict:
train_metrics.update_metrics(output_dict)
return train_metrics, output_dict
@@ -572,7 +575,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
batch = preprocessor(batch)
train_tracker.dataloading_s = time.perf_counter() - start_time
train_tracker, output_dict = update_policy(
train_tracker, _ = update_policy(
train_tracker,
policy,
batch,
@@ -605,9 +608,10 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
train_tracker.samples_per_s = effective_batch_size / step_time
logging.info(train_tracker)
if wandb_logger:
# Policy sub-losses (latent_loss, action_loss, ...) are aggregated into the
# tracker by update_policy, so to_dict() already carries their windowed,
# rank-reduced averages — no per-step output_dict passthrough needed.
wandb_log_dict = train_tracker.to_dict()
if output_dict:
wandb_log_dict.update(output_dict)
# Log sample weighting statistics if enabled
if sample_weighter is not None:
weighter_stats = sample_weighter.get_stats()
+14
View File
@@ -59,6 +59,20 @@ def get_safe_torch_device(try_device: str, log: bool = False) -> torch.device:
return device
def resolve_safetensors_device(map_location: str | torch.device) -> str:
"""Resolve a device string for a safetensors load, working around a device-mapping quirk.
safetensors' load maps the bare string "cuda" to cuda:0 regardless of the current device
(unlike torch's .to("cuda"), which honors torch.cuda.current_device()). Under multi-GPU
accelerate/FSDP every rank would then load its weights onto GPU 0, OOMing it before sharding.
Resolve "cuda" to the concrete current-device index so each rank loads onto its own GPU.
"""
map_location = str(map_location)
if map_location == "cuda" and torch.cuda.is_available():
return f"cuda:{torch.cuda.current_device()}"
return map_location
def get_safe_dtype(dtype: torch.dtype, device: str | torch.device):
"""
mps is currently not compatible with float64
+19
View File
@@ -104,6 +104,7 @@ class MetricsTracker:
"episodes",
"epochs",
"accelerator",
"_caller_metrics",
]
def __init__(
@@ -129,6 +130,9 @@ class MetricsTracker:
self.episodes = self.samples / self._avg_samples_per_ep
self.epochs = self.samples / self._num_frames
self.accelerator = accelerator
# Meter names the caller registered up front. update_metrics() leaves these untouched, so a
# policy that echoes e.g. "loss" in its output dict can't clobber the aggregated meter.
self._caller_metrics: set[str] = set(self.metrics)
def __getattr__(self, name: str) -> int | dict[str, AverageMeter] | AverageMeter | Any:
if name in self.__dict__:
@@ -156,6 +160,21 @@ class MetricsTracker:
self.episodes = self.samples / self._avg_samples_per_ep
self.epochs = self.samples / self._num_frames
def update_metrics(self, values: dict[str, Any]) -> None:
"""Accumulate a dict of scalar metrics, auto-registering a meter for each new key.
Non-numeric values and bools are ignored.
Caller-registered metrics (those passed to the constructor) are never overridden.
"""
for name, value in values.items():
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
if name in self._caller_metrics:
continue
if name not in self.metrics:
self.metrics[name] = AverageMeter(name, ":.3f", reduction="mean")
self.metrics[name].update(float(value))
def reduce_across_ranks(self) -> None:
"""
Synchronises the running averages of every metric whose ``reduction`` is not ``"none"``
@@ -0,0 +1,142 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Regression tests for PEFT loading when the checkpoint is a base model (see issue #3975).
Starting a fresh LoRA/PEFT fine-tune points ``--policy.path`` at a *base model* (no
``adapter_config.json``) while also setting ``use_peft=True``. This must NOT be mistaken for
loading an existing PEFT adapter. These tests lock in the base-model vs. adapter distinction
made by ``lerobot.policies.factory._has_peft_adapter_config`` and the branch it drives in
``make_policy``. They are pure/fast (no network, no ``peft``), so they run in CI.
"""
import json
from unittest.mock import MagicMock, patch
import torch
from huggingface_hub.errors import HfHubHTTPError
from lerobot.policies.factory import _has_peft_adapter_config
def test_local_base_model_dir_has_no_adapter_config(tmp_path):
# A base-model checkpoint directory (only model weights, no adapter config).
(tmp_path / "model.safetensors").write_bytes(b"")
(tmp_path / "config.json").write_text("{}")
assert _has_peft_adapter_config(str(tmp_path)) is False
def test_local_adapter_dir_has_adapter_config(tmp_path):
(tmp_path / "adapter_config.json").write_text(json.dumps({"peft_type": "LORA"}))
(tmp_path / "adapter_model.safetensors").write_bytes(b"")
assert _has_peft_adapter_config(str(tmp_path)) is True
def test_hub_base_model_repo_has_no_adapter_config():
with patch("huggingface_hub.file_exists", return_value=False) as mock_exists:
assert _has_peft_adapter_config("lerobot/lingbot_va_base") is False
mock_exists.assert_called_once()
assert mock_exists.call_args.args[1] == "adapter_config.json"
def test_hub_adapter_repo_has_adapter_config():
with patch("huggingface_hub.file_exists", return_value=True):
assert _has_peft_adapter_config("some/adapter-repo", revision="main") is True
def test_hub_lookup_error_falls_back_to_base_model():
# Offline / private / transient Hub errors must not crash; treat as "not an adapter".
with patch(
"huggingface_hub.file_exists",
side_effect=HfHubHTTPError("boom", response=MagicMock()),
):
assert _has_peft_adapter_config("some/private-repo") is False
with patch("huggingface_hub.file_exists", side_effect=OSError("offline")):
assert _has_peft_adapter_config("some/repo") is False
def _make_dummy_policy_cfg(pretrained_path, use_peft):
cfg = MagicMock()
cfg.type = "act"
cfg.device = "cpu"
cfg.pretrained_path = pretrained_path
cfg.pretrained_revision = None
cfg.use_peft = use_peft
cfg.input_features = {}
cfg.output_features = {}
return cfg
@patch("lerobot.policies.factory.validate_visual_features_consistency")
@patch("lerobot.policies.factory.env_to_policy_features", return_value={})
@patch("lerobot.policies.factory.get_policy_class")
def test_make_policy_base_model_with_use_peft_loads_base_not_adapter(
mock_get_cls, _mock_features, _mock_validate
):
"""`use_peft=True` on a base model must load the base weights, not a PEFT adapter.
Before the #3975 fix this went down the ``PeftConfig.from_pretrained`` path and failed
looking for a non-existent ``adapter_config.json``.
"""
from lerobot.policies import factory
policy_cls = MagicMock()
loaded_policy = torch.nn.Linear(1, 1) # a real nn.Module so make_policy's assert passes
policy_cls.from_pretrained.return_value = loaded_policy
mock_get_cls.return_value = policy_cls
cfg = _make_dummy_policy_cfg(pretrained_path="lerobot/lingbot_va_base", use_peft=True)
env_cfg = MagicMock()
with patch.object(factory, "_has_peft_adapter_config", return_value=False) as mock_has_adapter:
policy = factory.make_policy(cfg=cfg, env_cfg=env_cfg)
mock_has_adapter.assert_called_once()
# Base model is loaded via the normal pretrained path...
policy_cls.from_pretrained.assert_called_once()
assert policy_cls.from_pretrained.call_args.kwargs["pretrained_name_or_path"] == (
"lerobot/lingbot_va_base"
)
# ...and PEFT adapter loading is NOT attempted (would need peft + adapter_config.json).
assert policy is loaded_policy
@patch("lerobot.policies.factory.validate_visual_features_consistency")
@patch("lerobot.policies.factory.env_to_policy_features", return_value={})
@patch("lerobot.policies.factory.get_policy_class")
def test_make_policy_existing_adapter_uses_peft_loading(mock_get_cls, _mock_features, _mock_validate):
"""A real adapter checkpoint (has ``adapter_config.json``) must go through PEFT loading."""
from lerobot.policies import factory
policy_cls = MagicMock()
mock_get_cls.return_value = policy_cls
cfg = _make_dummy_policy_cfg(pretrained_path="some/adapter-repo", use_peft=True)
env_cfg = MagicMock()
policy_cls.from_pretrained.return_value = torch.nn.Linear(1, 1)
fake_peft = MagicMock()
fake_peft_config = MagicMock()
fake_peft_config.base_model_name_or_path = "lerobot/lingbot_va_base"
fake_peft.PeftConfig.from_pretrained.return_value = fake_peft_config
fake_peft.PeftModel.from_pretrained.return_value = torch.nn.Linear(1, 1)
with (
patch.object(factory, "_has_peft_adapter_config", return_value=True),
patch.dict("sys.modules", {"peft": fake_peft}),
):
factory.make_policy(cfg=cfg, env_cfg=env_cfg)
fake_peft.PeftConfig.from_pretrained.assert_called_once_with("some/adapter-repo")
fake_peft.PeftModel.from_pretrained.assert_called_once()
+20 -7
View File
@@ -18,6 +18,8 @@ import json
from types import SimpleNamespace
import pytest
import requests
from huggingface_hub.errors import RevisionNotFoundError
# ``lerobot.scripts.lerobot_annotate`` (and the ``_push_to_hub`` path it
# exercises) imports ``lerobot.datasets``, which only ships under the
@@ -26,11 +28,13 @@ pytest.importorskip("datasets", reason="datasets is required (install lerobot[da
def test_push_to_hub_tags_uploaded_dataset_revision(tmp_path, monkeypatch):
from lerobot.scripts.lerobot_annotate import _push_to_hub
from lerobot.scripts import lerobot_annotate
root = tmp_path / "dataset"
(root / "meta").mkdir(parents=True)
(root / "meta" / "info.json").write_text(json.dumps({"codebase_version": "v3.0"}))
(root / "meta" / "info.json").write_text(
json.dumps({"codebase_version": "v3.0", "fps": 30, "features": {}})
)
calls = {}
@@ -43,9 +47,6 @@ def test_push_to_hub_tags_uploaded_dataset_revision(tmp_path, monkeypatch):
return SimpleNamespace(oid="abc123")
def delete_tag(self, repo_id, **kwargs):
import requests
from huggingface_hub.errors import RevisionNotFoundError
calls["delete_tag"] = {"repo_id": repo_id, **kwargs}
# Simulate the common case: no stale tag to delete.
raise RevisionNotFoundError("no such tag", response=requests.Response())
@@ -53,7 +54,12 @@ def test_push_to_hub_tags_uploaded_dataset_revision(tmp_path, monkeypatch):
def create_tag(self, **kwargs):
calls["create_tag"] = kwargs
monkeypatch.setattr("huggingface_hub.HfApi", FakeHfApi)
monkeypatch.setattr(lerobot_annotate, "HfApi", FakeHfApi)
def fake_card_push(self, **kwargs):
calls["card_push"] = {"content": str(self), **kwargs}
monkeypatch.setattr("huggingface_hub.DatasetCard.push_to_hub", fake_card_push)
cfg = SimpleNamespace(
repo_id="source/dataset",
@@ -62,7 +68,7 @@ def test_push_to_hub_tags_uploaded_dataset_revision(tmp_path, monkeypatch):
push_commit_message=None,
)
_push_to_hub(root, cfg)
lerobot_annotate._push_to_hub(root, cfg)
assert calls["create_repo"] == {
"repo_id": "annotated/dataset",
@@ -71,6 +77,13 @@ def test_push_to_hub_tags_uploaded_dataset_revision(tmp_path, monkeypatch):
"exist_ok": True,
}
assert calls["upload_folder"]["repo_id"] == "annotated/dataset"
# The source README must not be copied over: its links (e.g. the
# visualize badge) point at the source dataset. A card regenerated for
# the target repo is pushed instead.
assert "README.md" in calls["upload_folder"]["ignore_patterns"]
assert calls["card_push"]["repo_id"] == "annotated/dataset"
assert "visualize_dataset?path=annotated/dataset" in calls["card_push"]["content"]
assert "source/dataset" not in calls["card_push"]["content"]
# A stale tag (e.g. from a previous annotation run) is deleted first so
# the new tag always points at the upload we just made.
assert calls["delete_tag"] == {
+34
View File
@@ -233,3 +233,37 @@ def test_metrics_tracker_reduce_across_ranks_invokes_reduce():
# accumulate against the cluster view rather than the stale per-rank sum.
meter = tracker.update_s
assert meter.sum / meter.count == pytest.approx(meter.avg)
def test_metrics_tracker_update_metrics_registers_and_averages():
tracker = MetricsTracker(batch_size=32, num_frames=1000, num_episodes=50, metrics={})
tracker.update_metrics({"latent_loss": 0.2, "action_loss": 0.4})
tracker.update_metrics({"latent_loss": 0.4, "action_loss": 0.6})
# New keys are auto-registered as mean-reduced meters and averaged over the window.
assert tracker.metrics["latent_loss"].reduction == "mean"
assert tracker.metrics["latent_loss"].avg == pytest.approx(0.3)
assert tracker.metrics["action_loss"].avg == pytest.approx(0.5)
assert tracker.to_dict()["latent_loss"] == pytest.approx(0.3)
def test_metrics_tracker_update_metrics_skips_non_numeric():
tracker = MetricsTracker(batch_size=32, num_frames=1000, num_episodes=50, metrics={})
tracker.update_metrics({"loss": 0.5, "head_mode": "sparse", "enabled": True})
# strings and bools ignored
assert "loss" in tracker.metrics
assert "head_mode" not in tracker.metrics
assert "enabled" not in tracker.metrics
def test_metrics_tracker_update_metrics_does_not_override_caller_meter():
# A policy that echoes "loss" in its output dict must not overwrite the caller-owned,
# already-aggregated loss meter.
metrics = {"loss": AverageMeter("loss", ":.3f", reduction="mean")}
tracker = MetricsTracker(batch_size=32, num_frames=1000, num_episodes=50, metrics=metrics)
tracker.loss = 1.0 # caller-set optimized loss
tracker.update_metrics({"loss": 99.0, "latent_loss": 0.2})
assert tracker.metrics["loss"].avg == pytest.approx(1.0) # snapshot ignored
assert tracker.metrics["latent_loss"].avg == pytest.approx(0.2)