Otter: Skill-Conditioned Chess Move Prediction Model

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 Details

  • Developed by: Peargent Labs
  • Model Type: Hybrid CNN + Transformer with Cross-Attention Fusion
  • License: MIT
  • Weights Format: Safetensors (model.safetensors)
  • GitHub Repository: PeargentLabs/otter-chess
  • W&B Report: Otter-3M-Run
  • Paper: (to be added)

Architecture Highlight

                  +-----------------------+      +---------------------------+
                  |  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)
  • Board Encoder: CNN backbone with 4 residual blocks, 2D factored position embeddings (rank + file), and channel-wise dropout.
  • History Encoder: A 2-layer Transformer encoder processing the last $K=20$ moves.
  • Conditioning Module: A 640-dimensional vector concatenating embeddings of active/opponent Elo ($11$ bins each), time control category ($5$ bins), remaining clock fraction (2-layer MLP), and mean-pooled history representations.
  • Attention Fusion: One skill-conditioned cross-attention layer followed by four skill-conditioned self-attention blocks.
  • Heads:
    • Policy Head: $4208$-dim output over all canonical UCI moves.
    • Value Head: Tanh projection to $[-1, +1]$ (predicted game outcome).
    • Auxiliary Head: $141$-dim multi-hot output (piece type moved, captures, check status, origin/destination squares).

Intended Uses & Limitations

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.


Training Data & Methodology

  • Source: Lichess open database monthly PGN dumps.
  • Split: Train on Jan–Dec 2024 (Rated Rapid), validate on Jan 2025.
  • Optimizer: AdamW, weight decay $1\times10^{-5}$.
  • LR schedule: peak $1\times10^{-4}$, linear warmup (10% of steps), cosine decay to $1\times10^{-6}$.
  • Loss weights: Policy 1.0, Value 0.25, Auxiliary 0.5.

Performance

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%

Move Vocabulary

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: history reserves 0 for PAD (e.g. "a1a2" β†’ 1), while policy indexes moves directly from 0 (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

How to Use

AutoModel gives you the raw network β€” tensors in, tensors out. Inputs:

Name Shape Dtype Meaning
board (batch, 18, 8, 8) float Board position, 18-plane encoding
history_ids (batch, 20) long Last 20 moves as vocab ids (0 = PAD)
history_mask (batch, 20) bool True where history_ids is a real move
active_elo (batch,) long Elo bucket of the player to move, 0–10
opponent_elo (batch,) long Elo bucket of the opponent, 0–10
tc (batch,) long Time-control bucket, 0–4
clock (batch, 2) float [clock_fraction_remaining, 0.0]

Quick smoke test (no chess logic, just shapes)

pip install transformers torch
import torch
from transformers import AutoModel

model = AutoModel.from_pretrained("peargentlabs/otter-chess", trust_remote_code=True)
model.eval()

board = torch.zeros(1, 18, 8, 8)
history_ids = torch.zeros(1, 20, dtype=torch.long)
history_mask = torch.zeros(1, 20, dtype=torch.bool)
active_elo = torch.tensor([5])
opponent_elo = torch.tensor([5])
tc = torch.tensor([2])
clock = torch.tensor([[0.8, 0.0]])

with torch.no_grad():
    output = model(board, history_ids, history_mask, active_elo, opponent_elo, tc, clock)

print(output.policy_logits.shape)  # (1, 4208)
print(output.aux_logits.shape)     # (1, 141)
print(output.value.shape)          # (1,)

Full example: FEN in, ranked moves out

Uses fastchess (the board library this model was trained with) to build the real board tensor and legal-move mask, and vocab.json (see above) to encode/decode moves β€” no otter-chess package required.

pip install transformers torch fastchess
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

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


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(time_control: str) -> int:
    base, inc = map(int, time_control.split("+"))
    effective = base + 40 * inc
    if effective < 60:
        return 0
    if effective < 180:
        return 1
    if effective < 600:
        return 2
    if effective < 1800:
        return 3
    return 4


# --- Example position: after 1. e4 e5, White to move, 1500 vs 1600, 10+0, half the clock left ---
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2"
history_moves = ["e2e4", "e7e5"]  # UCI, oldest -> most recent
player_elo, opponent_elo_val = 1500, 1600
time_control = "600+0"
clock_fraction = 0.5

board_obj = fastchess.Board(fen)
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 = history_moves[-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]])

with torch.no_grad():
    output = model(board, history_ids, history_mask, active_elo, opponent_elo, tc, clock)

# Mask illegal moves, then rank the legal ones
legal_mask = torch.zeros(4208, dtype=torch.bool)
for move_uci in board_obj.legal_moves_uci():
    if move_uci in policy_vocab:
        legal_mask[policy_vocab[move_uci]] = True

probs = torch.softmax(output.policy_logits[0].masked_fill(~legal_mask, -1e9), dim=-1)
top_probs, top_ids = torch.topk(probs, k=5)

print("win_probability:", output.value.item())
for p, i in zip(top_probs.tolist(), top_ids.tolist()):
    print(f"{id_to_move[i]}: {p:.3f}")

trust_remote_code=True pulls the model's custom architecture code from this repo β€” no separate package install required.


Citation

@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}
}
Downloads last month
35
Safetensors
Model size
15.3M params
Tensor type
I64
Β·
F32
Β·
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support