| --- |
| license: mit |
| tags: |
| - chess |
| - character-level |
| - from-scratch |
| - text-generation |
| library_name: pytorch |
| pipeline_tag: text-generation |
| --- |
| |
| # chessLLM β 94.4M character-level chess model |
|
|
| A decoder-only transformer trained from scratch on Lichess games written in SAN |
| notation. It has never seen a board: no FEN, no piece list, no legal-move |
| generator. It reads the text of a game and predicts the characters that come next. |
| Everything it does with chess it inferred from move notation alone. |
|
|
| Trained on a single **RTX 4060 Laptop (8GB)**. |
|
|
| Its sibling is [**miniLLM**](https://huggingface.co/amanm10000/minillm) β the same |
| architecture and training code, trained on ordinary text instead of move notation. |
|
|
| ## Architecture |
|
|
| | | | |
| |---|---| |
| | Parameters | 94.4M | |
| | Layers | 15 | |
| | Attention | 12 query heads / 4 KV heads (GQA), head_dim 64 | |
| | d_model | 768 | |
| | FFN | SwiGLU, hidden 2048 | |
| | Position | RoPE, ΞΈ=10000 | |
| | Context | 1024 tokens | |
| | Vocab | **40** characters (frozen id map) | |
| | Norm | RMSNorm, QK-norm | |
| | Embeddings | Tied | |
| | Optimizer | Muon (matrices) + AdamW (rest) | |
| | Final val loss | **0.4150** at step 7505/7505 | |
|
|
| Llama-3-shaped, scaled to fit 8GB. |
|
|
| ## Input format |
|
|
| Games are plain characters, one game per line: |
|
|
| ``` |
| ;<20> e4 e5 Nf3 Nc6 Bb5 a6 ... |
| ``` |
|
|
| `;` starts a game, `<20>` is a rating bucket (mean player rating // 100, so `<15>` |
| through `<24>` = 1500β2499), moves are space-separated SAN, and a newline ends the |
| game. The 40-id vocabulary covers exactly the characters SAN needs. |
|
|
| ## Measured results |
|
|
| Honest numbers, including the ones that miss their targets. |
|
|
| | Metric | Measured | Target | | |
| |---|---|---|---| |
| | Legal moves (self-play) | **99.22%** over 11,216 moves | >99.5% | close | |
| | Mean survival before first illegal | **55.6 plies** | β | | |
| | Elo vs Stockfish | **~1131** (Β±103, 95%) | 1300β1600 | **misses** | |
| | Board probe (linear, layer 10) | **85.6%** vs 64.5% baseline | >90% | **misses** | |
|
|
| Stockfish match: 8 wins / 5 draws / 47 losses over 60 games, 17.5% score, plus 24 |
| illegal-move forfeits and 12 adjudicated resignations. |
|
|
| **The two misses are the interesting part.** 99.22% legality in self-play against |
| 40% of games forfeiting on an illegal move versus a real engine is not a |
| contradiction β it's the finding. Stockfish steers positions out of the training |
| distribution fast, and the model's internal board degrades as it goes. The linear |
| probe agrees: 85.6% per-square accuracy means a real but *lossy* board |
| representation, well above the 64.5% majority-class baseline yet short of a clean |
| one. Probe train/test are split on **whole-game** boundaries; splitting on random |
| positions inflates this number badly, since positions one ply apart are nearly |
| identical. |
|
|
| **Syntax is solved; state is not.** Of the 87 illegal moves in the legality run, 80 |
| (92%) are semantic β well-formed notation describing a move that is illegal in that |
| position, dominated by "piece cannot reach that square from where it is." Only 7 of |
| 11,216 are malformed SAN. So notation is right 99.94% of the time while |
| board-tracking is right 99.29% of the time: the residual error sits almost entirely |
| in the half of the task that requires a board. |
|
|
| The model also **stops playing when it is losing**, which it was never told to do. |
| Adjudicating all 88 self-terminated games with Stockfish: 55 justified (clearly lost |
| or facing forced mate), 25 genuinely terminal, 15 indefensible. Against a control of |
| random earlier plies from the same games, stop positions are a median **β616cp** |
| versus **β18cp** β the end token is conditioned on the position, not on elapsed |
| length. |
|
|
| Greedy decoding (temperature 0) is strictly worse: 54 samples collapsed to ~2 |
| distinct games, surviving 39 plies against 55.6 at temperature 0.7. Every one of |
| those failures was "piece cannot reach that square", with zero notation errors. |
|
|
| ## Usage |
|
|
| ```python |
| import torch |
| from model import MiniLLM, ModelConfig # from github.com/amanmprojects/llm |
| import chess_format as cf # from that repo's chess/ directory |
| |
| ck = torch.load("ckpt.pt", map_location="cpu", weights_only=False) |
| cfg = ModelConfig(**{k: v for k, v in ck["cfg"].items() |
| if k in ModelConfig.__dataclass_fields__}) |
| model = MiniLLM(cfg) |
| model.load_state_dict(ck["model"]) |
| model.eval() |
| |
| ids = [cf.GAME_START_ID] + cf.tokenize("<20> e4 e5") + [cf.SPACE_ID] |
| out = model.generate(torch.tensor([ids]), 200, temperature=0.7, |
| eos_id=cf.GAME_END_ID) |
| print(cf.decode(out[0].tolist())) |
| ``` |
|
|
| Full code, training pipeline, and evaluation harness: |
| **https://github.com/amanmprojects/llm** |
|
|
| This checkpoint is inference-only β optimizer state stripped (756 MB β 378 MB). |
|
|
| ## Limitations |
|
|
| - **Needs move history, not a position.** A FEN is meaningless to it. It cannot be |
| handed a puzzle or resumed from an arbitrary board. |
| - **Proposes illegal moves**, more often as games get longer or stranger. Always |
| validate with a real move generator; treat retries as expected. |
| - **Sometimes stops mid-game**, emitting its end-of-game token in live positions β |
| occasionally while winning. |
| - **~1131 Elo**: beginner-to-casual club strength. |
| - **Rating-bucket conditioning does not control strength.** This was tested: 40 games |
| per bucket vs Stockfish gave `<15>` and `<20>` identical records (5W-3D-32L, ~1115 |
| Elo) and `<24>` slightly worse (3W-3D-34L, ~1041), all inside Β±126. The tag shifts |
| style at most. Do not use it as a difficulty setting. |
|
|
| ## Data |
|
|
| Filtered Lichess games (SAN, rating buckets 1500β2499), from the public |
| [Lichess database](https://database.lichess.org/). Weights are MIT-licensed; the |
| underlying games are Lichess users'. |
|
|