Hanat is a highly constrained, data-efficient chess engine. The project explores the limits of sample efficiency in reinforcement learning by training a robust policy and value head under a strict joint budget of grandmaster (GM) games and limited oracle evaluations.
Instead of separating data sources by architecture, the engine is given complete freedom to leverage the total data pool to maximize Elo under extreme data scarcity.
Modern chess engines and AlphaZero-like architectures typically rely on millions of games and massive compute budgets. Hanat operates under a strict data-cap constraint to evaluate maximum performance per data point:
- Human Baseline: Exactly 5,000 GM-sourced games.
- Oracle Budget: Maximum 10,000 calls to the Stockfish evaluation engine.
- Search Architecture: Standardized Monte Carlo Tree Search (MCTS).
- Goal: Maximize engine Elo by training Policy and Value heads within these boundaries, allocating the available data dynamically.
Hanat decouples the neural network heads from the search architecture to ensure clean optimization:
+-----------------------------------+ | MCTS Search Architecture | +-----------------+-----------------+ | +----------------+----------------+ | | v v +--------------------+ +-------------------+ | Policy Head | | Value Head | | (Move Selection) | | (State Evaluation)| +--------------------+ +-------------------+
Both the Policy Head and Value Head have open access to the total constrained data pool. The core machine learning challenge of this project is determining the optimal data allocation strategy:
- How to balance the 5,000 GM games between policy imitation and value target bootstrapping.
- How to strategically spend the 10,000 Stockfish oracle calls to resolve high-entropy states for either head.
Hanat requires Python 3.10 or newer.
To set up the project locally:
- Clone the repository and navigate to the project root.
- Install the package in editable mode along with its dependencies:
pip install -e .(Note: The core rules engine is dependency-free, but training requires packages listed in =pyproject.toml=, such as =numpy=, =tqdm=, and =torch_geometric=. You should install PyTorch following the official instructions for your platform.)
For a ~100x speedup in move generation (critical for bulk dataset loading or search), compile the native C++ backend (requires a C++17 compiler):
python3 setup.py build_ext --inplaceThe engine automatically detects and falls back to the pure-Python implementation if the compiled extension is not found.
To verify the installation and run the move generation verification suite:
python3 -m unittest discover testsTo train the GNN outcome model on a PGN dataset:
python3 train.py --data data/twic1646.pgn --max-games 3000 --epochs 5For training on a rented GPU instance, launch the recommended vast.ai PyTorch image (it ships CUDA, Python, PyTorch, and JupyterLab), then run the setup script from the repo root. It installs the C++ toolchain and native engine, Stockfish, cutechess-cli, Ordo, and torch_geometric on top of the base image:
bash setup.shAfter it finishes, export the Stockfish path so the ELO benchmark can find it:
# apt installs stockfish to /usr/games, which is often not on PATH
export STOCKFISH_PATH=$(command -v stockfish || echo /usr/games/stockfish)The engine is a thin search shell. You supply two callables — a policy head and a value head — and the native PUCT MCTS does the rest. Nothing about the heads is fixed: a hand-written heuristic, a trained neural net, or the random baselines below all plug in the same way.
A policy head takes a Board and returns a prior over the legal moves. Any
one of three return shapes is accepted:
- a
list[float]aligned index-for-index withboard.legal_moves()(should sum to 1), - a
dictmapping UCI strings to probabilities, e.g.{"e2e4": 0.4, "d2d4": 0.6}, - a
dictmapping(from_sq, to_sq, promotion)tuples to probabilities.
A value head takes a Board and returns a single float in [-1, 1],
evaluated from the side-to-move’s perspective (+1 = side to move is winning,
-1 = losing, 0 = drawn). This POV convention lets one value head serve both
colors.
Inside a head you mostly use: board.legal_moves() (a list of Move, each with
.from_sq, .to_sq, .promotion, and .uci()), board.fen(), and board.turn
("white" / "black").
These need no training and are handy for smoke-testing the whole pipeline:
from hanat import Game, MCTSEngine
# Uniform policy: every legal move equally likely.
def uniform_policy_head(board):
legal = board.legal_moves()
if not legal:
return []
p = 1.0 / len(legal)
return [p] * len(legal)
# Material value: crude centipawn balance squashed into [-1, 1], from the POV
# of the side to move.
_VAL = {"p": 1, "n": 3, "b": 3, "r": 5, "q": 9, "k": 0}
def material_value_head(board):
score = 0
for sq in range(64):
piece = board.piece_at(sq)
if not piece:
continue
v = _VAL[piece.lower()]
score += v if piece.isupper() else -v # white positive
if board.turn == "black":
score = -score # flip to side-to-move POV
return max(-1.0, min(1.0, score / 10.0))Wrap your trained models in the same callable shape. The key detail is the
policy head: score all moves, then mask to the legal set and softmax only over
those so the output stays aligned with board.legal_moves(). Here encode()
and the policy_net / value_net modules come from your training notebook (see
train.py for the encoding plumbing):
import torch
def make_policy_head(policy_net):
policy_net.eval()
def policy_head(board):
legal = board.legal_moves()
if not legal:
return []
with torch.no_grad():
logits = policy_net(encode(board).unsqueeze(0)).squeeze(0) # [4096]
idx = [m.from_sq * 64 + m.to_sq for m in legal] # (from, to) index
return torch.softmax(logits[idx], dim=0).tolist() # aligned w/ legal
return policy_head
def make_value_head(value_net):
value_net.eval()
def value_head(board):
with torch.no_grad():
return float(value_net(encode(board).unsqueeze(0)).item()) # already tanh'd to [-1,1]
return value_headengine = MCTSEngine(
policy_head=uniform_policy_head, # or make_policy_head(policy_net)
value_head=material_value_head, # or make_value_head(value_net)
num_simulations=100, # search budget per move (higher = stronger, slower)
c_puct=1.414, # exploration constant
)
game = Game(engine=engine)
move = game.predict_next_move() # returns a Move (or None if game over)
print(move.uci())
game.push(move)find_elo() (on any engine) plays a match against Stockfish via cutechess-cli
and rates the result with ordo. It requires stockfish, cutechess-cli, and
ordo on PATH (all provided by setup.sh), and a reachable Stockfish binary
(set STOCKFISH_PATH or pass stockfish_path).
elo = engine.find_elo(
benchmark_level=0, # Stockfish Skill Level, 0-20 (0 = weakest opponent)
num_games=50, # total games played (rounds = num_games // 2)
concurrency=2, # parallel games
tc="40/10", # time control: 40 moves / 10 seconds
)
print(f"ELO: {elo}")The returned number is relative: Ordo anchors Stockfish at 0, so a positive
value means Hanat outscored that Stockfish skill level and a negative value means
it lost ground. Raise benchmark_level as the engine improves to keep the
opponent competitive.
The demo.py random baselines are the natural floor to compare against. Import
them (or paste the definitions) and run the same find_elo:
from hanat import MCTSEngine
from demo import random_policy_head, random_value_head
random_engine = MCTSEngine(
policy_head=random_policy_head, # softmax over random per-move scores
value_head=random_value_head, # uniform random in [-1, 1]
num_simulations=100,
)
baseline_elo = random_engine.find_elo(benchmark_level=0, num_games=50)
print(f"Random-head baseline ELO: {baseline_elo}")Run this once to establish your floor, then compare your trained heads’ ELO against it to confirm the networks are actually learning something.