モンティ・ホール問題を検証するプログラムを作ってみた

# プログラミング縁起
きっかけは、Twitter、もとい、Xにおける「おがたつ@中学数学専門」さんのポストでした。

https://x.com/OgaTatsu_NAC/status/1829096655972643097

ポストへの「🖤」も1,600くらいあるようですが、ツッコミも各方面から届いておりまして、X的にはかなりバズっていてよいポストだと思います。
わたしとしてはサイコロを１万回振って大数の法則を検証する根性はないので、その中学生がうらやましかったです。
で、いろいろ考えているうちに、モンティ・ホール問題をシミュレートするプログラムを作りたいな、と思っていたことを思いだしたのでした。
作ってみるか。
# 作ってみました。
開発言語は、一番得意なのはExcelVBAなのですが、Pythonも書けたほうがいいよな、というスケベ心がわいてきたので、Pythonで書いております。
# モンティ・ホール問題
さきほどQiitaで調べてみたら、モンティ・ホール問題を扱った記事はたくさんありました。屋上屋を架してしまったようです。
問題の詳細については当該Wikipediaを参照しましょう。

https://ja.wikipedia.org/wiki/%E3%83%A2%E3%83%B3%E3%83%86%E3%82%A3%E3%83%BB%E3%83%9B%E3%83%BC%E3%83%AB%E5%95%8F%E9%A1%8C

概略はこうです。

* 会場に3つのドアが設置してある
* 3つのドアのうち、ひとつのドアのうしろには新車（あたり）が、残りふたつのドアのうしろにはヤギ（ハズレ）がある
* プレイヤーは3つのドアのうちひとつを選ぶ。開けたドアのうしろに新車があればもらえる
* プレイヤーがドアをひとつ選んだあと、残ったドアのなかで、ヤギのドアが司会者（モンティ・ホール）によって開かれる
* この時点で、ドアの状況は、つぎの3種類となる（順不同）
    * 【プレイヤーの選んだドア（不明）】
    * 【開かれたドア（ヤギ）】
    * 【残ったドア（不明）】
* その後、プレーヤーはドアを変更する権利を得る。現在選んでいるドア（不明）から残ったドア（不明）に選択を変えることができるし、変えないこともできる
* この際、ドアを選びなおしてもういっぽうに変更するのが得か、それとも現在のドアを保持するのが得か？

