Monty Hall Simulation#
Source: examples/monty_hall_simulation.py
Introduction#
Model the Monty Hall game as a tiny two-condition drex.Study and simulate
100 random games for each strategy to show why switching usually wins more
often than staying.
Technical Implementation#
Define a study with one manipulated factor (
strategy) and two levels:stayandswitch.Validate the study and materialize the two conditions with
drex.build_design.Pass a typed condition callback to
drex.run_studyso the standard runner owns deterministic seeds, result normalization, and canonical artifacts.
1from __future__ import annotations
2
3import random
4from pathlib import Path
5
6import design_research_experiments as drex
7
8DOORS = ("A", "B", "C")
9SIMULATED_GAMES = 100
10SIMULATION_SEED = 5
11
12
13def build_monty_hall_study(output_dir: Path) -> drex.Study:
14 """Build a study with one condition per contestant strategy."""
15 return drex.Study(
16 study_id="monty-hall-simulation",
17 title="Monty Hall Simulation",
18 description=(
19 "Compare stay versus switch by simulating random Monty Hall games "
20 "inside each study condition."
21 ),
22 factors=(
23 drex.Factor(
24 name="strategy",
25 description="Contestant decision after the host reveals a goat door.",
26 kind=drex.FactorKind.MANIPULATED,
27 levels=(
28 drex.Level(name="stay", value="stay"),
29 drex.Level(name="switch", value="switch"),
30 ),
31 ),
32 ),
33 hypotheses=(
34 drex.Hypothesis(
35 hypothesis_id="h1",
36 label="Switching improves win rate",
37 statement="Switching wins more often than staying in the Monty Hall game.",
38 independent_vars=("strategy",),
39 dependent_vars=("win_rate",),
40 ),
41 ),
42 outcomes=(
43 drex.OutcomeSpec(
44 name="win_rate",
45 source_table="runs",
46 column="won",
47 aggregation="mean",
48 primary=True,
49 description="Share of scored conditions that end with the prize.",
50 ),
51 ),
52 analysis_plans=(
53 drex.AnalysisPlan(
54 analysis_plan_id="ap1",
55 hypothesis_ids=("h1",),
56 tests=("simulation_summary",),
57 outcomes=("win_rate",),
58 ),
59 ),
60 design_spec=drex.DesignSpec(kind=drex.DesignKind.FULL_FACTORIAL),
61 seed_policy=drex.SeedPolicy(base_seed=SIMULATION_SEED),
62 output_dir=output_dir,
63 )
64
65
66def reveal_goat_door(*, prize_door: str, initial_choice: str, rng: random.Random) -> str:
67 """Randomly reveal one admissible goat door."""
68 goat_doors = [door for door in DOORS if door != prize_door and door != initial_choice]
69 if not goat_doors:
70 raise RuntimeError("Expected at least one goat door to reveal.")
71 return str(rng.choice(goat_doors))
72
73
74def resolve_final_choice(*, initial_choice: str, revealed_door: str, strategy: str) -> str:
75 """Return the contestant's final door after staying or switching."""
76 if strategy == "stay":
77 return initial_choice
78
79 for door in DOORS:
80 if door != initial_choice and door != revealed_door:
81 return door
82 raise RuntimeError("Expected exactly one switch target.")
83
84
85def simulate_condition(
86 run_spec: drex.RunSpec,
87 condition: drex.Condition,
88) -> drex.RunOutput:
89 """Simulate one strategy condition with the runner-provided seed."""
90 assignments = condition.factor_assignments
91 strategy = str(assignments["strategy"])
92 rng = random.Random(run_spec.seed)
93 wins = 0
94
95 for _ in range(SIMULATED_GAMES):
96 prize_door = str(rng.choice(DOORS))
97 initial_choice = str(rng.choice(DOORS))
98 revealed_door = reveal_goat_door(
99 prize_door=prize_door,
100 initial_choice=initial_choice,
101 rng=rng,
102 )
103 final_choice = resolve_final_choice(
104 initial_choice=initial_choice,
105 revealed_door=revealed_door,
106 strategy=strategy,
107 )
108 wins += int(final_choice == prize_door)
109
110 win_rate = round(wins / SIMULATED_GAMES, 2)
111 return drex.RunOutput(
112 outputs={
113 "condition_id": condition.condition_id,
114 "strategy": strategy,
115 "seed": run_spec.seed,
116 "games": SIMULATED_GAMES,
117 "wins": wins,
118 "win_rate": win_rate,
119 },
120 metrics={"primary_outcome": win_rate, "win_rate": win_rate},
121 )
122
123
124def lookup_strategy(rows: list[dict[str, object]], *, strategy: str) -> dict[str, object]:
125 """Return the summary row for one strategy."""
126 for row in rows:
127 if row["strategy"] == strategy:
128 return row
129 raise RuntimeError(f"Missing strategy summary for {strategy!r}.")
130
131
132def main() -> None:
133 """Run random Monty Hall games through standalone study orchestration."""
134 output_dir = Path("artifacts") / "monty-hall"
135 study = build_monty_hall_study(output_dir)
136
137 errors = drex.validate_study(study)
138 if errors:
139 raise RuntimeError("\n".join(errors))
140
141 runner: drex.ConditionRunner = simulate_condition
142 results = drex.run_study(
143 study,
144 condition_runner=runner,
145 show_progress=False,
146 )
147 rows = [result.outputs for result in results]
148
149 stay = lookup_strategy(rows, strategy="stay")
150 switch = lookup_strategy(rows, strategy="switch")
151
152 if float(switch["win_rate"]) <= float(stay["win_rate"]):
153 raise RuntimeError("Switching should strictly outperform staying in Monty Hall.")
154
155 print(f"Completed {len(results)} conditions")
156 print(f"Simulated {SIMULATED_GAMES} games per condition")
157 print(f"stay wins {stay['wins']}/{stay['games']} = {stay['win_rate']:.2f}")
158 print(f"switch wins {switch['wins']}/{switch['games']} = {switch['win_rate']:.2f}")
159 print(f"Wrote canonical artifacts to {output_dir}")
160
161
162if __name__ == "__main__":
163 main()
Expected Results#
Run Command
PYTHONPATH=src python examples/monty_hall_simulation.py
The script completes 2 conditions, simulates 100 games per condition, reports
stay winning 32/100 and switch winning 70/100, and writes the
canonical artifact set under artifacts/monty-hall.