test1978 commited on
Commit
4bcf263
·
verified ·
1 Parent(s): 9319f6a

delete breakthrough_mcvs.py

Browse files
Files changed (1) hide show
  1. breakthrough_mcvs.py +0 -1054
breakthrough_mcvs.py DELETED
@@ -1,1054 +0,0 @@
1
- import numpy as np
2
- import torch
3
- import torch.nn as nn
4
- import torch.optim as optim
5
- import torch.nn.functional as F
6
- from torch.utils.data import DataLoader, TensorDataset
7
- from typing import List, Dict, Tuple
8
- import random
9
- import time
10
- import os
11
- import bisect
12
-
13
- from abc_model import ABCModelDynamic
14
- from matrix_model import WeightedMatrixABC
15
-
16
- # ============================================================================
17
- # HILBERT CURVE UTILITIES
18
- # ============================================================================
19
-
20
- def xy2d(n: int, x: int, y: int) -> int:
21
- """Convert (x,y) to Hilbert curve distance."""
22
- d = 0
23
- s = n // 2
24
- while s > 0:
25
- rx = 1 if (x & s) > 0 else 0
26
- ry = 1 if (y & s) > 0 else 0
27
- d += s * s * ((3 * rx) ^ ry)
28
- if ry == 0:
29
- if rx == 1:
30
- x = n - 1 - x
31
- y = n - 1 - y
32
- x, y = y, x
33
- s //= 2
34
- return d
35
-
36
- def matrix_to_hilbert_index(W: np.ndarray) -> int:
37
- """
38
- Convert 64x64 matrix to Hilbert curve index.
39
- """
40
- if W.size == 0:
41
- return 0
42
-
43
- # Find location of maximum value
44
- W_flat = W.flatten()
45
- max_idx = np.argmax(W_flat)
46
-
47
- x = max_idx % 64
48
- y = max_idx // 64
49
- idx = xy2d(64, x, y)
50
- # Sanity check: index must be in [0, 64*64-1]
51
- assert 0 <= idx < 64 * 64, f"Hilbert index out of range: {idx}"
52
- return idx
53
-
54
-
55
- def move_to_index(fr: int, fc: int, tr: int, tc: int) -> int:
56
- """Convert a move tuple (fr,fc,tr,tc) to a policy index in [0,4095].
57
-
58
- Mapping: idx = (from_sq) * 64 + to_sq, where from_sq = fr*8 + fc, to_sq = tr*8 + tc
59
- """
60
- from_sq = fr * 8 + fc
61
- to_sq = tr * 8 + tc
62
- return from_sq * 64 + to_sq
63
-
64
- # ============================================================================
65
- # BREAKTHROUGH GAME LOGIC
66
- # ============================================================================
67
-
68
- class Breakthrough:
69
- ROWS, COLS = 8, 8
70
- EMPTY, PLAYER1, PLAYER2 = 0, 1, 2
71
-
72
- def __init__(self):
73
- self.board = np.zeros((self.ROWS, self.COLS), dtype=np.int32)
74
- self.board[0:2, :] = self.PLAYER1
75
- self.board[6:8, :] = self.PLAYER2
76
- self.move_count = 0
77
- self.abc_model = ABCModelDynamic(n=2, t=1.0, T=1.41)
78
- self._cached_matrix = None
79
- self._setup_abc_initial()
80
-
81
- def _setup_abc_initial(self):
82
- """Initialize ABC model with starting pieces."""
83
- for row in range(2):
84
- for col in range(self.COLS):
85
- x = col - 3.5
86
- y = 3.5 - row
87
- self.abc_model.add_piece((x, y), (1.0, 1.0, 1.0))
88
-
89
- for row in range(6, 8):
90
- for col in range(self.COLS):
91
- x = col - 3.5
92
- y = 3.5 - row
93
- self.abc_model.add_piece((x, y), (1.0, 1.1, 1.0))
94
-
95
- def copy(self):
96
- """Create a deep copy of the game state."""
97
- new_game = Breakthrough()
98
- new_game.board = self.board.copy()
99
- new_game.move_count = self.move_count
100
- new_game.abc_model = ABCModelDynamic(n=2, t=1.0, T=1.41)
101
-
102
- for entry in self.abc_model.history:
103
- pos = entry['SB_position']
104
- # Preserve kappa (tokenized vector) if present
105
- kappa_vec = entry.get('kappa', None)
106
- new_game.abc_model.add_piece(pos, entry['delta'], kappa_vector=kappa_vec)
107
-
108
- new_game._cached_matrix = self._cached_matrix.copy() if self._cached_matrix is not None else None
109
- return new_game
110
-
111
- def get_legal_moves(self) -> List[Tuple[int, int, int, int]]:
112
- """Get all legal moves for the current player."""
113
- moves = []
114
- player = self.PLAYER1 if self.move_count % 2 == 0 else self.PLAYER2
115
- direction = 1 if player == self.PLAYER1 else -1
116
-
117
- for r in range(self.ROWS):
118
- for c in range(self.COLS):
119
- if self.board[r, c] == player:
120
- # Forward move
121
- nr = r + direction
122
- if 0 <= nr < self.ROWS and self.board[nr, c] == self.EMPTY:
123
- moves.append((r, c, nr, c))
124
-
125
- # Diagonal captures
126
- for dc in [-1, 1]:
127
- nc = c + dc
128
- nr = r + direction
129
- opponent = 3 - player
130
- if 0 <= nr < self.ROWS and 0 <= nc < self.COLS and self.board[nr, nc] == opponent:
131
- moves.append((r, c, nr, nc))
132
-
133
- return moves
134
-
135
- def apply_move(self, move: Tuple[int, int, int, int]) -> None:
136
- """Apply a move and update the ABC model."""
137
- fr, fc, tr, tc = move
138
- player = self.board[fr, fc]
139
- self.board[tr, tc] = player
140
- self.board[fr, fc] = self.EMPTY
141
- self.move_count += 1
142
-
143
- x = tc - 3.5
144
- y = 3.5 - tr
145
- delta_2 = 1.0 if player == self.PLAYER1 else 1.1
146
-
147
- self.abc_model.add_piece((x, y), (1.0, delta_2, 1.0))
148
- self._cached_matrix = None
149
-
150
- def check_winner(self) -> int:
151
- """Check if there's a winner."""
152
- if np.any(self.board[0, :] == self.PLAYER2):
153
- return self.PLAYER2
154
- if np.any(self.board[7, :] == self.PLAYER1):
155
- return self.PLAYER1
156
- return self.EMPTY
157
-
158
- def is_terminal(self) -> bool:
159
- """Check if the game has ended."""
160
- return self.check_winner() != self.EMPTY or len(self.get_legal_moves()) == 0
161
-
162
- def get_weighted_adjacency_matrix(self) -> np.ndarray:
163
- if self._cached_matrix is not None:
164
- return self._cached_matrix
165
-
166
- occupied = []
167
- for r in range(8):
168
- for c in range(8):
169
- piece = self.board[r, c]
170
- if piece != self.EMPTY:
171
- x = c - 3.5
172
- y = 3.5 - r
173
- delta_2 = 1.0 if piece == self.PLAYER1 else 1.1
174
- kappa = (1.0, delta_2, 1.0) # δ1=1.0 fixed, δ2=color, δ3=1.0 (no piece-type variation)
175
- hilbert_d = xy2d(8, c, r) # file=c, rank=r
176
- occupied.append((hilbert_d, (x, y), kappa))
177
-
178
- occupied.sort(key=lambda t: t[0]) # Hilbert order
179
-
180
- abc = ABCModelDynamic(n=2, t=1.0, T=1.41)
181
- for _, pos, kappa_vec in occupied:
182
- abc.add_piece(pos, delta_values=kappa_vec)
183
-
184
- builder = WeightedMatrixABC(abc, sigma=1.0)
185
- W_small = builder.compute_weighted_matrix()
186
-
187
- n = len(occupied)
188
- W = np.zeros((64, 64), dtype=np.float32)
189
- if n > 0:
190
- W[:n, :n] = W_small
191
-
192
- self._cached_matrix = W
193
- return W
194
-
195
- # ============================================================================
196
- # NEURAL NETWORKS
197
- # ============================================================================
198
-
199
- class PolicyNetworkCNN(nn.Module):
200
- def __init__(self):
201
- super().__init__()
202
- self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
203
- self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
204
- self.relu = nn.ReLU()
205
- self.pool = nn.AdaptiveAvgPool2d(8)
206
- self.fc = nn.Linear(64 * 8 * 8, 4096)
207
-
208
- def forward(self, Wt):
209
- x = self.relu(self.conv1(Wt))
210
- x = self.relu(self.conv2(x))
211
- x = self.pool(x)
212
- x = x.view(x.size(0), -1)
213
- logits = self.fc(x)
214
- return logits
215
-
216
-
217
- class ValueNetworkCNN(nn.Module):
218
- def __init__(self):
219
- super().__init__()
220
- self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
221
- self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
222
- self.relu = nn.ReLU()
223
- self.pool = nn.AdaptiveAvgPool2d(4)
224
- self.fc1 = nn.Linear(64 * 4 * 4, 64)
225
- self.fc2 = nn.Linear(64, 1)
226
- self.tanh = nn.Tanh()
227
-
228
- def forward(self, Wt):
229
- x = self.relu(self.conv1(Wt))
230
- x = self.relu(self.conv2(x))
231
- x = self.pool(x)
232
- x = x.view(x.size(0), -1)
233
- x = self.relu(self.fc1(x))
234
- value = self.tanh(self.fc2(x))
235
- return value
236
-
237
-
238
- # ============================================================================
239
- # ZONE DATABASE
240
- # ============================================================================
241
-
242
- class HilbertOrderedZoneDatabase:
243
- def __init__(self, filepath: str = "breakthrough_zone_db.npz", max_size: int = 10000):
244
- self.filepath = filepath
245
- self.max_size = max_size # Maximum matrices per zone
246
- self.winning_matrices = []
247
- self.winning_indices = []
248
- self.losing_matrices = []
249
- self.losing_indices = []
250
- self.draw_matrices = []
251
- self.draw_indices = []
252
- self.load()
253
-
254
- def add_winning_matrix(self, W: np.ndarray):
255
- if W.shape != (64, 64):
256
- return
257
-
258
- hilbert_idx = matrix_to_hilbert_index(W)
259
- pos = bisect.bisect_left(self.winning_indices, hilbert_idx)
260
- self.winning_indices.insert(pos, hilbert_idx)
261
- self.winning_matrices.insert(pos, W.copy())
262
-
263
- def add_losing_matrix(self, W: np.ndarray):
264
- if W.shape != (64, 64):
265
- return
266
-
267
- hilbert_idx = matrix_to_hilbert_index(W)
268
- pos = bisect.bisect_left(self.losing_indices, hilbert_idx)
269
- self.losing_indices.insert(pos, hilbert_idx)
270
- self.losing_matrices.insert(pos, W.copy())
271
-
272
- def add_draw_matrix(self, W: np.ndarray):
273
- if W.shape != (64, 64):
274
- return
275
-
276
- hilbert_idx = matrix_to_hilbert_index(W)
277
- pos = bisect.bisect_left(self.draw_indices, hilbert_idx)
278
- self.draw_indices.insert(pos, hilbert_idx)
279
- self.draw_matrices.insert(pos, W.copy())
280
-
281
- def add_game_record(self, trajectory: List[Breakthrough], result: int, sample_rate: float = 0.3):
282
- """Add game record with sampling to control database size."""
283
- # Sample states: always keep first and last, random subset of middle
284
- sampled_states = []
285
-
286
- for i, state in enumerate(trajectory):
287
- if i == 0 or i == len(trajectory) - 1:
288
- # Always keep start and end positions
289
- sampled_states.append(state)
290
- elif random.random() < sample_rate:
291
- # Randomly sample middle positions
292
- sampled_states.append(state)
293
-
294
- for state in sampled_states:
295
- W = state.get_weighted_adjacency_matrix()
296
-
297
- if result == 1:
298
- self.add_winning_matrix(W)
299
- elif result == -1:
300
- self.add_losing_matrix(W)
301
- else:
302
- self.add_draw_matrix(W)
303
-
304
- def save(self):
305
- """Save database with pruning if too large."""
306
- # Prune if any zone exceeds 80% of max_size
307
- threshold = int(self.max_size * 0.8)
308
-
309
- if (len(self.winning_matrices) > threshold or
310
- len(self.losing_matrices) > threshold or
311
- len(self.draw_matrices) > threshold):
312
- print(f"Pruning database before save...")
313
- self.prune_database(target_size=int(self.max_size * 0.7))
314
-
315
- try:
316
- np.savez_compressed(
317
- self.filepath,
318
- winning=np.array(self.winning_matrices, dtype=object),
319
- winning_indices=np.array(self.winning_indices),
320
- losing=np.array(self.losing_matrices, dtype=object),
321
- losing_indices=np.array(self.losing_indices),
322
- draw=np.array(self.draw_matrices, dtype=object),
323
- draw_indices=np.array(self.draw_indices),
324
- )
325
- print(f"Zone DB saved: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
326
- except MemoryError:
327
- print(f"MemoryError during save! Pruning more aggressively...")
328
- self.prune_database(target_size=1000)
329
- # Try again with much smaller size
330
- np.savez_compressed(
331
- self.filepath,
332
- winning=np.array(self.winning_matrices, dtype=object),
333
- winning_indices=np.array(self.winning_indices),
334
- losing=np.array(self.losing_matrices, dtype=object),
335
- losing_indices=np.array(self.losing_indices),
336
- draw=np.array(self.draw_matrices, dtype=object),
337
- draw_indices=np.array(self.draw_indices),
338
- )
339
-
340
- def load(self):
341
- if os.path.exists(self.filepath):
342
- try:
343
- data = np.load(self.filepath, allow_pickle=True)
344
- self.winning_matrices = list(data['winning'])
345
- self.winning_indices = list(data['winning_indices'])
346
- self.losing_matrices = list(data['losing'])
347
- self.losing_indices = list(data['losing_indices'])
348
- self.draw_matrices = list(data['draw'])
349
- self.draw_indices = list(data['draw_indices'])
350
- print(f"Loaded zone DB: W={len(self.winning_matrices)}, L={len(self.losing_matrices)}, D={len(self.draw_matrices)}")
351
- except Exception as e:
352
- print(f"Failed to load zone database: {e}")
353
-
354
- def prune_database(self, target_size: int = 5000):
355
- """Keep only evenly-spaced diverse samples along Hilbert curve."""
356
- def prune_zone(matrices, indices, target):
357
- if len(matrices) <= target:
358
- return matrices, indices
359
-
360
- # Keep evenly spaced samples
361
- step = max(1, len(matrices) // target)
362
- keep_idx = list(range(0, len(matrices), step))[:target]
363
-
364
- new_matrices = [matrices[i] for i in keep_idx]
365
- new_indices = [indices[i] for i in keep_idx]
366
-
367
- return new_matrices, new_indices
368
-
369
- old_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
370
-
371
- self.winning_matrices, self.winning_indices = prune_zone(
372
- self.winning_matrices, self.winning_indices, target_size
373
- )
374
- self.losing_matrices, self.losing_indices = prune_zone(
375
- self.losing_matrices, self.losing_indices, target_size
376
- )
377
- self.draw_matrices, self.draw_indices = prune_zone(
378
- self.draw_matrices, self.draw_indices, target_size
379
- )
380
-
381
- new_sizes = (len(self.winning_matrices), len(self.losing_matrices), len(self.draw_matrices))
382
- print(f"Pruned: W {old_sizes}→{new_sizes}, L {old_sizes}→{new_sizes}, D {old_sizes}→{new_sizes}")
383
-
384
- def compute_zone_score(self, W: np.ndarray, k: int = 5, beta: float = 0.5) -> float:
385
- """Compute zone guidance score Z(x(t), a) ∈ [-1, 1]."""
386
- if W.shape != (64, 64):
387
- return 0.0
388
-
389
- def knn_similarity(matrices, k_val):
390
- if len(matrices) == 0:
391
- return 0.0
392
-
393
- k_actual = min(k_val, len(matrices))
394
- distances = []
395
-
396
- for mat in matrices[:k_actual]:
397
- dist = np.sum(np.abs(W - mat)) / (64.0 * 64.0)
398
- distances.append(dist)
399
-
400
- similarities = [1.0 - d for d in distances]
401
- return np.mean(similarities)
402
-
403
- zone_win = knn_similarity(self.winning_matrices, k)
404
- zone_loss = knn_similarity(self.losing_matrices, k)
405
- zone_draw = knn_similarity(self.draw_matrices, k)
406
-
407
- Z = zone_win - zone_loss + beta * zone_draw
408
- return float(np.clip(Z, -1.0, 1.0))
409
-
410
-
411
-
412
- # ============================================================================
413
- # MCVS
414
- # ============================================================================
415
-
416
- class MCVSNode:
417
- def __init__(self, game: Breakthrough, parent=None, move=None):
418
- self.game = game.copy()
419
- self.parent = parent
420
- self.move = move
421
- self.children = {}
422
- self.N = 0
423
- self.Q = 0.0
424
- self.P = 0.0
425
- self.Z = 0.0 # Zone guidance score Z(x(t), a) ∈ [-1,1]
426
-
427
- def value(self) -> float:
428
- return self.Q / self.N if self.N > 0 else 0.0
429
-
430
- class MCVSSearcher:
431
- class Node:
432
- def __init__(self, prior: float = 0.0, zone: float = 0.0):
433
- self.prior = prior
434
- self.zone = zone
435
- self.visit_count = 0
436
- self.value_sum = 0.0
437
- self.children = {}
438
-
439
- def __init__(self, policy_net=None, value_net=None, zone_db=None, device='cpu',
440
- cpuct=1.41, lambda_zone=0.0, k_zone=5, use_nets=True):
441
- self.policy_net = policy_net.to(device) if policy_net and use_nets else None
442
- self.value_net = value_net.to(device) if value_net and use_nets else None
443
- self.zone_db = zone_db
444
- self.device = device
445
- self.cpuct = cpuct
446
- self.lambda_zone = lambda_zone
447
- self.k_zone = k_zone
448
- self.use_nets = use_nets
449
-
450
- def _quick_rollout(self, game):
451
- current = game.copy()
452
- for _ in range(100):
453
- if current.is_terminal():
454
- break
455
- moves = current.get_legal_moves()
456
- if not moves:
457
- break
458
- current.apply_move(random.choice(moves))
459
- winner = current.check_winner()
460
- if winner == Breakthrough.PLAYER1:
461
- return 1.0
462
- elif winner == Breakthrough.PLAYER2:
463
- return -1.0
464
- else:
465
- return 0.0
466
-
467
- def search_with_time_budget(self, game, time_budget: float):
468
- root = self.Node()
469
- start_time = time.time()
470
- simulations = 0
471
- eps = 1e-6
472
-
473
- # If there are no legal moves at the root, return empty visits and zero sims
474
- root_legal = game.get_legal_moves()
475
- if not root_legal:
476
- return {}, 0
477
-
478
- while time.time() - start_time < time_budget:
479
- current_game = game.copy()
480
- node = root
481
- path = []
482
-
483
- # SELECTION
484
- while node.children:
485
- path.append(node)
486
- total_n = sum(c.visit_count for c in node.children.values()) + 1
487
- best_score = -float('inf')
488
- best_move = None
489
- best_child = None
490
- for move, child in node.children.items():
491
- q = child.value_sum / child.visit_count if child.visit_count > 0 else 0.0
492
- p_lambda = max(child.prior + self.lambda_zone * child.zone, eps)
493
- u = self.cpuct * p_lambda * np.sqrt(total_n) / (1 + child.visit_count)
494
- score = q + u
495
- if score > best_score:
496
- best_score = score
497
- best_move = move
498
- best_child = child
499
- # Advance game and node
500
- if best_move is None:
501
- break
502
- current_game.apply_move(best_move)
503
- node = best_child
504
-
505
- path.append(node)
506
-
507
- # EXPANSION / EVALUATION
508
- if current_game.is_terminal():
509
- w = current_game.check_winner()
510
- if w == Breakthrough.PLAYER1:
511
- value = 1.0
512
- elif w == Breakthrough.PLAYER2:
513
- value = -1.0
514
- else:
515
- value = 0.0
516
- legal_moves = []
517
- prior_dict = {}
518
- else:
519
- legal_moves = current_game.get_legal_moves()
520
- if not legal_moves:
521
- prior_dict = {}
522
- value = 0.0
523
- else:
524
- if self.use_nets and self.policy_net is not None and self.value_net is not None:
525
- W = current_game.get_weighted_adjacency_matrix()
526
- W_tensor = torch.tensor(W, dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(self.device)
527
- with torch.no_grad():
528
- logits = self.policy_net(W_tensor)
529
- probs = torch.softmax(logits, dim=1).squeeze(0).cpu().numpy()
530
- val = float(self.value_net(W_tensor).squeeze().cpu().numpy())
531
- value = float(np.clip(val, -1.0, 1.0))
532
-
533
- prior_dict = {}
534
- total_p = 0.0
535
- for m in legal_moves:
536
- fr, fc, tr, tc = m
537
- from_sq = fr * 8 + fc
538
- to_sq = tr * 8 + tc
539
- idx = from_sq * 64 + to_sq
540
- p = float(probs[idx]) if idx < probs.size else 0.0
541
- prior_dict[m] = p
542
- total_p += p
543
- if total_p <= 0.0:
544
- prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves}
545
- else:
546
- for m in prior_dict:
547
- prior_dict[m] /= total_p
548
- else:
549
- prior_dict = {m: 1.0 / len(legal_moves) for m in legal_moves}
550
- value = self._quick_rollout(current_game)
551
-
552
- # EXPAND CHILDREN
553
- for m in legal_moves:
554
- child_game = current_game.copy()
555
- child_game.apply_move(m)
556
- child_W = child_game.get_weighted_adjacency_matrix()
557
- child_Z = self.zone_db.compute_zone_score(child_W, k=self.k_zone) if self.zone_db else 0.0
558
- child_prior = prior_dict.get(m, 1.0 / len(legal_moves))
559
- node_prior = max(child_prior + self.lambda_zone * child_Z, eps)
560
- node.children[m] = self.Node(prior=node_prior, zone=child_Z)
561
-
562
- # BACKPROPAGATION
563
- v = value
564
- for n in reversed(path):
565
- n.visit_count += 1
566
- n.value_sum += v
567
- v = -v
568
-
569
- simulations += 1
570
-
571
- visits = {move: child.visit_count for move, child in root.children.items()}
572
- if not visits:
573
- legal = game.get_legal_moves()
574
- visits = {random.choice(legal): 1} if legal else {}
575
- return visits, simulations
576
-
577
- # ============================================================================
578
- # UCT-MCTS (Baseline without guidance)
579
- # ============================================================================
580
-
581
- class UCTNode:
582
- def __init__(self, game: Breakthrough, parent=None, move=None):
583
- self.game = game.copy()
584
- self.parent = parent
585
- self.move = move
586
- self.children: Dict[Tuple[int,int,int,int], "UCTNode"] = {}
587
- self.N = 0 # visit count
588
- self.Q = 0.0 # total value
589
-
590
- def value(self) -> float:
591
- return self.Q / self.N if self.N > 0 else 0.0
592
-
593
- class UCTSearcher:
594
- def __init__(self, cpuct: float = np.sqrt(2.0)):
595
- self.cpuct = cpuct
596
-
597
- def uct_score(self, parent: UCTNode, child: UCTNode) -> float:
598
- if child.N == 0:
599
- # Encourage exploration of unvisited nodes
600
- return float('inf')
601
- Q = child.value()
602
- U = self.cpuct * np.sqrt(parent.N + 1) / child.N
603
- return Q + U
604
-
605
- def simulate(self, game: Breakthrough) -> float:
606
- """
607
- Default policy: random playout to terminal state.
608
- Return +1 if PLAYER1 wins, -1 if PLAYER2 wins, 0 for draw.
609
- Value is from the perspective of the player to move at root,
610
- but since Breakthrough is alternating and we flip sign in backprop,
611
- we can just return game outcome from fixed perspective.
612
- """
613
- sim_game = game.copy()
614
- while not sim_game.is_terminal():
615
- moves = sim_game.get_legal_moves()
616
- if not moves:
617
- break
618
- move = random.choice(moves)
619
- sim_game.apply_move(move)
620
- winner = sim_game.check_winner()
621
- if winner == Breakthrough.PLAYER1:
622
- return 1.0
623
- elif winner == Breakthrough.PLAYER2:
624
- return -1.0
625
- else:
626
- return 0.0
627
-
628
- def search_with_time_budget(self, game: Breakthrough, time_budget: float) -> Dict[Tuple[int,int,int,int], int]:
629
- """
630
- Standard UCT search with a time budget (seconds).
631
- Returns a tuple: (move->visit count dict, simulations)
632
- """
633
- root = UCTNode(game)
634
- start_time = time.time()
635
- simulations = 0
636
-
637
- legal_moves = game.get_legal_moves()
638
- if not legal_moves:
639
- return {}, 0
640
-
641
- # Run simulations until time budget exhausted
642
- while time.time() - start_time < time_budget:
643
- node = root
644
-
645
- # SELECTION & EXPANSION
646
- while not node.game.is_terminal():
647
- legal = node.game.get_legal_moves()
648
- if not legal:
649
- break
650
-
651
- # If any unexpanded moves exist, expand one
652
- if len(node.children) < len(legal):
653
- untried = [m for m in legal if m not in node.children]
654
- if untried:
655
- move = random.choice(untried)
656
- child_game = node.game.copy()
657
- child_game.apply_move(move)
658
- child = UCTNode(child_game, parent=node, move=move)
659
- node.children[move] = child
660
- node = child
661
- break
662
-
663
- # Otherwise, select best child by UCT score
664
- best_move = max(node.children.keys(),
665
- key=lambda m: self.uct_score(node, node.children[m]))
666
- node = node.children[best_move]
667
-
668
- # SIMULATION
669
- value = self.simulate(node.game)
670
-
671
- # BACKPROPAGATION
672
- current = node
673
- while current is not None:
674
- current.N += 1
675
- current.Q += value
676
- value = -value # switch perspective
677
- current = current.parent
678
- simulations += 1
679
- return {move: child.N for move, child in root.children.items()}, simulations
680
-
681
-
682
- # ============================================================================
683
- # Self-play Data Generation
684
- # ============================================================================
685
-
686
- def generate_self_play_data(policy_net, value_net, zone_db, max_time_seconds=60.0,
687
- lambda_zone=0.5, device='cpu'):
688
- """Generate self-play games using MCTS with zone guidance."""
689
- searcher = MCVSSearcher(policy_net, value_net, zone_db, device=device,
690
- cpuct=1.41, lambda_zone=lambda_zone, k_zone=5)
691
-
692
- W_states = []
693
- policies = []
694
- values = []
695
- trajectories = []
696
-
697
- start_time = time.time()
698
- game_idx = 0
699
-
700
- while time.time() - start_time < max_time_seconds:
701
- game_start_time = time.time()
702
- game = Breakthrough()
703
- trajectory = [game.copy()]
704
- W_state_list = []
705
- policy_list = []
706
-
707
- while not game.is_terminal():
708
- time_per_move = 0.3 # Increased from 0.05 for more exploration/variety
709
- W_t = game.get_weighted_adjacency_matrix()
710
- visits, _ = searcher.search_with_time_budget(game, time_per_move)
711
-
712
- if not visits:
713
- break
714
-
715
- moves = list(visits.keys())
716
- visit_counts = np.array([visits[m] for m in moves], dtype=float)
717
- total_visits = visit_counts.sum()
718
- probs = visit_counts / total_visits
719
-
720
- # Add temperature for more variety in selection
721
- temperature = 1.0 if game.move_count < 8 else 0.25 # Higher early, lower late
722
- if temperature != 1.0:
723
- probs = probs ** (1.0 / temperature)
724
- probs = probs / probs.sum()
725
-
726
- policy_target = np.zeros(4096)
727
- for move, count in visits.items():
728
- fr, fc, tr, tc = move
729
- idx = move_to_index(fr, fc, tr, tc)
730
- if 0 <= idx < 4096:
731
- policy_target[idx] = count / total_visits
732
-
733
- W_state_list.append(W_t)
734
- policy_list.append(policy_target)
735
-
736
- if len(moves) > 1:
737
- chosen_idx = np.random.choice(len(moves), p=probs)
738
- chosen_move = moves[chosen_idx]
739
- else:
740
- chosen_move = moves[0]
741
-
742
- game.apply_move(chosen_move)
743
- trajectory.append(game.copy())
744
-
745
- winner = game.check_winner()
746
- result = 1 if winner == Breakthrough.PLAYER1 else -1 if winner == Breakthrough.PLAYER2 else 0
747
-
748
- for i in range(len(W_state_list)):
749
- player_to_move = 1 if i % 2 == 0 else -1
750
- value = result if result == 0 else (result if result == player_to_move else -result)
751
-
752
- W_states.append(W_state_list[i])
753
- policies.append(policy_list[i])
754
- values.append(value)
755
-
756
- zone_db.add_game_record(trajectory, result)
757
- trajectories.append(trajectory)
758
-
759
- game_idx += 1
760
- game_duration = time.time() - game_start_time
761
- total_elapsed = time.time() - start_time
762
-
763
- print(f"Game {game_idx}: Winner={winner}, Moves={game.move_count}, Duration={game_duration:.1f}s, Total={total_elapsed:.1f}s")
764
-
765
- elapsed = time.time() - start_time
766
- print(f"\n✅ Self-play finished: {game_idx} games in {elapsed:.1f}s, {len(W_states)} training examples collected")
767
-
768
- return W_states, policies, values, trajectories
769
-
770
-
771
- # ============================================================================
772
- # Training
773
- # ============================================================================
774
-
775
- def train_networks(policy_net, value_net, W_states, policies, values, epochs=5, batch_size=32, device='cpu', learning_rate=None, lr=0.001):
776
- """Train policy and value networks.
777
-
778
- Accepts either `learning_rate=` (preferred) or `lr=` for backward compatibility.
779
- """
780
- if learning_rate is not None:
781
- lr = learning_rate
782
-
783
- policy_net.train()
784
- value_net.train()
785
- opt_p = optim.Adam(policy_net.parameters(), lr=lr)
786
- opt_v = optim.Adam(value_net.parameters(), lr=lr)
787
-
788
- W_tensor = torch.tensor(np.array(W_states)[:, np.newaxis, :, :], dtype=torch.float32)
789
- pi_tensor = torch.tensor(np.array(policies), dtype=torch.float32)
790
- v_tensor = torch.tensor(np.array(values), dtype=torch.float32).unsqueeze(1)
791
-
792
- dataset = TensorDataset(W_tensor, pi_tensor, v_tensor)
793
- loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
794
-
795
- for epoch in range(epochs):
796
- total_loss = 0.0
797
- for batch_W, batch_pi, batch_v in loader:
798
- batch_W = batch_W.to(device)
799
- batch_pi = batch_pi.to(device)
800
- batch_v = batch_v.to(device)
801
-
802
- logits = policy_net(batch_W)
803
- # Fixed policy loss: KL divergence between network policy and MCTS visit distribution
804
- policy_loss = F.kl_div(F.log_softmax(logits, dim=1), batch_pi, reduction='batchmean')
805
-
806
- pred_v = value_net(batch_W)
807
- value_loss = F.mse_loss(pred_v, batch_v)
808
-
809
- loss = policy_loss + value_loss
810
- total_loss += loss.item()
811
-
812
- opt_p.zero_grad()
813
- opt_v.zero_grad()
814
- loss.backward()
815
-
816
- # Optional but recommended: gradient clipping
817
- torch.nn.utils.clip_grad_norm_(policy_net.parameters(), max_norm=1.0)
818
- torch.nn.utils.clip_grad_norm_(value_net.parameters(), max_norm=1.0)
819
-
820
- opt_p.step()
821
- opt_v.step()
822
-
823
- # Optional: print epoch loss for monitoring
824
- print(f"Epoch {epoch+1}/{epochs} - Loss: {total_loss/len(loader):.4f}")
825
-
826
-
827
- # ============================================================================
828
- # Diagnostic Test for Move Diversity
829
- # ============================================================================
830
-
831
- def test_move_diversity(policy_net, value_net, zone_db, num_trials=30, time_budget=2.0, device='cpu'):
832
- """Test if the AI chooses different moves in the same starting position."""
833
- searcher = MCVSSearcher(policy_net, value_net, zone_db, device=device)
834
- game = Breakthrough() # Starting position; change to any position you want to test
835
-
836
- move_counts = {}
837
- for i in range(num_trials):
838
- visits, _ = searcher.search_with_time_budget(game, time_budget)
839
- if visits:
840
- # Use argmax for the "best" move, but since there's noise/temp, it should vary
841
- best_move = max(visits, key=visits.get)
842
- move_counts[best_move] = move_counts.get(best_move, 0) + 1
843
-
844
- print("\nMove diversity test (starting position):")
845
- total = sum(move_counts.values())
846
- for m, cnt in sorted(move_counts.items(), key=lambda x: -x[1]):
847
- print(f"{m}: {cnt:2d}× ({cnt/total:.1%})")
848
- print(f"Unique moves chosen: {len(move_counts)} out of {len(game.get_legal_moves())} legal\n")
849
-
850
- # ============================================================================
851
- # Guided MCVS vs UCT-MCTS Match
852
- # ============================================================================
853
-
854
- def play_match_mcvs_vs_uct(policy_net,
855
- value_net,
856
- zone_db,
857
- num_games: int = 200,
858
- time_per_move: float = 0.3,
859
- device: str = 'cpu'):
860
- """
861
- Play a tournament between guided MCVS and UCT.
862
- Odd games: MCVS as PLAYER1 (first), UCT as PLAYER2.
863
- Even games: UCT as PLAYER1, MCVS as PLAYER2.
864
- """
865
- mcvs_searcher = MCVSSearcher(policy_net, value_net, zone_db,
866
- device=device, cpuct=1.41,
867
- lambda_zone=1.0, k_zone=5)
868
- uct_searcher = UCTSearcher(cpuct=np.sqrt(2.0))
869
-
870
- mcvs_wins = 0
871
- uct_wins = 0
872
- draws = 0
873
-
874
- for g in range(1, num_games + 1):
875
- game = Breakthrough()
876
- player_to_move = Breakthrough.PLAYER1 # always 1 then 2 then 1...
877
-
878
- # Alternate roles
879
- # True: MCVS plays PLAYER1 in this game
880
- mcvs_as_player1 = (g % 2 == 1)
881
-
882
- while not game.is_terminal():
883
- moves = game.get_legal_moves()
884
- if not moves:
885
- break
886
-
887
- # Decide which engine moves
888
- if player_to_move == Breakthrough.PLAYER1:
889
- use_mcvs = mcvs_as_player1
890
- else:
891
- use_mcvs = not mcvs_as_player1
892
-
893
- if use_mcvs:
894
- # Guided MCVS move
895
- visits, _ = mcvs_searcher.search_with_time_budget(game, time_per_move)
896
- else:
897
- # UCT move
898
- visits, _ = uct_searcher.search_with_time_budget(game, time_per_move)
899
-
900
- if not visits:
901
- break
902
-
903
- # Choose move by max visit count
904
- moves_list = list(visits.keys())
905
- counts = np.array([visits[m] for m in moves_list], dtype=float)
906
- best_index = int(np.argmax(counts))
907
- chosen_move = moves_list[best_index]
908
-
909
- game.apply_move(chosen_move)
910
- player_to_move = Breakthrough.PLAYER2 if player_to_move == Breakthrough.PLAYER1 else Breakthrough.PLAYER1
911
-
912
- winner = game.check_winner()
913
- if winner == Breakthrough.PLAYER1:
914
- if mcvs_as_player1:
915
- mcvs_wins += 1
916
- else:
917
- uct_wins += 1
918
- elif winner == Breakthrough.PLAYER2:
919
- if mcvs_as_player1:
920
- uct_wins += 1
921
- else:
922
- mcvs_wins += 1
923
- else:
924
- draws += 1
925
-
926
- print(f"Game {g}/{num_games} finished. Winner = {winner} "
927
- f"(MCVS as P1: {mcvs_as_player1})")
928
-
929
- print("\n=== MCVS vs UCT Tournament Results ===")
930
- print(f"Total games: {num_games}")
931
- print(f"MCVS wins : {mcvs_wins}")
932
- print(f"UCT wins : {uct_wins}")
933
- print(f"Draws : {draws}")
934
- if num_games > 0:
935
- mcvs_rate = mcvs_wins / num_games * 100.0
936
- uct_rate = uct_wins / num_games * 100.0
937
- draw_rate = draws / num_games * 100.0
938
- print(f"Win rates: MCVS={mcvs_rate:.1f}%, UCT={uct_rate:.1f}%, Draw={draw_rate:.1f}%\n")
939
-
940
-
941
- # ============================================================================
942
- # MAIN
943
- # ============================================================================
944
-
945
- if __name__ == "__main__":
946
- print("="*80)
947
- print("BREAKTHROUGH MCVS - INCREMENTAL TRAINING")
948
- print("="*80)
949
-
950
- device = "cuda" if torch.cuda.is_available() else "cpu"
951
- print(f"Device: {device}")
952
-
953
- # Initialize networks
954
- policy_net = PolicyNetworkCNN().to(device)
955
- value_net = ValueNetworkCNN().to(device)
956
-
957
- # Initialize zone database (with fixed version - max_size limits growth)
958
- zone_db = HilbertOrderedZoneDatabase("breakthrough_zone_db.npz", max_size=10000)
959
-
960
- # Try to load existing checkpoint
961
- checkpoint_path = "breakthrough_checkpoint.pt"
962
- iteration = 0
963
-
964
- if os.path.exists(checkpoint_path):
965
- print(f"Loading checkpoint from {checkpoint_path}...")
966
- checkpoint = torch.load(checkpoint_path, map_location=device)
967
- policy_net.load_state_dict(checkpoint['policy'])
968
- value_net.load_state_dict(checkpoint['value'])
969
- iteration = checkpoint.get('iteration', 0)
970
- print(f"Resumed from iteration {iteration}")
971
-
972
- # Accumulate training data across iterations
973
- all_W_states = []
974
- all_policies = []
975
- all_values = []
976
-
977
- # Main training loop
978
- while True:
979
- print("="*80)
980
- print(f"ITERATION {iteration}")
981
- print("="*80)
982
-
983
- # 1. Generate self-play games (adds to zone_db automatically)
984
- print("Generating self-play data (30 minutes)...")
985
- lambda_zone = 0.0 if iteration == 0 else 1.0 # No guidance first iteration
986
-
987
- W_states, policies, values, trajectories = generate_self_play_data(
988
- policy_net, value_net, zone_db,
989
- max_time_seconds=1800.0, # 30 minutes
990
- lambda_zone=lambda_zone,
991
- device=device
992
- )
993
-
994
- print(f"Collected {len(W_states)} training examples from {len(trajectories)} games")
995
-
996
- # 2. Accumulate training data
997
- all_W_states.extend(W_states)
998
- all_policies.extend(policies)
999
- all_values.extend(values)
1000
-
1001
- # Optional: Limit total training data size (keep most recent N examples)
1002
- max_training_examples = 50000
1003
- if len(all_W_states) > max_training_examples:
1004
- print(f"Trimming training data to most recent {max_training_examples} examples...")
1005
- all_W_states = all_W_states[-max_training_examples:]
1006
- all_policies = all_policies[-max_training_examples:]
1007
- all_values = all_values[-max_training_examples:]
1008
-
1009
- print(f"Total accumulated training examples: {len(all_W_states)}")
1010
-
1011
- # 3. Train networks on ALL accumulated data
1012
- print("Training networks...")
1013
- train_networks(
1014
- policy_net, value_net,
1015
- all_W_states, all_policies, all_values,
1016
- epochs=10, # Fewer epochs since we train more frequently
1017
- device=device,
1018
- learning_rate=0.001
1019
- )
1020
-
1021
- # 4. Save checkpoint (ONE .pt file)
1022
- print("Saving checkpoint...")
1023
- torch.save({
1024
- 'policy': policy_net.state_dict(),
1025
- 'value': value_net.state_dict(),
1026
- 'iteration': iteration,
1027
- 'num_training_examples': len(all_W_states)
1028
- }, checkpoint_path)
1029
- print(f"✅ Checkpoint saved: {checkpoint_path}")
1030
-
1031
- # 5. Save zone database (ONE .npz file)
1032
- zone_db.save()
1033
- print(f"✅ Zone DB saved: W={len(zone_db.winning_matrices)}, "
1034
- f"L={len(zone_db.losing_matrices)}, "
1035
- f"D={len(zone_db.draw_matrices)}")
1036
-
1037
- iteration += 1
1038
-
1039
- print("\n" + "="*80)
1040
- print(f"Iteration {iteration} complete!")
1041
- print(f"Files updated:")
1042
- print(f" - {checkpoint_path}")
1043
- print(f" - {zone_db.filepath}")
1044
- print("="*80 + "\n")
1045
-
1046
- # Optional: Run tournament evaluation every N iterations
1047
- if iteration % 5 == 0:
1048
- print("\nRunning tournament evaluation...")
1049
- play_match_mcvs_vs_uct(
1050
- policy_net, value_net, zone_db,
1051
- num_games=20,
1052
- time_per_move=0.5,
1053
- device=device
1054
- )