Skip to content

Commit 10d13fb

Browse files
author
miskibin
committed
refactor: Revamp AlphaBeta engine with dynamic piece-square table generation and optimized Zobrist hashing
- Replaced static piece-square tables with dynamic generation functions for men and kings, improving adaptability to different board sizes. - Enhanced Zobrist hashing to be initialized lazily per board size, reducing memory usage and improving performance. - Updated evaluation method to utilize the new dynamic PST structure, ensuring accurate scoring across board variants. - Added tests to validate engine performance against random moves, ensuring consistent winning outcomes for depth-1 searches.
1 parent 9a11a1a commit 10d13fb

File tree

2 files changed

+152
-62
lines changed

2 files changed

+152
-62
lines changed

draughts/engines/alpha_beta.py

Lines changed: 110 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -22,45 +22,45 @@
2222
MAN_VALUE = 1.0
2323
KING_VALUE = 2.5 # Kings are very powerful in draughts
2424

25-
# PST Tables for men - rewards advancement (simple linear)
26-
PST_MAN_BLACK = np.array([
27-
0.25, 0.30, 0.30, 0.30, 0.25, # 0-4: Near promotion
28-
0.20, 0.22, 0.25, 0.22, 0.20, # 5-9
29-
0.15, 0.18, 0.20, 0.18, 0.15, # 10-14
30-
0.12, 0.15, 0.18, 0.15, 0.12, # 15-19
31-
0.08, 0.12, 0.15, 0.12, 0.08, # 20-24
32-
0.06, 0.10, 0.12, 0.10, 0.06, # 25-29
33-
0.04, 0.08, 0.10, 0.08, 0.04, # 30-34
34-
0.02, 0.05, 0.08, 0.05, 0.02, # 35-39
35-
0.01, 0.02, 0.04, 0.02, 0.01, # 40-44
36-
0.00, 0.00, 0.00, 0.00, 0.00 # 45-49: Starting rank
37-
])
38-
39-
PST_MAN_WHITE = PST_MAN_BLACK[::-1]
40-
41-
# King PST - strongly prefers center, avoids edges
42-
PST_KING_BLACK = np.array([
43-
0.00, 0.05, 0.05, 0.05, 0.00,
44-
0.05, 0.10, 0.12, 0.10, 0.05,
45-
0.05, 0.12, 0.15, 0.12, 0.05,
46-
0.08, 0.15, 0.20, 0.15, 0.08,
47-
0.10, 0.18, 0.25, 0.18, 0.10,
48-
0.10, 0.18, 0.25, 0.18, 0.10,
49-
0.08, 0.15, 0.20, 0.15, 0.08,
50-
0.05, 0.12, 0.15, 0.12, 0.05,
51-
0.05, 0.10, 0.12, 0.10, 0.05,
52-
0.00, 0.05, 0.05, 0.05, 0.00
53-
])
54-
55-
PST_KING_WHITE = PST_KING_BLACK[::-1]
25+
26+
def _create_pst_man(num_squares: int, rows: int) -> np.ndarray:
27+
"""Create piece-square table for men (rewards advancement)."""
28+
squares_per_row = num_squares // rows
29+
pst = np.zeros(num_squares)
30+
for i in range(num_squares):
31+
row = i // squares_per_row
32+
# Higher value for squares closer to promotion (row 0)
33+
advancement_bonus = (rows - 1 - row) / (rows - 1) * 0.3
34+
# Small center bonus
35+
col = i % squares_per_row
36+
center_bonus = (1 - abs(col - squares_per_row / 2) / (squares_per_row / 2)) * 0.05
37+
pst[i] = advancement_bonus + center_bonus
38+
return pst
39+
40+
41+
def _create_pst_king(num_squares: int, rows: int) -> np.ndarray:
42+
"""Create piece-square table for kings (prefers center)."""
43+
squares_per_row = num_squares // rows
44+
pst = np.zeros(num_squares)
45+
center_row = rows / 2
46+
center_col = squares_per_row / 2
47+
for i in range(num_squares):
48+
row = i // squares_per_row
49+
col = i % squares_per_row
50+
# Distance from center (normalized)
51+
row_dist = abs(row - center_row) / center_row
52+
col_dist = abs(col - center_col) / center_col
53+
# Higher value for center squares
54+
pst[i] = (1 - (row_dist + col_dist) / 2) * 0.25
55+
return pst
5656

