Skip to content

Otter-Crew/range-reader

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

range-reader

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.


What's different from a vanilla GPT

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.


From hand histories to a token stream

# 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-poker

The 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.py parses rs-poker Open Hand History JSON into a CanonicalHand (0-indexed seats, chips not yet normalized).
  • encoder.py:augment_hand turns 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.encode flattens a sample into the token stream: sit per 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.

The token vocabulary

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 actionssit, 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 model: embeddings that mirror poker

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.

Positional encoding: NoPE vs RoPE (measured)

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.


Suit symmetry as a free lunch

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 augmentationencode_sample applies one of the 24 suit permutations at random to every sample (the token stream and the target combo transform together via PERM_ACTION / PERM_COMBO), so the model learns approximate suit-equivariance from a suit-symmetric dataset.
  • Test-time augmentation (TTA)inference.tta_canonical_probs averages P(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 lifts mrr. The same TTA path serves the product read and the recorded eval, so results.tsv reports the TTA number.

Training

# 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 tensorboard

The 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_mask is 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 -inf before 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.


Evaluation and inference

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.

The read sharpens as the betting comes in

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-steps

query.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).


Development

mise run check   # format check + ruff + pyrefly + pytest (all gates)
mise run fix     # auto-fix formatting + ruff + TOML

Toolchain: 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).

Where things live

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

About

GPT that will read poker hand from open hand history training data

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Contributors

Languages