Simulating Bingo

Playing bingo is not all that interesting, however, simulating bingo is much more fun! Today I wrote some Python which performs stochastic bingo simulations across a number of rounds and number of players.

Specifically, I simulated a bingo game with 20 rounds for n players. This allows us to determine a few things. But first, a little terminology:

  • Player: An individual bingo board which lasts for only one round
  • Turn: A random number which is drawn and applied to all current players
  • Round: Resets all bingo boards for n players
  • Game: A number of bingo rounds

I wanted to know the average number of turns within a round of bingo. We would expect the number of turns per round to fall for larger numbers of players because there is greater opportunity for someone to win.

Additionally, I also wanted to know the probability of winning bingo at least once for n players. Fortunately, this is simple to compute with math. Lets assume there are 100 players, 20 rounds, and an equal chance to win each round with replacement.

We can compute the probability of not winning each round. With 100 players this is 0.99. If we take this to the power of 20, representing the number of rounds, we get the probability of not winning. Thus the probability of winning is 1 minus this value:

P(\text{Winning at least once}) = 1 - 0.99^{20} = 0.182

Now lets validate this with code!


import numpy as np
from random import sample, choice

class Player:

    def __init__(self):

        starts = [1, 16, 31, 46, 61]
        self.board = np.array([sample(range(i, i + 15), 5) for i in starts])

        self.flipped = np.zeros((5, 5), dtype=bool)
        self.flipped[2, 2] = True

    def check(self, value):

        self.flipped |= (self.board == value)

        return any([
            np.any(np.sum(self.flipped, axis=0) == 5),
            np.any(np.sum(self.flipped, axis=1) == 5),
            np.sum(np.diag(self.flipped)) == 5,
            np.sum(np.diag(np.rot90(self.flipped))) == 5
        ])

results = []

for n_players in range(10, 200, 10):
    for _ in range(20):

        rounds = 0
        winners = []
        players = [Player() for _ in range(n_players)]
        values = set(range(1, 76))

        while not winners:
            rounds += 1
            value = choice(list(values))
            values.remove(value)
            for i, player in enumerate(players):
                if player.check(value):
                    winners.append(str(i))

        results.append({
            'rounds': rounds,
            'players': n_players,
            'winners': ','.join(winners)
        })

First, I generated a plot that shows the number of rounds by number of players. As expected, this falls when the number of players increases!

Finally, I looked at the probability of winning at least once by number of players. It appears that our simulated probability is 0.19 very similar to the computed probability of 0.182!

Stay or Switch? A Frequentist Approach to Monte Hall in R

The Monte Hall is a famous brain teaser in the form of a probability puzzle. For the uninitiated, the problem is as follows: Consider three doors where two of the doors have nothing behind them and one of the doors has a prize. You are instructed to select the door with the prize.

The game show host opens a door that is different than the door you chose that does not contain the prize. You now know that the prize is either behind the door you originally chose or the door that the game show host did not open. Should you stay at your current door, or switch to the unopened door?

Initially, most people would say that it shouldn’t matter. After all, you chose the first door randomly. As it turns out, you have a 1-in-3 chance of being correct if you stay and a 2-in-3 chance of being correct if you switch! This seems impossible. The frequentist in me knows that we can simulate this using a scripting language!


Our simulation will randomly select two integers between 1 and 3; the first integer represents the contestant’s first choice whereas the second integer represents the correct answer (the door with the prize). We also denote the simulation ID in the first column.

Next we randomly determine which door will be revealed to the contestant. If the correct answer and the contestant’s choice are different, then the door that is revealed is whatever door is left (i.e., if the answer is door 1 and the choice is door 2 then door 3 is revealed).

If the correct answer and the contestant’s choice are the same, then the revealed door is chosen at random between the remaining doors (i.e., if the contestant chose door 1 and the answer is door 1, then either door 2 or 3 is revealed).

Finally, we need to determine which door is the switch door. That is, whatever door has not already been chosen and not revealed. If door 1 was originally chosen and door 2 was revealed, then door 3 is the switch door (regardless of the correct answer)

row_idanswerchoicerevealswitch
11321
22213
Example simulations

Now we can use R and the tidyverse to construct a function which will simulate Monte Hall’s problem many times. Because vectorized operations in R are fast, we will stay within that constraint for our function. Additionally, this solution leverages the row ID to prevent grouping the data frame into n groups providing further performance benefits!

simulate <- function(n = 1e6) {
  # Simulation of the Monte Hall problem
  
  values <- seq(3)
  reveal <- values
  switch <- values
  
  tibble::tibble(
    row_id = seq(n),
    answer = sample(values, n, replace = TRUE),
    choice = sample(values, n, replace = TRUE)
  ) |>
    tidyr::expand_grid(reveal) |>
    dplyr::filter(
      reveal != answer,
      reveal != choice
    ) |>
    dplyr::distinct(
      row_id,
      answer,
      choice,
      .keep_all = TRUE
    ) |>
    tidyr::expand_grid(switch) |>
    dplyr::filter(
      switch != choice,
      switch != reveal
    ) |>
    dplyr::summarize(
      stay = mean(choice == answer),
      swap = mean(switch == answer)
    )
}

This function can carry out over 1.5 million simulations in around 1 second on my machine thanks to the power of vectorization! The function starts by randomly selecting the answer and choice.

It then finds out which door to reveal by looking for the door not already chosen by the answer or choice. If the answer and choice are the same, then distinct ensures there is only one revealed door for each combination.

Finally, we determine which is the switch door. Because choice and reveal are never the same, there is no need to ensure distinct records here. The resulting data is summarized to determine the number of times the contestant would be correct if they stayed versus swapped to a the other door.


Over the 1 million simulations I performed, staying at the same door was correct 334,160 times whereas switching was correct 665,840 times. This is almost exactly 1-in-3 and 2-in-3 for staying and switching respectively sufficiently satisfying the frequentist approach to the Monte Hall problem!