This repo scaffolds poker RL experiments in simulation (OpenSpiel). It is not for real-money play or bypassing site rules.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python train_cfr.py --game leduc_poker --iterations 2000 --eval-every 200For the custom multi-player NLHE environment with continuous bet sizing, see
custom_poker_lab/README.md.
The Gymnasium wrapper lives in poker_rl/envs/openspiel_poker_env.py and gives
you a single-agent view with an internal opponent. This makes it easy to plug
into RL code and verify correctness on Leduc before scaling.
Example:
from poker_rl.envs import OpenSpielPokerEnv
from poker_rl.solvers import CFRPolicy, SolverAuditPolicy
env = OpenSpielPokerEnv("leduc_poker")
opponent = CFRPolicy("leduc_poker", iterations=2000)
env.set_opponent_policy(opponent)You can use CFRPolicy as a strong opponent or as a test-time check. For small
games (Leduc), CFR can get very close to optimal quickly, which is useful to
validate your learning code.
For larger games (HUNL/NLHE), use abstractions and fewer CFR iterations, or do depth-limited re-solving on subgames rather than full brute force.
Example audit wrapper (gates a policy with a CFR check):
from poker_rl.solvers import CFRPolicy, SolverAuditPolicy
solver = CFRPolicy("leduc_poker", iterations=2000)
auditor = SolverAuditPolicy(
base_policy=lambda state, rng: rng.choice(state.legal_actions(state.current_player())),
solver_policy=solver,
min_solver_prob=0.05,
fallback="argmax",
)If you want to avoid PufferLib entirely, use the population trainer that runs directly on OpenSpiel with a lightweight policy/value network:
python league_train_alpha.py --game leduc_poker --rounds 5 --population 4 --top-k 2This is a simple Alpha-style loop: train a population against a hall of fame, score round-robin (and optionally vs CFR), then keep the top-K for the next round.
To get progress feedback during training, use:
python league_train_alpha.py --game leduc_poker --rounds 5 --population 4 --top-k 2 --log-every 500To batch multiple episodes per update (better GPU utilization):
python league_train_alpha.py --game leduc_poker --batch-episodes 8To train multiple candidates in parallel (CPU by default):
python league_train_alpha.py --game leduc_poker --parallel-agents 4 --worker-device cpuTo parallelize evaluation (round-robin + CFR) across processes:
python league_train_alpha.py --game leduc_poker --eval-parallel 4The league run saves checkpoints to experiments/alpha_league/round_XX.pt.
Evaluate vs CFR or random:
python eval_alpha.py --model experiments/alpha_league/round_01.pt --opponent cfr --episodes 500
python eval_alpha.py --model experiments/alpha_league/round_01.pt --opponent random --episodes 500To resume from a saved checkpoint:
python league_train_alpha.py --game leduc_poker --resume-model experiments/alpha_league/round_01.ptBy default, the trainer will auto-resume from the latest checkpoint in
--output-dir and continue round numbering. Use --no-auto-resume to disable it.
List available OpenSpiel games:
python - <<'PY'
import pyspiel
print(sorted(pyspiel.registered_names()))
PYYour OpenSpiel build exposes universal_poker, which can be configured to
approximate 2-player Limit Hold'em. The default config uses a reduced deck
(num_ranks=6) to keep training tractable.
Train Deep CFR (LHE-like):
python train_universal_deep_cfr.py --iterations 200 --traversals 200 --num-ranks 6To move closer to real LHE, increase the deck size:
python train_universal_deep_cfr.py --iterations 200 --traversals 200 --num-ranks 13If you see invalid first player errors, ensure your --first-player string
uses 1-based player IDs (e.g., 1 1 1 1 for four rounds).
If betting="limit" is not supported in your build, change it to nolimit
inside poker_rl/universal_poker.py and use bettingAbstraction="fcpa".
If deep_cfr is not available in your OpenSpiel build, try:
python train_universal_deep_cfr.py --algorithm outcome_sampling_mccfr --iterations 5000Use the preset to target a stronger NLHE-like configuration:
python train_universal_deep_cfr.py --preset best_nlhe \
--algorithm outcome_sampling_mccfr \
--iterations 5000000 --log-every 100000 --eval-every 500000 --eval-episodes 5000 \
--checkpoint-every 500000 --checkpoint-dir experiments/universal_poker_mccfr_bestFor a closer-to-real stack depth and raise sizing, try:
python train_universal_deep_cfr.py --preset real_nlhe \
--algorithm outcome_sampling_mccfr \
--iterations 5000000 --log-every 100000 --eval-every 500000 --eval-episodes 5000 \
--checkpoint-every 500000 --checkpoint-dir experiments/universal_poker_mccfr_realFor a more granular (but slower) NLHE-like abstraction:
python train_universal_deep_cfr.py --preset granular_nlhe \
--algorithm outcome_sampling_mccfr \
--iterations 5000000 --log-every 100000 --eval-every 500000 --eval-episodes 5000 \
--checkpoint-every 500000 --checkpoint-dir experiments/universal_poker_mccfr_granularNote: granular_nlhe increases raise steps per round and uses tighter sizing,
but still relies on the fcpa action abstraction. Going beyond this requires
custom abstractions or a different engine.
Add progress logging, eval, and checkpoints:
python train_universal_deep_cfr.py --algorithm outcome_sampling_mccfr \
--iterations 20000 --log-every 1000 --eval-every 5000 --eval-episodes 200 \
--checkpoint-every 5000 --checkpoint-dir experiments/universal_poker_mccfrEvaluate a saved universal_poker policy:
python eval_universal_policy.py --policy experiments/universal_poker_mccfr/policy_iter_100000.pkl \
--config nlhe --num-ranks 13 --betting-abstraction fcpa --opponent random --episodes 2000Evaluate vs a CFR baseline (slow for large games):
python eval_universal_policy.py --policy experiments/universal_poker_mccfr/policy_iter_100000.pkl \
--config nlhe --num-ranks 13 --betting-abstraction fcpa --opponent cfr \
--cfr-iterations 200 --episodes 500If you want a separate RLCard-based setup with a two-head policy (action type +
raise size), see rlcard_lab/README.md. This uses RLCard's built-in NLHE action
set (fold / check-call / half-pot / pot / all-in).
For a research-only multi-player NLHE environment with parameterized bet sizes,
see custom_poker_lab/README.md.
For the vectorized, high-throughput engine, see custom_poker_lab/prod_lab/README.md.
For the CUDA-native environment, see custom_poker_lab/cuda_lab/README.md.
Evaluate vs a local best-response proxy (LBR):
python eval_universal_policy.py --policy experiments/universal_poker_mccfr/policy_iter_100000.pkl \
--config nlhe --num-ranks 13 --betting-abstraction fcpa --opponent lbr \
--lbr-rollouts 32 --episodes 2000Run multiple seeds in parallel and write a summary:
python train_universal_mccfr_multi.py --preset best_nlhe \
--seeds 41,42,43,44 --parallel 4 \
--algorithm outcome_sampling_mccfr \
--iterations 5000000 --log-every 100000 --eval-every 500000 --eval-episodes 5000 \
--checkpoint-every 500000 --checkpoint-dir experiments/universal_poker_mccfr_multiIf you want to see live logs in the console instead of per-seed log files:
python train_universal_mccfr_multi.py --preset best_nlhe \
--seeds 41,42,43,44 --parallel 4 \
--algorithm outcome_sampling_mccfr \
--iterations 5000000 --log-every 100000 --eval-every 500000 --eval-episodes 5000 \
--checkpoint-every 500000 --checkpoint-dir experiments/universal_poker_mccfr_multi \
--no-log-redirectpython select_best_mccfr_seed.py --root-dir experiments/universal_poker_mccfr_multi \
--opponent lbr --lbr-rollouts 32 --episodes 2000 --parallel 4