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 +0 -0
- zerorl/algorithms/__init__.py +0 -0
- zerorl/algorithms/ppo/__init__.py +9 -0
- zerorl/algorithms/ppo/easy_ppo.py +70 -0
- zerorl/algorithms/ppo/ppo.py +232 -0
- zerorl/buffer.py +80 -0
- zerorl/config.py +87 -0
- zerorl/errors.py +59 -0
- zerorl/factory.py +121 -0
- zerorl/functions.py +117 -0
- zerorl/helpers/agent.py +48 -0
- zerorl/helpers/env.py +59 -0
- zerorl/processing.py +77 -0
- zerorl/train.py +397 -0
- zerorl-0.1.1.dist-info/METADATA +349 -0
- zerorl-0.1.1.dist-info/RECORD +18 -0
- zerorl-0.1.1.dist-info/WHEEL +4 -0
- zerorl-0.1.1.dist-info/licenses/LICENSE +21 -0
zerorl/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -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}
|