Rafs-an09002's picture
Create engine/evaluate.py
308bdff verified
"""
Position Evaluation Module
Combines neural network evaluation with classical heuristics
Research References:
- AlphaZero (Silver et al., 2017) - Pure neural evaluation
- Stockfish NNUE (2020) - Hybrid neural-classical approach
- Leela Chess Zero - MCTS with neural evaluation
"""
import onnxruntime as ort
import numpy as np
import chess
import logging
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger(__name__)
class NeuralEvaluator:
"""
Synapse-Base neural network evaluator
119-channel input, hybrid CNN-Transformer architecture
"""
# Piece values for material balance (Stockfish values)
PIECE_VALUES = {
chess.PAWN: 100,
chess.KNIGHT: 320,
chess.BISHOP: 330,
chess.ROOK: 500,
chess.QUEEN: 900,
chess.KING: 0
}
# Piece-Square Tables (simplified Stockfish PST)
PST_PAWN = np.array([
[0, 0, 0, 0, 0, 0, 0, 0],
[50, 50, 50, 50, 50, 50, 50, 50],
[10, 10, 20, 30, 30, 20, 10, 10],
[5, 5, 10, 25, 25, 10, 5, 5],
[0, 0, 0, 20, 20, 0, 0, 0],
[5, -5,-10, 0, 0,-10, -5, 5],
[5, 10, 10,-20,-20, 10, 10, 5],
[0, 0, 0, 0, 0, 0, 0, 0]
], dtype=np.float32)
PST_KNIGHT = np.array([
[-50,-40,-30,-30,-30,-30,-40,-50],
[-40,-20, 0, 0, 0, 0,-20,-40],
[-30, 0, 10, 15, 15, 10, 0,-30],
[-30, 5, 15, 20, 20, 15, 5,-30],
[-30, 0, 15, 20, 20, 15, 0,-30],
[-30, 5, 10, 15, 15, 10, 5,-30],
[-40,-20, 0, 5, 5, 0,-20,-40],
[-50,-40,-30,-30,-30,-30,-40,-50]
], dtype=np.float32)
PST_KING_MG = np.array([
[-30,-40,-40,-50,-50,-40,-40,-30],
[-30,-40,-40,-50,-50,-40,-40,-30],
[-30,-40,-40,-50,-50,-40,-40,-30],
[-30,-40,-40,-50,-50,-40,-40,-30],
[-20,-30,-30,-40,-40,-30,-30,-20],
[-10,-20,-20,-20,-20,-20,-20,-10],
[ 20, 20, 0, 0, 0, 0, 20, 20],
[ 20, 30, 10, 0, 0, 10, 30, 20]
], dtype=np.float32)
def __init__(self, model_path: str, num_threads: int = 2):
"""Initialize neural evaluator"""
self.model_path = Path(model_path)
if not self.model_path.exists():
raise FileNotFoundError(f"Model not found: {model_path}")
# ONNX Runtime session (CPU optimized)
sess_options = ort.SessionOptions()
sess_options.intra_op_num_threads = num_threads
sess_options.inter_op_num_threads = num_threads
sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
logger.info(f"Loading Synapse-Base model from {model_path}...")
self.session = ort.InferenceSession(
str(self.model_path),
sess_options=sess_options,
providers=['CPUExecutionProvider']
)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [output.name for output in self.session.get_outputs()]
logger.info(f"✅ Model loaded: {self.input_name} -> {self.output_names}")
def _build_119_channel_tensor(self, board: chess.Board) -> np.ndarray:
"""
Convert board to 119-channel tensor
Based on Synapse-Base input specification
"""
tensor = np.zeros((1, 119, 8, 8), dtype=np.float32)
# === CHANNELS 0-11: Piece Positions ===
piece_map = board.piece_map()
piece_to_channel = {
chess.PAWN: 0, chess.KNIGHT: 1, chess.BISHOP: 2,
chess.ROOK: 3, chess.QUEEN: 4, chess.KING: 5
}
for square, piece in piece_map.items():
rank, file = divmod(square, 8)
channel = piece_to_channel[piece.piece_type]
if piece.color == chess.BLACK:
channel += 6
tensor[0, channel, rank, file] = 1.0
# === CHANNELS 12-26: Metadata ===
tensor[0, 12, :, :] = float(board.turn == chess.WHITE)
tensor[0, 13, :, :] = float(board.has_kingside_castling_rights(chess.WHITE))
tensor[0, 14, :, :] = float(board.has_queenside_castling_rights(chess.WHITE))
tensor[0, 15, :, :] = float(board.has_kingside_castling_rights(chess.BLACK))
tensor[0, 16, :, :] = float(board.has_queenside_castling_rights(chess.BLACK))
if board.ep_square is not None:
ep_rank, ep_file = divmod(board.ep_square, 8)
tensor[0, 17, ep_rank, ep_file] = 1.0
tensor[0, 18, :, :] = min(board.halfmove_clock / 100.0, 1.0)
tensor[0, 19, :, :] = min(board.fullmove_number / 100.0, 1.0)
tensor[0, 20, :, :] = float(board.is_check() and board.turn == chess.WHITE)
tensor[0, 21, :, :] = float(board.is_check() and board.turn == chess.BLACK)
# Material counts
for i, piece_type in enumerate([chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]):
white_count = len(board.pieces(piece_type, chess.WHITE))
black_count = len(board.pieces(piece_type, chess.BLACK))
max_count = 8 if piece_type == chess.PAWN else 2
tensor[0, 22 + i*2, :, :] = white_count / max_count
tensor[0, 23 + i*2, :, :] = black_count / max_count
# === CHANNELS 27-50: Attack Maps ===
for square in chess.SQUARES:
rank, file = divmod(square, 8)
if board.is_attacked_by(chess.WHITE, square):
tensor[0, 27, rank, file] = 1.0
if board.is_attacked_by(chess.BLACK, square):
tensor[0, 28, rank, file] = 1.0
# Mobility (number of legal moves)
white_mobility = len(list(board.legal_moves)) if board.turn == chess.WHITE else 0
black_mobility = len(list(board.legal_moves)) if board.turn == chess.BLACK else 0
tensor[0, 29, :, :] = min(white_mobility / 50.0, 1.0)
tensor[0, 30, :, :] = min(black_mobility / 50.0, 1.0)
# === CHANNELS 51-66: Coordinate Encoding ===
for rank in range(8):
tensor[0, 51 + rank, rank, :] = 1.0
for file in range(8):
tensor[0, 59 + file, :, file] = 1.0
# === CHANNELS 67-118: Positional Features ===
# Center control
center = [chess.D4, chess.D5, chess.E4, chess.E5]
for sq in center:
r, f = divmod(sq, 8)
tensor[0, 67, r, f] = 0.5
# King safety zones
for color, offset in [(chess.WHITE, 68), (chess.BLACK, 69)]:
king_sq = board.king(color)
if king_sq is not None:
kr, kf = divmod(king_sq, 8)
for dr in [-1, 0, 1]:
for df in [-1, 0, 1]:
r, f = kr + dr, kf + df
if 0 <= r < 8 and 0 <= f < 8:
tensor[0, offset, r, f] = 1.0
# Piece-square table values
for square, piece in piece_map.items():
rank, file = divmod(square, 8)
if piece.piece_type == chess.PAWN:
pst_value = self.PST_PAWN[rank, file] / 50.0
tensor[0, 70, rank, file] = pst_value if piece.color == chess.WHITE else -pst_value
elif piece.piece_type == chess.KNIGHT:
pst_value = self.PST_KNIGHT[rank, file] / 30.0
tensor[0, 71, rank, file] = pst_value if piece.color == chess.WHITE else -pst_value
return tensor
def evaluate_neural(self, board: chess.Board) -> float:
"""
Neural network evaluation
Returns score from white's perspective
"""
input_tensor = self._build_119_channel_tensor(board)
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
# Value head output (tanh normalized to [-1, 1])
raw_eval = float(outputs[0][0][0])
# Convert to centipawns (multiply by 4 for scaling)
return raw_eval * 400.0
def evaluate_material(self, board: chess.Board) -> int:
"""Classical material evaluation"""
material = 0
for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]:
material += len(board.pieces(piece_type, chess.WHITE)) * self.PIECE_VALUES[piece_type]
material -= len(board.pieces(piece_type, chess.BLACK)) * self.PIECE_VALUES[piece_type]
return material
def evaluate_hybrid(self, board: chess.Board) -> float:
"""
Hybrid evaluation combining neural and classical
Research: Stockfish NNUE approach
"""
# Neural evaluation (primary)
neural_eval = self.evaluate_neural(board)
# Classical material balance (safety check)
material_eval = self.evaluate_material(board)
# Blend: 95% neural, 5% material (for stability)
hybrid_eval = 0.95 * neural_eval + 0.05 * material_eval
# Perspective flip for black
if board.turn == chess.BLACK:
hybrid_eval = -hybrid_eval
return hybrid_eval
def get_model_size_mb(self) -> float:
"""Get model size in MB"""
return self.model_path.stat().st_size / (1024 * 1024)