Read a poker player's hole cards from the way they bet. range-reader learns
P(villain_hand | game_history) — a full distribution over the 1326 Texas
Hold'em starting-hand combos for one seat — from nothing but a hand history.
Poker is a game of hidden information. Everyone sees the betting, the board, and
their own two cards — but not yours. range-reader treats recovering that hidden
holding as a masked-translation problem: mask the villain's two cards, then
translate the public record of the hand — who sat where, who bet how much, what
fell on the board — into a belief over what those cards are. The mask is a real
token in the stream (<predict_hole>), the target-side vocabulary is constrained
by which cards are still possible, and the model re-emits its translation at
every position after the mask — so you watch the read sharpen street by street
as the chips go in.
Underneath it is a GPT decoder, but almost nothing around the transformer blocks is copied from a language model. The encoding, the embeddings, the symmetry, and the loss are each shaped to mirror the structure of poker — and that shaping is what the rest of this README is about.
The single decision metric is val/mrr — the mean reciprocal rank of the true
hand. For each prediction we sort all 1326 combos by probability and take
1 / (rank of the truth): the truth ranked 1st scores 1.0, 10th scores 0.1,
100th scores 0.01. Averaging that over val rewards pushing the true hand toward
the top, not merely off the bottom — which is why it, and not loss or mean rank,
is the keep/discard metric.
A uniform guess over 1326 combos sets the bar on all three: the truth is then equally likely at any rank, so
mrr = H₁₃₂₆ / 1326 ≈ 0.0059(the 1326th harmonic number over the combo count),loss = ln(1326) ≈ 7.19,mean_rank = (1326 + 1) / 2 ≈ 663.5.
The job is to beat all three by reading the betting alone. For scale, the NoPE
baseline below lands at mrr ≈ 0.017 — roughly 3× the random floor — pulling
the true hand from ~664th toward the low hundreds on average.
That single average hides the point of re-emitting the belief at every position:
the read sharpens as the betting comes in. Following the same showdown hands
across the streets, the true hand's mrr climbs from 0.0164 just after the flop
to 0.0209 by showdown (~25% sharper) and its mean rank improves from ~351 to ~306
of 1326 — most of the gain landing by the turn. The model isn't guessing a static
range; it reads, and reads better the more a player commits. The
Evaluation section measures this climb directly.
If you know the decoder recipe, here is what is not standard — each row links to where it lives and why it earns its place.
| Vanilla GPT | range-reader | Why |
|---|---|---|
| One discrete token per position | Each position is an (action, chip-amount) pair — a discrete symbol and a continuous scalar | A bet isn't a token, it's an action and an amount. Bucketing the amount into tokens throws away the exact signal we read. |
One flat (vocab, d) lookup table |
A factored, additive embedding assembled from poker primitives; hole cards composed from card vectors | Every axis is a real poker concept — seat, button-relative position, table size, card identity, bet size — not an opaque learned row. See embeddings that mirror poker. |
| Learned / rotary positional encoding | NoPE — and chosen by experiment, not taste | Tested head-to-head against RoPE at an equal time budget; the numbers are below. |
| GELU MLP, LayerNorm w/ affine | ReLU² MLP, RMSNorm without learnable scale, QK-norm attention | Current small-decoder practice. |
| Predict the next token | Predict the same hidden hand at every supervised position | Turns one label into a streaming belief trajectory over the hand. |
| Softmax over the whole vocab | Softmax over 1326 combos with a per-position dead-card mask | Cards you can already see make most combos impossible; never spend probability on them. |
| Augment by dropout / noise | Suit-permutation symmetry exploited at train and test time | Relabeling the four suits is an exact symmetry of poker — free data and free variance reduction. |
The sections below walk the pipeline and expand each point.
# Parse a directory of rs-poker OHH NDJSON files into train/ and val/ parquet
# shards under data/. (≈5% of hands go to val, by a stable hash of the hand id.)
mise run prepare ../rs-pokerThe encoding is the conceptual core; the model that follows is a fairly standard decoder. Three steps turn raw histories into the masked-translation stream:
hand_history.pyparses rs-poker Open Hand History JSON into aCanonicalHand(0-indexed seats, chips not yet normalized).encoder.py:augment_handturns one hand into many training samples — one per ordered(villain, perspective)pair of seats with known hole cards. "Seat 2 reading seat 5" and "seat 5 reading seat 2" are different reads, so a single showdown can yield a dozen samples. A seat that folds without ever voluntarily committing chips is skipped as a villain (an open-fold reveals only a wide fold range), but still appears as a perspective — its cards constrain the villain by removal — or as a hidden seat.encoder.py:HandEncoder.encodeflattens a sample into the token stream:sitper seat (carrying its stack),is_button,preflop, then one hole token per seat — the perspective's visible combo,<predict_hole>for the villain,<hide_hole>for everyone else — then street / community-card / action tokens. Chip amounts are normalized to big blinds and ride along in the value channel.
After encoding, prepare permutes each split before writing shards, so every
shard is a uniform mix of source files and table sizes. (Without it, shards inherit
file order — all heads-up, then all 3-handed — which makes the stream non-i.i.d.
and skews the eval-batch checkpoint monitor.) This is the first of two shuffles;
the training section covers the second.
The result is data/train/shard_*.parquet and data/val/..., each row a
(actions, values, villain_combo, hand_id) record.
tokens.py owns the vocabulary (VOCAB_SIZE 1596) and every id↔meaning lookup.
Its one non-obvious choice is that actions are attached to seats.
The table has up to 16 seats (MAX_SEATS). The 13 base actions — sit,
post_blind, fold, check, call, bet, raise, all_in, is_button,
<hide_hole>, <predict_hole>, and so on — are each attached to the seat that
takes them, rather than emitted as a bare action plus a separate "whose turn"
feature. The vocab is their cross product: 13 × 16 = 208 seat-attached ids, so
raise@seat3 and raise@seat7 are genuinely different tokens. (Global events —
preflop, the board deals, the community cards — are seatless tokens of their own.)
The model never has to infer whose action it is reading; the seat is in the token.
Crucially <predict_hole> and fold are seat-attached too, which is exactly what
makes the villain's supervised window recoverable from the stream in
one pass, and invariant to suit permutation.
Two more layout facts matter downstream:
- The 1326 hole-combo ids get no embedding row. They're composed from card embeddings at forward time — see the next section.
- 24 suit permutations (
PERM_ACTION,PERM_COMBO) and a card→combos table (for dead-card masking) are precomputed once at import, powering both augmentation and test-time augmentation.
The transformer blocks are a clean modern decoder — pre-norm, RMSNorm with no
learnable scale, QK-norm attention, ReLU² MLP. Default size is ~50M
params (10 layers, d=640, 10 heads), trained bf16-true on a single GPU. The
output head is a plain Linear(d, 1326): composed on the way in, flat classifier
on the way out, returning (B, T, 1326) logits — the villain's hand scored at
every position.
The interesting work is the embedding. A language model maps each token id through
one big (vocab, d) table and asks it to learn everything. range-reader instead
builds each position's vector as a sum of pieces, where every piece is a poker
concept (RangeReaderEmbedding):
| Component | What it encodes | Poker meaning |
|---|---|---|
E_action |
one shared row per base action | what happened — all 16 seat variants share a row; the seat is added separately, so raise means raise regardless of who |
E_seat |
which seat the token belongs to | who |
E_relpos |
(seat − button) mod n_players |
position — BTN / SB / BB / UTG / …, the strategically meaningful seat label |
E_n_players |
count of sit tokens |
table size |
E_n_live |
table size minus folds so far | how many are still in the pot |
W_value @ phi(value) |
per-base-action projection of the chip amount | how much — the continuous bet size, never bucketed (see below) |
The position/size/live components are derived in-model from the token stream
(_positional_indices), not fed as inputs — so they can never disagree with the
actions they summarize. phi Fourier-featurizes the (log1p) chip amount into 8
features; only the seven value-bearing actions contribute, and this is how a
continuous bet size enters the model without ever being quantized into tokens.
Hole cards are composed, not looked up. At every forward pass a combo's embedding is built as
card(low) + card(high) + E_combo_type[pair | suited | offsuit]
where card(c) = E_card[c] + E_card_rank[c // 4]
The 13-row rank table shares gradient across the four cards of a rank, and
E_combo_type supplies the pair/suited/offsuit feature that additive card vectors
can't express on their own. There is no per-suit embedding — under the
suit-permutation augmentation the optimal suit
embedding is four equal vectors, i.e. nothing. So the 1326-way output is learned
from ~70 card-and-structure rows, not 1326 independent ones.
A causal decoder already recovers ordering from its attention mask, so positional
encoding is optional here — and the position information that matters (street,
button-relative seat, table size) is already explicit content in the embedding
above. That argues for NoPE (no positional encoding at all), but the honest way
to settle it is to measure, so the model carries a rope flag (GPTConfig) and we
ran the A/B: two fresh runs, identical except the flag, same 35-minute wall-clock
budget, same full-val 24-perm eval.
| positional encoding | steps in budget | val loss | top64 | mean rank | mrr |
|---|---|---|---|---|---|
| NoPE (default) | 40,420 | 6.549 | 0.154 | 324.6 | 0.0174 |
| RoPE (rotary on seq index) | 36,746 | 6.562 | 0.152 | 330.1 | 0.0173 |
RoPE was marginally worse on every metric, and — because rotary adds per-step compute — it also fit ~9% fewer steps into the same 35 minutes. Rotating by the raw sequence index turns out to encode noise here: that index depends on how many players sat down and how many actions occurred, none of which is the position information the model actually needs (street, button-relative seat, table size are already explicit content). So NoPE stays the default — because it won the A/B, not because the story sounds nice.
Relabeling the four suits consistently is an exact symmetry of Hold'em: no suit is privileged, and flushes care only about suits matching, not which suit. The project cashes this in three ways:
- Architecture — no suit factor in the embedding (above).
- Train-time augmentation —
encode_sampleapplies one of the 24 suit permutations at random to every sample (the token stream and the target combo transform together viaPERM_ACTION/PERM_COMBO), so the model learns approximate suit-equivariance from a suit-symmetric dataset. - Test-time augmentation (TTA) —
inference.tta_canonical_probsaveragesP(combo)over all 24 suit relabelings, mapping each back to canonical order. For a perfectly symmetric model this changes nothing; for the real model it's a near- free variance reduction that measurably liftsmrr. The same TTA path serves the product read and the recorded eval, soresults.tsvreports the TTA number.
# Quick sanity run: 3000 steps, eval every 250.
mise run train -- --steps 3000 --batch-size 256 --eval-every 250
# The experiment-loop default: a fixed 35-minute wall-clock budget.
mise run train:timed
# Watch it live.
mise run tensorboardThe training design is summarized in scripts/train.py's module docstring, with
the rationale for each piece kept next to its code. The two load-bearing ideas:
- Two masks (
data.py) define what is supervised.loss_maskis the villain's supervised window — it opens just after<predict_hole>(before that, the model hasn't been told which seat to read) and closes at the villain's fold (a folded villain reveals nothing new); a showdown keeps it open to the end.dead_mask(T, 1326)sends card-removal-impossible combos to-infbefore the softmax. Both are read from the seat-attached, suit-free tokens, so they survive suit permutation unchanged. - The same hidden hand is the target at every position in the window, so the
loss (
lightning._masked_ce, one batch-global mean of cross-entropy) trains a belief trajectory: at inference you read off how the distribution sharpens as the betting reveals more.
Supporting machinery — EMA-eval (callbacks.py), the wall-clock LR schedule
(optim.py), the shared micro-averaged metric (metrics.py), and the second,
runtime shuffle (data.py) — is documented at each module's top; train.py links
them together.
Training saves the best checkpoint by val/mrr (best-step*.ckpt), and that
is the one to evaluate and ship — not last.ckpt. Point the tools at it:
# Resolve the best checkpoint saved by the last run.
CKPT=$(ls -t ~/.cache/range-reader/checkpoints/best-step*.ckpt | head -1)
# Full-val eval: prints a table stratified by how far the villain got before
# folding, and appends the decision row (TTA-averaged mrr) to results.tsv.
mise run eval:record "$CKPT" "my run"eval_stratified.py buckets each villain by the last street where it was live
(preflop-fold → flop-fold → turn-fold → river-fold → showdown) — where, by final
outcome, the model reads well. Showdown villains are read best (mrr 0.0189,
top16 0.052); the fold buckets cluster lower (~0.014–0.016) with no clean street
ordering, since a fold ends the signal and a river-fold is a specifically ambiguous
hand. Every bucket still clears the random floor (0.0059) by 2.4–3.2×. (This is a
by-outcome cut and averages each villain over its whole window — it is not the
within-hand sharpening below.)
evaluated 146785 val samples on cuda (TTA x24 suit-perms)
bucket samples %pos loss top16 top32 top64 top128 mean_rank mrr
-----------------------------------------------------------------------------------------
preflop-fold 13867 5.6% 6.672 0.039 0.076 0.147 0.269 357.0 0.0154
flop-fold 18357 11.2% 6.529 0.039 0.077 0.149 0.276 310.3 0.0164
turn-fold 15538 12.2% 6.563 0.037 0.071 0.135 0.258 324.5 0.0141
river-fold 12368 11.7% 6.586 0.035 0.072 0.133 0.259 331.2 0.0146
showdown 86655 59.3% 6.531 0.052 0.094 0.163 0.287 322.9 0.0189
TOTAL 146785 100.0% 6.549 0.046 0.086 0.154 0.278 324.6 0.0174
random baseline: loss=7.190 mean_rank=663.5
The recorded total uses the shared MaskedRankMetrics and the shared TTA path, so
the headline mrr matches both training-time val/mrr (up to the TTA gain) and the
product read.
To watch accuracy climb within a hand — the same villain, progressively more
betting — follow showdown villains across the streets with eval_sharpening.py:
uv run python -m scripts.eval_sharpening --ckpt "$CKPT" --data data/showdown villains: 86655
read after mean_rank mrr
--------------------------------
flop 351.4 0.0164
turn 310.0 0.0199
river 314.3 0.0199
showdown 305.5 0.0209
random baseline: mrr=0.0059 mean_rank=663.5
Each board deal carries the prior street's betting, so these are the same hands
read with progressively more information. The truth's mean rank improves from ~351
(after the flop) to ~306 (showdown) and mrr from 0.0164 to 0.0209 — most of
the lift is in by the turn, then a gentle plateau. (These are single-forward numbers
at one position per street, so the levels aren't comparable to the TTA, whole-window
headline above — the climb across streets is the point.) This is the by-outcome
table's missing piece: showdown villains score best precisely because they're read
across all of these increasingly-informed positions.
# Inspect the belief on one real hand, street by street.
uv run python -m scripts.query \
--ckpt "$CKPT" \
--ohh ../rs-poker/out_min-2_max-6.ohh \
--hand-index 288 --villain 5 --perspective 0 --show-stepsquery.py (inference.RangeReader.belief) is the product-facing call: top-K
predicted combos for a hand, with the truth's rank and probability printed per
street under --show-steps.
results.tsv is the append-only experiment log — one row per run, mrr the
decision column, status ∈ {keep, discard, crash} (results_log.py owns the schema
and verdict).
mise run check # format check + ruff + pyrefly + pytest (all gates)
mise run fix # auto-fix formatting + ruff + TOMLToolchain: Python 3.14, torch 2.12 (CUDA 13 on Linux, CPU/MPS on macOS — from a
platform-specific index, see pyproject.toml). Lint/typecheck is ruff + pyrefly;
formatting is uv format + taplo. Tests run on CPU with num_workers=0 for
determinism. Checkpoints and the prepared dataset cache live under
$RANGE_READER_HOME (default ~/.cache/range-reader).
| File | Responsibility |
|---|---|
hand_history.py |
rs-poker OHH JSON → CanonicalHand |
encoder.py |
augment_hand (samples per hand) + HandEncoder.encode (token stream) |
tokens.py |
vocab layout, id↔meaning, suit-perm + dead-card tables |
dataset.py / data.py |
parquet shard IO, masks, DataModule, the two-level shuffle |
model.py |
the GPT: factored/composed embedding + modern decoder blocks |
lightning.py |
loss, validation, optimizer/schedule wiring |
optim.py |
AdamW + wall-clock cosine LR |
callbacks.py |
EMA-eval |
metrics.py |
shared micro-averaged rank metrics |
inference.py |
checkpoint load + belief (with suit-perm TTA) |
scripts/ |
prepare, train, eval_stratified, eval_sharpening, query, experiment |