mujax 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.
mujax/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Stochastic MuZero and Gumbel MuZero, built on JAX, mctx and Gymnasium.
2
+
3
+ Module map:
4
+ config.py MuZeroConfig
5
+ algorithm.py Algorithm, SearchPolicy: the contract between the infrastructure and an algorithm
6
+ algorithms/ ALGORITHMS registry and the algorithms
7
+ muzero.py shared math of the MuZero family: Support, network blocks, loss skeleton
8
+ smz.py / gmz.py algorithms (config, networks, search, loss)
9
+ replay.py / reanalyze.py flashbax replay and reanalyze iterator
10
+ actor.py / learner.py / loop.py environment interaction, gradient step, env loop
11
+ observers.py / loggers.py / checkpoint.py metrics, terminal/W&B writers, orbax checkpoint
12
+ experiment.py ExperimentConfig, run_experiment, load_actor
13
+ """
14
+
15
+ from mujax.actor import Actor
16
+ from mujax.algorithm import Algorithm
17
+ from mujax.algorithms import ALGORITHMS, GMZ, SMZ, GMZConfig, SMZConfig, algorithm_for
18
+ from mujax.checkpoint import CheckpointingConfig
19
+ from mujax.config import MuZeroConfig
20
+ from mujax.experiment import EvaluationConfig, ExperimentConfig, load_actor, run_experiment
21
+ from mujax.learner import Learner
22
+ from mujax.loggers import ConsoleWriter, Logger, WandbWriter, Writer, create_writer
23
+ from mujax.loop import EnvironmentLoop
24
+ from mujax.observers import EnvLoopObserver, InfoKeysObserver
25
+ from mujax.types import EnvironmentSpec, make_environment_spec
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "ALGORITHMS",
31
+ "GMZ",
32
+ "SMZ",
33
+ "Actor",
34
+ "Algorithm",
35
+ "CheckpointingConfig",
36
+ "ConsoleWriter",
37
+ "EnvLoopObserver",
38
+ "EnvironmentLoop",
39
+ "EnvironmentSpec",
40
+ "EvaluationConfig",
41
+ "ExperimentConfig",
42
+ "GMZConfig",
43
+ "InfoKeysObserver",
44
+ "Learner",
45
+ "Logger",
46
+ "MuZeroConfig",
47
+ "SMZConfig",
48
+ "WandbWriter",
49
+ "Writer",
50
+ "algorithm_for",
51
+ "create_writer",
52
+ "load_actor",
53
+ "make_environment_spec",
54
+ "run_experiment",
55
+ ]
mujax/actor.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import jax
7
+ import numpy as np
8
+
9
+ from mujax.algorithm import SearchPolicy
10
+ from mujax.replay import Adder
11
+ from mujax.types import SearchOutput, TimeStep, Transition
12
+
13
+
14
+ def invalid_actions_from_info(info: dict | None, num_envs: int, num_actions: int) -> np.ndarray:
15
+ """If `info["invalid_actions"]` (0/1 mask, 1 = invalid) is absent, all actions are treated as valid."""
16
+ raw = info.get("invalid_actions") if info else None
17
+ if raw is None:
18
+ return np.zeros((num_envs, num_actions), dtype=np.float32)
19
+ return np.asarray(raw, dtype=np.float32).reshape(num_envs, num_actions)
20
+
21
+
22
+ class Actor:
23
+ """The env-facing side: jitted search policy + learner params + writing to replay.
24
+
25
+ Observations are (num_envs, obs_dim), actions are (num_envs,).
26
+
27
+ Args:
28
+ get_params: () -> (params, learner_steps); usually `Learner.get_params`.
29
+ replay: if given (`Buffer.adder(i)`), every step is written there (training actor).
30
+ value_fn: (params, obs) -> search-free value (N,); for episodes cut by the time limit,
31
+ to bootstrap with V(final_obs). If None, a truncated episode is treated as terminated.
32
+ update_period: how many `select_action` calls between params fetches.
33
+ per_episode_update: if True, params are fetched only at episode start (evaluator: the score
34
+ matches the params that played that episode).
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ policy: SearchPolicy,
40
+ random_key: jax.Array,
41
+ get_params: Callable[[], tuple[Any, int]],
42
+ *,
43
+ num_actions: int,
44
+ replay: Adder | None = None,
45
+ value_fn: Callable[[Any, Any], Any] | None = None,
46
+ device: jax.Device | None = None,
47
+ update_period: int = 1,
48
+ per_episode_update: bool = False,
49
+ ) -> None:
50
+ self._search = jax.jit(policy)
51
+ self._key = random_key
52
+ self._get_params = get_params
53
+ self._num_actions = num_actions
54
+ self._replay = replay
55
+ self._value = jax.jit(value_fn) if value_fn is not None else None
56
+ self._device = device
57
+ self._update_period = max(1, update_period)
58
+ self._per_episode_update = per_episode_update
59
+ self._num_calls = 0
60
+ self._episode_start = True
61
+ self._last_search: SearchOutput | None = None
62
+ self._invalid_actions: np.ndarray | None = None
63
+ self._pull_params()
64
+
65
+ def _pull_params(self) -> None:
66
+ params, learner_steps = self._get_params()
67
+ self._params = (jax.device_put(params, self._device), learner_steps) # single assignment
68
+
69
+ def get_params(self) -> tuple[Any, int]:
70
+ """The (params, learner_steps) currently playing."""
71
+ return self._params
72
+
73
+ def observe_first(self, observation: np.ndarray, info: dict) -> None:
74
+ self._invalid_actions = invalid_actions_from_info(info, len(observation), self._num_actions)
75
+ self._episode_start = True
76
+
77
+ def select_action(self, observation: np.ndarray) -> np.ndarray:
78
+ self._num_calls += 1
79
+ if (self._per_episode_update and self._episode_start) or (not self._per_episode_update and self._num_calls % self._update_period == 0):
80
+ self._pull_params()
81
+ self._episode_start = False
82
+
83
+ params, learner_steps = self._params
84
+ self._key, search_key = jax.random.split(self._key)
85
+ search = self._search(params, np.asarray(observation), search_key, self._invalid_actions, learner_steps)
86
+ self._last_search = SearchOutput(
87
+ action=np.asarray(search.action, dtype=np.int32),
88
+ policy_probs=np.asarray(search.policy_probs, dtype=np.float32),
89
+ value=np.asarray(search.value, dtype=np.float32),
90
+ )
91
+ return self._last_search.action
92
+
93
+ def _final_values(self, step: TimeStep, truncated: np.ndarray) -> np.ndarray:
94
+ """V(final_obs) for truncated envs; under SAME_STEP the final observation is in `info["final_obs"]`."""
95
+ values = np.zeros(len(truncated), dtype=np.float32)
96
+ if not truncated.any():
97
+ return values
98
+ # To call the jit with a single shape, the whole batch is filled; non-truncated rows are discarded.
99
+ obs = np.zeros_like(np.asarray(step.observation), dtype=np.float32)
100
+ for index in np.flatnonzero(truncated):
101
+ obs[index] = step.info["final_obs"][index]
102
+ values[truncated] = np.asarray(self._value(self._params[0], obs))[truncated]
103
+ return values
104
+
105
+ def last_search(self) -> SearchOutput | None:
106
+ """Search output of the last `select_action`; EnvironmentLoop puts it into the TimeStep."""
107
+ return self._last_search
108
+
109
+ def observe(self, step: TimeStep) -> None:
110
+ terminated = np.asarray(step.terminated, dtype=bool)
111
+ truncated = np.asarray(step.truncated, dtype=bool) & ~terminated
112
+ done = terminated | truncated
113
+ if self._replay is not None:
114
+ if self._value is None: # V(final_obs) cannot be computed: a truncated episode is treated as terminated
115
+ terminated, truncated = done, np.zeros_like(truncated)
116
+ self._replay.add(
117
+ Transition(
118
+ observation=np.asarray(step.observation, dtype=np.float32),
119
+ action=self._last_search.action,
120
+ reward=np.asarray(step.reward, dtype=np.float32),
121
+ discount=np.where(terminated, 0.0, 1.0).astype(np.float32),
122
+ policy_probs=self._last_search.policy_probs,
123
+ value=self._last_search.value,
124
+ invalid_actions=self._invalid_actions,
125
+ truncated=truncated.astype(np.float32),
126
+ bootstrap_value=self._final_values(step, truncated),
127
+ )
128
+ )
129
+ self._invalid_actions = invalid_actions_from_info(step.info, len(done), self._num_actions)
130
+ if done.any():
131
+ self._episode_start = True
mujax/algorithm.py ADDED
@@ -0,0 +1,53 @@
1
+ """The contract between the shared infrastructure and an algorithm in `mujax.algorithms`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import jax
10
+ import jax.numpy as jnp
11
+ from flax import nnx
12
+
13
+ from mujax.config import MuZeroConfig
14
+ from mujax.types import EnvironmentSpec, SearchOutput
15
+
16
+ # Batched search policy: (params, obs, key, invalid_actions, learner_steps) -> SearchOutput.
17
+ # All inputs and outputs have shape (num_envs, ...).
18
+ SearchPolicy = Callable[[Any, Any, jax.Array, Any, Any], SearchOutput]
19
+
20
+ # Search-free root value: (params, obs) -> (N,); used for the bootstrap tail in reanalyze.
21
+ ValueFn = Callable[[Any, Any], jnp.ndarray]
22
+
23
+ # Algorithm loss: (graphdef, config, params, batch) -> (loss, metrics).
24
+ LossFn = Callable[[Any, MuZeroConfig, Any, Any], tuple[jnp.ndarray, dict[str, jnp.ndarray]]]
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True)
28
+ class Algorithm:
29
+ """Algorithm-specific parts; everything else (replay, reanalyze, actor, learner, loop) is shared.
30
+
31
+ The model is a single `nnx.Module`; `init` splits it into a graphdef (static structure) and params
32
+ (`nnx.State`, a pytree). Params travel separately (learner, actor threads, mctx, checkpoints) and the
33
+ algorithm functions rebuild the model with `nnx.merge(graphdef, params)`.
34
+
35
+ Attributes:
36
+ name: Short name written to checkpoint metadata ("gmz", "smz").
37
+ config_cls: Config class of this algorithm; `algorithm_for` uses it to pick the algorithm from a config.
38
+ make_model: (spec, config, rngs) -> model (nnx.Module).
39
+ make_policy: (graphdef, spec, config, evaluation) -> SearchPolicy.
40
+ loss: See `LossFn`.
41
+ make_value_fn: (graphdef, config) -> ValueFn.
42
+ """
43
+
44
+ name: str
45
+ config_cls: type[MuZeroConfig]
46
+ make_model: Callable[[EnvironmentSpec, MuZeroConfig, nnx.Rngs], nnx.Module]
47
+ make_policy: Callable[[nnx.GraphDef, EnvironmentSpec, MuZeroConfig, bool], SearchPolicy]
48
+ loss: LossFn
49
+ make_value_fn: Callable[[nnx.GraphDef, MuZeroConfig], ValueFn]
50
+
51
+ def init(self, spec: EnvironmentSpec, config: MuZeroConfig, key: jax.Array) -> tuple[nnx.GraphDef, nnx.State]:
52
+ """(graphdef, params) of a freshly initialized model."""
53
+ return nnx.split(self.make_model(spec, config, nnx.Rngs(key)))
@@ -0,0 +1,21 @@
1
+ """Algorithms; each module defines an `Algorithm` (see `mujax.algorithm`). New algorithms are registered in `ALGORITHMS`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mujax.algorithm import Algorithm
6
+ from mujax.algorithms.gmz import GMZ, GMZConfig
7
+ from mujax.algorithms.smz import SMZ, SMZConfig
8
+ from mujax.config import MuZeroConfig
9
+
10
+ ALGORITHMS: dict[str, Algorithm] = {SMZ.name: SMZ, GMZ.name: GMZ}
11
+
12
+
13
+ def algorithm_for(config: MuZeroConfig) -> Algorithm:
14
+ """The algorithm whose config class `config` is an instance of."""
15
+ for algorithm in ALGORITHMS.values():
16
+ if isinstance(config, algorithm.config_cls):
17
+ return algorithm
18
+ raise ValueError(f"{type(config).__name__} icin algoritma bulunamadi")
19
+
20
+
21
+ __all__ = ["ALGORITHMS", "GMZ", "SMZ", "GMZConfig", "SMZConfig", "algorithm_for"]
@@ -0,0 +1,116 @@
1
+ """Gumbel MuZero: config, networks, search and loss."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import functools
7
+ from collections.abc import Sequence
8
+
9
+ import jax.numpy as jnp
10
+ import mctx
11
+ from flax import nnx
12
+
13
+ from mujax.algorithm import Algorithm, SearchPolicy
14
+ from mujax.algorithms import muzero
15
+ from mujax.algorithms.muzero import Embedding, Head, Prediction, Support, SymlogInput, Targets
16
+ from mujax.config import MuZeroConfig
17
+ from mujax.types import EnvironmentSpec, SearchOutput
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class GMZConfig(MuZeroConfig):
22
+ """Gumbel MuZero hyperparameters."""
23
+
24
+ num_simulations: int = 16
25
+ max_depth: int | None = None
26
+ max_num_considered_actions: int = 16 # clipped to the number of actions
27
+ # Gumbel noise is gmz's only source of exploration (smz uses temperature + Dirichlet).
28
+ gumbel_scale: float = 1.0
29
+ value_scale: float = 0.1
30
+ maxvisit_init: float = 50.0
31
+
32
+ dynamics_layer_sizes: tuple[int, ...] = (256, 256, 256)
33
+
34
+
35
+ class Dynamics(nnx.Module):
36
+ def __init__(self, embedding_dim: int, layer_sizes: Sequence[int], num_actions: int, num_bins: int, *, rngs: nnx.Rngs):
37
+ self.num_actions = num_actions
38
+ self.embedding = Embedding(embedding_dim + num_actions, layer_sizes, embedding_dim, rngs=rngs)
39
+ self.reward = Head(embedding_dim, layer_sizes, num_bins, rngs=rngs)
40
+
41
+ def __call__(self, latent, action) -> tuple[jnp.ndarray, jnp.ndarray]:
42
+ """(next_latent, reward_logits)."""
43
+ next_latent = self.embedding(muzero.one_hot_concat(latent, action, self.num_actions))
44
+ return next_latent, self.reward(next_latent)
45
+
46
+
47
+ class GMZModel(nnx.Module):
48
+ """The deterministic model: representation, prediction and dynamics."""
49
+
50
+ def __init__(self, spec: EnvironmentSpec, config: GMZConfig, rngs: nnx.Rngs):
51
+ self.representation = SymlogInput(Embedding(spec.obs_dim, config.representation_layer_sizes, config.embedding_dim, rngs=rngs))
52
+ self.prediction = Prediction(
53
+ config.embedding_dim, config.prediction_layer_sizes, spec.num_actions, config.num_bins, unimix=config.policy_unimix, rngs=rngs
54
+ )
55
+ self.dynamics = Dynamics(config.embedding_dim, config.dynamics_layer_sizes, spec.num_actions, config.num_bins, rngs=rngs)
56
+
57
+
58
+ def recurrent_fn(graphdef: nnx.GraphDef, support: Support, config: GMZConfig):
59
+ """mctx recurrent_fn of the deterministic model."""
60
+
61
+ def fn(params, key, action, latent):
62
+ model = nnx.merge(graphdef, params)
63
+ next_latent, reward_logits = model.dynamics(latent, action)
64
+ value_logits, policy_logits = model.prediction(next_latent)
65
+ reward = support.logits_to_scalar(reward_logits)
66
+ output = mctx.RecurrentFnOutput(
67
+ reward=reward,
68
+ discount=jnp.full_like(reward, config.discount),
69
+ prior_logits=policy_logits,
70
+ value=support.logits_to_scalar(value_logits),
71
+ )
72
+ return output, next_latent
73
+
74
+ return fn
75
+
76
+
77
+ def make_policy(graphdef: nnx.GraphDef, spec: EnvironmentSpec, config: GMZConfig, evaluation: bool) -> SearchPolicy:
78
+ """Batched Gumbel MuZero search. `evaluation=True` turns off the Gumbel noise."""
79
+ support = Support.from_config(config)
80
+ max_num_considered_actions = min(int(config.max_num_considered_actions), spec.num_actions)
81
+ qtransform = functools.partial(mctx.qtransform_completed_by_mix_value, value_scale=config.value_scale, maxvisit_init=config.maxvisit_init)
82
+
83
+ def policy(params, obs, key, invalid_actions, learner_steps) -> SearchOutput:
84
+ del learner_steps
85
+ out = mctx.gumbel_muzero_policy(
86
+ params,
87
+ key,
88
+ muzero.root_output(nnx.merge(graphdef, params), support, obs),
89
+ recurrent_fn(graphdef, support, config),
90
+ num_simulations=config.num_simulations,
91
+ invalid_actions=invalid_actions,
92
+ max_depth=config.max_depth,
93
+ qtransform=qtransform,
94
+ max_num_considered_actions=max_num_considered_actions,
95
+ gumbel_scale=0.0 if evaluation else config.gumbel_scale,
96
+ )
97
+ return SearchOutput(out.action, out.action_weights, out.search_tree.summary().value)
98
+
99
+ return policy
100
+
101
+
102
+ def loss(graphdef: nnx.GraphDef, config: GMZConfig, params: nnx.State, batch) -> tuple[jnp.ndarray, dict[str, jnp.ndarray]]:
103
+ def unroll_step(model: GMZModel, latent, now: Targets, next_: Targets):
104
+ next_latent, reward_logits = model.dynamics(latent, now.action)
105
+ value_logits, policy_logits = model.prediction(next_latent)
106
+ losses = {
107
+ "reward": muzero.masked_mean(muzero.cross_entropy(reward_logits, now.reward_probs), now.in_episode),
108
+ "value": muzero.masked_mean(muzero.cross_entropy(value_logits, next_.value_probs), next_.in_episode),
109
+ "policy": muzero.masked_mean(muzero.cross_entropy(policy_logits, next_.policy_probs), next_.in_episode),
110
+ }
111
+ return next_latent, losses, {}
112
+
113
+ return muzero.loss(graphdef, config, params, batch, unroll_step=unroll_step, loss_weights={"value": config.value_loss_weight})
114
+
115
+
116
+ GMZ = Algorithm("gmz", GMZConfig, GMZModel, make_policy, loss, muzero.make_value_fn)