10
\$\begingroup\$

To get a feel of Monty Hall problem, I've implemented Monty Hall simulator in Python, using only two strategies: never switch the door or always switch the door. Please review it with an eye towards any problem with the code you can think of, things like better design, flaws in code, etc.

#!/usr/bin/env python3

import random
import sys


def available_doors():
    return [1, 2, 3]


class MontyHallBox(object):
    def __init__(self):
        self.__doors = {1: None, 2: None, 3: None}
        self.__init_doors()

    def reinitialize(self):
        self.__init_doors()

    def __init_doors(self):
        self.__doors = {n: 'goat' for n in range(1, 4)}
        car = random.choice(available_doors())
        self.__doors[car] = 'car'

    def _get_rand_doornum_item(self):
        door_num = random.choice(available_doors())
        item = self.__doors[door_num]
        return door_num, item

    def reveal_noncar(self, initial_door):
        door_num, item = self._get_rand_doornum_item()
        while item == 'car' or door_num == initial_door:
            door_num, item = self._get_rand_doornum_item()
        return door_num

    def reveal_selected(self, door):
        return self.__doors[door]


def run_sim_always_switch(mhb):
    mhb.reinitialize()
    initial_door = random.choice(available_doors())
    noncar_door = mhb.reveal_noncar(initial_door)
    switch_door_set = set(available_doors()) - set([initial_door, noncar_door])
    switch_door = list(switch_door_set)[0]
    item = mhb.reveal_selected(switch_door)
    if item == 'car':
        return 'success'
    return 'fail'


def run_sim_never_switch(mhb):
    mhb.reinitialize()
    initial_door = random.choice(available_doors())
    # this is pointless really as this info is not used by game participant
    _ = mhb.reveal_noncar(initial_door)
    item = mhb.reveal_selected(initial_door)
    if item == 'car':
        return 'success'
    return 'fail'


def run_simn(num, simn_fun):
    mhb = MontyHallBox()
    results = []
    for i in range(num):
        results.append(simn_fun(mhb))
    successes = len(list(filter(lambda x: x == 'success', results)))
    return successes / num


def main():
    num = 10000
    if num < 1:
        print('Simulation has to be ran at least 1 time')
        sys.exit(1)
    print('Always switch, run simulation {} times.'.format(num))
    print('Success ratio: {}'.format(run_simn(num, run_sim_always_switch)))
    print()
    print('Never switch, run simulation {} times.'.format(num))
    print('Success ratio: {}'.format(run_simn(num, run_sim_never_switch)))


if __name__ == '__main__':
    main()

\$\endgroup\$

2 Answers 2

16
\$\begingroup\$

Available doors

Why is this a stand-alone function? Why are door identifiers hard-coded here? The MontyHallBox knows what the doors are, and what they are called:

class MontyHallBox(object):
    def available_doors(self):
        return list(self.__doors.keys())

Class Syntax

class MontyHallBox(object):
    ...

is obsolete syntax. Use:

class MontyHallBox:
    ...

Class Private Names

Why use a double underscore for self.__doors and self.__init_doors()? This is a Python feature for avoiding name collision in derived classes, and "mangles" the names is a predictable fashion (adding the classname as a prefix):

>>> mhb = MontyHallBox()
>>> mhb._MontyHallBox__doors
{1: 'car', 2: 'goat', 3: 'goat'}
>>> 

It does not provide any extra security of the member.

Use set notation

Instead of set([initial_door, noncar_door]), write {initial_door, noncar_door}.

Throw-away variables

The variable i is never used:

    for i in range(num):
        results.append(simn_fun(mhb))

The PEP-8 recommendation is to use _ for these throw-away variables:

    for _ in range(num):
        results.append(simn_fun(mhb))

List comprehension

Building up a list of results should be done using list comprehension, instead of initialization & repeated .append operations. Instead of:

    results = []
    for i in range(num):
        results.append(simn_fun(mhb))

Use:

    results = [simn_fun(mhb) for _ in range(num)]

Counting

There is no need to build up a long list of results, just to count the result distributions. Pass the results as they are generated to a counter.

>>> import collections
>>> num = 10000
>>> simn_fun = run_sim_always_switch
>>> counts = collections.Counter(simn_fun(mhb) for _ in range(num))
>>> counts['success']
6717
>>> counts['fail']
3283

