feat: refactor AlphaBetaSearch and ClassicalBot for improved evaluation and organization
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.bot.ai.Weights
|
||||
|
||||
object EvaluationNNUE extends Weights:
|
||||
|
||||
private val nnue = NNUE()
|
||||
|
||||
val CHECKMATE_SCORE: Int = 10_000_000
|
||||
val DRAW_SCORE: Int = 0
|
||||
|
||||
/** Evaluate the position using NNUE neural network.
|
||||
* Returns score from the perspective of context.turn (positive = good for the side to move). */
|
||||
def evaluate(context: GameContext): Int =
|
||||
nnue.evaluate(context)
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
class NNUE:
|
||||
|
||||
private val l1Weights = NNUEWeights.l1_weights
|
||||
private val l1Bias = NNUEWeights.l1_bias
|
||||
private val l2Weights = NNUEWeights.l2_weights
|
||||
private val l2Bias = NNUEWeights.l2_bias
|
||||
private val l3Weights = NNUEWeights.l3_weights
|
||||
private val l3Bias = NNUEWeights.l3_bias
|
||||
|
||||
// Pre-allocated buffers for inference
|
||||
private val features = new Array[Float](768)
|
||||
private val l1Output = new Array[Float](256)
|
||||
private val l2Output = new Array[Float](32)
|
||||
|
||||
/** Convert a position to 768-dimensional binary feature vector.
|
||||
* 12 piece types (white pawn to black king) × 64 squares from white's perspective. */
|
||||
def positionToFeatures(board: Board, sideToMove: Color): Array[Float] =
|
||||
// Zero out features array
|
||||
java.util.Arrays.fill(features, 0f)
|
||||
|
||||
// Piece type to feature index offset: wp=0, wn=64, wb=128, wr=192, wq=256, wk=320, bp=384, bn=448, bb=512, br=576, bq=640, bk=704
|
||||
val pieceToFeatureOffset = Array(
|
||||
0, // White Pawn (0)
|
||||
64, // White Knight (1)
|
||||
128, // White Bishop (2)
|
||||
192, // White Rook (3)
|
||||
256, // White Queen (4)
|
||||
320, // White King (5)
|
||||
384, // Black Pawn (6)
|
||||
448, // Black Knight (7)
|
||||
512, // Black Bishop (8)
|
||||
576, // Black Rook (9)
|
||||
640, // Black Queen (10)
|
||||
704 // Black King (11)
|
||||
)
|
||||
|
||||
// Build features: always from white's perspective
|
||||
for
|
||||
fileIdx <- 0 until 8
|
||||
rankIdx <- 0 until 8
|
||||
do
|
||||
val file = File.values(fileIdx)
|
||||
val rank = Rank.values(rankIdx)
|
||||
val square = Square(file, rank)
|
||||
val squareNum = rankIdx * 8 + fileIdx
|
||||
|
||||
board.pieceAt(square).foreach { piece =>
|
||||
val featureIdx = if sideToMove == Color.Black then
|
||||
// Mirror square for black side-to-move
|
||||
val mirroredSq = squareNum ^ 56
|
||||
val offset = pieceToFeatureOffset(piece.color.ordinal * 6 + piece.pieceType.ordinal)
|
||||
offset + mirroredSq
|
||||
else
|
||||
val offset = pieceToFeatureOffset(piece.color.ordinal * 6 + piece.pieceType.ordinal)
|
||||
offset + squareNum
|
||||
|
||||
if featureIdx >= 0 && featureIdx < 768 then
|
||||
features(featureIdx) = 1f
|
||||
}
|
||||
|
||||
features
|
||||
|
||||
/** Run NNUE inference on the given position.
|
||||
* Returns centipawn score from the perspective of the side-to-move.
|
||||
* No allocations in the hot path (uses pre-allocated buffers). */
|
||||
def evaluate(context: GameContext): Int =
|
||||
val features = positionToFeatures(context.board, context.turn)
|
||||
|
||||
// Layer 1: Dense(768 -> 256) + ReLU
|
||||
for i <- 0 until 256 do
|
||||
var sum = l1Bias(i)
|
||||
for j <- 0 until 768 do
|
||||
sum += features(j) * l1Weights(i * 768 + j)
|
||||
l1Output(i) = if sum > 0f then sum else 0f
|
||||
|
||||
// Layer 2: Dense(256 -> 32) + ReLU
|
||||
for i <- 0 until 32 do
|
||||
var sum = l2Bias(i)
|
||||
for j <- 0 until 256 do
|
||||
sum += l1Output(j) * l2Weights(i * 256 + j)
|
||||
l2Output(i) = if sum > 0f then sum else 0f
|
||||
|
||||
// Layer 3: Dense(32 -> 1), no activation
|
||||
var output = l3Bias(0)
|
||||
for j <- 0 until 32 do
|
||||
output += l2Output(j) * l3Weights(j)
|
||||
|
||||
// Convert from sigmoid(output) back to centipawns (output is trained as sigmoid(eval/400))
|
||||
// Inverse sigmoid: eval/400 = ln(output / (1 - output))
|
||||
// But for simplicity, just scale directly: output ≈ sigmoid(eval/400), so eval ≈ 400 * (output - 0.5) * 2
|
||||
val cp = (output * 400f).toInt
|
||||
math.max(-20000, math.min(20000, cp))
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.bot.bots.nnue.EvaluationNNUE
|
||||
import de.nowchess.bot.logic.AlphaBetaSearch
|
||||
import de.nowchess.bot.util.PolyglotBook
|
||||
import de.nowchess.bot.{Bot, BotDifficulty}
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
final class NNUEBot(
|
||||
difficulty: BotDifficulty,
|
||||
rules: RuleSet = DefaultRules,
|
||||
book: Option[PolyglotBook] = None
|
||||
) extends Bot:
|
||||
|
||||
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
|
||||
private val TIME_BUDGET_MS = 1000L
|
||||
|
||||
override val name: String = s"NNUEBot(${difficulty.toString})"
|
||||
|
||||
override def nextMove(context: GameContext): Option[Move] =
|
||||
book.flatMap(_.probe(context))
|
||||
.orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS))
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
object NNUEWeights:
|
||||
|
||||
// PLACEHOLDER: This file is generated by export_weights.py
|
||||
// Run: python3 modules/bot/python/run_pipeline.sh to generate actual weights
|
||||
|
||||
// Layer 1: Input(768) -> Hidden(256)
|
||||
val l1_weights = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [256, 768]
|
||||
|
||||
val l1_bias = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [256]
|
||||
|
||||
// Layer 2: Hidden(256) -> Hidden(32)
|
||||
val l2_weights = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [32, 256]
|
||||
|
||||
val l2_bias = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [32]
|
||||
|
||||
// Layer 3: Hidden(32) -> Output(1)
|
||||
val l3_weights = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [1, 32]
|
||||
|
||||
val l3_bias = Array(
|
||||
0f
|
||||
)
|
||||
// Shape: [1]
|
||||
@@ -3,6 +3,7 @@ package de.nowchess.bot
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.bot.bots.classic.EvaluationClassic
|
||||
import de.nowchess.bot.logic.AlphaBetaSearch
|
||||
import de.nowchess.rules.RuleSet
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
@@ -12,7 +13,7 @@ import de.nowchess.rules.sets.DefaultRules
|
||||
class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("bestMove on initial position returns a move"):
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 2)
|
||||
move should not be None
|
||||
|
||||
@@ -20,7 +21,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
// Create a simple position: White king on h1, Black rook on a2
|
||||
// (set up so there's only one legal move available)
|
||||
// For simplicity, just test that a position with forced mate returns a move
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
val context = GameContext.initial
|
||||
val move = search.bestMove(context, maxDepth = 1)
|
||||
move should not be None
|
||||
@@ -38,12 +39,12 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = false
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(stubRules)
|
||||
val search = AlphaBetaSearch(stubRules, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 2)
|
||||
move should be(None)
|
||||
|
||||
test("transposition table is cleared at start of bestMove"):
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
val context = GameContext.initial
|
||||
// Call bestMove twice and verify both work independently
|
||||
val move1 = search.bestMove(context, maxDepth = 1)
|
||||
@@ -51,7 +52,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
move1 should be(move2)
|
||||
|
||||
test("quiescence captures are ordered"):
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
// A position with multiple captures to verify quiescence orders them
|
||||
val context = GameContext.initial
|
||||
val move = search.bestMove(context, maxDepth = 2)
|
||||
@@ -60,13 +61,13 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("search respects alpha-beta bounds"):
|
||||
// This is implicit in the structure, but we test via behavior
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
val context = GameContext.initial
|
||||
val move = search.bestMove(context, maxDepth = 3)
|
||||
move should not be None
|
||||
|
||||
test("iterative deepening finds a move at each depth"):
|
||||
val search = AlphaBetaSearch(DefaultRules)
|
||||
val search = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
val context = GameContext.initial
|
||||
// Searching to depth 3 should use iterative deepening (depths 1, 2, 3)
|
||||
val move = search.bestMove(context, maxDepth = 3)
|
||||
@@ -85,7 +86,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = false
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(stalematRules)
|
||||
val search = AlphaBetaSearch(stalematRules, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 1)
|
||||
move should be(None)
|
||||
|
||||
@@ -101,7 +102,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = false
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(insufficientRules)
|
||||
val search = AlphaBetaSearch(insufficientRules, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 1)
|
||||
move should be(None)
|
||||
|
||||
@@ -117,7 +118,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = true
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(fiftyMoveRules)
|
||||
val search = AlphaBetaSearch(fiftyMoveRules, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 1)
|
||||
move should be(None)
|
||||
|
||||
@@ -141,7 +142,7 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = false
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(rulesWithCapture)
|
||||
val search = AlphaBetaSearch(rulesWithCapture, weights = EvaluationClassic)
|
||||
val move = search.bestMove(context, maxDepth = 1)
|
||||
move should be(Some(captureMove))
|
||||
|
||||
@@ -158,6 +159,6 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
def isFiftyMoveRule(context: GameContext) = false
|
||||
def applyMove(context: GameContext)(move: Move) = context
|
||||
|
||||
val search = AlphaBetaSearch(rulesQuiet)
|
||||
val search = AlphaBetaSearch(rulesQuiet, weights = EvaluationClassic)
|
||||
val move = search.bestMove(GameContext.initial, maxDepth = 1)
|
||||
move should be(Some(quietMove)) // bestMove returns the quiet move since it's the only legal move
|
||||
|
||||
Reference in New Issue
Block a user