From 2a3f0d6be33678e10f3aad6dbd0837832197843f Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Wed, 24 Jun 2026 19:30:22 +0200 Subject: [PATCH] feat(official-bots): perspective-independent training via board flip for Black (NCS-116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/official-bots/python/src/train.py | 15 +++++++++++- .../de/nowchess/bot/bots/nnue/NNUE.scala | 23 +++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/modules/official-bots/python/src/train.py b/modules/official-bots/python/src/train.py index 699bf00..5ed5b21 100644 --- a/modules/official-bots/python/src/train.py +++ b/modules/official-bots/python/src/train.py @@ -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: diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala index 9ba63ee..a4916d4 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala @@ -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