or, knowing True is 1 and False is zero, count the successes as you generate them yourself:

>>> success = sum(simn_fun(mhb) == 'success' for _ in range(num))
>>> success
6614

Formatting

Use f-strings to put the variables/expressions directly in the format statements:

    print(f'Always switch, run simulation {num} times.')
    print(f'Success ratio: {run_simn(num, run_sim_always_switch)}')
    print(f'Never switch, run simulation {num} times.')
    print(f'Success ratio: {run_simn(num, run_sim_never_switch)}')

Round results to the desired significant figures with appropriate format codes:

    print(f'Success ratio: {run_simn(num, run_sim_never_switch):.3f}')

Named Constants

If you accidentally wrote the tests as if item == 'Car':, you'd find that nobody wins, and would have to hunt down where in the code you made spelling/capitalization errors. The IDE won't help you; it can't auto complete strings for you. If you used named constants ...

CAR = 'car'
GOAT = 'goat'

... and you wrote if item == Car:, instead of nobody winning, the program would crash with an unknown identifier and identity the line where the problem exists, which is much better than having to search through all of the code looking for where logic is going wrong. But the above named "constants" aren't actually constants; they are variables we hope never get accidentally (or maliciously) changed.

from enum import Enum

Prize = Enum('Prize', 'CAR, GOAT')

Now we have Prize.CAR and Prize.GOAT, which are constants.

Malicious Strategies

A friend can say they've come up with a great strategy - and let you test their code:

import friend

run_simn(10_000, friend.strategy)

And lo and behold, they always win the car! How is this possible?

Friend strategy 1:

def strategy(mhb):
    initial_door = next(door for door, prize in mhb._MontyHallBox__doors.items()
                        if prize == 'car')
    _ = mhb.reveal_noncar(initial_door)
    item = mhb.reveal_selected(initial_door)
    return 'success' if item == 'car' else 'fail'

Friend strategy 2:

def strategy(mhb):
    initial_door = 1
    goats = { mhb.reveal_noncar(initial_door) for _ in range(10) }

    if len(goats) == 2:
        selected_door = initial_door
    else:
        selected_door = 2 if goats == { 3 } else 3
    item = mhb.reveal_selected(initial_door)

    return 'success' if item == 'car' else 'fail'

Friend strategy 3:

def strategy(mhb):
    return 'success'

They are cheating. Some friend.


A safer (harder to cheat) simulation approach

Let's start by creating some type aliases, to make clearer code.

from typing import Callable, Counter, List, NewType, Tuple

# Doors are numbered
Door = NewType('Door', int)
Doors = List[Door]

This Door is just a fancy way of saying the int type-hint, and Doors is a fancy way of saying the List[int] type-hint.

Now let's create two methods which the let's the user play the Monty Hall game:

def first_pick(doors: Doors) -> Door:
    """
    Ask the player to pick a door
    """

    return int(input(f"Pick a door [{', '.join(map(str,doors))}]: "))

def second_pick(doors: Doors, selected: int, goats: Doors) -> Door:
    """
    Show some goats behind doors the player didn't pick.
    Ask them again to pick a door.
    """

    print(f"Monty shows you a goat behind door #{', '.join(map(str, goats))}.")
    choice = input(f"Pick a new door [default: {selected}]: ")
    return int(choice) if choice else selected

We pass all the information the player needs to make their choices. In the first case, just the list of available Doors. In the second, we add what their original Door selection was, and the Doors Monty Hall revealed goats behind. No extra MontyHallBox object, or self parameter that the user can peek into to cheat and get extra information.

We can even add types for these two functions up where we are defining the types we're using:

FirstPick = Callable[[Doors], Door]
SecondPick = Callable[[Doors, Door, Doors], Door]

Now, let's build the actual game:

