One way to answer the question is to use the so called Bayes’s Theorem, as explained by Allen B. Downey here.
Note: Below, you will find a shortened explanation. To understand the topic more satisfactorily you will likely need to read the first two chapters of Think Bayes by Allen B. Downey.
First the theorem:
\[P(A|B) = \frac{P(A) * P(B|A)}{P(B)}\]
which can be also written as:
\[P(H|D) = \frac{P(H) * P(D|H)}{P(D)}\qquad{(9)}\]
where:
Regarding the priors or P(H), initially it is equally likely for the car to be behind any of the three doors. Therefore, the probability that the car is behind Door X is: 1 out of 3, so 1/3. This can be represented as:
import DataFrames as Dfs
df = Dfs.DataFrame(Dict("Door" => 1:3))
df.prior = repeat([1//3], 3) # 1//3 is Rational (a fraction)
df
| Door | prior |
|---|---|
| 1 | 1//3 |
| 2 | 1//3 |
| 3 | 1//3 |
Let’s move to likelihood or P(D|H). If the car was behind Door 1, then the host may open either Door 2 or Door 3. Hence, the probability of him opening Door 3 (as he did in the problem description) is 1 out of 2, so 1/2 or 0.5. If the car was behind Door 2, then the host had no other option but to open Door 3 (since Door 1 is the trader’s choice and he doesn’t want to reveal the car behind Door 2). Therefore, in this case the probability is equal to 1 (certain event). If the car were behind Door 3, then there is no way the host would have opened it (since it would have revealed the car and spoiled the game). So, in this case the probability is 0.
Let’s add this information to the table.
df.likelihood = [1//2, 1, 0]
df
| Door | prior | likelihood |
|---|---|---|
| 1 | 1//3 | 1//2 |
| 2 | 1//3 | 1 |
| 3 | 1//3 | 0//1 |
Now we perform a so called Bayesian update, e.g. per the formula in Equation 9: first we multiply \(P(H)\) (prior) by \(P(D|H)\) (likelihood) then we divide it by \(P(D)\) (sum of all probabilities) like so:
function bayesUpdate!(df::Dfs.DataFrame)::Nothing
unnorm = df.prior .* df.likelihood
df.posterior = unnorm ./ sum(unnorm)
return nothing
end
bayesUpdate!(df)
df # to see Rationals as floats: convert.(Flt, df.posterior)
| Door | prior | likelihood | posterior |
|---|---|---|---|
| 1 | 1//3 | 1//2 | 1//3 |
| 2 | 1//3 | 1 | 2//3 |
| 3 | 1//3 | 0//1 | 0//1 |
Clearly, switching to Door 2 gives the trader a better chance of winning the car (\(P = \frac{2}{3} \approx 0.66\)) than remaining with the original choice (\(P = \frac{1}{3} \approx 0.33\)).
Note: To see the posterior as floats you may use, e.g.
convert.(Flt, df.posterior).
If that doesn’t convince you then let’s do a computer simulation.
import Random as Rnd
mutable struct Door
isCar::Bool
isChosen::Bool
isOpen::Bool
end
function get3RandDoors()::Vec{Door}
cars::Vec{Bool} = Rnd.shuffle([true, false, false])
chosen::Vec{Bool} = Rnd.shuffle([true, false, false])
return [Door(car, chosen, false)
for (car, chosen) in zip(cars, chosen)]
end
# open first non-chosen, non-car door
function openEligibleDoor!(doors::Vec{Door})::Nothing
@assert length(doors) == 3 "need 3 doors"
indEligible::Int = findfirst(d -> !d.isCar && !d.isChosen, doors)
doors[indEligible].isOpen = true
return nothing
end
# swap to first non-chosen, non-open door
function swapChoice!(doors::Vec{Door})::Nothing
@assert length(doors) == 3 "need 3 doors"
indCurChosen::Int = findfirst(d -> d.isChosen, doors)
ind2Choose::Int = findfirst(d -> !d.isChosen && !d.isOpen, doors)
doors[indCurChosen].isChosen = false
doors[ind2Choose].isChosen = true
return nothing
end
We start by defining a Door structure that has all the necessary fields in order to simulate our game-show. Notice the mutable keyword, it will allow us to change a property of a Door in-place. Anyway, we follow the structure definition with a random generator of three doors (get3RandDoors) door opener openEligibleDoor and swapChoice. All the above act per the game description. Of note, findfirst accepts a predicate and a vector. The predicate is a function that is executed on consecutive elements of the vector (doors) and returns true or false for each of them. In the end findfirst returns the first index from the vector (doors) for which the predicate was true or nothing if no such thing happened. Normally, we should handle both the possible cases (Int if true and nothing if all the tests came negative). However, if we’re pretty sure to get the Int and actually we want to get the error if we’re mistaken, then we may leave it as in the code snippet above.
Now, we only need a way to determine did the player win.
function didTraderWin(doors::Vec{Door})::Bool
indWinner::Union{Nothing, Int} = findfirst(
d -> d.isChosen && d.isCar, doors)
return isnothing(indWinner) ? false : true
end
function getResultOfDoorsGame(shouldSwap::Bool=false)::Bool
doors::Vec{Door} = get3RandDoors()
openEligibleDoor!(doors)
if shouldSwap
swapChoice!(doors)
end
return didTraderWin(doors)
end
Of note, in the above snippet we used findfirst once more. However, this time we may not find the index of a winner (indWinner). We mark that possibility with an Union type (indWinner is an Int if behind the doors chosen by the trader is a car or nothing otherwise). Lastly, we handle such a situation with isnothing and a ternary expression.
Anyway, now we are ready to estimate the probability:
# treats: true as 1, false as 0
function getAvg(successes::Vec{Bool})::Flt
return sum(successes) / length(successes)
end
function getProbOfWinningDoorsGame(shouldSwap::Bool=false,
nSimul::Int=10_000)::Flt
@assert 1e3 <= nSimul <= 1e5 "nSimul must be in range [1e3 - 1e5]"
return [getResultOfDoorsGame(shouldSwap) for _ in 1:nSimul] |> getAvg
end
Rnd.seed!(1492)
getProbOfWinningDoorsGame(false), getProbOfWinningDoorsGame(true)
(0.3354, 0.6668)
which ends up to be equivalent to our theoretical calculations.
Now, you could argue the doing 10,000 computer simulations to estimate the probability is overkill, after all the total number of possibilities cannot be that big for this simple case. Well, I guess you’re right. So, let’s try again, this time we will list all the possible scenarios and see in how many of them we succeed.
# list all the possibilities of car location and choice location
function getAll3DoorSets()::Vec{Vec{Door}}
allDoorSets::Vec{Vec{Door}} = []
subset::Vec{Door} = Door[]
for indCar in 1:3, indChosen in 1:3
subset = [Door(false, false, false) for _ in 1:3]
subset[indCar].isCar = true
subset[indChosen].isChosen = true
push!(allDoorSets, subset)
end
return allDoorSets
end
Now we change the return statements in our openEligibleDoor! and swapChoice.
# open first non-chosen, non-car door
function openEligibleDoor!(doors::Vec{Door})::Vec{Door}
@assert length(doors) == 3 "need 3 doors"
indEligible::Int = findfirst(d -> !d.isCar && !d.isChosen, doors)
doors[indEligible].isOpen = true
return doors # instead of: return nothing
end
# swap to first non-chosen, non-open door
function swapChoice!(doors::Vec{Door})::Vec{Door}
@assert length(doors) == 3 "need 3 doors"
indCurChosen::Int = findfirst(d -> d.isChosen, doors)
ind2Choose::Int = findfirst(d -> !d.isChosen && !d.isOpen, doors)
doors[indCurChosen].isChosen = false
doors[ind2Choose].isChosen = true
return doors # instead of: return nothing
end
Thanks to this small change we can answer our question with this few-liner.
map(didTraderWin ∘ openEligibleDoor!, getAll3DoorSets()) |> getAvg,
map(didTraderWin ∘ swapChoice! ∘ openEligibleDoor!,
getAll3DoorSets()) |> getAvg
(0.3333333333333333, 0.6666666666666666)
Note,
∘is a function composition operator that you can obtain by typing\circand pressing Tab. Themap(fn2 ∘ fn1, xs)means take everyxofxsand send it tofn1as an argument. Then send the result offn1(x)as an argument tofn2. Finally, present the result offn2(fn1(x))(executed on allxs) as a collection.
And that’s it. Three methods, three similar results. Time to make that door swap.
Interestingly, the code presented above will likely work right only for the three doors scenario (since, e.g. we’re actually open/swap to the first eligible doors). If you’re still not tired and up for challenges try to answer the same question for 5 and 7 doors scenarios. Just remember: 1 car in a random location, 1 empty door opened at random position, a random switch performed). Feel free to modify some (e.g. 1 solution only) or all the code in this chapter. Alternatively, if you are a bit exhausted you may just check the exemplary solution in the code snippets.