Solving Seven and a half

Solving Seven and a Half
In [1]:
import pandas as pd
import numpy as np
import random
import statistics
import matplotlib.pyplot as plt
import seaborn as sns
random.seed(123)

Note: The code that I use to run the Monte Carlo simulations outlined below can be found on Github here.

Seven and a half is a card game that’s something like 2/3rds blackjack and 1/3 poker. It’s known as setto e mezzo in Italy or Siete y Media in Spain (that literally just means 7.5 in Italian/Spanish, but the point being it is popular in these other countries) and is played with a deck of 40 cards (standard deck of cards, minus the 8s, 9s, and 10s). Players receive one card face down (as does the dealer), and a player’s goal is to beat the dealer by getting a hand as close to 7 1/2 as possible without busting (going over 7.5). Face cards are worth 1/2 point and all other cards are worth their face value. A player can hit until they either choose to stay or they bust, at which point the dealer can then hit until they choose to stay or bust as well. Ties go to the dealer. When I have played this game, a player doesn’t need to place their bet on their hand until receiving their first card, though I understand this may not be traditional.

After playing the game a few times, I’ve started to get curious on what ideal gameplay actually looks like. Since this isn’t a game of perfect information (both the player and the dealer have one face-down card, so an element of bluffing – the poker aspect – is certainly at play), you can’t necessarily solve for a true dominant strategy, but it’s at least possible to map out what would be ideal gameplay if all information was known in each stage. Here’s an attempt to do that (and a ‘fun’ opportunity to walk through a Monte Carlo simulation in Python).

The goal of the game is to beat the dealer, but to start off, let’s just see what happens if your goal is just to maximize your hand, regardless of what any other player has. We’ll use a basic “hit until you reach a score of X” strategy, change what X is, and see what X gives you the highest expected value if we simulate a million hands for each value of X:

In [2]:
overall_scores = []
for i in range(15):
    threshold = (0.5 + i * 0.5)
    final_scores = []
    for j in range(1000000):
        hand = []
        deck = [2, 3, 4, 5, 6, 7, 1, 0.5, 0.5, 0.5] * 4
        random.shuffle(deck)
        hand.append(deck.pop())
        score = sum(hand)
        while score < threshold:
            hand.append(deck.pop())
            score = sum(hand)
        if score > 7.5:
            final_score = 0
        else:
            final_score = score
        final_scores.append(final_score)
    overall_scores.append([threshold, final_scores])
In [3]:
score_data = pd.DataFrame(dict(overall_scores)).mean()
scores = pd.DataFrame(score_data).reset_index()
scores.columns = ['Threshold', 'Mean Score']
scores
Out[3]:
Threshold Mean Score
0 0.5 2.952205
1 1.0 3.855234
2 1.5 4.260065
3 2.0 4.445243
4 2.5 4.620090
5 3.0 4.710584
6 3.5 4.741632
7 4.0 4.741801
8 4.5 4.589865
9 5.0 4.466452
10 5.5 4.062339
11 6.0 3.765453
12 6.5 3.025663
13 7.0 2.469758
14 7.5 1.282080

Alright, so the strategy with the highest expected hand value is “hit until your hand is at least 3.5 or 4.0,” which seems pretty reasonable, considering that’s about halfway to the max score of 7.5 (reasonably high, but not so high that you’ll bust very frequently).

Now let’s look at the two-player version of the game (Player versus Dealer) and work backwards a little. Assume you’re the dealer, and you know a player has X. Your goal is to at least tie X, which means your strategy is simply to keep hitting until your score is at least X. If you bust before you hit X, you lose. If you don’t, you win. What’re your odds of winning? Here I’ll again simulate a million hands for each X and see what we get:

In [4]:
overall_scores = []
for i in range(15):
    threshold = (0.5 + i * 0.5)
    final_scores = []
    for j in range(1000000):
        hand = []
        deck = [2, 3, 4, 5, 6, 7, 1, 0.5, 0.5, 0.5] * 4
        random.shuffle(deck)
        hand.append(deck.pop())
        score = sum(hand)
        while score < threshold:
            hand.append(deck.pop())
            score = sum(hand)
        if score > 7.5:
            final_score = 0
        else:
            final_score = 1
        final_scores.append(final_score)
    overall_scores.append([threshold, final_scores])
In [5]:
dealer_wins = pd.DataFrame(dict(overall_scores)).mean()
dealer_win = pd.DataFrame(dealer_wins).reset_index()
dealer_win.columns = ['Threshold', 'Dealer Win %']
dealer_win
Out[5]:
Threshold Dealer Win %
0 0.5 1.000000
1 1.0 1.000000
2 1.5 0.980902
3 2.0 0.971675
4 2.5 0.942755
5 3.0 0.926696
6 3.5 0.876972
7 4.0 0.846855
8 4.5 0.777397
9 5.0 0.731635
10 5.5 0.633348
11 6.0 0.568218
12 6.5 0.436844
13 7.0 0.346587
14 7.5 0.171096