def monty_hall(first: FirstPick = first_pick, second: SecondPick = second_pick, *,
               cars: int = 1, goats: int = 2, reveal: int = 1) -> Tuple[Door, Prize]:
    """
    Play a Monty Hall type game.

    The player picks one of several doors, behind each is either a Car or a Goat.
    Monty Hall shows the player behind one of the doors they didn't pick,
    "Oh look, behind this door is a Goat!  Now, do you want to change your mind?"
    The player now gets alter their selection, but does it pay to change their mind?

    Parameters:
        first: given the doors, pick a door
        second: given the doors, the first pick, and what was revealed, pick a door
        cars: number of cars (default 1)
        goats: number of goats (default 2)
        reveal: number of hidden goats to reveal (default 1)

    Returns:
        The door they choose, and the Prize behind it (a Goat or a Car)
    """

    # Sanity checks
    if cars < 1 or goats < 1:
        raise ValueError("Rigged game!  There must be both cars & goats!")
    if reveal >= goats:
        raise ValueError("Monty is not allowed to reveal all of the goats!")

    # Set up the game
    prizes = [Prize.CAR] * cars + [Prize.GOAT] * goats
    random.shuffle(prizes)
    doors = list(range(1, len(prizes) + 1))
    game = { door: prize for door, prize in zip(doors, prizes) }

    # Ask player to make their initial selection...
    selected = first(doors)

    # Monty reveals some doors
    goats = [ door for door, prize in game.items()
              if prize == Prize.GOAT and door != selected ]
    random.shuffle(goats)
    goats = goats[:reveal]

    # Based on original choice and revealed doors, ask player makes new choice...
    selected = second(doors, selected, goats)

    # ... and give the player their prize
    return selected, game[selected]

Ok, I've beefed up the game a little bit. We've got a configurable number of cars (default 1), goats (default 2), and doors to reveal (default 1). We've got first and second functions which default to the first_pick and second_pick user player functions above. We've got a long doc-string describing the function. Then we've got some validation on number of cars, goats, and reveals.

Then we have the meat-and-potatoes of the function. Create the prizes, mix them up, and hide them behind doors. Call the first method to get the initial door selection. Then Monty Hall picks some doors with goats to reveal. The second the second function is called to get the final door selection. Finally, the resulting prize is returned.

Want to play the game?

def human_verses_monty(**kwargs):
    door, prize = monty_hall(**kwargs)
    print(f"Behind door #{door}, you find a {prize.name}!")

Use human_verses_monty(). Or maybe human_verses_monty(cars=3, goats=3, reveal=2)

I'm using **kwargs for brevity, so I can pass additional keywords through to the underlying monty_hall(...) method. It would be clearer to list all of the keyword arguments out, but this post is already getting pretty long, and I'm not done yet.

Playing multiple times

Now that we have our safe simulation, let's make it so we can play the game multiple times, and collect the results:

def monty_halls(strategy: SecondPick, games, *,
                initial: FirstPick = random.choice, **kwargs) -> Counter[Prize]:    
    return Counter(monty_hall(initial, strategy, **kwargs)[1] for _ in range(games))

The initial door pick defaults to random.choice. We need to pass in a strategy for the second pick. The game is played the given number of times, with Door, Prize being returned, from which we select only the Prize with [1], and count the number of times we get each prize in a Counter.

Now we need some strategies:

def always_switch(doors: Doors, selected: Door, goats: Doors) -> Door:
    return random.choice(list(set(doors) - set(goats) - {selected}))

def never_switch(doors: Doors, selected: int, goats: Doors) -> Door:
    return selected

Again, no extra information to allow them to cheat. And they don't get to break the rules and play incorrectly (asking Monty Hall multiple times to reveal doors) to cheat.

How about function to evaluate various strategies:

def evaluate(strategies: List[SecondPick], games, **kwargs):
    for strategy in strategies:
        prizes = monty_halls(strategy, games, **kwargs)
        wins = prizes[Prize.CAR]
        losses = prizes[Prize.GOAT]
        total = wins + losses
        print(f"{strategy.__name__}: wins {wins / total * 100:.2f} %")
    print()

Let's see it in action:

if __name__ == '__main__':
    print("Standard game (1 car, 2 goats)")
    evaluate([always_switch, never_switch], 10_000)

    print("Alternate game (2 car, 3 goats, 2 reveals)")
    evaluate([always_switch, never_switch], 10_000, cars=2, goats=3, reveal=2)

Running this gives:

Standard game (1 car, 2 goats)
always_switch: wins 66.88 %
never_switch: wins 33.12 %

