Monty Hall implementation
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):

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


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 &amp; 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.

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


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

&gt;&gt;&gt; success = sum(simn_fun(mhb) == 'success' for _ in range(num))
&gt;&gt;&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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 &lt; 1 or goats &lt; 1:
        raise ValueError("Rigged game!  There must be both cars &amp; goats!")
    if reveal &gt;= 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) -&gt; 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) -&gt; Door:
    return random.choice(list(set(doors) - set(goats) - {selected}))

def never_switch(doors: Doors, selected: int, goats: Doors) -&gt; 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.