Dorian’s Math
機率與統計

用 Python 看條件機率:兩個小孩、蒙提霍爾問題,以及依證據更新

把 Blitzstein 與 Hwang 第二章的 R 小節,改寫成 CS61A 風格的 Python:把述詞當成值傳遞、用次數估計 P(A|B),並把蒙提霍爾遊戲拆成純邏輯與輸入輸出兩部分。

Dorian Verlaine

0. 從上一篇接著走

這篇接續用 Python 計數與模擬, 上一篇改寫了第一章的 R 小節。Blitzstein 與 Hwang《Introduction to Probability》的第二章談條件機率,它的 R 小節(§2.10)用模擬驗證兩個著名的 結果:兩個小孩問題與蒙提霍爾問題(Monty Hall problem)。寫法和上一篇一樣: 有名字的小函式、doctest,以及把函式當成值傳遞。只用標準函式庫就夠了,這次 也不需要上一篇的 replicate。

1. 把條件機率看成次數

條件機率的頻率學派解讀是:把實驗重複 nn 次,數出 BB 發生的次數 nBn_B, 以及 AA 與 BB 同時發生的次數 nABn_{AB}。當 nn 很大時,

P(A∣B)≈nABnB.P(A\mid B)\approx\frac{n_{AB}}{n_B}.

我們只看 BB 發生的那些回合,再問其中 AA 發生了多少次。

1.1. 模擬家庭

假設每個家庭有兩個小孩,每個小孩各自獨立、以相同機率是女孩(1)或男孩(2)。 R 會建立兩個向量,每個小孩一個;Python 也可以這樣做,再用 zip 把它們配成 一個家庭一個 tuple:

import random

random.seed(110)
n = 10 ** 5
child1 = random.choices([1, 2], k=n)     # 每個家庭的老大
child2 = random.choices([1, 2], k=n)     # 老二
families = list(zip(child1, child2))

families[0] 可能是 (1, 2):老大是女孩、老二是男孩。把同一個家庭的兩個 小孩放在同一個 tuple 裡,就不可能不小心把一個家庭的老大和另一個家庭的老二 配在一起。

1.2. 沒有逐元素比較

R 的 child1 == 1 會把向量的每個元素都拿來和 1 比較,傳回一個由 TRUE、 FALSE 組成的向量。Python 的 == 比較的是整個值,而一個串列不會等於一個 數字。

單選

Python 會顯示什麼?比較一個串列

Python 會顯示什麼?

>>> child1 = [1, 2, 1]
>>> child1 == 1
你的答案

選擇一個答案。

提示
1

Python 的 == 會拿左邊的整個值和右邊的整個值比較。

2

一個串列可能等於一個數字嗎?

參考解答

False。Python 把整個串列 [1, 2, 1] 和數字 1 比較,兩者不相等。它不會逐元素比較;在 R 裡,child1 == 1 則會得到 TRUE FALSE TRUE。要得到 [True, False, True],請寫 [c == 1 for c in child1]。

來源: 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

所以 Python 用串列生成式把迴圈寫出來。R 的 & 和 | 變成 Python 的 and 和 or,寫在生成式裡面:

>>> pairs = [(1, 1), (1, 2), (1, 1)]
>>> [c1 == 1 and c2 == 1 for c1, c2 in pairs]
[True, False, True]
填空

Python 會顯示什麼?用 and 計數

Python 會顯示什麼?

>>> pairs = [(1, 1), (1, 2), (2, 1), (1, 1)]
>>> sum([c1 == 1 and c2 == 1 for c1, c2 in pairs])
你的答案

可以輸入整數、小數或分數,例如 1/2。 這題需要精確值。

提示
1

生成式的結果是 [True, False, False, True]。

2

sum 把每個 True 當作 1。

參考解答

2。只有第一組和最後一組的兩個值都是 1,所以串列是 [True, False, False, True],加總為 2。這就是 AA=兩個都是女孩時的 nABn_{AB}。

來源: 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. 事件就是述詞

與其每個問題都重寫一次計數,不如把每個事件描述成一個述詞(predicate): 一個接收結果、傳回 True 或 False 的函式。這樣一個函式就能估計任何條件 機率。這正是 CS61A 所說的「把函式當成參數傳遞」。

Python

估計條件機率

完成 conditional_frequency(outcomes, a, b)。a 和 b 是述詞,也就是接收一個結果、傳回 True 或 False 的函式。請傳回 nAB/nBn_{AB}/n_B:兩者都成立的結果數,除以 b 成立的結果數。如果 b 從未成立,傳回 None。

你的答案

Python 函式練習。Tab 會移動焦點;選取程式行後,可用按鈕調整縮排。

公開測試

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
提示
1

用 sum(1 for o in outcomes if b(o)) 數出 nBn_B。

2

用同樣的方式數出 nABn_{AB},條件改成 a(o) and b(o)。

3

除法之前先檢查 nB=0n_B = 0 的情況。

參考解答
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

這個函式對小孩或骰子一無所知,只會呼叫傳進來的述詞;正因如此,一個定義就能回答筆記中的每個問題。

來源: 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

寫好 conditional_frequency 之後,書中的兩個問題各只要一行:

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)

範例 1.1 · 一次實際執行的結果

用上面的種子與 10510^5 個家庭,第一個估計值是 0.50050.5005,第二個是 0.33120.3312。 書中例 2.2.5 的精確答案分別是 1/21/2 與 1/31/3。知道「老大是女孩」和知道 「至少一個是女孩」是不同的資訊,因此答案也不同。

2. 蒙提霍爾問題

三扇門後面,一扇是汽車,另外兩扇是山羊。你先選一扇門。知道汽車在哪裡的主持人 蒙提,會打開另外兩扇中的一扇,讓你看到山羊,然後問你要不要換。該換嗎?

2.1. 永遠不換

假設你總是選 1 號門。那麼「永遠不換」這個策略只有在汽車就在 1 號門後面時 才會贏,連蒙提都不必模擬:

random.seed(110)
cardoor = random.choices([1, 2, 3], k=n)
sum(car == 1 for car in cardoor) / n

以種子 110 執行一次得到 0.33130.3313,接近 1/31/3。

2.2. 把蒙提寫成模型

要模擬換門,就需要蒙提的選擇。如果你選的門後面是山羊,蒙提只有一扇門可以開; 如果你選的正是汽車,他有兩扇可以選,實際遊戲中他會隨機挑一扇。為了讓程式容易 測試,下一題改成固定的選擇:永遠開允許的門中編號最小的那一扇。隨機性之後可以 再從外面加回來。

Python

蒙提會開哪一扇門?

針對 1、2、3 號門,完成 monty_opens(chosen, car)。蒙提從不打開玩家選的門,也不打開有汽車的門。當有兩扇門可以開(玩家正好選中汽車)時,傳回編號較小的那一扇,讓結果可以預測。

你的答案

Python 函式練習。Tab 會移動焦點;選取程式行後,可用按鈕調整縮排。

公開測試

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
提示
1

逐一檢查 (1, 2, 3) 中的每扇門,只留下既不是 chosen 也不是 car 的門。

2

對允許的門取 min,就是編號較小的那一扇。

參考解答
def monty_opens(chosen, car):
    return min(door for door in (1, 2, 3) if door != chosen and door != car)

當 chosen != car 時,只有一扇門可以開;兩者相等時有兩扇,min 選編號較小的。實際遊戲中蒙提此時會隨機挑選,筆記中的模擬會把這個隨機性加回去。

來源: 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、2、3 號門加起來 是 6,所以那扇門是 6 - chosen - opened。

填空

換門獲勝的機率

你選 1 號門,蒙提打開一扇後面是山羊的門,而你永遠選擇換門。你贏得汽車的機率是多少?請以分數作答。

你的答案

可以輸入整數、小數或分數,例如 1/2。 這題需要精確值。

提示
1

換門正好在你第一次選錯時會贏。

2

第一次選錯的機率是 2/32/3。

參考解答

2/32/3。如果第一次就選中汽車(機率 1/31/3),換門就會輸。如果第一次選到山羊(機率 2/32/3),蒙提只能打開另一扇山羊門,所以你換過去的那扇門後面一定是汽車。筆記中的模擬得到 0.66900.6690。

來源: 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

要驗證這個答案,就模擬整個遊戲,並讓蒙提在有選擇時隨機挑門:

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

範例 2.1 · 一次實際執行的結果

以種子 110 模擬 10510^5 局,換門贏的比例是 0.66900.6690,接近 2/32/3。永遠不換只在 第一次就選對時才贏,所以換門正好在第一次選錯時贏。

2.3. 實際玩一局

書中最後寫了一個互動式的 monty() 函式。在 Python 裡,input 扮演 R 中 scan 的角色:它會印出提示,並傳回玩家輸入的內容,而且一定是字串,所以要 用 int(...) 把門號轉成整數。f-string 取代 R 的 paste,startswith 則取代 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)                       # 汽車所在的門
    print("Monty Hall says 'Pick a door, any door!'")
    chosen = int(input('Your door (1, 2, or 3): '))
    # 蒙提打開一扇既不是你的、也不是汽車的門。
    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!')

把它存成 monty.py,執行 python3 -i monty.py,之後想玩幾次就呼叫幾次 monty()。本頁的練習無法執行它,因為練習環境沒有鍵盤輸入。

注意這個程式的結構。所有不需要有人坐在鍵盤前就能測試的決定(switch_door, 以及練習中的 monty_opens)都是純函式;只有最外層的 monty 會和玩家互動。 CS61A 把這稱為「把計算與輸入輸出分開」,這也是前面的練習能用 doctest 檢查 邏輯的原因。

3. 後驗變成先驗

第二章結尾(圖 2.7)描述了證據如何一步一步到來。還沒有任何證據時,P(A)P(A) 是 先驗機率。得知 BB 之後,用貝氏定理算出後驗機率 P(A∣B)P(A\mid B)。這個後驗又成為 下一步的先驗,再得知 CC 就得到 P(A∣B,C)P(A\mid B,C)。

定理 3.1 · 用補集寫出的貝氏定理

若 0<P(A)<10<P(A)<1 且 P(E)>0P(E)>0,則

P(A∣E)=P(E∣A) P(A)P(E∣A) P(A)+P(E∣Ac) P(Ac).P(A\mid E)=\frac{P(E\mid A)\,P(A)}{P(E\mid A)\,P(A)+P(E\mid A^c)\,P(A^c)}.

分母就是 P(E)P(E),由全機率公式展開。

Python

依證據更新機率

完成 bayes_update(prior, p_evidence_if_a, p_evidence_if_not_a)。已知 P(A)P(A)、P(E∣A)P(E\mid A) 與 P(E∣Ac)P(E\mid A^c),請用貝氏定理搭配全機率公式傳回 P(A∣E)P(A\mid E)。測試傳入的是 Fraction,所以答案是精確值。假設 P(E)>0P(E)>0。

你的答案

Python 函式練習。Tab 會移動焦點;選取程式行後,可用按鈕調整縮排。

公開測試

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)
提示
1

分子是 P(E∣A) P(A)P(E\mid A)\,P(A)。

2

分母是在分子之外再加上 P(E∣Ac) (1−P(A))P(E\mid A^c)\,(1-P(A))。

參考解答
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))

結果仍然是 AA 的機率,所以可以直接當成下一次的先驗再傳進去,這就是依序更新。在 AA 與非 AA 之下同樣可能出現的證據,不會改變先驗。

來源: 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

一次更新一項證據,就是一連串的函式呼叫,每次的輸出成為下一次的輸入。再看一次 兩個小孩的家庭,令 AA=兩個都是女孩,所以 P(A)=1/4P(A)=1/4。

範例 3.2 · 星期一,然後星期二

**星期一:**得知 BB=至少一個是女孩。若 AA 成立,BB 一定成立;若不成立, 在其餘三種同樣可能的家庭中,BB 在兩種裡成立,所以 P(B∣Ac)=2/3P(B\mid A^c)=2/3。

>>> monday = bayes_update(Fraction(1, 4), 1, Fraction(2, 3))
>>> monday
Fraction(1, 3)

**星期二:**又得知 CC=老大是女孩。在 BB 成立而 AA 不成立時,家庭是 「女孩、男孩」或「男孩、女孩」,所以 P(C∣Ac,B)=1/2P(C\mid A^c,B)=1/2。

>>> bayes_update(monday, 1, Fraction(1, 2))
Fraction(1, 2)

兩個答案都和第 1 節的模擬一致。先得知 BB 再得知 CC,等於直接得知「老大是 女孩」,這也是檢查計算的好方法。

fractions.Fraction 讓計算保持精確,1/31/3 就一直是 1/31/3,不會變成帶有 捨入誤差的 0.333…0.333\ldots。

參考資料

  • Joseph K. Blitzstein、Jessica Hwang,《Introduction to Probability》, 第 2 章:例 2.2.5(兩個小孩)、蒙提霍爾問題、圖 2.7(依序更新)與 §2.10(R)。課程資源:stat110.net。
  • CS61A 與 John DeNero 的 《Composing Programs》:高階函式、 doctest,以及把邏輯與輸入輸出分開。
  • Python 官方文件:random、 fractions、 input。
閱讀 Markdown 版本 ↗全部筆記