tinybaselines 0.1.0a1__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.
Files changed (51) hide show
  1. tinybaselines-0.1.0a1/LICENSE +21 -0
  2. tinybaselines-0.1.0a1/MANIFEST.in +1 -0
  3. tinybaselines-0.1.0a1/PKG-INFO +94 -0
  4. tinybaselines-0.1.0a1/README.md +82 -0
  5. tinybaselines-0.1.0a1/examples/__init__.py +0 -0
  6. tinybaselines-0.1.0a1/examples/lunar_lander/__init__.py +0 -0
  7. tinybaselines-0.1.0a1/examples/lunar_lander/config.py +5 -0
  8. tinybaselines-0.1.0a1/examples/lunar_lander/reference.py +335 -0
  9. tinybaselines-0.1.0a1/examples/lunar_lander/requirements-reference.txt +2 -0
  10. tinybaselines-0.1.0a1/examples/lunar_lander/requirements.txt +4 -0
  11. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-initial-tinygrad-thread4.jsonl +1 -0
  12. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-initial-torch.jsonl +6 -0
  13. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-protocol.json +53 -0
  14. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-tinygrad-thread10.jsonl +6 -0
  15. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-tinygrad-thread4-error.jsonl +2 -0
  16. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/phase-torch-thread4.jsonl +6 -0
  17. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/protocol.json +15 -0
  18. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/tinygrad-cpu.jsonl +8 -0
  19. tinybaselines-0.1.0a1/examples/lunar_lander/results/2026-10-26/torch-cpu.jsonl +8 -0
  20. tinybaselines-0.1.0a1/examples/lunar_lander/run_learning.py +81 -0
  21. tinybaselines-0.1.0a1/examples/lunar_lander/train.py +156 -0
  22. tinybaselines-0.1.0a1/examples/pendulum/__init__.py +0 -0
  23. tinybaselines-0.1.0a1/examples/pendulum/env.py +10 -0
  24. tinybaselines-0.1.0a1/examples/pendulum/requirements.txt +2 -0
  25. tinybaselines-0.1.0a1/examples/pendulum/results/cpu-2.jsonl +101 -0
  26. tinybaselines-0.1.0a1/examples/pendulum/results/cpu-3.jsonl +101 -0
  27. tinybaselines-0.1.0a1/examples/pendulum/results/cpu.jsonl +101 -0
  28. tinybaselines-0.1.0a1/examples/pendulum/train.py +70 -0
  29. tinybaselines-0.1.0a1/pyproject.toml +19 -0
  30. tinybaselines-0.1.0a1/requirements.txt +2 -0
  31. tinybaselines-0.1.0a1/setup.cfg +4 -0
  32. tinybaselines-0.1.0a1/tests/test_checkpoint.py +138 -0
  33. tinybaselines-0.1.0a1/tests/test_core.py +86 -0
  34. tinybaselines-0.1.0a1/tests/test_evaluation.py +76 -0
  35. tinybaselines-0.1.0a1/tests/test_learning.py +47 -0
  36. tinybaselines-0.1.0a1/tests/test_learning_run.py +81 -0
  37. tinybaselines-0.1.0a1/tests/test_parity.py +214 -0
  38. tinybaselines-0.1.0a1/tests/test_pendulum.py +48 -0
  39. tinybaselines-0.1.0a1/tests/test_rollout.py +157 -0
  40. tinybaselines-0.1.0a1/tests/test_training.py +96 -0
  41. tinybaselines-0.1.0a1/tinybaselines/__init__.py +5 -0
  42. tinybaselines-0.1.0a1/tinybaselines/evaluation.py +6 -0
  43. tinybaselines-0.1.0a1/tinybaselines/policies.py +75 -0
  44. tinybaselines-0.1.0a1/tinybaselines/ppo.py +155 -0
  45. tinybaselines-0.1.0a1/tinybaselines/rollout.py +80 -0
  46. tinybaselines-0.1.0a1/tinybaselines/training.py +32 -0
  47. tinybaselines-0.1.0a1/tinybaselines.egg-info/PKG-INFO +94 -0
  48. tinybaselines-0.1.0a1/tinybaselines.egg-info/SOURCES.txt +49 -0
  49. tinybaselines-0.1.0a1/tinybaselines.egg-info/dependency_links.txt +1 -0
  50. tinybaselines-0.1.0a1/tinybaselines.egg-info/requires.txt +2 -0
  51. tinybaselines-0.1.0a1/tinybaselines.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Utkarsh Gill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ recursive-include examples *.py *.txt *.json *.jsonl
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinybaselines
3
+ Version: 0.1.0a1
4
+ Summary: PPO in tinygrad.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: tinygrad==0.14.0
10
+ Requires-Dist: numpy<3,>=1.26
11
+ Dynamic: license-file
12
+
13
+ # tinybaselines
14
+
15
+ PPO in tinygrad. Continuous actions clipped to `[-1, 1]`.
16
+
17
+ Python 3.11 and clang.
18
+
19
+ ```sh
20
+ python3.11 -m venv .venv
21
+ source .venv/bin/activate
22
+ pip install -e . -r examples/lunar_lander/requirements.txt
23
+ ```
24
+
25
+ Train, save, and load.
26
+
27
+ ```python
28
+ from statistics import mean
29
+ from tinybaselines import GaussianActorCritic, PPO, evaluate, learn
30
+ from examples.lunar_lander.config import OBS_SCALE
31
+ from examples.lunar_lander.train import make_env
32
+
33
+ env = make_env(4, asynchronous=False)
34
+ policy = GaussianActorCritic(state_dim=8, action_dim=2, hidden_dim=128,
35
+ actor_layers=4, critic_layers=4, observation_scale=OBS_SCALE)
36
+ ppo = PPO(policy, pi_lr=1e-3, vf_lr=1e-3, gamma=0.99, lamda=0.95,
37
+ K_epochs=20, eps_clip=0.2, batch_size=10_000, vf_coef=1.0, entropy_coef=0.001)
38
+
39
+ for step in learn(env, ppo, total_timesteps=2_000_000, rollout_steps=20_000):
40
+ print(step.timesteps, step.rollout_seconds, step.update_seconds)
41
+
42
+ ppo.save('ppo.safetensors')
43
+ ppo = PPO.load('ppo.safetensors')
44
+ policy = ppo.actor_critic
45
+ observations, _ = env.reset()
46
+ actions, raw_actions = policy.act(observations, deterministic=True)
47
+ env.close()
48
+
49
+ eval_env = make_env(4, asynchronous=False)
50
+ returns = evaluate(eval_env, policy, num_episodes=100)
51
+ eval_env.close()
52
+ print(mean(returns))
53
+ ```
54
+
55
+ `total_timesteps` and `rollout_steps` count transitions across all environments.
56
+ Use a positive multiple of `env.num_envs` for `rollout_steps`. Training stops
57
+ after the first full rollout that reaches the budget. Each step contains completed
58
+ `episode_returns` and collection/update times. The caller owns the environment.
59
+ Use a separate environment for evaluation during training.
60
+ `evaluate` resets its environment and returns complete episode returns. It samples
61
+ actions by default; `deterministic=True` uses clipped policy means.
62
+ `actions` go to the environment; `raw_actions` go into PPO.
63
+ The collector uses Gymnasium vector environments with same-step autoreset.
64
+ For time-limit bootstrapping, the collector reads pre-reset observations from `infos['final_obs']`.
65
+
66
+ PPO checkpoints contain the policy, observation scale, Adam state, and PPO settings.
67
+ Continue training by passing the loaded PPO to `learn` with a new transition budget.
68
+ Each `learn` call starts fresh episodes and a new transition counter.
69
+ `policy.save(path)` exports a policy for inference.
70
+
71
+ The full LunarLander trainer, evaluation, and PyTorch reference live in
72
+ `examples/lunar_lander`.
73
+
74
+ ```sh
75
+ python -m examples.lunar_lander.train
76
+ ```
77
+
78
+ Pendulum uses three observations and one action. Its example rescales policy actions
79
+ from `[-1, 1]` to torque in `[-2, 2]`.
80
+
81
+ ```sh
82
+ pip install -e . -r examples/pendulum/requirements.txt
83
+ DEV=CPU BEAM=0 python -m examples.pendulum.train --output runs/pendulum
84
+ ```
85
+
86
+ The output directory contains the configuration, episode returns, update times,
87
+ and a PPO checkpoint. Initial and final evaluations each use 100 fresh stochastic episodes.
88
+
89
+ Three independent four-environment CPU runs reached mean returns of -141.4, -162.2,
90
+ and -150.8 after 200,704 transitions each. Each final evaluation used the reloaded checkpoint.
91
+ Our acceptance cutoff is -200 over 100 fresh stochastic episodes per run; all three passed.
92
+ Gymnasium defines no solved threshold for Pendulum. We chose this cutoff after the first run.
93
+ Run records: [1](examples/pendulum/results/cpu.jsonl),
94
+ [2](examples/pendulum/results/cpu-2.jsonl), [3](examples/pendulum/results/cpu-3.jsonl).
@@ -0,0 +1,82 @@
1
+ # tinybaselines
2
+
3
+ PPO in tinygrad. Continuous actions clipped to `[-1, 1]`.
4
+
5
+ Python 3.11 and clang.
6
+
7
+ ```sh
8
+ python3.11 -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -e . -r examples/lunar_lander/requirements.txt
11
+ ```
12
+
13
+ Train, save, and load.
14
+
15
+ ```python
16
+ from statistics import mean
17
+ from tinybaselines import GaussianActorCritic, PPO, evaluate, learn
18
+ from examples.lunar_lander.config import OBS_SCALE
19
+ from examples.lunar_lander.train import make_env
20
+
21
+ env = make_env(4, asynchronous=False)
22
+ policy = GaussianActorCritic(state_dim=8, action_dim=2, hidden_dim=128,
23
+ actor_layers=4, critic_layers=4, observation_scale=OBS_SCALE)
24
+ ppo = PPO(policy, pi_lr=1e-3, vf_lr=1e-3, gamma=0.99, lamda=0.95,
25
+ K_epochs=20, eps_clip=0.2, batch_size=10_000, vf_coef=1.0, entropy_coef=0.001)
26
+
27
+ for step in learn(env, ppo, total_timesteps=2_000_000, rollout_steps=20_000):
28
+ print(step.timesteps, step.rollout_seconds, step.update_seconds)
29
+
30
+ ppo.save('ppo.safetensors')
31
+ ppo = PPO.load('ppo.safetensors')
32
+ policy = ppo.actor_critic
33
+ observations, _ = env.reset()
34
+ actions, raw_actions = policy.act(observations, deterministic=True)
35
+ env.close()
36
+
37
+ eval_env = make_env(4, asynchronous=False)
38
+ returns = evaluate(eval_env, policy, num_episodes=100)
39
+ eval_env.close()
40
+ print(mean(returns))
41
+ ```
42
+
43
+ `total_timesteps` and `rollout_steps` count transitions across all environments.
44
+ Use a positive multiple of `env.num_envs` for `rollout_steps`. Training stops
45
+ after the first full rollout that reaches the budget. Each step contains completed
46
+ `episode_returns` and collection/update times. The caller owns the environment.
47
+ Use a separate environment for evaluation during training.
48
+ `evaluate` resets its environment and returns complete episode returns. It samples
49
+ actions by default; `deterministic=True` uses clipped policy means.
50
+ `actions` go to the environment; `raw_actions` go into PPO.
51
+ The collector uses Gymnasium vector environments with same-step autoreset.
52
+ For time-limit bootstrapping, the collector reads pre-reset observations from `infos['final_obs']`.
53
+
54
+ PPO checkpoints contain the policy, observation scale, Adam state, and PPO settings.
55
+ Continue training by passing the loaded PPO to `learn` with a new transition budget.
56
+ Each `learn` call starts fresh episodes and a new transition counter.
57
+ `policy.save(path)` exports a policy for inference.
58
+
59
+ The full LunarLander trainer, evaluation, and PyTorch reference live in
60
+ `examples/lunar_lander`.
61
+
62
+ ```sh
63
+ python -m examples.lunar_lander.train
64
+ ```
65
+
66
+ Pendulum uses three observations and one action. Its example rescales policy actions
67
+ from `[-1, 1]` to torque in `[-2, 2]`.
68
+
69
+ ```sh
70
+ pip install -e . -r examples/pendulum/requirements.txt
71
+ DEV=CPU BEAM=0 python -m examples.pendulum.train --output runs/pendulum
72
+ ```
73
+
74
+ The output directory contains the configuration, episode returns, update times,
75
+ and a PPO checkpoint. Initial and final evaluations each use 100 fresh stochastic episodes.
76
+
77
+ Three independent four-environment CPU runs reached mean returns of -141.4, -162.2,
78
+ and -150.8 after 200,704 transitions each. Each final evaluation used the reloaded checkpoint.
79
+ Our acceptance cutoff is -200 over 100 fresh stochastic episodes per run; all three passed.
80
+ Gymnasium defines no solved threshold for Pendulum. We chose this cutoff after the first run.
81
+ Run records: [1](examples/pendulum/results/cpu.jsonl),
82
+ [2](examples/pendulum/results/cpu-2.jsonl), [3](examples/pendulum/results/cpu-3.jsonl).
File without changes
@@ -0,0 +1,5 @@
1
+ import numpy as np
2
+
3
+ ENV_NAME = 'LunarLanderContinuous-v3'
4
+ # https://gymnasium.farama.org/environments/box2d/lunar_lander/
5
+ OBS_SCALE = np.array([10, 6.666, 5, 7.5, 1, 2.5, 1, 1], dtype=np.float32)
@@ -0,0 +1,335 @@
1
+ import gymnasium as gym
2
+ import numpy as np
3
+ import time
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.nn.functional as F
7
+ import torch.optim as optim
8
+ import os
9
+ import warnings
10
+ from tqdm import trange
11
+ from .config import ENV_NAME, OBS_SCALE
12
+ from tinybaselines.rollout import RolloutBatch, RolloutState
13
+
14
+ warnings.filterwarnings('ignore', message='pkg_resources is deprecated')
15
+
16
+ state_dim, action_dim = 8, 2
17
+ num_envs = int(os.getenv('NUM_ENVS', 4))
18
+
19
+ max_epochs, steps_per_epoch = 100, 20_000
20
+ log_interval, eval_interval = 5, 5
21
+ eval_episodes = 100
22
+ batch_size, K_epochs = 10_000, 20
23
+ hidden_dim = 128
24
+ actor_layers, critic_layers = 4, 4
25
+ pi_lr, vf_lr = 1e-3, 1e-3
26
+ gamma, gae_lambda, eps_clip = 0.99, 0.95, 0.2
27
+ vf_coef, entropy_coef = 1.0, 0.001
28
+ solved_threshold = 200
29
+
30
+ PLOT = bool(int(os.getenv('PLOT', '0')))
31
+ RENDER = bool(int(os.getenv('RENDER', '0')))
32
+ RENDER_EPISODES = int(os.getenv('RENDER_EPISODES', '3'))
33
+ METAL = bool(int(os.getenv('METAL', '0')))
34
+
35
+ device = torch.device('mps' if METAL and torch.backends.mps.is_available() else 'cuda' if torch.cuda.is_available() else 'cpu')
36
+
37
+ if PLOT:
38
+ import matplotlib.pyplot as plt
39
+
40
+ def make_env(n, render=False, asynchronous=True):
41
+ render_mode = 'human' if render else None
42
+ env_class = gym.vector.AsyncVectorEnv if asynchronous else gym.vector.SyncVectorEnv
43
+ return env_class(
44
+ [lambda: gym.make(ENV_NAME, render_mode=render_mode) for _ in range(n)],
45
+ autoreset_mode=gym.vector.AutoresetMode.SAME_STEP,
46
+ )
47
+
48
+ def update_plot(ax, returns, threshold):
49
+ ax.clear()
50
+ ax.plot(returns, alpha=0.3, label='Episode Returns')
51
+ if len(returns) >= 100:
52
+ ma = np.convolve(returns, np.ones(100)/100, mode='valid')
53
+ ax.plot(range(99, len(returns)), ma, label='100-ep MA', linewidth=2)
54
+ ax.axhline(threshold, color='red', linestyle='--', alpha=0.5, label=f'Solved ({threshold})')
55
+ ax.legend()
56
+ ax.set_xlabel('Episode')
57
+ ax.set_ylabel('Return')
58
+ plt.pause(0.01)
59
+
60
+
61
+ def track_episode_returns(done_mask, ep_returns, ep_rets, remaining=None):
62
+ for idx in np.where(done_mask)[0]:
63
+ if remaining is None or remaining[idx] > 0:
64
+ ep_returns.append(ep_rets[idx])
65
+ if remaining is not None:
66
+ remaining[idx] -= 1
67
+ ep_rets[idx] = 0.0
68
+
69
+ class ActorCritic(nn.Module):
70
+ def __init__(self, state_dim, action_dim, hidden_dim, actor_layers, critic_layers):
71
+ super(ActorCritic, self).__init__()
72
+ self.config = dict(state_dim=state_dim, action_dim=action_dim, hidden_dim=hidden_dim,
73
+ actor_layers=actor_layers, critic_layers=critic_layers)
74
+ self.register_buffer('observation_scale', torch.from_numpy(OBS_SCALE.copy()))
75
+
76
+ actor = [nn.Linear(state_dim, hidden_dim), nn.ReLU()]
77
+ for _ in range(actor_layers - 1):
78
+ actor.extend([nn.Linear(hidden_dim, hidden_dim), nn.ReLU()])
79
+ actor.append(nn.Linear(hidden_dim, action_dim))
80
+ self.actor = nn.Sequential(*actor)
81
+ # small mu init → near-zero thrust initially (slow descent > random crashes)
82
+ self.actor[-1].weight.data.mul_(0.01)
83
+ self.actor[-1].bias.data.zero_()
84
+ self.log_std = nn.Parameter(torch.full((action_dim,), -0.7))
85
+
86
+ critic = [nn.Linear(state_dim, hidden_dim), nn.ReLU()]
87
+ for _ in range(critic_layers - 1):
88
+ critic.extend([nn.Linear(hidden_dim, hidden_dim), nn.ReLU()])
89
+ critic.append(nn.Linear(hidden_dim, 1))
90
+ self.critic = nn.Sequential(*critic)
91
+
92
+ def forward(self, state):
93
+ action_mean = self.actor(state)
94
+ action_std = self.log_std.clamp(-5, 2).exp().expand_as(action_mean)
95
+ value = self.critic(state)
96
+ return action_mean, action_std, value
97
+
98
+ def preprocess(self, observations):
99
+ return torch.as_tensor(observations, device=self.observation_scale.device, dtype=torch.float32) * self.observation_scale
100
+
101
+ @torch.inference_mode()
102
+ def act(self, state, deterministic=False):
103
+ state_tensor = self.preprocess(state)
104
+ action_mean = self.actor(state_tensor)
105
+ action_std = self.log_std.clamp(-5, 2).exp().expand_as(action_mean)
106
+ if deterministic:
107
+ action = action_mean.clamp(-1, 1)
108
+ return action.cpu().numpy(), action_mean.cpu().numpy()
109
+ sample = torch.distributions.Normal(action_mean, action_std).sample()
110
+ action = sample.clamp(-1, 1)
111
+ return action.cpu().numpy(), sample.cpu().numpy()
112
+
113
+ def save(self, path):
114
+ torch.save({'config': self.config, 'state_dict': self.state_dict()}, path)
115
+
116
+ @classmethod
117
+ def load(cls, path):
118
+ checkpoint = torch.load(path, map_location=device, weights_only=True)
119
+ model = cls(**checkpoint['config']).to(device)
120
+ model.load_state_dict(checkpoint['state_dict'])
121
+ return model
122
+
123
+
124
+ class PPO:
125
+ def __init__(self, actor_critic, pi_lr, vf_lr, gamma, lamda, K_epochs, eps_clip, batch_size, vf_coef, entropy_coef):
126
+ self.actor_critic = actor_critic
127
+ self.pi_optimizer = optim.Adam(list(actor_critic.actor.parameters()) + [actor_critic.log_std], lr=pi_lr)
128
+ self.vf_optimizer = optim.Adam(actor_critic.critic.parameters(), lr=vf_lr)
129
+ self.gamma, self.lamda, self.K_epochs = gamma, lamda, K_epochs
130
+ self.eps_clip, self.batch_size, self.vf_coef, self.entropy_coef = eps_clip, batch_size, vf_coef, entropy_coef
131
+
132
+ def compute_advantages(self, rewards, state_values, terminated, truncated, next_values):
133
+ T, N = rewards.shape
134
+ advantages, gae = torch.zeros_like(rewards), torch.zeros(N, device=rewards.device)
135
+ episode_ends = terminated | truncated
136
+ for t in reversed(range(T)):
137
+ delta = rewards[t] + self.gamma * next_values[t] * ~terminated[t] - state_values[t]
138
+ gae = delta + self.gamma * self.lamda * ~episode_ends[t] * gae
139
+ advantages[t] = gae
140
+ returns = advantages + state_values
141
+ advantages = (advantages - advantages.mean()) / (advantages.std(correction=0) + 1e-8)
142
+ return advantages.reshape(-1), returns.reshape(-1)
143
+
144
+ def compute_loss(self, batch_states, batch_actions, batch_logprobs, batch_advantages, batch_returns):
145
+ action_means, action_stds, state_values = self.actor_critic(batch_states)
146
+ dist = torch.distributions.Normal(action_means, action_stds)
147
+ action_logprobs = dist.log_prob(batch_actions).sum(-1)
148
+ ratios = torch.exp(action_logprobs - batch_logprobs)
149
+ actor_loss = -torch.min(ratios * batch_advantages, torch.clamp(ratios, 1-self.eps_clip, 1+self.eps_clip) * batch_advantages).mean()
150
+ critic_loss = F.mse_loss(state_values.squeeze(-1), batch_returns)
151
+ entropy = dist.entropy().sum(-1).mean()
152
+
153
+ return actor_loss + self.vf_coef * critic_loss - self.entropy_coef * entropy
154
+
155
+ def train_step(self, batch_states, batch_actions, batch_logprobs, batch_advantages, batch_returns):
156
+ self.pi_optimizer.zero_grad()
157
+ self.vf_optimizer.zero_grad()
158
+ loss = self.compute_loss(batch_states, batch_actions, batch_logprobs, batch_advantages, batch_returns)
159
+ loss.backward()
160
+ torch.nn.utils.clip_grad_norm_(list(self.actor_critic.actor.parameters()) + [self.actor_critic.log_std], max_norm=0.5)
161
+ torch.nn.utils.clip_grad_norm_(self.actor_critic.critic.parameters(), max_norm=0.5)
162
+ self.pi_optimizer.step()
163
+ self.vf_optimizer.step()
164
+ return loss.detach()
165
+
166
+ def prepare(self, batch):
167
+ T, N = batch.rewards.shape
168
+ obs_flat = self.actor_critic.preprocess(batch.observations.reshape(T * N, -1))
169
+ raw_act_flat = torch.from_numpy(batch.raw_actions.reshape(T * N, -1)).to(device=device, dtype=torch.float32)
170
+ rew_t = torch.from_numpy(batch.rewards).to(device=device, dtype=torch.float32)
171
+ terminated_t = torch.from_numpy(batch.terminated).to(device=device)
172
+ truncated_t = torch.from_numpy(batch.truncated).to(device=device)
173
+
174
+ with torch.no_grad():
175
+ mean, std, val = self.actor_critic(obs_flat)
176
+ dist = torch.distributions.Normal(mean, std)
177
+ old_logprobs = dist.log_prob(raw_act_flat).sum(-1)
178
+ old_values = val.squeeze(-1).view(T, N)
179
+ next_obs_t = self.actor_critic.preprocess(batch.next_observations.reshape(T * N, -1))
180
+ next_values = self.actor_critic.critic(next_obs_t).squeeze(-1).view(T, N)
181
+ advantages, returns = self.compute_advantages(rew_t, old_values, terminated_t, truncated_t, next_values)
182
+
183
+ return obs_flat, raw_act_flat, old_logprobs, advantages, returns
184
+
185
+ def update(self, batch):
186
+ obs_flat, raw_act_flat, old_logprobs, advantages, returns = self.prepare(batch)
187
+ num_samples = obs_flat.size(0)
188
+ for _ in range(self.K_epochs):
189
+ perm = torch.randperm(num_samples, device=device)
190
+ for start in range(0, num_samples, self.batch_size):
191
+ idx = perm[start:start + self.batch_size]
192
+ self.train_step(obs_flat[idx], raw_act_flat[idx], old_logprobs[idx], advantages[idx], returns[idx])
193
+
194
+ def rollout(env, actor_critic, num_steps=None, num_episodes=None, deterministic=False, state=None):
195
+ assert (num_steps is None) != (num_episodes is None), "Specify exactly one: num_steps or num_episodes"
196
+ if num_episodes is not None and num_episodes <= 0:
197
+ raise ValueError('num_episodes must be positive')
198
+
199
+ N = env.num_envs
200
+ if state is None:
201
+ state = RolloutState(env.reset()[0], np.zeros(N))
202
+ states = state.observations
203
+ ep_returns, ep_rets = [], state.episode_returns
204
+
205
+ collect = num_steps is not None
206
+ remaining = None if collect else (num_episodes + np.arange(N)) // N
207
+ if collect:
208
+ T = num_steps // N
209
+ obs = np.empty((T, N, state_dim), dtype=np.float32)
210
+ next_obs = np.empty_like(obs)
211
+ raw_act = np.empty((T, N, action_dim), dtype=np.float32)
212
+ rew = np.empty((T, N), dtype=np.float32)
213
+ terminations = np.empty((T, N), dtype=bool)
214
+ truncations = np.empty((T, N), dtype=bool)
215
+
216
+ t = 0
217
+ while True:
218
+ actions, raw_actions = actor_critic.act(states, deterministic=deterministic)
219
+ if collect:
220
+ obs[t], raw_act[t] = states, raw_actions
221
+
222
+ states, rewards, terminated, truncated, infos = env.step(actions)
223
+ d = np.logical_or(terminated, truncated)
224
+ if collect:
225
+ rew[t], terminations[t], truncations[t] = rewards, terminated, truncated
226
+ next_obs[t] = states
227
+ for idx in np.flatnonzero(d):
228
+ next_obs[t, idx] = infos['final_obs'][idx]
229
+
230
+ ep_rets += rewards
231
+ track_episode_returns(d, ep_returns, ep_rets, remaining)
232
+ t += 1
233
+
234
+ if (collect and t >= T) or (not collect and not remaining.any()):
235
+ break
236
+
237
+ state.observations = states
238
+ return (RolloutBatch(obs, raw_act, rew, terminations, truncations, next_obs), ep_returns) if collect else ep_returns
239
+
240
+ class TrainingContext:
241
+ def __init__(self):
242
+ self.ac = ActorCritic(state_dim, action_dim, hidden_dim, actor_layers, critic_layers).to(device)
243
+ self.ppo = PPO(self.ac, pi_lr, vf_lr, gamma, gae_lambda, K_epochs, eps_clip, batch_size, vf_coef, entropy_coef)
244
+
245
+ self.env = make_env(num_envs)
246
+ self.rollout_state = RolloutState(self.env.reset()[0], np.zeros(num_envs))
247
+ self.eval_env = make_env(min(num_envs, eval_episodes), asynchronous=False)
248
+ self.all_episode_returns = []
249
+ self.last_eval_stochastic = float('-inf')
250
+ self.pbar = trange(max_epochs, desc="Training", unit='epoch')
251
+ self.rollout_times = []
252
+ self.update_times = []
253
+
254
+ if PLOT:
255
+ plt.ion()
256
+ _, self.ax = plt.subplots()
257
+ else:
258
+ self.ax = None
259
+
260
+ def cleanup(self):
261
+ self.env.close()
262
+ self.eval_env.close()
263
+ self.pbar.close()
264
+ if PLOT:
265
+ plt.ioff()
266
+ plt.show()
267
+
268
+ def train_one_epoch(epoch, ctx):
269
+ t0 = time.perf_counter()
270
+ batch, ep_rets = rollout(
271
+ ctx.env, ctx.ac, num_steps=steps_per_epoch, state=ctx.rollout_state
272
+ )
273
+ t1 = time.perf_counter()
274
+ ctx.rollout_times.append(t1 - t0)
275
+
276
+ t0 = time.perf_counter()
277
+ ctx.ppo.update(batch)
278
+ t1 = time.perf_counter()
279
+ ctx.update_times.append(t1 - t0)
280
+
281
+ ctx.all_episode_returns.extend(ep_rets)
282
+ ctx.pbar.update(1)
283
+
284
+ if epoch % eval_interval == 0:
285
+ ctx.last_eval_stochastic = evaluate_policy(
286
+ ctx.ac, env=ctx.eval_env, deterministic=False
287
+ )
288
+ if RENDER:
289
+ evaluate_policy(ctx.ac, render=True, num_episodes=RENDER_EPISODES, deterministic=False)
290
+
291
+ if epoch % log_interval == 0:
292
+ s = ctx.ac.log_std.clamp(-5, 2).exp().detach().cpu().numpy()
293
+ rollout_ms = np.mean(ctx.rollout_times[-log_interval:]) * 1000
294
+ update_ms = np.mean(ctx.update_times[-log_interval:]) * 1000
295
+ total_ms = rollout_ms + update_ms
296
+ ctx.pbar.write(f"Epoch {epoch:3d} n_ep={len(ep_rets):3d} ret={np.mean(ep_rets):7.1f}±{np.std(ep_rets):5.1f} eval_stoch={ctx.last_eval_stochastic:6.1f} σ=[{s[0]:.2f} {s[1]:.2f}] ⏱ {total_ms:.0f}ms (rollout:{rollout_ms:.0f}ms update:{update_ms:.0f}ms)")
297
+
298
+ if PLOT and epoch % (log_interval * 2) == 0:
299
+ update_plot(ctx.ax, ctx.all_episode_returns, solved_threshold)
300
+
301
+ if ctx.last_eval_stochastic >= solved_threshold:
302
+ ctx.pbar.write(f"\n{'='*60}\nSOLVED at epoch {epoch}! eval_stoch={ctx.last_eval_stochastic:.1f} ≥ {solved_threshold}\n{'='*60}")
303
+ if RENDER:
304
+ evaluate_policy(ctx.ac, render=True, num_episodes=RENDER_EPISODES, deterministic=False)
305
+ return True
306
+
307
+ return False
308
+
309
+ def train():
310
+ ctx = TrainingContext()
311
+ for epoch in range(max_epochs):
312
+ if train_one_epoch(epoch, ctx):
313
+ break
314
+ ctx.cleanup()
315
+
316
+ def evaluate_policy(actor_critic, num_episodes=eval_episodes, render=False, env=None, deterministic=True):
317
+ close_env = env is None
318
+ if env is None:
319
+ env = make_env(1 if render else num_episodes, render)
320
+ ep_rets = rollout(env, actor_critic, num_episodes=num_episodes, deterministic=deterministic)
321
+ if close_env:
322
+ env.close()
323
+ return float(np.mean(ep_rets)) if ep_rets else 0.0
324
+
325
+ if __name__ == '__main__':
326
+ print(f"Using {device} device")
327
+ if METAL:
328
+ print(f"MPS available: {torch.backends.mps.is_available()}")
329
+ print(f"MPS built: {torch.backends.mps.is_built()}")
330
+ if device.type == 'mps':
331
+ print("✅ MPS device active")
332
+ else:
333
+ print("⚠️ METAL=1 but device is not MPS")
334
+ print(f"PyTorch threads: {torch.get_num_threads()}")
335
+ train()
@@ -0,0 +1,2 @@
1
+ -r requirements.txt
2
+ torch==2.8.0
@@ -0,0 +1,4 @@
1
+ -r ../../requirements.txt
2
+ gymnasium[box2d]==1.1.1
3
+ tqdm>=4.65
4
+ matplotlib>=3.5
@@ -0,0 +1 @@
1
+ {"seconds": 0.614734499999031, "event": "config", "framework": "tinygrad", "environment": "LunarLanderContinuous-v3", "source_commit": "8f0b31a96b03e23f43b35dcf12e36302e0036e40", "device": "CPU", "dtype": "dtypes.float", "platform": "macOS-27.0.1-arm64-arm-64bit", "python": "3.11.14", "versions": {"gymnasium": "1.1.1", "numpy": "2.4.6", "tinygrad": "0.13.0"}, "backend_source": {"url": "https://github.com/tinygrad/tinygrad.git", "vcs_info": {"vcs": "git", "commit_id": "5b60a09ab0a7f20c3426e505251c48cac020d1fa", "requested_revision": "5b60a09ab0a7f20c3426e505251c48cac020d1fa"}}, "settings": {"num_envs": 4, "steps_per_epoch": 20000, "max_epochs": 3, "batch_size": 10000, "K_epochs": 20, "pi_lr": 0.001, "vf_lr": 0.001, "gamma": 0.99, "gae_lambda": 0.95, "eps_clip": 0.2, "vf_coef": 1.0, "entropy_coef": 0.001, "eval_interval": 5, "eval_episodes": 100, "solved_threshold": 200}, "architecture": {"state_dim": 8, "action_dim": 2, "hidden_dim": 128, "actor_layers": 4, "critic_layers": 4}, "beam": "1", "torch_threads": null}
@@ -0,0 +1,6 @@
1
+ {"seconds": 0.48239595799532253, "event": "config", "framework": "torch", "environment": "LunarLanderContinuous-v3", "source_commit": "8f0b31a96b03e23f43b35dcf12e36302e0036e40", "device": "cpu", "dtype": "torch.float32", "platform": "macOS-27.0.1-arm64-arm-64bit", "python": "3.11.14", "versions": {"gymnasium": "1.1.1", "numpy": "2.4.6", "torch": "2.8.0"}, "backend_source": {}, "settings": {"num_envs": 4, "steps_per_epoch": 20000, "max_epochs": 3, "batch_size": 10000, "K_epochs": 20, "pi_lr": 0.001, "vf_lr": 0.001, "gamma": 0.99, "gae_lambda": 0.95, "eps_clip": 0.2, "vf_coef": 1.0, "entropy_coef": 0.001, "eval_interval": 5, "eval_episodes": 100, "solved_threshold": 200}, "architecture": {"state_dim": 8, "action_dim": 2, "hidden_dim": 128, "actor_layers": 4, "critic_layers": 4}, "beam": "1", "torch_threads": 4}
2
+ {"seconds": 2.1622258749994216, "event": "epoch", "steps": 20000, "seconds_total": 1.679733083001338, "rollout_seconds": 0.8259355000045616, "update_seconds": 0.43762720800441457, "other_seconds": 0.41617037499236176}
3
+ {"seconds": 2.1622854999950505, "event": "evaluation", "steps": 20000, "score": -144.3682780688725}
4
+ {"seconds": 3.3910218749952037, "event": "epoch", "steps": 40000, "seconds_total": 1.2287172080032178, "rollout_seconds": 0.8063115420009126, "update_seconds": 0.4221108749989071, "other_seconds": 0.0002947910033981316}
5
+ {"seconds": 4.608377207994636, "event": "epoch", "steps": 60000, "seconds_total": 1.2172969999955967, "rollout_seconds": 0.7961072079997393, "update_seconds": 0.4209024999945541, "other_seconds": 0.00028729200130328536}
6
+ {"seconds": 5.081694374996005, "event": "final", "steps": 60000, "score": -104.08706254110945, "seconds_to_stop": 4.608431249995192, "stopped_on_threshold": false, "policy": "policy.pt"}
@@ -0,0 +1,53 @@
1
+ {
2
+ "date": "2026-10-26",
3
+ "hardware": "Apple M4",
4
+ "purpose": "Locate wall time in the real training path. These are independent stochastic runs, not identical-input backend comparisons.",
5
+ "source_commit": "483d1da6b213611ff1dccd9520149edd4777671d",
6
+ "selected_runs": {
7
+ "torch": "phase-torch-thread4.jsonl",
8
+ "tinygrad": "phase-tinygrad-thread10.jsonl"
9
+ },
10
+ "run_order": [
11
+ "phase-initial-torch.jsonl",
12
+ "phase-initial-tinygrad-thread4.jsonl",
13
+ "phase-tinygrad-thread4-error.jsonl",
14
+ "phase-torch-thread4.jsonl",
15
+ "phase-tinygrad-thread10.jsonl"
16
+ ],
17
+ "environment": {
18
+ "METAL": "0",
19
+ "DEV": "CPU",
20
+ "NUM_ENVS": "4",
21
+ "NUM_CPU_THREADS": "10",
22
+ "BEAM": "1",
23
+ "JIT": "1",
24
+ "XDG_CACHE_HOME": "$PWD/.venv/cache"
25
+ },
26
+ "requested_threads": {
27
+ "torch": 4,
28
+ "tinygrad": 10
29
+ },
30
+ "compiler_cache_directory_reused": true,
31
+ "epochs_per_completed_run": 3,
32
+ "warmup": "No epochs excluded from the records. Article table shows zero-based epoch 2; first epoch includes initial JIT calls.",
33
+ "timings": {
34
+ "seconds_total": "Wall time around train_one_epoch. Excludes backend import, environment construction, save/load and the final evaluation.",
35
+ "rollout_seconds": "Collection of 20000 transitions, including policy.act, environment step/IPC, and rollout buffer work.",
36
+ "update_seconds": "PPO.update, including preparation/GAE, shuffling/indexing, and 40 optimizer steps.",
37
+ "other_seconds": "seconds_total minus rollout_seconds and update_seconds. Includes scheduled evaluation, progress display and logging."
38
+ },
39
+ "evaluation": "100 fresh stochastic episodes at epoch 0 and after save/load. Three-epoch probes are not convergence runs.",
40
+ "initial_runs": "First Torch run and the first four-thread tinygrad failure used 8f0b31a. The initial failure has only a config record because error logging was not yet implemented.",
41
+ "failed_run": {
42
+ "file": "phase-tinygrad-thread4-error.jsonl",
43
+ "NUM_CPU_THREADS": "4",
44
+ "error_type": "KernelOptError",
45
+ "message": "too many threads",
46
+ "location": "Replaying cached beam-search options during PPO.prepare, before any epoch completed.",
47
+ "cache_manually_cleared": false
48
+ },
49
+ "reproduce": [
50
+ "METAL=0 DEV=CPU NUM_ENVS=4 NUM_CPU_THREADS=10 BEAM=1 JIT=1 XDG_CACHE_HOME=\"$PWD/.venv/cache\" .venv/bin/python -c 'from pathlib import Path; from examples.lunar_lander import reference; from examples.lunar_lander.run_learning import run; reference.max_epochs=3; run(\"torch\", Path(\"runs/torch-phases\"))'",
51
+ "METAL=0 DEV=CPU NUM_ENVS=4 NUM_CPU_THREADS=10 BEAM=1 JIT=1 XDG_CACHE_HOME=\"$PWD/.venv/cache\" .venv/bin/python -c 'from pathlib import Path; from examples.lunar_lander import train; from examples.lunar_lander.run_learning import run; train.max_epochs=3; run(\"tinygrad\", Path(\"runs/tinygrad-phases\"))'"
52
+ ]
53
+ }
@@ -0,0 +1,6 @@
1
+ {"seconds": 0.707170124995173, "event": "config", "framework": "tinygrad", "environment": "LunarLanderContinuous-v3", "source_commit": "483d1da6b213611ff1dccd9520149edd4777671d", "device": "CPU", "dtype": "dtypes.float", "platform": "macOS-27.0.1-arm64-arm-64bit", "python": "3.11.14", "versions": {"gymnasium": "1.1.1", "numpy": "2.4.6", "tinygrad": "0.13.0"}, "backend_source": {"url": "https://github.com/tinygrad/tinygrad.git", "vcs_info": {"vcs": "git", "commit_id": "5b60a09ab0a7f20c3426e505251c48cac020d1fa", "requested_revision": "5b60a09ab0a7f20c3426e505251c48cac020d1fa"}}, "settings": {"num_envs": 4, "steps_per_epoch": 20000, "max_epochs": 3, "batch_size": 10000, "K_epochs": 20, "pi_lr": 0.001, "vf_lr": 0.001, "gamma": 0.99, "gae_lambda": 0.95, "eps_clip": 0.2, "vf_coef": 1.0, "entropy_coef": 0.001, "eval_interval": 5, "eval_episodes": 100, "solved_threshold": 200}, "architecture": {"state_dim": 8, "action_dim": 2, "hidden_dim": 128, "actor_layers": 4, "critic_layers": 4}, "beam": "1", "torch_threads": null, "tinygrad_cpu_threads": 10}
2
+ {"seconds": 27.337733790998755, "event": "epoch", "steps": 20000, "seconds_total": 26.63045024999883, "rollout_seconds": 10.67004020800232, "update_seconds": 10.632532167001045, "other_seconds": 5.327877874995465}
3
+ {"seconds": 27.33781787499902, "event": "evaluation", "steps": 20000, "score": -148.26613410664652}
4
+ {"seconds": 41.51010841599782, "event": "epoch", "steps": 40000, "seconds_total": 14.172251832998882, "rollout_seconds": 9.591514917003224, "update_seconds": 4.580453624999791, "other_seconds": 0.0002832909958669916}
5
+ {"seconds": 55.548277791000146, "event": "epoch", "steps": 60000, "seconds_total": 14.038110916000733, "rollout_seconds": 9.761195333005162, "update_seconds": 4.276613417001499, "other_seconds": 0.00030216599407140166}
6
+ {"seconds": 62.28786079099518, "event": "final", "steps": 60000, "score": -90.02494137380951, "seconds_to_stop": 55.548339540997404, "stopped_on_threshold": false, "policy": "policy.safetensors"}