netris-rl 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.
netris_rl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Training utilities for the Netris GameBoy Tetris environment."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,160 @@
1
+ """PPO with an auxiliary KL anchor to a frozen reference policy.
2
+
3
+ Used to fine-tune a behavior-cloned policy without letting RL drift destroy
4
+ it: the total loss is `PPO loss + anchor_coef * KL(anchor || policy)`.
5
+
6
+ `train()` is adapted from stable-baselines3's PPO (MIT license), with the
7
+ anchor term added.
8
+ """
9
+
10
+ import numpy as np
11
+ import torch as th
12
+ import torch.nn.functional as F
13
+ from gymnasium import spaces
14
+ from stable_baselines3 import PPO
15
+ from stable_baselines3.common.utils import explained_variance
16
+
17
+
18
+ class AnchoredPPO(PPO):
19
+ def __init__(self, *args, anchor_model=None, anchor_coef=0.05, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self.anchor_coef = anchor_coef
22
+ self.anchor_policy = None
23
+ if anchor_model is not None:
24
+ self.set_anchor(anchor_model)
25
+
26
+ def set_anchor(self, anchor_model, coef=None):
27
+ policy = anchor_model.policy if hasattr(anchor_model, "policy") else anchor_model
28
+ self.anchor_policy = policy
29
+ self.anchor_policy.set_training_mode(False)
30
+ for parameter in self.anchor_policy.parameters():
31
+ parameter.requires_grad_(False)
32
+ if coef is not None:
33
+ self.anchor_coef = coef
34
+
35
+ def train(self) -> None:
36
+ self.policy.set_training_mode(True)
37
+ self._update_learning_rate(self.policy.optimizer)
38
+
39
+ clip_range = self.clip_range(self._current_progress_remaining)
40
+ clip_range_vf = None
41
+ if self.clip_range_vf is not None:
42
+ clip_range_vf = self.clip_range_vf(self._current_progress_remaining)
43
+
44
+ entropy_losses = []
45
+ pg_losses, value_losses = [], []
46
+ clip_fractions = []
47
+ anchor_kls = []
48
+
49
+ continue_training = True
50
+ for epoch in range(self.n_epochs):
51
+ approx_kl_divs = []
52
+ for rollout_data in self.rollout_buffer.get(self.batch_size):
53
+ actions = rollout_data.actions
54
+ if isinstance(self.action_space, spaces.Discrete):
55
+ actions = rollout_data.actions.long().flatten()
56
+
57
+ values, log_prob, entropy = self.policy.evaluate_actions(
58
+ rollout_data.observations, actions
59
+ )
60
+ values = values.flatten()
61
+ advantages = rollout_data.advantages
62
+ if self.normalize_advantage and len(advantages) > 1:
63
+ advantages = (advantages - advantages.mean()) / (
64
+ advantages.std() + 1e-8
65
+ )
66
+
67
+ ratio = th.exp(log_prob - rollout_data.old_log_prob)
68
+ policy_loss_1 = advantages * ratio
69
+ policy_loss_2 = advantages * th.clamp(
70
+ ratio, 1 - clip_range, 1 + clip_range
71
+ )
72
+ policy_loss = -th.min(policy_loss_1, policy_loss_2).mean()
73
+
74
+ pg_losses.append(policy_loss.item())
75
+ clip_fraction = th.mean((th.abs(ratio - 1) > clip_range).float()).item()
76
+ clip_fractions.append(clip_fraction)
77
+
78
+ if clip_range_vf is None:
79
+ values_pred = values
80
+ else:
81
+ values_pred = rollout_data.old_values + th.clamp(
82
+ values - rollout_data.old_values, -clip_range_vf, clip_range_vf
83
+ )
84
+ value_loss = F.mse_loss(rollout_data.returns, values_pred)
85
+ value_losses.append(value_loss.item())
86
+
87
+ if entropy is None:
88
+ entropy_loss = -th.mean(-log_prob)
89
+ else:
90
+ entropy_loss = -th.mean(entropy)
91
+ entropy_losses.append(entropy_loss.item())
92
+
93
+ loss = (
94
+ policy_loss
95
+ + self.ent_coef * entropy_loss
96
+ + self.vf_coef * value_loss
97
+ )
98
+
99
+ if self.anchor_policy is not None:
100
+ with th.no_grad():
101
+ anchor_dist = self.anchor_policy.get_distribution(
102
+ rollout_data.observations
103
+ ).distribution
104
+ policy_dist = self.policy.get_distribution(
105
+ rollout_data.observations
106
+ ).distribution
107
+ anchor_kl = th.distributions.kl_divergence(
108
+ anchor_dist, policy_dist
109
+ ).mean()
110
+ loss = loss + self.anchor_coef * anchor_kl
111
+ anchor_kls.append(float(anchor_kl.item()))
112
+
113
+ with th.no_grad():
114
+ log_ratio = log_prob - rollout_data.old_log_prob
115
+ approx_kl_div = th.mean(
116
+ (th.exp(log_ratio) - 1) - log_ratio
117
+ ).cpu().numpy()
118
+ approx_kl_divs.append(approx_kl_div)
119
+
120
+ if self.target_kl is not None and approx_kl_div > 1.5 * self.target_kl:
121
+ continue_training = False
122
+ if self.verbose >= 1:
123
+ print(
124
+ f"Early stopping at step {epoch} due to reaching max kl: "
125
+ f"{approx_kl_div:.2f}"
126
+ )
127
+ break
128
+
129
+ self.policy.optimizer.zero_grad()
130
+ loss.backward()
131
+ th.nn.utils.clip_grad_norm_(
132
+ self.policy.parameters(), self.max_grad_norm
133
+ )
134
+ self.policy.optimizer.step()
135
+
136
+ self._n_updates += 1
137
+ if not continue_training:
138
+ break
139
+
140
+ explained_var = explained_variance(
141
+ self.rollout_buffer.values.flatten(),
142
+ self.rollout_buffer.returns.flatten(),
143
+ )
144
+
145
+ self.logger.record("train/entropy_loss", np.mean(entropy_losses))
146
+ self.logger.record("train/policy_gradient_loss", np.mean(pg_losses))
147
+ self.logger.record("train/value_loss", np.mean(value_losses))
148
+ self.logger.record("train/approx_kl", np.mean(approx_kl_divs))
149
+ self.logger.record("train/clip_fraction", np.mean(clip_fractions))
150
+ self.logger.record("train/loss", loss.item())
151
+ self.logger.record("train/explained_variance", explained_var)
152
+ if anchor_kls:
153
+ self.logger.record("train/anchor_kl", np.mean(anchor_kls))
154
+ if hasattr(self.policy, "log_std"):
155
+ self.logger.record("train/std", th.exp(self.policy.log_std).mean().item())
156
+
157
+ self.logger.record("train/n_updates", self._n_updates, exclude="tensorboard")
158
+ self.logger.record("train/clip_range", clip_range)
159
+ if self.clip_range_vf is not None:
160
+ self.logger.record("train/clip_range_vf", clip_range_vf)
netris_rl/callbacks.py ADDED
@@ -0,0 +1,214 @@
1
+ """Training callbacks: episode metrics, evaluation and best-model selection."""
2
+
3
+ import csv
4
+ import json
5
+ import math
6
+ import os
7
+ from collections import Counter
8
+ from datetime import UTC, datetime
9
+ from typing import ClassVar
10
+
11
+ from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
12
+
13
+
14
+ class TetrisMetricsCallback(BaseCallback):
15
+ """Persists per-episode metrics and logs them to TensorBoard.
16
+
17
+ Environments put an `episode_summary` dict in `info` when an episode ends
18
+ (done or truncated). Because envs may live in subprocesses, writing the CSV
19
+ here (main process) is the only writer.
20
+ """
21
+
22
+ FIELDNAMES: ClassVar[list] = [
23
+ "timestamp", "episode", "steps", "pieces", "lines_cleared",
24
+ "max_height", "avg_bumpiness", "avg_holes", "reward_total",
25
+ "reward_lines", "reward_holes", "reward_height", "reward_bumpiness",
26
+ "rotations", "avg_placement_col", "avg_placement_height",
27
+ "action_distribution", "line_clear_distribution",
28
+ ]
29
+
30
+ def __init__(self, log_dir="logs/metrics", verbose=0):
31
+ super().__init__(verbose)
32
+ self.log_dir = log_dir
33
+ os.makedirs(log_dir, exist_ok=True)
34
+ self.csv_path = os.path.join(log_dir, "episodes.csv")
35
+ self.episode_buffer = []
36
+
37
+ def _on_step(self):
38
+ infos = self.locals.get("infos")
39
+ if infos is None:
40
+ return True
41
+
42
+ for info in infos:
43
+ summary = info.get("episode_summary") if info else None
44
+ if summary:
45
+ self.episode_buffer.append(summary)
46
+ self._record_tensorboard(summary)
47
+
48
+ if self.episode_buffer:
49
+ self._flush_episodes()
50
+
51
+ return True
52
+
53
+ def _record_tensorboard(self, summary):
54
+ if self.logger is None:
55
+ return
56
+ self.logger.record("train_ep/lines_cleared", summary["lines_cleared"])
57
+ self.logger.record("train_ep/max_height", summary["max_height"])
58
+ self.logger.record("train_ep/avg_holes", summary["avg_holes"])
59
+ self.logger.record("train_ep/avg_bumpiness", summary["avg_bumpiness"])
60
+ self.logger.record("train_ep/reward_lines", summary["reward_lines"])
61
+ self.logger.record("train_ep/reward_holes", summary["reward_holes"])
62
+ self.logger.record("train_ep/reward_height", summary["reward_height"])
63
+ self.logger.record("train_ep/reward_bumpiness", summary["reward_bumpiness"])
64
+ try:
65
+ actions = json.loads(summary.get("action_distribution", "{}"))
66
+ except (ValueError, TypeError):
67
+ actions = {}
68
+ counts = Counter({int(k): int(v) for k, v in actions.items()})
69
+ total = sum(counts.values())
70
+ if total:
71
+ entropy = -sum(
72
+ (count / total) * math.log(count / total)
73
+ for count in counts.values() if count
74
+ )
75
+ self.logger.record("train_ep/action_entropy", entropy)
76
+ top_action, top_count = counts.most_common(1)[0]
77
+ self.logger.record("train_ep/top_action", top_action)
78
+ self.logger.record("train_ep/top_action_share", top_count / total)
79
+
80
+ def _flush_episodes(self):
81
+ if not self.episode_buffer:
82
+ return
83
+ write_header = not os.path.exists(self.csv_path)
84
+ with open(self.csv_path, "a", newline="") as f:
85
+ writer = csv.DictWriter(
86
+ f, fieldnames=self.FIELDNAMES, extrasaction="ignore"
87
+ )
88
+ if write_header:
89
+ writer.writeheader()
90
+ writer.writerows(self.episode_buffer)
91
+ self.episode_buffer = []
92
+
93
+ def on_training_end(self):
94
+ self._flush_episodes()
95
+
96
+
97
+ class NetrisEvalCallback(EvalCallback):
98
+ """Evaluation callback that records lines cleared and protects the starter
99
+ (behavior-cloned) model: a new best is only saved when eval mean lines beat
100
+ the measured baseline.
101
+ """
102
+
103
+ def __init__(self, *args, run_number=1,
104
+ history_path="logs/eval_logs/lines_history.csv",
105
+ best_model_dir="logs/best_model", baseline_mean_lines=0.0,
106
+ verbose=0, **kwargs):
107
+ best_model_save_path = kwargs.pop("best_model_save_path", None)
108
+ super().__init__(
109
+ *args, verbose=verbose, best_model_save_path=None, **kwargs
110
+ )
111
+ if best_model_save_path is not None:
112
+ best_model_dir = best_model_save_path
113
+ self.best_model_dir = best_model_dir
114
+ self.baseline_mean_lines = float(baseline_mean_lines)
115
+ self.run_number = run_number
116
+ self.history_path = history_path
117
+ self.best_eval_mean_lines = self.baseline_mean_lines
118
+ self.best_eval_max_lines = 0
119
+ self.last_eval_mean_lines = 0.0
120
+ self.last_eval_max_lines = 0
121
+ self.last_eval_mean_reward = 0.0
122
+ self.last_eval_mean_length = 0.0
123
+ self._eval_episode_lines = []
124
+ self._eval_episode_lengths = []
125
+ os.makedirs(os.path.dirname(history_path), exist_ok=True)
126
+
127
+ def _log_success_callback(self, locals_, globals_):
128
+ super()._log_success_callback(locals_, globals_)
129
+ info = locals_.get("info") or {}
130
+ if locals_.get("done"):
131
+ self._eval_episode_lines.append(int(info.get("lines_cleared", 0)))
132
+ index = locals_.get("i")
133
+ lengths = locals_.get("current_lengths")
134
+ if index is not None and lengths is not None:
135
+ self._eval_episode_lengths.append(int(lengths[index]))
136
+
137
+ def _on_step(self):
138
+ num_evals_before = len(self.evaluations_timesteps)
139
+ continue_training = super()._on_step()
140
+ if len(self.evaluations_timesteps) > num_evals_before:
141
+ try:
142
+ self._on_eval_complete()
143
+ except Exception as e: # noqa: BLE001
144
+ print(f"[NetrisEvalCallback] Error reporting eval: {e}")
145
+ return continue_training
146
+
147
+ def _on_eval_complete(self):
148
+ episode_rewards = self.evaluations_results[-1]
149
+ mean_reward = float(sum(episode_rewards) / max(1, len(episode_rewards)))
150
+ lines = self._eval_episode_lines
151
+ lengths = self._eval_episode_lengths
152
+ self._eval_episode_lines = []
153
+ self._eval_episode_lengths = []
154
+
155
+ mean_lines = float(sum(lines) / max(1, len(lines)))
156
+ max_lines = max(lines) if lines else 0
157
+ mean_length = float(sum(lengths) / max(1, len(lengths))) if lengths else 0.0
158
+ timestep = int(self.evaluations_timesteps[-1])
159
+
160
+ self.last_eval_mean_lines = mean_lines
161
+ self.last_eval_max_lines = max_lines
162
+ self.last_eval_mean_reward = mean_reward
163
+ self.last_eval_mean_length = mean_length
164
+
165
+ is_new_best = mean_lines > self.best_eval_mean_lines
166
+ if is_new_best:
167
+ self.best_eval_mean_lines = mean_lines
168
+ self.best_eval_max_lines = max_lines
169
+ self._save_best_model()
170
+ print(
171
+ f"[NetrisEvalCallback] New best: {mean_lines:.2f} mean lines "
172
+ f"-> {self.best_model_dir}"
173
+ )
174
+
175
+ if self.logger is not None:
176
+ self.logger.record("eval/mean_lines", mean_lines)
177
+ self.logger.record("eval/max_lines", max_lines)
178
+ self.logger.record("eval/mean_length", mean_length)
179
+ self.logger.record("eval/mean_reward", mean_reward)
180
+ self.logger.record("eval/best_mean_lines", self.best_eval_mean_lines)
181
+
182
+ self._write_history(timestep, lines, mean_reward, mean_length)
183
+
184
+ def _save_best_model(self):
185
+ os.makedirs(self.best_model_dir, exist_ok=True)
186
+ self.model.save(os.path.join(self.best_model_dir, "best_model"))
187
+ from stable_baselines3.common.vec_env import VecNormalize
188
+
189
+ training_env = self.training_env
190
+ if isinstance(training_env, VecNormalize):
191
+ training_env.save(
192
+ os.path.join(self.best_model_dir, "vecnormalize.pkl")
193
+ )
194
+
195
+ def _write_history(self, timestep, lines, mean_reward, mean_length=0.0):
196
+ write_header = not os.path.exists(self.history_path)
197
+ with open(self.history_path, "a", newline="") as f:
198
+ writer = csv.writer(f)
199
+ if write_header:
200
+ writer.writerow([
201
+ "timestamp", "timestep", "episodes", "mean_lines",
202
+ "max_lines", "mean_reward", "mean_length",
203
+ "best_mean_lines",
204
+ ])
205
+ writer.writerow([
206
+ datetime.now(UTC).isoformat(),
207
+ timestep,
208
+ len(lines),
209
+ round(sum(lines) / max(1, len(lines)), 4),
210
+ max(lines) if lines else 0,
211
+ round(mean_reward, 4),
212
+ round(mean_length, 2),
213
+ round(self.best_eval_mean_lines, 4),
214
+ ])
File without changes
netris_rl/cli/bc.py ADDED
@@ -0,0 +1,170 @@
1
+ """Behavior cloning CLI (`netris-bc`).
2
+
3
+ Trains the CNN policy on teacher/DAgger demonstrations with cross-entropy,
4
+ optionally warm-starting from an existing model.
5
+ """
6
+
7
+ import argparse
8
+ import glob
9
+ import os
10
+ import time
11
+
12
+ import numpy as np
13
+ import torch
14
+ from netris import NetrisEnvConfig, TetrisEnv
15
+ from stable_baselines3 import PPO
16
+ from stable_baselines3.common.vec_env import DummyVecEnv, VecMonitor
17
+
18
+ from netris_rl.features import TetrisFeaturesExtractor
19
+
20
+ OBS_KEYS = [
21
+ "board",
22
+ "current_piece",
23
+ "next_piece",
24
+ "row_completion",
25
+ "holes",
26
+ "bumpiness",
27
+ "aggregate_height",
28
+ ]
29
+
30
+
31
+ def make_env(rom_path, include_next_piece):
32
+ return TetrisEnv(
33
+ rom_path,
34
+ log_level="error",
35
+ config=NetrisEnvConfig(include_next_piece=include_next_piece),
36
+ )
37
+
38
+
39
+ def load_demos(patterns, keys=None):
40
+ keys = keys or OBS_KEYS
41
+ if isinstance(patterns, str):
42
+ patterns = [patterns]
43
+ paths = []
44
+ for pattern in patterns:
45
+ paths.extend(glob.glob(pattern))
46
+ paths = sorted(set(paths))
47
+ if not paths:
48
+ raise FileNotFoundError(f"No demo files match {patterns}")
49
+ parts = [np.load(path) for path in paths]
50
+ data = {
51
+ key: np.concatenate([part[key] for part in parts])
52
+ for key in keys + ["actions"]
53
+ }
54
+ print(f"Loaded {len(data['actions'])} demonstrations from {len(paths)} files")
55
+ return data
56
+
57
+
58
+ def batch_observations(data, indices, keys):
59
+ return {
60
+ key: torch.as_tensor(data[key][indices], dtype=torch.float32)
61
+ for key in keys
62
+ }
63
+
64
+
65
+ def accuracy(policy, data, indices, keys, batch_size=1024):
66
+ correct = 0
67
+ policy.eval()
68
+ with torch.no_grad():
69
+ for start in range(0, len(indices), batch_size):
70
+ batch = indices[start:start + batch_size]
71
+ observations = batch_observations(data, batch, keys)
72
+ distribution = policy.get_distribution(observations)
73
+ predictions = distribution.distribution.probs.argmax(dim=1)
74
+ targets = torch.as_tensor(data["actions"][batch], dtype=torch.long)
75
+ correct += int((predictions == targets).sum())
76
+ policy.train()
77
+ return correct / max(1, len(indices))
78
+
79
+
80
+ def main():
81
+ parser = argparse.ArgumentParser(description="Behavior cloning for Netris")
82
+ parser.add_argument("--demos", nargs="+", default=["logs/demos/demos_*.npz"])
83
+ parser.add_argument("--rom", default=None,
84
+ help="Path to the GameBoy Tetris ROM (or set NETRIS_ROM)")
85
+ parser.add_argument("--init-model", default="",
86
+ help="Warm-start from an existing PPO/BC model")
87
+ parser.add_argument("--epochs", type=int, default=30)
88
+ parser.add_argument("--batch-size", type=int, default=256)
89
+ parser.add_argument("--learning-rate", type=float, default=3e-4)
90
+ parser.add_argument("--val-fraction", type=float, default=0.1)
91
+ parser.add_argument("--output", default="logs/bc/bc_model")
92
+ parser.add_argument("--net-arch", type=int, nargs="+", default=[256, 256])
93
+ parser.add_argument("--no-next-piece", action="store_true")
94
+ args = parser.parse_args()
95
+
96
+ obs_keys = [
97
+ key for key in OBS_KEYS
98
+ if not args.no_next_piece or key != "next_piece"
99
+ ]
100
+ data = load_demos(args.demos, obs_keys)
101
+ total = len(data["actions"])
102
+ rng = np.random.default_rng(0)
103
+ permutation = rng.permutation(total)
104
+ val_size = int(total * args.val_fraction)
105
+ val_indices = permutation[:val_size]
106
+ train_indices = permutation[val_size:]
107
+
108
+ policy_kwargs = {
109
+ "features_extractor_class": TetrisFeaturesExtractor,
110
+ "features_extractor_kwargs": {"features_dim": 256},
111
+ "net_arch": {"pi": list(args.net_arch), "vf": list(args.net_arch)},
112
+ }
113
+
114
+ env = VecMonitor(DummyVecEnv([
115
+ lambda: make_env(args.rom, include_next_piece=not args.no_next_piece)
116
+ ]))
117
+ if args.init_model:
118
+ print(f"Warm-starting from {args.init_model}")
119
+ model = PPO.load(args.init_model, env=env, device="cpu")
120
+ else:
121
+ model = PPO(
122
+ "MultiInputPolicy",
123
+ env,
124
+ policy_kwargs=policy_kwargs,
125
+ device="cpu",
126
+ verbose=0,
127
+ )
128
+ policy = model.policy
129
+ policy.train()
130
+ optimizer = torch.optim.Adam(policy.parameters(), lr=args.learning_rate)
131
+
132
+ actions = torch.as_tensor(data["actions"], dtype=torch.long)
133
+ start_time = time.time()
134
+ val_accuracy = 0.0
135
+
136
+ for epoch in range(args.epochs):
137
+ rng.shuffle(train_indices)
138
+ epoch_loss = 0.0
139
+ batches = 0
140
+ for start in range(0, len(train_indices), args.batch_size):
141
+ batch = train_indices[start:start + args.batch_size]
142
+ observations = batch_observations(data, batch, obs_keys)
143
+ distribution = policy.get_distribution(observations)
144
+ log_prob = distribution.log_prob(actions[batch])
145
+ loss = -log_prob.mean()
146
+
147
+ optimizer.zero_grad()
148
+ loss.backward()
149
+ optimizer.step()
150
+
151
+ epoch_loss += loss.item()
152
+ batches += 1
153
+
154
+ val_accuracy = accuracy(policy, data, val_indices, obs_keys)
155
+ print(
156
+ f"epoch {epoch + 1:02d}/{args.epochs} "
157
+ f"loss={epoch_loss / max(1, batches):.4f} "
158
+ f"val_accuracy={val_accuracy:.3f} "
159
+ f"elapsed={time.time() - start_time:.0f}s",
160
+ flush=True,
161
+ )
162
+
163
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
164
+ model.save(args.output)
165
+ print(f"Saved BC model to {args.output}.zip (val accuracy {val_accuracy:.3f})")
166
+ env.close()
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()