How Python Proved Me Wrong About Poker

Simon CroweSimon Crowe
5 min read

This is my first post. It is 50% an anecdote about an experience that inspired me to start this blog, and 50% a showcase of how a domain can be modelled using a few lines of Python.

I was recently staying at a hotel situated above a casino. Curiosity got the better of me on the last day and I decided to write off what remained of my travel money. Being a gambling novice, I avoided the tables and made a beeline for the cluster of noisy and garish machines. Among various themed slot games, I found something vaguely familiar: Jacks-or-better video poker.

The premise was simple: the player is dealt five cards and can hold onto any of these, the remainder being replaced by new cards. They win money if they get a pair of jacks or higher, or any other poker hand.

Is the machine lying to me?

Despite getting an occasional winning hand, I steadily lost money. I began to suspect this was partly due to the machine hinting that I should hold the wrong cards. Going on my statistical intuition I began ignoring suggestions to hold certain cards.

Thinking on my feet

To me, the most glaringly bad suggestion was to hold a pair of anything from twos to tens. On their own merits, these would be worthless. I was being encouraged to bet on three of a kind or something better.

The probability of getting the same value card for one of the three new cards would be something like 2/50 + 2/49 + 2/48. Let's say 6/50. While holding a pair precludes straights and flushes, there is also a higher-than-normal chance of a full house and four of a kind. Despite this, I was sure that getting a completely new hand would give me a better chance of winning.

Doing my homework

When I got home I decided to crunch some numbers to put my intuition to the test.

Let's say I opted to hold a pair of twos: a two of hearts and a two of diamonds.

>>> SUITS = 'CDHS'
>>> VALUES = '23456789TJQKA'
>>> full_deck = [val + suit for suit in SUITS for val in VALUES]
>>> my_deck = full_deck.copy()
>>> hold = []
>>> hold.append(my_deck.pop(my_deck.index("2H")))
>>> hold.append(my_deck.pop(my_deck.index("2D")))

Bear with me. For a fair comparison, I need to compute all possible hands for two cases:

  • if I hold the pair of twos

  • if I don't hold anything

Python's itertools package comes in handy here.

>>> import itertools
>>> threes = list(itertools.combinations(my_deck, 3))
>>> possible_hands_if_i_hold = [[*three, *hold] for three in threes]
>>> len(possible_hands_if_i_hold)
22100
>>> all_possible_hands = list(itertools.combinations(full_deck, 5))
>>> len(all_possible_hands)
2598960

I didn't expect orders of magnitude fewer possibilities when holding, but it makes sense.

Here's the function I devised to determine if a poker hand wins.

def rank_hand(hand):
    if poker.is_royal_flush(hand):
        return Hands.ROYAL_FLUSH.value
    elif poker.is_straight_flush(hand):
        return Hands.STRAIGHT_FLUSH.value
    elif poker.is_four_of_a_kind(hand):
        return Hands.FOUR_OF_KIND.value
    elif poker.is_full_house(hand):
        return Hands.FULL_HOUSE.value
    elif poker.is_flush(hand):
        return Hands.FLUSH.value
    elif poker.is_straight(hand):
        return Hands.STRAIGHT.value
    elif poker.is_three_of_a_kind(hand):
        return Hands.THREE_OF_A_KIND.value
    elif poker.is_two_pair(hand):
        return Hands.TWO_PAIR.value
    elif is_pair(hand):
        return Hands.PAIR_JACKS_OR_BETTER.value

    return 0

This function relies on predicates like this one:

def is_pair(hand):
    """Jacks-or-better variant of is_pair"""
    hand_values, _ = zip(*hand)
    hand_ranks = sorted(poker.VALUES.find(val) for val in hand_values)
    rank_counter = collections.Counter(hand_ranks)

    for rank, count in rank_counter.items():
        if count == 2 and rank >= poker.VALUES.find("J"):
            return True

    return False

You can find the rest of the predicates here.

Because Python integers can be truthy (and for that matter booleans are integers e.g. True + True == 2), rank_hand also functions as a predicate and can be used with filter to get a winning subset of all hands.

>>> import poker, jacks_or_better
>>> len(list(filter(jacks_or_better.rank_hand, all_possible_hands)))/ len(all_possible_hands)
0.20588235294117646
>>> len(list(filter(jacks_or_better.rank_hand, possible_hands_if_i_hold)))/ len(possible_hands_if_i_hold)
0.2816326530612245

Numbers don't lie! It turns out that I was throwing away an 8% chance of winning something by not holding onto low pairs.

Out of curiosity, what winning hands did I have with a pair of twos?

>>> ranks_if_hold = collections.Counter(sorted(map(jacks_or_better.rank_hand, possible_hands_if_i_hold)))
>>> hands_by_rank = {hand.value: hand.name for hand in jacks_or_better.Hands}
>>> for rank, count in ranks_if_hold.items(): print(hands_by_rank.get(rank), count)
None 14080
FOUR_OF_KIND 48
FULL_HOUSE 192
THREE_OF_A_KIND 2112
TWO_PAIR 3168

These aren't the best hands, but as those are rare, these are still probably the best bet.

Although gambling machines are designed to give the house at least a marginal edge on players, I wonder if I could have won at video poker with an optimal strategy.

Conclusions

There are a couple of things I can take from this:

  • I made more money playing an Egyptian-themed slot game than poker, although all I did was repeatedly press a button like a rat in some kind of kaleidoscopic Skinner box. I still prefer video poker as it leaves some room for the player's agency.

  • My intuitive grasp of statistics is shaky. I'll do some research before setting foot in a casino again.

  • Python is a great tool for quickly modelling a problem domain. However, my laptop takes a few seconds to rank all possible hands. Rust's speed and expressive type system might make it a better fit for further work with modelling Poker. For example, I could enumerate and classify different starting hands and factor in the payout of winning hands.


I hope you enjoyed this little post.

I'll follow up soon with more substantial writing on backend software development and cloud engineering.

0
Subscribe to my newsletter

Read articles from Simon Crowe directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Simon Crowe
Simon Crowe

I'm a backend engineer currently working in the DevOps space. In addition to cloud-native technologies like Kubernetes, I maintain an active interest in coding, particularly Python, Go and Rust. I started coding over ten years ago with C# and Unity as a hobbyist. Some years later I learned Python and began working as a backend software engineer. This has taken me through several companies and tech stacks and given me a lot of exposure to cloud technologies.