After this most recent Thanksgiving where I taught my girlfriend’s mom how to play Texas Hold ’Em, I’ve been playing a lot of free online poker. My girlfriend describes it as: “you’re not even gambling on money, you’re gambling on your ego.”
When I was younger, I was definitively not a good poker player. I wanted to play every hand and hope for just the right river card that would give me the win. Whenever I did sit out, I would have the moments of “but if I had stayed in, I would have gotten a flush!” or other similar reactions.
In my recent Poker adventures, I’ve taken a much more conservative approach which involves only playing “premium” hands (eg a pair of aces, pair of kings, king and ace, etc) or only calling into the pot with mid-range hands if I’m already committed via the big blind. I try not to get too in my head if I “would have won if I stayed in,” and make decisions based only on the information I have at that point.
Even so, I still run into cases where I get outplayed. Or, as I like to think, “I just hit a bad beat!”
There’s a big world of programmers that specialize in Poker; simulating it, creating GTO solvers, equity calculators, and more, so while being a fun game to play it also crosses over heavily with my interest in stats and programming. As a basic starting point, I wanted to run simulations to answer: with the hands I lost, did I ever have a chance in the grand scheme of things? Or was I basically destined to lose?
Simulating Poker Hands in Julia
Why Julia?
Despite my blog featuring Julia heavily over the years, since I started my new role at Asana I’ve been living entirely in Python day to day. I currently mostly leverage PySpark within Databricks to do data transformations and build pipelines that feel more sane and maintanable to me than giant queries we had before.
I’ll probably write another post about how I’ve transitioned my analytics workflows over to Python from R/Julia, but at a high level this is what I’ve found:
polars>pandasplotnineallowing me to transfer all of my ggplot knowledge over to plotting in Python- a fast package/environment manager in
uv mypyand other static type checkers that encourage me to write better code
Generally, I haven’t actually been using Julia much recently. I still really like the language, and enjoy working in it from time-to-time…but most of my analysis needs are served well by Python using more modern packages like polars, plotnine, uv, etc. I chose to use Julia for these simulations just because the combo of the PlayingCards and PokerHandEvaluator packages was pretty simple to pick up, and it lets us use pretty unicode for the cards themselves. I also don’t want to get too rusty in the langauge since I’ve spent a lot of time in it.
Generally, I plan on spending more time in Python and less time in Julia going forward. If Julia’s adoption ever becomes more widespread, I think I’ll be in a great position as an early adopter! But as of right now, my career is pushing me more towards Python, and I must oblige.
Anyway, let’s dive into some simulations.
Some Setup
Here are the libraries we’ll be using today.
I also created a few helper functions that make the process of running these simulations a bit easier.
Code
function slimmed_down_deck(hero_hole_cards; community_cards = ())
"""
slimmed_down_deck()
If we provide community cards to later functions, they should be removed from the possible set
of cards that can be in someone's hand. The same goes for the hole cards that we would assign ourselves.
"""
deck = ordered_deck()
shuffle!(deck)
if !isempty(community_cards)
for card in community_cards
pop!(deck, card)
end
end
for card in hero_hole_cards
pop!(deck, card)
end
return deck
end
function get_all_hands(hero_hand, n_players, deck)
"""
get_all_hands()
given a player's hand, the number of other players, and a deck to source the cards from, generate
a named dict of n + 1 hands.
"""
hands = OrderedDict()
hands["hero_hand"] = hero_hand
for player in 2:n_players + 1
hands["player_$player"] = (pop!(deck, 2))
end
return hands
end
function get_hands_for_eval(hands, community_cards)
"""
get_hands_for_eval()
gather the hands of the players into the proper format for PokerHandEvaluator
"""
a = collect(
((hand[1], hand[2], community_cards...) for hand in values(hands))
)
return a
endTo simplify the simulations we’ll be running, we’re going to ignore some of the more complex aspects of Poker like betting, position, etc. For this post, we’re going to assume that all players just go all-in once they’ve received their hole cards.
The simulation function itself is a simple Monte Carlo method where we deal our opponents random hands from a deck and compare them to specific hands we’re interested in. The function returns a boolean vector indicating whether we won in a given scenario, the winning hand type, the best 5 cards from the winning hand, and the hole cards that the winner held. Every run, besides the initial hole cards, is random. We can also optionally provide specfiic community cards to backtest historical hands against other scenarios.
I also have another function below that lets us plot distributions of outcomes across multiple sets of simulations, but I’ll talk more about that a bit later.
Code
function simulate(player_hole_cards, community_cards, n_players, n_runs)
"""
simulate()
run a simulation that accepts specific cards and then deals random cards to the other players.
if given a set of community cards, determines how often the other players' hands would win on that
specific set of community cards. otherwise, determines how often a player would win with a random community
card draw with a specific set of hands versus other random hands.
captures whether the player won, what the winning hand type was (eg a flush, a straight, etc), and the hole
cards that the winner was holding.
"""
is_winner = []
winning_hand_type = []
winning_hand = []
winner_hole_cards = []
is_community_cards_fixed = !isempty(community_cards)
for i in 1:n_runs
if is_community_cards_fixed
deck = slimmed_down_deck(
player_hole_cards,
community_cards = community_cards
)
else
deck = slimmed_down_deck(player_hole_cards)
community_cards = pop!(deck, 5)
end
hands = get_all_hands(player_hole_cards, n_players, deck)
eval = get_hands_for_eval(hands, community_cards)
fhe = FullHandEval.(eval)
winner = argmin(hand_rank.(fhe))
if winner == 1
push!(is_winner, true)
hole_cards = hands["hero_hand"]
else
push!(is_winner, false)
hole_cards = hands["player_$winner"]
end
push!(winning_hand_type, hand_type(fhe[winner]))
push!(winning_hand, fhe[winner].best_cards)
push!(winner_hole_cards, hole_cards)
end
return is_winner, winning_hand_type, winning_hand, winner_hole_cards
end
function plot_simulations_for_range_of_hands(hands, n_players, n_simulations, plot_title, plot_subtitle)
"""
plot_simulations_for_range_of_hands()
captures the % of wins from a simulation given one or more player hands. the simulate function simulates 1000 potential matchups, and this function runs this n times to generate a distribution of possible win rates for a given hand or set of hands.
"""
F = Figure(
fonts = (; regular = "IBM Plex Sans", color = :black),
dpi = 1400
)
ax = Axis(
F[1, 1],
title = plot_title,
subtitle = plot_subtitle,
xlabel = "Win Rate",
ygridstyle = :dash,
ygridcolor = (:lightgrey, 0.45),
xgridvisible = false,
ylabelvisible = false,
titlecolor = :black
)
ax.xtickformat = v -> [(@sprintf("%.0f", x * 100) * "%") for x in v]
hideydecorations!(ax)
blue_palette = Makie.cgrad(:Blues, 10, categorical = true, rev = true)
for (i, hand) in enumerate(hands)
outcomes = [
(count(simulate(hand, (), n_players, 100)[1])) / 100 for x in 1:n_simulations
]
avg_outcome = mean(outcomes)
density!(outcomes, label = "$hand", color = (blue_palette[i], .25), strokewidth = 1.5, strokecolor = blue_palette[i])
vlines!(avg_outcome, color = blue_palette[i], linestyle = :dash)
axislegend(position = :rt)
xlims!(ax, 0, 1.2)
end
return F
end;Pocket Aces
To begin, let’s run our simulation on the most premium hand in Poker: pocket aces. Assuming we have pocket aces in a head-to-head Poker game, how often would we win over 100,000 games with random community cards?
Here’s how we can simulate that using my code.
Our simulation suggests that against another random hand, with a random set of 5 community cards, pocket aces would win about 85.42% of the time.
However, as the number of players in the game grows this win rate will decrease due to increased variance and increased probability that an opponent will hit a stronger hand. For example, what if we’re playing against 5 others?
Let’s go ahead and run our full simulation function, and then gather its results into a DataFrame for analysis.
Code
wins, hands, best_cards, hole_cards = simulate(
(A♡, A♢), # player has pocket aces
(), # random community cards
5, # playing against 5 other players
100000 # run simulation 100,000 times
)
results = DataFrame(
"won" => wins,
"hand_type" => string.(hands),
"winning_hand" => best_cards,
"winner_hole_cards" => hole_cards
)The results of our simulations look like this:
| Row | won | hand_type | winning_hand | winner_hole_cards |
|---|---|---|---|---|
| Any | String | Any | Any | |
| 1 | false | trips | (7♠, 3♠, T♣, 3♢, 3♣) | (7♠, 3♠) |
| 2 | true | trips | (A♡, A♢, A♠, K♢, 8♡) | (A♡, A♢) |
| 3 | true | one_pair | (A♡, A♢, 8♢, 9♢, K♠) | (A♡, A♢) |
| 4 | false | straight | (T♠, 9♢, J♠, Q♠, K♠) | (T♠, 5♡) |
| 5 | true | trips | (A♡, A♢, 4♣, A♠, T♠) | (A♡, A♢) |
| 6 | false | trips | (K♡, Q♢, Q♣, Q♡, T♠) | (K♡, Q♢) |
| 7 | false | straight | (Q♠, 9♠, T♢, 8♠, J♠) | (4♡, Q♠) |
| 8 | true | full_house | (A♡, A♢, 5♣, 5♡, A♠) | (A♡, A♢) |
| 9 | false | trips | (Q♡, Q♢, 9♠, Q♠, K♢) | (Q♡, Q♢) |
| 10 | false | full_house | (8♡, T♢, 8♠, T♠, T♡) | (8♡, T♢) |
We can use the dataframe to calculate our win rate again, which now comes out to 49.78%, which is quite a drop!
So, what’s beating us when we have more players in the mix?
Code
function plot_hands_that_win(df, title = "", subtitle = "")
hands_that_win = @chain df begin
@subset :won .== false
@groupby :hand_type
@combine :total= length(:hand_type)
@transform :prop = :total / sum(:total)
@orderby -:prop
end
n_labs = nrow(hands_that_win)
F = Figure(
fonts = (; regular = "IBM Plex Sans", color = :black),
dpi = 1400
)
ax = Axis(
F[1,1],
title = title,
subtitle = subtitle,
ylabel = "% Won",
xticks = (1:n_labs, hands_that_win[!, :hand_type]),
xgridvisible = false,
ygridstyle = :dash,
ygridcolor = (:lightgrey, 0.45),
ytickformat = x -> string.(round.(x * 100, digits = 1, RoundNearest), "%"),
titlealign = :center,
titlecolor = :black
)
barplot!(
1:n_labs, hands_that_win[!, :prop],
color = "#033f63",
width = .5,
bar_labels = [(@sprintf("%.01f", x * 100) * "%") for x in hands_that_win[!, :prop]]
)
ylims!(0, 1)
F
end
plot_hands_that_win(
results,
"Hands that Beat Pocket Aces (A♡, A♢)",
"with randomized community cards, against 5 other players"
)Straights are the biggest danger against pocket Aces–and this is definitely a trap I’ve fallen into multiple times, where I just don’t see the possibility of a straight amongst the community cards!
Distributions of Outcomes for Different Hands
It’s important to note that every time we run this simulation, there’s a bit of variance in the win rate due to randomness in the cards dealt to our opponents as well as the cards dealt to the community cards. Running the samples multiple times will get us a lot closer to the “true” win rate of the provided hands.
The plotting function I defined above treats each run of our simulation function as a single data point. By repeating the simulation n times (100 in this case), we can generate a distribution of the estimates.
Here’s the distribution of win rates of pocket aces in a 6 person game with 100 runs:
Code
It’s centered around 50%, but there is clearly some variability where the deck draws turned out more favorable.
We can also compare the win rates of pocket Aces vs other “premium” hands.
Code
Pocket aces are still the best set of hole cards you can get against 5 opponents, followed closely by KK. While it’s important in Poker to generally understand your odds, which this helps us do by showing a range of possible outcomes, there is also a lot more to the game such as understanding why someone is betting, whether you’re just being bluffed or whether they really do have one of these other hands that can beat you.
Speaking of, lets use the simulations to review some of my stupid/good moves!
Backtesting Some of My Own Hands
Pocket 7s
Let’s picture this: you’re me. You’ve just rediscovered your interest in poker, and want to avenge your younger self who lost basically every hand you played. You’re on a hot winning streak, going from 25,000 chips to 125,000 over the course of a day solely by playing conservatively against randos on the internet.
You get cocky and loosen up, you start playing more hands “just in case.” Eventually, you get dealt this hand: 7♠, 7♣.
After shaking out some other players by throwing in some continuation bets, the river results in a board of 8♣, 9♣, 2♡, 9♠, 3♡…but your opponent has bet big on the river, shoving all-in.
What do you do?
Well, my first piece of advice is…if someone bets huge on the river, they probably have something pretty good unless they’ve been bluffing the whole night! But if we wanted to know generally what our chance of winning is, we can run our simulation to see what would happen over hundreds of thousands of random hands.
According to our simulation, I would win approximately 29.63% of the time against 5 opponents with this hand and this set of community cards.
Those odds aren’t that bad. In fact, it means that given this same board…I would win in the long run almost 1/3 of the time simply by going all-in once I saw I had a pair of sevens.
But ultimately, a pair of sevens is not a strong hand–because there are plenty of opportunities for your opponent to get just the right cards they need to beat you. In our simulation, these were the hands that came out on top.
Code
It’s clear that there’s a variety of hands that are dangerous to our pocket pair of sevens. At the most basic, if anyone has another higher two-pair (which is not all that unlikely, half of the hands that beat us were two pair!) they’ll win. Unfortunately, in this scenario I fell victim to 3 of a kind.
Basically, if you get excited by having a pair of sevens…it would be wise to consider whether your opponent has anything like this:
10-element Vector{Any}:
(3♢, 8♡)
(8♡, 5♡)
(8♠, J♣)
(Q♠, 8♡)
(8♢, K♣)
(J♢, 8♡)
(Q♣, Q♢)
(7♢, 8♢)
(8♡, T♠)
(K♡, 8♢)
If I had been dealt TT, even offsuit, my odds of winning would have both increased on average and covered a wider range of potential deals.
Full House - Aces Full of Kings
Now, let’s take a look at another hand I had…but this one was great.
I was dealt A♡K♢, which I played pretty aggressively out of the gate by raising pre-flop.
On the flop, I was brought a two pair of Aces and Kings…raised again. Then, the turn brought me a set of community cards that looked like this: K♡, J♢, A♢, A♠, 6♡
Meaning that my hand was a Full House, Aces full of Kings. I excitedly raised, only for my opponent to again shove all-in on their hand. I called…
Now, let’s consider this hand against a random set of community cards and a slightly higher pair of JJ.
Code
Basically any pocket pair from TT to AA would be better than this, but the specific run from the flop to the turn in this situation is very important for a few reasons.
First, while the two diamonds on the flop did give the potential for a flush if another card hit just right…I immediately had a two pair of Kings and Aces, which in this scenario is the highest two pair one could have. Someone could have been holding onto a three of a kind by this point, but I took that as unlikely since my continuation bet was met only by calls or folding (again, this is something our simulation doesn’t cover).
The third Ace sealed the deal, mainly because it meant 1) only 1 other Ace could be in somebody’s hand, and 2) since I had a pair of kings as well, I had the highest possible full house. My opponent suddenly got very aggressive after this was dealt, so I assumed he probably had the last remaining Ace and some other kicker card. But, they didn’t bet at all until the turn–they just called my raises each time, so I felt safe in assuming they did not have a king.
Even if they had a Full House, Aces full of Jacks, they couldn’t have won by this point…and even if they somehow did have AK, we would just end up splitting the pot.
Let’s run our simulation and see how often we’d win in this scenario.
In this scenario, this hand has a win rate of a whopping 100.00%. Not only was this the best possible hand to have in this situation, it was literally the only one that could win. The best outcome for my opponent in this case would have been to also have this hand and hope for a tie and a split pot.
Now if only I could be dealt more hands like that…😄