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

  1. Define a study with one manipulated factor (strategy) and two levels: stay and switch.

  2. Validate the study and materialize the two conditions with drex.build_design.

  3. Simulate 100 seeded random Monty Hall games for each condition, write a summary CSV artifact, and print the resulting win counts.

  1from __future__ import annotations
  2
  3import csv
  4import random
  5from pathlib import Path
  6
  7import design_research_experiments as drex
  8
  9DOORS = ("A", "B", "C")
 10SIMULATED_GAMES = 100
 11SIMULATION_SEED = 5
 12
 13
 14def build_monty_hall_study(output_dir: Path) -> drex.Study:
 15    """Build a study with one condition per contestant strategy."""
 16    return drex.Study(
 17        study_id="monty-hall-simulation",
 18        title="Monty Hall Simulation",
 19        description=(
 20            "Compare stay versus switch by simulating random Monty Hall games "
 21            "inside each study condition."
 22        ),
 23        factors=(
 24            drex.Factor(
 25                name="strategy",
 26                description="Contestant decision after the host reveals a goat door.",
 27                kind=drex.FactorKind.MANIPULATED,
 28                levels=(
 29                    drex.Level(name="stay", value="stay"),
 30                    drex.Level(name="switch", value="switch"),
 31                ),
 32            ),
 33        ),
 34        hypotheses=(
 35            drex.Hypothesis(
 36                hypothesis_id="h1",
 37                label="Switching improves win rate",
 38                statement="Switching wins more often than staying in the Monty Hall game.",
 39                independent_vars=("strategy",),
 40                dependent_vars=("win_rate",),
 41                linked_analysis_plan_id="ap1",
 42            ),
 43        ),
 44        outcomes=(
 45            drex.OutcomeSpec(
 46                name="win_rate",
 47                source_table="runs",
 48                column="won",
 49                aggregation="mean",
 50                primary=True,
 51                description="Share of scored conditions that end with the prize.",
 52            ),
 53        ),
 54        analysis_plans=(
 55            drex.AnalysisPlan(
 56                analysis_plan_id="ap1",
 57                hypothesis_ids=("h1",),
 58                tests=("simulation_summary",),
 59                outcomes=("win_rate",),
 60            ),
 61        ),
 62        design_spec={"kind": "full_factorial", "randomize": False},
 63        seed_policy=drex.SeedPolicy(base_seed=SIMULATION_SEED),
 64        output_dir=output_dir,
 65        problem_ids=("monty-hall-game",),
 66        primary_outcomes=("win_rate",),
 67    )
 68
 69
 70def reveal_goat_door(*, prize_door: str, initial_choice: str, rng: random.Random) -> str:
 71    """Randomly reveal one admissible goat door."""
 72    goat_doors = [door for door in DOORS if door != prize_door and door != initial_choice]
 73    if not goat_doors:
 74        raise RuntimeError("Expected at least one goat door to reveal.")
 75    return str(rng.choice(goat_doors))
 76
 77
 78def resolve_final_choice(*, initial_choice: str, revealed_door: str, strategy: str) -> str:
 79    """Return the contestant's final door after staying or switching."""
 80    if strategy == "stay":
 81        return initial_choice
 82
 83    for door in DOORS:
 84        if door != initial_choice and door != revealed_door:
 85            return door
 86    raise RuntimeError("Expected exactly one switch target.")
 87
 88
 89def simulate_condition(condition: drex.Condition, *, n_games: int, seed: int) -> dict[str, object]:
 90    """Simulate one strategy condition over many random Monty Hall games."""
 91    assignments = condition.factor_assignments
 92    strategy = str(assignments["strategy"])
 93    rng = random.Random(seed)
 94    wins = 0
 95
 96    for _ in range(n_games):
 97        prize_door = str(rng.choice(DOORS))
 98        initial_choice = str(rng.choice(DOORS))
 99        revealed_door = reveal_goat_door(
100            prize_door=prize_door,
101            initial_choice=initial_choice,
102            rng=rng,
103        )
104        final_choice = resolve_final_choice(
105            initial_choice=initial_choice,
106            revealed_door=revealed_door,
107            strategy=strategy,
108        )
109        wins += int(final_choice == prize_door)
110
111    return {
112        "condition_id": condition.condition_id,
113        "strategy": strategy,
114        "seed": seed,
115        "games": n_games,
116        "wins": wins,
117        "win_rate": round(wins / n_games, 2),
118    }
119
120
121def write_summary(path: Path, rows: list[dict[str, object]]) -> None:
122    """Write the per-condition simulation summary as a CSV artifact."""
123    if not rows:
124        raise RuntimeError("Expected scored rows before writing artifacts.")
125
126    path.parent.mkdir(parents=True, exist_ok=True)
127    with path.open("w", encoding="utf-8", newline="") as file_obj:
128        writer = csv.DictWriter(file_obj, fieldnames=list(rows[0]))
129        writer.writeheader()
130        writer.writerows(rows)
131
132
133def lookup_strategy(rows: list[dict[str, object]], *, strategy: str) -> dict[str, object]:
134    """Return the summary row for one strategy."""
135    for row in rows:
136        if row["strategy"] == strategy:
137            return row
138    raise RuntimeError(f"Missing strategy summary for {strategy!r}.")
139
140
141def main() -> None:
142    """Simulate random Monty Hall games for each strategy condition."""
143    output_dir = Path("artifacts") / "monty-hall"
144    study = build_monty_hall_study(output_dir)
145
146    errors = drex.validate_study(study)
147    if errors:
148        raise RuntimeError("\n".join(errors))
149
150    conditions = drex.build_design(study)
151    rows = [
152        simulate_condition(
153            condition,
154            n_games=SIMULATED_GAMES,
155            seed=study.seed_policy.base_seed,
156        )
157        for condition in conditions
158    ]
159
160    csv_path = output_dir / "simulation_summary.csv"
161    write_summary(csv_path, rows)
162
163    stay = lookup_strategy(rows, strategy="stay")
164    switch = lookup_strategy(rows, strategy="switch")
165
166    if float(switch["win_rate"]) <= float(stay["win_rate"]):
167        raise RuntimeError("Switching should strictly outperform staying in Monty Hall.")
168
169    print(f"Materialized {len(conditions)} conditions")
170    print(
171        f"Simulated {SIMULATED_GAMES} games per condition with seed {study.seed_policy.base_seed}"
172    )
173    print(f"stay wins {stay['wins']}/{stay['games']} = {stay['win_rate']:.2f}")
174    print(f"switch wins {switch['wins']}/{switch['games']} = {switch['win_rate']:.2f}")
175    print(f"Wrote simulation summary CSV to {csv_path}")
176
177
178if __name__ == "__main__":
179    main()

Expected Results

Run Command

PYTHONPATH=src python examples/monty_hall_simulation.py

The script prints 2 materialized conditions, simulates 100 games per condition with a fixed seed, reports stay winning 35/100 and switch winning 65/100, and writes a summary CSV artifact under artifacts/monty-hall.