Dorian’s Math
Probability & statistics

Counting and chance in Python: simulating the matching and birthday problems

The R section of Blitzstein and Hwang’s first chapter, rebuilt in Python in the style of CS61A: small named functions, doctests, and simulations passed around as values.

Dorian Verlaine

0. What this note is for

Chapter 1 of Blitzstein and Hwang’s Introduction to Probability ends with a short section of R code: vectors, factorials, random sampling, and two simulations that check results proved earlier in the chapter. This note covers the same ground in Python.

The translation is not line by line. R is built around vectors; Python is built around names, functions, and lists. So the code below follows the habits of CS61A instead:

  • every idea gets a small named function with a docstring;
  • every docstring carries doctests, examples that Python can check;
  • a simulation is a function passed as a value, not a special command.

Everything uses only the standard library (math, random, collections), so it runs in any Python 3 and in the exercises on this page.

1. From vectors to lists

R’s c(3,1,4,1,5,9) becomes a Python list:

>>> v = [3, 1, 4, 1, 5, 9]
>>> sum(v), max(v), min(v), len(v)
(23, 9, 1, 6)

The sequence 1,2,…,n1,2,\dots,n, written 1:n in R, is range(1, n + 1) in Python. A range stops before its second argument, which is why the + 1 is there. Counting down from mm to nn needs a negative step: range(m, n - 1, -1).

1.1. Positions start at zero

The biggest change is indexing. R numbers positions from 1; Python numbers them from 0. So v[0] is the first entry, and the iith entry in the book’s sense is v[i - 1].

Fill in the blank

What would Python display? Indexing

What would Python display?

>>> v = [3, 1, 4, 1, 5, 9]
>>> v[1]
Your answer

Use an integer, decimal, or fraction such as 1/2. An exact value is expected.

Hints
1

Python numbers positions from 0, not from 1.

2

v[0] is 3. Which entry comes next?

Reference solution

1. Python positions start at 0, so v[1] is the second entry. In R, v[1] would be the first entry, 3.

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

Picking out the 1st, 3rd, and 5th entries, v[c(1,3,5)] in R, is a list comprehension over positions. Removing the 2nd through 4th entries, v[-(2:4)] in R, is two slices joined together:

>>> [v[i - 1] for i in (1, 3, 5)]
[3, 4, 5]
>>> v[:1] + v[4:]
[3, 5, 9]

A negative index means something else in Python: v[-1] is the last entry, not “everything except the first”.

1.2. Componentwise work is written out

R applies arithmetic to each entry of a vector automatically. In Python a list comprehension says so explicitly. The vector (1,122,132,…,11002)\left(1,\frac{1}{2^2},\frac{1}{3^2},\dots,\frac{1}{100^2}\right) is

>>> squares = [1 / k ** 2 for k in range(1, 101)]
>>> squares[:3]
[1.0, 0.25, 0.1111111111111111]

Powers use **. In Python ^ is bitwise exclusive or, so 2 ^ 3 is 1, not 8. This is one of the easiest mistakes to make when porting R code.

R also recycles a shorter vector: v + 3 adds 3 to every entry. Python lists do not.

Single choice

What would Python display? No recycling

What happens here?

>>> v = [3, 1, 4]
>>> v + 3
Your answer

Choose one answer.

Hints
1

+ between two lists joins them. Is 3 a list?

2

Python does not repeat a shorter value to fit a longer one.

Reference solution

Python raises TypeError: can only concatenate list (not "int") to list. For lists, + means concatenation, and it needs a list on both sides. Write [x + 3 for x in v] to add 3 to each entry, which gives [6, 4, 7].

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

2. Factorials and binomial coefficients

The standard library has both: math.factorial(n) and math.comb(n, k).

>>> import math
>>> math.factorial(20)
2432902008176640000
>>> math.comb(10, 3)
120

Python integers have no fixed size, so math.factorial(1000) is an exact 2568-digit integer. The limit appears only when we convert to a floating-point number: float(math.factorial(171)) raises OverflowError, the same point at which R returns Inf. For large nn, work with logarithms. Since n!=Γ(n+1)n!=\Gamma(n+1), the natural log log⁡(n!)\log(n!) is math.lgamma(n + 1), and log⁡(nk)\log\binom{n}{k} is lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1).

Writing (nk)\binom{n}{k} ourselves is a good first recursion. The base cases come straight from the definition, and the recursive case is Pascal’s rule.

Theorem 2.1 · Pascal’s rule

For integers n≥1n\ge 1 and 1≤k≤n−11\le k\le n-1,

(nk)=(n−1k−1)+(n−1k).\binom{n}{k}=\binom{n-1}{k-1}+\binom{n-1}{k}.

Choose a particular person out of the nn. A committee of size kk either includes that person, leaving k−1k-1 more to choose from the other n−1n-1, or it does not, leaving all kk to choose from the other n−1n-1.

