Python
オブジェクト指向
雑記
プログラミング教育
モンティホール

モンティ・ホール問題をプログラミングで解く

はじめに

なぜモンティ・ホール問題が2/3なのか知人と改めて揉めたので解決してみたかった。
また、たまには簡単なアルゴリズムをプログラミングしてプログラムが楽しかったあの頃を思い出したい。

モンティ・ホール問題とは

以下のルールのゲームにあなたは参加しました。

1. 三つの扉がある。扉の奥は一つが新車(正解)。二つはヤギ(ハズレ)。正解するとその新車をあなたがもらえる。
2. あなたは初めに三つの扉の中から一つの扉を選択する。
3. 司会者は答えを知っており,残り二つの扉の中から不正解の扉を一つ選んで開ける。
4. その後、あなたは自分の回答を残りの扉に変更することが可能となる。

あなたは4の時、扉を変更する方が良いか、変更しないほうが良いか。

という問題です。
直感的回答と確率論的回答が異なることが多いことから有名なおもしろ問題とされています。

よくある間違え

扉を変更してもしなくても同じである。
なぜならハズレが一つ潰れている時点で、二つの扉から正解を選択するため、確率は1/2である。

正解

扉を変更する方が良い。
なぜなら最初の扉で正解する確率は1/3だが、残りの扉に変更した場合は2/3になるからである。


司会者が開けたというからややこしくなりますが、最初に選んだ回答の正当率が1/3ということは、それ以外の二つの扉の中身を見ることで正答率が2/3になる。というものですね。
そのあたりの細かい解説は沢山の解説ページがあるので参考にしてみてください。(私はここがわかりやすかったです。)

ちなみにモンティというのは司会者のことだそうです。

私は「正解する確率が2/3になる」という理屈は納得できたのですが、しかし同時に「1/2が間違えである」というのが納得行きませんでした。。。
なぜならハズレが一つ潰れている時点で、二つの扉から正解を選択するため、確率は1/2である。
というのは果たして間違えなのか?
考え方の違いでしかないのではないか?
と。

現実的に、このゲームを試してみることも難しいので、ここはプログラマーの端くれとして、「このルールに則ったプログラムを100万回くらい施行すればおのずと答えは見えるはず!」と考え実践してみました。

ゲームの実装

簡単に実装しました。
わかりやすく(?)3つクラスを用意します。
1. ゲームのプレイヤー(問題文のあなた)となるPlayerクラス
2. ゲームの司会者となるMontyクラス
3. 扉の情報を管理するPandoraクラス

Player.py
import random
import Pandora

class Player :
    """
    ゲームのプレイヤークラス
    """

    def __init__(self) :
        # Playerが選択した正解の値
        self.__answer = None

    def selectFirstAnswer(self, pandora:Pandora):
        """
        初回の回答を選択する。(回答可能の要素からランダムに一つ選択する。)
        """
        # Pandoraの長さ(回答の選択肢)を取得
        length = pandora.getLength()

        # 乱数から初回の選択肢を生成
        self.__answer = random.randrange(length)

        # Pandoraに回答を設定
        pandora.setPlayerAnswer(self.__answer)

    def selectChangeAnswer(self, pandora:Pandora):
        """
        Montyが開いたドアの番号を受けて、自身の回答を残りのドアに変更する。
        """
        door = pandora.getSelectableDoor()
        # Pandoraに変更した回答を設定
        self.__answer = random.choice(door)
        pandora.setPlayerAnswer(self.__answer)
Monty.py
import random
import Pandora

class Monty :
    """
    ゲームの司会者(Monty)クラス
    """
    def __init__(self):
        pass

    def visualizeLosing(self, pandora:Pandora):
        """
        残りの扉から、ハズレの扉を開ける。
        """
        losing_list = pandora.getRemaining()
        open_door = random.choice(losing_list)
        pandora.setOpenDoor(open_door)
Pandora.py
import random

class Pandora:
    """
    複数の扉の情報を管理するクラス
    """

    def __init__(self, length:int=3):
        # 回答可能なドアの総数
        self.__door = list(range(length))
        # 今回の正解のドアの番号をランダムに生成
        self.__answer = random.randrange(length)

        self.__player_answer = None

    def getLength(self):
        """
        回答可能なドアの総数を返す。
        """
        return len(self.__door)

    def setPlayerAnswer(self, answer):
        """
        Playerが選択した回答を設定
        """
        self.__player_answer = answer

    def getRemaining(self):
        """
        選択されていないドアの中で、ハズレであるドアの添字をリストで返す
        """
        tmp_door = self.__door.copy()
        # Playerが選択したドアを除外する
        tmp_door.remove(self.__player_answer)
        # 残りのドアに回答のドアが存在する場合は、除外する
        if self.__answer in tmp_door:
            tmp_door.remove(self.__answer)

        return tmp_door

    def setOpenDoor(self, open_door:int):
        """
        ハズレのドアを開く
        """
        if self.__answer == open_door:
            raise ValueError("正解の扉を開くことはできません")
        if self.__player_answer == open_door:
            raise ValueError("Playerが選択した扉を開くことはできません")
        self.__open_door = open_door

    def getSelectableDoor(self):
        """
        選択可能なドアを返す。
        """
        # 回答可能な全てのドアを取得する。
        tmp_door = self.__door.copy()
        # すでにPlayerが選択したものを除外
        tmp_door.remove(self.__player_answer)
        # すでに開けられているドアを除外
        tmp_door.remove(self.__open_door)
        return tmp_door

    def isCorrect(self):
        """
        Playerの選択が正解か否かを判定する
        """
        return self.__answer == self.__player_answer

