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.
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any