몬티 홀 문제, 직관을 의심하고 코드로 증명하기
학생 때 확률과 통계 수업에서 처음 접했던 몬티 홀 문제.
당시에도 "바꾸는 게 유리하다"는 결론은 머리로 받아들였지만,
직관적으로는 끝까지 와닿지 않았다.
오랜만에 생각나서 Python으로 시뮬레이션을 짜봤는데,
단순한 검증을 넘어서 흥미로운 사실 하나를 더 발견했다.
몬티 홀 문제란
미국의 게임쇼 Let's Make a Deal에서 나온 유명한 확률 퍼즐이다.
참가자 앞에 세 개의 문이 있다. 한 문 뒤에는 자동차가, 나머지 두 문 뒤에는 염소가 있다.
- 참가자가 문 하나를 고른다.
- 진행자(몬티 홀)는 남은 두 문 중 염소가 있는 문 하나를 열어 보여준다.
- 참가자에게 묻는다. "고른 문을 유지할래요, 아니면 다른 문으로 바꿀래요?"
직관적으로는 "이제 문이 두 개 남았으니 50:50 아닌가?"라고 생각하기 쉽다. 하지만 정답은 바꾸는 것이 유리하다. 바꾸면 승률이 2/3, 유지하면 1/3이다.
처음 들으면 받아들이기 어렵다. 1990년 메릴린 보스 사반트가 이 답을 잡지에 발표했을 때, 박사 학위를 가진 수학자들조차 "틀렸다"고 항의 편지를 보냈다고 한다. 그만큼 직관에 반하는 문제다.
왜 바꾸는 게 유리한가: 조건부 확률로 보기
핵심은 진행자가 무작위로 문을 여는 게 아니라는 점이다. 진행자는 자동차의 위치를 알고, 의도적으로 염소가 있는 문을 연다. 이 "정보"가 확률을 비대칭으로 만든다.
처음 문을 골랐을 때의 확률을 보자.
- 내가 고른 문에 자동차가 있을 확률: 1/3
- 내가 고른 문에 자동차가 없을 확률 (= 나머지 두 문 중 하나에 자동차가 있을 확률): 2/3
진행자가 염소 문을 열어준 뒤에도 이 확률은 변하지 않는다. 내가 처음 고른 문이 자동차일 확률은 여전히 1/3이고, "내가 고르지 않은 두 문 중 어딘가에 자동차가 있을 확률"은 여전히 2/3다. 다만 진행자가 그 두 문 중 염소가 있는 쪽을 알려줬으므로, 이제 그 2/3 확률이 남은 한 문에 모두 집중된다.
조건부 확률로 표현하면 이렇다. 자동차가 1번 문 뒤에 있을 사건을 $C_1$, 진행자가 3번 문을 여는 사건을 $H_3$이라 하자. 내가 1번 문을 골랐을 때, 진행자가 3번 문을 연 조건에서 자동차가 2번 문 뒤에 있을 확률은:
$$P(C_2 | H_3) = \frac{P(H_3 | C_2) \cdot P(C_2)}{P(H_3)} = \frac{1 \cdot \frac{1}{3}}{\frac{1}{2}} = \frac{2}{3}$$
자동차가 2번 문에 있다면 진행자는 무조건 3번 문을 열어야 하므로 $P(H_3 | C_2) = 1$이다. 반면 자동차가 내가 고른 1번 문에 있다면 진행자는 2번이나 3번 중 무작위로 선택하므로 $P(H_3 | C_1) = 1/2$다. 이 비대칭이 2/3이라는 결과를 만든다.
수식으로 보면 명확하지만, 그래도 직관적으로는 여전히 어색할 수 있다. 그래서 시뮬레이션이 필요하다.
큰 수의 법칙으로 검증하기
이론은 이론이고, 직접 돌려보고 싶었다. 큰 수의 법칙(Law of Large Numbers)에 따르면, 시행 횟수가 충분히 많아지면 실험적 확률은 이론적 확률에 수렴한다. 10만 번 정도 돌려보면 결과가 명확하게 보일 것이다.
Python으로 짠 코드의 핵심 함수는 이렇다.
def play_once(switch: bool, smart_host: bool, rng: random.Random):
"""Returns (won, valid). valid=False only for dumb host who reveals the car."""
car = rng.randrange(3) # 자동차 위치 무작위
choice = rng.randrange(3) # 참가자 선택 무작위
if smart_host:
# 똑똑한 진행자: 염소 있는 문만 연다
host_options = [d for d in range(3) if d != choice and d != car]
host_opens = rng.choice(host_options)
else:
# 멍청한 진행자: 무작위로 연다 (자동차를 열어버릴 수도 있음)
host_options = [d for d in range(3) if d != choice]
host_opens = rng.choice(host_options)
if host_opens == car:
return False, False # 자동차를 공개해버린 라운드는 무효
if switch:
choice = next(d for d in range(3) if d != choice and d != host_opens)
return choice == car, True
여기서 smart_host=True인 경우가 일반적인 몬티 홀 문제다. 그런데 코드를 짜다 보니 자연스럽게 한 가지 의문이 생겼다. 만약 진행자가 자동차의 위치를 모르고 무작위로 문을 연다면? 그래도 바꾸는 게 유리할까?
Smart host vs Dumb host: 진행자의 지식이 만드는 차이
10만 번씩 돌려본 결과는 이렇다.