Results seem pretty straightforward – the dealer’s odds of winning decrease as the score he has to beat goes up – and the odds are falling a bit less than linearly as we go – but it’s actually pretty interesting to me to see how favored the dealer is in this game. Based on this, the dealer is favored to win for any player score under 6.5, which I think suggests a couple things. First, as the player it pays to be aggressive; even if score-maximizing in the abstract happens with a cutoff of 3.5 /4 or so, you’re probably going to lose as the player unless you keep hitting. Second, bluffing clearly matters; whatever you can do to throw the dealer off his game will help you in a big way, and you need it.

Okay, now let’s take a step back and think about this from the player’s perspective. Every time you decide whether to hit, you impact the probability that you win the game in one of two ways:

  • You bust with some probability, which results in a 100% chance the dealer wins
  • Conditional upon not busting, you lower the dealer’s chances of winning by some probability

For example, say you start with an Ace, so you hand is worth 1. The table above shows a dealer with perfect information has a 100% chance of winning here. If you draw a 7, you bust, so your odds don’t get any worse – you still lose. If you draw a 6, your hand is now worth 7, so the dealer has a 35% chance of winning – your odds of winning have improved by 65 percentage points.

As a player, your goal is to maximize the probability you have of winning the game. Phrased differently, your goal is to minimize the probability the dealer has of winning the game. As such, a player should hit only when doing so increases their chances of winning (and decreases the dealer’s chances). Since we know the probability the dealer has of winning given every player score, we can then figure out exactly how a hit is expected to impact the player’s probability of winning, which tells us when the player should / should not hit.

Given that, here’s what we’ll do:

  • Take the inverse of ‘dealer win probability’ as ‘player win probability’
  • Start at each possible score (0.5 to 7.5), and simulate a million hands where you take a hit.
  • Compare starting win probability to average ending win probability to see where a hit improves your win probability.
In [6]:
player_win_prob = (1 - dealer_wins)
player_outcomes = []
for i in range(15):
    start = (0.5 + i * 0.5)
    final_scores = []
    start_probs = []
    end_probs = []
    bust_count = []
    for j in range(1000000):
        hand = [start]
        start_win_probability = player_win_prob[start]
        deck = [2, 3, 4, 5, 6, 7, 1, 0.5, 0.5, 0.5] * 4
        random.shuffle(deck)
        hand.append(deck.pop())
        final_score = sum(hand)
        try: 
            end_win_probability = player_win_prob[final_score]
        except:
            end_win_probability = 0
            
        if final_score > 7.5:
            bust = 1
        else:
            bust = 0
        
        start_probs.append(start_win_probability)
        end_probs.append(end_win_probability)
        final_scores.append(final_score)
        bust_count.append(bust)
        
    player_outcomes.append([start, statistics.mean(bust_count), statistics.mean(final_scores), statistics.mean(start_probs), statistics.mean(end_probs)])
In [7]:
player_wins = pd.DataFrame((player_outcomes))
player_wins.columns = ['score_start', 'bust_prob', 'score_end_avg', 'win_prob_start', 'win_prob_end']
player_wins['score_increase'] = player_wins['score_end_avg'] - player_wins['score_start']
player_wins['prob_change'] = player_wins['win_prob_end'] - player_wins['win_prob_start']
player_wins
Out[7]:
score_start bust_prob score_end_avg win_prob_start win_prob_end score_increase prob_change
0 0.5 0.000000 3.451197 0.000000 0.218151 2.951197 0.218151
1 1.0 0.099600 3.951945 0.000000 0.166967 2.951945 0.166967
2 1.5 0.100605 4.453678 0.019098 0.224610 2.953678 0.205512
3 2.0 0.200260 4.952095 0.028325 0.175222 2.952095 0.146897
4 2.5 0.199984 5.450374 0.057245 0.232531 2.950374 0.175286
5 3.0 0.300559 5.951943 0.073304 0.187373 2.951943 0.114069
6 3.5 0.299449 6.448955 0.123028 0.244408 2.948955 0.121380
7 4.0 0.399449 6.947668 0.153145 0.202418 2.947668 0.049273
8 4.5 0.400126 7.451454 0.222603 0.256427 2.951454 0.033824
9 5.0 0.500725 7.953501 0.268365 0.218260 2.953501 -0.050105
10 5.5 0.498952 8.446520 0.366652 0.269267 2.946520 -0.097385
11 6.0 0.600080 8.949286 0.431782 0.234253 2.949286 -0.197529
12 6.5 0.600560 9.451000 0.563156 0.278526 2.951000 -0.284630
13 7.0 0.700566 9.951140 0.653413 0.248202 2.951140 -0.405211
14 7.5 1.000000 10.451923 0.828904 0.000000 2.951923 -0.828904

