robot-data-diff 0.1.0__tar.gz
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.
- robot_data_diff-0.1.0/PKG-INFO +127 -0
- robot_data_diff-0.1.0/README.md +115 -0
- robot_data_diff-0.1.0/pyproject.toml +24 -0
- robot_data_diff-0.1.0/robot_data_diff/__init__.py +6 -0
- robot_data_diff-0.1.0/robot_data_diff/__main__.py +5 -0
- robot_data_diff-0.1.0/robot_data_diff/cli.py +62 -0
- robot_data_diff-0.1.0/robot_data_diff/differ.py +124 -0
- robot_data_diff-0.1.0/robot_data_diff/loader.py +258 -0
- robot_data_diff-0.1.0/robot_data_diff/report.py +154 -0
- robot_data_diff-0.1.0/robot_data_diff/stats.py +53 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/PKG-INFO +127 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/SOURCES.txt +15 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/dependency_links.txt +1 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/entry_points.txt +2 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/requires.txt +4 -0
- robot_data_diff-0.1.0/robot_data_diff.egg-info/top_level.txt +1 -0
- robot_data_diff-0.1.0/setup.cfg +4 -0
|
@@ -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,115 @@
|
|
|
1
|
+
# robot-data-diff
|
|
2
|
+
|
|
3
|
+
**Episode-aware diff for LeRobot robot-training datasets.**
|
|
4
|
+
|
|
5
|
+
You retrained the policy and it got worse. Was it the code — or the data?
|
|
6
|
+
`robot-data-diff` answers *"what changed in the data since the last good run?"*
|
|
7
|
+
by diffing two revisions of a dataset, episode by episode.
|
|
8
|
+
|
|
9
|
+
Free, CPU-only, pure Python. No GPU, no uploads, no accounts needed.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m venv .venv && source .venv/bin/activate
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Dependencies (all free): `pyarrow`, `pandas`, `numpy`, `huggingface_hub`.
|
|
19
|
+
|
|
20
|
+
> Note: this sandbox's egress proxy breaks `httpx`'s `no_proxy` parsing
|
|
21
|
+
> (IPv6 entries). The tool strips `no_proxy`/`NO_PROXY` for its own
|
|
22
|
+
> Hugging Face calls; your shell is untouched.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# two local dataset directories
|
|
28
|
+
robot-data-diff diff ./my_dataset_v1 ./my_dataset_v2
|
|
29
|
+
|
|
30
|
+
# Hugging Face ids (videos are never downloaded — meta + data parquet only)
|
|
31
|
+
robot-data-diff diff lerobot/pusht ./my_local_copy
|
|
32
|
+
|
|
33
|
+
# same repo, two revisions
|
|
34
|
+
robot-data-diff diff lerobot/pusht@main lerobot/pusht@<commit-sha>
|
|
35
|
+
|
|
36
|
+
# link the diff to a policy checkpoint, and write an HTML report
|
|
37
|
+
robot-data-diff diff v1 v2 --checkpoint ckpt-42 --html report.html
|
|
38
|
+
|
|
39
|
+
# metadata-only mode (skips per-episode quality stats; fastest)
|
|
40
|
+
robot-data-diff diff v1 v2 --no-quality
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Each side can be a local path or a Hugging Face dataset id
|
|
44
|
+
(`org/repo`, optionally `org/repo@revision` or `hf://org/repo@revision`).
|
|
45
|
+
|
|
46
|
+
## What the report shows
|
|
47
|
+
|
|
48
|
+
- **Episode inventory:** total counts, episodes added / removed / changed
|
|
49
|
+
(same index, different data fingerprint)
|
|
50
|
+
- **Per-episode deltas** for changed episodes: frame count, action
|
|
51
|
+
min/max/mean per dimension, idle-frame-ratio delta, action-spike delta
|
|
52
|
+
- **Task inventory:** episodes per task, old → new
|
|
53
|
+
- **Schema drift:** features added / removed / changed
|
|
54
|
+
- **Quality summary:** mean idle-frame ratio and total action spikes,
|
|
55
|
+
old → new (lightweight CPU heuristics, not a full validator)
|
|
56
|
+
|
|
57
|
+
## Supported formats
|
|
58
|
+
|
|
59
|
+
- **LeRobot v3.0** — verified against the real `lerobot/pusht` dataset
|
|
60
|
+
(parquet episode metadata + data chunks + `meta/info.json`).
|
|
61
|
+
- **LeRobot v2.1** — implemented against the documented v2.1 layout
|
|
62
|
+
(`meta/episodes.jsonl`, `meta/episodes_stats.jsonl`, per-episode data
|
|
63
|
+
files). Tested with a synthetic v2 fixture (`tests/make_v2_fixture.py`);
|
|
64
|
+
no public v2 dataset was reachable at build time, so treat v2 support
|
|
65
|
+
as beta and report issues.
|
|
66
|
+
|
|
67
|
+
## Example
|
|
68
|
+
|
|
69
|
+
`demo/diff-demo-output.txt` is the real output of diffing `lerobot/pusht`
|
|
70
|
+
(206 episodes) against a modified copy: episodes 200–205 removed, episodes
|
|
71
|
+
0–4 had their actions scaled ×1.5, episode 206 added. The tool reports
|
|
72
|
+
exactly that:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Episodes: 206 -> 201 (+1 added, -6 removed, ~5 changed, =195 unchanged)
|
|
76
|
+
added: [206]
|
|
77
|
+
removed: [200, 201, 202, 203, 204, 205]
|
|
78
|
+
|
|
79
|
+
CHANGED EPISODES (5):
|
|
80
|
+
[ep 0] length 161 -> 161 (+0 frames)
|
|
81
|
+
d(action_min) [+46.5000, +35.5000]
|
|
82
|
+
d(action_max) [+187.5000, +224.5000]
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
See `demo/diff-demo-report.html` for the HTML version.
|
|
87
|
+
|
|
88
|
+
## Reproduce the demo
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
source .venv/bin/activate
|
|
92
|
+
python tests/make_modified_fixture.py # builds testdata/pusht_v2
|
|
93
|
+
robot-data-diff diff testdata/pusht testdata/pusht_v2 \
|
|
94
|
+
--checkpoint ckpt-42 --html demo/diff-demo-report.html
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Honest limits
|
|
98
|
+
|
|
99
|
+
- This is a **diff**, not a validator: it tells you *what changed* between
|
|
100
|
+
revisions, not whether your data is good. (The validator lane already has
|
|
101
|
+
6+ tools — that's why this exists.)
|
|
102
|
+
- "Changed" is detected via a stats fingerprint (length, action
|
|
103
|
+
min/max/mean, idle ratio, spike count). Bit-identical data with reordered
|
|
104
|
+
episodes shows as added+removed, not moved — episode identity is the index.
|
|
105
|
+
- Remote datasets download `meta/**` + `data/**`; very large datasets will
|
|
106
|
+
take a while on first run (cached afterwards via `--cache-dir`).
|
|
107
|
+
- RLDS / Zarr / HDF5 / rosbag support is out of scope for v0.1 (LeRobot only).
|
|
108
|
+
|
|
109
|
+
## Related work
|
|
110
|
+
|
|
111
|
+
- [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.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
Apache-2.0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "robot-data-diff"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Episode-aware diff for LeRobot robot-training datasets: what changed in the data since the last good run?"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pyarrow>=12",
|
|
14
|
+
"pandas>=2",
|
|
15
|
+
"numpy>=1.24",
|
|
16
|
+
"huggingface_hub>=0.20",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
robot-data-diff = "robot_data_diff.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["."]
|
|
24
|
+
include = ["robot_data_diff*"]
|
|
@@ -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} → {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} → "
|
|
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} → {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} → {r.b_episodes} episodes —
|
|
138
|
+
+{len(r.added)} added, −{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>Δaction_min</th>
|
|
143
|
+
<th>Δaction_max</th><th>Δidle</th><th>Δ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,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
robot_data_diff/__init__.py
|
|
4
|
+
robot_data_diff/__main__.py
|
|
5
|
+
robot_data_diff/cli.py
|
|
6
|
+
robot_data_diff/differ.py
|
|
7
|
+
robot_data_diff/loader.py
|
|
8
|
+
robot_data_diff/report.py
|
|
9
|
+
robot_data_diff/stats.py
|
|
10
|
+
robot_data_diff.egg-info/PKG-INFO
|
|
11
|
+
robot_data_diff.egg-info/SOURCES.txt
|
|
12
|
+
robot_data_diff.egg-info/dependency_links.txt
|
|
13
|
+
robot_data_diff.egg-info/entry_points.txt
|
|
14
|
+
robot_data_diff.egg-info/requires.txt
|
|
15
|
+
robot_data_diff.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
robot_data_diff
|