feat(official-bots): perspective-independent training via board flip for Black (NCS-116)
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
Training: for Black-to-move positions, mirror the board (ranks flipped,
colours swapped) before feature extraction so the model always sees the
position from the side-to-move's perspective. Eval label is negated to
match. Implemented in fen_to_features (board.mirror()) and __getitem__
(' b ' check in FEN string).
Inference (legacy evaluate()): applies the same flip for Black so the
model receives features in the format it was trained on. The
scoreFromOutput negation converts back to White's absolute perspective.
Incremental accumulator path is unchanged — it uses the raw HalfKP
features with the existing sign-negation at output; the quality gain comes
from the richer training distribution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,11 @@ class NNUEDataset(Dataset):
|
||||
eval_val = self.evals[idx]
|
||||
features = fen_to_features(fen)
|
||||
|
||||
# Board is flipped for Black-to-move in fen_to_features; negate eval
|
||||
# so the label still means "good for the side shown as White after flip"
|
||||
if ' b ' in fen:
|
||||
eval_val = -eval_val
|
||||
|
||||
# Use evaluation as-is if normalized, otherwise apply sigmoid scaling
|
||||
if self.is_normalized:
|
||||
target = torch.tensor(eval_val, dtype=torch.float32)
|
||||
@@ -75,10 +80,18 @@ _PIECE_TO_IDX = {
|
||||
|
||||
|
||||
def fen_to_features(fen):
|
||||
"""Convert FEN to 98304-dim king-relative (HalfKP) feature vector."""
|
||||
"""Convert FEN to 98304-dim king-relative (HalfKP) feature vector.
|
||||
|
||||
For Black-to-move positions the board is mirrored (ranks flipped, colours
|
||||
swapped) so the network always sees the position from the side-to-move's
|
||||
perspective. The caller is responsible for negating the eval label to match.
|
||||
"""
|
||||
features = torch.zeros(INPUT_SIZE, dtype=torch.float32)
|
||||
try:
|
||||
board = chess.Board(fen)
|
||||
# Perspective flip: present all positions as if White is to move
|
||||
if board.turn == chess.BLACK:
|
||||
board = board.mirror()
|
||||
wk = board.king(chess.WHITE)
|
||||
bk = board.king(chess.BLACK)
|
||||
if wk is None or bk is None:
|
||||
|
||||
@@ -35,6 +35,9 @@ class NNUE(model: NbaiModel):
|
||||
|
||||
private def squareNum(sq: Square): Int = sq.rank.ordinal * 8 + sq.file.ordinal
|
||||
|
||||
// Mirror square vertically (rank 0 ↔ rank 7) for the perspective flip
|
||||
private def flipSqNum(sqNum: Int): Int = (7 - sqNum / 8) * 8 + sqNum % 8
|
||||
|
||||
private def pieceIdx(piece: Piece): Int =
|
||||
if piece.color == Color.White then 6 + piece.pieceType.ordinal else piece.pieceType.ordinal
|
||||
|
||||
@@ -225,11 +228,23 @@ class NNUE(model: NbaiModel):
|
||||
private val legacyL1 = new Array[Float](accSize)
|
||||
|
||||
def evaluate(context: GameContext): Int =
|
||||
val wkSq = wkSqOf(context.board)
|
||||
val bkSq = bkSqOf(context.board)
|
||||
// Match training: for Black-to-move positions, mirror the board (ranks flipped,
|
||||
// colours swapped) so the model always sees from the side-to-move's perspective.
|
||||
// The scoreFromOutput negation then converts back to White's absolute perspective.
|
||||
val (wkSq, bkSq, pieces, turn) =
|
||||
if context.turn == Color.Black then
|
||||
val wk = flipSqNum(bkSqOf(context.board)) // flipped Black king → new "White" king
|
||||
val bk = flipSqNum(wkSqOf(context.board)) // flipped White king → new "Black" king
|
||||
val flipped = context.board.pieces.map { case (sq, p) =>
|
||||
(sq, Piece(p.color.opposite, p.pieceType))
|
||||
}
|
||||
(wk, bk, flipped, Color.Black) // pass Black so scoreFromOutput negates the result
|
||||
else (wkSqOf(context.board), bkSqOf(context.board), context.board.pieces, context.turn)
|
||||
System.arraycopy(model.weights(0).bias, 0, legacyL1, 0, accSize)
|
||||
for (sq, piece) <- context.board.pieces do addPiece(legacyL1, piece, squareNum(sq), wkSq, bkSq)
|
||||
runL2toOutput(legacyL1, context.turn)
|
||||
for (sq, piece) <- pieces do
|
||||
val sqNum = if turn == Color.Black then flipSqNum(squareNum(sq)) else squareNum(sq)
|
||||
addPiece(legacyL1, piece, sqNum, wkSq, bkSq)
|
||||
runL2toOutput(legacyL1, turn)
|
||||
|
||||
def benchmark(): Unit =
|
||||
val context = GameContext.initial
|
||||
|
||||
Reference in New Issue
Block a user