Pretty straightforward! Looks like the player should hit if their hand is anything below 5 – above there, hitting has negative value (the odds of busting out are too great).

Astute cardplayers will notice that the above is a bit simplistic – I’m pretending the entire deck of cards is in play when you take a hit, when in reality at least one (or two) cards have to have been played already to make the player’s starting hand. That’s totally fair, but:

  • I think the actual analysis there is beyond the scope of what I can put together,
  • For someone not counting cards, in practice you won’t really be able to adjust your math on the fly sufficiently to use a more sophisticated version, and
  • In theory, if someone were playing with an infinite number of decks, the math above is still right, so let’s go with it.

Now – last step. Given the above, we know the dealer’s odds of winning a hand based on where the player ends up, and we know how the player should act given those odds (hit until 5 or higher, then stay). My last question is how the player’s odds of winning vary based on the card they’re dealt. Intuitively, I’d expect 6s and 7s to be great cards (clearly if you get a 7 off the bat, you’ve got nearly a 2/3rds chance of winning, which is great), but I’m not sure whether a face card (0.5) or an Ace (1) will be a lot better than a 3 or a 4 – your starting score is lower, but your odds of busting are a lot better.

Let’s take a shot at figuring out the best cards by simulating a million hands using the “hit until you reach 5 or better” strategy, and see how often the player wins at each starting card:

In [8]:
overall_outcomes = []
for i in range(1000000):
    player_hand = []
    dealer_hand = []
    deck = [2, 3, 4, 5, 6, 7, 1, 0.5, 0.5, 0.5] * 4
    random.shuffle(deck)
    player_hand.append(deck.pop())
    player_first_card = player_hand[0]
    dealer_hand.append(deck.pop())
    player_score = sum(player_hand)
    dealer_score = sum(dealer_hand)
    while player_score < 5:
        player_hand.append(deck.pop())
        player_score = sum(player_hand)
    if player_score > 7.5:
        player_final_score = 0
    else:
        player_final_score = player_score
    while dealer_score < player_final_score:
        dealer_hand.append(deck.pop())
        dealer_score = sum(dealer_hand)
    if dealer_score > 7.5:
        dealer_final_score = 0
    else:
        dealer_final_score = dealer_score
    if dealer_final_score >= player_final_score:
        outcome = 0
    else:
        outcome = 1
    overall_outcomes.append([player_first_card, outcome])
In [9]:
result = pd.DataFrame(overall_outcomes, columns = ['first_card', 'outcome'])
summary = result.groupby('first_card').agg(['mean', 'count']).reset_index()
summary.columns = ['First Card', 'Win Probability', 'Count']
summary
Out[9]:
First Card Win Probability Count
0 0.5 0.385798 300069
1 1.0 0.307743 99999
2 2.0 0.270537 99979
3 3.0 0.221334 100283
4 4.0 0.220465 99807
5 5.0 0.275419 99681
6 6.0 0.436908 99648
7 7.0 0.664114 100534

Not surprisingly, 7 was confirmed best (about a 2:1 favorite to win) – but the rest is pretty interesting. First off, a 6 is actually pretty bad! Only a 44% chance to win, and that’s the second best odds of any card in the deck. After 6, the next best starting card is actually 0.5 by a pretty wide margin – it’s almost twice as good as starting with a 3 or a 4, and isn’t much worse than a 6 (39% chance to win versus 44%). Also notably, the player’s overall odds of winning a hand here (before knowing their card) are ~35%.

So, to recap:

  • In the abstract, hitting until 3.5 or 4 gets you the highest average hand
  • Since the dealer goes second, and this analysis assumes he knows what he has to get, hitting until 5 is the best strategy.
  • The player is a pretty big underdog here, with ~35% chance of winning in a perfect information setting. Unless they’re dealt a 7, the dealer is favored, and that doesn’t change unless they’ve got at least 6.5 in their hand.
  • Given the above, it’s obvious how important the lack of perfect information is in this game – bluffing is huge for the player. In terms of optimal betting strategy you should bet big if and only if you get a 7, unless you’re trying to bluff. Conversely, if you’re the dealer, you’re not really afraid of a player betting big unless you’re pretty sure they have a 7 – anything else and you’re favored to win the hand.

All in all, this was a pretty fun and illuminating exercise – and I feel a bit better about how to approach the game going forward.