| 시나리오 | 승률 | 이론값 |
|---|---|---|
| Smart host + Switch | 0.6675 | 2/3 |
| Smart host + Stay | 0.3325 | 1/3 |
| Dumb host + Switch | 0.4998 | 1/2 |
| Dumb host + Stay | 0.5002 | 1/2 |
흥미로운 결과가 두 가지 보인다.
첫째, 일반적인 몬티 홀(Smart host)은 이론대로 2/3 vs 1/3이다. 시행이 100번을 넘어가면서부터 빠르게 수렴하는 게 그래프에서 보인다. 큰 수의 법칙이 깔끔하게 작동했다.
둘째, 진행자가 무작위로 문을 여는 경우(Dumb host)에는 바꾸든 유지하든 50:50이다. 이 부분이 핵심이다.
왜 이런 차이가 날까? Smart host의 경우 진행자의 행동이 정보를 담고 있다. "염소가 있는 문을 의도적으로 골라서 열었다"는 사실 자체가 확률 분포에 영향을 준다. 반면 Dumb host는 무작위로 행동하므로 새로운 정보가 없다. 진행자가 우연히 염소 문을 열었을 뿐, 그 행동에 의미가 없다.
이게 몬티 홀 문제의 진짜 핵심이다. "문이 두 개 남았다"는 사실 자체가 50:50을 만드는 게 아니라, "진행자가 어떤 규칙으로 문을 열었느냐"가 확률을 결정한다. 같은 결과(염소가 있는 문이 열림)를 봤더라도, 그것이 어떤 과정에서 나왔는지에 따라 사후 확률이 완전히 달라진다.
참고로 코드에서 Dumb host가 자동차를 공개해버린 라운드(전체의 약 1/3)는 무효로 처리했다. 게임이 성립하지 않으니까. 이 무효 라운드를 빼고 "게임이 끝까지 진행된" 라운드만 집계해도 50:50이 나온다는 게 흥미로운 점이다.
코드 작성 시 신경 쓴 부분
시뮬레이션 코드를 짤 때 두 가지를 신경 썼다.
재현 가능성. random.Random(seed) 인스턴스를 시나리오마다 따로 만들었다. 전역 random을 쓰면 다른 시나리오가 영향을 주고받기 때문에, 같은 seed로 돌렸을 때 결과가 일정하지 않다. 시나리오별로 독립된 RNG를 쓰면 디버깅도 쉽고, 결과 비교도 정확하다.
수렴 과정 시각화. 단순히 최종 승률만 출력하는 게 아니라, 매 시행마다의 누적 승률을 기록해서 plot으로 그렸다. 100번에서는 노이즈가 크지만 1만 번을 넘으면 이론값에 딱 붙는 모습이 보이는데, 큰 수의 법칙을 시각적으로 체감할 수 있다. x축을 로그 스케일로 잡은 것도 초기 변동성과 후기 수렴을 한 그래프에서 같이 보기 위해서였다.
전체 코드는 GitHub에 올려뒀다. python monty_hall.py -n 100000 으로 바로 돌려볼 수 있고, --seed 42 같은 식으로 seed를 고정할 수도 있다.
마무리
몬티 홀 문제를 다시 풀어보면서 두 가지를 다시 확인했다.
하나는 확률에서 직관은 자주 틀린다는 것. 우리 뇌는 "남은 선택지가 두 개니까 50:50"이라는 단순한 휴리스틱에 끌리지만, 실제 확률은 정보가 어떻게 들어왔는지에 따라 달라진다. 박사들도 틀렸던 문제니까, 처음에 헷갈리는 게 당연하다.
다른 하나는 시뮬레이션의 가치다. 수학적으로 증명된 결과라도 직접 돌려보면 이해의 깊이가 다르다. 특히 Smart vs Dumb host처럼 변형을 만들어보면서, "진행자의 지식이 확률에 어떻게 영향을 주는가"를 체감할 수 있었다. 이건 수식만 봐서는 잘 안 와닿는 부분이다.
수업 시간에 외운 결론을 10년 만에 코드로 증명해본 셈인데, 의외로 재밌었다. 다음에 비슷하게 직관에 반하는 확률 문제(생일 문제, 두 봉투 역설 같은)를 만나면 또 시뮬레이션을 짜볼 것 같다.