netris-rl 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.
@@ -0,0 +1,36 @@
1
+ # Python caches and bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .hypothesis/
8
+ .ipynb_checkpoints/
9
+
10
+ # Coverage
11
+ .coverage
12
+ .coverage.*
13
+ htmlcov/
14
+
15
+ # Virtual environments
16
+ venv/
17
+ .venv/
18
+ env/
19
+
20
+ # Build artifacts
21
+ dist/
22
+ build/
23
+ *.egg-info/
24
+
25
+ # Game Boy ROM (copyrighted, never commit)
26
+ *.gb
27
+ *.gb.ram
28
+
29
+ # Local logs and model artifacts
30
+ logs/
31
+ models/
32
+ *.zip
33
+ *.pkl
34
+
35
+ # Build
36
+ .pypirc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Aranda
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,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: netris-rl
3
+ Version: 0.1.0
4
+ Summary: Behavior cloning, DAgger and anchored PPO training for the netris environment
5
+ Project-URL: Homepage, https://github.com/javi-aranda/netris
6
+ Author: Javier Aranda
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Javier Aranda
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: behavior-cloning,gameboy,ppo,reinforcement-learning,tetris
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
35
+ Requires-Python: >=3.11
36
+ Requires-Dist: netris<0.2,>=0.1
37
+ Requires-Dist: stable-baselines3>=2.0
38
+ Requires-Dist: tensorboard>=2.0
39
+ Requires-Dist: torch>=2.0
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=8.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.6; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # netris-rl
46
+
47
+ Training utilities for the [netris](../netris) Game Boy Tetris environment:
48
+ behavior cloning from a heuristic teacher, DAgger rounds and PPO fine-tuning
49
+ with a KL anchor.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install netris-rl
55
+ ```
56
+
57
+ ## CLIs
58
+
59
+ ```bash
60
+ # collect teacher demonstrations (optionally DAgger with --policy-model)
61
+ netris-collect --rom /path/to/tetris.gb --steps 30000 --workers 2
62
+
63
+ # behavior cloning (optionally warm-started with --init-model)
64
+ netris-bc --demos "logs/demos/demos_*.npz" --epochs 30 --output logs/bc/model
65
+
66
+ # PPO fine-tuning, optionally anchored to a frozen reference policy
67
+ netris-train --rom /path/to/tetris.gb --resume --no-obs-norm \
68
+ --anchor-model logs/bc/model.zip --total-steps 500000
69
+
70
+ # evaluation
71
+ netris-eval --model logs/best_model/best_model.zip --episodes 20
72
+ ```
73
+
74
+ ## Pipeline used to reach a line-clearing agent
75
+
76
+ 1. `netris-collect` with the analytic Dellacherie teacher.
77
+ 2. `netris-bc` (behavior cloning).
78
+ 3. `netris-collect --policy-model` (DAgger), then `netris-bc --init-model`.
79
+ 4. `netris-train --resume --anchor-model <bc model>` (anchored PPO).
80
+
81
+ ## License
82
+
83
+ MIT.
@@ -0,0 +1,39 @@
1
+ # netris-rl
2
+
3
+ Training utilities for the [netris](../netris) Game Boy Tetris environment:
4
+ behavior cloning from a heuristic teacher, DAgger rounds and PPO fine-tuning
5
+ with a KL anchor.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install netris-rl
11
+ ```
12
+
13
+ ## CLIs
14
+
15
+ ```bash
16
+ # collect teacher demonstrations (optionally DAgger with --policy-model)
17
+ netris-collect --rom /path/to/tetris.gb --steps 30000 --workers 2
18
+
19
+ # behavior cloning (optionally warm-started with --init-model)
20
+ netris-bc --demos "logs/demos/demos_*.npz" --epochs 30 --output logs/bc/model
21
+
22
+ # PPO fine-tuning, optionally anchored to a frozen reference policy
23
+ netris-train --rom /path/to/tetris.gb --resume --no-obs-norm \
24
+ --anchor-model logs/bc/model.zip --total-steps 500000
25
+
26
+ # evaluation
27
+ netris-eval --model logs/best_model/best_model.zip --episodes 20
28
+ ```
29
+
30
+ ## Pipeline used to reach a line-clearing agent
31
+
32
+ 1. `netris-collect` with the analytic Dellacherie teacher.
33
+ 2. `netris-bc` (behavior cloning).
34
+ 3. `netris-collect --policy-model` (DAgger), then `netris-bc --init-model`.
35
+ 4. `netris-train --resume --anchor-model <bc model>` (anchored PPO).
36
+
37
+ ## License
38
+
39
+ MIT.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "netris-rl"
7
+ version = "0.1.0"
8
+ description = "Behavior cloning, DAgger and anchored PPO training for the netris environment"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Javier Aranda" }]
13
+ keywords = ["reinforcement-learning", "ppo", "behavior-cloning", "tetris", "gameboy"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ ]
21
+ dependencies = [
22
+ "netris>=0.1,<0.2",
23
+ "stable-baselines3>=2.0",
24
+ "torch>=2.0",
25
+ "tensorboard>=2.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "ruff>=0.6"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/javi-aranda/netris"
33
+
34
+ [project.scripts]
35
+ netris-train = "netris_rl.cli.train:main"
36
+ netris-eval = "netris_rl.cli.eval:main"
37
+ netris-collect = "netris_rl.cli.collect:main"
38
+ netris-bc = "netris_rl.cli.bc:main"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/netris_rl"]
42
+
43
+ [tool.pytest.ini_options]
44
+ markers = ["rom: requires a GameBoy Tetris ROM"]
45
+ addopts = "-m \"not rom\""
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
@@ -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)
@@ -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