答は「ドアを変更すると新車を得られる確率が2倍になる」です。
つまり、ドアを変更するだけで、1/3だった確率が2/3になります。
この結論が直感に反しているため、相当な論争が繰りひろげられました。
このプログラムは、コンピュータ内で「ドアを選ぶ」、「司会者がドアをひとつ開ける」、「ドアを変更する」、といったアクションをショー１回分のオブジェクト（monty_hall_lottery）に対して行えるようになっています。
# コード
```python:monty_hall_simulator.py
from random import randint

# モンティ・ホール問題をシミュレートするクラス
class monty_hall_lottery:

    # 初期化メソッド
    def __init__(self):
        self.refresh_lottery()

    # 初期化
    def refresh_lottery(self):

        # くじの作成

        # 最初に全ドアのうしろにヤギを配置
        self.doors = ['Goat', 'Goat', 'Goat']
        # １個のドアのうしろに車を配置
        self.doors[randint(0, 2)] = 'Car'

        # 車のドアのリスト
        self.car_doors = []  # リストを初期化

        # ヤギのドアのリスト
        self.goat_doors = []  # リストを初期化

        # リストを作成
        for offset in range(3):
            if self.doors[offset] == 'Car':
                self.car_doors.append(offset)
            else:
                self.goat_doors.append(offset)

        # プレイヤーの選択はまだなし
        self.selection = None

        # MHによって開かれたドアはまだなし
        self.opened = None

        # ゲームはまだ終了していない
        self.ended = False

    # 番号を指定して、プレイヤーがドアを選択
    def select(self, selection):
        self.selection = selection
        return self.selection

    # どのドアを選択しているのかを返す
    def get_selection(self):
        return self.selection

    # 選ばれていないドアのうち、ヤギがいるものの1つを開ける
    def show_one_goat_door(self):
        if self.selection is None:
            return None

        if self.doors[self.selection] == 'Car':
            # 残りのヤギドアからランダムに1つを返す
            self.opened = self.goat_doors[randint(0, len(self.goat_doors) - 1)]
        else:
            # 選んでいないヤギドアを返す
            self.opened = self.goat_doors[0] if self.selection != self.goat_doors[0] else self.goat_doors[1]

        return self.opened

    # プレイヤーが選択を変更する
    def change_selection(self):
        if self.selection is None or self.opened is None:
            return -1

        # ドアは[0, 1, 2]だが、この中から現在の選択と開かれたドアを抜いてavailable_doorsとする。
        available_doors = set(range(3)) - {self.selection, self.opened}
        # 残った値（ドア番号）を取り出す
        self.selection = available_doors.pop()
        return self.selection

    # ゲームの結果を見せる
    def show_result(self):
        if self.selection is not None:
            self.ended = True
            return self.doors[self.selection]
        return None

    # 現在のドアの状態を1行の文字列で返す
    def get_door_status(self):
        result = ''
        for idx in range(3):
            # そのドアがすでに開かれたものなら[GOAT]
            if (idx == self.opened):
                result = result + '[*GOAT*]'
            # そのドアが車のドアでゲームが終了した場合
            elif (idx == self.car_doors[0] and self.ended):
                result = result + '[*CAR**]'
            elif (idx == self.selection):
                result = result + '[SELECT]'
            else:
                result = result + '[******]'
        return result

# 実行フェーズ
def run_simulation(change_selection, trial_times):
    mhl = monty_hall_lottery()
    win = 0
    lose = 0
    result = ''

    # trial_times回試行する
    for _ in range(trial_times):
        # 初期化
        mhl.refresh_lottery()

        # ドア選択１回目
        mhl.select(randint(0, 2))

        # MontyHallがドアを１つ開ける
        mhl.show_one_goat_door()

        # ドアを変えるか？
        if change_selection:
            mhl.change_selection()
        else:
            mhl.get_selection()

        #結果を取得
        result = mhl.show_result()

        if result == 'Car':
            win += 1
        else:
            lose += 1

    # 結果を返す
    return f'選択変更：{"Yes" if change_selection else "No"}、試行回数：{trial_times}回、勝数：{win}、負数：{lose}、勝率：{win / ( win + lose ) : .5f}'

# 試行回数
NUMBER_OF_TRIALS = 100000

# 変更する場合
print(run_simulation(change_selection=True, trial_times=NUMBER_OF_TRIALS))

# 変更しない場合
print(run_simulation(change_selection=False, trial_times=NUMBER_OF_TRIALS))

```
実行すると、結果をプリントしてくれます。
# 実行結果
> 選択変更：Yes、試行回数：1000000回、勝数：666750、負数：333250、勝率： 0.667
> 
> 選択変更：No、試行回数：1000000回、勝数：333779、負数：666221、勝率： 0.334

100万回試行させてみたところ、モンティ・ホールによってヤギのドアが開けられた後、選択を「変える」としたほうは勝つ確率が2/3に近く、変えないとしたほうは1/3に近くなっています。
ホントだ！
# 手を動かして理解する
ところで、このプログラムを書いているうちに、わたし自身なんとなくこの問題を理解することができました。
## 選択を変えない場合
最初の状況と変わりませんので、当たりの確率は当初通り1/3です。
## 選択を変える場合
ところが、選択を変更することは、残りのドア2つを選んだのと同じ結果になります。そのうちの1つは事前に親切にもハズレのドアであることが明かされているため、まだ開かれていないドアを選ぶわけです。
選択を変えてハズレを引く確率は、選択を変えずにそのまま当たりを引く確率と同じです。つまり1/3です。
ということは、選択を変える場合に当たりを引く確率は1-1/3=2/3で、変えたほうが圧倒的に得だ、という結論になります。
# 問題点
ヤギ好きのわたしとしては、うしろにヤギのいる扉が「ハズレ」とされているのが納得いきません。
![s-received_708661980908417.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557120/5c9f3388-f38b-eeae-2a39-2a706062543e.jpeg)
どう考えても当たりやろ！