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. Pass a typed condition callback to drex.run_study so 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.