Downloads · 30 days
23
17% of all-time downloads
peargentlabs/otter-chess
otter-chess is a machine learning model from peargentlabs. Use it for the machine learning task on the model card, and read the license before you ship it in a product. It is set up for transformers. The card lists the license as mit.
Otter is a 15.3M parameter neural network trained to predict the moves of chess players at specific skill (Elo) levels. It is conditioned jointly on the game board state, move history, time control, remaining clock ti…
Downloads · 30 days
23
17% of all-time downloads
All-time downloads
133
Public
Parameters
15.3M
36.8 GB on disk
Likes
2
Public
Click a slice to open those files.
.safetensors36.8 GB · 100%
How the weights are stored.
F3215.3M · 100%
From the Hugging Face model README
Otter is a 15.3M parameter neural network trained to predict the moves of chess players at specific skill (Elo) levels. It is conditioned jointly on the game board state, move history, time control, remaining clock time, and the Elo ratings of both players.
In addition to move prediction (policy head), Otter is trained multi-task style to predict game outcomes (value head) and move metadata (auxiliary head) such as piece types, captures, check status, and origin/destination squares.
model.safetensors) +-----------------------+ +---------------------------+
| Board Position FEN | | Last K Moves (K=20) |
| (18 x 8 x 8 Tensor) | | (Canonical UCI Strings) |
+-----------+-----------+ +-------------+-------------+
| |
v v
[ 4x ResNet Blocks ] [ 2-Layer Transformer ]
| |
v v
[ Board Tokens ] [ History Tokens ]
(64 x 256 Dim) (20 x 256 Dim)
| |
+--------------+-----------------+
|
v
[ Cross-Attention Fusion ]
|
v
[ Skill-Conditioned Self-Attn ] <--- [ 640d Conditioning Vector ]
(4 blocks, conditioned on Elo, (Elo, Opponent Elo,
TC, Clock, and Pooled History) TC, Clock, Pooled History)
|
v
[ Global Average Pooling ]
|
+--------------+--------------+
| | |
v v v
[Policy] [Value] [Auxiliary]
(Move) (Outcome) (Metadata)
Intended uses: player modeling / style emulation at a target Elo, move recommendation for training or commentary, win-probability estimation conditioned on both players' ratings and clock time.
Limitations: trained on rated Rapid games from Lichess — move distributions may not transfer well to Blitz/Bullet/Classical. Predicts human-like move probabilities, not optimal/engine-strength play.
Validation (Jan 2025): Top-1 accuracy 55.2%, Top-5 accuracy 91.0% (mean across Elo buckets, 100,000 validation samples per bucket).
| Rating | Top-1 Accuracy | Top-5 Accuracy |
|---|---|---|
| < 1100 | 49.48% | 86.08% |
| 1100–1199 | 53.54% | 89.67% |
| 1200–1299 | 54.61% | 90.28% |
| 1300–1399 | 55.19% | 90.75% |
| 1400–1499 | 55.29% | 90.98% |
| 1500–1599 | 55.39% | 91.04% |
| 1600–1699 | 56.59% | 91.94% |
| 1700–1799 | 56.46% | 92.11% |
| 1800–1899 | 56.85% | 92.25% |
| 1900–1999 | 57.38% | 92.85% |
| ≥ 2000 | 56.80% | 92.49% |
Moves are UCI strings mapped to integer IDs via vocab.json, shipped in this repo:
vocab["history"] — maps prior moves in the game window to embedding IDs (4,209 entries, PAD = 0).vocab["policy"] — maps policy-head output indices back to UCI moves (4,208 entries).Indices are shifted by 1 relative to each other:
historyreserves0forPAD(e.g."a1a2"→1), whilepolicyindexes moves directly from0(e.g."a1a2"→0).
import json
with open("vocab.json") as f:
vocab = json.load(f)
history_vocab = vocab["history"]
policy_vocab = vocab["policy"]
id_to_policy_move = {v: k for k, v in policy_vocab.items()}
history_vocab.get("e2e4", 0) # tokenize for history input
id_to_policy_move.get(277) # decode a policy-head prediction
otter-chess package — Recommended)The easiest and most reliable way to use Otter is via the official otter-chess Python package. OtterModel automatically handles board state tensor encoding, move canonicalization for White/Black perspectives, history window tracking, legal move masking, and output decoding.
pip install otter-chess
# or install from GitHub source:
# pip install git+https://github.com/PeargentLabs/otter-chess.git
from otter import OtterModel
# Initialize model (automatically downloads and caches weights from Hugging Face if not cached)
model = OtterModel(device="cpu")
# --- Example position: after 1. e4 e5, White to move, 1500 vs 1600, 10+0 time control ---
res = model.predict(
fen="rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2",
player_elo=1500,
opponent_elo=1600,
history_moves=["e2e4", "e7e5"],
time_control="600+0",
clock_fraction=0.5,
top_k=5,
)
print(f"Predicted Win Probability: {res['win_probability']:.3f}")
print("\nTop Move Predictions:")
for m in res["moves"]:
print(f" {m['move']:<6} | Probability: {m['probability']:.1%}")
print("\nAuxiliary Head Predictions:")
aux = res["aux_predictions"]
print(f" Moving Piece: {aux['moving_piece']} ({aux['moving_piece_confidence']:.1%})")
print(f" From Square: {aux['from_square']} ({aux['from_square_confidence']:.1%})")
print(f" To Square: {aux['to_square']} ({aux['to_square_confidence']:.1%})")
print(f" Check Prob: {aux['results_in_check_probability']:.1%}")
fastchess UsageIf you prefer to load weights directly without installing the otter-chess package, you can interface with the PyTorch model tensors using fastchess and vocab.json.
pip install transformers torch fastchess huggingface_hub
import json
import torch
import fastchess
from transformers import AutoModel
from huggingface_hub import hf_hub_download
REPO_ID = "peargentlabs/otter-chess"
HISTORY_K = 20
# Load model and vocabulary
model = AutoModel.from_pretrained(REPO_ID, trust_remote_code=True)
model.eval()
with open(hf_hub_download(REPO_ID, "vocab.json")) as f:
vocab = json.load(f)
history_vocab = vocab["history"]
policy_vocab = vocab["policy"]
id_to_move = {v: k for k, v in policy_vocab.items()}
# Perspective helpers
def mirror_square(sq: str) -> str: return f"{sq[0]}{9 - int(sq[1])}"
def mirror_move(mv: str) -> str: return f"{mirror_square(mv[:2])}{mirror_square(mv[2:4])}{mv[4:]}"
def canonicalize_move(mv: str, turn: int) -> str: return mirror_move(mv) if turn == fastchess.BLACK else mv
def elo_to_bucket(elo: int) -> int:
if elo < 1100: return 0
if elo >= 2000: return 10
return 1 + (elo - 1100) // 100
def time_control_to_bucket(tc: str) -> int:
if not tc or "+" not in tc: return 4
try:
base, inc = map(int, tc.split("+"))
eff = base + 40 * inc
if eff < 60: return 0
if eff < 180: return 1
if eff < 600: return 2
if eff < 1800: return 3
return 4
except Exception: return 4
# Position setup
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2"
history_moves = ["e2e4", "e7e5"]
player_elo, opponent_elo_val = 1500, 1600
time_control = "600+0"
clock_fraction = 0.5
board_obj = fastchess.Board(fen)
turn = board_obj.turn
# 1. Canonicalize move history
canonical_history = []
temp_board = fastchess.Board()
for m in history_moves:
orig_turn = temp_board.turn
try:
temp_board.push_uci(m)
canonical_history.append(canonicalize_move(m, orig_turn))
except Exception:
canonical_history.append(canonicalize_move(m, fastchess.WHITE))
# 2. Prepare board & history tensors
board = torch.from_numpy(board_obj.to_tensor(canonical=True)).unsqueeze(0)
history_ids = torch.zeros(1, HISTORY_K, dtype=torch.long)
history_mask = torch.zeros(1, HISTORY_K, dtype=torch.bool)
window = canonical_history[-HISTORY_K:]
start = HISTORY_K - len(window)
for i, move in enumerate(window, start=start):
history_ids[0, i] = history_vocab.get(move, 0)
history_mask[0, i] = True
active_elo = torch.tensor([elo_to_bucket(player_elo)])
opponent_elo = torch.tensor([elo_to_bucket(opponent_elo_val)])
tc = torch.tensor([time_control_to_bucket(time_control)])
clock = torch.tensor([[clock_fraction, 0.0]])
# 3. Model forward pass (returns tuple: policy_logits, aux_logits, value_pred)
with torch.no_grad():
policy_logits, aux_logits, value_pred = model(
board, history_ids, history_mask, active_elo, opponent_elo, tc, clock
)
# 4. Legal move masking & output un-canonicalization
legal_mask = torch.zeros(4208, dtype=torch.bool)
for move_uci in board_obj.legal_moves_uci():
canon = canonicalize_move(move_uci, turn)
if canon in policy_vocab:
legal_mask[policy_vocab[canon]] = True
probs = torch.softmax(policy_logits[0].masked_fill(~legal_mask, -1e9), dim=-1)
top_probs, top_ids = torch.topk(probs, k=5)
print(f"Win Probability: {value_pred.item():.3f}")
for p, i in zip(top_probs.tolist(), top_ids.tolist()):
move_canon = id_to_move[i]
real_move = mirror_move(move_canon) if turn == fastchess.BLACK else move_canon
print(f" {real_move}: {p:.3f}")
If interfacing with raw model tensors directly, inputs are structured as follows:
| Name | Shape | Dtype | Meaning |
|---|---|---|---|
board | (batch, 18, 8, 8) | float | Board position, 18-plane canonical encoding |
history_ids | (batch, 20) | long | Last 20 moves as canonical vocab ids (0 = PAD) |
history_mask | (batch, 20) | bool | True where history_ids is a valid move |
active_elo | (batch,) | long | Elo bucket of player to move, 0–10 |
opponent_elo | (batch,) | long | Elo bucket of opponent, 0–10 |
tc | (batch,) | long | Time-control bucket, 0–4 |
clock | (batch, 2) | float | [clock_fraction_remaining, 0.0] |
@software{otter_chess_2026,
author = {Tarun and Peargent Labs},
title = {Otter: A Time-Aware, History-Conditioned Human Chess AI},
year = {2026},
url = {https://github.com/peargentlabs/otter-chess},
version = {0.1.0}
}