File size: 5,933 Bytes
3ee44d2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """Board <-> tensor and move <-> index conversions.
Everything here lives in the **canonical frame**: the side to move is always
"white, playing up the board". When black is to move, every square is mirrored
vertically (``square ^ 56``) and the piece colours are swapped.
Two consequences, both wanted:
* the value head always predicts "how good is this for the player to move",
which is a well defined question -- unlike the previous absolute encoding
which did not even tell the network whose turn it was;
* a position and its colour-reversed twin share the same representation, so
every self-play game trains both colours at once.
"""
from typing import List, Tuple
import chess
import numpy as np
# 12 piece planes + 4 castling + 1 en-passant + 1 fifty-move + 1 repetition
N_PLANES = 19
ACTION_SIZE = 64 * 64 # from-square * 64 + to-square, canonical frame
PIECE_ORDER: Tuple[int, ...] = (
chess.PAWN,
chess.KNIGHT,
chess.BISHOP,
chess.ROOK,
chess.QUEEN,
chess.KING,
)
# Plane layout, for reference / debugging.
PLANE_NAMES: Tuple[str, ...] = (
"us_pawn", "us_knight", "us_bishop", "us_rook", "us_queen", "us_king",
"them_pawn", "them_knight", "them_bishop", "them_rook", "them_queen", "them_king",
"us_castle_k", "us_castle_q", "them_castle_k", "them_castle_q",
"en_passant", "fifty_move", "repetition",
)
def canonical_square(square: int, turn: bool) -> int:
"""Map a board square into the frame of the side to move.
``x ^ 56`` flips the rank and keeps the file, and is its own inverse, so the
same function encodes and decodes.
"""
return square if turn == chess.WHITE else square ^ 56
def move_to_index(move: chess.Move, turn: bool) -> int:
"""Flatten a move to ``[0, 4096)`` in the canonical frame.
Promotions collapse onto the same index as the plain from/to move; decoding
resolves the ambiguity in favour of the queen (see :func:`index_to_move`).
Under-promotions are therefore not representable -- a deliberate trade-off,
they are worth well under 0.1% of moves in practice.
"""
return canonical_square(move.from_square, turn) * 64 + canonical_square(move.to_square, turn)
def index_to_squares(index: int, turn: bool) -> Tuple[int, int]:
"""Inverse of :func:`move_to_index`, returning real (non-canonical) squares."""
from_canonical, to_canonical = divmod(int(index), 64)
return canonical_square(from_canonical, turn), canonical_square(to_canonical, turn)
def index_to_move(index: int, board: chess.Board) -> chess.Move:
"""Resolve a policy index against the legal moves of `board`.
Returns ``chess.Move.null()`` when the index matches no legal move, which
lets callers fail loudly instead of silently playing something else.
"""
from_square, to_square = index_to_squares(index, board.turn)
# Try the queen promotion first, then the plain move: both are cheap
# membership tests and cover every legal case except under-promotions.
for promotion in (None, chess.QUEEN):
move = chess.Move(from_square, to_square, promotion=promotion)
if board.is_legal(move):
return move
for move in board.legal_moves:
if move.from_square == from_square and move.to_square == to_square:
return move
return chess.Move.null()
def legal_move_indices(board: chess.Board) -> Tuple[List[chess.Move], np.ndarray]:
"""Return the legal moves and their canonical policy indices.
Duplicate indices (under-promotions sharing a from/to pair) are dropped so
that the index array can be used directly as a legality mask. The common
case -- no promotion available -- takes the fast path and never builds the
deduplication set.
"""
moves = list(board.legal_moves)
if board.turn == chess.WHITE:
indices = [m.from_square * 64 + m.to_square for m in moves]
else:
indices = [(m.from_square ^ 56) * 64 + (m.to_square ^ 56) for m in moves]
if len(set(indices)) != len(indices):
unique: dict = {}
for move, index in zip(moves, indices):
# Keep the queen promotion, which is what index_to_move decodes to.
if index not in unique or move.promotion == chess.QUEEN:
unique[index] = move
indices = list(unique.keys())
moves = list(unique.values())
return moves, np.asarray(indices, dtype=np.int16)
def board_to_planes(board: chess.Board) -> np.ndarray:
"""Encode `board` as a ``(N_PLANES, 8, 8)`` float32 tensor."""
planes = np.zeros((N_PLANES, 8, 8), dtype=np.float32)
turn = board.turn
# Unpacking the twelve bitboards in one go is about four times faster than
# walking the occupied squares in Python, and this runs once per position
# of every game.
raw = (
np.array(
[board.pieces_mask(piece, color) for color in (turn, not turn) for piece in PIECE_ORDER],
dtype=">u8",
)
.view(np.uint8)
.reshape(12, 8)
)
bits = np.unpackbits(raw, axis=1).reshape(12, 8, 8)
# Big-endian bytes come out rank 7 first, and file 7 first within a rank.
# Reversing both axes gives White's frame; for Black the rank reversal is
# exactly the ``square ^ 56`` mirror, so it cancels out.
planes[:12] = bits[:, ::-1, ::-1] if turn == chess.WHITE else bits[:, :, ::-1]
planes[12] = float(board.has_kingside_castling_rights(turn))
planes[13] = float(board.has_queenside_castling_rights(turn))
planes[14] = float(board.has_kingside_castling_rights(not turn))
planes[15] = float(board.has_queenside_castling_rights(not turn))
if board.ep_square is not None:
canonical = canonical_square(board.ep_square, turn)
planes[16, canonical >> 3, canonical & 7] = 1.0
planes[17] = min(board.halfmove_clock, 100) / 100.0
planes[18] = 1.0 if board.is_repetition(2) else 0.0
return planes
|