Goat or Car? Solving The Monty Hall Problem With Python and NumPy
It's a different Monty this time—not Monty Python and not Monty from The White Room analogy, either
You know, sometimes, on social media, a story picks up momentum and gets shared over and over again in different forms, on different platforms. Recently, one such story was that of Marilyn vos Savant and the furore that arose around her response to the Monty Hall problem. Spoiler alert: she was right, and thousands of idiots with PhDs who condescendingly criticised her were wrong.
I know this story is making the rounds on social media because I saw it on one of my feeds, and then my wife, whose social media timeline normally has no overlap with mine, also mentioned it. It's the first time I heard of the Monty Hall problem. I didn't know who Monty Hall was, either, and nor had I ever heard of his TV game show.
Anyway, we were discussing it at home with our kids, and some members of the family couldn't accept the result was correct. So…
…we wrote a Python simulation to confirm it, of course.
The Monty Hall Problem
I won't focus on the social media slant of this story. You can look up the social media posts for that. Here, I care about the problem and its solution.
Here's the question someone asked Vos Savant in a newspaper column:
Suppose you're on a game show, and you're given the choice of three doors. Behind one door is a car, behind the others, goats. You pick a door, say #1, and the host, who knows what's behind the doors, opens another door, say #3, which has a goat. He says to you, "Do you want to pick door #2?" Is it to your advantage to switch your choice of doors? -- (from Marilyn vos Savant | The Game Show Problem)
So, you pick a door, which remains closed. The host reveals one of the other doors, but one that has a goat—so it's a losing option that’s been removed. Do you keep your first choice or switch to the remaining door? If you've not come across this problem before, take some time to think about what you'd do.
The intuitive answer is that there's still a 50/50 chance for either of the remaining closed doors to have the car. So it seems like it wouldn't matter whether you switch or not.
But Vos Savant's answer was different. She claimed that switching was the better option if you wanted to win the car. And she stuck with her answer even after the barrage of mathematicians confidently claiming she was wrong.
Why Switching is a No-Brainer
There are three doors. There's a car behind one of them and a goat behind each of the other two.
But in the game, the doors are closed, so you don't know which is which. In the game, you need to choose a door. At this point in the game, you have a one-in-three chance of picking the door that's hiding the car.
This image shows what's behind the doors, but in the game, you can't know this at this stage. The doors remain closed.
Whichever door you choose, there's at least one goat behind the remaining doors. The game host opens one of the other doors showing the goat. This door is removed from contention.
If your first choice was a door with a goat, then the remaining door must have a car. Switching is the best option, but remember that you don't know what's behind your first-choice door in the game.
If your first choice had a car behind it, then the other door must have a goat. Switching leads to losing the car in this case.
Here are all the options:
So, if your strategy is to always keep your original choice in the second part of the game after the host reveals one of the goats, you'll get a goat two out of three times you play, on average. And you'll get a car one out of three times. Look at the purple "Keep" options at the bottom of the diagram above.
"Keep" gives you a 1 in 3 chance of winning, or 33.33…%.
But look at what happens when your strategy is to switch doors in the second part of the game. You'll get the car two out of every three times you play, on average.
"Switch" gives you a 2 in 3 chance of winning, or 66.66…%.
A minute of your time. Thank you to all of you who read these articles. And thank you to the paid subscribers and those who contribute from time to time. As you can imagine, a lot of time and effort goes into these articles, all in the evenings and weekends.
If you want to contribute, you can upgrade to a paid subscription, either just for a few months ($5 per month) or using the yearly option ($50).
Alternatively, you can make a one-off contribution whenever you can, any amount you want.
Now, back to the article.
"I Don't Believe That"
When Marilyn vos Savant told her readers they should always switch, she received lots of replies rubbishing her response. Many were mathematicians with PhDs, with a more-than-a-touch of ego and arrogance. You already know how this story ends. Vos Savant was right. They were wrong. And this incident tells us a lot about how much to trust self-important, degree-waving, confident-sounding experts keen to tell us what they think. But that's another topic…
There's only one way to convince you that the reasoning I followed in the previous section, which confirms Vos Savant's answer, is correct.
I'll present two versions of Python code. This is often the approach I take when solving a problem using Python. First, write the most basic and straightforward option. Then, if needed, make it better, more efficient, neater, more Pythonic.
Let's start with the straightforward version.
Version 1
Start by creating a list with three doors. You can use 0
to indicate a goat and 1
for a car. Therefore, 1
is the winning option:
You create a list containing three 0
's but then pick a random index and change that value to 1
. The code places the car in a random position each time you run the code:
[0, 0, 1]
The player then chooses a door in the game. Let's pick one at random:
You pop the chosen door out of the list, leaving the remaining two in doors
:
[1, 0, 0]
First choice door is 0.
Remaining doors are [1, 0]
We know that whatever is behind the door the player chooses, there's at least one goat left behind the remaining doors. So, you can check whether the first of the remaining doors is 0
and pop it out, or else, you can pop the last one:
Note that I removed the print()
calls from the code.
So, if you switch doors, the outcome is whatever is left in the list doors
, which only has one element now. If you choose to keep your original door, then the outcome is the value of first_choice
.
You can now run this experiment many times and store all the results for both strategies, the "switch" and "keep" strategies:
And this confirms that you're better off switching doors:
The success rate when switching is 66.681%
The success rate when keeping is 33.319%
Let's stick this code in a function and time it using the timeit
module:
This code repeats the whole experiment ten times, each time running the simulation for 1,000,000 games. You can comment out the print()
calls in the function if you prefer.
The amount of time needed to run this code will depend on your setup. On my computer, this took just over 6 seconds (although it was around 18 seconds when I ran it on an older device):
6.507697333999886
This works. It confirms the counterintuitive result of the Monty Hall problem.
But this is a Python publication. So, let's try to find a different way to write this code.
Version 2 Using NumPy
Let's use NumPy in this version. If you're new to NumPy, you can read some articles I wrote here on The Stack in The NumPy for Numpties (sort-of) series, especially the first of these articles: Why Can't I Just Use A List? • Understanding NumPy's ndarray
(A NumPy for Numpties article). There are also plenty of NumPy tutorials on Real Python.
You can start by creating the three doors in all repeats of the experiment in a single NumPy array. For now, we'll use 10 experiment repeats so we can easily display the array. You'll change this back to 1,000,000 later.
Start by creating a 10 x 3
array full of 0
's using np.zeros()
and replace the first column with 1
's for now:
Note that there are two pairs of parentheses after np.zeros
since the argument must be a tuple. The expression doors[:, 0]
selects the first element in every row.
This is what the array doors
looks like at this point:
[[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]]
But, you don't want the car, represented by 1
's, to always be in the first place, of course. So, you can randomise the positions in each row. Although NumPy has a .shuffle()
method, this would shuffle the columns for all the rows in the same way. Instead, you can use .permuted()
to shuffle each row independently of the others:
In NumPy, the newer and preferred technique to deal with randomness is to create a random number generator object using default_rng()
and then call the methods on this object. The second argument in the rng.permuted()
call, axis=1
, ensures that the rows are shuffled. The final argument sets the output array for the result. In this case, you choose to overwrite doors
.
Here's the doors
array:
[[1. 0. 0.]
[0. 0. 1.]
[0. 0. 1.]
[0. 0. 1.]
[0. 1. 0.]
[0. 1. 0.]
[1. 0. 0.]
[0. 0. 1.]
[0. 0. 1.]
[0. 1. 0.]]
The winning options are now in random positions for each run of the game. Each row represents one run of this game.
Since the positions are random, we can safely assume that the player will always pick the first door, the one with index 0
, as their first choice. There's no loss of generality in doing this, as mathematicians would say!
Therefore, the first column contains the player's first choice in all the runs of the game.
The second and third columns represent the remaining doors. Recall that these must contain at least one goat, or 0
. So, if we sort the second and third elements in each row in numerical order, we're guaranteed to have a goat, a 0
, in the second column. This middle column, therefore, represents the door that the game host opens to reveal a goat:
The expression doors[:, 1:].sort()
indexes the array by selecting all rows but only the columns from index 1
onwards. In this case, that's the second and third columns. Therefore, .sort()
only sorts the elements in the second and third spots in each row. Since .sort()
sorts in place, the array doors
is now guaranteed to have a goat in the middle column:
[[1. 0. 0.]
[0. 0. 1.]
[1. 0. 0.]
[0. 0. 1.]
[0. 0. 1.]
[1. 0. 0.]
[0. 0. 1.]
[1. 0. 0.]
[0. 0. 1.]
[0. 0. 1.]]
So, the first column is the player's first choice. The last column is the only door left at the end of the remaining two. If the player chooses to switch doors, they switch from the first to the last column or from index 0
to index 2
.
Therefore, the mean of the first column is the likelihood of success for the "keep" strategy, when the player always keeps their original choice. The mean of the final column is the likelihood of success for the "switch" strategy. You can do this since you used 0
to represent a loss and 1
to represent a win.
So, let's work out these means and display them. This is also the time to change repeat
to 1,000,000, as it was in version 1 above:
The output confirms the success rates and that switching is the better strategy, as before:
The success rate when switching is 66.641%
The success rate when keeping is 33.358%
It's not surprising the results are the same. The two code versions are different ways of achieving the same outcome.
One last step. As you did earlier, you can place this code in a function and time it:
And here's the result:
0.3716881249999915
It took less than half a second this time to run 10 experiments, each with 1,000,000 games. Definitely quicker than version 1.
And I'm sure some other techniques and modules can shave even more off this time. Feel free to make any suggestions in the comments below.
Edit: And indeed, a long-time reader of The Python Coding Stack did suggest a faster option, which is also simpler than my NumPy version.
Here it is:
The array car_positions
includes the index for where the car is located in the set of three doors. NumPy's rng.integers()
excludes the endpoint, as is common in most number ranges in Python. Therefore, car_positions
will contain 0
's, 1
's, and 2
's.
The array initial_choices
has a similar structure, but these indices represent the player's first choice of door.
The !=
and ==
operators return NumPy arrays containing Boolean values when used on NumPy arrays, unlike say when you use them on lists when a single Boolean value is returned. And recall that True
is equivalent to 1
and False
is equivalent to 0
.
So, if the player has a strategy to keep their original choice, then the mean of keep_wins
gives us the success rate for this strategy.
And if the initial choice doesn't coincide with a door with a car, then switching guarantees a win. This is what's happening when populating switch_wins
.
This code confirms the same two-thirds success rate when switching. But here's how long this took:
0.07608441599995786
Thanks
for suggesting this solution. I got too stuck into playing around with NumPy arrays and didn't see this better option!Final Words
Humans are not good with statistical intuition. There are many studies to show this. But the probabilistic reasoning earlier in this article and the Python code in the previous sections confirm that, in this case, the non-intuitive answer is indeed correct.
If you're ever offered to play the Monty Hall game in real life, you should always switch. Unless you want a goat, of course!
Code in this article uses Python 3.13
The code images used in this article are created using Snappify. [Affiliate link]
For more Python resources, you can also visit Real Python—you may even stumble on one of my own articles or courses there!
And you can find out more about me at stephengruppetta.com
Further reading related to this article’s topic:
Appendix: Code Blocks
Code Block #1
import random
doors = [0, 0, 0]
doors[random.randint(0, 2)] = 1
print(doors)
Code Block #2
import random
doors = [0, 0, 0]
doors[random.randint(0, 2)] = 1
print(doors)
first_choice = doors.pop(random.randint(0, 2))
print(
f"First choice door is {first_choice}.\n"
f"Remaining doors are {doors}"
)
Code Block #3
import random
doors = [0, 0, 0]
doors[random.randint(0, 2)] = 1
first_choice = doors.pop(random.randint(0, 2))
if doors[0] == 0:
doors.pop(0)
else:
doors.pop(1)
Code Block #4
import random
repeats = 1_000_000
switch_results = []
keep_results = []
for _ in range(repeats):
doors = [0, 0, 0]
doors[random.randint(0, 2)] = 1
first_choice = doors.pop(random.randint(0, 2))
if doors[0] == 0:
doors.pop(0)
else:
doors.pop(1)
keep_results.append(first_choice)
switch_results.append(doors[0])
print(
f"The success rate when switching is "
f"{sum(switch_results) / len(switch_results):.3%}"
)
print(
f"The success rate when keeping is "
f"{sum(keep_results) / len(keep_results):.3%}"
)
Code Block #5
import random
import timeit
def solve_monty_hall_version_1():
repeats = 1_000_000
switch_results = []
keep_results = []
for _ in range(repeats):
doors = [0, 0, 0]
doors[random.randint(0, 2)] = 1
first_choice = doors.pop(random.randint(0, 2))
if doors[0] == 0:
doors.pop(0)
else:
doors.pop(1)
keep_results.append(first_choice)
switch_results.append(doors[0])
print(
f"The success rate when switching is "
f"{sum(switch_results) / len(switch_results):.3%}"
)
print(
f"The success rate when keeping is "
f"{sum(keep_results) / len(keep_results):.3%}"
)
print(
timeit.timeit(
"solve_monty_hall_version_1()",
globals=globals(),
number=10,
)
)
Code Block #6
import numpy as np
repeats = 10
doors = np.zeros((repeats, 3))
doors[:, 0] = 1
print(doors)
Code Block #7
import numpy as np
repeats = 10
doors = np.zeros((repeats, 3))
doors[:, 0] = 1
rng = np.random.default_rng()
rng.permuted(doors, axis=1, out=doors)
print(doors)
Code Block #8
import numpy as np
repeats = 10
doors = np.zeros((repeats, 3))
doors[:, 0] = 1
rng = np.random.default_rng()
rng.permuted(doors, axis=1, out=doors)
doors[:, 1:].sort()
print(doors)
Code Block #9
import numpy as np
repeats = 1_000_000
doors = np.zeros((repeats, 3))
doors[:, 0] = 1
rng = np.random.default_rng()
rng.permuted(doors, axis=1, out=doors)
doors[:, 1:].sort()
means = doors.mean(axis=0)
print(
f"The success rate when switching is "
f"{means[2]:.3%}"
)
print(
f"The success rate when keeping is "
f"{means[0]:.3%}"
)
Code Block #10
import timeit
import numpy as np
def solve_monty_hall_version_2_numpy():
repeats = 1_000_000
doors = np.zeros((repeats, 3))
doors[:, 0] = 1
rng = np.random.default_rng()
rng.permuted(doors, axis=1, out=doors)
doors[:, 1:].sort()
means = doors.mean(axis=0)
print(
f"The success rate when switching is "
f"{means[2]:.3%}"
)
print(
f"The success rate when keeping is "
f"{means[0]:.3%}"
)
print(
timeit.timeit(
"solve_monty_hall_version_2_numpy()",
globals=globals(),
number=10,
)
)
Code Block #11
import timeit
import numpy as np
def solve_monty_hall_version_3_numpy():
repeats = 1_000_000
rng = np.random.default_rng()
car_positions = rng.integers(0, 3, repeats)
initial_choices = rng.integers(0, 3, repeats)
switch_wins = np.mean(car_positions != initial_choices)
keep_wins = np.mean(car_positions == initial_choices)
print(
f"The success rate when switching is "
f"{switch_wins:.3%}"
)
print(
f"The success rate when keeping is "
f"{keep_wins:.3%}"
)
print(
timeit.timeit(
"solve_monty_hall_version_3_numpy()",
globals=globals(),
number=10,
)
)
For more Python resources, you can also visit Real Python—you may even stumble on one of my own articles or courses there!
And you can find out more about me at stephengruppetta.com
I didn't use np, but I did generate an array:
tries = []
for n in range(n_times+1):
____doors = [goat, goat, goat]
____doors[randint(0,2)] = 'car'
____tries.append(doors)
(I really wish Substack had better a comment interface!)
Just as an aside:
dpick = randint(0,2)
dopen = door_with_a_goat(dpick)
dswap = 3 - (dpick + dopen)