5757

5858
class AlphaBetaEngine(Engine):
5959
"""
6060
AI engine using Negamax search with alpha-beta pruning.
6161
6262
This engine implements a strong draughts AI with several optimizations
63-
for efficient tree search.
63+
for efficient tree search. Works with any board variant (Standard, American, etc.).
6464
6565
**Algorithm:**
6666
@@ -88,6 +88,12 @@ class AlphaBetaEngine(Engine):
8888
>>> move = engine.get_best_move(board)
8989
>>> board.push(move)
9090
91+
Example with American Draughts:
92+
>>> from draughts.boards.american import Board
93+
>>> board = Board()
94+
>>> engine = AlphaBetaEngine(depth_limit=6)
95+
>>> move = engine.get_best_move(board)
96+
9197
Example with evaluation:
9298
>>> move, score = engine.get_best_move(board, with_evaluation=True)
9399
>>> print(f"Best: {move}, Score: {score:.2f}")
@@ -116,10 +122,18 @@ def __init__(self, depth_limit: int = 6, time_limit: float | None = None, name:
116122
self.history: dict[tuple[int, int], int] = {}
117123
self.killers: dict[int, list[Move]] = {}
118124

119-
# Zobrist Hashing (deterministic per-engine; does not perturb global RNG)
125+
# Zobrist Hashing - initialized lazily per board size
120126
self._zobrist_rng = random.Random(0)
121-
self.zobrist_table = self._init_zobrist()
122-
self.zobrist_turn = self._zobrist_rng.getrandbits(64)
127+
self._zobrist_tables: dict[int, list[list[int]]] = {} # num_squares -> table
128+
self._zobrist_turn = self._zobrist_rng.getrandbits(64)
129+
130+
# PST tables - cached per board configuration
131+
self._pst_cache: dict[tuple[int, int], tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = {}
132+
133+
# Current search state (set at start of search, used during eval)
134+
# Initialize with standard 50-square defaults so evaluate() works standalone
135+
self._current_zobrist: list[list[int]] = self._get_zobrist_table(50)
136+
self._current_pst: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] = self._get_pst_tables(50, 10)
123137

124138
self.start_time: float = 0.0
125139
self.stop_search: bool = False
@@ -133,29 +147,58 @@ def inspected_nodes(self) -> int:
133147
def inspected_nodes(self, value: int) -> None:
134148
self.nodes = value
135149

136-
def _init_zobrist(self):
137-
# 50 squares, 5 piece types
138-
table = [[self._zobrist_rng.getrandbits(64) for _ in range(5)] for _ in range(50)]
139-
return table
150+
def _get_zobrist_table(self, num_squares: int) -> list[list[int]]:
151+
"""Get or create Zobrist table for given board size."""
152+
if num_squares not in self._zobrist_tables:
153+
# Create new table with deterministic RNG
154+
rng = random.Random(num_squares) # Seed based on size for consistency
155+
table = [[rng.getrandbits(64) for _ in range(5)] for _ in range(num_squares)]
156+
self._zobrist_tables[num_squares] = table
157+
return self._zobrist_tables[num_squares]
158+
159+
def _get_pst_tables(self, num_squares: int, rows: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
160+
"""Get or create PST tables for given board configuration."""
161+
key = (num_squares, rows)
162+
if key not in self._pst_cache:
163+
pst_man_black = _create_pst_man(num_squares, rows)
164+
pst_man_white = pst_man_black[::-1].copy()
165+
pst_king_black = _create_pst_king(num_squares, rows)
166+
pst_king_white = pst_king_black[::-1].copy()
167+
self._pst_cache[key] = (pst_man_black, pst_man_white, pst_king_black, pst_king_white)
168+
return self._pst_cache[key]
140169

141170
def _get_piece_index(self, piece):
142171
return piece + 2
143172

173+
def _compute_hash_fast(self, board: BaseBoard) -> int:
174+
"""Compute hash using cached zobrist table."""
175+
h = 0
176+
for i, piece in enumerate(board._pos):
177+
if piece != 0:
178+
h ^= self._current_zobrist[i][self._get_piece_index(piece)]
179+
if board.turn == Color.BLACK:
180+
h ^= self._zobrist_turn
181+
return h
182+
144183
def compute_hash(self, board: BaseBoard) -> int:
145-
"""Compute Zobrist hash for a board position."""
184+
"""Compute Zobrist hash for a board position (standalone, slower)."""
185+
num_squares = len(board._pos)
186+
zobrist_table = self._get_zobrist_table(num_squares)
187+
146188
h = 0
147189
for i, piece in enumerate(board._pos):
148190
if piece != 0:
149-
h ^= self.zobrist_table[i][self._get_piece_index(piece)]
191+
h ^= zobrist_table[i][self._get_piece_index(piece)]
150192
if board.turn == Color.BLACK:
151-
h ^= self.zobrist_turn
193+
h ^= self._zobrist_turn
152194
return h
153195

154196
def evaluate(self, board: BaseBoard) -> float:
155197
"""
156198
Evaluate the board position.
157199
158200
Uses material count and piece-square tables.
201+
Works with any board variant.
159202
160203
Args:
161204
board: The board to evaluate.
@@ -165,33 +208,31 @@ def evaluate(self, board: BaseBoard) -> float:
165208
Positive = good for current player.
166209
"""
167210
pos = board._pos
211+
pst = self._current_pst # Cached at init or start of search
168212

169213
# Piece masks
170214
white_men = (pos == -1)
171215
white_kings = (pos == -2)
172216
black_men = (pos == 1)
173217
black_kings = (pos == 2)
174218

175-
# Count pieces
176-
n_white_men = np.sum(white_men)
177-
n_white_kings = np.sum(white_kings)
178-
n_black_men = np.sum(black_men)
179-
n_black_kings = np.sum(black_kings)
180-
181219
# Material
220+
n_white_men = white_men.sum()
221+
n_white_kings = white_kings.sum()
222+
n_black_men = black_men.sum()
223+
n_black_kings = black_kings.sum()
224+
182225
score = (n_black_men - n_white_men) * MAN_VALUE
183226
score += (n_black_kings - n_white_kings) * KING_VALUE
184227

185228
# PST - Piece Square Tables
186-
score += np.sum(PST_MAN_BLACK[black_men])
187-
score -= np.sum(PST_MAN_WHITE[white_men])
188-
score += np.sum(PST_KING_BLACK[black_kings])
189-
score -= np.sum(PST_KING_WHITE[white_kings])
229+
score += pst[0][black_men].sum() # pst_man_black
230+
score -= pst[1][white_men].sum() # pst_man_white
231+
score += pst[2][black_kings].sum() # pst_king_black
232+
score -= pst[3][white_kings].sum() # pst_king_white
190233

191234
# Return score relative to side to move
192-
if board.turn == Color.WHITE:
193-
return -score
194-
return score
235+
return -score if board.turn == Color.WHITE else score
195236

196237
def get_best_move(self, board: BaseBoard, with_evaluation: bool = False) -> Move | tuple[Move, float]:
197238
"""
@@ -215,12 +256,18 @@ def get_best_move(self, board: BaseBoard, with_evaluation: bool = False) -> Move
215256
self.nodes = 0
216257
self.stop_search = False
217258

