rl-template 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.
- rl_template/__init__.py +0 -0
- rl_template/agent.py +69 -0
- rl_template/algorithms/__init__.py +0 -0
- rl_template/algorithms/ppo/__init__.py +0 -0
- rl_template/algorithms/ppo/ppo.py +183 -0
- rl_template/common.py +119 -0
- rl_template/config.py +73 -0
- rl_template/env.py +52 -0
- rl_template/errors.py +26 -0
- rl_template/train.py +127 -0
- rl_template-0.1.0.dist-info/METADATA +145 -0
- rl_template-0.1.0.dist-info/RECORD +13 -0
- rl_template-0.1.0.dist-info/WHEEL +4 -0
rl_template/__init__.py
ADDED
|
File without changes
|
rl_template/agent.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Abstract base agent interface for reinforcement learning.
|
|
2
|
+
|
|
3
|
+
Provides BaseAgent, an ABC + nn.Module hybrid that enforces a consistent
|
|
4
|
+
policy/value interface for all RL agent implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch.nn as nn
|
|
9
|
+
from torch.distributions import Distribution
|
|
10
|
+
from torch import Tensor
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseAgent(ABC, nn.Module):
|
|
15
|
+
"""Abstract base class for all RL agents.
|
|
16
|
+
|
|
17
|
+
Subclasses must implement forward() and get_distribution(). The
|
|
18
|
+
get_action() template method composes those two to sample actions,
|
|
19
|
+
compute log probabilities, and return critic values.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def forward(self, state: np.ndarray) -> tuple[Tensor, Tensor]:
|
|
27
|
+
"""Compute raw policy logits and value estimate.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
state: Environment observation.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Tuple of (policy_logits, value) tensors.
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_distribution(self, state: np.ndarray) -> tuple[Distribution, Tensor]:
|
|
39
|
+
"""Build a distribution over actions for the given state.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
state: Environment observation.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple of (distribution, value) where distribution is a
|
|
46
|
+
torch.distributions.Distribution instance.
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def get_action(self, state: np.ndarray, action: int | None = None) -> tuple[Tensor, Tensor, Tensor, Tensor]:
|
|
51
|
+
"""Sample or evaluate an action under the current policy.
|
|
52
|
+
|
|
53
|
+
Template method: calls get_distribution(), then samples if no
|
|
54
|
+
action is provided.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
state: Environment observation.
|
|
58
|
+
action: Optional pre-selected action. When None, samples from
|
|
59
|
+
the policy distribution.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (action, log_prob, entropy, value).
|
|
63
|
+
"""
|
|
64
|
+
dist, value = self.get_distribution(state)
|
|
65
|
+
if action is None:
|
|
66
|
+
action = dist.sample()
|
|
67
|
+
log_prob = dist.log_prob(action)
|
|
68
|
+
dist_entropy = dist.entropy()
|
|
69
|
+
return (action, log_prob, dist_entropy, value)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Proximal Policy Optimization (PPO) trainer implementation.
|
|
2
|
+
|
|
3
|
+
Provides PPOTrainer, which computes GAE advantages and runs the clipped
|
|
4
|
+
surrogate loss optimization with linear learning rate decay.
|
|
5
|
+
|
|
6
|
+
Reference: Schulman et al., "Proximal Policy Optimization Algorithms" (2017)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
import torch.nn as nn
|
|
11
|
+
from torch import optim
|
|
12
|
+
import numpy as np
|
|
13
|
+
from ...common import Buffer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PPOTrainer:
|
|
17
|
+
"""PPO trainer handling advantage computation and policy updates.
|
|
18
|
+
|
|
19
|
+
Maintains an Adam optimizer and applies linear LR decay over training.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
model: Neural network with a get_action(state, action) method.
|
|
23
|
+
lr: Initial learning rate.
|
|
24
|
+
gamma: Discount factor.
|
|
25
|
+
gae_lambda: GAE lambda (bias-variance tradeoff).
|
|
26
|
+
clip_eps: PPO clipping parameter.
|
|
27
|
+
value_coef: Value loss coefficient.
|
|
28
|
+
ent_coef: Entropy bonus coefficient.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self,
|
|
32
|
+
model: nn.Module,
|
|
33
|
+
lr: float = 3e-4,
|
|
34
|
+
gamma: float = 0.99,
|
|
35
|
+
gae_lambda: float = 0.95,
|
|
36
|
+
clip_eps: float = 0.2,
|
|
37
|
+
value_coef: float = 0.5,
|
|
38
|
+
ent_coef: float = 0.01):
|
|
39
|
+
|
|
40
|
+
self.model = model
|
|
41
|
+
self.lr = lr
|
|
42
|
+
self.optimizer = optim.Adam(model.parameters(), lr=self.lr)
|
|
43
|
+
self.gamma = gamma
|
|
44
|
+
self.gae_lambda = gae_lambda
|
|
45
|
+
self.clip_eps = clip_eps
|
|
46
|
+
self.value_coef = value_coef
|
|
47
|
+
self.ent_coef = ent_coef
|
|
48
|
+
self.mse_loss = nn.MSELoss()
|
|
49
|
+
|
|
50
|
+
def compute_gae(self,
|
|
51
|
+
rewards: np.ndarray,
|
|
52
|
+
values: np.ndarray,
|
|
53
|
+
last_value: float,
|
|
54
|
+
dones: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
55
|
+
"""Compute Generalized Advantage Estimation.
|
|
56
|
+
|
|
57
|
+
Works backwards through the trajectory, accumulating TD errors
|
|
58
|
+
with exponentially decaying weights.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
rewards: Rewards for each timestep, shape (T,).
|
|
62
|
+
values: Value estimates for each timestep, shape (T,).
|
|
63
|
+
last_value: Bootstrap value for the state after the last step.
|
|
64
|
+
dones: Episode termination flags, shape (T,). 1.0 = done.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Tuple of (returns, advantages, deltas), each shape (T,).
|
|
68
|
+
"""
|
|
69
|
+
gae = 0.0
|
|
70
|
+
# Mask: 0.0 at episode boundaries (no bootstrapping across episodes)
|
|
71
|
+
mask = 1.0 - dones
|
|
72
|
+
next_values = np.concatenate((values[1:], [last_value]), axis=0)
|
|
73
|
+
total_size = rewards.shape[0]
|
|
74
|
+
advantages = np.zeros_like(rewards)
|
|
75
|
+
|
|
76
|
+
delta = rewards + self.gamma * next_values * mask - values
|
|
77
|
+
|
|
78
|
+
for step in reversed(range(total_size)):
|
|
79
|
+
gae = delta[step] + self.gamma * self.gae_lambda * mask[step] * gae
|
|
80
|
+
advantages[step] = gae
|
|
81
|
+
|
|
82
|
+
returns = advantages + values
|
|
83
|
+
return (returns, advantages, delta)
|
|
84
|
+
|
|
85
|
+
def lr_decay(self, lr: float, total_steps: int, step: int):
|
|
86
|
+
"""Apply linear learning rate decay from lr to 0.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
lr: Base learning rate.
|
|
90
|
+
total_steps: Total number of training steps.
|
|
91
|
+
step: Current training step.
|
|
92
|
+
"""
|
|
93
|
+
frac = 1.0 - (step / total_steps)
|
|
94
|
+
current_lr = lr * frac
|
|
95
|
+
for param_group in self.optimizer.param_groups:
|
|
96
|
+
param_group["lr"] = current_lr
|
|
97
|
+
|
|
98
|
+
def update(self, memory: Buffer, total_steps: int, step: int,
|
|
99
|
+
batch_size: int = 64, epochs: int = 10) -> tuple:
|
|
100
|
+
"""Run a PPO update on collected rollout data.
|
|
101
|
+
|
|
102
|
+
Normalizes advantages and returns, then runs multiple epochs of
|
|
103
|
+
minibatch SGD with the clipped surrogate loss.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
memory: Buffer containing rollout data.
|
|
107
|
+
total_steps: Total training steps (LR decay denominator).
|
|
108
|
+
step: Current training step (LR decay numerator).
|
|
109
|
+
batch_size: Minibatch size.
|
|
110
|
+
epochs: Number of passes over the data.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Tuple of (total_loss, policy_loss, value_loss, entropy),
|
|
114
|
+
each averaged over all minibatch updates.
|
|
115
|
+
"""
|
|
116
|
+
self.lr_decay(self.lr, total_steps, step)
|
|
117
|
+
|
|
118
|
+
states, actions, old_log_probs, returns, adv, _, _, _ = memory.get_all()
|
|
119
|
+
|
|
120
|
+
advantages = (adv - adv.mean()) / (adv.std() + 1e-8)
|
|
121
|
+
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
|
|
122
|
+
|
|
123
|
+
dataset_size = actions.size(0)
|
|
124
|
+
num_batch = dataset_size // batch_size
|
|
125
|
+
|
|
126
|
+
size_total = int((dataset_size / batch_size) * epochs)
|
|
127
|
+
epoch_losses = torch.zeros((size_total), dtype=torch.float32)
|
|
128
|
+
epoch_pi_losses = torch.zeros((size_total))
|
|
129
|
+
epoch_v_losses = torch.zeros((size_total))
|
|
130
|
+
epoch_entropies = torch.zeros((size_total))
|
|
131
|
+
index_loss = 0
|
|
132
|
+
|
|
133
|
+
batch_rollout = torch.arange(0, dataset_size, batch_size)
|
|
134
|
+
|
|
135
|
+
for _ in range(epochs):
|
|
136
|
+
shuffle_index = batch_rollout[torch.randperm(num_batch)]
|
|
137
|
+
|
|
138
|
+
for start in shuffle_index:
|
|
139
|
+
end = start + batch_size
|
|
140
|
+
idx = torch.arange(start, end)
|
|
141
|
+
if idx.numel() == 0:
|
|
142
|
+
continue # Skip empty batches
|
|
143
|
+
|
|
144
|
+
_, new_log_probs, dist_entropy, new_values = self.model.get_action(
|
|
145
|
+
states[idx],
|
|
146
|
+
actions[idx]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
logratio = new_log_probs - old_log_probs[idx]
|
|
150
|
+
ratio = torch.exp(logratio)
|
|
151
|
+
|
|
152
|
+
idx_adv = advantages[idx].flatten()
|
|
153
|
+
surr1 = ratio * idx_adv
|
|
154
|
+
surr2 = torch.clamp(ratio, 1.0 - self.clip_eps,
|
|
155
|
+
1.0 + self.clip_eps) * idx_adv
|
|
156
|
+
|
|
157
|
+
policy_loss = -torch.min(surr1, surr2).mean()
|
|
158
|
+
|
|
159
|
+
value_loss = self.mse_loss(new_values.flatten(), returns[idx].flatten())
|
|
160
|
+
|
|
161
|
+
entropy_loss = dist_entropy.mean()
|
|
162
|
+
|
|
163
|
+
loss = policy_loss + \
|
|
164
|
+
(self.value_coef * value_loss) + \
|
|
165
|
+
(self.ent_coef * entropy_loss)
|
|
166
|
+
|
|
167
|
+
self.optimizer.zero_grad(set_to_none=True)
|
|
168
|
+
loss.backward()
|
|
169
|
+
torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)
|
|
170
|
+
self.optimizer.step()
|
|
171
|
+
|
|
172
|
+
epoch_losses[index_loss] = loss.detach()
|
|
173
|
+
epoch_pi_losses[index_loss] = policy_loss.detach()
|
|
174
|
+
epoch_v_losses[index_loss] = value_loss.detach()
|
|
175
|
+
epoch_entropies[index_loss] = entropy_loss.detach()
|
|
176
|
+
index_loss += 1
|
|
177
|
+
|
|
178
|
+
# Return average losses over all actual updates ([:index_loss] excludes
|
|
179
|
+
# any unused pre-allocated entries)
|
|
180
|
+
return (epoch_losses[:index_loss].mean().item(),
|
|
181
|
+
epoch_pi_losses[:index_loss].mean().item(),
|
|
182
|
+
epoch_v_losses[:index_loss].mean().item(),
|
|
183
|
+
epoch_entropies[:index_loss].mean().item())
|
rl_template/common.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Pre-allocated numpy rollout buffer for RL training.
|
|
2
|
+
|
|
3
|
+
Provides Buffer, which stores trajectory data in fixed-size numpy arrays
|
|
4
|
+
and converts them to PyTorch tensors for the PPO update step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Buffer:
|
|
12
|
+
"""Pre-allocated rollout buffer for collecting RL trajectory data.
|
|
13
|
+
|
|
14
|
+
Stores 8 arrays (states, actions, old_log_probs, returns, advantages,
|
|
15
|
+
rewards, values, dones) in pre-allocated numpy arrays with a slice
|
|
16
|
+
pointer for O(1) insertion. After a full rollout, insert_returns()
|
|
17
|
+
fills in GAE-computed returns and advantages, and get_all() converts
|
|
18
|
+
everything to PyTorch tensors for the PPO update step.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
buf = Buffer(step=2048, state_shape=(4,), action_shape=())
|
|
22
|
+
for _ in range(2048):
|
|
23
|
+
buf.insert(state, action, log_prob, reward, value, done)
|
|
24
|
+
buf.insert_returns(returns, advantages)
|
|
25
|
+
tensors = buf.get_all()
|
|
26
|
+
buf.clear()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, step: int, state_shape: tuple, action_shape: tuple = ()):
|
|
30
|
+
"""Initialize pre-allocated arrays.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
step: Maximum number of timesteps (capacity).
|
|
34
|
+
state_shape: Shape of a single observation (excluding batch dim).
|
|
35
|
+
action_shape: Shape of a single action (excluding batch dim).
|
|
36
|
+
Use () for scalar discrete actions.
|
|
37
|
+
"""
|
|
38
|
+
self.step = step
|
|
39
|
+
self.slice: int = 0
|
|
40
|
+
self.states = np.zeros((step, *state_shape), dtype=np.float32)
|
|
41
|
+
self.actions = np.zeros((step, *action_shape), dtype=np.float32)
|
|
42
|
+
self.old_log_probs = np.zeros(self.step, dtype=np.float32)
|
|
43
|
+
self.returns = np.zeros(self.step, dtype=np.float32)
|
|
44
|
+
self.adv = np.zeros(self.step, dtype=np.float32)
|
|
45
|
+
self.rewards = np.zeros(self.step, dtype=np.float32)
|
|
46
|
+
self.values = np.zeros(self.step, dtype=np.float32)
|
|
47
|
+
self.dones = np.zeros(self.step, dtype=np.float32)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def size(self) -> int:
|
|
51
|
+
"""Return the current number of stored elements."""
|
|
52
|
+
return self.slice
|
|
53
|
+
|
|
54
|
+
def insert(self,
|
|
55
|
+
state: np.ndarray,
|
|
56
|
+
action: int,
|
|
57
|
+
old_log_prob: float,
|
|
58
|
+
reward: float,
|
|
59
|
+
value: float,
|
|
60
|
+
dones: int):
|
|
61
|
+
"""Store a single timestep of rollout data.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
state: Environment observation.
|
|
65
|
+
action: Action taken by the agent.
|
|
66
|
+
old_log_prob: Log probability of the action under the old policy.
|
|
67
|
+
reward: Reward received from the environment.
|
|
68
|
+
value: Critic value estimate for this state.
|
|
69
|
+
dones: 1 if the episode ended, 0 otherwise.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If the buffer is already at capacity.
|
|
73
|
+
"""
|
|
74
|
+
if self.slice >= self.step:
|
|
75
|
+
raise ValueError(f"Buffer is full (size={self.step}). Cannot insert more data.")
|
|
76
|
+
self.states[self.slice] = state
|
|
77
|
+
self.actions[self.slice] = action
|
|
78
|
+
self.old_log_probs[self.slice] = old_log_prob
|
|
79
|
+
self.rewards[self.slice] = reward
|
|
80
|
+
self.values[self.slice] = value
|
|
81
|
+
self.dones[self.slice] = dones
|
|
82
|
+
self.slice += 1
|
|
83
|
+
|
|
84
|
+
def insert_returns(self, returns: np.ndarray, adv: np.ndarray):
|
|
85
|
+
"""Write GAE-computed returns and advantages into the buffer.
|
|
86
|
+
|
|
87
|
+
Called after the rollout phase when GAE has been computed.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
returns: Discounted return values for each timestep.
|
|
91
|
+
adv: Generalized Advantage Estimation values for each timestep.
|
|
92
|
+
"""
|
|
93
|
+
self.returns[:] = returns
|
|
94
|
+
self.adv[:] = adv
|
|
95
|
+
|
|
96
|
+
def get_all(self) -> tuple:
|
|
97
|
+
"""Convert buffer data to PyTorch tensors for the PPO update.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Tuple of 8 tensors: (states, actions, old_log_probs, returns,
|
|
101
|
+
advantages, rewards, values, dones). States are float32,
|
|
102
|
+
dones are long (integer), everything else is float32.
|
|
103
|
+
"""
|
|
104
|
+
return (torch.tensor(self.states, dtype=torch.float32),
|
|
105
|
+
torch.tensor(self.actions, dtype=torch.float32),
|
|
106
|
+
torch.tensor(self.old_log_probs, dtype=torch.float32),
|
|
107
|
+
torch.tensor(self.returns, dtype=torch.float32),
|
|
108
|
+
torch.tensor(self.adv, dtype=torch.float32),
|
|
109
|
+
torch.tensor(self.rewards, dtype=torch.float32),
|
|
110
|
+
torch.tensor(self.values, dtype=torch.float32),
|
|
111
|
+
torch.tensor(self.dones, dtype=torch.long))
|
|
112
|
+
|
|
113
|
+
def clear(self):
|
|
114
|
+
"""Reset the buffer for reuse.
|
|
115
|
+
|
|
116
|
+
Resets the slice pointer to 0. Underlying arrays are not zeroed;
|
|
117
|
+
old data is overwritten by subsequent insert() calls.
|
|
118
|
+
"""
|
|
119
|
+
self.slice = 0
|
rl_template/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Configuration dataclasses for PPO training.
|
|
2
|
+
|
|
3
|
+
Provides PPOConfig (immutable hyperparameters), TrainConfig (mutable
|
|
4
|
+
training settings with computed fields), and WandbConfig (logging).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PPOConfig:
|
|
13
|
+
"""Immutable PPO hyperparameters.
|
|
14
|
+
|
|
15
|
+
Frozen to prevent accidental modification during training.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
lr: Adam optimizer learning rate.
|
|
19
|
+
gamma: Discount factor for future rewards.
|
|
20
|
+
gae_lambda: GAE lambda parameter (bias-variance tradeoff).
|
|
21
|
+
clip_eps: Clipping range for the PPO surrogate loss.
|
|
22
|
+
ent_coef: Entropy bonus coefficient.
|
|
23
|
+
value_coef: Value loss coefficient.
|
|
24
|
+
"""
|
|
25
|
+
lr: float = 3e-5
|
|
26
|
+
gamma: float = 0.999
|
|
27
|
+
gae_lambda: float = 0.95
|
|
28
|
+
clip_eps: float = 0.1
|
|
29
|
+
ent_coef: float = 0.01
|
|
30
|
+
value_coef: float = 0.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TrainConfig:
|
|
35
|
+
"""Training configuration with computed fields.
|
|
36
|
+
|
|
37
|
+
model_path and num_update are derived in __post_init__(); do not
|
|
38
|
+
pass them to the constructor.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
model_name: Model name (used in the saved file path).
|
|
42
|
+
model_saved_path: Directory for model checkpoints.
|
|
43
|
+
device: PyTorch device string (auto-detects CUDA).
|
|
44
|
+
model_path: Computed as "{model_saved_path}/{model_name}.pt".
|
|
45
|
+
timestamp: Total environment timesteps for training.
|
|
46
|
+
batch_size: Minibatch size for PPO updates.
|
|
47
|
+
rollout_steps: Steps collected before each PPO update.
|
|
48
|
+
num_update: Computed as timestamp // rollout_steps.
|
|
49
|
+
"""
|
|
50
|
+
model_name: str
|
|
51
|
+
model_saved_path: str
|
|
52
|
+
device: str = "cuda:0" if torch.cuda.is_available() else "cpu"
|
|
53
|
+
model_path: str = field(init=False)
|
|
54
|
+
timestamp: int = 6_000_000
|
|
55
|
+
batch_size: int = 64
|
|
56
|
+
rollout_steps: int = 2048
|
|
57
|
+
num_update: int = field(init=False)
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
self.model_path = f"{self.model_saved_path}/{self.model_name}.pt"
|
|
61
|
+
self.num_update = self.timestamp // self.rollout_steps
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class WandbConfig:
|
|
66
|
+
"""Weights & Biases logging configuration.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
name: Display name for the W&B run.
|
|
70
|
+
logs: Metric name-to-value pairs logged to W&B.
|
|
71
|
+
"""
|
|
72
|
+
name: str
|
|
73
|
+
logs: dict[str, float] = field(default_factory=dict)
|
rl_template/env.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Abstract base environment interface for reinforcement learning.
|
|
2
|
+
|
|
3
|
+
Provides BaseEnv, a Gymnasium v1 API wrapper that all environment
|
|
4
|
+
implementations must extend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from gymnasium import spaces
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseEnv(ABC):
|
|
14
|
+
"""Abstract base class for all RL environments.
|
|
15
|
+
|
|
16
|
+
Follows the Gymnasium v1 API where step() returns 5 values:
|
|
17
|
+
(obs, reward, terminated, truncated, info). Subclasses must set
|
|
18
|
+
observation_space and action_space in their __init__().
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.observation_space: spaces.Space = None
|
|
23
|
+
self.action_space: spaces.Space = None
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def reset(self, seed: int | None = None) -> tuple[np.ndarray, dict[str, Any]]:
|
|
27
|
+
"""Reset the environment to its initial state.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
seed: Optional random seed for reproducibility.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Tuple of (observation, info).
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict[str, Any]]:
|
|
39
|
+
"""Execute one step in the environment.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
action: The action to take.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
5-tuple of (observation, reward, terminated, truncated, info).
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def close(self):
|
|
51
|
+
"""Release environment resources (render windows, connections, etc.)."""
|
|
52
|
+
pass
|
rl_template/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Custom exceptions for the rl_template project."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmptyBufferError(Exception):
|
|
5
|
+
"""Raised when an update is attempted on an insufficiently filled buffer.
|
|
6
|
+
|
|
7
|
+
Attributes:
|
|
8
|
+
current_size: Number of entries currently in the buffer.
|
|
9
|
+
require_buffer_size: Minimum entries required for an update.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, current_size: int, require_buffer_size: int):
|
|
13
|
+
self.current_size = current_size
|
|
14
|
+
self.require_buffer_size = require_buffer_size
|
|
15
|
+
|
|
16
|
+
self.message = "Training agent flow is incorrect: the buffer is empty"
|
|
17
|
+
super().__init__(self.message)
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
"""Return a detailed error message with context and suggestion."""
|
|
21
|
+
suggestion = "Call Rollout before update weights"
|
|
22
|
+
details = f"[Crash Workflow] {self.message}\n"
|
|
23
|
+
details += f"the current buffer size: {self.current_size}\n"
|
|
24
|
+
details += f" the minimal size required is: {self.require_buffer_size}\n"
|
|
25
|
+
details += f"{suggestion}"
|
|
26
|
+
return details
|
rl_template/train.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Abstract training loop for RL agents.
|
|
2
|
+
|
|
3
|
+
Provides BaseTrain, which orchestrates the rollout-update cycle:
|
|
4
|
+
collect experience, compute GAE, run PPO, and repeat.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import torch
|
|
9
|
+
import numpy as np
|
|
10
|
+
from .env import BaseEnv
|
|
11
|
+
from .agent import BaseAgent
|
|
12
|
+
from .algorithms.ppo.ppo import PPOTrainer
|
|
13
|
+
from .common import Buffer
|
|
14
|
+
from .config import TrainConfig
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from .errors import EmptyBufferError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseTrain(ABC):
|
|
20
|
+
"""Abstract training loop coordinating agent, environment, and PPO.
|
|
21
|
+
|
|
22
|
+
Subclasses must implement rollout_phase(), update_weights(), and
|
|
23
|
+
save_model(). The provided implementations are template methods;
|
|
24
|
+
subclasses may override or call super() to reuse them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self,
|
|
28
|
+
agent: BaseAgent,
|
|
29
|
+
env: BaseEnv,
|
|
30
|
+
buffer: Buffer,
|
|
31
|
+
train_config: TrainConfig,
|
|
32
|
+
ppo_trainer: PPOTrainer):
|
|
33
|
+
"""Initialize the training loop.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
agent: Neural network policy to train.
|
|
37
|
+
env: Environment to collect experience from.
|
|
38
|
+
buffer: Pre-allocated buffer for rollout data.
|
|
39
|
+
train_config: Training configuration (device, paths, hyperparams).
|
|
40
|
+
ppo_trainer: PPO trainer handling GAE and weight updates.
|
|
41
|
+
"""
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.agent = agent
|
|
44
|
+
self.env = env
|
|
45
|
+
self.buffer = buffer
|
|
46
|
+
self.train_config = train_config
|
|
47
|
+
self.ppo_trainer = ppo_trainer
|
|
48
|
+
self.last_value = 0.0
|
|
49
|
+
self.cumulative_reward = 0.0
|
|
50
|
+
self.require_buffer_size = 10
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rollout_phase(self, state: np.ndarray):
|
|
54
|
+
"""Collect experience by running the agent in the environment.
|
|
55
|
+
|
|
56
|
+
Stores each transition in the buffer and resets on episode end.
|
|
57
|
+
After the rollout, computes the bootstrap value for GAE.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
state: Initial observation to start the rollout from.
|
|
61
|
+
"""
|
|
62
|
+
for _ in range(self.train_config.rollout_steps):
|
|
63
|
+
with torch.inference_mode():
|
|
64
|
+
action_t, log_prob, _, value = self.agent.get_action(state)
|
|
65
|
+
|
|
66
|
+
# Convention: truncate = terminated (episode naturally ended)
|
|
67
|
+
# done = truncated (episode cut short by time limit)
|
|
68
|
+
next_state, reward, truncate, done, _ = self.env.step(action_t)
|
|
69
|
+
done_casted = 1 if done else 0
|
|
70
|
+
|
|
71
|
+
self.buffer.insert(
|
|
72
|
+
state=state,
|
|
73
|
+
action=action_t.item(),
|
|
74
|
+
old_log_prob=log_prob,
|
|
75
|
+
reward=reward,
|
|
76
|
+
value=value,
|
|
77
|
+
dones=done_casted,
|
|
78
|
+
)
|
|
79
|
+
self.cumulative_reward += reward
|
|
80
|
+
|
|
81
|
+
if done or truncate:
|
|
82
|
+
state = self.env.reset()
|
|
83
|
+
else:
|
|
84
|
+
state = next_state
|
|
85
|
+
|
|
86
|
+
with torch.inference_mode():
|
|
87
|
+
_, _, _, next_value = self.agent.get_action(state)
|
|
88
|
+
self.last_value = next_value.item()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def update_weights(self, step: int):
|
|
92
|
+
"""Compute GAE and update agent weights via PPO.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
step: Current training step (used for learning rate decay).
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
EmptyBufferError: If the buffer has fewer entries than
|
|
99
|
+
require_buffer_size.
|
|
100
|
+
"""
|
|
101
|
+
if self.buffer.size < self.require_buffer_size:
|
|
102
|
+
raise EmptyBufferError(self.buffer.size, self.require_buffer_size)
|
|
103
|
+
|
|
104
|
+
rewards_list = self.buffer.rewards
|
|
105
|
+
values_list = self.buffer.values
|
|
106
|
+
dones_list = self.buffer.dones
|
|
107
|
+
|
|
108
|
+
with torch.inference_mode():
|
|
109
|
+
returns, adv, _ = self.ppo_trainer.compute_gae(rewards_list,
|
|
110
|
+
values_list,
|
|
111
|
+
self.last_value,
|
|
112
|
+
dones_list)
|
|
113
|
+
self.buffer.insert_returns(returns, adv)
|
|
114
|
+
|
|
115
|
+
loss, policy_loss, value_loss, entropy = self.ppo_trainer.update(
|
|
116
|
+
self.buffer,
|
|
117
|
+
self.train_config.timestamp,
|
|
118
|
+
step,
|
|
119
|
+
self.train_config.batch_size
|
|
120
|
+
)
|
|
121
|
+
self.buffer.clear()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_model(self):
|
|
125
|
+
"""Save agent weights to the path in train_config.model_path."""
|
|
126
|
+
os.makedirs(os.path.dirname(self.train_config.model_path), exist_ok=True)
|
|
127
|
+
torch.save(self.agent.state_dict(), self.train_config.model_path)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rl-template
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A type-safe, algorithm-agnostic Reinforcement Learning framework
|
|
5
|
+
Author-email: Mohamed Tine <mohamedtine17@gmail.com>
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: numpy>=1.24.0
|
|
11
|
+
Requires-Dist: torch>=2.0.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# RL Template
|
|
15
|
+
|
|
16
|
+
A modular reinforcement learning training framework built on PyTorch and Gymnasium, providing abstract base classes and a concrete PPO implementation.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Abstract base classes** — `BaseAgent`, `BaseEnv`, `BaseTrain` with template method pattern for easy extension
|
|
21
|
+
- **PPO implementation** — Clipped surrogate loss, GAE, linear LR decay, entropy bonus, gradient clipping
|
|
22
|
+
- **Pre-allocated buffer** — Zero-allocation rollout storage with bounds checking, supports discrete and continuous actions
|
|
23
|
+
- **Frozen configs** — Immutable `PPOConfig` and computed `TrainConfig` dataclasses
|
|
24
|
+
- **Auto GPU detection** — Automatic CUDA/CPU device selection
|
|
25
|
+
- **107 tests** — Full test suite covering all components
|
|
26
|
+
|
|
27
|
+
## Project Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
rl_template/
|
|
31
|
+
__init__.py
|
|
32
|
+
agent.py # BaseAgent — abstract policy/value interface
|
|
33
|
+
env.py # BaseEnv — Gymnasium v1 API wrapper
|
|
34
|
+
train.py # BaseTrain — rollout/update training loop
|
|
35
|
+
common.py # Buffer — pre-allocated numpy rollout buffer
|
|
36
|
+
config.py # PPOConfig, TrainConfig, WandbConfig
|
|
37
|
+
errors.py # EmptyBufferError
|
|
38
|
+
algorithms/
|
|
39
|
+
ppo/
|
|
40
|
+
ppo.py # PPOTrainer — GAE + clipped surrogate loss
|
|
41
|
+
tests/
|
|
42
|
+
test_agent.py # BaseAgent tests (12)
|
|
43
|
+
test_env.py # BaseEnv tests (11)
|
|
44
|
+
test_train.py # BaseTrain tests (6)
|
|
45
|
+
test_common.py # Buffer tests (21)
|
|
46
|
+
test_config.py # Config tests (13)
|
|
47
|
+
test_errors.py # Error tests (7)
|
|
48
|
+
test_ppo.py # PPOTrainer tests (15)
|
|
49
|
+
test_buffer_integration.py # Buffer integration tests (6)
|
|
50
|
+
test_continuous_actions.py # Continuous action tests (16)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
Requires Python 3.11+.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# With uv (recommended)
|
|
59
|
+
uv sync
|
|
60
|
+
|
|
61
|
+
# With pip
|
|
62
|
+
pip install -r requirements.txt
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import numpy as np
|
|
69
|
+
from rl_template.agent import BaseAgent
|
|
70
|
+
from rl_template.env import BaseEnv
|
|
71
|
+
from rl_template.train import BaseTrain
|
|
72
|
+
from rl_template.common import Buffer
|
|
73
|
+
from rl_template.config import TrainConfig, PPOConfig
|
|
74
|
+
from rl_template.algorithms.ppo.ppo import PPOTrainer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# 1. Implement your agent
|
|
78
|
+
class MyAgent(BaseAgent):
|
|
79
|
+
# Implement forward(), get_distribution()
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
# 2. Implement your environment
|
|
83
|
+
class MyEnv(BaseEnv):
|
|
84
|
+
# Implement reset(), step(), close()
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
# 3. Configure training
|
|
88
|
+
config = TrainConfig(model_name="my_agent", model_saved_path="./checkpoints")
|
|
89
|
+
ppo_config = PPOConfig(lr=3e-4, gamma=0.99, clip_eps=0.2)
|
|
90
|
+
|
|
91
|
+
# 4. Wire everything together
|
|
92
|
+
agent = MyAgent()
|
|
93
|
+
env = MyEnv()
|
|
94
|
+
buffer = Buffer(step=config.rollout_steps, state_shape=(4,))
|
|
95
|
+
ppo_trainer = PPOTrainer(agent, lr=ppo_config.lr, gamma=ppo_config.gamma)
|
|
96
|
+
|
|
97
|
+
# 5. Create trainer and run
|
|
98
|
+
class MyTrain(BaseTrain):
|
|
99
|
+
def rollout_phase(self, state): super().rollout_phase(state)
|
|
100
|
+
def update_weights(self, step): super().update_weights(step)
|
|
101
|
+
def save_model(self): super().save_model()
|
|
102
|
+
|
|
103
|
+
trainer = MyTrain(agent, env, buffer, config, ppo_trainer)
|
|
104
|
+
state, _ = env.reset()
|
|
105
|
+
|
|
106
|
+
for step in range(config.num_update):
|
|
107
|
+
trainer.rollout_phase(state)
|
|
108
|
+
trainer.update_weights(step)
|
|
109
|
+
trainer.save_model()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Key Classes
|
|
113
|
+
|
|
114
|
+
| Class | Description |
|
|
115
|
+
|-------|-------------|
|
|
116
|
+
| `BaseAgent` | Abstract agent combining ABC and nn.Module. Subclasses implement `forward()` and `get_distribution()`. The `get_action()` template method handles sampling and log-probability computation. |
|
|
117
|
+
| `BaseEnv` | Abstract Gymnasium v1 environment wrapper. Subclasses implement `reset()`, `step()`, and `close()`. |
|
|
118
|
+
| `BaseTrain` | Abstract training loop orchestrating rollout collection, GAE computation, and PPO updates. |
|
|
119
|
+
| `Buffer` | Pre-allocated numpy buffer for rollout data. Supports discrete and continuous actions with automatic dtype handling. |
|
|
120
|
+
| `PPOTrainer` | PPO algorithm implementation with GAE, clipped surrogate loss, value loss, entropy bonus, and linear LR decay. |
|
|
121
|
+
| `PPOConfig` | Frozen (immutable) PPO hyperparameters. |
|
|
122
|
+
| `TrainConfig` | Training configuration with auto-computed `model_path` and `num_update`. |
|
|
123
|
+
|
|
124
|
+
## PPO Algorithm
|
|
125
|
+
|
|
126
|
+
The PPO trainer implements:
|
|
127
|
+
|
|
128
|
+
- **Generalized Advantage Estimation (GAE)** — Blended n-step returns for low-variance advantage estimates
|
|
129
|
+
- **Clipped surrogate loss** — Prevents destructive policy updates
|
|
130
|
+
- **Value function loss** — MSE loss for the critic network
|
|
131
|
+
- **Entropy bonus** — Encourages exploration
|
|
132
|
+
- **Linear LR decay** — Learning rate anneals to zero over training
|
|
133
|
+
- **Gradient clipping** — Max norm of 1.0 for stability
|
|
134
|
+
|
|
135
|
+
## Testing
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
python -m pytest tests/ -v
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
All 107 tests cover unit tests for each component, integration tests for buffer workflows, and continuous action space validation.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
rl_template/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
rl_template/agent.py,sha256=D4f7ntcyjRGjziYTxH_r5eerXhqERj-wPXzVVQY9JR0,2166
|
|
3
|
+
rl_template/common.py,sha256=vkSA4Iy8pBWbeWsdrtPUlKQC_DLomCOia0YN4UgOPOY,4706
|
|
4
|
+
rl_template/config.py,sha256=IN_YFy1tEJfSgH4XZY-FwjwqRY5N1RkdYJ4739GyXZs,2308
|
|
5
|
+
rl_template/env.py,sha256=IVCesOGfkYQC42Grrchur3rNWu1ZgagI5N6kP5wIgys,1467
|
|
6
|
+
rl_template/errors.py,sha256=rEV8MTcvSukMTu5NV9wBl4jQI-aKeGz60g_kstCeJgE,1040
|
|
7
|
+
rl_template/train.py,sha256=fEi7wVFOVPD8dRLZItBw2HdZLsielihcj0tcQSGh58Q,4456
|
|
8
|
+
rl_template/algorithms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
rl_template/algorithms/ppo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
rl_template/algorithms/ppo/ppo.py,sha256=rYQpW-Exhpof1A5bX8TrwHUZqPK5hJy8ZEIdbT1e_mc,6886
|
|
11
|
+
rl_template-0.1.0.dist-info/METADATA,sha256=BI90PwOXxX0BlHDHFSbkH5Wd-hYAokk5iovqMpRzwTU,5234
|
|
12
|
+
rl_template-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
rl_template-0.1.0.dist-info/RECORD,,
|