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