259+
# Cache board-specific data for this search (avoids repeated lookups)
260+
num_squares = len(board._pos)
261+
self._current_zobrist = self._get_zobrist_table(num_squares)
262+
rows = 10 if num_squares == 50 else (8 if num_squares == 32 else int(np.sqrt(num_squares * 2)))
263+
self._current_pst = self._get_pst_tables(num_squares, rows)
264+
218265
# Age history table (decay old values)
219266
for key in self.history:
220267
self.history[key] //= 2
221268

222269
# Initial Hash
223-
current_hash = self.compute_hash(board)
270+
current_hash = self._compute_hash_fast(board)
224271

225272
best_move: Move | None = None
226273
best_score = -INF
@@ -403,27 +450,28 @@ def quiescence_search(self, board: BaseBoard, alpha: float, beta: float, h: int,
403450
return alpha
404451

405452
def _update_hash(self, current_hash: int, board: BaseBoard, move: Move) -> int:
453+
zt = self._current_zobrist # Cached zobrist table
454+
406455
# XOR out source
407456
start_sq = move.square_list[0]
408457
piece = board._pos[start_sq]
409-
current_hash ^= self.zobrist_table[start_sq][self._get_piece_index(piece)]
458+
current_hash ^= zt[start_sq][piece + 2]
410459

411460
# XOR in dest
412461
end_sq = move.square_list[-1]
413462
new_piece = piece
414463
if move.is_promotion:
415-
if piece == 1: new_piece = 2
416-
elif piece == -1: new_piece = -2
464+
new_piece = 2 if piece == 1 else -2
417465

418-
current_hash ^= self.zobrist_table[end_sq][self._get_piece_index(new_piece)]
466+
current_hash ^= zt[end_sq][new_piece + 2]
419467

420468
# XOR out captures
421469
for cap_sq in move.captured_list:
422470
cap_piece = board._pos[cap_sq]
423-
current_hash ^= self.zobrist_table[cap_sq][self._get_piece_index(cap_piece)]
471+
current_hash ^= zt[cap_sq][cap_piece + 2]
424472

425473
# Switch turn
426-
current_hash ^= self.zobrist_turn
474+
current_hash ^= self._zobrist_turn
427475

428476
return current_hash
429477

test/test_engine.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,45 @@ def test_engine_populates_transposition_table_for_root(seed):
146146
assert entry is not None
147147
_depth, _flag, _score, best_move = entry
148148
assert best_move in list(board.legal_moves)
149+
150+
151+
# ─────────────────────────────────────────────────────────────────────────────
152+
# Engine vs Random: depth-1 engine should reliably beat random moves
153+
# ─────────────────────────────────────────────────────────────────────────────
154+
155+
import random as stdlib_random
156+
157+
158+
def _play_engine_vs_random(board, engine, engine_is_white: bool, seed: int) -> str:
159+
"""Play a game: engine vs random. Returns the result string."""
160+
rng = stdlib_random.Random(seed)
161+
from draughts.models import Color
162+
163+
while not board.game_over:
164+
is_engine_turn = (board.turn == Color.WHITE) == engine_is_white
165+
if is_engine_turn:
166+
move = engine.get_best_move(board)
167+
else:
168+
legal = list(board.legal_moves)
169+
move = rng.choice(legal)
170+
board.push(move)
171+
172+
return board.result
173+
174+
175+
@pytest.mark.parametrize("variant", ["standard", "american", "russian", "frisian"])
176+
@pytest.mark.parametrize("game_idx", range(5))
177+
def test_engine_depth1_beats_random(variant, game_idx):
178+
"""AlphaBeta depth-1 should consistently beat random moves."""
179+
board = get_board(variant)
180+
engine = AlphaBetaEngine(depth_limit=1)
181+
182+
# Alternate colors
183+
engine_is_white = game_idx % 2 == 0
184+
result = _play_engine_vs_random(board, engine, engine_is_white, seed=game_idx * 100 + hash(variant))
185+
186+
# Engine should win or draw (never lose to random)
187+
if engine_is_white:
188+
assert result in ("1-0"), f"{variant} game {game_idx}: engine (white) got {result}"
189+
else:
190+
assert result in ("0-1"), f"{variant} game {game_idx}: engine (black) got {result}"

0 commit comments

Comments
 (0)