| | """ |
| | 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 = { |
| | chess.PAWN: 100, |
| | chess.KNIGHT: 320, |
| | chess.BISHOP: 330, |
| | chess.ROOK: 500, |
| | chess.QUEEN: 900, |
| | chess.KING: 0 |
| | } |
| | |
| | |
| | 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}") |
| | |
| | |
| | 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) |
| | |
| | |
| | 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 |
| | |
| | |
| | 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) |
| | |
| | |
| | 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 |
| | |
| | |
| | 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 |
| | |
| | |
| | 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) |
| | |
| | |
| | for rank in range(8): |
| | tensor[0, 51 + rank, rank, :] = 1.0 |
| | for file in range(8): |
| | tensor[0, 59 + file, :, file] = 1.0 |
| | |
| | |
| | |
| | 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 |
| | |
| | |
| | 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 |
| | |
| | |
| | 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}) |
| | |
| | |
| | raw_eval = float(outputs[0][0][0]) |
| | |
| | |
| | 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_eval = self.evaluate_neural(board) |
| | |
| | |
| | material_eval = self.evaluate_material(board) |
| | |
| | |
| | hybrid_eval = 0.95 * neural_eval + 0.05 * material_eval |
| | |
| | |
| | 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) |