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 +3 -0
- netris_rl/anchored_ppo.py +160 -0
- netris_rl/callbacks.py +214 -0
- netris_rl/cli/__init__.py +0 -0
- netris_rl/cli/bc.py +170 -0
- netris_rl/cli/collect.py +158 -0
- netris_rl/cli/eval.py +81 -0
- netris_rl/cli/train.py +237 -0
- netris_rl/features.py +61 -0
- netris_rl/probe_callback.py +120 -0
- netris_rl/teacher.py +169 -0
- netris_rl-0.1.0.dist-info/METADATA +83 -0
- netris_rl-0.1.0.dist-info/RECORD +16 -0
- netris_rl-0.1.0.dist-info/WHEEL +4 -0
- netris_rl-0.1.0.dist-info/entry_points.txt +5 -0
- netris_rl-0.1.0.dist-info/licenses/LICENSE +21 -0
netris_rl/__init__.py
ADDED
|
@@ -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()
|