そしてゲームを実行して正当するをカウントするプログラムを最後に作ります。

game.py
import sys
import Player
import Monty
import Pandora

# ゲームの施行回数をコマンドライン引数から取得(デフォルト100万回)
args = sys.argv
count = int(args[1]) if (len(args) >= 2) else 1000000

# 選択を変更しない場合の正解数の合計
no_change = 0
# 選択を変更した場合の正解数の合計
change = 0

# 施行回数分ゲームをループする
for num in range(count):
    # Player,Monty,Pandoraの生成
    player = Player.Player()
    monty = Monty.Monty()
    pandora = Pandora.Pandora()

    # 2. あなたは初めに三つの扉の中から一つの扉を選択する。
    player.selectFirstAnswer(pandora)
    # 3. 司会者は答えを知っており,残り二つの扉の中から不正解の扉を一つ選んで開ける。
    monty.visualizeLosing(pandora)

    # 4. その後、あなたは自分の選択を残りの扉に変更することが可能となる。

    # 選択肢を変更しない場合
    if pandora.isCorrect():
        # この時点で正解であれば、選択変更なしの正解数を増加
        no_change+=1

    # Playerが選択肢を変更
    player.selectChangeAnswer(pandora)
    if pandora.isCorrect():
        # 変更後に正解であれば、選択変更ありの正解数を増加
        change+=1

print(str(count) + "回ゲームを施行し、")
print("選択肢を変更しない場合 : " + str(no_change) + " 回正解")
print("選択肢を変更した場合   : " + str(change) + " 回正解")
print("しました。")

実行結果

$ python3 game.py
1000000回ゲームを施行し、
選択肢を変更しない場合 : 332778 回正解
選択肢を変更した場合   : 667222 回正解
しました。

本当に2/3くらいだった。。。
このあとなんども試しましたが、どれもほぼほぼ同じくらいの割合です。

なぜ1/2ではないのか

ハズレが一つ潰れている状態から考えれば、正解は2つに一つのはずなのに、なぜここまで差が出てしまったのか考えました。
そこでゲームのルールを以下のように少し変更して再実施してみることに。

ゲームのルール
1.  三つの扉がある。中身は一つが新車(正解)。二つはヤギ(ハズレ)。正解するとその新車をあなたがもらえる。
2.  あなたは初めに三つの扉の中から一つの扉を選択する。
3.  司会者は答えを知っており,残り二つの扉の中から不正解の扉を一つ選んで開ける。
+4'. このとき司会者が残りの扉二つの中で正解をシャッフルする。
4.  その後、あなたは自分の選択を残りの扉に変更することが可能となる。

「いやいや、結局どちらかに正解はあるんだから、シャッフルしたところで結果は一緒でしょう」と思うでしょうか。

ルール変更の実装

Pandor.py
+     def shuffle(self):
+        """
+        開いた扉を除き、残りの扉の中から正解をシャッフルする
+        """
+        # 回答可能な全てのドアを取得する。
+        tmp_door = self.__door.copy()
+        # すでに開けられているドアを除外
+        tmp_door.remove(self.__open_door)
+        # 回答の扉を選び直す。
+        self.__answer = random.choice(tmp_door)
game.py
    # 2. Playerが最初の回答をランダムに選択する
    player.selectFirstAnswer(pandora)
    # 3. 司会者は答えを知っており,残り二つの扉の中から不正解の扉を一つ選んで開ける。
    monty.visualizeLosing(pandora)

+   # 4`.このとき司会者が残りの扉二つの中で正解をシャッフルする。
+   pandora.shuffle()

再実施

$ python3 game.py
1000000回ゲームを施行し、
選択肢を変更しない場合 : 500410 回正解
選択肢を変更した場合   : 499590 回正解
しました。

結果はほぼ五分五分!!
プレイヤーの目には一切見えない部分のルール変更によってゲームの正当率が変わってしまいました。

モンティ・ホール問題に対して「1/2」と回答してしまう人は「目に見えない部分は開けるまで何があるかわからないため、結果に影響しない」という人間の心理が働いてしまっているのでしょう。
シュレーディンガーの猫です。(誤用)

感想

正解の確率が1/2ではないことはわかりましたが、結局「なぜ1/2じゃないの!?」って聞かれるとそれを納得させる回答は私の日本語力だと難しいですね。。

しかし、久しぶりにアルゴリズムらしい(?)プログラムをしたのは楽しかったです。
これから小学校でもプログラミングの授業が始まるようですし、こんな感じで、「現実的に施行することが難しい回数のものをプログラミングで解決する」みたいなことを教えると、興味を持ってくれる子供もいるんじゃないでしょうか。

世の中不思議がいっぱいですね。(こなみかん)