Download this notebook (.ipynb)

Experiments: Test the Monty Hall Strategy#

Monty Hall is a useful standalone experiment because the study definition, treatment, outcome, and ground-truth expectation are all visible. We define stay and switch as two conditions, run 100 seeded games for each, and save a compact result artifact.

Setup#

python -m pip install design-research-experiments==0.3.0

Step 1: Define the study contract#

[1]:
import csv
import random
import warnings
from pathlib import Path
from typing import TypedDict

warnings.filterwarnings("ignore", message="IProgress not found.*")

import design_research_experiments as experiments  # noqa: E402

DOORS = ("A", "B", "C")
GAMES_PER_CONDITION = 100
SEED = 5

study = experiments.Study(
    study_id="monty-hall-simulation",
    title="Monty Hall Simulation",
    description="Compare stay and switch over seeded random games.",
    factors=(
        experiments.Factor(
            name="strategy",
            description="Decision after the host reveals a goat door.",
            kind=experiments.FactorKind.MANIPULATED,
            levels=(
                experiments.Level(name="stay", value="stay"),
                experiments.Level(name="switch", value="switch"),
            ),
        ),
    ),
    hypotheses=(
        experiments.Hypothesis(
            hypothesis_id="h1",
            label="Switching improves win rate",
            statement="Switching wins more often than staying.",
            independent_vars=("strategy",),
            dependent_vars=("win_rate",),
        ),
    ),
    outcomes=(
        experiments.OutcomeSpec(
            name="win_rate",
            source_table="runs",
            column="won",
            aggregation="mean",
            primary=True,
        ),
    ),
    analysis_plans=(
        experiments.AnalysisPlan(
            analysis_plan_id="ap1",
            hypothesis_ids=("h1",),
            tests=("simulation_summary",),
            outcomes=("win_rate",),
        ),
    ),
    design_spec=experiments.DesignSpec(kind=experiments.DesignKind.FULL_FACTORIAL, randomize=False),
    seed_policy=experiments.SeedPolicy(base_seed=SEED),
    problem_ids=("monty-hall-game",),
)
errors = experiments.validate_study(study)
print("Study valid:", not errors)
print("Hypothesis:", study.hypotheses[0].statement)
Study valid: True
Hypothesis: Switching wins more often than staying.

Step 2: Materialize one condition per strategy#

[2]:
conditions = experiments.build_design(study)
print("Conditions:", len(conditions))
for condition in conditions:
    print(f"- {condition.condition_id}: {condition.factor_assignments['strategy']}")
Conditions: 2
- cond-ecf5c1dda7b3: stay
- cond-a18b5b000ae6: switch

Step 3: Make the host and contestant rules explicit#

[3]:
def reveal_goat(prize: str, initial: str, rng: random.Random) -> str:
    """Return one admissible goat door for the host to reveal."""
    candidates = [door for door in DOORS if door != prize and door != initial]
    return str(rng.choice(candidates))


def final_choice(initial: str, revealed: str, strategy: str) -> str:
    """Resolve the contestant's final door from the assigned strategy."""
    if strategy == "stay":
        return initial
    return next(door for door in DOORS if door != initial and door != revealed)


demo_rng = random.Random(SEED)
demo_prize = "A"
demo_initial = "A"
demo_revealed = reveal_goat(demo_prize, demo_initial, demo_rng)
print("Prize:", demo_prize)
print("Initial choice:", demo_initial)
print("Host reveals:", demo_revealed)
print("Stay chooses:", final_choice(demo_initial, demo_revealed, "stay"))
print("Switch chooses:", final_choice(demo_initial, demo_revealed, "switch"))
Prize: A
Initial choice: A
Host reveals: C
Stay chooses: A
Switch chooses: B

Step 4: Run both seeded conditions#

[4]:
class SimulationRow(TypedDict):
    """One seeded strategy result."""

    strategy: str
    games: int
    wins: int
    win_rate: float
    seed: int


def simulate(strategy: str, *, games: int, seed: int) -> SimulationRow:
    """Simulate one seeded strategy condition."""
    rng = random.Random(seed)
    wins = 0
    for _ in range(games):
        prize = str(rng.choice(DOORS))
        initial = str(rng.choice(DOORS))
        revealed = reveal_goat(prize, initial, rng)
        choice = final_choice(initial, revealed, strategy)
        wins += int(choice == prize)
    return {
        "strategy": strategy,
        "games": games,
        "wins": wins,
        "win_rate": wins / games,
        "seed": seed,
    }


rows = [
    simulate(str(condition.factor_assignments["strategy"]), games=GAMES_PER_CONDITION, seed=SEED)
    for condition in conditions
]
for row in rows:
    print(f"{row['strategy']}: {row['wins']}/{row['games']} = {row['win_rate']:.2f}")
stay: 35/100 = 0.35
switch: 65/100 = 0.65

Step 5: Preserve the result as an artifact#

[5]:
output_path = Path("artifacts/tutorials/experiments_monty_hall/simulation_summary.csv")
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8", newline="") as file_obj:
    writer = csv.DictWriter(file_obj, fieldnames=list(rows[0]))
    writer.writeheader()
    writer.writerows(rows)

stay, switch = rows
print("Observed lift:", f"{switch['win_rate'] - stay['win_rate']:.2f}")
print("Artifact:", output_path)
print(output_path.read_text(encoding="utf-8").strip())
Observed lift: 0.30
Artifact: artifacts/tutorials/experiments_monty_hall/simulation_summary.csv
strategy,games,wins,win_rate,seed
stay,100,35,0.35,5
switch,100,65,0.65,5

Interpretation#

The seeded run produces 35 stay wins and 65 switch wins. That finite result is close to the theoretical probabilities of one third and two thirds; changing the seed or run budget changes the observed counts but not the study contract.