Skip to content

abhichavali/Hanat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hanat (ハナツ)

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.

The Core Challenge

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.

Architecture & Data Strategy

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.

Getting Started

Installation & Requirements

Hanat requires Python 3.10 or newer.

To set up the project locally:

  1. Clone the repository and navigate to the project root.
  2. 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.)

Compiling the Native Backend (Optional but Recommended)

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 --inplace

The engine automatically detects and falls back to the pure-Python implementation if the compiled extension is not found.

Running Tests

To verify the installation and run the move generation verification suite:

python3 -m unittest discover tests

Running Training

To train the GNN outcome model on a PGN dataset:

python3 train.py --data data/twic1646.pgn --max-games 3000 --epochs 5

Setup on a GPU box (vast.ai)

For 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.sh

After 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)

Usage Guide: Heads, Search, and ELO

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.

The head interface

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 with board.legal_moves() (should sum to 1),
  • a dict mapping UCI strings to probabilities, e.g. {"e2e4": 0.4, "d2d4": 0.6},
  • a dict mapping (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").

Declaring simple (hand-written) heads

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))

Declaring neural-network heads

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_head

Building the engine and playing

engine = 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)

Measuring ELO

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.

Benchmarking the default random heads

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.

About

Data Efficient Chess Engine Challenge

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors