用 Python 計數與模擬:配對問題與生日問題
把 Blitzstein 與 Hwang 第一章的 R 小節,改寫成 CS61A 風格的 Python:小而有名字的函式、doctest,以及當成值傳遞的模擬。
0. 這篇筆記要做什麼
Blitzstein 與 Hwang 的《Introduction to Probability》第一章結尾有一小節 R 程式:向量、階乘、隨機抽樣,以及兩個用來驗證前文結果的模擬。這篇筆記用 Python 走一遍同樣的內容。
這不是逐行翻譯。R 以向量為核心;Python 以名稱、函式和串列(list)為核心。 所以下面的程式採用 CS61A 的習慣:
- 每個想法都寫成一個有名字的小函式,並附上 docstring;
- 每個 docstring 都帶有 doctest,也就是 Python 可以自動檢查的範例;
- 模擬是一個被當成值傳遞的函式,而不是一個特殊指令。
全部只用標準函式庫(math、random、collections),因此在任何 Python 3
環境,以及本頁的練習裡都能執行。
1. 從向量到串列
R 的 c(3,1,4,1,5,9) 在 Python 裡是一個串列:
>>> v = [3, 1, 4, 1, 5, 9]
>>> sum(v), max(v), min(v), len(v)
(23, 9, 1, 6)
數列 在 R 寫成 1:n,在 Python 寫成 range(1, n + 1)。
range 會在第二個參數之前停下,所以要加 1。從 倒數到 則需要負的
步長:range(m, n - 1, -1)。
1.1. 位置從零開始
最大的差別是索引。R 的位置從 1 開始編號,Python 從 0 開始。所以 v[0] 是第
一個元素,書中所說的第 個元素是 v[i - 1]。
Python 會顯示什麼?索引
Python 會顯示什麼?
>>> v = [3, 1, 4, 1, 5, 9]
>>> v[1]提示
Python 的位置從 0 開始編號,不是從 1。
v[0] 是 3,下一個是什麼?
參考解答
1。Python 的位置從 0 開始,所以 v[1] 是第二個元素。在 R 裡,v[1] 則是第一個元素 3。
來源: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
取出第 1、3、5 個元素(R 的 v[c(1,3,5)])可以用串列生成式走過這些位置。
去掉第 2 到第 4 個元素(R 的 v[-(2:4)])則是把兩段切片接起來:
>>> [v[i - 1] for i in (1, 3, 5)]
[3, 4, 5]
>>> v[:1] + v[4:]
[3, 5, 9]
負的索引在 Python 裡意思不同:v[-1] 是最後一個元素,而不是「除了第一
個以外的全部」。
1.2. 逐元素運算要寫出來
R 會自動把算術套用到向量的每個元素上;Python 則用串列生成式明確寫出來。向量 寫成:
>>> squares = [1 / k ** 2 for k in range(1, 101)]
>>> squares[:3]
[1.0, 0.25, 0.1111111111111111]
次方用 **。在 Python 裡 ^ 是位元互斥或(XOR),所以 2 ^ 3 是 1,
不是 8。這是把 R 程式搬到 Python 時最容易犯的錯之一。
R 還會把較短的向量循環使用(recycling):v + 3 會把每個元素都加 3。
Python 的串列不會這樣做。
Python 會顯示什麼?沒有循環使用
這裡會發生什麼事?
>>> v = [3, 1, 4]
>>> v + 3提示
兩個串列之間的 + 會把它們接起來。3 是串列嗎?
Python 不會把較短的值重複到和較長的一樣長。
參考解答
Python 會引發 TypeError: can only concatenate list (not "int") to list。對串列而言,+ 是串接,兩邊都必須是串列。要讓每個元素加 3,寫成 [x + 3 for x in v],得到 [6, 4, 7]。
來源: 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. 階乘與二項式係數
標準函式庫兩者都有:math.factorial(n) 與 math.comb(n, k)。
>>> import math
>>> math.factorial(20)
2432902008176640000
>>> math.comb(10, 3)
120
Python 的整數沒有固定大小,所以 math.factorial(1000) 是一個精確的 2568 位
整數。上限只在轉成浮點數時出現:float(math.factorial(171)) 會引發
OverflowError,正好是 R 開始回傳 Inf 的地方。 很大時,改用對數計算。
因為 ,自然對數 就是 math.lgamma(n + 1);
則是 lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1)。
自己寫 是很好的第一個遞迴練習。基本情況直接來自定義,遞迴情況 則是巴斯卡法則。
定理 2.1 · 巴斯卡法則
對整數 與 ,
在 個人中指定某一個人。大小為 的委員會要嘛包含這個人,剩下 人從其餘 人中選;要嘛不包含, 人全都從其餘 人中選。
用遞迴計算二項式係數
用遞迴完成 choose(n, k),依據巴斯卡法則 。不要使用 math.comb 或階乘。 或 時傳回 0。
公開測試
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
輸出與測試詳情
提示
有兩種基本情況:不可能的選法,以及一個都不選或全選。
為 0 或等於 時,恰好只有一種選法。
其他情況,傳回兩個較小 choose 呼叫的和。
參考解答
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)
第一個基本情況處理不可能的要求,第二個是巴斯卡三角形的邊緣。每次遞迴呼叫都會讓 變小,所以遞迴一定會結束。它會重複計算很多次, 小時沒有問題,這也正是之後學習記憶化(memoization)的好理由。
來源: 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 模組扮演 R 中 sample 的角色。書中的位置是 ,所以從
range(1, n + 1) 抽樣。
| R | Python | 意思 |
|---|---|---|
sample(n, k) | random.sample(range(1, n + 1), k) | 不放回抽 個 |
sample(n, k, replace=TRUE) | random.choices(range(1, n + 1), k=k) | 放回抽 個 |
sample(n) | random.sample(range(1, n + 1), n) | 隨機排列 |
sample(letters, 7) | random.sample(string.ascii_lowercase, 7) | 七個不重複的字母 |
sample(4, 3, replace=TRUE, prob=…) | random.choices(range(1, 5), weights=[0.1, 0.2, 0.3, 0.4], k=3) | 加權、放回 |
random.choices 一定是放回抽樣;標準函式庫沒有加權的不放回抽樣。
這些數字是偽隨機的,由一個確定性的演算法產生。這其實很有用:先呼叫
random.seed(110),模擬就能重現,別人執行你的程式也會得到同樣的數字。
4. 模擬就是一個函式
R 的 replicate(times, expression) 會把一個運算式重複求值許多次。在 Python
裡,我們直接把實驗本身,也就是一個不帶參數的函式傳進去,要呼叫幾次都可以:
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)]
這就是 CS61A 所說的高階函式。replicate 對紙牌或生日一無所知,它只知道
怎麼重複。lambda 把一個運算式包成函式,讓它延後、而且每次呼叫都重新求值,
而不是只算一次。
5. 配對問題
把標上 的一副牌洗亂,再一張一張翻開。如果第 張翻開的正好是 號牌,就叫做一次配對。書中證明(例 1.6.4)至少出現一次配對的機率是
要模擬,先得數出一副牌裡有幾次配對。在 Python 的算術裡,True 和 False
的行為就像 1 和 0,和 R 的 TRUE、FALSE 一樣。
Python 會顯示什麼?把真假值相加
Python 會顯示什麼?
>>> deck = [2, 1, 3, 4]
>>> sum([card == position for position, card in enumerate(deck, start=1)])提示
裡面的串列是 [False, False, True, True]。
在算術中,True 當作 1,False 當作 0。
參考解答
2。3 號和 4 號牌在自己的位置上,所以串列是 [False, False, True, True],加總就是在數 True 的個數。
來源: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
數出配對數
完成 count_fixed_points(deck)。deck 依翻開順序列出牌;第 張翻開的若是 號牌,就算一次配對。請傳回配對的次數。
公開測試
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
輸出與測試詳情
提示
enumerate(deck, start=1) 會同時給出每個位置和那張牌。
把每張牌和它的位置比較,再把結果加總。
參考解答
def count_fixed_points(deck):
return sum(card == position for position, card in enumerate(deck, start=1))
每次比較的結果是 True 或 False,sum 會把它們當作 1 或 0。空的牌堆沒有配對,加總為 0。
來源: Adapted from Blitzstein & Hwang, Introduction to Probability, §1.8 (R), rewritten in Python · Original exercise; the book's R examples are paraphrased, not copied
寫好 count_fixed_points 之後,洗一次牌和完整的模擬都只要一兩行:
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 # 至少有一次配對的比例
範例 5.1 · 一次實際執行的結果
以種子 110 對 100 張牌洗 次,至少有一次配對的比例是 , 理論值是 。換一個種子或 Python 版本,比例可能稍有不同; 兩者的差距大約以 的量級縮小。
6. 生日問題
個人、365 個同樣可能的生日(忽略 2 月 29 日)時,至少兩人同一天生日的 機率是
math.prod 可以直接算出乘積,精確的整數運算讓結果一直準確到最後的除法:
>>> k = 23
>>> 1 - math.prod(range(365 - k + 1, 366)) / 365 ** k
0.5072972343239854
寫成帶有 doctest 的函式,一次乘一個因子:
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 的 qbirthday(p) 回答反過來的問題:要多少人,機率才會達到 ?Python
的標準函式庫沒有對應的函式,那就自己寫。
需要多少人?
題目已提供 birthday_match(k, days)。請完成 people_needed(p, days):傳回讓 birthday_match(k, days) 至少為 p 的最小人數 。這正是 R 的 qbirthday 所計算的。假設 。
公開測試
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
輸出與測試詳情
提示
從一個人開始,逐次加人,直到機率夠大。
適合用 while 迴圈:當 birthday_match(k, days) < p 時就繼續。
參考解答
def people_needed(p, days=365):
k = 1
while birthday_match(k, days) < p:
k += 1
return k
迴圈一定會停:有 days + 1 個人時必定有人同天生日,機率會達到 1。
來源: 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. 改用模擬
模擬時,放回抽出 23 個生日,再看最常見的生日出現了幾次。
collections.Counter 做的就是 R 中 tabulate 的工作:
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 # 至少一組同天生日
sum(x >= 3 for x in r) / 10 ** 4 # 至少一組三人同天
模擬能回答公式回答不了的問題。R 的 pbirthday(23, coincident=3) 給出 23
人中出現三人同天生日的機率約為 ,qbirthday(0.5, coincident=3)
則給出 88。Python 沒有處理三人同天的內建函式,但 most_shared 不必多寫任何
程式就能處理。
範例 6.1 · 一次實際執行的結果
以種子 110 模擬 組,23 人中有人同天生日的比例是 (精確值 ),出現三人同天的比例是 。88 人時,三人同天的比例是 ,與 R 給出的 88 人一致。
生日機率不相等時,公式會變得很難,模擬卻依然簡單:把 weights= 傳給
random.choices 就好。
7. 註解與 docstring
兩種語言都用 # 開始一行註解。Python 另外有 docstring,也就是函式最上方說明
它做什麼的字串。裡面放了 doctest,說明文件同時也是測試。執行
python3 -m doctest simulation.py -v
Python 就會檢查檔案裡每一個 >>> 範例。docstring 寫函式承諾了什麼,
註解寫某一行為什麼這樣寫。
參考資料
- Joseph K. Blitzstein、Jessica Hwang,《Introduction to Probability》, 第 1 章 §1.6(例 1.6.4,配對問題)與 §1.8(R)。 課程資源:stat110.net。
- CS61A 與 John DeNero 的 《Composing Programs》,本文的寫法 來自這裡:高階函式、doctest,以及「Python 會顯示什麼?」
- Python 官方文件:
random、math、collections.Counter。
練習資料只保留至本頁關閉為止。