robostats 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.
- robostats/__init__.py +115 -0
- robostats/adapters/__init__.py +13 -0
- robostats/adapters/robodojo.py +109 -0
- robostats/adapters/robotwin.py +78 -0
- robostats/compare.py +2167 -0
- robostats/errors.py +96 -0
- robostats/intervals.py +238 -0
- robostats/io.py +1018 -0
- robostats/overlap.py +273 -0
- robostats/presets.py +399 -0
- robostats/py.typed +0 -0
- robostats/recording.py +299 -0
- robostats/records.py +1032 -0
- robostats/report.py +729 -0
- robostats-0.1.0.dist-info/METADATA +361 -0
- robostats-0.1.0.dist-info/RECORD +18 -0
- robostats-0.1.0.dist-info/WHEEL +4 -0
- robostats-0.1.0.dist-info/licenses/LICENSE +201 -0
robostats/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Statistics and uncertainty quantification for robot policy evaluation.
|
|
2
|
+
|
|
3
|
+
robostats consumes episode records, one row per rollout, and emits statistics:
|
|
4
|
+
confidence intervals, paired comparisons, and formatted reports. It computes and
|
|
5
|
+
reports; it never judges whether a number is trustworthy.
|
|
6
|
+
|
|
7
|
+
The path through the package
|
|
8
|
+
----------------------------
|
|
9
|
+
Load records with :func:`load_jsonl` or :func:`load_csv`, naming every column
|
|
10
|
+
explicitly and passing the protocol the run was collected under. Join two runs
|
|
11
|
+
on ``scenario_id`` with :func:`pair`. Compare them with :func:`compare`, which
|
|
12
|
+
returns the estimate, its interval and the p-value together. Render the result
|
|
13
|
+
with :func:`report`.
|
|
14
|
+
|
|
15
|
+
A caller who already holds records in memory, from a DataFrame or anywhere else,
|
|
16
|
+
builds a :class:`RecordSet` directly and skips the loaders.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from robostats import adapters
|
|
20
|
+
from robostats.compare import (
|
|
21
|
+
AUTO_MIN_SHARED,
|
|
22
|
+
CombinedResult,
|
|
23
|
+
ComparisonResult,
|
|
24
|
+
McNemarResult,
|
|
25
|
+
SensitivityResult,
|
|
26
|
+
UnpairedResult,
|
|
27
|
+
combined_difference,
|
|
28
|
+
compare,
|
|
29
|
+
compare_combined,
|
|
30
|
+
compare_unpaired,
|
|
31
|
+
mcnemar,
|
|
32
|
+
paired_difference,
|
|
33
|
+
select_mode,
|
|
34
|
+
unpaired_difference,
|
|
35
|
+
)
|
|
36
|
+
from robostats.errors import (
|
|
37
|
+
PresetMismatchError,
|
|
38
|
+
PresetNotFoundError,
|
|
39
|
+
RobostatsError,
|
|
40
|
+
ScenarioSpecMismatchError,
|
|
41
|
+
)
|
|
42
|
+
from robostats.intervals import (
|
|
43
|
+
ConfidenceInterval,
|
|
44
|
+
agresti_coull,
|
|
45
|
+
clopper_pearson,
|
|
46
|
+
wilson,
|
|
47
|
+
)
|
|
48
|
+
from robostats.io import load_csv, load_jsonl, load_manifest
|
|
49
|
+
from robostats.overlap import OverlapResult, overlap, subset_counts
|
|
50
|
+
from robostats.presets import Preset, describe_preset, preset_names, register_preset
|
|
51
|
+
from robostats.recording import EpisodeRecorder
|
|
52
|
+
from robostats.records import (
|
|
53
|
+
SCHEMA_VERSION,
|
|
54
|
+
Alignment,
|
|
55
|
+
EpisodeRecord,
|
|
56
|
+
LoadProvenance,
|
|
57
|
+
PairedResult,
|
|
58
|
+
Protocol,
|
|
59
|
+
RecordSet,
|
|
60
|
+
align,
|
|
61
|
+
pair,
|
|
62
|
+
)
|
|
63
|
+
from robostats.report import report
|
|
64
|
+
|
|
65
|
+
#: Package version. A test asserts this equals the version in pyproject.toml,
|
|
66
|
+
#: because the two drift otherwise.
|
|
67
|
+
__version__ = "0.1.0"
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AUTO_MIN_SHARED",
|
|
71
|
+
"SCHEMA_VERSION",
|
|
72
|
+
"Alignment",
|
|
73
|
+
"CombinedResult",
|
|
74
|
+
"ComparisonResult",
|
|
75
|
+
"ConfidenceInterval",
|
|
76
|
+
"EpisodeRecord",
|
|
77
|
+
"EpisodeRecorder",
|
|
78
|
+
"LoadProvenance",
|
|
79
|
+
"McNemarResult",
|
|
80
|
+
"OverlapResult",
|
|
81
|
+
"PairedResult",
|
|
82
|
+
"Preset",
|
|
83
|
+
"PresetMismatchError",
|
|
84
|
+
"PresetNotFoundError",
|
|
85
|
+
"Protocol",
|
|
86
|
+
"RecordSet",
|
|
87
|
+
"RobostatsError",
|
|
88
|
+
"ScenarioSpecMismatchError",
|
|
89
|
+
"SensitivityResult",
|
|
90
|
+
"UnpairedResult",
|
|
91
|
+
"__version__",
|
|
92
|
+
"adapters",
|
|
93
|
+
"agresti_coull",
|
|
94
|
+
"align",
|
|
95
|
+
"clopper_pearson",
|
|
96
|
+
"combined_difference",
|
|
97
|
+
"compare",
|
|
98
|
+
"compare_combined",
|
|
99
|
+
"compare_unpaired",
|
|
100
|
+
"describe_preset",
|
|
101
|
+
"load_csv",
|
|
102
|
+
"load_jsonl",
|
|
103
|
+
"load_manifest",
|
|
104
|
+
"mcnemar",
|
|
105
|
+
"overlap",
|
|
106
|
+
"pair",
|
|
107
|
+
"paired_difference",
|
|
108
|
+
"preset_names",
|
|
109
|
+
"register_preset",
|
|
110
|
+
"report",
|
|
111
|
+
"select_mode",
|
|
112
|
+
"subset_counts",
|
|
113
|
+
"unpaired_difference",
|
|
114
|
+
"wilson",
|
|
115
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Readers for evaluation harnesses that already persist per-episode outcomes.
|
|
2
|
+
|
|
3
|
+
Nothing here imports a harness, not even for a type hint. An adapter parses a
|
|
4
|
+
file the harness wrote; it does not talk to the harness, load a policy, or step
|
|
5
|
+
an environment. A harness that discards per-episode outcomes before writing
|
|
6
|
+
cannot have an adapter at all, because there is nothing left in the file to read.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = ["robodojo", "robotwin"]
|
|
12
|
+
|
|
13
|
+
from robostats.adapters import robodojo, robotwin
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Read a RoboDojo evaluation manifest.
|
|
2
|
+
|
|
3
|
+
RoboDojo writes one JSON document holding both levels: run settings once at the
|
|
4
|
+
top, and per-episode outcomes in a ``details`` map keyed by index. Those keys are
|
|
5
|
+
positional, so they are ignored; ``layout_id`` is the scenario identity.
|
|
6
|
+
|
|
7
|
+
This module imports nothing from RoboDojo. It parses a file that RoboDojo already
|
|
8
|
+
wrote, using the published mapping in :data:`robostats.presets.ROBODOJO`.
|
|
9
|
+
|
|
10
|
+
The episodes the run left out
|
|
11
|
+
-----------------------------
|
|
12
|
+
The manifest records ``completed_layout_ids``, ``abandoned_layout_ids``,
|
|
13
|
+
``unstable_nums`` and ``restart_count``. A success rate computed from ``details``
|
|
14
|
+
has a denominator that excludes the abandoned episodes, which is defensible but
|
|
15
|
+
not what a reader assumes, and is a bias if abandonment correlates with
|
|
16
|
+
difficulty. The counts are carried onto the returned set and stated by
|
|
17
|
+
:func:`~robostats.report.report`. Nothing here adjusts a statistic for them, and
|
|
18
|
+
nothing here judges whether they matter.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from robostats.errors import LoadError
|
|
28
|
+
from robostats.io import load_manifest
|
|
29
|
+
from robostats.presets import ROBODOJO
|
|
30
|
+
from robostats.records import LoadProvenance, Protocol, RecordSet
|
|
31
|
+
|
|
32
|
+
__all__ = ["load"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(path: str | Path, *, protocol: Protocol | None = None) -> RecordSet:
|
|
36
|
+
"""Load a RoboDojo manifest into records.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
path : str or pathlib.Path
|
|
41
|
+
The manifest to read.
|
|
42
|
+
protocol : Protocol or None
|
|
43
|
+
The protocol this run was collected under. ``None`` means nothing was
|
|
44
|
+
declared, which is what the manifest itself supports: the protocol is
|
|
45
|
+
never inferred from a file's contents, so a caller who knows the
|
|
46
|
+
execution horizon should pass it rather than leave it blank.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
RecordSet
|
|
51
|
+
One record per episode, with ``scenario_id`` composed as
|
|
52
|
+
``config_name/layout_id``, ``score`` carried in ``success_detail``
|
|
53
|
+
unthresholded, and the counts of episodes the run left out recorded on
|
|
54
|
+
the set.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
LoadError
|
|
59
|
+
If the file is missing, is not a JSON object, or has no ``details``.
|
|
60
|
+
"""
|
|
61
|
+
location = Path(path)
|
|
62
|
+
records = load_manifest(location, protocol=protocol or Protocol(), benchmark=ROBODOJO.name)
|
|
63
|
+
return RecordSet(
|
|
64
|
+
records.records,
|
|
65
|
+
scenario_spec=records.scenario_spec,
|
|
66
|
+
provenance=LoadProvenance(
|
|
67
|
+
preset=ROBODOJO.name,
|
|
68
|
+
preset_version=ROBODOJO.version,
|
|
69
|
+
excluded=_excluded_counts(location),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _excluded_counts(path: Path) -> dict[str, int]:
|
|
75
|
+
"""Count the episodes the run did not put in ``details``.
|
|
76
|
+
|
|
77
|
+
Read from the manifest's own bookkeeping, under the names RoboDojo uses. The
|
|
78
|
+
keys are passed through rather than translated, because renaming another
|
|
79
|
+
tool's vocabulary makes its numbers harder to check against its own output.
|
|
80
|
+
"""
|
|
81
|
+
document = _document(path)
|
|
82
|
+
counts: dict[str, int] = {}
|
|
83
|
+
for name, key in (
|
|
84
|
+
("completed", "completed_layout_ids"),
|
|
85
|
+
("abandoned", "abandoned_layout_ids"),
|
|
86
|
+
):
|
|
87
|
+
value = document.get(key)
|
|
88
|
+
if isinstance(value, list):
|
|
89
|
+
counts[name] = len(value)
|
|
90
|
+
for name, key in (("unstable", "unstable_nums"), ("restarts", "restart_count")):
|
|
91
|
+
value = document.get(key)
|
|
92
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
93
|
+
counts[name] = value
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _document(path: Path) -> dict[str, Any]:
|
|
98
|
+
"""Parse the manifest, or raise a LoadError naming the file."""
|
|
99
|
+
try:
|
|
100
|
+
text = path.read_text()
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise LoadError(f"{path}: cannot be read ({error.strerror})") from error
|
|
103
|
+
try:
|
|
104
|
+
parsed = json.loads(text)
|
|
105
|
+
except json.JSONDecodeError as error:
|
|
106
|
+
raise LoadError(f"{path}: not valid JSON ({error.msg})") from error
|
|
107
|
+
if not isinstance(parsed, dict):
|
|
108
|
+
raise LoadError(f"{path}: expected a JSON object, got {type(parsed).__name__}")
|
|
109
|
+
return parsed
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Record RoboTwin episodes from its trial-end hook.
|
|
2
|
+
|
|
3
|
+
RoboTwin computes a success rate and writes no per-episode file, so there is
|
|
4
|
+
nothing to read back and no reader here. What it does have is a named interface:
|
|
5
|
+
after every episode it calls ``notify_trial_end(model_client, task_name, seed,
|
|
6
|
+
success)``, which sends ``{task_name, seed, success}`` to the policy server over
|
|
7
|
+
the ``ws`` protocol. Users already write a policy adapter, so handling that
|
|
8
|
+
message is a few lines in a file they own, with no patch to RoboTwin.
|
|
9
|
+
|
|
10
|
+
This module turns one such payload into a record. It imports nothing from
|
|
11
|
+
RoboTwin and never talks to it: RoboTwin calls the user's handler, and the
|
|
12
|
+
handler calls this.
|
|
13
|
+
|
|
14
|
+
``task_config`` selects ``demo_clean`` or ``demo_randomized`` and is not in the
|
|
15
|
+
payload, because it is a directory name rather than episode data. It is pinned by
|
|
16
|
+
the caller and composed into ``scenario_id`` ahead of the seed, using the
|
|
17
|
+
composition the ``robotwin`` preset declares, so that recording and loading
|
|
18
|
+
cannot drift apart. Seed 17 under one configuration is a different scene from
|
|
19
|
+
seed 17 under the other; a key that omits the configuration joins the two
|
|
20
|
+
silently.
|
|
21
|
+
|
|
22
|
+
The hook fires only on the ``ws`` path. A run using the local policy path never
|
|
23
|
+
reaches it, and needs the generic
|
|
24
|
+
:class:`~robostats.recording.EpisodeRecorder` instead. See ``docs/recording.md``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from robostats.presets import ROBOTWIN
|
|
33
|
+
from robostats.recording import EpisodeRecorder
|
|
34
|
+
from robostats.records import EpisodeRecord
|
|
35
|
+
|
|
36
|
+
__all__ = ["record_trial_end"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_trial_end(
|
|
40
|
+
recorder: EpisodeRecorder, payload: Mapping[str, Any], *, task_config: str
|
|
41
|
+
) -> EpisodeRecord:
|
|
42
|
+
"""Write one record from a RoboTwin ``trial_end`` payload.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
recorder : EpisodeRecorder
|
|
47
|
+
The open recorder to write to. It carries the policy id, the run id and
|
|
48
|
+
the protocol, none of which are in the payload.
|
|
49
|
+
payload : Mapping[str, Any]
|
|
50
|
+
The hook's message, carrying ``task_name``, ``seed`` and ``success``.
|
|
51
|
+
task_config : str
|
|
52
|
+
The RoboTwin configuration this run used, for example ``"demo_clean"``.
|
|
53
|
+
Pinned by the caller because it is a directory name, and composed into
|
|
54
|
+
``scenario_id`` ahead of the seed.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
EpisodeRecord
|
|
59
|
+
The record as written.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
PresetMismatchError
|
|
64
|
+
If the payload does not carry the three keys the hook sends. A changed
|
|
65
|
+
payload is worth stopping for: silently recording two of three fields
|
|
66
|
+
would produce a file that loads cleanly and means something else.
|
|
67
|
+
SchemaError
|
|
68
|
+
If a value violates the record schema, ``success`` not being a ``bool``
|
|
69
|
+
above all. This aborts the eval run, which is the point: see
|
|
70
|
+
``docs/recording.md``.
|
|
71
|
+
"""
|
|
72
|
+
ROBOTWIN.check_shape(list(payload), "<trial_end payload>")
|
|
73
|
+
return recorder.record(
|
|
74
|
+
task_id=str(payload["task_name"]),
|
|
75
|
+
scenario_id=ROBOTWIN.compose_scenario_id((task_config,), payload),
|
|
76
|
+
success=payload["success"],
|
|
77
|
+
seed=payload["seed"] if isinstance(payload["seed"], int) else None,
|
|
78
|
+
)
|