chess-policy-net
A 5.58M-parameter policy+value transformer that plays chess from a single forward pass β no search. Trained by supervised learning on human games from the January 2023 Lichess dump.
It picks moves the way a strong-ish club player picks them before calculating: by pattern. Given a position it emits a distribution over the 4,672-slot move space, illegal slots are masked out, and the argmax is played. One position is examined per move, so it has no tactical lookahead at all β that is the main thing to know about its playing strength.
Results
Scored on a held-out 13,000-position split (1Ο on top1 β 0.43pp):
| metric | value |
|---|---|
| top-1 move agreement | 43.1% |
| top-1, independent 3,000-position holdout | 43.7% |
| legal-move rate | 100.0% |
| cross-entropy | 1.957 |
| value head MSE | 0.75 |
Playing strength against Stockfish at fixed Skill Levels, 24β30 games each, fast time control:
| opponent | score | est. Elo | 95% CI |
|---|---|---|---|
| Stockfish β1320 | 8.5/30 (28%) | 1159 | 1024β1293 |
| Stockfish β1400 | 7.5/30 (25%) | 1209 | 1069β1349 |
| Stockfish β1500 | 1.0/30 (3%) | 915 | 610β1220 |
Treat this as a band of roughly 1150β1200 Elo, not a rating. The samples are small, the anchors are approximate, and a searchless engine's results are noisier than the CIs suggest. The sharp fall-off at 1500 is what a no-lookahead model looks like when the opponent starts setting two-move traps.
Files
| file | what it is |
|---|---|
ckpt.pt |
the checkpoint (67MB) β weights, AdamW state, step, best_acc |
model.py |
ChessNet definition |
features.py |
board encoding and the 64Γ73 move-slot mapping |
model.onnx |
the same weights exported to ONNX for in-browser play |
ckpt.pt keeps the optimizer moments so training can be resumed from it, which
is why it is 67MB rather than the ~22MB the weights alone need. model.onnx
is regenerated from ckpt.pt by hf/export_onnx.py in the source repo and is
verified to produce identical moves (max |Ξlogits| < 1e-4).
Play it in the browser
The chess UI is a static HuggingFace Space β no Python involved. model.onnx
runs inside a Web Worker via onnxruntime-web (WebAssembly):
huggingface.co/spaces/amanm10000/chess-bot
Pick Neural net in the level dropdown. The feature encoding and move-slot
mapping are ported to JS in the Space's src/neural.js, pinned by tests
against python-chess fixtures.
Usage
Needs torch and python-chess. features.py is not optional β it holds the
exact encoding the weights were trained against, and any mismatch produces
legal but meaningless moves.
import chess, torch
from huggingface_hub import hf_hub_download
from model import ChessNet # from this repo
from features import board_to_features, policy_mask
path = hf_hub_download("amanm10000/chess-policy-net", "ckpt.pt")
ckpt = torch.load(path, map_location="cpu", weights_only=False)
net = ChessNet(d=256, n_layers=7, n_heads=8)
net.load_state_dict(ckpt["model"])
net.eval()
board = chess.Board()
pieces, aux = board_to_features(board)
with torch.no_grad():
logits, value = net(torch.from_numpy(pieces).long()[None],
torch.from_numpy(aux).long()[None])
# Mask illegal slots before the argmax. Keep this in float32: -1e9 overflows
# float16, which torch raises on rather than saturating.
mask = torch.from_numpy(policy_mask(board))
masked = logits[0].float().masked_fill(~mask, -1e9)
slot = int(masked.argmax())
print(slot, float(value))
To turn a slot back into a move, scan the legal moves for the one whose
move_to_slot(board, move) matches β the mapping is a bijection over legal
moves, so exactly one matches.
Architecture
64 square tokens + 3 aux tokens (side to move, castling rights, en-passant file)
67 learned position vectors
7 pre-norm transformer blocks, d=256, 8 heads, MLP Γ4
policy head: shared Linear(d -> 73) per square -> 4672 logits
value head: mean-pool over squares -> Linear -> tanh
Move encoding (73 labels per from-square, AlphaZero-style): 56 queen slides (8 directions Γ 7 distances), 8 knight jumps, 9 underpromotions (3 destination files Γ N/B/R). Castling is a king slide of 2, en passant is a pawn diagonal of 1, and queen promotion is an ordinary slide β so those need no special labels.
Board encoding: one uint8 piece id per square (0 empty, 1β6 white PNBRQK, 7β12 black), row-major from a8 to h1.
Training
| data | lichess_db_standard_rated_2023-01, 9.1M games β 2.6M positions |
| filters | both players β₯1800 Elo, base time β₯180s, Termination=Normal, 12β250 moves |
| split | 2,587,000 train / 13,000 val |
| optimizer | AdamW, cosine LR, AMP, batch 1024 |
| hardware | one RTX 4060 |
| shipped step | 40,000 (~16 epochs) of 55,572 trained |
The final checkpoint is not the shipped one. Training ran 22 epochs and the
model overfit from roughly epoch 10 onward: train CE fell 1.73 β 1.16 while
held-out val CE rose 1.847 β 2.16. Step 40,000 is the peak; the step-55,572
final weights score 1.6pp worse. best_acc gating is the only reason the good
weights survived.
Steps 24,000 β 40,000 bought +0.66pp top-1 while CE got 0.057 worse β about 90 minutes of GPU time for nearly nothing. On this architecture the real lever is more unique positions, not more epochs: 2.6M records is only ~4.7% of a single month of Lichess.
Limitations
- No search. It will hang pieces to any two-move tactic. Pairing the policy with even shallow alpha-beta would be worth more than any amount of extra training at this scale.
- Imitation, not strength. The objective is "what would a 1800+ human play here", so it inherits human habits, including bad ones, and cannot exceed the data.
- The value head is weak (MSE 0.75 against Β±1 targets). It is useful as an eval-bar hint and little else.
- Trained only on standard chess from one month of one site. No Chess960, no endgame tablebases, no opening book.
Source
Training pipeline, evaluation, and the Elo harness: github.com/amanmprojects/chess-bot