Compressing chess moves for fun and profit
mbuffett.com
mbuffett.com
But one can do better on average, by assuming that moves are not uniformly distributed. Let an engine assign each legal move m some probability p of being played. The worse the move, the lower the probability we assign to it. Then a more optimal code for move m is -log p bits, corresponding to an entropy (expected surprise) of sum -p log p bits for the distribution over legal moves.
Related: accurately estimating the number of legal positions of chess [1].
Building machine learning systems is vastly different from building operating systems which is vastly different from embedded systems which is vastly different from networking which is vastly different what most of us do (myself included), which is building CRUD apps. We're just solving different problems. Of course there are common themes to writing good code, just like there are common themes to performing science, but the code and solutions will look almost nothing alike.
I think everyone's optimizing the wrong thing! The size of a useful, real-world chess database would be dominated by its search indexes—indexes that allow fast, random-access lookup of every position that occurs in every game. (Or even better: fuzzy searching for nearby, "similar" positions). This is the difficult, open-ended algorithmic problem. Disk space is cheap!
(Out of curiosity, does anyone here actually know how to solve the "fuzzy search of chess positions" problem?)
I don't know any good, fast heuristics for predicting this (likelihood of a human choosing a specific chess move). Do you? Chess engine evaluation is computationally quite heavy.
U could also play two or so turns of minimax, or perhaps use a neural network to evaluate the various reachable board states.
So for a given state, enumerate possible transitions, score the resulting states, and map to some sort of probability distribution, then use some prefix code (think Huffman tree[+]) based on the distribution of probabilities to encode the transition.
It’s perhaps not super fast, and not super accurate but if you can weed out the 50% of dumb moves, that already saves a bit.
[+] an easier and better approach is to map the probabilities into an interval between 0 and 1, and keep using fractional bits to „zoom in“ until one of the subintervals is uniquely defined, then recurse on that. Some of the common compression algorithms uses that (but I don’t remember the specifics, those intro courses were a long time ago).
So probably some kind of tree structure so you can look up games by sequence of moves since opening. And something like zobrist hashing for looking up a given position (regardless of sequence).
Some of these ultra-small encodings would be counterproductive for this purpose.
Which gets particularly good if you combine it with some hack like a Bloom filter. There's a trick, I don't remember if it has a name, where you can effect a time/space tradeoff by partitioning a search set and building a separate Bloom filter for each partition. A lookup is a constant-time query of each Bloom filter, plus a linear scan of each partition that signaled a match.
(This is a reasonable thing to do, because (0) the size of chess databases can get extremely large and (1) the queries are human UX interactions that need to be responsive).
Typically that's going to be the best way to index into full games. Since you can point to an entry via a game tree search (eg; each node of the tree is a position that you can use as an index to a big transposition table).
You can probably use a bloom filter to check if a given position has ever been encountered (eg; as a layer on top of the transposition table) though. For something like storing 10 billion positions w/ a 10^-6 false positive probability (w/ reasonable number of hashes) you're looking at 30+GiB bloom filters.
When I implemented this, I deliberately avoided constructing the hash table you're describing, because (to my recollection) it was larger than my remaining disk space! :)
A classification based indexing would help here. The data points of the index would be not just the position of the pieces, but their relative as well. Things like the squares under attack, pinned weaker pieces, and maybe some evaluation of control of the board (again maybe via some attacking based metric).
This seems like the kind of thing that could be done by analyzing a larger dataset of games to create a set of functional categorizations.
Once the data points themselves have been identified, actually indexing them is the easy part.
Solving this more generally for the “find me games like X…” is much trickier without locking down that X to something concrete.
This version is easy to formalize: it's just querying a bitstring x \in {0,1}^N, a bitboard vector representation of a chess position, and asking if there's any other needles within a short Hamming distance. I haven't a clue how to solve that (or if it's solvable at all).
It only has to be accurate enough to be beneficial though. Even a knowledge about two sets of more frequent and less frequent moves would be much better than the baseline.
This is the thing the parent comment was referring to,
This is the same as Huffman coding, just applied to a different domain.
For that matter, it might also resemble LLM compression of input text, although LLM compression is intentionally lossy and the above schemes are not.
In short, you put the most likely or most common values at the front of the dictionary. Values 0-255 take up one byte, 256-65535 take two bytes, etc. The lower your index, the fewer bits are needed to represent the corresponding state.
A simplified example: suppose you have four letters in your alphabet that you want to represent in binary. You can't just represent these as A=0, B=1, C=10, and D=01, since it's impossible to tell whether "01011" is meant to represent ABABB or DDA or ACBB.
(Hamming codes on the other hand are intended for error correction, where you want maximally distant code words to minimize the chance that bitflips cause decoding errors.)
That's really a problem inherent to binary streams in general, not just Huffman encoding.
> Values 0-255 take up one byte, 256-65535 take two bytes
If you wanted to encode more than 256 values, then at best you'd be able to specify values 0-254 taking one byte, e.g. if you used 0xff as a special prefix to represent "use another byte for all other values", but that particular encoding means that you'd only be able to encode another 256 values with a second byte.
https://lichess.org/@/lichess/blog/developer-update-275-impr...
https://www.chessprogramming.org/Encoding_Moves sort-of mentions it (search for 5.11), but I can't for the life of me find the actual analysis.
(My implementation: https://incoherency.co.uk/chess-steg/ and explanation at https://incoherency.co.uk/blog/stories/chess-steg.html )
I'm surprised stalemate detection is harder than checkmate detection. Aren't they the same except checkmate has the extra step of detecting check?
Stalemate can and almost always does involve pieces that can't move because they would create check.
This assumes you're storing moves of players who are good at chess!
Anyway, I wrote it in the form of a joke, but I was trying to suggest that for optimal compression, you may need to model how a player actually plays, not how they should ideally do it.
If they’re storing chess games, why do they try to compress individual moves?
If you compress games, you can get much better compression. For example, in any board position, deterministically order all legal moves (they’re already ignoring some illegal moves in their setup, so I think ignoring all is within the rules of the game), and write down the number of the move made.
At game start, that will be an integer between 1 and 20 (16 different pawn moves, 4 different knight moves). Black’s reply similarly will be an integer between 1 and 20. For white’s second move, the maximum will depend on the first move.
To compress an entire game, use arithmetic encoding (https://en.wikipedia.org/wiki/Arithmetic_coding). If there are, on average, n legal moves per position, that will use 2log n bits per move.
https://www.chessprogramming.org/Chess_Position: “The maximum number of moves per chess position seems 218”, so that will give you less than 8 bits per move. In real life, it probably will be less than 6.
The only reason I see why this wouldn’t be an option is performance. Decoding would require move generation and thus be more complex.
To improve on that, make a deterministic predictor of how likely moves are and use that to improve on the arithmetic encoder. That probably isn’t worth it, though because of the increased decoding complexity.
You’re spot on about the performance reason I didn’t want to do this originally, but I did some testing and turns out move generation in the library I use is Blazing Fast and wouldn’t be a bottleneck
That you only got to about 10-12 bits per move is actually kind of sad in a way, because it means you're not doing substantially better than the approach where you record a move as just (delete-piece-at ____, recreate-that-piece-at ____) 12-bit pairs, where castles are implicitly only recorded by moving the king further than usual and underpromotion has to be explicitly recorded with an extra 12-bit move that is otherwise semantically impossible.
<title>Posts on A blog</title>
<link>https://mbuffett.com/posts/</link>
<description>Recent content in Posts on A blog</description>[1] Fabian Giesen's sample implementation is already as good as is, and also contains a good alias table implementation if you want multi-symbol inputs: https://github.com/rygorous/ryg_rans/blob/master/rans_byte.h
And offer draw.
And resign.
Just to complete that thought.
Compressing Chess Moves Even Further, To 3.7 Bits Per Move
https://mbuffett.com/posts/compressing-chess-moves-even-furt...
Each move only needs four bits to identify the piece because each side only has 16 pieces at maximum. The game replayer would need to keep track of each pawn's starting file though (including after promotions).
Variable-length encodings of the move options help a lot. Pawns only need two bits because there are never more than four legal moves for a pawn - except for promotions, but just handle those specially for those exact cases (if a pawn moves to the last rank then encode the promotion with two bits, otherwise encode the next move). Knights and kings each need three bits for the move - encode each king-castling option as a move to the appropriate rear diagonal (normally off the board so not otherwise legal). Bishops and rooks need four bits (rank and direction). Queens only need five (three bits for rank, two for direction).
This way you can get down to between six and nine bits per move.
That seems unnecessary. Don’t index the pieces based on their type and where they started — index them based on their current location. So the piece the lexicographically first (rank, file) is piece 0, and 4 bits trivially identifies a piece once you know the color.
But if you allow board state, you can do less than 14. 3 bits for the type of piece, 6 for the destination, 2 extra bits. (Promotion if destination is last/first line, disambiguation for pawns in en passant)
You're still at about twice the theoretical minimum, I think. I vaguely recall an upper bound of 6 bits?
I think my initial stab would be to encoding the source position (6 bits) and end position (6 bits) for a constant 12 bits per move.
You don't need to store whether it's a capture, you can figure that out from the game state. You don't need to store disambiguations, there are none in that format. You don't need to store check/mate, you can figure that out from the game state.
The only wrinkles (that I can tell) are castles and promotions. But you can get around this by the fact that kings and pawns have limited movement, so their legal destination squares are highly constrained.
So you could signal a promotion by encoding the destination with opposite rank, and using the file to encode which piece. Promoting to a rook on c8 gets its destination encoded as a1 - "a" for a rook, and "1" to indicate a promotion.
Similarly, you could encode a castle by encoding the king's move with opposite destination rank. Castling the king to f1 gets encoded as f8, and to g1 as g8.
Encode a white's move exd8=N with white pawns on e7 and c7 and three black queens on f8, d8 and b8.
Ooh, didn't think of that, thanks. Still there's enough constraint in the legal destination squares to work around that. There's at least half the board that's inaccessible to a pawn about to promote, which should be enough to encode any of the 3 legal destination squares and 5 possible promotion targets.
Edit: maybe keep the destination file as-is, and use the destination rank to encode the promotion piece?
> Additionally, you could have two pawns promoting on the same square
The source square should disambiguate between those though, right?
Source position -> 6 bits destination position as ( forward left / forward / forward right -> 2 bits Target piece info Queen/Bishop/Knight/Rook 2 bits )
You are overcomplicating this. For castling just record it as the King's source and destination. E.g., White kingside castling is e1g1, White castling queenside is e1c1, Black castling kingside is e8g8, and white castling queenside is e8c8.
All king moves other than castling move the king at most one file, so when you see a king on e1 and the move is e1g1 or e1c2 which is a move of two files you can infer that it must be castling.
For promotion, I suggest splitting it into two cases: promotion to a queen and promotion to something else. I saw a post once on Reddit from someone who analyzed promotions from all games in the Lichess database, and 98.7% were to queens, so we'll make that case the simplest.
I suggest that pawns that promote to a queen are simply recorded as moving to the promotion square. It is implicit that they promote to a queen. For example a pawn at b7 that moves straight forward and promotes to a queen would be recorded as b7b8. A pawn at b7 that captures on a8 and promotes to a queen would be recorded as b7a8.
For pawns that promote to a rook, record the move as a move to a square one rank back from the promotion square. For example b7b8 promoting to rook would be recorded as b7b7, and b7a8 promoting to a rook would be recorded as b7a7.
Similarly for promotions to bishops. Record the destination two ranks back. So b7b8 promoting to bishop would be b7b6. Similar for knights but three ranks back, so b7a5 for a pawn at b7 that captures on a8 and promotes to a knight.
Yeah. For some reason I had a brain fart and thought that the two castles moved the king 1 and 2 files, instead of 2 and 3 files, and that made me think you needed to disambiguate a 1 file castle with a 1 file move.
Which is clearly dumb. I blame insufficient coffee.
Or, better, get a ranked list of all possible moves from stockfish, and use a variable-length integer to encode the position in the list. Then the best move takes ~1 bit, and worse moves take more bits. (And we can do fun things like compute how bad a player is by how big their game is after compression.)
I suspect the sweet spot here would be to use a much worse chess engine for the predictions, giving faster compression/decompression at the expense of the compression ratio.
This ties the algorithm down to one specific version of Stockfish, and configured identically (stuff like the hashtable size etc.), because all such factors will have an impact on Stockfish's evaluations. One factor changes, and you can't decompress the backup.
https://github.com/diku-dk/openbanko
Among other things, contains:
Theoretically optimal compression of excessive amounts of bingo cards
GPU-accelerated massively parallel bingo simulation using Futhark
I suspect the research was triggered by a late professor of Computer Science who unironically calculated how many (Danish) bingo cards there are: https://sprutskalle.dk/blog/wp-content/uploads/bankoplader.p... -- to see the mathematical rigour played out on such a casual question is nothing but inspirational.
On a single game? I don’t think the goal is to pack all plays together and unpack them every time. Every game must be individually accessible as I understand.
Huffman (and even more so arithmetic encoding or ANS (asymmetric numeral systems [0]) would be significantly better, if you're careful how you encode data.
[0]: https://en.wikipedia.org/wiki/Asymmetric_numeral_systems
Basically I demonstrate how you can compress to much less than one byte per move, but settle instead on a one byte per move scheme that is also very performant, something you'd have to sacrifice for optimal compression. I used this one byte representation to good effect in my chess GUI Tarrasch https://triplehappy.com
https://lichess.org/@/lichess/blog/developer-update-275-impr...
But really the whole thing is silly. 100 million games is a lot of chess, but at 1KB per "inefficient" PGN you are talking a whopping 100GB--big deal. (At 12 bits and, say, 80 half-moves on average, you are talking ~12GB.) Plus the article says that he is "IO-constrained" but shrinking game size isn't going to help with random lookups--a PGN is already below the page size.
If you really did care about best compression you would simply assign all legal moves an index and use a heuristic (chess engine) to figure out which moves were likely (i.e. good). In chess, it's not uncommon that good players are usually picking between only a few reasonable candidate moves. I wouldn't be surprised if good compression of human games yielded something like 2-3 bits per move.
The proper way to go is to compute all available moves on a given position, assign them a probability distribution and then perform arithmetic coding using it.
If you want simplicity, assign an uniform distribution. For optimal compression, use an engine such as Stockfish to evaluate how strong the moves are, and then apply a statistical model that converts move strength to weights to make a probability distribution for; also use an opening books for the openings and tablebases for the endgames.
My wild guess is that it will probably result in something like 3-4 bits per move on average.
If you instead want a simple encoding, then encode in 4 bits the piece that moved based on any order on the chessboard squares, then in 6 bits the destination square for 10 bits per move.
For example, I've just played a game, now I want to go through the opening and fetch all games from the database that went through the same initial moves/positions (that's not the same thing, as a game may arrive at the same position through a different order of moves; AKA transposition). Let's say, all the way until move 15 or 20, because it will only be at that point that a decent game finally becomes unique by deviating from all the recorded games in the database (AKA a novelty was played).
Or I want to find all games where an endgame of a Queen and a pawn against a lonely Queen occurred. There is actually a query language for that, named (surprise, surprise) Chess Query Language: https://www.chessprogramming.org/Chess_Query_Language
I feel that whatever a superior alternative to PGN might be, its strength would likely be better queryability rather than higher storage efficiency as such.
Because in the former case it may still be best to accept some compromise (in the form of redundancy/simplicity) to hit the sweet spot.
Especially in the context of many comments that seem to have taken an extremely "code golf"-like approach towards the problem.
If on the other hand you can squeeze another 10% storage from Huffman encoded inverse tree lookup tables that only neckbeards understand, you’re limiting your pool of people able to do maintenance on this system in the future when the author is long gone.
If the goal is to replay the moves, instead, you need to keep track of the position anyway. In this case I would suggest a simple scheme: piece-destination. Each player only has 16 pieces at most at any moment in a game, so 4 bits will suffice. Each piece in a given square can only move to a fixed number of destination squares. A Queen can move to 27 other squares (7 horizontally, 7 vertically, 13 diagonally when in one of the four central square). All other pieces have less freedom: Kings have 8 natural moves, plus 2 castles; Pawns have 2 captures, 2 normal moves forward, and 4 possible move-and-promote; and so on. So the 27 for a Queen is the upper limit, and that needs 5 bits. In total 9 bits per move. Unfortunately that's more than one byte, so you still need some bit-wise processing...
The mid- or lategame are also far from random and could probably be compressed in small "chunks" via a coder that would (effectively) learn to predict patterns of e.g. capture being followed by a recapture and cause such chunks to require fewer bits to represent.
I'm not very knowledgeable about compression algorithms, though; I'm sure others will be able to provide corrections or references.
Chess.com's opening explorer is pretty good, which it seems like this is kind of trying to compete against.
What I struggle with is the post game review, where I make a dubious move but I don't actually understand the reason why it's bad. Sure, the engine shows better moves and the refutation to my move, but I often don't know why. The automatically generated explanation is usually pretty poor.
I wish I had even a poor copy of Danya giving me commentary.
Perhaps I should just lean in and try to implement something like this, but focus it more on coaching than commentary.
https://news.ycombinator.com/item?id=37525348
Site linked there seems to be offline, so here's the archive copy: https://web.archive.org/web/20230928072950/https://www.ezzer...
Since chess databases have come along higher level chess players can look pretty far ahead. We are reaching the point where if you study your opponent you can pregame it but studying what openings they make and prepping for openings.
But now we have come to the point if you can memorize large portions of chess databases you know optimal play given the database because if anyone has played the game and gets any advantage out of it people will play the line. It would be interesting to take the chess databases see their compression ratio and then how long would it take to lose against swordfish.
How would a typical compression algorithm do, losslessly compressing the whole game?
Can you lossily compress the game? If so, you would end up, after decompressing, with a game that had some ambiguous moves, right? But, only one is valid all the way through. Why not lossily compress and then step through each game, checking if each move is valid? Is that even still considered lossy?
Hypothetically you could end up with multiple valid games I guess… but they are just games, haha, who cares if a few are wrong?
Rather than use a general algorithm, your compressor would have to reason: I'd have to spend a bit to disambiguate x & y, but only x is a valid board state, so I won't bother and the decompressor also knows I won't bother so it will all work out.
This sort of implicit coding can work, but it is fragile.
Because there are so many, one approach would be to sort the list of lines lexicographically to group similar ones together, then compress the result with a sliding window compression algorithm.
The sliding window compression will avoid storing repeated parts a second time, and the first part of the lines will be repeated a lot because of the sorting. There may also be some other repeated sequences.
This assumes that it's OK to sort the lines, though, which might or might not be true.
Also, I don't bother with storing capture, or check, because those you can infer from the game state. Again, depends on how you be using the result - I just wanted to be able to re-play the game state.
64 squares * (Empty | (Black | White) * (King | Queen | Bishop | Knight | Rook | Pawn)) + (Black | White) * (Can castle queenside | Can castle kingside)
It could be encoded as 64 4-bit int columns + 4 bool columns = 260 bits, most of which don't change between moves. Normal columnar encoding and compression strategies could probably reduce that to a few bits per board state.
The thing I optimized for is that there’s very often repeated blank spaces or repeated pawns on the same rank.
Also instead of storing castling status separately, it’s stored as a separate piece on the appropriate rook.
These take advantage of the fact that there’s 6 pieces, so 3 bits to encode them leaves two options remaining. One is PawnRepeating and the other is RookCastleAvailable, in my scheme.
There’s probably improvements to be made. I’ll write a post on it when it’s finalized.
I guess you also need some way to encode which player's turn it is. Though maybe you could eliminate that by flipping the board on black's move and always encoding from the perspective of the last player's move?
I'm curious about whether a naive columnar encoding scheme could beat a more complex encoding scheme, after compression. Not columnar in the sense of ranks and files, but columnar in the sense of data science storage formats (e.g. parquet), where each 'column' is the state of a specific square across different board states. Given 64 such columns, a game state is a single row. The hypothesis being that if you're encoding all the game states of a large number of games (say 1000), after RLE and compression etc you would see a net compression better than more complex encoding schemes. Given a big block of game states like this, then a single 'game' would be a list of offsets into the game states. This would probably also compress very nicely in columnar format.
Now I want to try it...
Further compression may be possible due to the number of legal moves may be much less, so some of the previously numbers may be used for common sequences of moves.
(It would also be possible to use some of the unused numbers to indicate e.g. resigning, agreement of a draw, and unfinished games.)
The implementation should store a state machine for the game, a struct for each pieces, and also the board with index to the pieces (occupation).
There is only 16 pieces for each player, the two players move one another.
The Naive would be that you index the pieces (4 bits) and store the movement after that,
Pawns 2 bits for movement (2 forward, 2 diagonal)
King 4 bits for movement (normal move 1 + 3 bits, castle 1 + 1 bits)
Knight 3 bits for movement (8 possible move)
Rook 4 bits for movement ( 1 bit orientation horizontal/vertical 3 bit new position )
Bishop 4 bits for movement ( 1 bit orientation left/right diagonal 3 bit new position)
Queen 5 bits for movement ( 2 bit orientation horizontal/vertical/ left/right diagonal 3 bits for new position)
This way you need
Pawn 4 (index) + 2 (movement) = 6 bits
King 4 (index) + 4 (movement) = 8 bits
Knight 4 (index) + 3 (movement) = 7 bits
Rook, Bishop 4 (index) + 4 (movement) = 8 bits
Queen 4 (index) + 5 (movement) = 9 bits
But you can encode the pieces index in another way, that the Queen got 3 bit index and other pieces got longer index (Pawn 5 bits)
index bits + movement 000 + 5 movement Queen -> 8 bits
00100 + 3 movement King Normal -> 8 bits
00101 + 1 movement King castle -> 6 bits
0011x + 3 movement Knight (2) -> 8 bits
01xxx + 2 movement Pawn (8) -> 7 bits
10x + 4 movement Bishop (2) -> 7 bits
11x + 4 movement Rook (2) -> 7 bits
Also other bit allocations are possible, I don't know if it's worth it to make the Pawns index 1 bit longer, but in this way the max 8 bits required for a movement. (You need the state machine for the "decompression", also have to track that the black and white Pawns are moving in the opposite direction. )
There can be further compression, if you check the possible move for the piece. ex. the first possible Knight movement is only 1 place, so no need to store the movement. But this need more calculation.
https://news.ycombinator.com/item?id=36431917 - Video Chess disassembled and commented (2023-06-22)
https://nanochess.org/video_chess.html
Video Chess for Atari 2600 worked with just 128 bytes of memory.
Arithmetic coding has been avoided in practical situations due to patents, but many of those have expired ( see the patent section of https://en.wikipedia.org/wiki/Arithmetic_coding or maybe not, lawyers tend to advise not even reading about patent specifics )
Overall the chilling effect was interesting - I found that many people doing academic work around compression didn't really know much about it, didn't teach it, just because it was a mess.
The core idea is elegant, and easily implemented.