Python

Binomial coefficients by recursion

Complete choose(n, k) recursively, using Pascal’s rule (nk)=(n−1k−1)+(n−1k)\binom{n}{k}=\binom{n-1}{k-1}+\binom{n-1}{k}. Do not use math.comb or factorials. Return 0 when k<0k<0 or k>nk>n.

Your answer

Python function exercise. Tab moves focus; use the indent buttons for selected lines.

Public tests

1. Small case

>>> choose(5, 2)
10

2. Middle of a row

>>> choose(10, 5)
252

3. Choose none or all

>>> choose(4, 0)
1
>>> choose(4, 4)
1

4. Too many

>>> choose(3, 5)
0
Hints
1

There are two base cases: impossible choices, and choosing none or all.

2

If kk is 0 or equal to nn, there is exactly one way.

3

Otherwise, return the sum of two smaller choose calls.

Reference solution
def choose(n, k):
    if k < 0 or k > n:
        return 0
    if k == 0 or k == n:
        return 1
    return choose(n - 1, k - 1) + choose(n - 1, k)

The first base case handles impossible requests; the second is the edge of Pascal’s triangle. Each recursive call lowers nn, so the recursion ends. It makes many repeated calls, which is fine for small nn and a good reason to meet memoization later.

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

3. Random samples

The random module plays the role of R’s sample. Positions in the book are the numbers 1,…,n1,\dots,n, so we sample from range(1, n + 1).

In RIn PythonMeaning
sample(n, k)random.sample(range(1, n + 1), k)kk draws without replacement
sample(n, k, replace=TRUE)random.choices(range(1, n + 1), k=k)kk draws with replacement
sample(n)random.sample(range(1, n + 1), n)a random permutation
sample(letters, 7)random.sample(string.ascii_lowercase, 7)seven distinct letters
sample(4, 3, replace=TRUE, prob=…)random.choices(range(1, 5), weights=[0.1, 0.2, 0.3, 0.4], k=3)weighted, with replacement

random.choices always samples with replacement; the standard library has no weighted sampling without replacement.

These numbers are pseudo-random: a deterministic algorithm produces them. That is useful. Calling random.seed(110) first makes a simulation reproducible, so someone else running your code gets the same numbers.

4. A simulation is a function

R’s replicate(times, expression) evaluates an expression many times. In Python we pass the experiment itself, a function of no arguments, and call it as often as we like:

def replicate(times, experiment):
    """Call EXPERIMENT, a function of no arguments, TIMES times.
    Return the results as a list.

    >>> replicate(3, lambda: 'hi')
    ['hi', 'hi', 'hi']
    """
    return [experiment() for _ in range(times)]

This is the CS61A idea of a higher-order function. replicate knows nothing about cards or birthdays; it only knows how to repeat. The lambda wraps an expression in a function so that it is evaluated later, and anew on each call, rather than once.

5. The matching problem

Shuffle a deck of cards labeled 1,…,n1,\dots,n and turn them over one at a time. A match happens when card ii is the iith card turned over. The book shows (Example 1.6.4) that the probability of at least one match is

1−12!+13!−⋯+(−1)n+11n!  ≈  1−1e≈0.632.1-\frac{1}{2!}+\frac{1}{3!}-\cdots+(-1)^{n+1}\frac{1}{n!}\;\approx\;1-\frac{1}{e}\approx 0.632.

To simulate it we need to count matches in a given deck. In Python, True and False behave as 1 and 0 in arithmetic, just as TRUE and FALSE do in R.

Fill in the blank

What would Python display? Adding truth values

What would Python display?

>>> deck = [2, 1, 3, 4]
>>> sum([card == position for position, card in enumerate(deck, start=1)])
Your answer

Use an integer, decimal, or fraction such as 1/2. An exact value is expected.

Hints
1

The list inside is [False, False, True, True].

2

In arithmetic, True counts as 1 and False as 0.

Reference solution

2. Cards 3 and 4 are in their own positions, so the list is [False, False, True, True], and its sum counts the True values.

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

Python

Count the matches

Complete count_fixed_points(deck). deck lists the cards in the order they are turned over; card ii matches when it is turned over iith. Return the number of matches.

Your answer

Python function exercise. Tab moves focus; use the indent buttons for selected lines.

Public tests

1. Every card matches

>>> count_fixed_points([1, 2, 3])
3

2. One match

>>> count_fixed_points([2, 1, 3])
1

3. No matches

>>> count_fixed_points([3, 1, 2])
0
>>> count_fixed_points([2, 1])
0

4. Empty deck

>>> count_fixed_points([])
0
Hints
1

enumerate(deck, start=1) gives each position together with its card.

2

Compare each card with its position, then add up the results.

Reference solution
def count_fixed_points(deck):
    return sum(card == position for position, card in enumerate(deck, start=1))

