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.
- netris_rl-0.1.0/.gitignore +36 -0
- netris_rl-0.1.0/LICENSE +21 -0
- netris_rl-0.1.0/PKG-INFO +83 -0
- netris_rl-0.1.0/README.md +39 -0
- netris_rl-0.1.0/pyproject.toml +49 -0
- netris_rl-0.1.0/src/netris_rl/__init__.py +3 -0
- netris_rl-0.1.0/src/netris_rl/anchored_ppo.py +160 -0
- netris_rl-0.1.0/src/netris_rl/callbacks.py +214 -0
- netris_rl-0.1.0/src/netris_rl/cli/__init__.py +0 -0
- netris_rl-0.1.0/src/netris_rl/cli/bc.py +170 -0
- netris_rl-0.1.0/src/netris_rl/cli/collect.py +158 -0
- netris_rl-0.1.0/src/netris_rl/cli/eval.py +81 -0
- netris_rl-0.1.0/src/netris_rl/cli/train.py +237 -0
- netris_rl-0.1.0/src/netris_rl/features.py +61 -0
- netris_rl-0.1.0/src/netris_rl/probe_callback.py +120 -0
- netris_rl-0.1.0/src/netris_rl/teacher.py +169 -0
- netris_rl-0.1.0/tests/conftest.py +11 -0
- netris_rl-0.1.0/tests/test_anchored_ppo.py +49 -0
- netris_rl-0.1.0/tests/test_features.py +63 -0
- netris_rl-0.1.0/tests/test_probe.py +108 -0
- netris_rl-0.1.0/tests/test_teacher.py +84 -0
|
@@ -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
|
netris_rl-0.1.0/LICENSE
ADDED
|
@@ -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.
|
netris_rl-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|