Conditional probability in Python: two children, Monty Hall, and updating on evidence
The R section of Blitzstein and Hwang’s second chapter, rebuilt in Python in the style of CS61A: predicates as values, a frequency that estimates P(A|B), and a Monty Hall game split into pure logic and input.
0. Where this picks up
This note continues Counting and chance in Python,
which rebuilt Chapter 1’s R section. Chapter 2 of Blitzstein and Hwang’s
Introduction to Probability is about conditional probability, and its R
section (§2.10) checks two famous results by simulation: the two-child
problem and the Monty Hall problem. The same habits apply: small named
functions, doctests, and functions passed around as values. The standard
library is enough, and replicate from the previous note is not needed here.
1. Conditional probability as a frequency
The frequentist reading of conditional probability says: repeat an experiment times, count the repetitions where happened () and where both and happened (). For large ,
We only look at the runs where happened, and ask how often happened among those.
1.1. Simulating families
Let each family have two children, each a girl (1) or a boy (2) with equal
probability, independently. R builds two vectors, one per child, and Python
can do the same. zip then pairs them up, one tuple per family:
import random
random.seed(110)
n = 10 ** 5
child1 = random.choices([1, 2], k=n) # elder child of each family
child2 = random.choices([1, 2], k=n) # younger child
families = list(zip(child1, child2))
families[0] might be (1, 2): an elder girl and a younger boy. Keeping the
two children of a family in one tuple means we can never accidentally pair
the elder child of one family with the younger child of another.
1.2. No elementwise comparison
R’s child1 == 1 compares every entry of a vector with 1 and returns a
vector of TRUE and FALSE. Python’s == compares whole values, and a
list is not equal to a number.
What would Python display? Comparing a list
What would Python display?
>>> child1 = [1, 2, 1]
>>> child1 == 1Hints
Python’s == compares the whole left value with the whole right value.
Is a list ever equal to a number?
Reference solution
False. Python compares the list [1, 2, 1] as a whole with the number 1, and they are not equal. Nothing is compared entry by entry, unlike R, where child1 == 1 gives TRUE FALSE TRUE. Write [c == 1 for c in child1] to get [True, False, True].
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
So Python spells out the loop with a comprehension. R’s & and | become
Python’s and and or, applied inside it:
>>> pairs = [(1, 1), (1, 2), (1, 1)]
>>> [c1 == 1 and c2 == 1 for c1, c2 in pairs]
[True, False, True]
What would Python display? Counting with and
What would Python display?
>>> pairs = [(1, 1), (1, 2), (2, 1), (1, 1)]
>>> sum([c1 == 1 and c2 == 1 for c1, c2 in pairs])Hints
The comprehension is [True, False, False, True].
sum counts each True as 1.
Reference solution
2. Only the first and last pairs have both children equal to 1, so the list is [True, False, False, True], and its sum is 2. This is for = both girls.
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
1.3. Events are predicates
Rather than writing a new count for every question, describe each event as a
predicate: a function that takes an outcome and returns True or
False. Then one function estimates any conditional probability. This is
the CS61A idea of passing functions as arguments.
Estimate a conditional probability
Complete conditional_frequency(outcomes, a, b). a and b are predicates, functions that take one outcome and return True or False. Return : the number of outcomes where both hold, divided by the number where b holds. Return None if b never holds.
Public tests
1. Elder child is a girl
>>> families = [(1, 1), (1, 2), (2, 1), (2, 2)]
>>> both_girls = lambda f: f == (1, 1)
>>> conditional_frequency(families, both_girls, lambda f: f[0] == 1)
0.5
2. At least one girl
>>> families = [(1, 1), (1, 2), (2, 1), (2, 2)]
>>> both_girls = lambda f: f == (1, 1)
>>> round(conditional_frequency(families, both_girls, lambda f: 1 in f), 3)
0.333
3. B never happens
>>> families = [(1, 1), (1, 2), (2, 1), (2, 2)]
>>> both_girls = lambda f: f == (1, 1)
>>> conditional_frequency(families, both_girls, lambda f: False) is None
True
4. Other outcomes
>>> rolls = [1, 2, 3, 4, 5, 6]
>>> conditional_frequency(rolls, lambda r: r == 6, lambda r: r % 2 == 0)
0.3333333333333333
Output and test details
Hints
Count with sum(1 for o in outcomes if b(o)).
Count the same way, requiring a(o) and b(o).
Check for before dividing.
Reference solution
def conditional_frequency(outcomes, a, b):
n_b = sum(1 for o in outcomes if b(o))
n_ab = sum(1 for o in outcomes if a(o) and b(o))
return None if n_b == 0 else n_ab / n_b
The function knows nothing about children or dice. It only calls the predicates it is given, which is what lets one definition answer every question in the note.
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
With conditional_frequency written, the book’s two questions are two lines:
both_girls = lambda f: f == (1, 1)
elder_girl = lambda f: f[0] == 1
at_least_one_girl = lambda f: 1 in f
conditional_frequency(families, both_girls, elder_girl)
conditional_frequency(families, both_girls, at_least_one_girl)
Example 1.1 · What one run gave
With the seed and families above, the first estimate was and the second . The exact answers from Example 2.2.5 in the book are and . Knowing that the elder child is a girl and knowing that at least one child is a girl are different pieces of information, and they lead to different answers.
2. The Monty Hall problem
A car is behind one of three doors, goats behind the other two. You pick a door. Monty, who knows where the car is, opens one of the other doors to show a goat, and offers to let you switch. Should you?
2.1. Never switching
Assume you always pick door 1. Then the never-switch strategy wins exactly when the car is behind door 1, and we do not even need to simulate Monty:
random.seed(110)
cardoor = random.choices([1, 2, 3], k=n)
sum(car == 1 for car in cardoor) / n
One run with seed 110 gave , close to .
2.2. Modeling Monty
To simulate switching we need Monty’s choice. When your door hides a goat, Monty has exactly one door he can open. When your door hides the car, he has two, and in the game he picks at random. For code that is easy to test, the next exercise makes a fixed choice instead, always the lowest-numbered door allowed. Randomness can be added back around it later.
Which door does Monty open?
Complete monty_opens(chosen, car) for doors 1, 2, and 3. Monty never opens the player’s door or the car’s door. When two doors are allowed (the player picked the car), return the lower-numbered one, so the result is always predictable.
Public tests
1. Only one door allowed
>>> monty_opens(1, 2)
3
>>> monty_opens(2, 1)
3
>>> monty_opens(3, 1)
2
2. Player picked the car
>>> monty_opens(1, 1)
2
>>> monty_opens(2, 2)
1
>>> monty_opens(3, 3)
1
Output and test details
Hints
Consider each door in (1, 2, 3) and keep the ones that are neither chosen nor car.
min of the allowed doors gives the lower-numbered one.
Reference solution
def monty_opens(chosen, car):
return min(door for door in (1, 2, 3) if door != chosen and door != car)
When chosen != car, exactly one door is allowed; when they are equal, two are, and min picks the lower. In the real game Monty chooses at random in that case, which the simulation in the note adds back.
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
Once Monty has opened a door, switching means taking the one door that is
neither yours nor his. Doors 1, 2, and 3 add up to 6, so that door is
6 - chosen - opened.
The chance of winning by switching
You pick door 1, Monty opens a door with a goat behind it, and you always switch. What is the probability that you win the car? Answer as a fraction.
Hints
Switching wins exactly when your first pick was wrong.
Your first pick is wrong with probability .
Reference solution
. If your first pick is the car (probability ), switching loses. If your first pick is a goat (probability ), Monty must open the other goat door, so the door you switch to has the car. The simulation in the note gives .
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
To check that answer, simulate the whole game, with Monty choosing at random when he has a choice:
def play_switching():
"""Pick door 1, watch Monty open a goat door, switch; return True on a win."""
car = random.randint(1, 3)
chosen = 1
opened = random.choice([d for d in (1, 2, 3) if d != chosen and d != car])
return 6 - chosen - opened == car
random.seed(110)
sum(play_switching() for _ in range(n)) / n
Example 2.1 · What one run gave
With seed 110 and games, switching won of the time, close to . Never switching wins only when the first pick was right, so switching wins exactly when it was wrong.
2.3. Playing the game
The book ends with an interactive monty() function. In Python, input
plays the role of R’s scan: it prints a prompt and returns what the player
types, always as a string, so int(...) converts the door number.
An f-string replaces R’s paste, and startswith replaces
substr(reply, 1, 1).
import random
def switch_door(chosen, opened):
"""The one door that is neither CHOSEN nor OPENED."""
return 6 - chosen - opened
def monty():
"""Play one round of the Monty Hall game at the terminal."""
doors = [1, 2, 3]
car = random.choice(doors) # where the car is
print("Monty Hall says 'Pick a door, any door!'")
chosen = int(input('Your door (1, 2, or 3): '))
# Monty opens a door that is neither yours nor the car's.
opened = random.choice([d for d in doors if d != chosen and d != car])
print(f'Monty opens door {opened}!')
reply = input('Would you like to switch (y/n)? ')
if reply.strip().lower().startswith('y'): # "yes", "yep", "Yeah!"
chosen = switch_door(chosen, opened)
print('You won!' if chosen == car else 'You lost!')
Save this as monty.py and run python3 -i monty.py, then call monty() as
often as you like. The exercises on this page cannot run it, because they
have no keyboard input.
Notice the shape of the program. Every decision that can be tested without a
person at the keyboard (switch_door, and monty_opens in the exercise) is
a pure function. Only the outer monty talks to the player. CS61A calls this
separating computation from input and output, and it is why the earlier
exercises can check the logic with doctests.
3. Posteriors become priors
The end of Chapter 2 (Figure 2.7) describes how evidence arrives in steps. Before any evidence, is the prior. After learning , the posterior comes from Bayes’ rule. That posterior is the prior for the next step, and learning gives .
Theorem 3.1 · Bayes’ rule with a complement
If and , then
The denominator is , expanded by the law of total probability.
Update a probability on evidence
Complete bayes_update(prior, p_evidence_if_a, p_evidence_if_not_a). Given , , and , return using Bayes’ rule with the law of total probability. The tests pass Fraction values so the answers are exact. Assume .
Public tests
1. Monday: at least one girl
>>> bayes_update(Fraction(1, 4), 1, Fraction(2, 3))
Fraction(1, 3)
2. Tuesday: the elder is a girl
>>> bayes_update(Fraction(1, 3), 1, Fraction(1, 2))
Fraction(1, 2)
3. Evidence that rules out not-A
>>> bayes_update(Fraction(1, 100), 1, 0)
Fraction(1, 1)
4. Evidence that says nothing
>>> bayes_update(Fraction(1, 5), Fraction(1, 2), Fraction(1, 2))
Fraction(1, 5)
Output and test details
Hints
The numerator is .
The denominator adds to the numerator.
Reference solution
def bayes_update(prior, p_evidence_if_a, p_evidence_if_not_a):
numerator = p_evidence_if_a * prior
return numerator / (numerator + p_evidence_if_not_a * (1 - prior))
Because the result is again a probability for , it can be passed straight back in as the next prior: that is sequential updating. Evidence equally likely under and not leaves the prior unchanged.
Source: Adapted from Blitzstein & Hwang, Introduction to Probability, Chapter 2 and §2.10 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
Updating one piece of evidence at a time is then a chain of calls, each output feeding the next input. Take the two-child family again, with = both children are girls, so .
Example 3.2 · Monday, then Tuesday
Monday: we learn = at least one child is a girl. If holds, certainly does; if not, holds in two of the three remaining equally likely families, so .
>>> monday = bayes_update(Fraction(1, 4), 1, Fraction(2, 3))
>>> monday
Fraction(1, 3)Tuesday: we also learn = the elder child is a girl. Given and not , the family is girl–boy or boy–girl, so .
>>> bayes_update(monday, 1, Fraction(1, 2))
Fraction(1, 2)Both answers match the simulations in Section 1. Learning and then is the same as learning “the elder child is a girl” directly, which is a useful check on the arithmetic.
fractions.Fraction keeps the arithmetic exact, so stays rather
than becoming with a rounding error.
References
- Joseph K. Blitzstein and Jessica Hwang, Introduction to Probability, Chapter 2: Example 2.2.5 (two children), the Monty Hall problem, Figure 2.7 (sequential updating), and §2.10 (R). Course materials: stat110.net.
- CS61A and John DeNero’s Composing Programs, for higher-order functions, doctests, and separating logic from input and output.
- Python documentation:
random,fractions, andinput.
Practice is kept only while this page is open.