Each comparison is True or False, which sum counts as 1 or 0. An empty deck has no matches, so the sum is 0.

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

With count_fixed_points written, one shuffle and a full simulation are each a line or two:

import random

def shuffled_matches(n):
    """Shuffle cards 1..N and count the matches."""
    deck = random.sample(range(1, n + 1), n)
    return count_fixed_points(deck)

random.seed(110)
results = replicate(10 ** 4, lambda: shuffled_matches(100))
sum(r >= 1 for r in results) / 10 ** 4     # proportion with a match

Example 5.1 · What one run gave

With seed 110 and 10410^4 shuffles of 100 cards, the proportion of decks with at least one match was 0.62670.6267, against 1−1/e≈0.63211-1/e\approx0.6321. A different seed or Python version can give a slightly different proportion. The gap between the two shrinks roughly like 1/104=0.011/\sqrt{10^4}=0.01.

6. The birthday problem

With kk people and 365 equally likely birthdays (ignoring February 29), the probability that at least two share a birthday is

1−365⋅364⋯(365−k+1)365k.1-\frac{365\cdot364\cdots(365-k+1)}{365^k}.

math.prod computes the product directly, and exact integer arithmetic keeps it accurate until the final division:

>>> k = 23
>>> 1 - math.prod(range(365 - k + 1, 366)) / 365 ** k
0.5072972343239854

As a function with doctests, multiplying one factor at a time:

def birthday_match(k, days=365):
    """Probability that at least two of K people share a birthday.

    >>> round(birthday_match(23), 3)
    0.507
    >>> birthday_match(1)
    0.0
    >>> birthday_match(366)
    1.0
    """
    no_match = 1.0
    for i in range(k):
        no_match *= (days - i) / days
    return 1 - no_match

R’s qbirthday(p) answers the reverse question: how many people are needed for the probability to reach pp? Python’s standard library has no equivalent, so write it.

Python

How many people are needed?

birthday_match(k, days) is provided. Complete people_needed(p, days): return the smallest number of people k≥1k\ge1 for which birthday_match(k, days) is at least p. This is what R’s qbirthday computes. Assume 0<p≤10<p\le1.

Your answer

Python function exercise. Tab moves focus; use the indent buttons for selected lines.

Public tests

1. Even odds

>>> people_needed(0.5)
23

2. Ninety percent

>>> people_needed(0.9)
41

3. Ninety-nine percent

>>> people_needed(0.99)
57

4. A shorter year

>>> people_needed(0.5, days=2)
2
Hints
1

Start with one person and add people until the probability is large enough.

2

A while loop fits: keep going while birthday_match(k, days) < p.

Reference solution
def people_needed(p, days=365):
    k = 1
    while birthday_match(k, days) < p:
        k += 1
    return k

The loop always stops: with days + 1 people a shared birthday is certain, so the probability reaches 1.

Source: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied

6.1. Simulating instead

To simulate, draw 23 birthdays with replacement and ask how often the most common birthday appears. collections.Counter does what R’s tabulate does:

from collections import Counter

def most_shared(k, days=365):
    """Draw K birthdays; return the size of the largest group sharing one."""
    birthdays = random.choices(range(1, days + 1), k=k)
    return max(Counter(birthdays).values())

random.seed(110)
r = replicate(10 ** 4, lambda: most_shared(23))
sum(x >= 2 for x in r) / 10 ** 4           # at least one shared birthday
sum(x >= 3 for x in r) / 10 ** 4           # at least one triple

The simulation answers questions the formula does not. R’s pbirthday(23, coincident=3) gives about 0.0140.014 for a triple birthday among 23 people, and qbirthday(0.5, coincident=3) gives 88. Python has no built-in for triples, but most_shared handles them with no extra work.

Example 6.1 · What one run gave

With seed 110 and 10410^4 groups, 23 people shared a birthday in 0.50390.5039 of groups (exact: 0.50730.5073) and had a triple in 0.0130.013 of them. With 88 people, a triple appeared in 0.50730.5073 of groups, consistent with R’s answer of 88.

Unequal birthday probabilities make the formula hard but the simulation easy: pass weights= to random.choices.

7. Comments and docstrings

A # starts a comment in both languages. Python adds the docstring: the string at the top of a function, which says what the function does. With doctests inside, the documentation is also a test. Run

python3 -m doctest simulation.py -v

and Python checks every >>> example in the file. Docstrings for what a function promises, comments for why a line is written the way it is.

References

  • Joseph K. Blitzstein and Jessica Hwang, Introduction to Probability, Chapter 1, §1.6 (Example 1.6.4, the matching problem) and §1.8 (R). Course materials: stat110.net.
  • CS61A and John DeNero’s Composing Programs, for the style: higher-order functions, doctests, and “What would Python display?”
  • Python documentation: random, math, and collections.Counter.
Read as Markdown ↗All notes