diff --git a/.github/workflows/games.yml b/.github/workflows/games.yml index ca44eb8..f3ef802 100644 --- a/.github/workflows/games.yml +++ b/.github/workflows/games.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: path: new persist-credentials: false @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ github.event.inputs.base_ref }} path: base @@ -86,60 +86,117 @@ jobs: with: name: base-engine path: base/build/${{ github.event.inputs.output_exec }} - - - test: + prepare-tools: runs-on: ubuntu-latest - needs: [build-new, build-base] steps: - - name: Download engines - uses: actions/download-artifact@v4 + - run: | + mkdir tools + cd tools - - name: Install tools - env: - OUTPUT_EXEC: ${{ github.event.inputs.output_exec }} - run: | sudo apt update sudo apt install -y wget unzip - wget https://github.com/Disservin/fastchess/releases/download/v1.8.0-alpha/fastchess-linux-x86-64.tar tar -xf fastchess-linux-x86-64.tar - chmod +x fastchess-linux-x86-64/fastchess mv fastchess-linux-x86-64/fastchess fastchess - wget https://github.com/official-stockfish/books/raw/refs/heads/master/UHO_Lichess_4852_v1.epd.zip unzip UHO_Lichess_4852_v1.epd.zip - wget https://github.com/michiguel/Ordo/releases/download/1.0/ordo-linux64 - chmod +x ordo-linux64 + - uses: actions/upload-artifact@v4 + with: + name: tools + path: tools/ + test: + needs: [build-new, build-base, prepare-tools] + strategy: + fail-fast: true + matrix: + shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,20,21,22,23,24,25,26,27,28,29] + + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + - name: Play games + env: + OUTPUT_EXEC: ${{ github.event.inputs.output_exec }} + TC: ${{ github.event.inputs.tc }} + ELO0: ${{ github.event.inputs.elo0 }} + ELO1: ${{ github.event.inputs.elo1 }} + run: | + set -eo pipefail chmod +x new-engine/engine chmod +x base-engine/$OUTPUT_EXEC + chmod +x tools/fastchess + chmod +x tools/ordo-linux64 + tools/fastchess \ + -engine cmd=new-engine/engine name=new \ + -engine cmd=base-engine/$OUTPUT_EXEC name=base \ + -openings file=tools/UHO_Lichess_4852_v1.epd format=epd order=random \ + -each tc=$TC -startup-ms 600000 -ucinewgame-ms 600000 -ping-ms 600000 \ + ROUNDS: ${{ github.event.inputs.rounds }} + run: | + set -eo pipefail + if [[ ! "$ROUNDS" =~ ^[0-9]+$ ]]; then + echo "rounds must be a decimal integer" >&2 + exit 1 + fi + rounds_value=$((10#$ROUNDS)) + shard_rounds=$((rounds_value / 30)) + -rounds "$shard_rounds" -repeat -log level=err \ + -concurrency $(nproc) -testEnv -sprt elo0=$ELO0 elo1=$ELO1 alpha=0.05 beta=0.05 \ + -pgnout notation=san nodes=true \ + file=games_${{ matrix.shard }}.pgn \ + | tee results_${{ matrix.shard }}.txt + + - uses: actions/upload-artifact@v4 + with: + name: shard-${{ matrix.shard }} + path: | + games_${{ matrix.shard }}.pgn + results_${{ matrix.shard }}.txt - - name: Run fastchess + evaluate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + + - name: Run Ordo + run: | + chmod +x tools/ordo-linux64 + printf '<%s>\n' shard-*/games_*.pgn + cat shard-*/games_*.pgn > games.pgn + tools/ordo-linux64 -o ratings.txt games.pgn + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: SPRT env: ELO0: ${{ github.event.inputs.elo0 }} ELO1: ${{ github.event.inputs.elo1 }} - TC: ${{ github.event.inputs.tc }} - OUTPUT_EXEC: ${{ github.event.inputs.output_exec }} - ROUNDS: ${{ github.event.inputs.rounds }} run: | - ./fastchess -recover \ - -engine cmd=new-engine/engine name=new \ - -engine cmd=base-engine/$OUTPUT_EXEC name=base \ - -openings file=UHO_Lichess_4852_v1.epd format=epd order=random \ - -each tc=$TC \ - -rounds $ROUNDS \ - -concurrency $(nproc) \ - -pgnout notation=san nodes=true file=games.pgn \ - -sprt elo0=$ELO0 elo1=$ELO1 alpha=0.05 beta=0.05 | tee results.txt - ./ordo-linux64 -o ratings.txt -- games.pgn - - name: Upload results - uses: actions/upload-artifact@v4 + pip install chess scipy + git clone https://github.com/jdart1/stats.git + RESULTS=$(python extract_ptnml.py shard-*/*.pgn | tee results.txt | tail -n1) + python -m stats.sprt \ + --elo0 "$ELO0" \ + --elo1 "$ELO1" \ + --alpha 0.05 \ + --beta 0.05 \ + --results $RESULTS | tee -a results.txt + + - uses: actions/upload-artifact@v4 with: name: results path: | - games.pgn ratings.txt + sprt.txt results.txt diff --git a/.github/workflows/spsa.yml b/.github/workflows/spsa.yml index c73ebdf..fe27de6 100644 --- a/.github/workflows/spsa.yml +++ b/.github/workflows/spsa.yml @@ -2,15 +2,6 @@ name: run SPSA (see gist) on: workflow_dispatch: - inputs: - base_ref: - description: "Tune branch" - required: false - default: "HandcraftedEngine" - output_exec: - description: "Executable output file name" - required: false - default: "engine" jobs: worker: runs-on: ubuntu-latest @@ -18,7 +9,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.base_ref }} persist-credentials: false - uses: actions/setup-python@v6 @@ -40,13 +30,13 @@ jobs: cmake --build . -j$(nproc) - name: Tune engine run: | - python spsa.py --iters 150 --pairs 30 --engine build/engine --workers $(nproc) + python spsa.py --iters 40 --pairs 30 --engine build/engine --workers $(nproc) - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: spsa-results path: | ./Weights.h - *.pgn - *.epd - compression-level: 0 + games*.pgn + games*.epd + compression-level: 9 diff --git a/Weights.h b/Weights.h index b20dd65..3dafdd6 100644 --- a/Weights.h +++ b/Weights.h @@ -2,12 +2,17 @@ #define WEIGHTS_H #include "eval.h" namespace engine::eval { -inline Value tempo = 20; -inline Value PawnValue = 100; -inline Value KnightValue = 320; -inline Value BishopValue = 330; -inline Value RookValue = 500; -inline Value QueenValue = 900; +inline Value tempo = 28; +inline Value PawnValueMG = 124; +inline Value KnightValueMG = 781; +inline Value BishopValueMG = 825; +inline Value RookValueMG = 1276; +inline Value QueenValueMG = 2538; +inline Value PawnValueEG = 206; +inline Value KnightValueEG = 854; +inline Value BishopValueEG = 915; +inline Value RookValueEG = 1380; +inline Value QueenValueEG = 2682; inline Value fianchettoBonus = 20; inline Value trappedBishopPenalty = 60; inline Value centerWeight = 5; @@ -98,7 +103,6 @@ inline Value eg_pawn_table[56] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 2, -2, -5, -10, -5, 10, 20, 20, 10, -5, -10, -20, -10, 30, 55, 55, 30, -10, -20, -35, -20, 70, 110, 110, 70, -20, -35, -60, -45, 120, 180, 180, 120, -45, -60 }; inline Value developedMg = 8; -inline Value developedEg = 4; inline Value outpostBonusKnight[2] = { 15, 30 }; inline Value outpostBonusBishop[2] = { 10, 25 }; inline Value kingProtector[6][2] = { diff --git a/eval.cpp b/eval.cpp index ca6517d..171fa2b 100644 --- a/eval.cpp +++ b/eval.cpp @@ -32,16 +32,26 @@ Value *egPst[] = { nullptr, eg_pawn_table, eg_knight_table, eg_bishop_table, eg_ TUNE(SetRange(5, 30), tempo, - SetRange(80, 120), - PawnValue, - SetRange(280, 370), - KnightValue, - SetRange(300, 400), - BishopValue, - SetRange(450, 550), - RookValue, + SetRange(80, 150), + PawnValueMG, + SetRange(160, 260), + PawnValueEG, + SetRange(650, 850), + KnightValueMG, + SetRange(750, 950), + KnightValueEG, + SetRange(700, 900), + BishopValueMG, SetRange(800, 1000), - QueenValue); + BishopValueEG, + SetRange(1100, 1400), + RookValueMG, + SetRange(1200, 1500), + RookValueEG, + SetRange(2200, 2800), + QueenValueMG, + SetRange(2400, 3000), + QueenValueEG); TUNE(SetRange(-50, 70), mgMobilityCnt, egMobilityCnt); TUNE(SetRange(0, 30), fianchettoBonus, SetRange(0, 100), trappedBishopPenalty); TUNE(SetRange(-20, 20), kingTropismMg, kingTropismEg); @@ -54,7 +64,7 @@ TUNE(SetRange(0, 30), SetRange(1, 30), spaceWeight); TUNE(SetRange(0, 50), bishopPairMg, SetRange(0, 50), bishopPairEg); -TUNE(SetRange(1, 20), developedMg, SetRange(1, 20), developedEg); +TUNE(SetRange(1, 20), developedMg); TUNE(SetRange(0, 30), rookOpenFileMg, SetRange(0, 30), @@ -238,7 +248,7 @@ EvalComponents eval_components(const chess::Position &board) { // Precompute pawn attacks (needed for outpost, threats, etc.) Bitboard pawnBB[2] = { board.pieces(PAWN, WHITE), board.pieces(PAWN, BLACK) }; Bitboard pawnAtks[2] = { attacks::pawn(pawnBB[WHITE]), attacks::pawn(pawnBB[BLACK]) }; -#if 0 +#if 1 // Development bonus: penalize undeveloped knights/bishops in middlegame int devCount[2] = { 0, 0 }; for (Color c : { WHITE, BLACK }) { @@ -250,19 +260,19 @@ EvalComponents eval_components(const chess::Position &board) { devCount[c] = popcount(knightsHome) + popcount(bishopsHome); } mgScore += (devCount[BLACK] - devCount[WHITE]) * developedMg; - egScore += (devCount[BLACK] - devCount[WHITE]) * developedEg; + // EG development term intentionally disabled // Early queen development penalty: queen moved but minors still on back rank for (Color c : { WHITE, BLACK }) { int s = (c == WHITE) ? 1 : -1; Square qStart = c == WHITE ? SQ_D1 : SQ_D8; - if (!(board.pieces(QUEEN, c) & (1ULL << qStart))) { + if (board.pieces(QUEEN, c) && !(board.pieces(QUEEN, c) & (1ULL << qStart))) { Bitboard backRank = c == WHITE ? attacks::MASK_RANK[0] : attacks::MASK_RANK[7]; int undeveloped = popcount((board.pieces(KNIGHT, c) | board.pieces(BISHOP, c)) & backRank); if (undeveloped >= 2) { mgScore -= s * earlyQueenPenalty; - //disabled intentionally in endgames - //egScore -= s * earlyQueenPenalty; + // disabled intentionally in endgames + // egScore -= s * earlyQueenPenalty; } } } @@ -281,8 +291,8 @@ EvalComponents eval_components(const chess::Position &board) { continue; mgScore += _sign * mgPst[pt][_sq]; egScore += _sign * egPst[pt][_sq]; - mgScore += _sign * piece_value(pt); - egScore += _sign * piece_value(pt); + mgScore += _sign * piece_value_mg(pt); + egScore += _sign * piece_value_eg(pt); if (pt == KNIGHT) phase += KnightPhase; else if (pt == BISHOP) @@ -312,7 +322,7 @@ EvalComponents eval_components(const chess::Position &board) { mgScore += _sign * (7 - kd) * kingTropismMg[pt]; egScore += _sign * (7 - kd) * kingTropismEg[pt]; } -#if 0 +#if 1 // King protector: bonus for pieces close to own king if (pt != PAWN && pt != KING) { int kdist = square_distance(sq, board.kingSq(pc)); @@ -361,7 +371,7 @@ EvalComponents eval_components(const chess::Position &board) { } } } -#if 0 +#if 1 // Trapped bishop penalty for (Color c : { WHITE, BLACK }) { int s = (c == WHITE) ? 1 : -1; @@ -394,7 +404,7 @@ EvalComponents eval_components(const chess::Position &board) { } } } -#if 0 +#if 1 // Rook on seventh rank bonus (endgame, x-raying >=2 undefended pawns) for (Color c : { WHITE, BLACK }) { int s = (c == WHITE) ? 1 : -1; @@ -463,7 +473,7 @@ EvalComponents eval_components(const chess::Position &board) { } } } -#if 0 +#if 1 // Pawn rams: blocked pawn penalty for (Color c : { WHITE, BLACK }) { int s = (c == WHITE) ? 1 : -1; @@ -606,7 +616,7 @@ EvalComponents eval_components(const chess::Position &board) { } // --- Threat evaluation --- -#if 0 +#if 1 for (Color c : { WHITE, BLACK }) { int s = (c == WHITE) ? 1 : -1; Color opp = ~c; @@ -715,18 +725,39 @@ EvalComponents eval_components(const chess::Position &board) { board.count() == 0 && board.count() == 0) return { 0, 0, 0 }; - phase = std::min((phase * 256 + TotalPhase / 2) / TotalPhase, 256); + phase = std::min((phase * 128 + TotalPhase / 2) / TotalPhase, 128); return { mgScore, egScore, phase }; } -Value eval(const chess::Position &board) { - const int sign = board.side_to_move() == WHITE ? 1 : -1; - auto [mg, eg, phase] = eval_components(board); - if (mg == 0 && eg == 0) +Value score_from_components(const EvalComponents &comp, const chess::Position &board) { + if (comp.mg == 0 && comp.eg == 0 && comp.phase == 0) return 0; - return (((mg * phase) + (eg * (256 - phase))) * sign) / 256 + engine::eval::tempo; + + int sf = 64; + int total = chess::popcount(board.occ()); + int pawns = board.count(); + if (total == 2 && pawns == 0) + return 0; + if (total <= 4 && pawns == 0) + sf = 32; + int eg = comp.eg * sf / 64; + + int v = (comp.mg * comp.phase + eg * (128 - comp.phase)) / 128; + v = (v / 16) * 16; + + int rule50 = std::min(static_cast(board.rule50_count()), 100); + v = v * (100 - rule50) / 100; + + const int sign = board.side_to_move() == chess::WHITE ? 1 : -1; + return v * sign + engine::eval::tempo; +} + +Value eval(const chess::Position &board) { return score_from_components(eval_components(board), board); } +Value piece_value_mg(PieceType pt) { + Value pieces[] = { 0, PawnValueMG, KnightValueMG, BishopValueMG, RookValueMG, QueenValueMG, 0 }; + return pieces[pt]; } -Value piece_value(PieceType pt) { - Value pieces[] = { 0, PawnValue, KnightValue, BishopValue, RookValue, QueenValue, 0 }; +Value piece_value_eg(PieceType pt) { + Value pieces[] = { 0, PawnValueEG, KnightValueEG, BishopValueEG, RookValueEG, QueenValueEG, 0 }; return pieces[pt]; } } // namespace engine::eval diff --git a/eval.h b/eval.h index aecdc5a..c290bca 100644 --- a/eval.h +++ b/eval.h @@ -52,6 +52,8 @@ extern Value *egPst[]; Value eval(const chess::Position &board); EvalComponents eval_components(const chess::Position &board); -Value piece_value(chess::PieceType pt); +Value score_from_components(const EvalComponents &comp, const chess::Position &board); +Value piece_value_mg(chess::PieceType pt); +Value piece_value_eg(chess::PieceType pt); } // namespace eval } // namespace engine diff --git a/extract_ptnml.py b/extract_ptnml.py new file mode 100644 index 0000000..ca7e054 --- /dev/null +++ b/extract_ptnml.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +import argparse +import glob +from collections import defaultdict +from itertools import chain +import chess.pgn + +# [LL, LD/DL, WL/DD/LW, WD/DW, WW] +PTNML_INDEX = {-2: 0, -1: 1, 0: 2, 1: 3, 2: 4} + + +def score_from_new(headers): + result = headers["Result"] + + if result == "1/2-1/2": + return 0 + + new_is_white = headers["White"] == "new" + + if new_is_white: + return 1 if result == "1-0" else -1 + else: + return 1 if result == "0-1" else -1 + + +def main(): + parser = argparse.ArgumentParser( + description="Extract WDL and pentanomial statistics from fastchess PGNs" + ) + parser.add_argument("pgns", nargs="+") + args = parser.parse_args() + + pairs = defaultdict(list) + + # WDL = [loss, draw, win] + wdl = [0, 0, 0] + games = 0 + + for filename in chain.from_iterable(glob.glob(p) for p in args.pgns): + with open(filename, encoding="utf-8") as f: + while (game := chess.pgn.read_game(f)) is not None: + headers = game.headers + + score = score_from_new(headers) + + games += 1 + if score < 0: + wdl[0] += 1 + elif score == 0: + wdl[1] += 1 + else: + wdl[2] += 1 + + fen = headers.get("FEN") + round_id = headers.get("Round") + + if fen is None or round_id is None: + raise RuntimeError( + f"{filename}: missing FEN or Round tag" + ) + + # Include filename because each shard starts rounds again + key = ( + filename, + round_id, + fen, + ) + + pairs[key].append(score) + + ptnml = [0] * 5 + pairs_count = 0 + + for key, scores in pairs.items(): + if len(scores) != 2: + print( + f"Skipping incomplete pair {key}: got {len(scores)} games" + ) + continue + + # Make sure the two games are the repeat pair + if scores[0] + scores[1] not in PTNML_INDEX: + raise RuntimeError("Invalid pair score") + + ptnml[PTNML_INDEX[scores[0] + scores[1]]] += 1 + pairs_count += 1 + + print(f"Games: {games}") + print(f"Pairs: {pairs_count}") + + print() + print("WDL:") + print(f" Loss: {wdl[0]}") + print(f" Draw: {wdl[1]}") + print(f" Win : {wdl[2]}") + print() + print(wdl) + + print() + print("PTNML:") + names = [ + "LL", + "LD", + "WL/DD", + "WD", + "WW", + ] + + for name, count in zip(names, ptnml): + print(f" {name:10}: {count}") + + print() + print(ptnml) + print(" ".join([str(w) for w in ptnml])) + +if __name__ == "__main__": + main() diff --git a/movepick.cpp b/movepick.cpp index 4134810..071402c 100644 --- a/movepick.cpp +++ b/movepick.cpp @@ -3,7 +3,7 @@ #include "search.h" #include using namespace chess; -using engine::eval::piece_value; +using engine::eval::piece_value_mg; namespace engine::movepick { static Bitboard att(PieceType pt, Square sq, Bitboard occ) { @@ -66,7 +66,7 @@ Value see(Position &board, Move move) { Value gain[32]; PieceType attacker = board.at(move.from()); - gain[0] = piece_value(captured); + gain[0] = piece_value_mg(captured); Color stm = ~board.side_to_move(); int d = 0; @@ -74,7 +74,7 @@ Value see(Position &board, Move move) { while (++d < 32) { // Charge the piece that just captured. - gain[d] = piece_value(attacker) - gain[d - 1]; + gain[d] = piece_value_mg(attacker) - gain[d - 1]; if (gain[d] < 0) break; @@ -109,8 +109,9 @@ void orderMoves(Position &board, Movelist &moves, Move ttMove, int ply, const en scores[i] = 10000; else if (board.isCapture(move)) { Value s = see(board, move); - Value capturedVal = move.type_of() == EN_PASSANT ? piece_value(PAWN) : piece_value(board.at(move.to())); - Value attackerVal = piece_value(board.at(move.from())); + Value capturedVal = + move.type_of() == EN_PASSANT ? piece_value_mg(PAWN) : piece_value_mg(board.at(move.to())); + Value attackerVal = piece_value_mg(board.at(move.from())); scores[i] = (s >= -50 ? 9000 : 4000) + std::max(s, Value(-50)) + (capturedVal * 10 - attackerVal) / 100; } else if (move == session.killerMoves[ply][0]) scores[i] = 8500; diff --git a/search.cpp b/search.cpp index 966f7ec..8c4a6c8 100644 --- a/search.cpp +++ b/search.cpp @@ -145,8 +145,8 @@ Value qsearch(Position &board, Value alpha, Value beta, search::Session &session if (!isCapture && !givesCheck) continue; if (isCapture && !givesCheck && move.type_of() != PROMOTION) { - Value capturedValue = - move.type_of() == EN_PASSANT ? eval::piece_value(PAWN) : eval::piece_value(board.at(move.to())); + Value capturedValue = move.type_of() == EN_PASSANT ? eval::piece_value_mg(PAWN) + : eval::piece_value_mg(board.at(move.to())); if (standPat + capturedValue + 200 < alpha) continue; if (movepick::see(board, move) < 0) diff --git a/spsa.py b/spsa.py index 82a4492..ad450b4 100644 --- a/spsa.py +++ b/spsa.py @@ -1,454 +1,416 @@ -import argparse -import csv -import random -import subprocess -import os -import json -import logging -import re -import copy -import tempfile -from collections import defaultdict -logger = logging.getLogger("spsa_tuner") -FASTCHESS_TEMPLATE = { - "resign": { - "move_count": 1, - "score": 0, - "twosided": False, - "enabled": False - }, - "draw": { - "move_number": 0, - "move_count": 1, - "score": 0, - "enabled": False - }, - "maxmoves": { - "move_count": 1, - "enabled": False - }, - "tb_adjudication": { - "syzygy_dirs": "", - "max_pieces": 0, - "ignore_50_move_rule": False, - "enabled": False - }, - "opening": { - "file": "UHO_Lichess_4852_v1.epd", - "format": 0, - "order": 0, - "plies": -1, - "start": 1 - }, - "pgn": { - "additional_lines_rgx": [], - "event_name": "Fastchess Tournament", - "site": "?", - "file": "", - "notation": 0, - "append_file": True, - "track_nodes": False, - "track_seldepth": False, - "track_nps": False, - "track_hashfull": False, - "track_tbhits": False, - "track_timeleft": False, - "track_latency": False, - "track_pv": False, - "min": False, - "crc": False - }, - "epd": { - "file": "", - "append_file": True - }, - "sprt": { - "alpha": 0.05, - "beta": 0.05, - "elo0": 0.0, - "elo1": 5.0, - "model": "normalized", - "enabled": True - }, - "config_name": "config.json", - "output": 0, - "variant": 0, - "type": 0, - "gauntlet_seeds": 1, - "seed": 0, - "ratinginterval": 10, - "scoreinterval": 1, - "wait": 0, - "autosaveinterval": 5, - "games": 2, - "rounds": 2000, - "concurrency": 6, - "force_concurrency": False, - "recover": True, - "noswap": False, - "reverse": False, - "report_penta": True, - "affinity": False, - "show_latency": False, - "log": { - "file": "", - "level": 2, - "append_file": True, - "compress": False, - "realtime": True, - "engine_coms": False - }, - "engines": [ - { - "name": "Plus", - "dir": "", - "cmd": "engine", - "args": "", - "restart": False, - "options": [], - "limit": { - "tc": { - "increment": 0, - "fixed_time": 0, - "time": 0, - "moves": 0, - "timemargin": 0 - }, - "nodes": 0, - "plies": 5 - }, - "variant": 0 - }, - { - "name": "Minus", - "dir": "", - "cmd": "engine", - "args": "", - "restart": False, - "options": [], - "limit": { - "tc": { - "increment": 0, - "fixed_time": 0, - "time": 0, - "moves": 0, - "timemargin": 0 - }, - "nodes": 0, - "plies": 5 - }, - "variant": 0 - } - ], - "stats": { - "Plus vs Minus": { - "wins": 0, - "losses": 0, - "draws": 0, - "penta_WW": 0, - "penta_WD": 0, - "penta_WL": 0, - "penta_DD": 0, - "penta_LD": 0, - "penta_LL": 0 - } - } -} - -# --- CLI Parser --- -parser = argparse.ArgumentParser(description="a SPSA tuner") -parser.add_argument("--engine", required=True, help="engine") -parser.add_argument("--infile", default="spsa_params.txt", help="inputs") -parser.add_argument("--outfile", default="spsa_params.txt", help="outs") -parser.add_argument("--iters", type=int, default=10, help="iters") -parser.add_argument("--pairs", type=int, default=4, help="pairs (including repeats)") -parser.add_argument("--workers", type=int, default=6, help="concurrency") -parser.add_argument("--stable_offset", type=int, default=3000, help="stability const") -parser.add_argument("--lr", type=float, default=1e-1, help="base lr") -parser.add_argument("--alpha", type=float, default=0.602, help="alpha") -parser.add_argument("--gamma", type=float, default=0.101, help="gamma") -parser.add_argument("--hash", type=str, default="16", help="TT size") - -# --- params --- -def load_params(path, engine_path): - params = {} - if os.path.exists(path): - with open(path) as f: - for row in csv.reader(f): - try: - if not row: - continue - line = row[0].strip() - - if line.startswith("(") and line.endswith(")"): - line = line[1:-1] - if len(row) == 6: - name, val, lo, hi, step, a = row - c = max(2.0 * float(step), 1.0) - elif len(row) == 7: - name, val, lo, hi, step, a, c = row - else: - raise ValueError(f"Bad row: {row}") - params[name] = { - "value": float(val), - "min": float(lo), - "max": float(hi), - "step": float(step), - "a": float(a), - "c": float(c), - } - except Exception as e:print(e, row) - return params - else: - logger.info("%s not found, starting engine to capture parameters", path) - with tempfile.NamedTemporaryFile(mode="r+", delete=False) as tmp: - subprocess.run([engine_path], stdout=tmp, stderr=subprocess.STDOUT, input="quit\n", text=True) - tmp.seek(0) - return load_params(tmp.name, engine_path) - -def save_params(path, params): - with open(path, "w", newline="", encoding="utf-8") as f: - w = csv.writer(f) - for n, p in params.items(): - w.writerow([n, p["value"], p["min"], p["max"], p["step"], p["a"], p["c"]]) - -# --- runner --- -def run_fastchess_match(plus_params, minus_params, args, iteration): - config = copy.deepcopy(FASTCHESS_TEMPLATE) - config["rounds"] = args.pairs - config["concurrency"] = args.workers - config["pgn"]["file"]=f"games{random.randint(0,2**31-1)}.pgn" - config["epd"]["file"]=f"games{random.randint(0,2**31-1)}.epd" - options_plus = [["Hash", str(args.hash)]] - for n, v in plus_params.items(): - options_plus.append([n, str(int(round(v)))]) - - options_minus = [["Hash", str(args.hash)]] - for n, v in minus_params.items(): - options_minus.append([n, str(int(round(v)))]) - - config["engines"][0]["cmd"] = args.engine - config["engines"][0]["options"] = options_plus - - config["engines"][1]["cmd"] = args.engine - config["engines"][1]["options"] = options_minus - - temp_config_path = "config.json" - with open(temp_config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=4) - - logger.info(f"--- Iteration {iteration}: {args.pairs} pairs ---") - cmd = ["./fastchess", "-config", f"file={temp_config_path}"] - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - except subprocess.CalledProcessError as e: - print("Return code:", e.returncode) - print("STDOUT:") - print(e.stdout) - print("STDERR:") - print(e.stderr) - with open(temp_config_path, "r", encoding="utf-8") as f: - output_config = json.load(f) - print(json.dumps(output_config["stats"], indent=2)) - stats = output_config.get("stats", {}) - stats_key = "Plus vs Minus" - - if stats_key in stats and "wins" in stats[stats_key]: - w = stats[stats_key]["wins"] - l = stats[stats_key]["losses"] - d = stats[stats_key]["draws"] - else: - w, l, d = 0, 0, 0 - logger.error("HOW IS THAT NOT EXIST") - - total_games = w + l + d - if total_games == 0: - logger.error("as we saw") - logger.debug(result.stdout) - logger.debug(result.stderr) - return 0.5, 0.5 - - f_plus = (w + 0.5 * d) / total_games - f_minus = (l + 0.5 * d) / total_games - - logger.info(f"Plus: WDL=[{w},{d},{l}] total={total_games}") - return f_plus, f_minus - - -INDEX_RE = re.compile(r"\[(\d+)\]") -def regroup_ndim(names, values): - scalars = {} - arrays_raw = defaultdict(dict) - for n, v in zip(names, values): - indices = [int(i) for i in INDEX_RE.findall(n)] - if indices: - base_name = n.split('[')[0].strip() - arrays_raw[base_name][tuple(indices)] = int(round(v)) - else: - scalars[n] = int(round(v)) - return scalars, arrays_raw - -def finalize_ndim_arrays(arrays_raw): - out_arrays = {} - for name, coord_dict in arrays_raw.items(): - sample_coords = list(coord_dict.keys())[0] - ndim = len(sample_coords) - shape = [] - for d in range(ndim): - max_idx = max(coords[d] for coords in coord_dict.keys()) - shape.append(max_idx + 1) - - def create_nested_list(dims): - if len(dims) == 1: - return [0] * dims[0] - return [create_nested_list(dims[1:]) for _ in range(dims[0])] - - nested_arr = create_nested_list(shape) - for coords, val in coord_dict.items(): - current = nested_arr - for i in range(len(coords) - 1): - current = current[coords[i]] - current[coords[-1]] = val - out_arrays[name] = (shape, nested_arr) - return out_arrays - -def format_cpp_array(arr): - if not isinstance(arr, list): - return str(arr) - inner = ", ".join(format_cpp_array(item) for item in arr) - return f"{{ {inner} }}" - -def write_weights_header(names, x): - scalars, arrays_raw = regroup_ndim(names, x) - arrays = finalize_ndim_arrays(arrays_raw) if arrays_raw else {} - lines = [ - "#ifndef WEIGHTS_H", - "#define WEIGHTS_H", - '#include "eval.h"', - "namespace engine::eval {", - ] - for k, v in scalars.items(): - lines.append(f"inline Value {k} = {v};") - for k, (shape, arr) in arrays.items(): - shape_str = "".join(f"[{dim}]" for dim in shape) - cpp_initializer = format_cpp_array(arr) - lines.append(f"inline Value {k}{shape_str} = {cpp_initializer};") - lines.append("} // namespace engine::eval") - lines.append("#endif") - - # Also write to the default location - with open("Weights.h", "w", encoding="utf-8") as f: - f.writelines(l + "\n" for l in lines) -# --- SPSA Core --- -def spsa_core(params, args): - for k in range(args.iters): - logger.info(f"=== iter: {k} ===") - - deltas = {n: (1 if random.random() < 0.5 else -1) for n in params} - ak = {n: p["a"] / (k + args.stable_offset) ** args.alpha for n, p in params.items()} - ck = { - n: p["c"] / (k + 1) ** args.gamma - for n, p in params.items() - } - - while True: - - deltas = { - n: 1 if random.random() < 0.5 else -1 - for n in params - } - - plus = { - n: min( - p["max"], - max( - p["min"], - p["value"] + ck[n] * deltas[n] - ), - ) - for n, p in params.items() - } - - minus = { - n: min( - p["max"], - max( - p["min"], - p["value"] - ck[n] * deltas[n] - ), - ) - for n, p in params.items() - } - - plus_int = { - n: int(round(v)) - for n, v in plus.items() - } - - minus_int = { - n: int(round(v)) - for n, v in minus.items() - } - - if plus_int != minus_int: - break - plus_int = {n: int(round(v)) for n, v in plus.items()} - minus_int = {n: int(round(v)) for n, v in minus.items()} - - if plus_int == minus_int: - continue - f_plus, f_minus = run_fastchess_match(plus, minus, args, k) - - for n, p in params.items(): - score = 2.0 * f_plus - 1.0 - - ghat = score / ( - 2 * ck[n] * deltas[n] - ) - old_val = p["value"] - new_val = old_val + ak[n] * ghat - p["value"] = min(p["max"], max(p["min"], new_val)) - - if int(round(old_val)) != int(round(p["value"])): - logger.info( - "[%s] %.3f -> %.3f | grad=% .4f ak=%.4f ck=%.3f", - n, - old_val, - p["value"], - ghat, - ak[n], - ck[n], - ) - - print(f"iter {k}: score (plus) = {f_plus:.3f}") - save_params(args.outfile, params) - write_weights_header(list(params.keys()), [p["value"] for p in params.values()]) - - return params - -def main(): - logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(message)s", - datefmt="%H:%M:%S" - ) - args = parser.parse_args() - - logger.info("loading params") - params = load_params(args.infile, args.engine) - for name, p in params.items(): - - assert p["min"] <= p["value"] <= p["max"] - - assert p["step"] > 0 - assert p["a"] > 0 - assert p["c"] > 0 - write_weights_header(list(params.keys()), [p["value"] for p in params.values()]) - logger.info(f"got {len(params)} params.") - - tuned_params = spsa_core(params, args) - save_params(args.outfile, tuned_params) - logger.info("FINISHED. now run a SPRT.") - -if __name__ == "__main__": - main() +import argparse +import csv +import random +import subprocess +import os +import json +import logging +import re +import copy +import tempfile +from collections import defaultdict +logger = logging.getLogger("spsa_tuner") +FASTCHESS_TEMPLATE = { + "resign": { + "move_count": 1, + "score": 0, + "twosided": False, + "enabled": False + }, + "draw": { + "move_number": 0, + "move_count": 1, + "score": 0, + "enabled": False + }, + "maxmoves": { + "move_count": 1, + "enabled": False + }, + "tb_adjudication": { + "syzygy_dirs": "", + "max_pieces": 0, + "ignore_50_move_rule": False, + "enabled": False + }, + "opening": { + "file": "UHO_Lichess_4852_v1.epd", + "format": 0, + "order": 0, + "plies": -1, + "start": 1 + }, + "pgn": { + "additional_lines_rgx": [], + "event_name": "Fastchess Tournament", + "site": "?", + "file": "", + "notation": 0, + "append_file": True, + "track_nodes": False, + "track_seldepth": False, + "track_nps": False, + "track_hashfull": False, + "track_tbhits": False, + "track_timeleft": False, + "track_latency": False, + "track_pv": False, + "min": False, + "crc": False + }, + "epd": { + "file": "", + "append_file": True + }, + "sprt": { + "alpha": 0.05, + "beta": 0.05, + "elo0": 0.0, + "elo1": 5.0, + "model": "normalized", + "enabled": True + }, + "config_name": "config.json", + "output": 0, + "variant": 0, + "type": 0, + "gauntlet_seeds": 1, + "seed": 0, + "ratinginterval": 10, + "scoreinterval": 1, + "wait": 0, + "autosaveinterval": 5, + "games": 2, + "rounds": 2000, + "concurrency": 6, + "force_concurrency": False, + "recover": True, + "noswap": False, + "reverse": False, + "report_penta": True, + "affinity": False, + "show_latency": False, + "log": { + "file": "", + "level": 2, + "append_file": True, + "compress": False, + "realtime": True, + "engine_coms": False + }, + "engines": [ + { + "name": "Plus", + "dir": "", + "cmd": "engine", + "args": "", + "restart": False, + "options": [], + "limit": { + "tc": { + "increment": 100, + "fixed_time": 0, + "time": 10000, + "moves": 0, + "timemargin": 0 + }, + "nodes": 0, + "plies": 0 + }, + "variant": 0 + }, + { + "name": "Minus", + "dir": "", + "cmd": "engine", + "args": "", + "restart": False, + "options": [], + "limit": { + "tc": { + "increment": 100, + "fixed_time": 0, + "time": 10000, + "moves": 0, + "timemargin": 0 + }, + "nodes": 0, + "plies": 0 + }, + "variant": 0 + } + ], + "stats": { + "Plus vs Minus": { + "wins": 0, + "losses": 0, + "draws": 0, + "penta_WW": 0, + "penta_WD": 0, + "penta_WL": 0, + "penta_DD": 0, + "penta_LD": 0, + "penta_LL": 0 + } + } +} + +# --- CLI Parser --- +parser = argparse.ArgumentParser(description="a SPSA tuner") +parser.add_argument("--engine", required=True, help="engine") +parser.add_argument("--infile", default="spsa_params.txt", help="inputs") +parser.add_argument("--outfile", default="spsa_params.txt", help="outs") +parser.add_argument("--iters", type=int, default=10, help="iters") +parser.add_argument("--pairs", type=int, default=4, help="pairs (including repeats)") +parser.add_argument("--workers", type=int, default=6, help="concurrency") +parser.add_argument("--stable_offset", type=int, default=3000, help="stability const") +parser.add_argument("--alpha", type=float, default=0.602, help="alpha") +parser.add_argument("--gamma", type=float, default=0.101, help="gamma") +parser.add_argument("--hash", type=str, default="16", help="TT size") + +# --- params --- +def load_params(path, engine_path): + params = {} + if os.path.exists(path): + with open(path) as f: + for row in csv.reader(f): + try: + if not row: + continue + line = row[0].strip() + + if line.startswith("(") and line.endswith(")"): + line = line[1:-1] + row[0]=line + if len(row) == 6: + name, val, lo, hi, step, a = row + c = max(2.0 * float(step), 1.0) + elif len(row) == 7: + name, val, lo, hi, step, a, c = row + else: + raise ValueError(f"Bad row: {row}") + params[name] = { + "value": float(val), + "min": float(lo), + "max": float(hi), + "step": float(step), + "a": float(a), + "c": float(c), + } + except (ValueError, IndexError) as e: + logger.debug("skipping unparseable row %r: %s", row, e) + return params + else: + logger.info("%s not found, starting engine to capture parameters", path) + with tempfile.NamedTemporaryFile(mode="r+", delete=False) as tmp: + subprocess.run([engine_path], stdout=tmp, stderr=subprocess.STDOUT, input="quit\n", text=True) + tmp.seek(0) + return load_params(tmp.name, engine_path) + +def save_params(path, params): + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + for n, p in params.items(): + w.writerow([n, p["value"], p["min"], p["max"], p["step"], p["a"], p["c"]]) + +# --- runner --- +def run_fastchess_match(plus_params, minus_params, args, iteration): + config = copy.deepcopy(FASTCHESS_TEMPLATE) + config["rounds"] = args.pairs + config["concurrency"] = args.workers + config["pgn"]["file"]=f"games{random.randint(0,2**31-1)}.pgn" + config["epd"]["file"]=f"games{random.randint(0,2**31-1)}.epd" + options_plus = [["Hash", str(args.hash)]] + for n, v in plus_params.items(): + options_plus.append([n, str(int(round(v)))]) + + options_minus = [["Hash", str(args.hash)]] + for n, v in minus_params.items(): + options_minus.append([n, str(int(round(v)))]) + + config["engines"][0]["cmd"] = args.engine + config["engines"][0]["options"] = options_plus + + config["engines"][1]["cmd"] = args.engine + config["engines"][1]["options"] = options_minus + + temp_config_path = "config.json" + with open(temp_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4) + + logger.info(f"--- Iteration {iteration}: {args.pairs} pairs ---") + cmd = ["./fastchess", "-config", f"file={temp_config_path}"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + logger.error("fastchess exited %s", e.returncode) + logger.debug("STDOUT:\n%s", e.stdout) + logger.debug("STDERR:\n%s", e.stderr) + return 0.5, 0.5 + try: + with open(temp_config_path, "r", encoding="utf-8") as f: + output_config = json.load(f) + except Exception: + logger.error("Could not read fastchess output config.") + return 0.5, 0.5 + stats = output_config.get("stats", {}) + stats_key="Plus vs Minus" + if stats_key in stats and "wins" in stats[stats_key]: + w = stats[stats_key]["wins"] + l = stats[stats_key]["losses"] + d = stats[stats_key]["draws"] + else: + w, l, d = 0, 0, 0 + logger.error("HOW IS THAT NOT EXIST") + + total_games = w + l + d + if total_games == 0: + logger.error("as we saw") + logger.debug(result.stdout) + logger.debug(result.stderr) + return 0.5, 0.5 + + f_plus = (w + 0.5 * d) / total_games + f_minus = (l + 0.5 * d) / total_games + + logger.info(f"Plus: WDL=[{w},{d},{l}] total={total_games}") + return f_plus, f_minus + + +INDEX_RE = re.compile(r"\[(\d+)\]") +def regroup_ndim(names, values): + scalars = {} + arrays_raw = defaultdict(dict) + for n, v in zip(names, values): + indices = [int(i) for i in INDEX_RE.findall(n)] + if indices: + base_name = n.split('[')[0].strip() + arrays_raw[base_name][tuple(indices)] = int(round(v)) + else: + scalars[n] = int(round(v)) + return scalars, arrays_raw + +def finalize_ndim_arrays(arrays_raw): + out_arrays = {} + for name, coord_dict in arrays_raw.items(): + sample_coords = list(coord_dict.keys())[0] + ndim = len(sample_coords) + shape = [] + for d in range(ndim): + max_idx = max(coords[d] for coords in coord_dict.keys()) + shape.append(max_idx + 1) + + def create_nested_list(dims): + if len(dims) == 1: + return [0] * dims[0] + return [create_nested_list(dims[1:]) for _ in range(dims[0])] + + nested_arr = create_nested_list(shape) + for coords, val in coord_dict.items(): + current = nested_arr + for i in range(len(coords) - 1): + current = current[coords[i]] + current[coords[-1]] = val + out_arrays[name] = (shape, nested_arr) + return out_arrays + +def format_cpp_array(arr): + if not isinstance(arr, list): + return str(arr) + inner = ", ".join(format_cpp_array(item) for item in arr) + return f"{{ {inner} }}" + +def write_weights_header(names, x): + scalars, arrays_raw = regroup_ndim(names, x) + arrays = finalize_ndim_arrays(arrays_raw) if arrays_raw else {} + lines = [ + "#ifndef WEIGHTS_H", + "#define WEIGHTS_H", + '#include "eval.h"', + "namespace engine::eval {", + ] + for k, v in scalars.items(): + lines.append(f"inline Value {k} = {v};") + for k, (shape, arr) in arrays.items(): + shape_str = "".join(f"[{dim}]" for dim in shape) + cpp_initializer = format_cpp_array(arr) + lines.append(f"inline Value {k}{shape_str} = {cpp_initializer};") + lines.append("} // namespace engine::eval") + lines.append("#endif") + + # Also write to the default location + with open("Weights.h", "w", encoding="utf-8") as f: + f.writelines(l + "\n" for l in lines) +# --- SPSA Core --- +def spsa_core(params, args): + for k in range(args.iters): + logger.info(f"=== iter: {k} ===") + + ak = {n: p["a"] / (k + args.stable_offset) ** args.alpha for n, p in params.items()} + ck = {n: p["c"] / (k + 1) ** args.gamma for n, p in params.items()} + + while True: + deltas = {n: 1 if random.random() < 0.5 else -1 for n in params} + plus = {n: min(p["max"], max(p["min"], p["value"] + ck[n] * deltas[n])) + for n, p in params.items()} + minus = {n: min(p["max"], max(p["min"], p["value"] - ck[n] * deltas[n])) + for n, p in params.items()} + plus_int = {n: int(round(v)) for n, v in plus.items()} + minus_int = {n: int(round(v)) for n, v in minus.items()} + if plus_int != minus_int: + break + f_plus, f_minus = run_fastchess_match(plus, minus, args, k) + + for n, p in params.items(): + score = 2.0 * f_plus - 1.0 + + ghat = score / ( + 2 * ck[n] * deltas[n] + ) + old_val = p["value"] + new_val = old_val + ak[n] * ghat + p["value"] = min(p["max"], max(p["min"], new_val)) + + if int(round(old_val)) != int(round(p["value"])): + logger.info( + "[%s] %.3f -> %.3f | grad=% .4f ak=%.4f ck=%.3f", + n, + old_val, + p["value"], + ghat, + ak[n], + ck[n], + ) + + print(f"iter {k}: score (plus) = {f_plus:.3f}") + save_params(args.outfile, params) + write_weights_header(list(params.keys()), [p["value"] for p in params.values()]) + + return params + +def main(): + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S" + ) + args = parser.parse_args() + + logger.info("loading params") + params = load_params(args.infile, args.engine) + for name, p in params.items(): + + assert p["min"] <= p["value"] <= p["max"] + + assert p["step"] > 0 + assert p["a"] > 0 + assert p["c"] > 0 + write_weights_header(list(params.keys()), [p["value"] for p in params.values()]) + logger.info(f"got {len(params)} params.") + + tuned_params = spsa_core(params, args) + save_params(args.outfile, tuned_params) + logger.info("FINISHED. now run a SPRT.") + +if __name__ == "__main__": + main() diff --git a/tune.cpp b/tune.cpp index c118521..92bd513 100644 --- a/tune.cpp +++ b/tune.cpp @@ -76,8 +76,8 @@ void Tune::make_option(OptionsMap *opts, const string &n, int v, const SetRange << v << "," // << a << "," // << b << "," // - << (b - a) / 20.0 << "," // - << "0.0020" << '\n'; + << (b - a) / 10.0 << "," // + << (b - a) / 10.0 << '\n'; } string Tune::next(string &names, bool pop) { diff --git a/tune_cmd.cpp b/tune_cmd.cpp index 2db5c19..1cfc879 100644 --- a/tune_cmd.cpp +++ b/tune_cmd.cpp @@ -147,7 +147,7 @@ int compute_game_phase(const chess::Position &board) { else if (pt == chess::QUEEN) phase += QueenPhase; } - return (phase * 256 + TotalPhase / 2) / TotalPhase; + return std::min((phase * 128 + TotalPhase / 2) / TotalPhase, 128); } void accumulate_gradient(const chess::Position &board, @@ -166,7 +166,7 @@ void accumulate_gradient(const chess::Position &board, for (Color c : { WHITE, BLACK }) { double s = (c == WHITE) ? 1.0 : -1.0; Square qStart = c == WHITE ? SQ_D1 : SQ_D8; - if (!(board.pieces(QUEEN, c) & (1ULL << qStart))) { + if (board.pieces(QUEEN, c) && !(board.pieces(QUEEN, c) & (1ULL << qStart))) { Bitboard backRank = c == WHITE ? attacks::MASK_RANK[0] : attacks::MASK_RANK[7]; int undeveloped = popcount((board.pieces(KNIGHT, c) | board.pieces(BISHOP, c)) & backRank); if (undeveloped >= 2) { @@ -177,6 +177,18 @@ void accumulate_gradient(const chess::Position &board, } } + { + auto it = addr_to_idx.find(&developedMg); + if (it != addr_to_idx.end()) { + int devCount[2] = { 0, 0 }; + for (Color c : { WHITE, BLACK }) { + Bitboard backRank = c == WHITE ? attacks::MASK_RANK[0] : attacks::MASK_RANK[7]; + devCount[c] = popcount((board.pieces(KNIGHT, c) | board.pieces(BISHOP, c)) & backRank); + } + gradient[it->second] += common_factor * stm_sign * (devCount[BLACK] - devCount[WHITE]) * phase_mg; + } + } + { Bitboard pinMask = board.pin_mask(); Bitboard occ = board.occ(), occ2 = occ; @@ -203,28 +215,29 @@ void accumulate_gradient(const chess::Position &board, gradient[it_eg->second] += common_factor * eff * phase_eg; } - auto add_mat = [&](auto *addr) { - auto it = addr_to_idx.find(addr); - if (it != addr_to_idx.end()) { - gradient[it->second] += common_factor * eff * phase_mg; - gradient[it->second] += common_factor * eff * phase_eg; - } + auto add_mat = [&](auto *addr_mg, auto *addr_eg) { + auto it_mg = addr_to_idx.find(addr_mg); + auto it_eg = addr_to_idx.find(addr_eg); + if (it_mg != addr_to_idx.end()) + gradient[it_mg->second] += common_factor * eff * phase_mg; + if (it_eg != addr_to_idx.end()) + gradient[it_eg->second] += common_factor * eff * phase_eg; }; switch (pt) { case PAWN: - add_mat(&PawnValue); + add_mat(&PawnValueMG, &PawnValueEG); break; case KNIGHT: - add_mat(&KnightValue); + add_mat(&KnightValueMG, &KnightValueEG); break; case BISHOP: - add_mat(&BishopValue); + add_mat(&BishopValueMG, &BishopValueEG); break; case ROOK: - add_mat(&RookValue); + add_mat(&RookValueMG, &RookValueEG); break; case QUEEN: - add_mat(&QueenValue); + add_mat(&QueenValueMG, &QueenValueEG); break; default: break; @@ -654,18 +667,18 @@ void accumulate_gradient(const chess::Position &board, auto it_mg = addr_to_idx.find(&threatByMinor[pt][0]); auto it_eg = addr_to_idx.find(&threatByMinor[pt][1]); if (it_mg != addr_to_idx.end()) - gradient[it_mg->second] += common_factor * eff_threat; + gradient[it_mg->second] += common_factor * eff_threat * phase_mg; if (it_eg != addr_to_idx.end()) - gradient[it_eg->second] += common_factor * eff_threat; + gradient[it_eg->second] += common_factor * eff_threat * phase_eg; } if (attackedByRook) { auto it_mg = addr_to_idx.find(&threatByRook[pt][0]); auto it_eg = addr_to_idx.find(&threatByRook[pt][1]); if (it_mg != addr_to_idx.end()) - gradient[it_mg->second] += common_factor * eff_threat; + gradient[it_mg->second] += common_factor * eff_threat * phase_mg; if (it_eg != addr_to_idx.end()) - gradient[it_eg->second] += common_factor * eff_threat; + gradient[it_eg->second] += common_factor * eff_threat * phase_eg; } Rank relRank = relative_rank(c, sq); @@ -742,21 +755,39 @@ void texel_tune(TuneData &all, pos.set_fen(fen); auto comp = eval::eval_components(pos); - const int sign = pos.side_to_move() == chess::WHITE ? 1 : -1; - Value score = (((comp.mg * comp.phase) + (comp.eg * (256 - comp.phase))) * sign) / 256 + eval::tempo; + Value score = eval::score_from_components(comp, pos); + + int sf = 64; + int total = chess::popcount(pos.occ()); + int pawns = pos.count(); + if (total == 2 && pawns == 0) + sf = 0; + else if (total <= 4 && pawns == 0) + sf = 32; double sig = sigmoid(score); double error = sig - all.result(idx); ploss += error * error; + // Skip gradient accumulation for sentinel positions (forced-zero components) + if (comp.mg == 0 && comp.eg == 0 && comp.phase == 0) + continue; + // Avoid division by zero in gradient if (std::abs(error) < 1e-12) continue; double sig_deriv = 0.004 * sig * (1.0 - sig); double common_factor = 2.0 * error * sig_deriv; - double phase_mg = comp.phase / 256.0; - double phase_eg = (256 - comp.phase) / 256.0; + + // Scale common_factor by rule-50 factor to match score_from_components + { + int rule50 = std::min(static_cast(pos.rule50_count()), 100); + common_factor = common_factor * (100 - rule50) / 100; + } + + double phase_mg = comp.phase / 128.0; + double phase_eg = sf ? (128 - comp.phase) / 128.0 * sf / 64.0 : 0.0; double stm_sign_val = (pos.side_to_move() == chess::WHITE) ? 1.0 : -1.0; accumulate_gradient(pos, common_factor, addr_map, phase_mg, phase_eg, stm_sign_val, pg);