zeroRl 0.1.1__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.
zerorl/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,9 @@
1
+ """Proximal Policy Optimization algorithm.
2
+
3
+ Re-exports gae_compute, ppo_func, and easy_train_ppo.
4
+ """
5
+
6
+ from .ppo import gae_compute, ppo_func
7
+ from .easy_ppo import easy_train_ppo
8
+
9
+ __all__ = ["gae_compute", "ppo_func", "easy_train_ppo"]
@@ -0,0 +1,70 @@
1
+ """One-call PPO quickstart.
2
+
3
+ Provides easy_train_ppo() which wires together agent, environment, buffer,
4
+ and PPO update into a ready-to-train BaseTrain instance.
5
+ """
6
+
7
+ from typing import Callable
8
+ from torch import optim
9
+ from zerorl.factory import get_env, get_actor_critic_buffer, ActorCriticAgent
10
+ from zerorl.train import BaseTrain
11
+ from zerorl.algorithms.ppo import ppo_func, gae_compute
12
+ from zerorl.config import TrainConfig, AlgoConfig
13
+ from zerorl.helpers.agent import BaseAgent
14
+ from zerorl.helpers.env import BaseEnv
15
+ from zerorl.functions import get_obs_act
16
+
17
+ def easy_train_ppo(env_spec: str | Callable | BaseEnv,
18
+ config: TrainConfig,
19
+ algo_config: AlgoConfig,
20
+ hidden_layer: int = 64,
21
+ render_mode: str | None = None,
22
+ base_agent: BaseAgent | None = None,
23
+ optimizer: optim.Optimizer | None = None,
24
+ schedule_func: Callable[[int], float] | None = None,
25
+ ):
26
+ """Create and return a BaseTrain instance with PPO wiring.
27
+
28
+ Automatically builds the environment, agent, buffer, and update function.
29
+
30
+ Args:
31
+ env_spec: Gymnasium env ID string, BaseEnv subclass/instance, or callable.
32
+ config: Training configuration (device, paths, timesteps).
33
+ algo_config: Algorithm hyperparameters (lr, gamma, clip_eps, etc.).
34
+ hidden_layer: Hidden layer size for the default ActorCriticAgent.
35
+ render_mode: Render mode for the environment.
36
+ base_agent: Custom agent (overrides the default ActorCriticAgent).
37
+ optimizer: Custom optimizer (overrides default Adam).
38
+ schedule_func: Custom LR schedule function (overrides default linear decay).
39
+
40
+ Returns:
41
+ BaseTrain instance ready for .train() or .test().
42
+ """
43
+ env = get_env(env_spec, config.num_envs, render_mode)
44
+ obs_dim, act_dim, n_obs, n_act, is_discrete = get_obs_act(env)
45
+
46
+ if base_agent is not None:
47
+ agent = base_agent
48
+ else:
49
+ agent = ActorCriticAgent(n_obs, n_act, is_discrete, hidden_layer) #type: ignore
50
+
51
+ buffer = get_actor_critic_buffer(obs_dim, act_dim, config) #type: ignore
52
+
53
+ #update weights function
54
+ def easy_update_weights(agent, buffer, scheduler, optimizer, last_output, algo_config):
55
+ data = buffer.get_all()
56
+ gae_compute(data["reward"], data["value"], last_output["value"], data["done"], buffer, algo_config)
57
+ return ppo_func(agent, optimizer, buffer, algo_config, scheduler, device=agent.device)
58
+
59
+ train = BaseTrain(
60
+ agent = agent,
61
+ env = env,
62
+ buffer = buffer,
63
+ update_weights = easy_update_weights,
64
+ config = config,
65
+ algo_config = algo_config,
66
+ optimizer = optimizer,
67
+ schedule_func = schedule_func,
68
+ render_mode = render_mode,
69
+ )
70
+ return train
@@ -0,0 +1,232 @@
1
+ """Proximal Policy Optimization (PPO) standalone functions.
2
+
3
+ Provides gae_compute(), ppo_loss(), and ppo_func() for computing GAE
4
+ advantages, computing the clipped surrogate loss, and running the PPO
5
+ optimization step.
6
+ Reference: Schulman et al., "Proximal Policy Optimization Algorithms" (2017)
7
+ """
8
+
9
+ import torch
10
+ from typing import Callable
11
+ from torch import nn
12
+ from torch import Tensor
13
+ from torch.optim import Optimizer
14
+ from torch.optim.lr_scheduler import LambdaLR
15
+ from zerorl.buffer import Buffer
16
+ from zerorl.helpers.agent import BaseAgent, eval_action
17
+ from zerorl.config import AlgoConfig
18
+ from zerorl.functions import get_buffer_params_model, fast_compile
19
+ from zerorl.errors import assert_agent_contract
20
+
21
+
22
+ def gae_compute(rewards: Tensor,
23
+ values: Tensor,
24
+ last_value: Tensor,
25
+ dones: Tensor,
26
+ buffer:Buffer,
27
+ algo_config: AlgoConfig):
28
+ """Compute Generalized Advantage Estimation.
29
+
30
+ Works backwards through the trajectory, accumulating TD errors
31
+ with exponentially decaying weights. Writes "advantage" and "return"
32
+ directly into buffer.data.
33
+
34
+ Args:
35
+ rewards: Rewards for each timestep, shape (T, num_envs).
36
+ values: Value estimates for each timestep, shape (T, num_envs).
37
+ last_value: Bootstrap value for the state after the last step, shape (num_envs,).
38
+ dones: Episode termination flags, shape (T, num_envs). 1.0 = done.
39
+ buffer: Buffer to write "advantage" and "return" into.
40
+ algo_config: Algorithm configuration (gamma, gae_lambda).
41
+ """
42
+ rewards = rewards.reshape(rewards.shape[0], -1)
43
+ values = values.reshape(values.shape[0], -1)
44
+ dones = dones.reshape(dones.shape[0], -1)
45
+ last_value = last_value.reshape(-1)
46
+ num_envs = rewards.shape[1]
47
+ gae = torch.zeros(num_envs, dtype=torch.float32, device=rewards.device)
48
+ # Mask: 0.0 at episode boundaries (no bootstrapping across episodes)
49
+ mask = 1.0 - dones
50
+ next_values = torch.cat((values[1:], last_value.unsqueeze(0)), 0)
51
+ total_size = rewards.shape[0]
52
+ delta = rewards + algo_config.gamma * next_values * mask - values
53
+ advantages = torch.empty_like(delta)
54
+ for step in reversed(range(total_size)):
55
+ gae = delta[step] + algo_config.gamma * algo_config.gae_lambda * mask[step] * gae
56
+ advantages[step] = gae
57
+ returns = advantages + values
58
+ buffer.data["advantage"][:buffer.size] = advantages
59
+ buffer.data["return"][:buffer.size] = returns
60
+
61
+
62
+ def ppo_loss(
63
+ agent: BaseAgent,
64
+ params: dict,
65
+ buffers: dict,
66
+ states: Tensor,
67
+ actions: Tensor,
68
+ old_log_prob: Tensor,
69
+ old_values: Tensor,
70
+ advantages: Tensor,
71
+ returns: Tensor,
72
+ ent_coef: float,
73
+ value_coef: float,
74
+ clip_eps: float,
75
+ clip_vf: float,
76
+ ) -> dict[str, Tensor]:
77
+ """Compute PPO clipped surrogate loss, value loss, and entropy bonus.
78
+
79
+ Args:
80
+ agent: The policy network.
81
+ params: Named parameters dict from get_buffer_params_model().
82
+ buffers: Named buffers dict from get_buffer_params_model().
83
+ states: Batch of observations.
84
+ actions: Batch of actions taken.
85
+ old_log_prob: Log probabilities from the old policy.
86
+ old_values: Value estimates from the old policy.
87
+ advantages: GAE advantage estimates.
88
+ returns: GAE return estimates.
89
+ ent_coef: Entropy bonus coefficient.
90
+ value_coef: Value loss coefficient.
91
+ clip_eps: PPO clipping range.
92
+ clip_vf: Whether to clip value predictions.
93
+
94
+ Returns:
95
+ Dict with keys "loss", "policy_loss", "value_loss", "entropy_loss".
96
+ """
97
+ logits, new_values = torch.func.functional_call(agent, (params, buffers), (states,))
98
+ dist = agent.build_distribution(logits) #type: ignore[operator]
99
+ new_log_probs, dist_entropy = eval_action(dist, actions)
100
+
101
+ idx_adv = advantages.view(-1)
102
+ idx_return = returns.view(-1)
103
+ new_values = new_values.view(-1)
104
+ old_values = old_values.view(-1)
105
+ old_log_prob = old_log_prob.view(-1)
106
+
107
+ logratio = new_log_probs - old_log_prob
108
+ ratio = torch.exp(logratio)
109
+
110
+ clip_eps = clip_eps
111
+ surr1 = ratio * idx_adv
112
+ surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * idx_adv
113
+ policy_loss = -torch.min(surr1, surr2).mean()
114
+
115
+ clip_vf = clip_vf
116
+ if clip_vf:
117
+ value_pred_clipped = old_values + (new_values - old_values).clamp(-clip_eps, clip_eps)
118
+ value_loss = 0.5 * torch.max((idx_return - new_values).pow(2), (value_pred_clipped - idx_return).pow(2)).mean()
119
+ else:
120
+ value_loss = 0.5 * nn.functional.mse_loss(new_values, idx_return)
121
+
122
+ entropy_loss = dist_entropy.mean()
123
+
124
+ loss = policy_loss + \
125
+ (value_coef * value_loss) - \
126
+ (ent_coef * entropy_loss)
127
+ return {'loss': loss,
128
+ 'policy_loss': policy_loss,
129
+ 'value_loss': value_loss,
130
+ 'entropy_loss':entropy_loss}
131
+
132
+
133
+ def ppo_func(agent: BaseAgent,
134
+ optimizer: Optimizer,
135
+ buffer: Buffer,
136
+ algo_config: AlgoConfig,
137
+ scheduler: LambdaLR,
138
+ *,
139
+ ppo_loss_func: Callable[[BaseAgent, dict, dict, Tensor, Tensor,
140
+ Tensor, Tensor, Tensor, Tensor, float,
141
+ float, float, float], dict[str, Tensor]] = ppo_loss,
142
+ device: torch.device = torch.device("cpu")
143
+ ) -> dict[str, Tensor]:
144
+ """Run a full PPO update on collected rollout data.
145
+
146
+ Steps the scheduler first, then normalizes advantages and runs
147
+ minibatch SGD epochs with clipped surrogate loss and gradient clipping.
148
+
149
+ Args:
150
+ agent: The policy network.
151
+ optimizer: Optimizer for the agent parameters.
152
+ buffer: Buffer containing rollout data with keys "state", "action",
153
+ "log_prob", "value", "advantage", "return".
154
+ algo_config: Algorithm configuration (batch_size, epochs, clip_eps, etc.).
155
+ scheduler: Learning rate scheduler (stepped once per call).
156
+ device: Torch device for computations.
157
+
158
+ Returns:
159
+ Dict of averaged loss metrics ("loss", "policy_loss", "value_loss", "entropy_loss").
160
+ """
161
+ assert_agent_contract(agent,
162
+ {"forward": "Your agent should have the method `forward`",
163
+ "build_distribution": "Your agent should have the method `build_distribution`"})
164
+
165
+ params, buffers = get_buffer_params_model(agent)
166
+ all_data = buffer.get_all()
167
+ flat_data = {key: tensor.reshape(-1, *tensor.shape[2:])
168
+ for key, tensor in all_data.items()}
169
+
170
+ mb_advantages = flat_data["advantage"]
171
+ adv_norm = (mb_advantages - mb_advantages.mean()) / (mb_advantages.std() + 1e-8)
172
+ returns = flat_data["return"]
173
+
174
+ #coefficient and eps for clipping
175
+ value_coef = algo_config.value_coef
176
+ ent_coef = algo_config.ent_coef
177
+ clip_eps = algo_config.clip_eps
178
+ clip_vf = getattr(algo_config, "clip_vf", False)
179
+
180
+ dataset_size = flat_data["action"].size(0)
181
+ final_metrics: dict[str, Tensor] = {}
182
+
183
+ @fast_compile(mode="reduce-overhead") #type: ignore
184
+ def ppo_backward(agent: BaseAgent,
185
+ params: dict,
186
+ buffers: dict,
187
+ state: Tensor,
188
+ action: Tensor,
189
+ old_log_prob: Tensor,
190
+ old_values: Tensor,
191
+ advantage: Tensor,
192
+ return_: Tensor) -> dict[str, Tensor]:
193
+ global_losses = ppo_loss_func(agent, params, buffers, state, action,
194
+ old_log_prob, old_values, advantage, return_,
195
+ value_coef, ent_coef, clip_eps, clip_vf)
196
+ loss_tensor = global_losses["loss"]
197
+ loss_tensor.backward()
198
+ return global_losses
199
+
200
+ def update() -> list[dict[str, Tensor]]:
201
+ """Run multiple epochs of minibatch SGD on rollout data.
202
+
203
+ Shuffles data each epoch, splits into minibatches, and updates weights
204
+ with gradient clipping.
205
+ """
206
+ history = []
207
+ for _ in range(algo_config.epochs):
208
+ shuffle_index = torch.randperm(dataset_size, device=device)
209
+ for start in range(0, dataset_size, algo_config.batch_size):
210
+ end = start + algo_config.batch_size
211
+ idx = shuffle_index[start:end]
212
+ optimizer.zero_grad(set_to_none=True)
213
+ torch.compiler.cudagraph_mark_step_begin()
214
+ global_losses: dict[str, Tensor] = ppo_backward(agent, params, buffers, flat_data["state"][idx],
215
+ flat_data["action"][idx], flat_data["log_prob"][idx],
216
+ flat_data["value"][idx], adv_norm[idx], returns[idx])
217
+ torch.nn.utils.clip_grad_norm_(agent.parameters(), 0.5)
218
+ optimizer.step()
219
+ history.append({k: v.clone().detach() for k, v in global_losses.items()})
220
+ return history
221
+
222
+ # Compute losses and update weights
223
+ history = update()
224
+ scheduler.step()
225
+ # Return average losses over all actual updates ([:index_loss] excludes
226
+ # any unused pre-allocated entries)
227
+ if history:
228
+ keys = history[0].keys()
229
+ for key in keys:
230
+ stacked = torch.stack([h[key] for h in history])
231
+ final_metrics[key] = stacked.mean()
232
+ return final_metrics
zerorl/buffer.py ADDED
@@ -0,0 +1,80 @@
1
+ """Pre-allocated rollout buffer for RL training.
2
+
3
+ Provides Buffer, which stores trajectory data in fixed-size PyTorch
4
+ tensors and converts them for the PPO update step.
5
+ """
6
+
7
+ import torch
8
+ from zerorl.errors import KeyBufferError
9
+ from zerorl.config import TrainConfig
10
+
11
+
12
+ class Buffer:
13
+ """Pre-allocated rollout buffer for collecting RL trajectory data.
14
+
15
+ Stores trajectory data in pre-allocated PyTorch tensors with a slice
16
+ pointer for O(1) insertion. gae_compute() writes advantage and return
17
+ directly into the buffer, then use get_all() to retrieve everything as
18
+ PyTorch tensors for the PPO update step.
19
+
20
+ Example:
21
+ buf = Buffer(step=2048, data={"state": (4,), "action": ()})
22
+ for _ in range(2048):
23
+ buf.insert(state=..., action=..., reward=..., ...)
24
+ tensors = buf.get_all()
25
+ buf.clear()
26
+ """
27
+
28
+ def __init__(self,
29
+ data: dict[str, tuple],
30
+ config: TrainConfig):
31
+ """Initialize pre-allocated arrays.
32
+
33
+ Args:
34
+ step: Maximum number of timesteps (capacity).
35
+ data: Dict mapping field names to shape tuples (e.g. {"state": (4,), "action": ()}).
36
+ device: Torch device to allocate tensors on.
37
+ """
38
+ self.step = config.rollout_steps
39
+ self.num_envs = config.num_envs
40
+ self.slice: int = 0
41
+ self.data = {
42
+ name: torch.zeros((self.step, self.num_envs, *shape), dtype = torch.float32, device = config.device)
43
+ for name, shape in data.items()
44
+ }
45
+
46
+ @property
47
+ def size(self): return self.slice
48
+
49
+ def insert(self, **kwargs):
50
+ """Insert one timestep of data into the buffer.
51
+
52
+ Args:
53
+ **kwargs: Keyword arguments matching the keys in self.data.
54
+
55
+ Raises:
56
+ ValueError: If the buffer is full.
57
+ KeyBufferError: If a key doesn't exist in the buffer.
58
+ """
59
+ if self.slice >= self.step:
60
+ raise ValueError(f"Buffer is full (size={self.step}). Cannot insert more data.")
61
+ for name, val in kwargs.items():
62
+ if name in self.data:
63
+ self.data[name][self.slice] = val
64
+ else:
65
+ raise KeyBufferError(name, kwargs)
66
+
67
+ self.slice += 1
68
+
69
+
70
+ def get_all(self) -> dict[str, torch.Tensor]:
71
+ """Return all inserted data as a dict of sliced tensors."""
72
+ return {name: val[:self.slice] for name, val in self.data.items()}
73
+
74
+ def clear(self):
75
+ """Reset the buffer for reuse.
76
+
77
+ Resets the slice pointer to 0. Underlying arrays are not zeroed;
78
+ old data is overwritten by subsequent insert() calls.
79
+ """
80
+ self.slice = 0
zerorl/config.py ADDED
@@ -0,0 +1,87 @@
1
+ """Configuration dataclasses for RL training.
2
+
3
+ Provides AlgoConfig (mutable hyperparameters) and TrainConfig (mutable
4
+ training settings with computed fields).
5
+ """
6
+
7
+ import torch
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class TrainConfig:
13
+ """Training configuration with computed fields.
14
+
15
+ model_path and num_update are derived in __post_init__(); do not
16
+ pass them to the constructor.
17
+
18
+ Attributes:
19
+ model_name: Model name (used in the saved file path).
20
+ model_save_path: Directory for model checkpoints.
21
+ device: PyTorch device (auto-detects CUDA).
22
+ model_path: Computed as "{model_save_path}/{model_name}.pt".
23
+ timestamp: Total environment timesteps for training.
24
+ rollout_steps: Steps collected before each PPO update.
25
+ num_update: Computed as timestamp // (rollout_steps * num_envs).
26
+ """
27
+ model_name: str
28
+ project_name: str
29
+ model_save_path: str = ".checkpoints"
30
+ timestamp: int = 1_000_000
31
+ rollout_steps: int = 2048
32
+ num_envs: int = 1
33
+ normalize: bool = False
34
+ profile: bool = False
35
+
36
+ device: torch.device = field(init=False)
37
+ num_update: int = field(init=False)
38
+ model_path: str = field(init=False)
39
+
40
+ def __post_init__(self) -> None:
41
+ self.device: torch.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
42
+ self.model_path = f"{self.model_save_path}/{self.model_name}.pt"
43
+ self.num_update = self.timestamp // (self.rollout_steps * self.num_envs)
44
+ if self.num_update <= 0:
45
+ raise ValueError(f"num_update must be greater than 0, got {self.num_update}")
46
+
47
+
48
+ @dataclass(init=False)
49
+ class AlgoConfig:
50
+ """Mutable algorithm hyperparameters for PPO and off-policy methods.
51
+
52
+ Attributes:
53
+ lr: Learning rate.
54
+ gamma: Discount factor.
55
+ batch_size: Minibatch size for PPO updates.
56
+ gae_lambda: GAE lambda.
57
+ clip_eps: PPO clipping range.
58
+ ent_coef: Entropy bonus coefficient.
59
+ value_coef: Value loss coefficient.
60
+ epochs: PPO epochs per update.
61
+ tau: Soft update coefficient (off-policy).
62
+ """
63
+
64
+ lr: float = 3e-4
65
+ gamma: float = 0.99
66
+ batch_size: int = 64
67
+
68
+ # For on-policy
69
+ gae_lambda: float = 0.95
70
+ clip_eps: float = 0.2
71
+ ent_coef: float = 0.01
72
+ value_coef: float = 0.5
73
+ epochs: int = 10
74
+
75
+ # For off-policy
76
+ tau: float = 0.005
77
+
78
+ def __init__(self, **kwargs):
79
+ for key in self.__annotations__:
80
+ setattr(self, key, getattr(self.__class__, key, None))
81
+
82
+ for key, value in kwargs.items():
83
+ setattr(self, key, value)
84
+
85
+ def to_dict(self) -> dict:
86
+ """Return all hyperparameters as a dictionary."""
87
+ return self.__dict__
zerorl/errors.py ADDED
@@ -0,0 +1,59 @@
1
+ """Custom exceptions for the zerorl project."""
2
+
3
+ from zerorl.helpers.agent import BaseAgent
4
+
5
+
6
+ def assert_agent_contract(agent: BaseAgent, attr_search: dict[str, str]):
7
+ """
8
+ Check if attributes exist in Agent
9
+ agent: Agent
10
+ attr_search: Key -> Attribute Name
11
+ Value -> Message Error
12
+ """
13
+ for attr, message in attr_search.items():
14
+ if not hasattr(agent, attr):
15
+ raise NotImplementedError(message)
16
+
17
+
18
+ class EmptyBufferError(Exception):
19
+ """Raised when an update is attempted on an insufficiently filled buffer.
20
+
21
+ Attributes:
22
+ current_size: Number of entries currently in the buffer.
23
+ require_buffer_size: Minimum entries required for an update.
24
+ """
25
+
26
+ def __init__(self, current_size: int, require_buffer_size: int):
27
+ self.current_size = current_size
28
+ self.require_buffer_size = require_buffer_size
29
+
30
+ self.message = "Training agent flow is incorrect: the buffer is empty"
31
+ super().__init__(self.message)
32
+
33
+ def __str__(self) -> str:
34
+ """Return a detailed error message with context and suggestion."""
35
+ suggestion = "Call rollout_phase() before update_weights()"
36
+ details = f"[Crash Workflow] {self.message}\n"
37
+ details += f"the current buffer size: {self.current_size}\n"
38
+ details += f" the minimum size required is: {self.require_buffer_size}\n"
39
+ details += f"{suggestion}"
40
+ return details
41
+
42
+
43
+ class KeyBufferError(Exception):
44
+ """Raised when the argument name does not exist in the data buffer.
45
+
46
+ Attributes:
47
+ arg_name: the argument name.
48
+ data_buffer: the data buffer
49
+ """
50
+
51
+ def __init__(self, arg_name: str, data_buffer: dict[str, object]):
52
+ self.arg_name = arg_name
53
+ self.data_buffer = data_buffer
54
+
55
+ def __str__(self) -> str:
56
+ details = f"Key '{self.arg_name}' (returned by agent.get_action) does not exist in the Buffer.\n"
57
+ details += f"Please ensure your Buffer is initialized with the key '{self.arg_name}'\n"
58
+ details += f"Current valid keys are: {list(self.data_buffer.keys())}"
59
+ return details
zerorl/factory.py ADDED
@@ -0,0 +1,121 @@
1
+ """Factory helpers for creating environments, buffers, and agents.
2
+
3
+ Provides convenience functions that wire together common RL components.
4
+ """
5
+
6
+ import numpy as np
7
+ import torch
8
+ from typing import Callable
9
+ from torch import Tensor
10
+ from zerorl.functions import vectorize_env
11
+ from zerorl.helpers.agent import BaseAgent, eval_action
12
+ from zerorl.helpers.env import BaseEnv
13
+ from zerorl.buffer import Buffer
14
+ from zerorl.config import TrainConfig
15
+ from torch import nn
16
+
17
+
18
+ def get_env(env_id: str | Callable | BaseEnv, num_envs: int = 1, render_mode: str | None= None):
19
+ """Create a vectorized environment from a spec, class, or instance.
20
+
21
+ Args:
22
+ env_id: Gymnasium env ID string, BaseEnv class/instance, or callable.
23
+ num_envs: Number of parallel environments.
24
+ render_mode: Render mode for the environment.
25
+
26
+ Returns:
27
+ SyncVectorEnv with SAME_STEP autoreset.
28
+ """
29
+ return vectorize_env(env_id, num_envs, render_mode)
30
+
31
+ def get_actor_critic_buffer(state_space: tuple, action_space: tuple, config: TrainConfig):
32
+ """Create a Buffer with standard PPO field names.
33
+
34
+ Args:
35
+ state_space: Observation shape tuple, e.g. (4,) for a 4-dim vector.
36
+ action_space: Action shape tuple, e.g. () for discrete or (n,) for continuous.
37
+ config: Training config providing rollout_steps, num_envs, and device.
38
+
39
+ Returns:
40
+ Buffer pre-allocated with keys: state, action, reward, done, truncated,
41
+ entropy, value, return, log_prob, advantage.
42
+ """
43
+ buffer = Buffer(data = {"state": state_space, "action": action_space,
44
+ "reward": (), "done": (), "truncated": (), "entropy": (),
45
+ "value": (), "return": (), "log_prob": (), "advantage": ()},
46
+ config=config)
47
+ return buffer
48
+
49
+ class ActorCriticAgent(BaseAgent):
50
+ """Built-in actor-critic agent with orthogonal initialization.
51
+
52
+ Supports both discrete (Categorical) and continuous (Normal) action spaces.
53
+ Uses a shared 2-layer Tanh MLP feature extractor.
54
+
55
+ Args:
56
+ input_dim: Observation dimension.
57
+ output_dim: Action dimension (n for discrete, dim for continuous).
58
+ is_discrete: Whether the action space is discrete.
59
+ hidden_dim: Hidden layer size (default 64).
60
+ """
61
+ def __init__(self, input_dim: int, output_dim: int, is_discrete: bool, hidden_dim: int = 64):
62
+ super().__init__()
63
+
64
+ self.is_discrete = is_discrete
65
+ self.hidden_dim = hidden_dim
66
+ self.input_dim = input_dim
67
+ self.output_dim = output_dim
68
+
69
+ # Feature Extractor
70
+ self.extract_layer = nn.Sequential(
71
+ nn.Linear(self.input_dim, self.hidden_dim),
72
+ nn.Tanh(),
73
+ nn.Linear(self.hidden_dim, self.hidden_dim),
74
+ nn.Tanh()
75
+ )
76
+ # Actor
77
+ self.actor = nn.Linear(self.hidden_dim, self.output_dim)
78
+ # Critic
79
+ self.critic = nn.Linear(self.hidden_dim, 1)
80
+
81
+ if not is_discrete:
82
+ self.log_std = nn.Parameter(torch.zeros(output_dim))
83
+
84
+ self.apply(self._orthogonal_init)
85
+
86
+
87
+ def _orthogonal_init(self, module: nn.Module):
88
+ """Apply orthogonal weight initialization with gain based on layer role."""
89
+ if isinstance(module, nn.Linear):
90
+ if module.out_features == self.hidden_dim:
91
+ nn.init.orthogonal_(module.weight, gain=np.sqrt(2))
92
+ elif module.out_features == 1:
93
+ nn.init.orthogonal_(module.weight, gain=1.0)
94
+ else:
95
+ nn.init.orthogonal_(module.weight, gain=0.01)
96
+
97
+ if module.bias is not None:
98
+ nn.init.constant_(module.bias, 0.0)
99
+
100
+ def forward(self, state: Tensor):
101
+ """Forward pass returning (logits, value)."""
102
+ x = self.extract_layer(state)
103
+ logits = self.actor(x)
104
+ value = self.critic(x)
105
+ return (logits, value)
106
+
107
+ def build_distribution(self, logits: torch.Tensor):
108
+ """Build a torch distribution from logits (Categorical or Normal)."""
109
+ if self.is_discrete:
110
+ return torch.distributions.Categorical(logits=logits)
111
+ log_std_clamped = torch.clamp(self.log_std, min=-3.0, max=1.0)
112
+ std = log_std_clamped.exp().expand_as(logits)
113
+ return torch.distributions.Normal(logits, std)
114
+
115
+ def get_action(self, state: torch.Tensor, action: torch.Tensor | None = None):
116
+ """Sample or evaluate an action, returning action, log_prob, entropy, value."""
117
+ logits, value = self.forward(state)
118
+ dist = self.build_distribution(logits)
119
+ if action is None: action = dist.sample()
120
+ log_prob, dist_entropy = eval_action(dist, action)
121
+ return {"action": action, "log_prob": log_prob, "entropy":dist_entropy, "value":value}