cpomdp-workshop 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.
- cpomdp_workshop/__init__.py +161 -0
- cpomdp_workshop/_seams.py +46 -0
- cpomdp_workshop/build.py +171 -0
- cpomdp_workshop/check.py +287 -0
- cpomdp_workshop/checkpoints.py +55 -0
- cpomdp_workshop/planner.py +112 -0
- cpomdp_workshop/plot.py +396 -0
- cpomdp_workshop/selfguided.py +68 -0
- cpomdp_workshop/sim.py +139 -0
- cpomdp_workshop/solutions.py +97 -0
- cpomdp_workshop/symptoms.py +123 -0
- cpomdp_workshop/world.py +107 -0
- cpomdp_workshop-0.1.0.dist-info/METADATA +108 -0
- cpomdp_workshop-0.1.0.dist-info/RECORD +16 -0
- cpomdp_workshop-0.1.0.dist-info/WHEEL +4 -0
- cpomdp_workshop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Build a curious rocket: the workshop package.
|
|
2
|
+
|
|
3
|
+
Everything the notebook imports lives here. The world and its sensor field are in
|
|
4
|
+
``world``, the compass moves and the search over them in ``planner``, the two rockets
|
|
5
|
+
in ``build``, the flight loop in ``sim`` and the checks the room runs in ``check``.
|
|
6
|
+
|
|
7
|
+
The cpomdp release the workshop was measured against is pinned in ``pyproject.toml``
|
|
8
|
+
and read back here as ``WORKSHOP_CPOMDP_VERSION``, so the notebook, the tests and the
|
|
9
|
+
install cannot disagree about which library produced a number.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
|
|
15
|
+
from cpomdp_workshop.build import (
|
|
16
|
+
THRUSTER_COST,
|
|
17
|
+
curious_rocket,
|
|
18
|
+
model_with,
|
|
19
|
+
one_node,
|
|
20
|
+
pad_goal,
|
|
21
|
+
pad_preference,
|
|
22
|
+
the_1960_rocket,
|
|
23
|
+
)
|
|
24
|
+
from cpomdp_workshop.check import (
|
|
25
|
+
check_agent,
|
|
26
|
+
check_backends,
|
|
27
|
+
check_my_sensor,
|
|
28
|
+
epistemic_spread,
|
|
29
|
+
grade_sensor,
|
|
30
|
+
probe,
|
|
31
|
+
show_probe,
|
|
32
|
+
)
|
|
33
|
+
from cpomdp_workshop.checkpoints import (
|
|
34
|
+
after_session_1,
|
|
35
|
+
after_session_2,
|
|
36
|
+
after_session_3,
|
|
37
|
+
)
|
|
38
|
+
from cpomdp_workshop.planner import (
|
|
39
|
+
STEP,
|
|
40
|
+
compass_actions,
|
|
41
|
+
compass_moves,
|
|
42
|
+
compass_planner,
|
|
43
|
+
)
|
|
44
|
+
from cpomdp_workshop.plot import (
|
|
45
|
+
plot_beliefs,
|
|
46
|
+
plot_flights,
|
|
47
|
+
plot_path,
|
|
48
|
+
plot_world,
|
|
49
|
+
preview,
|
|
50
|
+
world_flights,
|
|
51
|
+
)
|
|
52
|
+
from cpomdp_workshop.selfguided import two_node_backend
|
|
53
|
+
from cpomdp_workshop.sim import ROCK, Flight, fly
|
|
54
|
+
from cpomdp_workshop.solutions import (
|
|
55
|
+
CLEAR_SKY_ROCK,
|
|
56
|
+
axis_certificate,
|
|
57
|
+
efe_split,
|
|
58
|
+
rxinfer_refusal,
|
|
59
|
+
)
|
|
60
|
+
from cpomdp_workshop.symptoms import symptoms
|
|
61
|
+
from cpomdp_workshop.world import (
|
|
62
|
+
BEACON,
|
|
63
|
+
BLACKOUT,
|
|
64
|
+
LAUNCH,
|
|
65
|
+
PAD,
|
|
66
|
+
ROCK_RADIUS,
|
|
67
|
+
beacon_sensor,
|
|
68
|
+
default_params,
|
|
69
|
+
reference_sensor,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"BEACON",
|
|
74
|
+
"BLACKOUT",
|
|
75
|
+
"CLEAR_SKY_ROCK",
|
|
76
|
+
"LAUNCH",
|
|
77
|
+
"MINIMUM_PYTHON",
|
|
78
|
+
"PAD",
|
|
79
|
+
"ROCK",
|
|
80
|
+
"ROCK_RADIUS",
|
|
81
|
+
"STEP",
|
|
82
|
+
"THRUSTER_COST",
|
|
83
|
+
"WORKSHOP_CPOMDP_VERSION",
|
|
84
|
+
"Flight",
|
|
85
|
+
"after_session_1",
|
|
86
|
+
"after_session_2",
|
|
87
|
+
"after_session_3",
|
|
88
|
+
"axis_certificate",
|
|
89
|
+
"beacon_sensor",
|
|
90
|
+
"check_agent",
|
|
91
|
+
"check_backends",
|
|
92
|
+
"check_my_sensor",
|
|
93
|
+
"compass_actions",
|
|
94
|
+
"compass_moves",
|
|
95
|
+
"compass_planner",
|
|
96
|
+
"curious_rocket",
|
|
97
|
+
"default_params",
|
|
98
|
+
"efe_split",
|
|
99
|
+
"epistemic_spread",
|
|
100
|
+
"fly",
|
|
101
|
+
"grade_sensor",
|
|
102
|
+
"hello",
|
|
103
|
+
"model_with",
|
|
104
|
+
"one_node",
|
|
105
|
+
"pad_goal",
|
|
106
|
+
"pad_preference",
|
|
107
|
+
"plot_beliefs",
|
|
108
|
+
"plot_flights",
|
|
109
|
+
"plot_path",
|
|
110
|
+
"plot_world",
|
|
111
|
+
"preview",
|
|
112
|
+
"probe",
|
|
113
|
+
"reference_sensor",
|
|
114
|
+
"rxinfer_refusal",
|
|
115
|
+
"show_probe",
|
|
116
|
+
"symptoms",
|
|
117
|
+
"the_1960_rocket",
|
|
118
|
+
"two_node_backend",
|
|
119
|
+
"world_flights",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
MINIMUM_PYTHON = (3, 11)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pinned_cpomdp_version() -> str:
|
|
126
|
+
"""The cpomdp version this distribution pins, read from its own metadata."""
|
|
127
|
+
for requirement in metadata.requires("cpomdp-workshop") or ():
|
|
128
|
+
name, pinned, rest = requirement.partition("==")
|
|
129
|
+
if name.strip() == "cpomdp" and pinned:
|
|
130
|
+
return rest.split(";")[0].strip()
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"cpomdp-workshop must pin cpomdp with '==' in pyproject.toml. The notebook "
|
|
133
|
+
"and the tests read the pin from there."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
WORKSHOP_CPOMDP_VERSION = _pinned_cpomdp_version()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def hello() -> None:
|
|
141
|
+
"""Print the versions in use and the world, after checking the install.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
RuntimeError: if Python is older than 3.11 or the installed cpomdp is not
|
|
145
|
+
the release the workshop was measured on.
|
|
146
|
+
"""
|
|
147
|
+
python = ".".join(str(part) for part in sys.version_info[:3])
|
|
148
|
+
if sys.version_info[:2] < MINIMUM_PYTHON:
|
|
149
|
+
raise RuntimeError(f"Python {python} is too old. The workshop needs 3.11+.")
|
|
150
|
+
installed = metadata.version("cpomdp")
|
|
151
|
+
if installed != WORKSHOP_CPOMDP_VERSION:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"cpomdp {installed} is installed and the workshop was measured on "
|
|
154
|
+
f"{WORKSHOP_CPOMDP_VERSION}. Run: pip install cpomdp=="
|
|
155
|
+
f"{WORKSHOP_CPOMDP_VERSION}"
|
|
156
|
+
)
|
|
157
|
+
print(f"cpomdp {installed}, jax {metadata.version('jax')}, Python {python}. Ready.")
|
|
158
|
+
print(
|
|
159
|
+
f"launch {LAUNCH} -> pad {PAD}, nav beacon at {BEACON}, "
|
|
160
|
+
f"blackout at {BLACKOUT} with a rock of radius {ROCK_RADIUS}"
|
|
161
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Where this package meets cpomdp past its top-level exports.
|
|
2
|
+
|
|
3
|
+
The finite-action search, the per-step EFE trace and the RxInfer oracle live in
|
|
4
|
+
submodules the library does not re-export at its top level. They are imported here and
|
|
5
|
+
nowhere else, so a rename in one of them is a fix to this file alone. The notebook
|
|
6
|
+
reaches them through the package and never imports a submodule itself.
|
|
7
|
+
|
|
8
|
+
The one typing workaround the pinned release needs lives here too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from cpomdp import CallableSensor, FixedSensor, ObservationModel
|
|
14
|
+
from cpomdp.backends.rxinfer import RxInferBackend
|
|
15
|
+
from cpomdp.efe import PolicyEFETrace, policy_efe_trace
|
|
16
|
+
from cpomdp.enumeration import (
|
|
17
|
+
ChunkedEFESearch,
|
|
18
|
+
CompletenessCertificate,
|
|
19
|
+
EnumeratedEFESearch,
|
|
20
|
+
ExhaustiveEFESearch,
|
|
21
|
+
FiniteActionSet,
|
|
22
|
+
RecedingHorizonSelector,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ChunkedEFESearch",
|
|
27
|
+
"CompletenessCertificate",
|
|
28
|
+
"EnumeratedEFESearch",
|
|
29
|
+
"ExhaustiveEFESearch",
|
|
30
|
+
"FiniteActionSet",
|
|
31
|
+
"PolicyEFETrace",
|
|
32
|
+
"RecedingHorizonSelector",
|
|
33
|
+
"RxInferBackend",
|
|
34
|
+
"as_observation_model",
|
|
35
|
+
"policy_efe_trace",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def as_observation_model(sensor: FixedSensor | CallableSensor) -> ObservationModel:
|
|
40
|
+
"""``sensor``, typed as the protocol it implements.
|
|
41
|
+
|
|
42
|
+
cpomdp 0.5.0 declares the protocol's ``is_fixed`` writable and sets it as a class
|
|
43
|
+
attribute on both sensors, so a type checker refuses either sensor where the
|
|
44
|
+
protocol is expected. The fix belongs in the library.
|
|
45
|
+
"""
|
|
46
|
+
return cast(ObservationModel, sensor)
|
cpomdp_workshop/build.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The two rockets, and the 1960 one again as a graph of one node.
|
|
2
|
+
|
|
3
|
+
Both rockets fly the same plant: a position in the plane, a move added straight to it,
|
|
4
|
+
a reading of where they are. They differ in the reading. The 1960 rocket's reading is
|
|
5
|
+
equally noisy everywhere, so no move can teach it more than another. It is handed a
|
|
6
|
+
state to reach and steers by LQR. The curious rocket's reading depends on where it is.
|
|
7
|
+
It is handed a reading to prefer and searches the compass moves for the plan that
|
|
8
|
+
best trades getting there against seeing clearly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from cpomdp import (
|
|
13
|
+
Agent,
|
|
14
|
+
Belief,
|
|
15
|
+
CallableSensor,
|
|
16
|
+
CouplingGraph,
|
|
17
|
+
CouplingGraphBackend,
|
|
18
|
+
FixedSensor,
|
|
19
|
+
GaussianTransition,
|
|
20
|
+
LinearGaussianModel,
|
|
21
|
+
Preference,
|
|
22
|
+
StateGoal,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from cpomdp_workshop._seams import as_observation_model
|
|
26
|
+
from cpomdp_workshop.planner import compass_planner
|
|
27
|
+
from cpomdp_workshop.world import (
|
|
28
|
+
DYNAMICS_NOISE,
|
|
29
|
+
LAUNCH,
|
|
30
|
+
NOISE_FLOOR,
|
|
31
|
+
PAD,
|
|
32
|
+
PRIOR_COV,
|
|
33
|
+
NoiseFn,
|
|
34
|
+
SensorParams,
|
|
35
|
+
default_params,
|
|
36
|
+
reference_sensor,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"THRUSTER_COST",
|
|
41
|
+
"curious_rocket",
|
|
42
|
+
"model_with",
|
|
43
|
+
"one_node",
|
|
44
|
+
"pad_goal",
|
|
45
|
+
"pad_preference",
|
|
46
|
+
"the_1960_rocket",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
THRUSTER_COST = 100.0 # effort
|
|
50
|
+
|
|
51
|
+
_IDENTITY = np.eye(2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def model_with(
|
|
55
|
+
sensor: NoiseFn | None = None, params: SensorParams | None = None
|
|
56
|
+
) -> LinearGaussianModel:
|
|
57
|
+
"""The rocket's generative model, with or without a state-dependent sensor.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
sensor: a noise function ``R(x, params)``, or ``None`` for a reading that is
|
|
61
|
+
equally noisy everywhere.
|
|
62
|
+
params: the sensor's parameters. Required when ``sensor`` is given.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A two-dimensional model. The rocket stays where it is unless moved, a move
|
|
66
|
+
adds straight to the position, and the reading is the position plus noise.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: if a sensor is given without parameters.
|
|
70
|
+
"""
|
|
71
|
+
if sensor is None:
|
|
72
|
+
observation_model = None
|
|
73
|
+
observation_matrix, observation_noise = (
|
|
74
|
+
_IDENTITY,
|
|
75
|
+
NOISE_FLOOR * _IDENTITY,
|
|
76
|
+
) # C, R
|
|
77
|
+
elif params is None:
|
|
78
|
+
raise ValueError("a sensor needs its params. Pass default_params(k).")
|
|
79
|
+
else:
|
|
80
|
+
# The model reads C, and R at the prior mean, from the sensor itself.
|
|
81
|
+
observation_model = as_observation_model(
|
|
82
|
+
CallableSensor(_IDENTITY, sensor, params)
|
|
83
|
+
)
|
|
84
|
+
observation_matrix = observation_noise = None
|
|
85
|
+
return LinearGaussianModel(
|
|
86
|
+
_IDENTITY,
|
|
87
|
+
control_matrix=_IDENTITY,
|
|
88
|
+
observation_matrix=observation_matrix,
|
|
89
|
+
observation_noise=observation_noise,
|
|
90
|
+
dynamics_noise=DYNAMICS_NOISE * _IDENTITY,
|
|
91
|
+
prior=Belief(mean=np.asarray(LAUNCH), cov=PRIOR_COV * _IDENTITY),
|
|
92
|
+
observation_model=observation_model,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pad_goal() -> StateGoal:
|
|
97
|
+
"""The pad as a state to reach, with every unit of thrust charged for.
|
|
98
|
+
|
|
99
|
+
The effort weight is the thruster cost. Left at its default of one, LQR would
|
|
100
|
+
cover more than half the distance to the pad in its first move.
|
|
101
|
+
"""
|
|
102
|
+
return StateGoal(
|
|
103
|
+
np.asarray(PAD), precision=_IDENTITY, effort=THRUSTER_COST * _IDENTITY
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pad_preference() -> Preference:
|
|
108
|
+
"""The pad as a reading to prefer, which is what a compass search scores."""
|
|
109
|
+
return Preference(np.asarray(PAD), precision=_IDENTITY)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def the_1960_rocket() -> Agent:
|
|
113
|
+
"""The Kalman filter with a goal bolted on: one fixed reading, steered by LQR."""
|
|
114
|
+
return Agent(model_with(), pad_goal())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def curious_rocket(
|
|
118
|
+
beacon_gain: float, sensor: NoiseFn = reference_sensor, *, horizon: int = 1
|
|
119
|
+
) -> Agent:
|
|
120
|
+
"""A rocket whose reading depends on where it is, searching the compass moves.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
beacon_gain: k, how fast the reading degrades away from the beacon.
|
|
124
|
+
sensor: the noise field the rocket models. The default has the beacon and
|
|
125
|
+
the blackout. ``beacon_sensor`` is the beacon alone.
|
|
126
|
+
horizon: how many moves ahead the search looks, H.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
An agent that prefers to read the pad and plans every step over every
|
|
130
|
+
compass plan of length ``horizon``.
|
|
131
|
+
"""
|
|
132
|
+
model = model_with(sensor, default_params(beacon_gain))
|
|
133
|
+
planner = compass_planner(model, horizon=horizon)
|
|
134
|
+
return Agent(model, pad_preference(), selector=planner)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def one_node(model: LinearGaussianModel) -> CouplingGraphBackend:
|
|
138
|
+
"""The same model as a graph of one node, to run it on the message-passing engine.
|
|
139
|
+
|
|
140
|
+
A coupling graph is for branching models, where separate variables evolve at
|
|
141
|
+
their own rates and only some of them are read. A single node with no edges is
|
|
142
|
+
the degenerate case: the filtering problem the Kalman backend solves, routed
|
|
143
|
+
through the graph code instead. The two engines should agree to the last few
|
|
144
|
+
digits.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
model: a fixed-sensor model from [`model_with`][cpomdp_workshop.model_with].
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A backend for ``Agent.from_backend``. Its graph is ``backend.graph``.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: if the model's sensor depends on the state. The backends would
|
|
154
|
+
then not be running the same model.
|
|
155
|
+
"""
|
|
156
|
+
if model.observation_model is not None:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"one_node takes a fixed-sensor model. Build it with model_with()."
|
|
159
|
+
)
|
|
160
|
+
sensor = as_observation_model(
|
|
161
|
+
FixedSensor(model.observation_matrix, observation_noise=model.observation_noise)
|
|
162
|
+
)
|
|
163
|
+
graph = CouplingGraph(
|
|
164
|
+
dims=[model.n_states], couplings=[], observation_models={0: sensor}
|
|
165
|
+
)
|
|
166
|
+
transition = GaussianTransition(
|
|
167
|
+
model.dynamics_matrix, dynamics_noise=model.dynamics_noise
|
|
168
|
+
)
|
|
169
|
+
return CouplingGraphBackend(
|
|
170
|
+
graph, [transition], prior=model.prior, control_matrix=model.control_matrix
|
|
171
|
+
)
|
cpomdp_workshop/check.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""What the notebook's checks compute, and what they print.
|
|
2
|
+
|
|
3
|
+
Each check is a pure function the tests assert on, paired with a ``check_`` or
|
|
4
|
+
``show_`` function that prints its result. A sensor is graded by flying a rocket with
|
|
5
|
+
it, never passed or failed. One that varies and still leaves the path straight is
|
|
6
|
+
told which way to move k.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import jax
|
|
12
|
+
import jax.numpy as jnp
|
|
13
|
+
import numpy as np
|
|
14
|
+
from cpomdp import Agent, InferenceBackend, LinearGaussianModel, ProbeReport
|
|
15
|
+
from cpomdp import probe_model as _probe_model
|
|
16
|
+
|
|
17
|
+
from cpomdp_workshop.build import model_with, pad_goal, pad_preference
|
|
18
|
+
from cpomdp_workshop.planner import compass_moves, compass_planner
|
|
19
|
+
from cpomdp_workshop.sim import fly
|
|
20
|
+
from cpomdp_workshop.world import (
|
|
21
|
+
LAUNCH,
|
|
22
|
+
PAD,
|
|
23
|
+
NoiseFn,
|
|
24
|
+
SensorParams,
|
|
25
|
+
beacon_sensor,
|
|
26
|
+
reference_sensor,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"LANDED",
|
|
31
|
+
"STRAIGHT_BEND",
|
|
32
|
+
"SensorGrade",
|
|
33
|
+
"backend_differences",
|
|
34
|
+
"check_agent",
|
|
35
|
+
"check_backends",
|
|
36
|
+
"check_my_sensor",
|
|
37
|
+
"epistemic_spread",
|
|
38
|
+
"grade_sensor",
|
|
39
|
+
"probe",
|
|
40
|
+
"show_probe",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
STRAIGHT_BEND = 0.1
|
|
44
|
+
"""A path that strays less than this from the launch-to-pad line counts as straight."""
|
|
45
|
+
LANDED = 0.5
|
|
46
|
+
"""A flight that ends further than this from the pad has not landed."""
|
|
47
|
+
|
|
48
|
+
_ROUTE = np.linspace(LAUNCH, PAD, 11)
|
|
49
|
+
_FIELD = np.stack(
|
|
50
|
+
np.meshgrid(np.linspace(-1.0, 6.0, 8), np.linspace(-2.0, 3.0, 6)), axis=-1
|
|
51
|
+
).reshape(-1, 2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def probe(model: LinearGaussianModel) -> ProbeReport:
|
|
55
|
+
"""Probe ``model`` over the nine compass moves from its prior.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
model: a rocket's generative model.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
cpomdp's report on whether a move can change what the rocket learns.
|
|
62
|
+
"""
|
|
63
|
+
return _probe_model(model, model.prior, compass_moves())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def epistemic_spread(report: ProbeReport) -> float:
|
|
67
|
+
"""How far apart the best and worst move are in what they teach, in nats."""
|
|
68
|
+
lowest, highest = report.epistemic_range
|
|
69
|
+
return highest - lowest
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def show_probe(report: ProbeReport) -> None:
|
|
73
|
+
"""Print the three numbers task 1 asks about, on one line."""
|
|
74
|
+
print(
|
|
75
|
+
f"epistemic_varies = {report.epistemic_varies} "
|
|
76
|
+
f"flattens = {report.flattens} "
|
|
77
|
+
f"spread = {epistemic_spread(report):.6f} nats"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_agent(agent: Agent) -> None:
|
|
82
|
+
"""Print the agent as built, then fly it in its own world and say where it lands.
|
|
83
|
+
|
|
84
|
+
The flight uses the agent up. Build it again to fly it again.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
agent: a rocket that has not flown.
|
|
88
|
+
"""
|
|
89
|
+
print(agent)
|
|
90
|
+
flight = fly(agent)
|
|
91
|
+
x, y = flight.final
|
|
92
|
+
print(
|
|
93
|
+
f"after {len(flight.path) - 1} moves it is at ({x:.2f}, {y:.2f}), "
|
|
94
|
+
f"{flight.distance_to(PAD):.2f} from the pad"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class SensorGrade:
|
|
100
|
+
"""What a sensor does for a rocket.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
grade: ``"broken"``, ``"flat"``, ``"straight"``, ``"bends"`` or
|
|
104
|
+
``"too curious"``.
|
|
105
|
+
message: what to tell the person who wrote it.
|
|
106
|
+
bend: how far the rocket strayed from the straight line, or ``None`` if it
|
|
107
|
+
did not fly.
|
|
108
|
+
distance: how far from the pad it ended, or ``None`` if it did not fly.
|
|
109
|
+
matches: the reference sensor it reproduces, or ``None``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
grade: str
|
|
113
|
+
message: str
|
|
114
|
+
bend: float | None = None
|
|
115
|
+
distance: float | None = None
|
|
116
|
+
matches: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _untraceable(sensor: NoiseFn, params: SensorParams) -> str | None:
|
|
120
|
+
"""Why JAX cannot trace ``sensor``, or ``None`` if it can.
|
|
121
|
+
|
|
122
|
+
The search differentiates and compiles through the sensor, so Python's ``min``,
|
|
123
|
+
the ``math`` module and NumPy fail there even where a plain call works.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
jax.jit(sensor)(jnp.asarray(_ROUTE[0]), params)
|
|
127
|
+
except Exception as error: # the learner's code, any failure is the finding
|
|
128
|
+
return (
|
|
129
|
+
f"JAX cannot trace it ({type(error).__name__}). Use jnp functions such as "
|
|
130
|
+
"jnp.minimum, jnp.exp and jnp.sum in place of min, math or np."
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _broken(sensor: NoiseFn, params: SensorParams) -> str | None:
|
|
136
|
+
"""Why ``sensor`` is not a noise covariance the search can use, or ``None``."""
|
|
137
|
+
for point in np.vstack([_ROUTE, _FIELD]):
|
|
138
|
+
where = f"({point[0]:.1f}, {point[1]:.1f})"
|
|
139
|
+
try:
|
|
140
|
+
noise = np.asarray(sensor(point, params), dtype=float)
|
|
141
|
+
except Exception as error: # the learner's code, any failure is the finding
|
|
142
|
+
return f"calling it at {where} raised {type(error).__name__}: {error}"
|
|
143
|
+
if noise.shape != (2, 2):
|
|
144
|
+
return (
|
|
145
|
+
f"it returned shape {noise.shape} at {where}. It needs a 2x2 matrix, "
|
|
146
|
+
"jnp.eye(2) * (...)"
|
|
147
|
+
)
|
|
148
|
+
if not np.all(np.isfinite(noise)):
|
|
149
|
+
return f"it returned a non-finite value at {where}."
|
|
150
|
+
if not np.allclose(noise, noise.T) or np.linalg.eigvalsh(noise).min() <= 0:
|
|
151
|
+
return f"it is not a positive noise at {where}: {noise.tolist()}"
|
|
152
|
+
return _untraceable(sensor, params)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _matching_reference(sensor: NoiseFn, params: SensorParams) -> str | None:
|
|
156
|
+
"""The name of the reference sensor ``sensor`` reproduces, if any."""
|
|
157
|
+
for name, reference in (
|
|
158
|
+
("beacon_sensor", beacon_sensor),
|
|
159
|
+
("reference_sensor", reference_sensor),
|
|
160
|
+
):
|
|
161
|
+
if all(
|
|
162
|
+
np.allclose(sensor(p, params), reference(p, params), atol=1e-12)
|
|
163
|
+
for p in np.vstack([_ROUTE, _FIELD])
|
|
164
|
+
):
|
|
165
|
+
return name
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def grade_sensor(sensor: NoiseFn, params: SensorParams) -> SensorGrade:
|
|
170
|
+
"""Grade a noise function by flying a curious rocket with it.
|
|
171
|
+
|
|
172
|
+
The rocket models ``sensor`` and flies in it from the default seed, as the
|
|
173
|
+
attempt cell's rocket does. A probe of what each move would teach is not the
|
|
174
|
+
grade, because it does not predict the path. The reading noise also enters what
|
|
175
|
+
each move is expected to read against "on the pad", and at these settings that
|
|
176
|
+
term does most of the steering.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
sensor: a noise function ``R(x, params)``.
|
|
180
|
+
params: its parameters.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
A [`SensorGrade`][cpomdp_workshop.SensorGrade].
|
|
184
|
+
"""
|
|
185
|
+
problem = _broken(sensor, params)
|
|
186
|
+
if problem is not None:
|
|
187
|
+
return SensorGrade("broken", f"Not a sensor yet: {problem}")
|
|
188
|
+
noises = [np.asarray(sensor(p, params)) for p in np.vstack([_ROUTE, _FIELD])]
|
|
189
|
+
if all(np.allclose(noise, noises[0], atol=1e-12) for noise in noises):
|
|
190
|
+
return SensorGrade(
|
|
191
|
+
"flat",
|
|
192
|
+
"R(x) is the same everywhere. That is the 1960 rocket's sensor, and no "
|
|
193
|
+
"move can teach it more than another.",
|
|
194
|
+
)
|
|
195
|
+
model = model_with(sensor, params)
|
|
196
|
+
planner = compass_planner(model)
|
|
197
|
+
flight = fly(Agent(model, pad_preference(), selector=planner))
|
|
198
|
+
bend = float(np.abs(flight.path[:, 1]).max())
|
|
199
|
+
distance = flight.distance_to(PAD)
|
|
200
|
+
matches = _matching_reference(sensor, params)
|
|
201
|
+
if bend < STRAIGHT_BEND:
|
|
202
|
+
message = (
|
|
203
|
+
"R(x) varies across the map. The rocket still flies straight, so the "
|
|
204
|
+
"pad's pull wins at this k. Raise it."
|
|
205
|
+
)
|
|
206
|
+
return SensorGrade("straight", message, bend, distance, matches)
|
|
207
|
+
path = f"The rocket bends {bend:.2f} off the straight line"
|
|
208
|
+
if distance > LANDED:
|
|
209
|
+
message = (
|
|
210
|
+
f"{path} and stops {distance:.2f} short of the pad. At this k, reading "
|
|
211
|
+
"near the beacon is worth more than reaching the pad."
|
|
212
|
+
)
|
|
213
|
+
return SensorGrade("too curious", message, bend, distance, matches)
|
|
214
|
+
message = f"{path} and lands {distance:.2f} from the pad."
|
|
215
|
+
if matches is not None:
|
|
216
|
+
message += f" It matches the workshop's {matches}."
|
|
217
|
+
return SensorGrade("bends", message, bend, distance, matches)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_my_sensor(sensor: NoiseFn, params: SensorParams) -> None:
|
|
221
|
+
"""Print what ``sensor`` does for a rocket, graded.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
sensor: a noise function ``R(x, params)``.
|
|
225
|
+
params: its parameters, from ``default_params(k)``.
|
|
226
|
+
"""
|
|
227
|
+
print(grade_sensor(sensor, params).message)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
BeliefTrack = tuple[np.ndarray, np.ndarray]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _belief_track(backend: InferenceBackend) -> BeliefTrack:
|
|
234
|
+
"""The belief means and covariances of the 1960 flight run on ``backend``."""
|
|
235
|
+
flight = fly(Agent.from_backend(backend, pad_goal()))
|
|
236
|
+
return flight.means, flight.covs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _largest_difference(track: BeliefTrack, reference: BeliefTrack) -> float:
|
|
240
|
+
"""The largest absolute difference in any belief mean or covariance entry."""
|
|
241
|
+
return float(
|
|
242
|
+
max(
|
|
243
|
+
np.abs(track[0] - reference[0]).max(), np.abs(track[1] - reference[1]).max()
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def backend_differences(
|
|
249
|
+
reference: InferenceBackend, *others: InferenceBackend
|
|
250
|
+
) -> list[float]:
|
|
251
|
+
"""How far each backend's beliefs stray from the reference's over one flight.
|
|
252
|
+
|
|
253
|
+
Every backend flies the 1960 rocket's goal from the same seed, in the world its
|
|
254
|
+
model describes.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
reference: the backend the others are measured against.
|
|
258
|
+
others: the backends to measure.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
For each of ``others``, the largest absolute difference from the reference in
|
|
262
|
+
any belief mean or covariance entry, over every step.
|
|
263
|
+
"""
|
|
264
|
+
reference_track = _belief_track(reference)
|
|
265
|
+
return [
|
|
266
|
+
_largest_difference(_belief_track(backend), reference_track)
|
|
267
|
+
for backend in others
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_backends(reference: InferenceBackend, *others: InferenceBackend) -> None:
|
|
272
|
+
"""Fly the 1960 rocket on each backend and print where their beliefs end up.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
reference: the backend the others are measured against.
|
|
276
|
+
others: the backends to measure.
|
|
277
|
+
"""
|
|
278
|
+
backends = (reference, *others)
|
|
279
|
+
tracks = [_belief_track(backend) for backend in backends]
|
|
280
|
+
for backend, (means, covs) in zip(backends, tracks, strict=True):
|
|
281
|
+
x, y = means[-1]
|
|
282
|
+
print(
|
|
283
|
+
f"{type(backend).__name__:<22} mean ({x:.6f}, {y:.6f}) "
|
|
284
|
+
f"cov diagonal ({covs[-1, 0, 0]:.6f}, {covs[-1, 1, 1]:.6f})"
|
|
285
|
+
)
|
|
286
|
+
largest = max(_largest_difference(track, tracks[0]) for track in tracks[1:])
|
|
287
|
+
print(f"max |difference| = {largest:.1e}")
|