ezppo 0.1.0__tar.gz

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.
ezppo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezppo
3
+ Version: 0.1.0
4
+ Summary: A simple PPO library for Discrete and Continuous environments
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: torch
7
+ Requires-Dist: gymnasium
8
+ Requires-Dist: numpy
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
ezppo-0.1.0/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # EzPPO
2
+
3
+ A simple, unified PPO (Proximal Policy Optimization) library for PyTorch. Supports both **discrete** and **continuous** action spaces.
4
+
5
+ ## Features
6
+
7
+ - **Unified API**: Same training loop for discrete and continuous
8
+ - **Observation normalization**: Online running statistics
9
+ - **GAE (Generalized Advantage Estimation)**: For stable advantage estimation
10
+ - **Clipped surrogate objective**: Standard PPO clipping
11
+ - **Mini-batch training**: With random shuffling
12
+ - **Gradient clipping**: Prevents exploding gradients
13
+ - **Checkpoint save/load**: Resume training easily
14
+ - **Multi-environment support**: Parallel environments built-in
15
+ - **GPU support**: Automatic device management
16
+
17
+ ## Quick Start
18
+
19
+ ### Discrete Actions (CartPole)
20
+
21
+ ```python
22
+ from ezppo import DiscretePPOAgent
23
+ import gymnasium as gym
24
+
25
+ agent = DiscretePPOAgent(state_dim=4, action_dim=2)
26
+ env = gym.make("CartPole-v1")
27
+
28
+ state, _ = env.reset()
29
+ action, log_prob = agent.act(state) # Stochastic action
30
+ # action = agent.act_deterministic(state) # Greedy action (for eval)
31
+ ```
32
+
33
+ ### Continuous Actions (Pendulum)
34
+
35
+ ```python
36
+ from ezppo import ContinuousPPOAgent
37
+ import gymnasium as gym
38
+
39
+ agent = ContinuousPPOAgent(state_dim=3, action_dim=1)
40
+ env = gym.make("Pendulum-v1")
41
+
42
+ state, _ = env.reset()
43
+ action, log_prob, raw_action = agent.act(state)
44
+ # action = agent.act_deterministic(state)[0] # Mean action (for eval)
45
+ ```
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install torch gymnasium numpy
51
+ ```
52
+
53
+ ## Architecture
54
+
55
+ ```
56
+ ezppo/
57
+ ├── __init__.py # Public API
58
+ ├── networks.py # DiscretePPO, ContinuousPPO, BasePPO
59
+ ├── normalizer.py # RunningNormalizer
60
+ └── agents.py # DiscretePPOAgent, ContinuousPPOAgent
61
+
62
+ examples/
63
+ ├── discrete_cartpole.py
64
+ └── continuous_pendulum.py
65
+ ```
66
+
67
+ ## Training Loop Template
68
+
69
+ ```python
70
+ trajectory = []
71
+ max_trajectory = 2048
72
+
73
+ for episode in range(num_episodes):
74
+ state, _ = env.reset()
75
+ done = False
76
+
77
+ while not done:
78
+ action, log_prob = agent.act(state)
79
+ next_state, reward, terminated, truncated, _ = env.step(action.item())
80
+ done = terminated or truncated
81
+
82
+ trajectory.append((state, action.item(), reward, log_prob.item(),
83
+ next_state, terminated))
84
+ state = next_state
85
+
86
+ if len(trajectory) >= max_trajectory:
87
+ agent.train(num_epochs=4, batch_size=64)
88
+ trajectory.clear()
89
+ ```
90
+
91
+ ## API Reference
92
+
93
+ ### Networks
94
+
95
+ | Class | Purpose |
96
+ |-------|---------|
97
+ | `BasePPO` | Base class with configurable architecture |
98
+ | `DiscretePPO` | Discrete actions → logits output |
99
+ | `ContinuousPPO` | Continuous actions → mean + learnable log_std |
100
+
101
+ ### Agents
102
+
103
+ | Class | Purpose |
104
+ |-------|---------|
105
+ | `DiscretePPOAgent` | Discrete action space |
106
+ | `ContinuousPPOAgent` | Continuous action space |
107
+
108
+ ### Key Methods
109
+
110
+ **Agent.act(state)** → `(action, log_prob)` or `(action, log_prob, raw_action)`
111
+ **Agent.act_deterministic(state)** → Deterministic action (for evaluation)
112
+ **Agent.train(num_epochs=4, batch_size=64)** → Train on trajectory
113
+ **Agent.store(packet)** → Add transition to trajectory
114
+ **Agent.clear()** → Clear trajectory buffer
115
+ **Agent.save(path)** → Save checkpoint
116
+ **Agent.load(path)** → Load checkpoint
117
+
118
+ ## Configuration
119
+
120
+ ```python
121
+ agent = DiscretePPOAgent(
122
+ state_dim=4, action_dim=2,
123
+ architecture={ # Custom network architecture
124
+ "actor": [state_dim, 64, 64, action_dim],
125
+ "critic": [state_dim, 64, 64, 1]
126
+ },
127
+ lr=3e-4, gamma=0.99,
128
+ gae_lambda=0.95, epsilon=0.2,
129
+ critic_coef=0.5, entropy_coef=0.01,
130
+ num_envs=1, trajectory_size=2048,
131
+ device="cpu"
132
+ )
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,29 @@
1
+ """
2
+ EzPPO - A simple, unified PPO library for Discrete and Continuous environments.
3
+
4
+ Usage:
5
+ from ezppo import DiscretePPOAgent, ContinuousPPOAgent
6
+
7
+ # Discrete example (CartPole):
8
+ agent = DiscretePPOAgent(state_dim=4, action_dim=2)
9
+ action, log_prob = agent.act(state)
10
+
11
+ # Continuous example (Pendulum):
12
+ agent = ContinuousPPOAgent(state_dim=3, action_dim=1)
13
+ action, log_prob, raw_action = agent.act(state)
14
+ """
15
+
16
+ from .networks import DiscretePPO, ContinuousPPO, BasePPO
17
+ from .normalizer import RunningNormalizer
18
+ from .agents import BasePPOAgent, DiscretePPOAgent, ContinuousPPOAgent
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = [
22
+ "DiscretePPO",
23
+ "ContinuousPPO",
24
+ "BasePPO",
25
+ "RunningNormalizer",
26
+ "BasePPOAgent",
27
+ "DiscretePPOAgent",
28
+ "ContinuousPPOAgent",
29
+ ]
@@ -0,0 +1,475 @@
1
+ """
2
+ Agent classes for Discrete and Continuous PPO.
3
+
4
+ Both agents share a common base class with:
5
+ - Trajectory collection (store/clear)
6
+ - GAE computation
7
+ - Observation normalization
8
+ - Mini-batch training loop
9
+ - Device management
10
+ - Save/load checkpointing
11
+ """
12
+
13
+ import torch
14
+ import numpy as np
15
+ import torch.optim as optim
16
+ from torch.distributions import Categorical, Normal
17
+ from torch.nn import MSELoss
18
+
19
+ from .networks import DiscretePPO, ContinuousPPO
20
+ from .normalizer import RunningNormalizer
21
+
22
+
23
+ class BasePPOAgent:
24
+ """Shared base for both discrete and continuous agents."""
25
+
26
+ def __init__(self, network, lr=3e-4, gamma=0.99, gae_lambda=0.95,
27
+ epsilon=0.2, critic_coef=0.5, entropy_coef=0.01,
28
+ num_envs=1, trajectory_size=2048, device="cpu",
29
+ normalize_observations=True, default_num_epochs=4,
30
+ default_batch_size=64):
31
+ self.network = network
32
+ self.optimizer = optim.Adam(network.parameters(), lr=lr, eps=1e-5)
33
+
34
+ self.trajectory = []
35
+ self._num_envs = num_envs
36
+ self._trajectory_size = trajectory_size
37
+
38
+ # PPO hyperparameters
39
+ self.gamma = gamma
40
+ self.gae_lambda = gae_lambda
41
+ self.epsilon = epsilon
42
+ self.critic_coef = critic_coef
43
+ self.entropy_coef = entropy_coef
44
+ self.lr = lr
45
+ self.device = torch.device(device)
46
+
47
+ self.normalize_observations = normalize_observations
48
+ self.default_num_epochs = default_num_epochs
49
+ self.default_batch_size = default_batch_size
50
+
51
+ # Set up observation normalizer (optional)
52
+ if self.normalize_observations:
53
+ self.obs_normalizer = RunningNormalizer(network.input_dim)
54
+ self.obs_normalizer.to(self.device)
55
+ else:
56
+ self.obs_normalizer = None
57
+
58
+ self.network.to(self.device)
59
+
60
+ def store(self, packet: tuple) -> None:
61
+ """Store a transition in the trajectory."""
62
+ self.trajectory.append(packet)
63
+
64
+ def clear(self) -> None:
65
+ """Clear the trajectory buffer."""
66
+ self.trajectory = []
67
+
68
+ def _compute_gae(self, states, rewards, values, next_values, terminal_mask):
69
+ """
70
+ Compute Generalized Advantage Estimation (GAE).
71
+
72
+ Args:
73
+ states: [T, B, obs_dim] or [T, obs_dim]
74
+ rewards: [T, B] or [T]
75
+ values: [T, B] or [T] - value of current states
76
+ next_values: [T, B] or [T] - value of next states
77
+ terminal_mask: [T, B] or [T] - 0 if terminal, 1 if not
78
+
79
+ Returns:
80
+ advantages: [T, B] or [T]
81
+ critic_targets: [T, B] or [T] - advantages + values (for critic loss)
82
+ """
83
+ # Handle both single-env [T] and multi-env [T, B] cases
84
+ if rewards.ndim == 1:
85
+ T = rewards.shape[0]
86
+ advantages = torch.zeros(T, device=self.device)
87
+ last_advantage = 0
88
+ for t in reversed(range(T)):
89
+ td_error = rewards[t] + self.gamma * next_values[t] * terminal_mask[t] - values[t]
90
+ last_advantage = td_error + self.gamma * self.gae_lambda * last_advantage * terminal_mask[t]
91
+ advantages[t] = last_advantage
92
+ else:
93
+ T, B = rewards.shape
94
+ advantages = torch.zeros(T, B, device=self.device)
95
+ last_advantage = 0
96
+ for t in reversed(range(T)):
97
+ td_error = rewards[t] + self.gamma * next_values[t] * terminal_mask[t] - values[t]
98
+ last_advantage = td_error + self.gamma * self.gae_lambda * last_advantage * terminal_mask[t]
99
+ advantages[t] = last_advantage
100
+
101
+ critic_targets = advantages + values
102
+ return advantages, critic_targets
103
+
104
+ def _flatten_batch(self, *tensors):
105
+ """Flatten [T, B, ...] to [T*B, ...] for batched training."""
106
+ return [t.flatten(end_dim=1) for t in tensors]
107
+
108
+ def train(self, num_epochs=None, batch_size=None):
109
+ """
110
+ Train PPO on the collected trajectory.
111
+
112
+ Args:
113
+ num_epochs: Number of PPO update epochs (uses default if None).
114
+ batch_size: Mini-batch size for training (uses default if None).
115
+ """
116
+ if not self.trajectory:
117
+ return
118
+
119
+ num_epochs = num_epochs if num_epochs is not None else self.default_num_epochs
120
+ batch_size = batch_size if batch_size is not None else self.default_batch_size
121
+
122
+ # Unpack trajectory
123
+ states, actions, rewards, old_log_probs, next_states, terminateds = zip(*self.trajectory)
124
+
125
+ # Convert to tensors
126
+ states = torch.from_numpy(np.stack(states)).float().to(self.device)
127
+ actions = torch.from_numpy(np.stack(actions)).float().to(self.device)
128
+ rewards = torch.from_numpy(np.stack(rewards)).float().to(self.device)
129
+ old_log_probs = torch.from_numpy(np.stack(old_log_probs)).float().to(self.device)
130
+ next_states = torch.from_numpy(np.stack(next_states)).float().to(self.device)
131
+ terminateds = torch.from_numpy(np.stack(terminateds)).float().to(self.device)
132
+
133
+ # Ensure consistent shapes
134
+ # For single env, stacking gives [T], [T, obs_dim] etc.
135
+ # We want [T, B] for scalar tensors and [T, B, obs_dim] for state tensors
136
+ if rewards.ndim == 1:
137
+ # Single environment case
138
+ rewards = rewards.unsqueeze(1) # [T, 1]
139
+ old_log_probs = old_log_probs.unsqueeze(1) # [T, 1]
140
+ terminateds = terminateds.unsqueeze(1) # [T, 1]
141
+ actions = actions.unsqueeze(1) # [T, 1]
142
+ if states.ndim == 2:
143
+ states = states.unsqueeze(1) # [T, 1, obs_dim]
144
+ if next_states.ndim == 2:
145
+ next_states = next_states.unsqueeze(1) # [T, 1, obs_dim]
146
+
147
+ terminal_mask = (1 - terminateds).float()
148
+
149
+ # Compute values
150
+ values = self.network.predict_value(states).squeeze(-1)
151
+ next_values = self.network.predict_value(next_states).squeeze(-1)
152
+
153
+ # Compute GAE
154
+ advantages, critic_targets = self._compute_gae(
155
+ states, rewards, values, next_values, terminal_mask
156
+ )
157
+
158
+ # Normalize advantages
159
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
160
+
161
+ # Detach targets (they shouldn't be used for backprop)
162
+ old_log_probs = old_log_probs.detach()
163
+ advantages = advantages.detach()
164
+ critic_targets = critic_targets.detach()
165
+
166
+ # Flatten for batched training
167
+ states, actions, old_log_probs, critic_targets, advantages = \
168
+ self._flatten_batch(states, actions, old_log_probs, critic_targets, advantages)
169
+
170
+ total_samples = states.shape[0]
171
+ avg_policy_loss = 0
172
+ avg_critic_loss = 0
173
+ avg_entropy = 0
174
+ num_updates = 0
175
+
176
+ # Training loop
177
+ for _ in range(num_epochs):
178
+ indices = np.arange(total_samples)
179
+ np.random.shuffle(indices)
180
+
181
+ for start in range(0, total_samples, batch_size):
182
+ end = min(start + batch_size, total_samples)
183
+ batch_idx = indices[start:end]
184
+
185
+ batch_states = states[batch_idx]
186
+ batch_actions = actions[batch_idx]
187
+ batch_old_log_probs = old_log_probs[batch_idx]
188
+ batch_critic_targets = critic_targets[batch_idx]
189
+ batch_advantages = advantages[batch_idx]
190
+
191
+ # Get new log probs and entropy (implemented by subclass)
192
+ new_log_probs, entropy = self._get_log_prob_and_entropy(batch_states, batch_actions)
193
+
194
+ # Compute ratios
195
+ ratio = torch.exp(new_log_probs - batch_old_log_probs)
196
+ clipped_ratio = torch.clamp(ratio, 1 - self.epsilon, 1 + self.epsilon)
197
+
198
+ # Policy loss (clipped surrogate)
199
+ policy_loss = -torch.min(
200
+ ratio * batch_advantages,
201
+ clipped_ratio * batch_advantages
202
+ ).mean()
203
+
204
+ # Critic loss (MSE)
205
+ new_values = self.network.predict_value(batch_states).squeeze(-1)
206
+ critic_loss = MSELoss()(new_values, batch_critic_targets)
207
+
208
+ # Entropy bonus
209
+ entropy_loss = -entropy.mean()
210
+
211
+ # Total loss
212
+ total_loss = policy_loss + self.critic_coef * critic_loss + self.entropy_coef * entropy_loss
213
+
214
+ # Optimizer step
215
+ self.optimizer.zero_grad()
216
+ total_loss.backward()
217
+ torch.nn.utils.clip_grad_norm_(self.network.parameters(), max_norm=0.5)
218
+ self.optimizer.step()
219
+
220
+ avg_policy_loss += policy_loss.item()
221
+ avg_critic_loss += critic_loss.item()
222
+ avg_entropy += entropy_loss.item()
223
+ num_updates += 1
224
+
225
+ # Update observation normalizer (if enabled)
226
+ if self.obs_normalizer is not None:
227
+ self.obs_normalizer.update(states.cpu().detach())
228
+
229
+ # Move back to CPU
230
+ self.network.to("cpu")
231
+ if self.obs_normalizer is not None:
232
+ self.obs_normalizer.to("cpu")
233
+
234
+ # Log
235
+ if num_updates > 0:
236
+ print(f"[{type(self).__name__}] Policy: {avg_policy_loss/num_updates:.4f} | "
237
+ f"Critic: {avg_critic_loss/num_updates:.4f} | "
238
+ f"Entropy: {avg_entropy/num_updates:.4f}")
239
+
240
+ def save(self, model_path: str, normalizer_path: str = None) -> None:
241
+ """Save checkpoint."""
242
+ torch.save(self.network.state_dict(), model_path)
243
+ if self.obs_normalizer is not None:
244
+ self.obs_normalizer.save(normalizer_path)
245
+
246
+ def load(self, model_path: str, normalizer_path: str | None = None) -> None:
247
+ """Load checkpoint.
248
+
249
+ Args:
250
+ model_path: Path to the network state dict file.
251
+ normalizer_path: Path to the normalizer file (only used if
252
+ normalize_observations=True; defaults to None).
253
+ """
254
+ self.network.load_state_dict(torch.load(model_path, weights_only=True))
255
+ if self.obs_normalizer is not None:
256
+ self.obs_normalizer.load(normalizer_path)
257
+ self.network.to(self.device)
258
+ if self.obs_normalizer is not None:
259
+ self.obs_normalizer.to(self.device)
260
+
261
+
262
+ class DiscretePPOAgent(BasePPOAgent):
263
+ """
264
+ PPO agent for discrete action spaces.
265
+
266
+ Usage:
267
+ agent = DiscretePPOAgent(state_dim=4, action_dim=2)
268
+ action, log_prob = agent.act(state)
269
+ agent.store((state, action, reward, log_prob, next_state, terminated))
270
+ agent.train()
271
+ """
272
+
273
+ def __init__(self, state_dim: int, action_dim: int,
274
+ architecture: dict = None,
275
+ lr: float = 3e-4, gamma: float = 0.99,
276
+ gae_lambda: float = 0.95, epsilon: float = 0.2,
277
+ critic_coef: float = 0.5, entropy_coef: float = 0.001,
278
+ num_envs: int = 1, trajectory_size: int = 2048,
279
+ device: str = "cpu", checkpoint: str = None,
280
+ normalize_observations: bool = True,
281
+ default_num_epochs: int = 4,
282
+ default_batch_size: int = 64):
283
+
284
+ # Default architecture
285
+ if architecture is None:
286
+ architecture = {
287
+ "actor": [state_dim, 64, 64, action_dim],
288
+ "critic": [state_dim, 64, 64, 1]
289
+ }
290
+
291
+ network = DiscretePPO(architecture)
292
+ super().__init__(network, lr, gamma, gae_lambda, epsilon,
293
+ critic_coef, entropy_coef, num_envs,
294
+ trajectory_size, device,
295
+ normalize_observations, default_num_epochs,
296
+ default_batch_size)
297
+
298
+ if checkpoint:
299
+ if self.obs_normalizer is not None:
300
+ self.load(checkpoint + ".pth", checkpoint + "_norm.npy")
301
+ else:
302
+ self.load(checkpoint + ".pth", None)
303
+
304
+ def act(self, state) -> tuple:
305
+ """
306
+ Select an action (stochastic).
307
+
308
+ Args:
309
+ state: Observation (numpy array or tensor).
310
+
311
+ Returns:
312
+ (action, log_prob)
313
+ """
314
+ state = torch.as_tensor(state, dtype=torch.float32)
315
+ if state.dim() == 1:
316
+ state = state.unsqueeze(0)
317
+ if self.normalize_observations:
318
+ state = self.obs_normalizer.normalize(state).to(self.device)
319
+ else:
320
+ state = state.to(self.device)
321
+
322
+ logits = self.network(state)
323
+ dist = Categorical(logits=logits)
324
+ action = dist.sample()
325
+ log_prob = dist.log_prob(action.squeeze(0))
326
+
327
+ return action.squeeze(0).cpu().detach(), log_prob.cpu().detach()
328
+
329
+ def act_deterministic(self, state) -> int:
330
+ """Select action greedily (no sampling)."""
331
+ state = torch.as_tensor(state, dtype=torch.float32)
332
+ if state.dim() == 1:
333
+ state = state.unsqueeze(0)
334
+ if self.normalize_observations:
335
+ state = self.obs_normalizer.normalize(state).to(self.device)
336
+ else:
337
+ state = state.to(self.device)
338
+
339
+ logits = self.network(state)
340
+ action = logits.argmax(dim=-1).squeeze(0)
341
+ return action.cpu().detach().item()
342
+
343
+ def _get_log_prob_and_entropy(self, states, actions):
344
+ """Compute log probability and entropy for a batch."""
345
+ logits = self.network(states)
346
+ dist = Categorical(logits=logits)
347
+ log_probs = dist.log_prob(actions)
348
+ entropies = dist.entropy().sum(-1)
349
+ return log_probs, entropies
350
+
351
+
352
+ class ContinuousPPOAgent(BasePPOAgent):
353
+ """
354
+ PPO agent for continuous action spaces with tanh squashing.
355
+
356
+ Uses reparameterization trick with tanh:
357
+ - Sample raw action from Gaussian
358
+ - Squash with tanh: action = tanh(raw)
359
+ - Correct log_prob with Jacobian: log_prob = raw_log_prob - log(1 - tanh^2 + eps)
360
+
361
+ Usage:
362
+ agent = ContinuousPPOAgent(state_dim=3, action_dim=1)
363
+ action, log_prob, raw_action = agent.act(state)
364
+ agent.store((state, raw_action, reward, log_prob, next_state, terminated))
365
+ agent.train()
366
+ """
367
+
368
+ def __init__(self, state_dim: int, action_dim: int,
369
+ architecture: dict = None,
370
+ lr: float = 3e-4, gamma: float = 0.99,
371
+ gae_lambda: float = 0.95, epsilon: float = 0.2,
372
+ critic_coef: float = 0.5, entropy_coef: float = 0.01,
373
+ num_envs: int = 1, trajectory_size: int = 2048,
374
+ device: str = "cpu", checkpoint: str = None,
375
+ normalize_observations: bool = True,
376
+ default_num_epochs: int = 4,
377
+ default_batch_size: int = 64):
378
+
379
+ # Default architecture
380
+ if architecture is None:
381
+ architecture = {
382
+ "actor": [state_dim, 64, 64, action_dim],
383
+ "critic": [state_dim, 64, 64, 1]
384
+ }
385
+
386
+ network = ContinuousPPO(architecture)
387
+ super().__init__(network, lr, gamma, gae_lambda, epsilon,
388
+ critic_coef, entropy_coef, num_envs,
389
+ trajectory_size, device,
390
+ normalize_observations, default_num_epochs,
391
+ default_batch_size)
392
+
393
+ if checkpoint:
394
+ if self.obs_normalizer is not None:
395
+ self.load(checkpoint + ".pth", checkpoint + "_norm.npy")
396
+ else:
397
+ self.load(checkpoint + ".pth", None)
398
+
399
+ def act(self, state) -> tuple:
400
+ """
401
+ Select an action (stochastic, with tanh squashing).
402
+
403
+ Args:
404
+ state: Observation (numpy array or tensor).
405
+
406
+ Returns:
407
+ (action, log_prob, raw_action)
408
+ - action: Clamped to [-1, 1]
409
+ - log_prob: Corrected for tanh squashing
410
+ - raw_action: Unsquashed Gaussian sample
411
+ """
412
+ state = torch.as_tensor(state, dtype=torch.float32)
413
+ if state.dim() == 1:
414
+ state = state.unsqueeze(0)
415
+ if self.normalize_observations:
416
+ state = self.obs_normalizer.normalize(state).to(self.device)
417
+ else:
418
+ state = state.to(self.device)
419
+
420
+ mean, log_std = self.network(state)
421
+ log_std = torch.clamp(log_std, -5, 2)
422
+ std = log_std.exp()
423
+
424
+ dist = Normal(mean, std)
425
+ raw_action = dist.rsample()
426
+ action = torch.tanh(raw_action)
427
+
428
+ # Jacobian correction for tanh squashing
429
+ log_prob = (dist.log_prob(raw_action) - torch.log(
430
+ 1 - action ** 2 + 1e-6
431
+ )).sum(-1)
432
+
433
+ return action.squeeze(0).cpu().detach(), \
434
+ log_prob.cpu().detach(), \
435
+ raw_action.squeeze(0).cpu().detach()
436
+
437
+ def act_deterministic(self, state) -> tuple:
438
+ """
439
+ Select action deterministically (mean of distribution).
440
+ Useful for evaluation.
441
+ """
442
+ state = torch.as_tensor(state, dtype=torch.float32)
443
+ if state.dim() == 1:
444
+ state = state.unsqueeze(0)
445
+ if self.normalize_observations:
446
+ state = self.obs_normalizer.normalize(state).to(self.device)
447
+ else:
448
+ state = state.to(self.device)
449
+
450
+ mean, log_std = self.network(state)
451
+ action = torch.tanh(mean)
452
+
453
+ log_prob = torch.zeros(mean.shape[0], device=self.device)
454
+ raw_action = torch.zeros(mean.shape[0], device=self.device)
455
+
456
+ return action.squeeze(0).cpu().detach(), \
457
+ log_prob.cpu().detach(), \
458
+ raw_action.cpu().detach()
459
+
460
+ def _get_log_prob_and_entropy(self, states, raw_actions):
461
+ """Compute log probability and entropy for a batch of raw actions."""
462
+ mean, log_std = self.network(states)
463
+ log_std = torch.clamp(log_std, -5, 2)
464
+ std = log_std.exp()
465
+
466
+ dist = Normal(mean, std)
467
+ actions = torch.tanh(raw_actions)
468
+
469
+ # Jacobian correction
470
+ log_probs = (dist.log_prob(raw_actions) - torch.log(
471
+ 1 - actions ** 2 + 1e-6
472
+ )).sum(-1)
473
+
474
+ entropy = dist.entropy().sum(-1)
475
+ return log_probs, entropy
@@ -0,0 +1,145 @@
1
+ """
2
+ PyTorch neural network modules for Discrete and Continuous PPO.
3
+
4
+ Both networks share a common base class (BasePPO) that provides:
5
+ - Configurable architecture via dict (actor, critic, optional shared backbone)
6
+ - Orthogonal weight initialization (standard for RL)
7
+ - Small output layer initialization (std=0.01) for stability
8
+ """
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ import numpy as np
13
+
14
+
15
+ class BasePPO(nn.Module):
16
+ """
17
+ Base PPO network with configurable architecture.
18
+
19
+ Architecture dict format:
20
+ {
21
+ "actor": [input_dim, hidden1, hidden2, ..., output_dim],
22
+ "critic": [input_dim, hidden1, hidden2, ..., 1],
23
+ "shared": [input_dim, hidden1, ...] # optional: shared backbone
24
+ }
25
+
26
+ If "shared" is provided, both actor and critic share an initial backbone.
27
+ Otherwise, actor and critic are independent networks.
28
+ """
29
+
30
+ def __init__(self, architecture: dict, hidden_activation=nn.Tanh):
31
+ super().__init__()
32
+
33
+ self.actor_head = self._init_layer(architecture["actor"], hidden_activation)
34
+ self.critic_head = self._init_layer(architecture["critic"], hidden_activation)
35
+
36
+ self.backbone = self._init_layer(
37
+ architecture["shared"], hidden_activation
38
+ ) if "shared" in architecture else None
39
+
40
+ self.input_dim = architecture["shared"][0] if self.backbone else architecture["actor"][0]
41
+ self.output_dim = architecture["actor"][-1]
42
+
43
+ # Choose forward methods based on whether we have a shared backbone
44
+ if self.backbone:
45
+ self._act_forward = self._act_forward_with_backbone
46
+ self._crit_forward = self._crit_forward_with_backbone
47
+ else:
48
+ self._act_forward = self._act_forward_independent
49
+ self._crit_forward = self._crit_forward_independent
50
+
51
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
52
+ """Actor forward pass (returns logits for discrete, mean for continuous)."""
53
+ return self._act_forward(x)
54
+
55
+ def predict_value(self, x: torch.Tensor) -> torch.Tensor:
56
+ """Critic forward pass (returns state value estimate)."""
57
+ return self._crit_forward(x)
58
+
59
+ def _init_layer(self, arch: list[int], activation):
60
+ """Build a sequential network from an architecture list."""
61
+ seq = nn.Sequential()
62
+ for i, v in enumerate(arch):
63
+ if i == len(arch) - 1:
64
+ break # Don't add output layer activation
65
+ input_n = v
66
+ output_n = arch[i + 1]
67
+
68
+ # Initialize weights with orthogonal init
69
+ if i + 1 == len(arch) - 1:
70
+ # Output layer: use small initialization for stability
71
+ linear = self._ortho_init(nn.Linear(input_n, output_n), std=0.01)
72
+ else:
73
+ # Hidden layers: use sqrt(2) orthogonal init (standard for RL)
74
+ linear = self._ortho_init(nn.Linear(input_n, output_n))
75
+
76
+ # Only add activation for hidden layers, not the output layer
77
+ if i + 1 != len(arch) - 1:
78
+ seq.extend([linear, activation()])
79
+ else:
80
+ seq.append(linear)
81
+ return seq
82
+
83
+ def _ortho_init(self, layer, std=np.sqrt(2), bias_const=0.0):
84
+ """Initialize a linear layer with orthogonal weights."""
85
+ nn.init.orthogonal_(layer.weight, std)
86
+ nn.init.constant_(layer.bias, bias_const)
87
+ return layer
88
+
89
+ # --- Forward method variants ---
90
+
91
+ def _act_forward_independent(self, x):
92
+ """Actor forward without shared backbone."""
93
+ return self.actor_head(x)
94
+
95
+ def _crit_forward_independent(self, x):
96
+ """Critic forward without shared backbone."""
97
+ return self.critic_head(x)
98
+
99
+ def _act_forward_with_backbone(self, x):
100
+ """Actor forward with shared backbone."""
101
+ return self.actor_head(self.backbone(x))
102
+
103
+ def _crit_forward_with_backbone(self, x):
104
+ """Critic forward with shared backbone."""
105
+ return self.critic_head(self.backbone(x))
106
+
107
+
108
+ class DiscretePPO(BasePPO):
109
+ """
110
+ Discrete action PPO network.
111
+
112
+ Output:
113
+ forward(x) -> action logits [batch, n_actions]
114
+ predict_value(x) -> value estimates [batch, 1]
115
+ """
116
+
117
+ def __init__(self, architecture: dict, hidden_activation=nn.Tanh):
118
+ super().__init__(architecture, hidden_activation)
119
+
120
+
121
+ class ContinuousPPO(BasePPO):
122
+ """
123
+ Continuous action PPO network with learnable log_std.
124
+
125
+ Output:
126
+ forward(x) -> (mean [batch, n_actions], log_std [n_actions])
127
+ predict_value(x) -> value estimates [batch, 1]
128
+
129
+ The log_std parameter is shared across all batches and is clamped
130
+ during action sampling to prevent explosion.
131
+ """
132
+
133
+ def __init__(self, architecture: dict, hidden_activation=nn.Tanh, default_log=-1.0):
134
+ super().__init__(architecture, hidden_activation)
135
+
136
+ # Learnable log_std parameter (shared across all layers/batches)
137
+ self.log_std = nn.Parameter(
138
+ torch.ones(self.output_dim) * default_log,
139
+ requires_grad=True
140
+ )
141
+
142
+ def forward(self, x: torch.Tensor):
143
+ """Returns (mean, log_std). Mean has shape [batch, n_actions]."""
144
+ mean = super().forward(x)
145
+ return mean, self.log_std
@@ -0,0 +1,105 @@
1
+ """
2
+ Running normalizer for observation preprocessing.
3
+ Uses online algorithm to track running mean and variance.
4
+ """
5
+
6
+ import torch
7
+ import numpy as np
8
+
9
+
10
+ class RunningNormalizer:
11
+ """
12
+ Online running normalizer for observations.
13
+
14
+ Uses Welford's algorithm for numerically stable running statistics.
15
+ Clips normalized values to prevent extreme outliers.
16
+ """
17
+
18
+ def __init__(self, shape: int | list[int], epsilon: float = 1e-8, clip: float = 10.0):
19
+ """
20
+ Args:
21
+ shape: Dimension of observations (int or list of ints).
22
+ Will be flattened to a 1D shape.
23
+ epsilon: Small constant for numerical stability.
24
+ clip: Maximum absolute value for normalized observations.
25
+ """
26
+ # Flatten shape if list or tuple
27
+ if isinstance(shape, (list, tuple)):
28
+ flat_shape = [int(np.prod(shape))]
29
+ else:
30
+ flat_shape = [int(shape)]
31
+
32
+ self.mean = torch.zeros(flat_shape)
33
+ self.var = torch.ones(flat_shape)
34
+ self.count = epsilon
35
+ self.epsilon = epsilon
36
+ self.clip = clip
37
+
38
+ def update(self, x: torch.Tensor) -> None:
39
+ """
40
+ Update running statistics with a batch of observations.
41
+
42
+ Args:
43
+ x: Tensor of shape [batch, ...] or [batch, obs_dim]
44
+ """
45
+ batch_mean = torch.mean(x, dim=0)
46
+ batch_var = torch.var(x, dim=0, unbiased=False)
47
+ batch_count = x.shape[0]
48
+
49
+ total_count = self.count + batch_count
50
+ delta = batch_mean - self.mean
51
+
52
+ self.mean += delta * batch_count / total_count
53
+ self.var = (
54
+ (self.var * self.count + batch_var * batch_count +
55
+ delta ** 2 * self.count * batch_count / total_count)
56
+ / total_count
57
+ )
58
+ self.count = total_count
59
+
60
+ def normalize(self, x: torch.Tensor) -> torch.Tensor:
61
+ """
62
+ Normalize observations.
63
+
64
+ Args:
65
+ x: Tensor of observations.
66
+
67
+ Returns:
68
+ Normalized tensor, clipped to [-clip, clip].
69
+ """
70
+ normed = (x - self.mean) / (torch.sqrt(self.var) + self.epsilon)
71
+ return torch.clip(normed, -self.clip, self.clip)
72
+
73
+ def denormalize(self, x: torch.Tensor) -> torch.Tensor:
74
+ """
75
+ Denormalize observations (inverse of normalize).
76
+
77
+ Args:
78
+ x: Normalized tensor.
79
+
80
+ Returns:
81
+ Original-scale tensor.
82
+ """
83
+ return (x * (torch.sqrt(self.var) + self.epsilon)) + self.mean
84
+
85
+ def save(self, path: str) -> None:
86
+ """Save statistics to a numpy file."""
87
+ np.save(path, [self.mean.cpu(), self.var.cpu()])
88
+
89
+ def load(self, path: str) -> None:
90
+ """Load statistics from a numpy file."""
91
+ data = np.load(path, allow_pickle=True)
92
+ self.mean = torch.as_tensor(data[0]).to(self.mean.device)
93
+ self.var = torch.as_tensor(data[1]).to(self.var.device)
94
+
95
+ def to(self, device: torch.device | str) -> "RunningNormalizer":
96
+ """Move statistics to device."""
97
+ self.mean = self.mean.to(device)
98
+ self.var = self.var.to(device)
99
+ return self
100
+
101
+ def reset(self) -> None:
102
+ """Reset statistics (useful for new training run)."""
103
+ self.mean = torch.zeros_like(self.mean)
104
+ self.var = torch.ones_like(self.var)
105
+ self.count = self.epsilon
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezppo
3
+ Version: 0.1.0
4
+ Summary: A simple PPO library for Discrete and Continuous environments
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: torch
7
+ Requires-Dist: gymnasium
8
+ Requires-Dist: numpy
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ ezppo/__init__.py
4
+ ezppo/agents.py
5
+ ezppo/networks.py
6
+ ezppo/normalizer.py
7
+ ezppo.egg-info/PKG-INFO
8
+ ezppo.egg-info/SOURCES.txt
9
+ ezppo.egg-info/dependency_links.txt
10
+ ezppo.egg-info/requires.txt
11
+ ezppo.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ torch
2
+ gymnasium
3
+ numpy
4
+
5
+ [dev]
6
+ pytest
@@ -0,0 +1 @@
1
+ ezppo
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ezppo"
7
+ version = "0.1.0"
8
+ description = "A simple PPO library for Discrete and Continuous environments"
9
+ requires-python = ">=3.8"
10
+ dependencies = [
11
+ "torch",
12
+ "gymnasium",
13
+ "numpy",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest",
19
+ ]
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["ezppo*"]
ezppo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+