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 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
+ )