Alternate game (2 car, 3 goats, 2 reveals)
always_switch: wins 80.14 %
never_switch: wins 39.91 %

Cheaters will be cheaters

It is still possible to cheat. One method would be to capture the stack frame, and look at the local variables in the calling frame. We can get around this by using a separate thread or process to execute the strategy calls in, so the caller can't simply walk up the stack looking for gold. This still isn't fool proof, 'cause the cheater could look for the parent thread, or parent process and try to inspect those.

At least we aren't making it easy to cheat, like the original MontyHallBox approach did.

\$\endgroup\$
6
  • 2
    \$\begingroup\$ +1 for making improvements you then delete anyway to teach the OP more things, e.g. changing i to _ even though you're about to replace the for loop with a list comprehension. \$\endgroup\$
    – J.G.
    Commented Apr 24, 2020 at 10:06
  • \$\begingroup\$ I would even go 1 step further in the and have simn_fun return a boolean instead of a flag \$\endgroup\$ Commented Apr 24, 2020 at 14:17
  • 1
    \$\begingroup\$ @MaartenFabré I went 3 steps further. \$\endgroup\$
    – AJNeufeld
    Commented Apr 24, 2020 at 15:08
  • \$\begingroup\$ @AJNeufeld, thank you for thorough improvement, just wanted to say that re "private" object attribute names some Python programmers maintain that _ should be interpreted as a hint to a (cooperative) programmer that "don't access it unless you know what you're doing" and __ as hint that "you really should not be using this". I thought this was nice convention. \$\endgroup\$ Commented May 26, 2020 at 17:13
  • \$\begingroup\$ __* identifiers "help avoid name clashes between “private” attributes of base and derived classes", so class Base: can define a __doors attribute, and class Derived(Base): can also define its own __doors attribute, and they won't clash because they are internally named _Base__doors and _Derived__doors respectively. It is not intended to be used as a "hint", but rather as a safety net where you don't need access to the base's source code in order to avoid name collisions. JSYK. \$\endgroup\$
    – AJNeufeld
    Commented May 26, 2020 at 17:39
6
\$\begingroup\$

Available doors

First of all, this:

    self._doors = {1: None, 2: None, 3: None}

should be calling available_doors, i.e.

self._doors = {i: None for i in available_doors()}

However, available_doors needn't be a function; it can be a global constant:

AVAILABLE_DOORS = [1, 2, 3]

Better yet, make it a set:

AVAILABLE_DOORS = {1, 2, 3}

Better yet, pass it as a parameter to the class:

def __init__(self, available_doors: set):
    self._doors = available_doors
    self._init_doors()

Better yet, don't even care about the collection; simply care about the number of doors:

def __init__(self, n_doors: int):
    self._n_doors = n_doors

Then this:

    self.__doors = {n: 'goat' for n in range(1, 4)}

can actually use it:

self._doors = {n: 'goat' for n in range (1, 1 + self._n_doors)}

In other words, the number of doors should be parametric, and only kept in one place.

Redundant init

Since reinitialize just calls _init_doors, why not have the contents of _init_doors in reinitialize, deleting _init_doors?

Stringly-typed results

This:

if item == 'car':
    return 'success'
return 'fail'

would make more sense as a boolean return value:

return item == 'car'

Counting successes

successes = len(list(filter(lambda x: x == 'success', results)))

is better written as

successes = sum(1 for x in results if x == 'success')

If you take the boolean suggestion above, it will just be

successes = sum(1 for x in results if x)

Technically, since bool can be cast to an int, this is equivalent to

successes = sum(results)
\$\endgroup\$
2
  • \$\begingroup\$ I upvoted your answer, but disagree on simulation result types. That function (e.g. run_sim_never_switch) represents a simulation. Simulation returns some sort of result, not truth or falsehood (in general). In a world of types this should be something specific, like an object, a number, etc. I think enum.Enum proposed by AJNeufeld is a nice way of expressing this approach without getting too philosophically-OO-heavy. \$\endgroup\$ Commented May 26, 2020 at 17:22
  • \$\begingroup\$ @LetMeSOThat4U Enum is not a terrible solution, but the OP's use of "success" or "failure" strings indicates a very clear case for a boolean. \$\endgroup\$
    – Reinderien
    Commented May 26, 2020 at 17:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.