test1978 commited on
Commit
c2b5eda
·
verified ·
1 Parent(s): 5b6e1b2

Add chess_mcvs.py

Browse files
Files changed (1) hide show
  1. chess_mcvs.py +738 -0
chess_mcvs.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # chess_mcvs.py
2
+ """
3
+ chess_mcvs.py - Full implementation analogous to breakthrough_mcvs.py
4
+
5
+ Features:
6
+ - Chess game logic using python-chess (full rules)
7
+ - Fixed 64×64 padded weighted adjacency matrix (Hilbert-ordered pieces)
8
+ - Piece-specific kappa values
9
+ - Queen-only promotions for flat 4096 policy head compatibility
10
+ - Policy and Value CNNs (lightweight with pooling for feasibility)
11
+ - HilbertOrderedZoneDatabase with add methods and proper k-NN zone score
12
+ - MCVSSearcher (guided λ-PUCT with policy/value/zone)
13
+ - UCTSearcher (baseline)
14
+ - train_networks function
15
+ - Ready for chess_mcvs_vs_uct.py tournament
16
+
17
+ Requires: pip install chess torch numpy
18
+ """
19
+
20
+ import numpy as np
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ import torch.optim as optim
25
+ from torch.utils.data import DataLoader, TensorDataset
26
+ import chess
27
+ import random
28
+ import time
29
+ import os
30
+ import bisect
31
+ from collections import Counter
32
+ from typing import List, Dict, Tuple
33
+
34
+ # ============================================================================
35
+ # HILBERT CURVE UTILITIES
36
+ # ============================================================================
37
+
38
+ def xy2d(n: int, x: int, y: int) -> int:
39
+ d = 0
40
+ s = n // 2
41
+ while s > 0:
42
+ rx = 1 if (x & s) > 0 else 0
43
+ ry = 1 if (y & s) > 0 else 0
44
+ d += s * s * ((3 * rx) ^ ry)
45
+ if ry == 0:
46
+ if rx == 1:
47
+ x = n - 1 - x
48
+ y = n - 1 - y
49
+ x, y = y, x
50
+ s //= 2
51
+ return d
52
+
53
+ def matrix_to_hilbert_index(W: np.ndarray) -> int:
54
+ if W.size == 0:
55
+ return 0
56
+ W_flat = W.flatten()
57
+ max_idx = np.argmax(W_flat)
58
+ x = max_idx % 64
59
+ y = max_idx // 64
60
+ return xy2d(64, x, y)
61
+
62
+ # ============================================================================
63
+ # ABC MODEL & WEIGHTED MATRIX
64
+ # ============================================================================
65
+
66
+ from abc_model import ABCModelDynamic
67
+ from matrix_model import WeightedMatrixABC
68
+
69
+ # ============================================================================
70
+ # CHESS GAME LOGIC
71
+ # ============================================================================
72
+
73
+ class Chess:
74
+ def __init__(self):
75
+ self.board = chess.Board()
76
+ self.move_count = 0
77
+ self.position_history = Counter()
78
+ self._update_position_key()
79
+
80
+ def _update_position_key(self):
81
+ key = (self.board.fen(), self.board.turn)
82
+ self.position_history[key] += 1
83
+
84
+ def copy(self):
85
+ new_game = Chess()
86
+ new_game.board = self.board.copy()
87
+ new_game.move_count = self.move_count
88
+ new_game.position_history = self.position_history.copy()
89
+ return new_game
90
+
91
+ def undo_move(self) -> None:
92
+ """
93
+ Undo the last move (pop) and revert bookkeeping (move_count, position_history).
94
+
95
+ This complements `apply_move` which increments move_count and updates
96
+ the position_history for the new state after a push. `undo_move` decrements
97
+ the same position record and pops the board to restore the previous state.
98
+ """
99
+ # The current board fen/turn corresponds to the state that was last pushed;
100
+ # decrement its count before popping to fully revert the update.
101
+ key = (self.board.fen(), self.board.turn)
102
+ if key in self.position_history:
103
+ # decrement safely (don't remove key to avoid KeyError on repeated ops)
104
+ self.position_history[key] = max(0, self.position_history[key] - 1)
105
+ # pop the board and update counters
106
+ self.board.pop()
107
+ self.move_count = max(0, self.move_count - 1)
108
+
109
+ def get_legal_moves(self) -> List[chess.Move]:
110
+ moves = []
111
+ for move in self.board.legal_moves:
112
+ if move.promotion is None or move.promotion == chess.QUEEN:
113
+ moves.append(move)
114
+ return moves
115
+
116
+ def apply_move(self, move: chess.Move) -> None:
117
+ self.board.push(move)
118
+ self.move_count += 1
119
+ self._update_position_key()
120
+
121
+ def is_terminal(self) -> bool:
122
+ return self.board.is_game_over(claim_draw=True)
123
+
124
+ def check_winner(self) -> int:
125
+ if not self.is_terminal():
126
+ return 0
127
+ outcome = self.board.outcome(claim_draw=True)
128
+ if outcome is None or outcome.winner is None:
129
+ return 0
130
+ return 1 if outcome.winner == chess.WHITE else -1
131
+
132
+ def get_weighted_adjacency_matrix(self) -> np.ndarray:
133
+ occupied = []
134
+ for sq in chess.SQUARES:
135
+ piece = self.board.piece_at(sq)
136
+ if piece:
137
+ file = chess.square_file(sq)
138
+ rank = chess.square_rank(sq)
139
+ x = float(file) - 3.5
140
+ y = float(rank) - 3.5
141
+ color_val = 1.0 if piece.color == chess.WHITE else 1.1
142
+ type_val = {
143
+ chess.PAWN: 1.0,
144
+ chess.KNIGHT: 3.0,
145
+ chess.BISHOP: 3.2,
146
+ chess.ROOK: 5.0,
147
+ chess.QUEEN: 9.0,
148
+ chess.KING: 20.0
149
+ }[piece.piece_type]
150
+ kappa = np.array([1.0, color_val, type_val])
151
+ hilbert_d = xy2d(8, file, rank)
152
+ occupied.append((hilbert_d, (x, y), kappa))
153
+
154
+ occupied.sort(key=lambda t: t[0])
155
+
156
+ abc = ABCModelDynamic(n=2, t=1.0, T=1.41)
157
+ for _, pos, kappa_vec in occupied:
158
+ abc.add_piece(pos, delta_values=(1.0, kappa_vec[1], kappa_vec[2]), kappa_vector=kappa_vec)
159
+
160
+ w_calc = WeightedMatrixABC(abc, sigma=1.0)
161
+ W_var = w_calc.compute_weighted_matrix()
162
+
163
+ num = len(occupied)
164
+ W = np.zeros((64, 64))
165
+ if num > 0:
166
+ W[:num, :num] = W_var
167
+ return W
168
+
169
+ # ============================================================================
170
+ # NEURAL NETWORKS
171
+ # ============================================================================
172
+
173
+ class PolicyNetworkCNN(nn.Module):
174
+ def __init__(self):
175
+ super().__init__()
176
+ self.conv = nn.Sequential(
177
+ nn.Conv2d(1, 64, kernel_size=5, padding=2, stride=2),
178
+ nn.ReLU(),
179
+ nn.Conv2d(64, 128, kernel_size=5, padding=2, stride=2),
180
+ nn.ReLU(),
181
+ nn.Conv2d(128, 128, kernel_size=3, padding=1, stride=2),
182
+ nn.ReLU(),
183
+ )
184
+ self.fc = nn.Linear(128 * 8 * 8, 4096)
185
+
186
+ def forward(self, x):
187
+ x = self.conv(x)
188
+ x = x.view(x.size(0), -1)
189
+ return self.fc(x)
190
+
191
+ class ValueNetworkCNN(nn.Module):
192
+ def __init__(self):
193
+ super().__init__()
194
+ self.conv = nn.Sequential(
195
+ nn.Conv2d(1, 64, kernel_size=5, padding=2, stride=2),
196
+ nn.ReLU(),
197
+ nn.Conv2d(64, 128, kernel_size=5, padding=2, stride=2),
198
+ nn.ReLU(),
199
+ nn.Conv2d(128, 128, kernel_size=3, padding=1, stride=2),
200
+ nn.ReLU(),
201
+ )
202
+ self.fc = nn.Sequential(
203
+ nn.Linear(128 * 8 * 8, 512),
204
+ nn.ReLU(),
205
+ nn.Linear(512, 1),
206
+ nn.Tanh()
207
+ )
208
+
209
+ def forward(self, x):
210
+ x = self.conv(x)
211
+ x = x.view(x.size(0), -1)
212
+ return self.fc(x)
213
+
214
+ # ============================================================================
215
+ # ZONE DATABASE
216
+ # ============================================================================
217
+
218
+ class HilbertOrderedZoneDatabase:
219
+ def __init__(self, filepath: str = "chess_zone_db.npz", max_size: int = 100000):
220
+ self.filepath = filepath
221
+ self.max_size = max_size # Maximum matrices per zone
222
+ self.winning_matrices = []
223
+ self.winning_indices = []
224
+ self.losing_matrices = []
225
+ self.losing_indices = []
226
+ self.draw_matrices = []
227
+ self.draw_indices = []
228
+ self.load()
229
+
230
+ def add_winning_matrix(self, W: np.ndarray):
231
+ if W.shape != (64, 64):
232
+ return
233
+
234
+ hilbert_idx = matrix_to_hilbert_index(W)
235
+ pos = bisect.bisect_left(self.winning_indices, hilbert_idx)
236
+ self.winning_indices.insert(pos, hilbert_idx)
237
+ self.winning_matrices.insert(pos, W.copy())
238
+
239
+ def add_losing_matrix(self, W: np.ndarray):
240
+ if W.shape != (64, 64):
241
+ return
242
+
243
+ hilbert_idx = matrix_to_hilbert_index(W)
244
+ pos = bisect.bisect_left(self.losing_indices, hilbert_idx)
245
+ self.losing_indices.insert(pos, hilbert_idx)
246
+ self.losing_matrices.insert(pos, W.copy())
247
+
248
+ def add_draw_matrix(self, W: np.ndarray):
249
+ if W.shape != (64, 64):
250
+ return
251
+
252
+ hilbert_idx = matrix_to_hilbert_index(W)
253
+ pos = bisect.bisect_left(self.draw_indices, hilbert_idx)
254
+ self.draw_indices.insert(pos, hilbert_idx)
255
+ self.draw_matrices.insert(pos, W.copy())
256
+
257
+ def add_game_record(self, trajectory: List[Chess], result: int, sample_rate: float = 0.3):
258
+ """Add game record with sampling to control database size."""
259
+ # Sample states: always keep first and last, random subset of middle
260
+ sampled_states = []
261
+
262
+ for i, state in enumerate(trajectory):
263
+ if i == 0 or i == len(trajectory) - 1:
264
+ # Always keep start and end positions
265
+ sampled_states.append(state)
266
+ elif random.random() < sample_rate:
267
+ # Randomly sample middle positions
268
+ sampled_states.append(state)
269
+
270
+ for state in sampled_states:
271
+ W = state.get_weighted_adjacency_matrix()
272
+
273
+ if result == 1:
274
+ self.add_winning_matrix(W)
275
+ elif result == -1:
276
+ self.add_losing_matrix(W)
277
+ else:
278
+ self.add_draw_matrix(W)
279
+
280
+ def save(self):
281
+ """Save database with pruning if too large."""
282
+ # Prune if any zone exceeds 80% of max_size
283
+ threshold = int(self.max_size * 0.8)
284
+
285
+ if (len(self.winning_matrices) > threshold or
286
+ len(self.losing_matrices) > threshold or
287
+ len(self.draw_matrices) > threshold):
288
+ print(f"Pruning database before save...")
289
+ self.prune_database(target_size=int(self.max_size * 0.7))
290
+
291
+ try:
292
+ np.savez_compressed(
293
+ self.filepath,
294
+ winning=np.array(self.winning_matrices, dtype=object),
295
+ winning_indices=np.array(self.winning_indices),
296
+ losing=np.array(self.losing_matrices, dtype=object),
297
+ losing_indices=np.array(self.losing_indices),
298
+ draw=np.array(self.draw_matrices, dtype=object),
299
+ draw_indices=np.array(self.draw_indices),
300
+ )
301
+ print(f"Zone DB saved: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
302
+ except MemoryError:
303
+ print(f"MemoryError during save! Pruning more aggressively...")
304
+ self.prune_database(target_size=1000)
305
+ # Try again with much smaller size
306
+ np.savez_compressed(
307
+ self.filepath,
308
+ winning=np.array(self.winning_matrices, dtype=object),
309
+ winning_indices=np.array(self.winning_indices),
310
+ losing=np.array(self.losing_matrices, dtype=object),
311
+ losing_indices=np.array(self.losing_indices),
312
+ draw=np.array(self.draw_matrices, dtype=object),
313
+ draw_indices=np.array(self.draw_indices),
314
+ )
315
+
316
+ def load(self):
317
+ if os.path.exists(self.filepath):
318
+ try:
319
+ data = np.load(self.filepath, allow_pickle=True)
320
+ self.winning_matrices = list(data['winning'])
321
+ self.winning_indices = list(data['winning_indices'])
322
+ self.losing_matrices = list(data['losing'])
323
+ self.losing_indices = list(data['losing_indices'])
324
+ self.draw_matrices = list(data['draw'])
325
+ self.draw_indices = list(data['draw_indices'])
326
+ print(f"Loaded zone DB: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
327
+ except Exception as e:
328
+ print(f"Failed to load zone database: {e}")
329
+
330
+ def prune_database(self, target_size: int = 5000):
331
+ """Keep only evenly-spaced diverse samples along Hilbert curve."""
332
+ def prune_zone(matrices, indices, target):
333
+ if len(matrices) <= target:
334
+ return matrices, indices
335
+
336
+ # Keep evenly spaced samples
337
+ step = max(1, len(matrices) // target)
338
+ keep_idx = list(range(0, len(matrices), step))[:target]
339
+
340
+ new_matrices = [matrices[i] for i in keep_idx]
341
+ new_indices = [indices[i] for i in keep_idx]
342
+
343
+ return new_matrices, new_indices
344
+
345
+ old_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
346
+
347
+ self.winning_matrices, self.winning_indices = prune_zone(
348
+ self.winning_matrices, self.winning_indices, target_size
349
+ )
350
+ self.losing_matrices, self.losing_indices = prune_zone(
351
+ self.losing_matrices, self.losing_indices, target_size
352
+ )
353
+ self.draw_matrices, self.draw_indices = prune_zone(
354
+ self.draw_matrices, self.draw_indices, target_size
355
+ )
356
+
357
+ new_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
358
+ print(f"Pruned: W {old_sizes}→{new_sizes}, L {old_sizes}→{new_sizes}, D {old_sizes}→{new_sizes}")
359
+
360
+ def compute_zone_score(self, W: np.ndarray, k: int = 5, beta: float = 0.5) -> float:
361
+ """Compute zone guidance score Z(x(t), a) ∈ [-1, 1]."""
362
+ if W.shape != (64, 64):
363
+ return 0.0
364
+
365
+ def knn_similarity(matrices, k_val):
366
+ if len(matrices) == 0:
367
+ return 0.0
368
+
369
+ k_actual = min(k_val, len(matrices))
370
+ distances = []
371
+
372
+ for mat in matrices[:k_actual]:
373
+ dist = np.sum(np.abs(W - mat)) / (64.0 * 64.0)
374
+ distances.append(dist)
375
+
376
+ similarities = [1.0 - d for d in distances]
377
+ return np.mean(similarities)
378
+
379
+ zone_win = knn_similarity(self.winning_matrices, k)
380
+ zone_loss = knn_similarity(self.losing_matrices, k)
381
+ zone_draw = knn_similarity(self.draw_matrices, k)
382
+
383
+ Z = zone_win - zone_loss + beta * zone_draw
384
+ return float(np.clip(Z, -1.0, 1.0))
385
+
386
+ # ============================================================================
387
+ # TRAINING FUNCTION
388
+ # ============================================================================
389
+
390
+ def train_networks(policy_net, value_net, W_states, policies, values, epochs=5, batch_size=32, device='cpu', lr=0.001):
391
+ policy_net.train()
392
+ value_net.train()
393
+ opt_p = optim.Adam(policy_net.parameters(), lr=lr)
394
+ opt_v = optim.Adam(value_net.parameters(), lr=lr)
395
+
396
+ W_tensor = torch.tensor(np.array(W_states)[:, np.newaxis, :, :], dtype=torch.float32)
397
+ pi_tensor = torch.tensor(np.array(policies), dtype=torch.float32)
398
+ v_tensor = torch.tensor(np.array(values), dtype=torch.float32).unsqueeze(1)
399
+
400
+ dataset = TensorDataset(W_tensor, pi_tensor, v_tensor)
401
+ loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
402
+
403
+ for epoch in range(epochs):
404
+ total_loss = 0.0
405
+ for batch_W, batch_pi, batch_v in loader:
406
+ batch_W = batch_W.to(device)
407
+ batch_pi = batch_pi.to(device)
408
+ batch_v = batch_v.to(device)
409
+
410
+ logits = policy_net(batch_W)
411
+ # Fixed policy loss: KL divergence between network policy and MCTS visit distribution
412
+ policy_loss = F.kl_div(F.log_softmax(logits, dim=1), batch_pi, reduction='batchmean')
413
+
414
+ pred_v = value_net(batch_W)
415
+ value_loss = F.mse_loss(pred_v, batch_v)
416
+
417
+ loss = policy_loss + value_loss
418
+ total_loss += loss.item()
419
+
420
+ opt_p.zero_grad()
421
+ opt_v.zero_grad()
422
+ loss.backward()
423
+
424
+ # Optional but recommended: gradient clipping
425
+ torch.nn.utils.clip_grad_norm_(policy_net.parameters(), max_norm=1.0)
426
+ torch.nn.utils.clip_grad_norm_(value_net.parameters(), max_norm=1.0)
427
+
428
+ opt_p.step()
429
+ opt_v.step()
430
+
431
+ # Optional: print epoch loss for monitoring
432
+ print(f"Epoch {epoch+1}/{epochs} - Loss: {total_loss/len(loader):.4f}")
433
+
434
+ # ============================================================================
435
+ # MCVS SEARCHER
436
+ # ============================================================================
437
+
438
+ class MCVSSearcher:
439
+ def __init__(self, policy_net, value_net, zone_db, device='cpu',
440
+ cpuct=1.41, lambda_zone=0.0, k_zone=5, use_nets=True,
441
+ dirichlet_alpha: float = 0.3, dirichlet_noise_fraction: float = 0.25):
442
+ self.policy_net = policy_net.to(device) if policy_net and use_nets else None
443
+ self.value_net = value_net.to(device) if value_net and use_nets else None
444
+ self.zone_db = zone_db
445
+ self.device = device
446
+ self.cpuct = cpuct
447
+ self.lambda_zone = lambda_zone
448
+ self.k_zone = k_zone
449
+ self.use_nets = use_nets
450
+ # Dirichlet noise hyperparameters (alpha for the Dirichlet, and fraction)
451
+ self.dirichlet_alpha = float(dirichlet_alpha)
452
+ self.dirichlet_noise_fraction = float(dirichlet_noise_fraction)
453
+
454
+ def _rollout(self, game: Chess) -> float:
455
+ """Simple random rollout for leaf evaluation when no value network.
456
+
457
+ Uses push/pop (apply_move / undo_move) on the provided `game` to avoid
458
+ allocating a full copy per rollout. The rollout will undo its own
459
+ moves before returning.
460
+ """
461
+ depth = 0
462
+ max_depth = 60 # reduced depth for faster rollouts (configurable)
463
+ moves_played = []
464
+ while not game.is_terminal() and depth < max_depth:
465
+ moves = game.get_legal_moves()
466
+ if not moves:
467
+ break
468
+ m = random.choice(moves)
469
+ game.apply_move(m)
470
+ moves_played.append(m)
471
+ depth += 1
472
+
473
+ winner = game.check_winner()
474
+
475
+ # Undo rollout moves
476
+ for _ in range(len(moves_played)):
477
+ game.undo_move()
478
+
479
+ return float(winner) # +1, -1, or 0
480
+
481
+ class Node:
482
+ def __init__(self, prior: float = 0.0, zone: float = 0.0):
483
+ self.prior = prior
484
+ self.zone = zone
485
+ self.visit_count = 0
486
+ self.value_sum = 0.0
487
+ self.children: Dict[chess.Move, 'MCVSSearcher.Node'] = {}
488
+
489
+ def search_with_time_budget(self, game: Chess, time_budget: float) -> Tuple[Dict[chess.Move, int], int]:
490
+ root = self.Node()
491
+ start_time = time.time()
492
+ simulations = 0
493
+
494
+ while time.time() - start_time < time_budget:
495
+ current_game = game
496
+ node = root
497
+ path = []
498
+ moves_made = []
499
+
500
+ # Selection
501
+ while node.children:
502
+ path.append(node)
503
+ total_n = sum(c.visit_count for c in node.children.values()) + 1
504
+ best_score = -float('inf')
505
+ best_move = None
506
+ best_child = None
507
+ for move, child in node.children.items():
508
+ q = child.value_sum / child.visit_count if child.visit_count > 0 else 0.0
509
+ p_lambda = max(child.prior + self.lambda_zone * child.zone, 0.001)
510
+ u = self.cpuct * p_lambda * np.sqrt(total_n) / (1 + child.visit_count)
511
+ score = q + u
512
+ if score > best_score:
513
+ best_score = score
514
+ best_move = move
515
+ best_child = child
516
+ current_game.apply_move(best_move)
517
+ moves_made.append(best_move)
518
+ node = best_child
519
+
520
+ path.append(node)
521
+
522
+ # Terminal node?
523
+ if current_game.is_terminal():
524
+ value = float(current_game.check_winner())
525
+ else:
526
+ # Expansion & Evaluation
527
+ legal_moves = current_game.get_legal_moves()
528
+
529
+ if self.use_nets and self.policy_net is not None and self.value_net is not None:
530
+ # === Neural network branch ===
531
+ W = current_game.get_weighted_adjacency_matrix()
532
+ W_tensor = torch.from_numpy(W)[None, None, :, :].float().to(self.device)
533
+ with torch.no_grad():
534
+ logits = self.policy_net(W_tensor)[0]
535
+ value = self.value_net(W_tensor)[0, 0].item()
536
+
537
+ probs = F.softmax(logits, dim=0).cpu().numpy()
538
+ prior_dict = {}
539
+ prior_sum = 0.0
540
+ for m in legal_moves:
541
+ idx = m.from_square * 64 + m.to_square
542
+ p = float(probs[idx])
543
+ prior_dict[m] = p
544
+ prior_sum += p
545
+ if prior_sum > 0.0:
546
+ for m in prior_dict:
547
+ prior_dict[m] /= prior_sum
548
+ else:
549
+ p = 1.0 / len(legal_moves)
550
+ prior_dict = {m: p for m in legal_moves}
551
+ else:
552
+ # === Zone-only / no-nets branch ===
553
+ value = self._rollout(current_game)
554
+ prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves} if legal_moves else {}
555
+
556
+ # Dirichlet noise only at root and only when using nets.
557
+ # Use configured hyperparameters so the same alpha/fraction
558
+ # can be applied to both priors and zone guidance.
559
+ if len(path) == 1 and self.use_nets and legal_moves:
560
+ noise = np.random.dirichlet([self.dirichlet_alpha] * len(legal_moves))
561
+ for i, m in enumerate(legal_moves):
562
+ prior_dict[m] = (1 - self.dirichlet_noise_fraction) * prior_dict[m] + \
563
+ self.dirichlet_noise_fraction * noise[i]
564
+ # Renormalize
565
+ prior_sum = sum(prior_dict.values())
566
+ if prior_sum > 0:
567
+ for m in prior_dict:
568
+ prior_dict[m] /= prior_sum
569
+
570
+ # Create children with zone scores. Use apply/undo on the shared
571
+ # board to avoid allocating a full copy per child. Skip heavy
572
+ # weighted-matrix computations when zone guidance is disabled
573
+ # (lambda_zone == 0) or when the DB is empty.
574
+ zone_db_empty = (len(self.zone_db.winning_matrices) == 0 and
575
+ len(self.zone_db.losing_matrices) == 0 and
576
+ len(self.zone_db.draw_matrices) == 0)
577
+ need_zone = (self.lambda_zone != 0.0) and (not zone_db_empty)
578
+
579
+ for i, m in enumerate(legal_moves):
580
+ if need_zone:
581
+ current_game.apply_move(m)
582
+ child_W = current_game.get_weighted_adjacency_matrix()
583
+ child_Z = self.zone_db.compute_zone_score(child_W, k=self.k_zone)
584
+ current_game.undo_move()
585
+ else:
586
+ child_Z = 0.0
587
+
588
+ # If Dirichlet noise was added to priors at the root, apply the same
589
+ # noise (mapped to [-1,1]) to zone guidance so both signals share
590
+ # the same stochastic perturbation controlled by the same alpha.
591
+ if len(path) == 1 and self.use_nets and legal_moves:
592
+ z_noise = (noise[i] * 2.0) - 1.0 # map [0,1] -> [-1,1]
593
+ child_Z = (1 - self.dirichlet_noise_fraction) * child_Z + \
594
+ self.dirichlet_noise_fraction * z_noise
595
+
596
+ child = self.Node(prior=prior_dict.get(m, 0.0), zone=child_Z)
597
+ node.children[m] = child
598
+
599
+ # Backpropagation
600
+ v = value
601
+ for n in reversed(path):
602
+ n.visit_count += 1
603
+ n.value_sum += v
604
+ v = -v
605
+
606
+ # Undo moves that were applied during selection (the rollout
607
+ # already undid its own moves). This restores `game` to the root
608
+ # state for the next simulation.
609
+ for _ in range(len(moves_made)):
610
+ current_game.undo_move()
611
+
612
+ simulations += 1
613
+
614
+ visits = {move: child.visit_count for move, child in root.children.items()}
615
+ if not visits:
616
+ legal = game.get_legal_moves()
617
+ visits = {random.choice(legal): 1} if legal else {}
618
+ return visits, simulations
619
+
620
+ # ============================================================================
621
+ # UCT SEARCHER (your original from the provided file)
622
+ # ============================================================================
623
+
624
+ class UCTSearcher:
625
+ def __init__(self, cpuct=np.sqrt(2.0)):
626
+ self.cpuct = cpuct
627
+
628
+ class Node:
629
+ def __init__(self):
630
+ self.visit_count = 0
631
+ self.value_sum = 0.0
632
+ self.children: Dict[chess.Move, 'UCTSearcher.Node'] = {}
633
+
634
+ def _rollout(self, game: Chess) -> float:
635
+ depth = 0
636
+ max_depth = 60
637
+ moves_played = []
638
+ while not game.is_terminal() and depth < max_depth:
639
+ moves = game.get_legal_moves()
640
+ if not moves:
641
+ break
642
+ m = random.choice(moves)
643
+ game.apply_move(m)
644
+ moves_played.append(m)
645
+ depth += 1
646
+
647
+ if depth >= max_depth:
648
+ # undo rollout moves
649
+ for _ in range(len(moves_played)):
650
+ game.undo_move()
651
+ return 0.0
652
+
653
+ winner = game.check_winner()
654
+
655
+ # undo rollout moves
656
+ for _ in range(len(moves_played)):
657
+ game.undo_move()
658
+
659
+ if winner == 1:
660
+ return 1.0
661
+ elif winner == -1:
662
+ return -1.0
663
+ else:
664
+ return 0.0
665
+
666
+ def search_with_time_budget(self, game: Chess, time_budget: float) -> Tuple[Dict[chess.Move, int], int]:
667
+ root = self.Node()
668
+ start_time = time.time()
669
+ simulations = 0
670
+
671
+ while time.time() - start_time < time_budget:
672
+ current_game = game
673
+ node = root
674
+ path = [node]
675
+ moves_made = []
676
+
677
+ while True:
678
+ legal_moves = current_game.get_legal_moves()
679
+
680
+ if current_game.is_terminal():
681
+ winner = current_game.check_winner()
682
+ value = 1.0 if winner == 1 else -1.0 if winner == -1 else 0.0
683
+ break
684
+
685
+ if not legal_moves:
686
+ value = 0.0
687
+ break
688
+
689
+ unvisited_moves = [m for m in legal_moves if m not in node.children]
690
+ if unvisited_moves:
691
+ move = random.choice(unvisited_moves)
692
+ child = self.Node()
693
+ node.children[move] = child
694
+ current_game.apply_move(move)
695
+ moves_made.append(move)
696
+ path.append(child)
697
+ value = self._rollout(current_game)
698
+ break
699
+
700
+ best_score = -float('inf')
701
+ best_move = None
702
+ best_child = None
703
+ total_visits = node.visit_count
704
+
705
+ for move in legal_moves:
706
+ child = node.children[move]
707
+ Q = child.value_sum / (child.visit_count + 1e-8) if child.visit_count > 0 else 0.0
708
+ U = self.cpuct * np.sqrt(np.log(total_visits + 1) / (child.visit_count + 1e-8))
709
+ score = Q + U
710
+ if score > best_score:
711
+ best_score = score
712
+ best_move = move
713
+ best_child = child
714
+
715
+ current_game.apply_move(best_move)
716
+ moves_made.append(best_move)
717
+ node = best_child
718
+ path.append(node)
719
+
720
+ for n in reversed(path):
721
+ n.visit_count += 1
722
+ n.value_sum += value
723
+ value = -value
724
+
725
+ # Undo selection moves to restore root state
726
+ for _ in range(len(moves_made)):
727
+ current_game.undo_move()
728
+
729
+ simulations += 1
730
+
731
+ visits = {move: child.visit_count for move, child in root.children.items()}
732
+ if not visits:
733
+ legal = game.get_legal_moves()
734
+ return {random.choice(legal): 1} if legal else {}, simulations
735
+ return visits, simulations
736
+
737
+ if __name__ == "__main__":
738
+ print("chess_mcvs.py loaded successfully")