robot-data-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """robot-data-diff: episode-aware diffs for LeRobot robot-training datasets.
2
+
3
+ Answers: "we retrained the policy and it got worse — what changed in the data?"
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m robot_data_diff`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
robot_data_diff/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """CLI entry point: robot-data-diff diff <A> <B> [--html OUT] [--checkpoint LBL]."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .differ import diff_datasets
9
+ from .loader import load_dataset
10
+ from .report import render_html, render_text
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="robot-data-diff",
16
+ description="Episode-aware diff for LeRobot robot-training datasets. "
17
+ "Answers: what changed in the data since the last good run?",
18
+ )
19
+ sub = p.add_subparsers(dest="cmd", required=True)
20
+ d = sub.add_parser("diff", help="diff two dataset revisions")
21
+ d.add_argument("dataset_a", help="local path or HF id (org/repo[@rev])")
22
+ d.add_argument("dataset_b", help="local path or HF id (org/repo[@rev])")
23
+ d.add_argument("--html", metavar="OUT",
24
+ help="also write an HTML report to OUT")
25
+ d.add_argument("--checkpoint", metavar="LABEL",
26
+ help="policy checkpoint name to link this diff to")
27
+ d.add_argument("--no-quality", action="store_true",
28
+ help="skip per-episode quality stats (metadata-only, faster)")
29
+ d.add_argument("--cache-dir", metavar="DIR", default=None,
30
+ help="where to cache Hugging Face downloads")
31
+ return p
32
+
33
+
34
+ def cmd_diff(args) -> int:
35
+ print("loading A ...", file=sys.stderr)
36
+ a = load_dataset(args.dataset_a, cache_dir=args.cache_dir,
37
+ with_quality=not args.no_quality)
38
+ print(f" {a.version}, {len(a.episodes)} episodes", file=sys.stderr)
39
+ print("loading B ...", file=sys.stderr)
40
+ b = load_dataset(args.dataset_b, cache_dir=args.cache_dir,
41
+ with_quality=not args.no_quality)
42
+ print(f" {b.version}, {len(b.episodes)} episodes", file=sys.stderr)
43
+
44
+ result = diff_datasets(a, b, checkpoint=args.checkpoint)
45
+ print(render_text(result))
46
+ if args.html:
47
+ out = Path(args.html)
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+ out.write_text(render_html(result))
50
+ print(f"\nHTML report written to {args.html}", file=sys.stderr)
51
+ return 0
52
+
53
+
54
+ def main(argv=None) -> int:
55
+ args = build_parser().parse_args(argv)
56
+ if args.cmd == "diff":
57
+ return cmd_diff(args)
58
+ return 1
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
@@ -0,0 +1,124 @@
1
+ """Episode-aware diff between two DatasetViews."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ from .loader import DatasetView
7
+ from .stats import fingerprint
8
+
9
+
10
+ @dataclass
11
+ class EpisodeDelta:
12
+ index: int
13
+ old_length: int
14
+ new_length: int
15
+ d_length: int
16
+ tasks_changed: bool
17
+ old_tasks: list = field(default_factory=list)
18
+ new_tasks: list = field(default_factory=list)
19
+ d_action_min: list | None = None # per-dim new - old
20
+ d_action_max: list | None = None
21
+ d_action_mean: list | None = None
22
+ d_idle_ratio: float | None = None
23
+ d_spike_count: int | None = None
24
+
25
+
26
+ @dataclass
27
+ class DiffResult:
28
+ a_name: str
29
+ b_name: str
30
+ a_version: str
31
+ b_version: str
32
+ a_fps: float | None
33
+ b_fps: float | None
34
+ a_episodes: int
35
+ b_episodes: int
36
+ added: list[int] = field(default_factory=list)
37
+ removed: list[int] = field(default_factory=list)
38
+ changed: list[EpisodeDelta] = field(default_factory=list)
39
+ unchanged: int = 0
40
+ task_counts: dict = field(default_factory=dict) # task -> (count_a, count_b)
41
+ features_added: list = field(default_factory=list)
42
+ features_removed: list = field(default_factory=list)
43
+ features_changed: list = field(default_factory=list)
44
+ mean_idle_a: float | None = None
45
+ mean_idle_b: float | None = None
46
+ spikes_a: int | None = None
47
+ spikes_b: int | None = None
48
+ checkpoint: str | None = None
49
+
50
+
51
+ def _sub(a, b):
52
+ if a is None or b is None or len(a) != len(b):
53
+ return None
54
+ return [round(float(x) - float(y), 6) for x, y in zip(b, a)]
55
+
56
+
57
+ def diff_datasets(a: DatasetView, b: DatasetView,
58
+ checkpoint: str | None = None) -> DiffResult:
59
+ res = DiffResult(
60
+ a_name=a.name, b_name=b.name,
61
+ a_version=a.version, b_version=b.version,
62
+ a_fps=a.fps, b_fps=b.fps,
63
+ a_episodes=len(a.episodes), b_episodes=len(b.episodes),
64
+ checkpoint=checkpoint,
65
+ )
66
+ ai, bi = set(a.episodes), set(b.episodes)
67
+ res.added = sorted(bi - ai)
68
+ res.removed = sorted(ai - bi)
69
+
70
+ for idx in sorted(ai & bi):
71
+ ea, eb = a.episodes[idx], b.episodes[idx]
72
+ if fingerprint(ea) == fingerprint(eb):
73
+ res.unchanged += 1
74
+ continue
75
+ res.changed.append(EpisodeDelta(
76
+ index=idx,
77
+ old_length=ea.length, new_length=eb.length,
78
+ d_length=eb.length - ea.length,
79
+ tasks_changed=sorted(ea.tasks) != sorted(eb.tasks),
80
+ old_tasks=ea.tasks, new_tasks=eb.tasks,
81
+ d_action_min=_sub(ea.action_min, eb.action_min),
82
+ d_action_max=_sub(ea.action_max, eb.action_max),
83
+ d_action_mean=_sub(ea.action_mean, eb.action_mean),
84
+ d_idle_ratio=(round(eb.idle_ratio - ea.idle_ratio, 4)
85
+ if ea.idle_ratio is not None and eb.idle_ratio is not None
86
+ else None),
87
+ d_spike_count=(eb.spike_count - ea.spike_count
88
+ if ea.spike_count is not None and eb.spike_count is not None
89
+ else None),
90
+ ))
91
+
92
+ # task inventory: task string -> (count in A, count in B)
93
+ def task_hist(ds: DatasetView) -> dict:
94
+ h: dict[str, int] = {}
95
+ for ep in ds.episodes.values():
96
+ for t in ep.tasks or ["<no task>"]:
97
+ h[t] = h.get(t, 0) + 1
98
+ return h
99
+
100
+ ha, hb = task_hist(a), task_hist(b)
101
+ for t in sorted(set(ha) | set(hb)):
102
+ res.task_counts[t] = (ha.get(t, 0), hb.get(t, 0))
103
+
104
+ # schema drift
105
+ fa, fb = set(a.features), set(b.features)
106
+ res.features_added = sorted(fb - fa)
107
+ res.features_removed = sorted(fa - fb)
108
+ for f in sorted(fa & fb):
109
+ if a.features[f] != b.features[f]:
110
+ res.features_changed.append(
111
+ f"{f}: {a.features[f]} -> {b.features[f]}")
112
+
113
+ # quality summary
114
+ def qmean(ds: DatasetView):
115
+ vals = [e.idle_ratio for e in ds.episodes.values()
116
+ if e.idle_ratio is not None]
117
+ sp = [e.spike_count for e in ds.episodes.values()
118
+ if e.spike_count is not None]
119
+ return (round(sum(vals) / len(vals), 4) if vals else None,
120
+ sum(sp) if sp else None)
121
+
122
+ res.mean_idle_a, res.spikes_a = qmean(a)
123
+ res.mean_idle_b, res.spikes_b = qmean(b)
124
+ return res
@@ -0,0 +1,258 @@
1
+ """Load LeRobot datasets (v2.1 and v3.0) from a local path or a Hugging Face repo id.
2
+
3
+ v3.0 layout (current):
4
+ meta/info.json codebase_version "v3.0", fps, features, totals
5
+ meta/episodes/chunk-*/file-*.parquet one row per episode; includes
6
+ episode_index, tasks, length, and per-episode stats/action/min|max|mean
7
+ meta/tasks.parquet task_index -> task string (in the index)
8
+ data/chunk-*/file-*.parquet frames: episode_index, frame_index,
9
+ timestamp, action, observation.state, ...
10
+
11
+ v2.1 layout (legacy, still common on the Hub):
12
+ meta/info.json codebase_version "v2.1", fps, features
13
+ meta/episodes.jsonl episode_index, tasks, length (one JSON/line)
14
+ meta/episodes_stats.jsonl per-episode min/max/mean/std per feature
15
+ meta/tasks.jsonl task_index -> task
16
+ data/chunk-*/episode_*.parquet one parquet per episode with the frames
17
+
18
+ Videos are never read — everything here is metadata + data parquet only.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import pyarrow.parquet as pq
29
+
30
+ from .stats import action_quality
31
+
32
+
33
+ @dataclass
34
+ class EpisodeInfo:
35
+ index: int
36
+ length: int
37
+ tasks: list = field(default_factory=list)
38
+ action_min: list | None = None
39
+ action_max: list | None = None
40
+ action_mean: list | None = None
41
+ state_min: list | None = None
42
+ state_max: list | None = None
43
+ idle_ratio: float | None = None
44
+ spike_count: int | None = None
45
+
46
+
47
+ @dataclass
48
+ class DatasetView:
49
+ name: str
50
+ version: str
51
+ fps: float | None
52
+ features: dict # feature name -> {"dtype": str, "shape": list}
53
+ episodes: dict # episode_index -> EpisodeInfo
54
+ root: Path
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # reference resolution: local path or Hugging Face id
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def _strip_no_proxy():
62
+ # The sandbox proxy env breaks httpx2's no_proxy parsing (IPv6 entries);
63
+ # the egress proxy handles everything, so drop no_proxy for HF calls.
64
+ for k in ("no_proxy", "NO_PROXY"):
65
+ os.environ.pop(k, None)
66
+
67
+
68
+ def resolve_ref(ref: str, cache_dir: str | Path | None = None) -> Path:
69
+ """Turn a dataset reference into a local directory Path.
70
+
71
+ Accepts a local path, or a Hugging Face dataset id like
72
+ "lerobot/pusht" or "lerobot/pusht@<revision>". HF downloads fetch
73
+ meta/** and data/** only — videos are skipped.
74
+ """
75
+ p = Path(ref)
76
+ if p.exists():
77
+ return p
78
+ _strip_no_proxy()
79
+ from huggingface_hub import snapshot_download
80
+
81
+ repo_id, _, revision = ref.removeprefix("hf://").partition("@")
82
+ if "/" not in repo_id:
83
+ raise ValueError(
84
+ f"'{ref}' is not a local path and not a Hugging Face id "
85
+ "(expected like 'lerobot/pusht' or 'lerobot/pusht@<rev>')."
86
+ )
87
+ local = snapshot_download(
88
+ repo_id,
89
+ repo_type="dataset",
90
+ revision=revision or None,
91
+ allow_patterns=["meta/**", "data/**"],
92
+ local_dir=cache_dir,
93
+ )
94
+ return Path(local)
95
+
96
+
97
+ def _as_list(v):
98
+ if v is None:
99
+ return None
100
+ return [float(x) for x in np.asarray(v).ravel()]
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # v3 loader
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _load_v3(root: Path, name: str) -> DatasetView:
108
+ info = json.loads((root / "meta" / "info.json").read_text())
109
+ features = {
110
+ k: {"dtype": str(v.get("dtype")), "shape": list(v.get("shape") or [])}
111
+ for k, v in (info.get("features") or {}).items()
112
+ }
113
+ fps = info.get("fps")
114
+
115
+ ep_files = sorted((root / "meta" / "episodes").rglob("*.parquet"))
116
+ if not ep_files:
117
+ raise ValueError(f"no episode metadata found under {root}/meta/episodes")
118
+ episodes: dict[int, EpisodeInfo] = {}
119
+ for f in ep_files:
120
+ table = pq.read_table(f).to_pandas()
121
+ for _, row in table.iterrows():
122
+ idx = int(row["episode_index"])
123
+ tasks = [str(t) for t in (row.get("tasks") or [])]
124
+ episodes[idx] = EpisodeInfo(
125
+ index=idx,
126
+ length=int(row["length"]),
127
+ tasks=tasks,
128
+ action_min=_as_list(row.get("stats/action/min")),
129
+ action_max=_as_list(row.get("stats/action/max")),
130
+ action_mean=_as_list(row.get("stats/action/mean")),
131
+ state_min=_as_list(row.get("stats/observation.state/min")),
132
+ state_max=_as_list(row.get("stats/observation.state/max")),
133
+ )
134
+ ds = DatasetView(name=name, version="v3.0", fps=fps, features=features,
135
+ episodes=episodes, root=root)
136
+ _add_quality_v3(ds)
137
+ return ds
138
+
139
+
140
+ def _add_quality_v3(ds: DatasetView):
141
+ """Compute idle-ratio / spike-count per episode from v3 data chunks."""
142
+ data_files = sorted((ds.root / "data").rglob("*.parquet"))
143
+ if not data_files:
144
+ return
145
+ for f in data_files:
146
+ try:
147
+ table = pq.read_table(f, columns=["episode_index", "action"])
148
+ except Exception:
149
+ continue # chunk without action column; skip
150
+ df = table.to_pandas()
151
+ for ep_idx, grp in df.groupby("episode_index"):
152
+ ep = ds.episodes.get(int(ep_idx))
153
+ if ep is None:
154
+ continue
155
+ acts = np.stack([np.asarray(a, dtype=np.float64).ravel()
156
+ for a in grp["action"].to_numpy()])
157
+ ep.idle_ratio, ep.spike_count = action_quality(acts)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # v2 loader (documented v2.1 layout)
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def _load_v2(root: Path, name: str) -> DatasetView:
165
+ info = json.loads((root / "meta" / "info.json").read_text())
166
+ features = {
167
+ k: {"dtype": str(v.get("dtype")), "shape": list(v.get("shape") or [])}
168
+ for k, v in (info.get("features") or {}).items()
169
+ }
170
+ fps = info.get("fps")
171
+
172
+ episodes: dict[int, EpisodeInfo] = {}
173
+ ep_path = root / "meta" / "episodes.jsonl"
174
+ for line in ep_path.read_text().splitlines():
175
+ line = line.strip()
176
+ if not line:
177
+ continue
178
+ rec = json.loads(line)
179
+ idx = int(rec["episode_index"])
180
+ episodes[idx] = EpisodeInfo(
181
+ index=idx,
182
+ length=int(rec.get("length", 0)),
183
+ tasks=[str(t) for t in (rec.get("tasks") or [])],
184
+ )
185
+
186
+ stats_path = root / "meta" / "episodes_stats.jsonl"
187
+ if stats_path.exists():
188
+ for line in stats_path.read_text().splitlines():
189
+ line = line.strip()
190
+ if not line:
191
+ continue
192
+ rec = json.loads(line)
193
+ ep = episodes.get(int(rec["episode_index"]))
194
+ if ep is None:
195
+ continue
196
+ feats = rec.get("stats") or rec.get("features") or rec
197
+ for feat_key, target, attr in (
198
+ ("action", "action", None),
199
+ ("observation.state", "state", None),
200
+ ("observation_state", "state", None),
201
+ ):
202
+ f = feats.get(feat_key)
203
+ if isinstance(f, dict):
204
+ if target == "action":
205
+ ep.action_min = _as_list(f.get("min"))
206
+ ep.action_max = _as_list(f.get("max"))
207
+ ep.action_mean = _as_list(f.get("mean"))
208
+ else:
209
+ ep.state_min = _as_list(f.get("min"))
210
+ ep.state_max = _as_list(f.get("max"))
211
+
212
+ ds = DatasetView(name=name, version="v2.1", fps=fps, features=features,
213
+ episodes=episodes, root=root)
214
+ _add_quality_v2(ds)
215
+ return ds
216
+
217
+
218
+ def _add_quality_v2(ds: DatasetView):
219
+ """Per-episode quality from v2 per-episode data files."""
220
+ for f in sorted((ds.root / "data").rglob("episode_*.parquet")):
221
+ try:
222
+ table = pq.read_table(f, columns=["action"])
223
+ except Exception:
224
+ continue
225
+ acts = np.stack([np.asarray(a, dtype=np.float64).ravel()
226
+ for a in table.column("action").to_pylist()])
227
+ # episode index from filename episode_000123.parquet
228
+ try:
229
+ ep_idx = int(f.stem.split("_")[-1])
230
+ except ValueError:
231
+ continue
232
+ ep = ds.episodes.get(ep_idx)
233
+ if ep is not None:
234
+ ep.idle_ratio, ep.spike_count = action_quality(acts)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def load_dataset(ref: str, cache_dir: str | Path | None = None,
240
+ with_quality: bool = True) -> DatasetView:
241
+ """Load a dataset reference into a DatasetView (v2.1 or v3.0)."""
242
+ root = resolve_ref(ref, cache_dir)
243
+ info_path = root / "meta" / "info.json"
244
+ if not info_path.exists():
245
+ raise ValueError(f"{root} does not look like a LeRobot dataset "
246
+ "(missing meta/info.json).")
247
+ version = str(json.loads(info_path.read_text()).get("codebase_version", ""))
248
+ name = ref if isinstance(ref, str) else str(ref)
249
+ if version.startswith("v3"):
250
+ ds = _load_v3(root, name)
251
+ elif version.startswith("v2"):
252
+ ds = _load_v2(root, name)
253
+ else:
254
+ raise ValueError(f"unsupported LeRobot codebase_version: {version!r}")
255
+ if not with_quality:
256
+ for ep in ds.episodes.values():
257
+ ep.idle_ratio, ep.spike_count = None, None
258
+ return ds
@@ -0,0 +1,154 @@
1
+ """Human-readable text and HTML reports for a DiffResult."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+
6
+ from .differ import DiffResult, EpisodeDelta
7
+
8
+
9
+ def _fmt_vec(v, nd=4):
10
+ if v is None:
11
+ return "n/a"
12
+ return "[" + ", ".join(f"{x:+.{nd}f}" for x in v) + "]"
13
+
14
+
15
+ def render_text(r: DiffResult) -> str:
16
+ L: list[str] = []
17
+ L.append("=" * 72)
18
+ L.append("robot-data-diff — episode-aware dataset diff")
19
+ L.append("=" * 72)
20
+ L.append(f"A: {r.a_name} ({r.a_version}, {r.a_episodes} episodes"
21
+ + (f", {r.a_fps} fps" if r.a_fps else "") + ")")
22
+ L.append(f"B: {r.b_name} ({r.b_version}, {r.b_episodes} episodes"
23
+ + (f", {r.b_fps} fps" if r.b_fps else "") + ")")
24
+ if r.checkpoint:
25
+ L.append(f"policy checkpoint: {r.checkpoint}")
26
+ L.append("")
27
+
28
+ L.append(f"Episodes: {r.a_episodes} -> {r.b_episodes} "
29
+ f"(+{len(r.added)} added, -{len(r.removed)} removed, "
30
+ f"~{len(r.changed)} changed, ={r.unchanged} unchanged)")
31
+ if r.added:
32
+ L.append(f" added: {r.added}")
33
+ if r.removed:
34
+ L.append(f" removed: {r.removed}")
35
+ L.append("")
36
+
37
+ if r.changed:
38
+ L.append(f"CHANGED EPISODES ({len(r.changed)}):")
39
+ for d in r.changed:
40
+ L.append(f" [ep {d.index}] length {d.old_length} -> {d.new_length} "
41
+ f"({d.d_length:+d} frames)")
42
+ if d.tasks_changed:
43
+ L.append(f" tasks: {d.old_tasks} -> {d.new_tasks}")
44
+ if d.d_action_min is not None:
45
+ L.append(f" d(action_min) {_fmt_vec(d.d_action_min)}")
46
+ if d.d_action_max is not None:
47
+ L.append(f" d(action_max) {_fmt_vec(d.d_action_max)}")
48
+ if d.d_action_mean is not None:
49
+ L.append(f" d(action_mean) {_fmt_vec(d.d_action_mean)}")
50
+ if d.d_idle_ratio is not None:
51
+ L.append(f" d(idle_ratio) {d.d_idle_ratio:+.4f}")
52
+ if d.d_spike_count is not None:
53
+ L.append(f" d(spikes) {d.d_spike_count:+d}")
54
+ L.append("")
55
+ else:
56
+ L.append("No changed episodes (same index + identical stats).")
57
+ L.append("")
58
+
59
+ if r.task_counts:
60
+ L.append("TASK INVENTORY (episodes per task: A -> B):")
61
+ for t, (ca, cb) in r.task_counts.items():
62
+ mark = " *" if ca != cb else " "
63
+ L.append(f"{mark} {t[:60]:62s} {ca:5d} -> {cb:5d}")
64
+ L.append("")
65
+
66
+ if r.features_added or r.features_removed or r.features_changed:
67
+ L.append("SCHEMA DRIFT:")
68
+ for f in r.features_added:
69
+ L.append(f" + {f}")
70
+ for f in r.features_removed:
71
+ L.append(f" - {f}")
72
+ for f in r.features_changed:
73
+ L.append(f" ~ {f}")
74
+ L.append("")
75
+ else:
76
+ L.append("Schema: identical feature sets.")
77
+ L.append("")
78
+
79
+ if r.mean_idle_a is not None:
80
+ L.append("QUALITY SUMMARY (heuristic, CPU-only):")
81
+ L.append(f" mean idle-frame ratio: {r.mean_idle_a:.4f} -> {r.mean_idle_b:.4f} "
82
+ f"({r.mean_idle_b - r.mean_idle_a:+.4f})")
83
+ L.append(f" total action spikes: {r.spikes_a} -> {r.spikes_b} "
84
+ f"({r.spikes_b - r.spikes_a:+d})")
85
+ L.append("")
86
+
87
+ L.append("=" * 72)
88
+ return "\n".join(L)
89
+
90
+
91
+ def render_html(r: DiffResult) -> str:
92
+ e = html.escape
93
+
94
+ def changed_rows():
95
+ rows = []
96
+ for d in r.changed:
97
+ rows.append(
98
+ f"<tr><td>{d.index}</td><td>{d.old_length} &rarr; {d.new_length} "
99
+ f"({d.d_length:+d})</td>"
100
+ f"<td>{_fmt_vec(d.d_action_min)}</td>"
101
+ f"<td>{_fmt_vec(d.d_action_max)}</td>"
102
+ f"<td>{d.d_idle_ratio if d.d_idle_ratio is not None else 'n/a'}</td>"
103
+ f"<td>{d.d_spike_count if d.d_spike_count is not None else 'n/a'}</td></tr>"
104
+ )
105
+ return "\n".join(rows) or '<tr><td colspan="6">none</td></tr>'
106
+
107
+ def task_rows():
108
+ rows = []
109
+ for t, (ca, cb) in r.task_counts.items():
110
+ cls = ' class="hl"' if ca != cb else ""
111
+ rows.append(f'<tr{cls}><td>{e(t[:80])}</td><td>{ca}</td><td>{cb}</td></tr>')
112
+ return "\n".join(rows)
113
+
114
+ ckpt = f"<p><b>Policy checkpoint:</b> {e(r.checkpoint)}</p>" if r.checkpoint else ""
115
+ qual = ""
116
+ if r.mean_idle_a is not None:
117
+ qual = (f"<p>Mean idle-frame ratio: {r.mean_idle_a:.4f} &rarr; "
118
+ f"{r.mean_idle_b:.4f} ({r.mean_idle_b - r.mean_idle_a:+.4f})<br>"
119
+ f"Total action spikes: {r.spikes_a} &rarr; {r.spikes_b} "
120
+ f"({r.spikes_b - r.spikes_a:+d})</p>")
121
+
122
+ return f"""<!DOCTYPE html>
123
+ <html><head><meta charset="utf-8"><title>robot-data-diff report</title>
124
+ <style>
125
+ body {{ font-family: system-ui, sans-serif; max-width: 1000px; margin: 2em auto; padding: 0 1em; }}
126
+ table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
127
+ th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; font-size: 14px; }}
128
+ th {{ background: #f4f4f4; }} .hl {{ background: #fff8e1; }}
129
+ code {{ background: #f4f4f4; padding: 1px 5px; border-radius: 3px; }}
130
+ h1 {{ font-size: 1.4em; }} h2 {{ font-size: 1.1em; margin-top: 1.6em; }}
131
+ </style></head><body>
132
+ <h1>robot-data-diff report</h1>
133
+ <p><b>A:</b> {e(r.a_name)} <code>{e(r.a_version)}</code> ({r.a_episodes} episodes)<br>
134
+ <b>B:</b> {e(r.b_name)} <code>{e(r.b_version)}</code> ({r.b_episodes} episodes)</p>
135
+ {ckpt}
136
+ <h2>Episode inventory</h2>
137
+ <p>{r.a_episodes} &rarr; {r.b_episodes} episodes &mdash;
138
+ +{len(r.added)} added, &minus;{len(r.removed)} removed,
139
+ ~{len(r.changed)} changed, ={r.unchanged} unchanged</p>
140
+ <p>Added: <code>{e(str(r.added))}</code><br>Removed: <code>{e(str(r.removed))}</code></p>
141
+ <h2>Changed episodes</h2>
142
+ <table><tr><th>ep</th><th>length</th><th>&Delta;action_min</th>
143
+ <th>&Delta;action_max</th><th>&Delta;idle</th><th>&Delta;spikes</th></tr>
144
+ {changed_rows()}</table>
145
+ <h2>Task inventory</h2>
146
+ <table><tr><th>task</th><th>A</th><th>B</th></tr>{task_rows()}</table>
147
+ <h2>Schema drift</h2>
148
+ <p>Added: <code>{e(str(r.features_added))}</code><br>
149
+ Removed: <code>{e(str(r.features_removed))}</code><br>
150
+ Changed: <code>{e(str(r.features_changed))}</code></p>
151
+ <h2>Quality summary</h2>
152
+ {qual}
153
+ </body></html>
154
+ """
@@ -0,0 +1,53 @@
1
+ """Per-episode statistics and lightweight quality heuristics (CPU-only, numpy)."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def action_quality(action: np.ndarray) -> tuple[float, int]:
8
+ """Idle-frame ratio and action-spike count for one episode's actions.
9
+
10
+ action: (T, D) float array of the episode's action vectors.
11
+ Returns (idle_ratio, spike_count).
12
+ - idle frame: max |delta| across dims below eps (eps scales with the
13
+ episode's own action range, so it works for any robot/action units).
14
+ - spike: a frame-to-frame jump far above the episode's typical motion.
15
+ """
16
+ action = np.asarray(action, dtype=np.float64)
17
+ if action.ndim == 1:
18
+ action = action[:, None]
19
+ if len(action) < 2:
20
+ return 0.0, 0
21
+ deltas = np.abs(np.diff(action, axis=0))
22
+ maxd = deltas.max(axis=1)
23
+ overall_range = float(np.ptp(action))
24
+ eps = max(1e-3 * overall_range, 1e-9)
25
+ idle_ratio = float((maxd < eps).mean())
26
+ med = float(np.median(maxd))
27
+ spike_thresh = max(10.0 * med, 5.0 * eps)
28
+ spike_count = int((maxd > spike_thresh).sum())
29
+ return idle_ratio, spike_count
30
+
31
+
32
+ def _r6(x) -> float:
33
+ return round(float(x), 6)
34
+
35
+
36
+ def fingerprint(ep) -> tuple:
37
+ """Hashable summary of an episode used to detect 'changed' episodes.
38
+
39
+ Two episodes with the same index but different fingerprints changed
40
+ between dataset revisions.
41
+ """
42
+ def rl(vals):
43
+ return tuple(_r6(v) for v in vals) if vals is not None else None
44
+
45
+ return (
46
+ ep.length,
47
+ tuple(sorted(ep.tasks)),
48
+ rl(ep.action_min),
49
+ rl(ep.action_max),
50
+ rl(ep.action_mean),
51
+ round(ep.idle_ratio, 4) if ep.idle_ratio is not None else None,
52
+ ep.spike_count,
53
+ )
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: robot-data-diff
3
+ Version: 0.1.0
4
+ Summary: Episode-aware diff for LeRobot robot-training datasets: what changed in the data since the last good run?
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pyarrow>=12
9
+ Requires-Dist: pandas>=2
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: huggingface_hub>=0.20
12
+
13
+ # robot-data-diff
14
+
15
+ **Episode-aware diff for LeRobot robot-training datasets.**
16
+
17
+ You retrained the policy and it got worse. Was it the code — or the data?
18
+ `robot-data-diff` answers *"what changed in the data since the last good run?"*
19
+ by diffing two revisions of a dataset, episode by episode.
20
+
21
+ Free, CPU-only, pure Python. No GPU, no uploads, no accounts needed.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ python -m venv .venv && source .venv/bin/activate
27
+ pip install -e .
28
+ ```
29
+
30
+ Dependencies (all free): `pyarrow`, `pandas`, `numpy`, `huggingface_hub`.
31
+
32
+ > Note: this sandbox's egress proxy breaks `httpx`'s `no_proxy` parsing
33
+ > (IPv6 entries). The tool strips `no_proxy`/`NO_PROXY` for its own
34
+ > Hugging Face calls; your shell is untouched.
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # two local dataset directories
40
+ robot-data-diff diff ./my_dataset_v1 ./my_dataset_v2
41
+
42
+ # Hugging Face ids (videos are never downloaded — meta + data parquet only)
43
+ robot-data-diff diff lerobot/pusht ./my_local_copy
44
+
45
+ # same repo, two revisions
46
+ robot-data-diff diff lerobot/pusht@main lerobot/pusht@<commit-sha>
47
+
48
+ # link the diff to a policy checkpoint, and write an HTML report
49
+ robot-data-diff diff v1 v2 --checkpoint ckpt-42 --html report.html
50
+
51
+ # metadata-only mode (skips per-episode quality stats; fastest)
52
+ robot-data-diff diff v1 v2 --no-quality
53
+ ```
54
+
55
+ Each side can be a local path or a Hugging Face dataset id
56
+ (`org/repo`, optionally `org/repo@revision` or `hf://org/repo@revision`).
57
+
58
+ ## What the report shows
59
+
60
+ - **Episode inventory:** total counts, episodes added / removed / changed
61
+ (same index, different data fingerprint)
62
+ - **Per-episode deltas** for changed episodes: frame count, action
63
+ min/max/mean per dimension, idle-frame-ratio delta, action-spike delta
64
+ - **Task inventory:** episodes per task, old → new
65
+ - **Schema drift:** features added / removed / changed
66
+ - **Quality summary:** mean idle-frame ratio and total action spikes,
67
+ old → new (lightweight CPU heuristics, not a full validator)
68
+
69
+ ## Supported formats
70
+
71
+ - **LeRobot v3.0** — verified against the real `lerobot/pusht` dataset
72
+ (parquet episode metadata + data chunks + `meta/info.json`).
73
+ - **LeRobot v2.1** — implemented against the documented v2.1 layout
74
+ (`meta/episodes.jsonl`, `meta/episodes_stats.jsonl`, per-episode data
75
+ files). Tested with a synthetic v2 fixture (`tests/make_v2_fixture.py`);
76
+ no public v2 dataset was reachable at build time, so treat v2 support
77
+ as beta and report issues.
78
+
79
+ ## Example
80
+
81
+ `demo/diff-demo-output.txt` is the real output of diffing `lerobot/pusht`
82
+ (206 episodes) against a modified copy: episodes 200–205 removed, episodes
83
+ 0–4 had their actions scaled ×1.5, episode 206 added. The tool reports
84
+ exactly that:
85
+
86
+ ```
87
+ Episodes: 206 -> 201 (+1 added, -6 removed, ~5 changed, =195 unchanged)
88
+ added: [206]
89
+ removed: [200, 201, 202, 203, 204, 205]
90
+
91
+ CHANGED EPISODES (5):
92
+ [ep 0] length 161 -> 161 (+0 frames)
93
+ d(action_min) [+46.5000, +35.5000]
94
+ d(action_max) [+187.5000, +224.5000]
95
+ ...
96
+ ```
97
+
98
+ See `demo/diff-demo-report.html` for the HTML version.
99
+
100
+ ## Reproduce the demo
101
+
102
+ ```bash
103
+ source .venv/bin/activate
104
+ python tests/make_modified_fixture.py # builds testdata/pusht_v2
105
+ robot-data-diff diff testdata/pusht testdata/pusht_v2 \
106
+ --checkpoint ckpt-42 --html demo/diff-demo-report.html
107
+ ```
108
+
109
+ ## Honest limits
110
+
111
+ - This is a **diff**, not a validator: it tells you *what changed* between
112
+ revisions, not whether your data is good. (The validator lane already has
113
+ 6+ tools — that's why this exists.)
114
+ - "Changed" is detected via a stats fingerprint (length, action
115
+ min/max/mean, idle ratio, spike count). Bit-identical data with reordered
116
+ episodes shows as added+removed, not moved — episode identity is the index.
117
+ - Remote datasets download `meta/**` + `data/**`; very large datasets will
118
+ take a while on first run (cached afterwards via `--cache-dir`).
119
+ - RLDS / Zarr / HDF5 / rosbag support is out of scope for v0.1 (LeRobot only).
120
+
121
+ ## Related work
122
+
123
+ - [openbot-data](https://github.com/openbotai/openbot-data) (MIT) — a broader robot-data toolkit whose `openbot-data diff` compares two *snapshot files* (`snapshot` first, then `diff`, with `--fail-on breaking` for CI-style breaking-change detection). robot-data-diff is narrower on purpose: it diffs two dataset revisions *directly* — local dirs or Hugging Face ids, no snapshot step — episode by episode, with per-episode stat deltas.
124
+
125
+ ## License
126
+
127
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ robot_data_diff/__init__.py,sha256=D9wTN28AMpb1GsfUVy7QPskqiP9S5ow7yuvqNn0sWrk,187
2
+ robot_data_diff/__main__.py,sha256=4NuD8jaGpiNEtSQ5PKg0cintKz77WEjHx_J4cvvmv18,120
3
+ robot_data_diff/cli.py,sha256=4q1s0XdyaUZAZ4IzIC7Jl3dzfXouqB1koZUMw0NAyE4,2363
4
+ robot_data_diff/differ.py,sha256=0hU8zSDuacvani_K7pFWQPSjo0PmCZZOkv9QREq1-Vo,4313
5
+ robot_data_diff/loader.py,sha256=RcfiJ3LMhtFvXwzBA4J19H84_FI77bXURNKaILpZhlo,9668
6
+ robot_data_diff/report.py,sha256=RFd1aTzwdVd1g10ylm90yedRS9URWvGczkK4_V0T4kU,6316
7
+ robot_data_diff/stats.py,sha256=byxPBgDq-YRtlB_-TwrKcz8I1OscaSThRuT4jZ-fxwY,1743
8
+ robot_data_diff-0.1.0.dist-info/METADATA,sha256=qJzfjeI6DHf1mkANhDtR972CgoOgg4pYJCHZR-s3Big,4819
9
+ robot_data_diff-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ robot_data_diff-0.1.0.dist-info/entry_points.txt,sha256=AnW6F9uHXXhDURk14LfFfWecF1lllcBvKYCUwJzf7WE,61
11
+ robot_data_diff-0.1.0.dist-info/top_level.txt,sha256=tRkGpxlssgvBHZaPpuupX1eAQY5z1vIV8i3YDz0rO3I,16
12
+ robot_data_diff-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ robot-data-diff = robot_data_diff.cli:main
@@ -0,0 +1 @@
1
+ robot_data_diff