bobbydeveloper2014 commited on
Commit
50e2653
·
verified ·
1 Parent(s): 8932d7c

Update train.py

Browse files
Files changed (1) hide show
  1. train.py +617 -546
train.py CHANGED
@@ -1,555 +1,626 @@
1
- def train():
2
- """
3
- Chess AI (AlphaZero-style) — tối ưu cho Google Colab
4
- =====================================================
5
- Cách dùng trên Colab:
6
- !pip install chess tinygrad
7
- from google.colab import drive; drive.mount('/content/drive')
8
- !python chess_ai_colab.py
9
- """
10
- import os
11
- import time
12
-
13
- # ==================== ENV ====================
14
- os.environ["DEV"] = "CUDA"
15
-
16
- import chess
17
- import chess.pgn
18
- import numpy as np
19
- import pickle
20
- from tinygrad import Tensor, nn
21
- from tinygrad.nn.state import get_state_dict, load_state_dict, get_parameters
22
- from tinygrad.nn.optim import Adam
23
-
24
- def log(msg):
25
- print(msg, flush=True)
26
-
27
- # =============================================================
28
- # CONFIG
29
- # =============================================================
30
- CFG = {
31
- "mcts_sims": 16,
32
- "buffer_maxlen": 52_000,
33
- "min_buffer": 1,
34
- "batch_size": 512,
35
- "lr": 1e-4,
36
- "train_steps": 30,
37
- "save_every": 10,
38
- "pgn_limit": 2000,
39
- "res_blocks": 6,
40
- "max_moves": 81,
41
- "checkpoint_path": "dataset stockfish/latest.pkl",
42
- "pgn_path": "dataset stockfish/million_games.pgn",
43
- "log_path": "dataset stockfish/training_log.txt",
44
- }
45
- CFG["pgn_chunk"] = 500
46
- CFG["pgn_offset_file"] = "dataset stockfish/pgn_offset.pkl"
47
- CFG["channels"] = 256
48
- TOTAL_STEPS_TARGET = CFG["train_steps"]
49
- os.makedirs(os.path.dirname(CFG["checkpoint_path"]), exist_ok=True)
50
-
51
- # =============================================================
52
- # HUGGING FACE CONFIG
53
- # =============================================================
54
- HF_REPO = "bobbydeveloper2014/bybychessengine"
55
- HF_TOKEN = "yourtoken"
56
- HF_SYNC_FILES = {
57
- "latest.pkl": CFG["checkpoint_path"],
58
- "pgn_offset.pkl": CFG["pgn_offset_file"],
59
- "training_log.txt": CFG["log_path"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- hf_api = None
63
  try:
64
- from huggingface_hub import HfApi, create_repo
65
- hf_api = HfApi(token=HF_TOKEN)
66
- create_repo(HF_REPO, token=HF_TOKEN, exist_ok=True, repo_type="model")
67
- log(f" [HF] sẵn sàng sync với {HF_REPO}")
68
- except Exception as e:
69
- log(f" [HF] lỗi khởi tạo (bỏ qua): {e}")
70
-
71
- def push_to_hf(games_played, total_steps):
72
- if hf_api is None:
73
- return
74
- for remote_name, local_path in HF_SYNC_FILES.items():
75
- if not os.path.exists(local_path):
76
- continue
77
- try:
78
- hf_api.upload_file(
79
- path_or_fileobj=local_path,
80
- path_in_repo=remote_name,
81
- repo_id=HF_REPO,
82
- repo_type="model",
83
- commit_message=f"checkpoint @ game={games_played} steps={total_steps}",
84
- )
85
- except Exception as e:
86
- log(f" [HF][ERROR] upload {remote_name}: {e}")
87
- log(f" [HF] uploaded -> games={games_played}, steps={total_steps:,}")
88
-
89
- # =============================================================
90
- # ENCODING
91
- # =============================================================
92
- MCTS_BATCH = 16
93
- ACTION_SIZE = 4672
94
- PROMOTION_OFFSET = 4096
95
- PROMOTION_MAP = {chess.QUEEN: 0, chess.ROOK: 1, chess.BISHOP: 2, chess.KNIGHT: 3}
96
- PIECE_IDX = {chess.PAWN: 0, chess.KNIGHT: 1, chess.BISHOP: 2,
97
- chess.ROOK: 3, chess.QUEEN: 4, chess.KING: 5}
98
- ENCODE_CACHE = {}
99
- EVAL_CACHE = {}
100
- CACHE_MAX = 100_000
101
-
102
- def encode_board(board):
103
- key = board.fen()
104
- if key in ENCODE_CACHE:
105
- return ENCODE_CACHE[key]
106
- x = np.zeros((12, 8, 8), dtype=np.float32)
107
- for sq, piece in board.piece_map().items():
108
- r, c = divmod(sq, 8)
109
- offset = 0 if piece.color == chess.WHITE else 6
110
- x[PIECE_IDX[piece.piece_type] + offset][r][c] = 1.0
111
- ENCODE_CACHE[key] = x
112
- return x
113
-
114
- def move_to_idx(move):
115
- if move.promotion is not None:
116
- return PROMOTION_OFFSET + move.to_square * 4 + PROMOTION_MAP[move.promotion]
117
- return move.from_square * 64 + move.to_square
118
-
119
- # =============================================================
120
- # MODEL
121
- # =============================================================
122
- class ResBlock:
123
- def __init__(self, channels):
124
- self.c1 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
125
- self.bn1 = nn.BatchNorm(channels)
126
- self.c2 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
127
- self.bn2 = nn.BatchNorm(channels)
128
- def __call__(self, x):
129
- r = x
130
- x = self.bn1(self.c1(x)).relu()
131
- x = self.bn2(self.c2(x))
132
- return (x + r).relu()
133
-
134
- class ChessNet:
135
- def __init__(self, channels=256, num_blocks=6):
136
- self.stem_conv = nn.Conv2d(12, channels, 3, padding=1, bias=False)
137
- self.stem_bn = nn.BatchNorm(channels)
138
- self.blocks = [ResBlock(channels) for _ in range(num_blocks)]
139
- self.policy_conv = nn.Conv2d(channels, 32, 1, bias=False)
140
- self.policy_bn = nn.BatchNorm(32)
141
- self.policy_fc = nn.Linear(32 * 8 * 8, ACTION_SIZE)
142
- self.value_conv = nn.Conv2d(channels, 8, 1, bias=False)
143
- self.value_bn = nn.BatchNorm(8)
144
- self.value_fc1 = nn.Linear(8 * 8 * 8, 256)
145
- self.value_fc2 = nn.Linear(256, 1)
146
- def __call__(self, x):
147
- x = self.stem_bn(self.stem_conv(x)).relu()
148
- for b in self.blocks: x = b(x)
149
- p = self.policy_bn(self.policy_conv(x)).relu()
150
- p = self.policy_fc(p.flatten(1))
151
- v = self.value_bn(self.value_conv(x)).relu()
152
- v = self.value_fc1(v.flatten(1)).relu()
153
- v = self.value_fc2(v).tanh()
154
- return p, v
155
-
156
- model = ChessNet(CFG["channels"], CFG["res_blocks"])
157
- optimizer = Adam(get_parameters(model), lr=CFG["lr"])
158
-
159
- # =============================================================
160
- # CHECKPOINT — weights luôn lưu numpy, tương thích Metal + CUDA
161
- # =============================================================
162
- def _tensor_to_numpy(v):
163
- """Convert bất kỳ kiểu weight nào → numpy float32."""
164
- if isinstance(v, np.ndarray):
165
- return v.astype(np.float32)
166
- if isinstance(v, Tensor):
167
- try:
168
- return v.numpy().astype(np.float32)
169
- except Exception:
170
- pass
171
- if hasattr(v, 'numpy'):
172
- try:
173
- return np.array(v.numpy(), dtype=np.float32)
174
- except Exception:
175
- pass
176
- if hasattr(v, 'data'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  try:
178
- return np.array(v.data, dtype=np.float32)
 
179
  except Exception:
180
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  try:
182
- return np.array(v, dtype=np.float32)
 
183
  except Exception:
184
- return None
185
-
186
- def save_checkpoint(games_played):
187
- # Luôn save numpy để macOS Metal load được
188
- numpy_weights = {}
189
- for k, v in get_state_dict(model).items():
190
- arr = _tensor_to_numpy(v)
191
- if arr is not None:
192
- numpy_weights[k] = arr
193
-
194
- data = {
195
- "games": games_played,
196
- "total_steps": total_steps,
197
- "weights": numpy_weights,
198
- }
199
- with open(CFG["checkpoint_path"], "wb") as f:
200
- pickle.dump(data, f)
201
- log(f" [CKPT] saved → games={games_played}, steps={total_steps:,}")
202
-
203
- def load_checkpoint():
204
- path = CFG["checkpoint_path"]
205
- if not os.path.exists(path):
206
- log(" [CKPT] không tìm thấy checkpoint, bắt đầu từ đầu.")
207
- return 0, 0
208
-
209
- with open(path, "rb") as f:
210
- data = pickle.load(f)
211
-
212
- # Convert tất cả về Tensor để load vào model
213
- clean = {}
214
- nan_keys = []
215
- for k, v in data["weights"].items():
216
- arr = _tensor_to_numpy(v)
217
- if arr is not None:
218
- if not np.isfinite(arr).all():
219
- nan_keys.append(k)
220
- clean[k] = Tensor(arr)
221
- load_state_dict(model, clean)
222
-
223
- if nan_keys:
224
- log(f" [CKPT][CẢNH BÁO] checkpoint chứa NaN/Inf ở {len(nan_keys)} tensor: {nan_keys[:5]}{'...' if len(nan_keys) > 5 else ''}")
225
- log(" [CKPT] -> checkpoint có thể đã hỏng. Kiểm tra lại trước khi train tiếp.")
226
-
227
- games = data.get("games", 0)
228
- steps = data.get("total_steps", 0)
229
- log(f" [CKPT] loaded → games={games}, steps={steps:,}")
230
- return games, steps
231
-
232
- # =============================================================
233
- # REPLAY BUFFER
234
- # =============================================================
235
- class ReplayBuffer:
236
- def __init__(self, maxlen):
237
- self.maxlen = maxlen
238
- self.states = np.zeros((maxlen, 12, 8, 8), dtype=np.float32)
239
- self.policies = np.zeros((maxlen, ACTION_SIZE), dtype=np.float32)
240
- self.values = np.zeros((maxlen, 1), dtype=np.float32)
241
- self.ptr = 0
242
- self.size = 0
243
-
244
- def extend(self, samples):
245
- for state, pi, z in samples:
246
- self.states[self.ptr] = state
247
- self.policies[self.ptr] = pi
248
- self.values[self.ptr, 0] = z
249
- self.ptr = (self.ptr + 1) % self.maxlen
250
- self.size = min(self.size + 1, self.maxlen)
251
-
252
- def sample_batch(self, batch_size):
253
- idx = np.random.randint(0, self.size, batch_size)
254
- return self.states[idx], self.policies[idx], self.values[idx]
255
-
256
- def __len__(self):
257
- return self.size
258
-
259
- # =============================================================
260
- # PGN
261
- # =============================================================
262
- def load_offset():
263
- if os.path.exists(CFG["pgn_offset_file"]):
264
- with open(CFG["pgn_offset_file"], "rb") as f:
265
- return pickle.load(f)
266
- return 0
267
-
268
- def load_pgn_chunk(buf, path, max_games=500):
269
- if not os.path.exists(path):
270
- return 0, None
271
- offset = load_offset()
272
- count = 0
273
- with open(path, "r") as f:
274
- f.seek(offset)
275
- while count < max_games:
276
- game = chess.pgn.read_game(f)
277
- if game is None: break
278
- result = game.headers.get("Result", "1/2-1/2")
279
- white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
280
- board = game.board()
281
- samples = []
282
- for move in game.mainline_moves():
283
- pi = np.zeros(ACTION_SIZE, dtype=np.float32)
284
- pi[move_to_idx(move)] = 1.0
285
- z = white_z if board.turn == chess.WHITE else -white_z
286
- samples.append((encode_board(board), pi, np.float32(z)))
287
- board.push(move)
288
- buf.extend(samples)
289
- count += 1
290
- new_offset = f.tell()
291
- with open(CFG["pgn_offset_file"], "wb") as f:
292
- pickle.dump(new_offset, f)
293
- log(f"[PGN] loaded chunk={count}, buffer={len(buf):,}")
294
- return count, new_offset
295
-
296
- def load_pgn(buf, path, limit=2000):
297
- if not os.path.exists(path):
298
- log(f" [PGN] không tìm thấy {path}, bỏ qua.")
299
- return
300
- count = 0
301
- with open(path) as f:
302
- while count < limit:
303
- game = chess.pgn.read_game(f)
304
- if game is None: break
305
- result = game.headers.get("Result", "1/2-1/2")
306
- white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
307
- board = game.board()
308
- samples = []
309
- for move in game.mainline_moves():
310
- pi = np.zeros(ACTION_SIZE, dtype=np.float32)
311
- pi[move_to_idx(move)] = 1.0
312
- z = white_z if board.turn == chess.WHITE else -white_z
313
- samples.append((encode_board(board), pi, np.float32(z)))
314
- board.push(move)
315
- buf.extend(samples)
316
- count += 1
317
- log(f" [PGN] loaded {count} games, buffer={len(buf):,}")
318
-
319
- # =============================================================
320
- # INFERENCE
321
- # =============================================================
322
- def predict_batch(boards):
323
- n = len(boards)
324
- states = np.empty((n, 12, 8, 8), dtype=np.float32)
325
- for i, b in enumerate(boards):
326
- states[i] = encode_board(b)
327
- x = Tensor(states)
328
- p_logits, values = model(x)
329
- p_logits = p_logits.numpy().astype(np.float64, copy=False)
330
- values = values.numpy()
331
- outputs = []
332
- for i, board in enumerate(boards):
333
- p = np.nan_to_num(p_logits[i], nan=0.0, posinf=0.0, neginf=0.0, copy=False)
334
- mask = np.zeros(ACTION_SIZE, dtype=np.float64)
335
- mask[[move_to_idx(m) for m in board.legal_moves]] = 1.0
336
- p -= p.max(); np.exp(p, out=p); p *= mask
337
- s = p.sum()
338
- p = p / s if s > 0 else mask / mask.sum()
339
- outputs.append((p.copy(), float(values[i, 0])))
340
- return outputs
341
-
342
- # =============================================================
343
- # MCTS
344
- # =============================================================
345
- class MCTSNode:
346
- __slots__ = ("board", "prior", "parent", "children", "visits", "total_value")
347
- def __init__(self, board, prior=0.0, parent=None):
348
- self.board = board
349
- self.prior = prior
350
- self.parent = parent
351
- self.children = {}
352
- self.visits = 0
353
- self.total_value = 0.0
354
- @property
355
- def q(self):
356
- return self.total_value / self.visits if self.visits > 0 else 0.0
357
- def uct_score(self):
358
- pv = self.parent.visits if self.parent else 1
359
- return self.q + 1.5 * self.prior * (pv ** 0.5) / (1 + self.visits)
360
-
361
- def terminal_value(board):
362
- out = board.outcome()
363
- if out is None or out.winner is None: return 0.0
364
- return -1.0
365
-
366
- def expand_node(node, policy):
367
- for move in node.board.legal_moves:
368
- if move not in node.children:
369
- cb = node.board.copy(stack=False); cb.push(move)
370
- node.children[move] = MCTSNode(cb, prior=float(policy[move_to_idx(move)]), parent=node)
371
-
372
- def backup_path(path, value):
373
- for n in reversed(path):
374
- n.visits += 1; n.total_value += value; value = -value
375
-
376
- def mcts_search(root, num_sims=64):
377
- remaining = num_sims
378
- while remaining > 0:
379
- batch_size = min(MCTS_BATCH, remaining)
380
- leaves, paths = [], []
381
- for _ in range(batch_size):
382
- node = root; path = [node]
383
- while node.children and not node.board.is_game_over():
384
- node = max(node.children.values(), key=lambda n: n.uct_score())
385
- path.append(node)
386
- leaves.append(node); paths.append(path)
387
-
388
- boards_to_eval, eval_indices = [], []
389
- values = [0.0] * batch_size
390
- policies = [None] * batch_size
391
- for i, node in enumerate(leaves):
392
- if node.board.is_game_over(): values[i] = terminal_value(node.board)
393
- else: boards_to_eval.append(node.board); eval_indices.append(i)
394
-
395
- if boards_to_eval:
396
- for idx, (pol, val) in zip(eval_indices, predict_batch(boards_to_eval)):
397
- policies[idx] = pol; values[idx] = val
398
-
399
- for i, node in enumerate(leaves):
400
- if not node.board.is_game_over():
401
- policy = policies[i]
402
- if node == root:
403
- legal = list(node.board.legal_moves)
404
- if legal:
405
- noise = np.random.dirichlet([0.3] * len(legal))
406
- for j, move in enumerate(legal):
407
- k = move_to_idx(move)
408
- policy[k] = 0.75 * policy[k] + 0.25 * noise[j]
409
- expand_node(node, policy)
410
- backup_path(paths[i], values[i])
411
- remaining -= batch_size
412
-
413
- # =============================================================
414
- # SELF-PLAY
415
- # =============================================================
416
- def play_one_game(temperature=1.0, game_id=0):
417
- board = chess.Board()
418
- history = []
419
- move_num = 0
420
- Tensor.training = False
421
- try:
422
- while not board.is_game_over() and move_num < CFG["max_moves"]:
423
- root = MCTSNode(board.copy(stack=False))
424
- mcts_search(root, CFG["mcts_sims"])
425
- moves_list = list(root.children.keys())
426
- if not moves_list: break
427
- visits = np.array([root.children[m].visits for m in moves_list], dtype=np.float32)
428
- s = visits.sum()
429
- if s <= 0 or np.isnan(s): visits[:] = 1.0
430
- idxs = np.array([move_to_idx(m) for m in moves_list], dtype=np.int32)
431
- pi = np.zeros(ACTION_SIZE, dtype=np.float32); pi[idxs] = visits
432
- s = pi.sum()
433
- if s > 0 and not np.isnan(s): pi /= s
434
- history.append((encode_board(board), pi, board.turn))
435
- if move_num < 30 and temperature > 0:
436
- probs = visits ** (1.0 / temperature)
437
- s = probs.sum()
438
- if s <= 0 or np.isnan(s): probs = np.ones(len(visits), dtype=np.float32)
439
- probs /= probs.sum()
440
- move = moves_list[np.random.choice(len(moves_list), p=probs)]
441
- else:
442
- move = moves_list[int(np.argmax(visits))]
443
- board.push(move); move_num += 1
444
- finally:
445
- Tensor.training = True
446
-
447
- result = board.result() if board.is_game_over() else "1/2-1/2"
448
- white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
449
- samples = [(s, p, np.float32(white_z if c == chess.WHITE else -white_z)) for s, p, c in history]
450
- return samples, result
451
-
452
- # =============================================================
453
- # TRAINING STEP
454
- # =============================================================
455
- def train_step(buf):
456
- if len(buf) < CFG["min_buffer"]:
457
- return None
458
- loss_policy = loss_value = loss = 0.0
459
- skipped = 0
460
- for _ in range(CFG["train_steps"]):
461
- s, p, z = buf.sample_batch(CFG["batch_size"])
462
- S = Tensor(np.ascontiguousarray(s))
463
- P = Tensor(np.ascontiguousarray(p))
464
- Z = Tensor(np.ascontiguousarray(z))
465
- logits, v = model(S)
466
- # clamp logits trước softmax để tránh exp() overflow -> inf
467
- logits = logits.clip(-30, 30)
468
- logits = logits - logits.max(axis=1).reshape(-1, 1)
469
- exp_l = logits.exp()
470
- prob = exp_l / exp_l.sum(axis=1).reshape(-1, 1)
471
- loss_policy = -(P * (prob + 1e-8).log()).sum(axis=1).mean()
472
- loss_value = (v - Z).square().mean()
473
- loss = loss_policy + loss_value
474
-
475
- loss_val = float(loss.item())
476
- if not np.isfinite(loss_val):
477
- # loss NaN/Inf -> bỏ qua step này, KHÔNG cập nhật weights
478
- skipped += 1
479
- log(f" [WARN] loss={loss_val} (NaN/Inf) -> skip optimizer.step()")
480
- continue
481
-
482
- optimizer.zero_grad(); loss.backward()
483
- # gradient clipping: chặn gradient explosion gây NaN lan vào weights
484
- for t in get_parameters(model):
485
- if t.grad is not None:
486
- t.grad = t.grad.clip(-1.0, 1.0)
487
- optimizer.step()
488
- return {"loss": float(loss.item()), "loss_policy": float(loss_policy.item()),
489
- "loss_value": float(loss_value.item()), "steps": CFG["train_steps"]}
490
-
491
- # =============================================================
492
- # MAIN
493
- # =============================================================
494
- if __name__ == "__main__":
495
- step_since_pgn = 0
496
- print("=" * 60)
497
- print(" Chess AI — AlphaZero-style, tối ưu Colab")
498
- print("=" * 60)
499
- buf = ReplayBuffer(CFG["buffer_maxlen"])
500
- games_played, total_steps = load_checkpoint()
501
- if games_played == 0:
502
- load_pgn(buf, CFG["pgn_path"], CFG["pgn_limit"])
503
- load_pgn_chunk(buf, CFG["pgn_path"], max_games=500)
504
- print(f"\n Bắt đầu training từ game #{games_played + 1}")
505
- print(f" Buffer: {len(buf):,} | Min train: {CFG['min_buffer']:,}\n")
506
-
507
- while True:
508
- t0 = time.time()
509
- step_since_pgn += CFG["train_steps"]
510
- if step_since_pgn >= CFG["pgn_chunk"]:
511
- load_pgn_chunk(buf, CFG["pgn_path"], CFG["pgn_chunk"]); step_since_pgn = 0
512
-
513
- try:
514
- samples, result = play_one_game(temperature=1.0, game_id=games_played + 1)
515
- except Exception as e:
516
- import traceback
517
- ts = time.strftime("%Y-%m-%d %H:%M:%S")
518
- msg = f"[ERROR] self-play: {e}\n{traceback.format_exc()}"
519
- log(f"[{ts}] {msg}")
520
- try:
521
- with open(CFG["log_path"], "a") as f: f.write(f"[{ts}] {msg}\n")
522
- except Exception: pass
523
- continue
524
-
525
- buf.extend(samples); games_played += 1
526
- elapsed_game = time.time() - t0
527
- t1 = time.time()
528
- info = train_step(buf)
529
- elapsed_train = time.time() - t1
530
-
531
- if info:
532
- total_steps += 1
533
- log(f" Steps: {total_steps}/{TOTAL_STEPS_TARGET} "
534
- f"loss={info['loss']:.4f} p={info['loss_policy']:.4f} v={info['loss_value']:.4f} "
535
- f"t={elapsed_train:.1f}s")
536
-
537
- loss_str = f"{info['loss']:.4f}" if info else "warmup"
538
- log(f"🎮 Game {games_played}: result={result} moves={len(samples)} "
539
- f"buf={len(buf):,} loss={loss_str} t={elapsed_game:.1f}s")
540
-
541
- ts = time.strftime("%Y-%m-%d %H:%M:%S")
542
- line = (f"game={games_played:5d} | {result} | moves={len(samples):3d} | buf={len(buf):,} | "
543
- f"steps={total_steps:,} | "
544
- + (f"loss={info['loss']:.4f} (p={info['loss_policy']:.4f} v={info['loss_value']:.4f})" if info else "warmup")
545
- + f" | game={elapsed_game:.1f}s train={elapsed_train:.1f}s")
546
- try:
547
- with open(CFG["log_path"], "a") as f: f.write(f"[{ts}] {line}\n")
548
- except Exception: pass
549
-
550
- if games_played % CFG["save_every"] == 0:
551
- save_checkpoint(games_played)
552
- return push_to_hf(games_played, total_steps)
553
-
554
 
555
- train()
 
 
1
+ !pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
2
+ """
3
+ Chess AI — AlphaZero-style
4
+ Backend : PyTorch CUDA (thay thế TinyGrad)
5
+ Checkpoint : pickle (giữ nguyên)
6
+ Deps : torch, chess, numpy, tqdm
7
+ """
8
+
9
+ EVAL_CACHE = {}
10
+ CACHE_MAX = 100_000
11
+
12
+ import os, pickle, time, traceback
13
+ import chess, chess.pgn
14
+ import numpy as np
15
+ from tqdm.auto import tqdm
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+ from torch.optim import Adam
21
+
22
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+ print(f"[Device] {DEVICE} | "
24
+ f"{torch.cuda.get_device_name(0) if DEVICE.type == 'cuda' else 'CPU'}")
25
+
26
+ # =============================================================
27
+ # CONFIG
28
+ # =============================================================
29
+ CFG = {
30
+ "mcts_sims": 16,
31
+ "buffer_maxlen": 50_000,
32
+ "min_buffer": 2_000,
33
+ "batch_size": 512,
34
+ "lr": 1e-4,
35
+ "train_steps": 30,
36
+ "save_every": 1,
37
+ "pgn_limit": 2000,
38
+ "res_blocks": 6,
39
+ "channels": 256,
40
+ "max_moves": 81,
41
+ "checkpoint_path": "/content/drive/MyDrive/dataset stockfish/latest.pkl",
42
+ "pgn_path": "/content/drive/MyDrive/dataset stockfish/million_games.pgn",
43
+ "log_path": "/content/drive/MyDrive/dataset stockfish/training_log.txt",
44
+ }
45
+ CFG["pgn_chunk"] = 500
46
+ CFG["pgn_offset_file"] = "/content/drive/MyDrive/dataset stockfish/pgn_offset.pkl"
47
+ TOTAL_STEPS_TARGET = CFG["train_steps"]
48
+ os.makedirs(os.path.dirname(CFG["checkpoint_path"]), exist_ok=True)
49
+
50
+ # =============================================================
51
+ # ENCODING (NumPy — CPU)
52
+ # =============================================================
53
+ MCTS_BATCH = 16
54
+
55
+ PIECE_IDX = {
56
+ chess.PAWN: 0, chess.KNIGHT: 1, chess.BISHOP: 2,
57
+ chess.ROOK: 3, chess.QUEEN: 4, chess.KING: 5,
58
+ }
59
+ PROMOTION_OFFSET = 4096
60
+ PROMOTION_MAP = {chess.QUEEN: 0, chess.ROOK: 1,
61
+ chess.BISHOP: 2, chess.KNIGHT: 3}
62
+ ACTION_SIZE = 4672
63
+
64
+ ENCODE_CACHE = {}
65
+
66
+ def encode_board(board: chess.Board) -> np.ndarray:
67
+ key = board.fen()
68
+ if key in ENCODE_CACHE:
69
+ return ENCODE_CACHE[key]
70
+ x = np.zeros((12, 8, 8), dtype=np.float32)
71
+ for sq, piece in board.piece_map().items():
72
+ row, col = divmod(sq, 8)
73
+ offset = 0 if piece.color == chess.WHITE else 6
74
+ x[PIECE_IDX[piece.piece_type] + offset][row][col] = 1.0
75
+ ENCODE_CACHE[key] = x
76
+ return x
77
+
78
+ def move_to_idx(move: chess.Move) -> int:
79
+ if move.promotion is not None:
80
+ return PROMOTION_OFFSET + move.to_square * 4 + PROMOTION_MAP[move.promotion]
81
+ return move.from_square * 64 + move.to_square
82
+
83
+ # =============================================================
84
+ # MODEL (PyTorch)
85
+ # =============================================================
86
+ class ResBlock(nn.Module):
87
+ def __init__(self, channels: int):
88
+ super().__init__()
89
+ self.c1 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
90
+ self.bn1 = nn.BatchNorm2d(channels)
91
+ self.c2 = nn.Conv2d(channels, channels, 3, padding=1, bias=False)
92
+ self.bn2 = nn.BatchNorm2d(channels)
93
+
94
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
95
+ residual = x
96
+ x = F.relu(self.bn1(self.c1(x)))
97
+ x = self.bn2(self.c2(x))
98
+ return F.relu(x + residual)
99
+
100
+
101
+ class ChessNet(nn.Module):
102
+ def __init__(self, channels: int = 256, num_blocks: int = 6):
103
+ super().__init__()
104
+ self.stem_conv = nn.Conv2d(12, channels, 3, padding=1, bias=False)
105
+ self.stem_bn = nn.BatchNorm2d(channels)
106
+ self.blocks = nn.ModuleList([ResBlock(channels) for _ in range(num_blocks)])
107
+
108
+ # Policy head
109
+ self.policy_conv = nn.Conv2d(channels, 32, 1, bias=False)
110
+ self.policy_bn = nn.BatchNorm2d(32)
111
+ self.policy_fc = nn.Linear(32 * 8 * 8, ACTION_SIZE)
112
+
113
+ # Value head
114
+ self.value_conv = nn.Conv2d(channels, 8, 1, bias=False)
115
+ self.value_bn = nn.BatchNorm2d(8)
116
+ self.value_fc1 = nn.Linear(8 * 8 * 8, 256)
117
+ self.value_fc2 = nn.Linear(256, 1)
118
+
119
+ def forward(self, x: torch.Tensor):
120
+ x = F.relu(self.stem_bn(self.stem_conv(x)))
121
+ for block in self.blocks:
122
+ x = block(x)
123
+
124
+ p = F.relu(self.policy_bn(self.policy_conv(x)))
125
+ p = self.policy_fc(p.flatten(1))
126
+
127
+ v = F.relu(self.value_bn(self.value_conv(x)))
128
+ v = F.relu(self.value_fc1(v.flatten(1)))
129
+ v = torch.tanh(self.value_fc2(v))
130
+ return p, v
131
+
132
+
133
+ model = ChessNet(CFG["channels"], CFG["res_blocks"]).to(DEVICE)
134
+ optimizer = Adam(model.parameters(), lr=CFG["lr"])
135
+
136
+ # =============================================================
137
+ # REPLAY BUFFER
138
+ # =============================================================
139
+ class ReplayBuffer:
140
+ def __init__(self, maxlen: int):
141
+ self.maxlen = maxlen
142
+ self.states = np.zeros((maxlen, 12, 8, 8), dtype=np.float32)
143
+ self.policies = np.zeros((maxlen, ACTION_SIZE), dtype=np.float32)
144
+ self.values = np.zeros((maxlen, 1), dtype=np.float32)
145
+ self.ptr = 0
146
+ self.size = 0
147
+
148
+ def extend(self, samples):
149
+ for state, pi, z in samples:
150
+ self.states[self.ptr] = state
151
+ self.policies[self.ptr] = pi
152
+ self.values[self.ptr, 0] = z
153
+ self.ptr = (self.ptr + 1) % self.maxlen
154
+ self.size = min(self.size + 1, self.maxlen)
155
+
156
+ def sample_batch(self, batch_size: int):
157
+ idx = np.random.randint(0, self.size, batch_size)
158
+ return self.states[idx], self.policies[idx], self.values[idx]
159
+
160
+ def __len__(self):
161
+ return self.size
162
+
163
+ # =============================================================
164
+ # CHECKPOINT (pickle — giữ nguyên format)
165
+ # =============================================================
166
+ def save_checkpoint(games_played: int):
167
+ data = {
168
+ "games": games_played,
169
+ "total_steps": total_steps,
170
+ "weights": {k: v.cpu() for k, v in model.state_dict().items()},
171
+ "optimizer": optimizer.state_dict(),
172
  }
173
+ with open(CFG["checkpoint_path"], "wb") as f:
174
+ pickle.dump(data, f)
175
+ tqdm.write(f" [CKPT] saved → games={games_played}, steps={total_steps:,}")
176
+
177
+
178
+ def load_checkpoint():
179
+ path = CFG["checkpoint_path"]
180
+ if not os.path.exists(path):
181
+ tqdm.write(" [CKPT] không tìm thấy checkpoint, bắt đầu từ đầu.")
182
+ return 0, 0
183
+ with open(path, "rb") as f:
184
+ data = pickle.load(f)
185
+ model.load_state_dict(data["weights"])
186
+ optimizer.load_state_dict(data["optimizer"])
187
+ games = data.get("games", 0)
188
+ steps = data.get("total_steps", 0)
189
+ tqdm.write(f" [CKPT] loaded → games={games}, steps={steps:,}")
190
+ return games, steps
191
+
192
+ # =============================================================
193
+ # PGN LOADING
194
+ # =============================================================
195
+ def load_pgn_offset():
196
+ if os.path.exists(CFG["pgn_offset_file"]):
197
+ with open(CFG["pgn_offset_file"], "rb") as f:
198
+ return pickle.load(f)
199
+ return 0
200
+
201
+
202
+ def load_pgn_chunk(buf: ReplayBuffer, path: str, max_games: int = 500):
203
+ if not os.path.exists(path):
204
+ return 0, None
205
+ offset = load_pgn_offset()
206
+ count = 0
207
+ with open(path, "r") as f:
208
+ f.seek(offset)
209
+ while count < max_games:
210
+ game = chess.pgn.read_game(f)
211
+ if game is None:
212
+ break
213
+ result = game.headers.get("Result", "1/2-1/2")
214
+ white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
215
+ board = game.board()
216
+ samples = []
217
+ for move in game.mainline_moves():
218
+ pi = np.zeros(ACTION_SIZE, dtype=np.float32)
219
+ pi[move_to_idx(move)] = 1.0
220
+ z = white_z if board.turn == chess.WHITE else -white_z
221
+ samples.append((encode_board(board), pi, np.float32(z)))
222
+ board.push(move)
223
+ buf.extend(samples)
224
+ count += 1
225
+ new_offset = f.tell()
226
+ with open(CFG["pgn_offset_file"], "wb") as f:
227
+ pickle.dump(new_offset, f)
228
+ tqdm.write(f"[PGN] loaded chunk={count}, buffer={len(buf):,}")
229
+ return count, new_offset
230
+
231
+
232
+ def load_pgn(buf: ReplayBuffer, path: str, limit: int = 2000):
233
+ if not os.path.exists(path):
234
+ tqdm.write(f" [PGN] không tìm thấy {path}, bỏ qua.")
235
+ return
236
+ count = 0
237
+ with open(path) as f:
238
+ pbar = tqdm(total=limit, desc="Loading PGN")
239
+ while count < limit:
240
+ game = chess.pgn.read_game(f)
241
+ if game is None:
242
+ break
243
+ result = game.headers.get("Result", "1/2-1/2")
244
+ white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
245
+ board = game.board()
246
+ samples = []
247
+ for move in game.mainline_moves():
248
+ pi = np.zeros(ACTION_SIZE, dtype=np.float32)
249
+ pi[move_to_idx(move)] = 1.0
250
+ z = white_z if board.turn == chess.WHITE else -white_z
251
+ samples.append((encode_board(board), pi, np.float32(z)))
252
+ board.push(move)
253
+ buf.extend(samples)
254
+ count += 1
255
+ pbar.update(1)
256
+ pbar.close()
257
+ tqdm.write(f" [PGN] loaded {count} games, buffer={len(buf):,}")
258
+
259
+ # =============================================================
260
+ # INFERENCE (torch.no_grad)
261
+ # =============================================================
262
+ def _softmax_masked(logits_np: np.ndarray, mask: np.ndarray) -> np.ndarray:
263
+ p = logits_np.astype(np.float64)
264
+ p = np.nan_to_num(p, nan=0.0, posinf=0.0, neginf=0.0)
265
+ p -= p.max()
266
+ np.exp(p, out=p)
267
+ p *= mask
268
+ total = p.sum()
269
+ if total > 0:
270
+ p /= total
271
+ else:
272
+ p = mask / mask.sum()
273
+ return p
274
+
275
+
276
+ def predict(board: chess.Board):
277
+ fen = board.fen()
278
+ cached = EVAL_CACHE.get(fen)
279
+ if cached is not None:
280
+ return cached
281
+
282
+ x = torch.from_numpy(encode_board(board)[None]).to(DEVICE) # (1,12,8,8)
283
+ model.eval()
284
+ with torch.no_grad():
285
+ logits, v = model(x)
286
+
287
+ p_np = logits[0].cpu().numpy()
288
+ v_scalar = float(v[0, 0].cpu().item())
289
+
290
+ mask = np.zeros(ACTION_SIZE, dtype=np.float64)
291
+ for m in board.legal_moves:
292
+ mask[move_to_idx(m)] = 1.0
293
+
294
+ p = _softmax_masked(p_np, mask)
295
+
296
+ result = (p, v_scalar)
297
+ if len(EVAL_CACHE) >= CACHE_MAX:
298
+ EVAL_CACHE.clear()
299
+ EVAL_CACHE[fen] = result
300
+ return result
301
+
302
+
303
+ def predict_batch(boards):
304
+ n = len(boards)
305
+ states = np.empty((n, 12, 8, 8), dtype=np.float32)
306
+ for i, b in enumerate(boards):
307
+ states[i] = encode_board(b)
308
+
309
+ x = torch.from_numpy(states).to(DEVICE)
310
+ model.eval()
311
+ with torch.no_grad():
312
+ logits, values = model(x)
313
+
314
+ logits_np = logits.cpu().numpy().astype(np.float64, copy=False)
315
+ values_np = values.cpu().numpy()
316
+
317
+ outputs = []
318
+ for i, board in enumerate(boards):
319
+ mask = np.zeros(ACTION_SIZE, dtype=np.float64)
320
+ for m in board.legal_moves:
321
+ mask[move_to_idx(m)] = 1.0
322
+ p = _softmax_masked(logits_np[i], mask)
323
+ outputs.append((p.copy(), float(values_np[i, 0])))
324
+ return outputs
325
+
326
+ # =============================================================
327
+ # MCTS (CPU — giữ nguyên)
328
+ # =============================================================
329
+ class MCTSNode:
330
+ __slots__ = ("board", "prior", "parent", "children", "visits", "total_value")
331
+
332
+ def __init__(self, board: chess.Board, prior: float = 0.0, parent=None):
333
+ self.board = board
334
+ self.prior = prior
335
+ self.parent = parent
336
+ self.children = {}
337
+ self.visits = 0
338
+ self.total_value = 0.0
339
+
340
+ @property
341
+ def q(self) -> float:
342
+ return self.total_value / self.visits if self.visits > 0 else 0.0
343
+
344
+ def uct_score(self) -> float:
345
+ c_puct = 1.5
346
+ parent_visits = self.parent.visits if self.parent else 1
347
+ u = c_puct * self.prior * (parent_visits ** 0.5) / (1 + self.visits)
348
+ return self.q + u
349
+
350
+
351
+ def terminal_value(board: chess.Board) -> float:
352
+ outcome = board.outcome()
353
+ if outcome is None or outcome.winner is None:
354
+ return 0.0
355
+ return -1.0
356
+
357
+
358
+ def expand_node(node: MCTSNode, policy: np.ndarray):
359
+ for move in node.board.legal_moves:
360
+ if move not in node.children:
361
+ child_board = node.board.copy(stack=False)
362
+ child_board.push(move)
363
+ node.children[move] = MCTSNode(
364
+ child_board, prior=float(policy[move_to_idx(move)]), parent=node)
365
+
366
+
367
+ def backup_path(path, value: float):
368
+ for n in reversed(path):
369
+ n.visits += 1
370
+ n.total_value += value
371
+ value = -value
372
+
373
+
374
+ def mcts_search(root: MCTSNode, num_sims: int = 64):
375
+ remaining = num_sims
376
+ while remaining > 0:
377
+ batch_size = min(MCTS_BATCH, remaining)
378
+ leaves, paths = [], []
379
+
380
+ for _ in range(batch_size):
381
+ node = root
382
+ path = [node]
383
+ while node.children and not node.board.is_game_over():
384
+ node = max(node.children.values(), key=lambda n: n.uct_score())
385
+ path.append(node)
386
+ leaves.append(node)
387
+ paths.append(path)
388
+
389
+ boards_to_eval, eval_indices = [], []
390
+ values = [0.0] * batch_size
391
+ policies = [None] * batch_size
392
+
393
+ for i, node in enumerate(leaves):
394
+ if node.board.is_game_over():
395
+ values[i] = terminal_value(node.board)
396
+ else:
397
+ boards_to_eval.append(node.board)
398
+ eval_indices.append(i)
399
+
400
+ if boards_to_eval:
401
+ outputs = predict_batch(boards_to_eval)
402
+ for idx, (policy, value) in zip(eval_indices, outputs):
403
+ policies[idx] = policy
404
+ values[idx] = value
405
+
406
+ for i, node in enumerate(leaves):
407
+ if not node.board.is_game_over():
408
+ policy = policies[i]
409
+ if node == root:
410
+ legal = list(node.board.legal_moves)
411
+ if legal:
412
+ noise = np.random.dirichlet([0.3] * len(legal))
413
+ for j, move in enumerate(legal):
414
+ k = move_to_idx(move)
415
+ policy[k] = 0.75 * policy[k] + 0.25 * noise[j]
416
+ expand_node(node, policy)
417
+ backup_path(paths[i], values[i])
418
+
419
+ remaining -= batch_size
420
+
421
+ # =============================================================
422
+ # SELF-PLAY
423
+ # =============================================================
424
+ def play_one_game(temperature: float = 1.0, game_id: int = 0):
425
+ board = chess.Board()
426
+ history = []
427
+ move_num = 0
428
+
429
+ move_bar = tqdm(
430
+ total=CFG["max_moves"], desc=f" ♟ Game #{game_id}",
431
+ leave=False, dynamic_ncols=True)
432
 
 
433
  try:
434
+ while not board.is_game_over() and move_num < CFG["max_moves"]:
435
+ root = MCTSNode(board.copy(stack=False))
436
+ mcts_search(root, CFG["mcts_sims"])
437
+
438
+ moves_list = list(root.children.keys())
439
+ if not moves_list:
440
+ break
441
+
442
+ visits = np.array(
443
+ [root.children[m].visits for m in moves_list], dtype=np.float32)
444
+ s = visits.sum()
445
+ if s <= 0 or np.isnan(s):
446
+ visits[:] = 1.0
447
+
448
+ idxs = np.array([move_to_idx(m) for m in moves_list], dtype=np.int32)
449
+ pi = np.zeros(ACTION_SIZE, dtype=np.float32)
450
+ pi[idxs] = visits
451
+ s = pi.sum()
452
+ if s > 0 and not np.isnan(s):
453
+ pi /= s
454
+
455
+ history.append((encode_board(board), pi, board.turn))
456
+
457
+ if move_num < 30 and temperature > 0:
458
+ probs = visits ** (1.0 / temperature)
459
+ s = probs.sum()
460
+ if s <= 0 or np.isnan(s):
461
+ probs = np.ones(len(visits), dtype=np.float32)
462
+ probs /= probs.sum()
463
+ move = moves_list[np.random.choice(len(moves_list), p=probs)]
464
+ else:
465
+ move = moves_list[int(np.argmax(visits))]
466
+
467
+ board.push(move)
468
+ move_num += 1
469
+ move_bar.update(1)
470
+ move_bar.set_postfix(
471
+ turn="W" if board.turn == chess.BLACK else "B",
472
+ move=move.uci(), sims=CFG["mcts_sims"])
473
+ finally:
474
+ move_bar.close()
475
+
476
+ result = board.result() if board.is_game_over() else "1/2-1/2"
477
+ white_z = 1.0 if result == "1-0" else -1.0 if result == "0-1" else 0.0
478
+ samples = [
479
+ (state, pi, np.float32(white_z if color == chess.WHITE else -white_z))
480
+ for state, pi, color in history
481
+ ]
482
+ return samples, result
483
+
484
+ # =============================================================
485
+ # TRAINING STEP
486
+ # =============================================================
487
+ def train_step(buf: ReplayBuffer):
488
+ if len(buf) < CFG["min_buffer"]:
489
+ return None
490
+
491
+ model.train()
492
+ train_bar = tqdm(
493
+ total=CFG["train_steps"], desc=" 🔧 Training",
494
+ leave=False, dynamic_ncols=True)
495
+
496
+ loss = loss_policy = loss_value = 0.0
497
+
498
+ for _ in range(CFG["train_steps"]):
499
+ s, p, z = buf.sample_batch(CFG["batch_size"])
500
+
501
+ S = torch.from_numpy(np.ascontiguousarray(s)).to(DEVICE) # (B,12,8,8)
502
+ P = torch.from_numpy(np.ascontiguousarray(p)).to(DEVICE) # (B,ACTION_SIZE)
503
+ Z = torch.from_numpy(np.ascontiguousarray(z)).to(DEVICE) # (B,1)
504
+
505
+ logits, v = model(S)
506
+
507
+ # Policy loss: stable cross-entropy via log_softmax
508
+ log_prob = F.log_softmax(logits, dim=1)
509
+ loss_policy = -(P * log_prob).sum(dim=1).mean()
510
+
511
+ # Value loss: MSE
512
+ loss_value = F.mse_loss(v, Z)
513
+
514
+ loss = loss_policy + loss_value
515
+
516
+ optimizer.zero_grad()
517
+ loss.backward()
518
+ optimizer.step()
519
+
520
+ train_bar.update(1)
521
+ train_bar.set_postfix(loss=f"{loss.item():.4f}")
522
+
523
+ train_bar.close()
524
+
525
+ return {
526
+ "loss": loss.item(),
527
+ "loss_policy": loss_policy.item(),
528
+ "loss_value": loss_value.item(),
529
+ "steps": CFG["train_steps"],
530
+ }
531
+
532
+ # =============================================================
533
+ # MAIN
534
+ # =============================================================
535
+ if __name__ == "__main__":
536
+ step_since_pgn = 0
537
+
538
+ print("=" * 60)
539
+ print(" Chess AI — AlphaZero-style | PyTorch CUDA")
540
+ print(f" Device : {DEVICE}")
541
+ print("=" * 60)
542
+
543
+ buf = ReplayBuffer(CFG["buffer_maxlen"])
544
+ games_played, total_steps = load_checkpoint()
545
+
546
+ if games_played == 0:
547
+ load_pgn(buf, CFG["pgn_path"], CFG["pgn_limit"])
548
+ load_pgn_chunk(buf, CFG["pgn_path"], max_games=500)
549
+
550
+ print(f"\n Bắt đầu training từ game #{games_played + 1}")
551
+ print(f" Buffer: {len(buf):,} | Min train: {CFG['min_buffer']:,}\n")
552
+
553
+ game_bar = tqdm(initial=games_played, unit="game",
554
+ desc="🎮 Games", dynamic_ncols=True)
555
+ step_bar = tqdm(
556
+ total=TOTAL_STEPS_TARGET, initial=total_steps,
557
+ unit="step", desc="Steps", dynamic_ncols=True,
558
+ bar_format="{desc}: {n_fmt}/{total_fmt} |{bar}| {elapsed}<{remaining}")
559
+
560
+ while True:
561
+ t0 = time.time()
562
+ step_since_pgn += CFG["train_steps"]
563
+ if step_since_pgn >= CFG["pgn_chunk"]:
564
+ load_pgn_chunk(buf, CFG["pgn_path"], CFG["pgn_chunk"])
565
+ step_since_pgn = 0
566
+
567
+ # ── Self-play ────────────────────────────────────────
568
+ try:
569
+ samples, result = play_one_game(temperature=1.0, game_id=games_played + 1)
570
+ except Exception as e:
571
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
572
+ msg = f"[ERROR] self-play: {e}\n{traceback.format_exc()}"
573
+ tqdm.write(f"[{ts}] {msg}")
574
  try:
575
+ with open(CFG["log_path"], "a") as f:
576
+ f.write(f"[{ts}] {msg}\n")
577
  except Exception:
578
  pass
579
+ continue
580
+
581
+ buf.extend(samples)
582
+ games_played += 1
583
+ elapsed_game = time.time() - t0
584
+
585
+ # ── Train ────────────────────────────────────────────
586
+ t1 = time.time()
587
+ info = train_step(buf)
588
+ elapsed_train = time.time() - t1
589
+
590
+ if info:
591
+ step_bar.update(1)
592
+ total_steps += 1
593
+ step_bar.set_postfix(
594
+ progress=f"{total_steps}/{TOTAL_STEPS_TARGET}",
595
+ loss=f"{info['loss']:.4f}",
596
+ p=f"{info['loss_policy']:.4f}",
597
+ v=f"{info['loss_value']:.4f}",
598
+ t=f"{elapsed_train:.1f}s",
599
+ )
600
+
601
+ game_bar.update(1)
602
+ game_bar.set_postfix(
603
+ result=result, moves=len(samples),
604
+ buf=f"{len(buf):,}",
605
+ loss=f"{info['loss']:.4f}" if info else "warmup",
606
+ t=f"{elapsed_game:.1f}s",
607
+ )
608
+
609
+ # ── Log ──────────────────────────────────────────────
610
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
611
+ line = (
612
+ f"game={games_played:5d} | {result} | moves={len(samples):3d} | "
613
+ f"buf={len(buf):,} | steps={total_steps:,} | "
614
+ + (f"loss={info['loss']:.4f} "
615
+ f"(p={info['loss_policy']:.4f} v={info['loss_value']:.4f})"
616
+ if info else "warmup")
617
+ + f" | game={elapsed_game:.1f}s train={elapsed_train:.1f}s"
618
+ )
619
  try:
620
+ with open(CFG["log_path"], "a") as f:
621
+ f.write(f"[{ts}] {line}\n")
622
  except Exception:
623
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
 
625
+ if games_played % CFG["save_every"] == 0:
626
+ save_checkpoint(games_played)