RDTChess / encoding.py
RDTvlokip's picture
RDTChess: poids, inference et GUI, carte de modele complete
3ee44d2 verified
Raw
History Blame Contribute Delete
5.93 kB
"""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