6
\$\begingroup\$

I mainly write code for data analysis so don't think about or use OOP at all on the day job. I thought I'd have a go at a simple Monty Hall simulation in an object-oriented style to figure out how it works - specifically I based it on this: https://www.reddit.com/r/dailyprogrammer/comments/n94io8/20210510_challenge_389_easy_the_monty_hall_problem/

Appreciate this project has been done to death but I just want to get a bit of feedback on whether I 'get' OOP or not (this is my first OOP program). Having written a lot of code in a procedural style, I found it quite confusing. Note that the strategies in the strategise method are from the link above. If an unrecognised name is used, it just picks a second door randomly.

What I specifically struggled with are things like:

  • What should be an object? There were lots of things that could have been. Doors, maybe a game show host, maybe the prizes... in the end I kept it simple but that was only after a few iterations and getting stuck.
  • Am I using the classes/methods correctly when running the simulation? Would you surface more of the methods rather than using a 'wrapper' method like I've done with Contestant.play_the_game?
  • The strategise method is a bit of a mess. Is there a better way of implementing this that isn't just a load of if...else?

Any other thoughts/criticisms on general style and anything else are welcome - I don't generally code in Python either so this was all very new.

My class definitions are in a file called montyHall.py:

import random

class MontyHall:
    def __init__(self):
        '''The Monty Hall game! Three doors, 2 goats, one car.'''
        self.doors = {1: "goat", 2:  "goat", 3: "goat"}
        prize_door = random.randint(1, 3)
        self.doors[prize_door] = "car"

    def check_door(self, door_number):
        '''Checks what is behind a given door.'''
        return self.doors[door_number]

    def first_reveal(self, door_number):
        '''Takes a door number picked by the contestant and opens one of other doors with a goat.'''
        doors = [1, 2, 3]
        doors.remove(door_number)
        prizes = list(map(self.check_door, doors))
        if "car" in prizes:
            del doors[prizes.index("car")]
        door = random.choice(doors)
        return door

    def second_reveal(self, door_number):
        '''Takes the contestant's second pick and reveals their prize.'''
        prize = self.check_door(door_number)
        return prize

class Contestant:
    def __init__(self, name):
        '''A contestant to play the Monty Hall game.'''
        self.name = name
        self.wins = []
        self.second_door = 0
        if self.name in ["Alice", "Bob", "Frank", "Gina"]:
            self.first_door = 1
        else:
            self.first_door = random.choice([1, 2, 3])

    def strategise(self, doors):
        '''Picks a second door. If the contestant name is not recognised, it just picks randomly.'''
        if self.name in ["Alice", "Dave"]:
            self.second_door = self.first_door
        elif self.name in ["Bob", "Erin"]:
            self.switch_doors(doors)
        elif self.name == "Frank":
            if 2 in doors:
                self.second_door = 2
            else:
                self.second_door = self.first_door
        elif self.name == "Gina":
            if not self.wins:
                self.second_door = self.first_door
            else:
                if self.wins[-1]:
                    if self.second_door != self.first_door:
                        self.switch_doors(doors)
                else:
                    if self.second_door != self.first_door:
                        self.second_door = self.first_door
                    else:
                        self.switch_doors(doors)
        else:
            self.second_door = random.choice(doors)

    def switch_doors(self, doors):
        '''Switches doors for the second contestant pick.'''
        doors.remove(self.first_door)
        self.second_door = doors[0]

    def first_pick(self, competition):
        '''Pass the first door choice to the competition to open one of the other doors, and then pick a strategy.'''
        open_door = competition.first_reveal(self.first_door)
        doors = [1, 2, 3]
        doors.remove(open_door)
        self.strategise(doors)

    def second_pick(self, competition):
        '''Pass the second door choice to the competition to reveal the prize!'''
        prize = competition.second_reveal(self.second_door)
        if (prize == "car"):
            self.wins.append(1)
        else:
            self.wins.append(0)

    def play_the_game(self, competition):
        '''Play the game.'''
        self.first_pick(competition)
        self.second_pick(competition)

And I run the simulation like this:

from montyHall import *

def main():
    alice = Contestant("Alice")
    bob = Contestant("Bob")
    carol = Contestant("Carol")
    dave = Contestant("Dave")
    erin = Contestant("Erin")
    frank = Contestant("Frank")
    gina = Contestant("Gina")

    for i in range(1000):
        for contestant in [alice, bob, carol, dave, erin, frank, gina]:
            competition = MontyHall()
            contestant.play_the_game(competition)

    for contestant in [alice, bob, carol, dave, erin, frank, gina]:
        print(contestant.name + " win rate: " + str(100 * sum(contestant.wins)/len(contestant.wins)) + "%")

if __name__ == "__main__":
    main()
\$\endgroup\$

4 Answers 4

4
\$\begingroup\$

I'm by no means an expert and recognizing that something can be improved doesn't necessarily mean you know how. But let's give it a try anyway.

For the OO approach: I would say there are 3 key elements in the Monty Hall problem (4 if the looping over games is also included) that we want to abstract/encapsulate:

  1. The "board"; random selection of goats and a car which can be revealed by opening a door.
  2. The player (which is mainly the strategy for the first pick and switching on the second).
  3. The logic of the game (or indeed the host).

For these elements we want to first think of the visible behaviors (without thinking of how to do it in code).

For the doors; we should be able to reveal what is behind the door (in this case it doesn't matter if that is for a contestant or the host).

For the player; we have to make a first guess, followed by a decision if we want to switch.

For the game; we want to play the game (with a "board" and a player).

This almost fully reveals how our classes should look and how we want to test them. See the test_monty_hall.py for how to use the classes without being concerned about implementation details. A TDD approach can also help in discovering what the classes and methods should be. Because to test something you fist need to figure out what it should do. On top of that, if things are difficult to test they are probably not at the right abstraction (the Game class might benefit from a different abstraction since we are testing "private" methods in this example, left as an exercise to the reader ...).

With this OO approach you also have a nicer alternative to strategise. Instead of a single class with all strategies you can have just an abstract class with concrete implementations of the different strategies. This keeps everything related to the contestants strategy nicely encapsulated.

In my attempt you would end up with the MontyHall.py (all code focuses on the OO approach, cutting corners in other places)

import random

from abc import ABC, abstractmethod


class Board:

    def __init__(self, inp=None):
        if not inp:
            inp = self._generate()
        self._doors = inp

    def reveal(self, door):
        """Reveal door content.
        
        Converts door number from human format to indices.
        """
        return self._doors[door - 1]

    def _generate(self):
        doors = ['car', 'goat', 'goat']
        random.shuffle(doors)
        return doors


class IPlayer(ABC):

    def __init__(self, name) -> None:
        super().__init__()
        self.name = name

    @abstractmethod
    def pick(self):
        pass

    @abstractmethod
    def switch(self):
        pass

class Game:

    def __init__(self, board: Board, player: IPlayer) -> None:
        self.board = board
        self.player = player
        self.won = False

    def play(self):
        player_pick = self.player.pick()
        other = self._reveal_other(player_pick)
        if self.player.switch():
            player_pick = self._switch_pick(player_pick, other)
        if self.board.reveal(player_pick) == 'car':
            self.won = True

    def _reveal_other(self, number):
        goats = {x+1 for x, item in enumerate(self.board._doors) if item == 'goat'}
        goats.discard(number)
        return random.choice(list(goats))

    def _switch_pick(self, player_pick, other):
        return {1,2,3}.difference((player_pick, other)).pop()


class FirstNoSwitch(IPlayer):

    def pick(self):
        return 1

    def switch(self):
        return False

class FirstSwitch(IPlayer):

    def pick(self):
        return 1

    def switch(self):
        return True

main.py

from MontyHall import Board, Game
from MontyHall import FirstNoSwitch, FirstSwitch


if __name__ == "__main__":
    bob = FirstSwitch("Bob")
    alice = FirstNoSwitch("Alice")

    runs = 1000
    b = 0
    a = 0
    for run in range(runs):
        bobs_game = Game(Board(), bob)
        alices_game = Game(Board(), alice)

        bobs_game.play()
        if bobs_game.won:
            b += 1

        alices_game.play()
        if alices_game.won:
            a += 1

    print("{} won {} out of {}: {:0.2f}%".format(bob.name, b, runs, 100*b/runs))
    print("{} won {} out of {}: {:0.2f}%".format(alice.name, a, runs, 100*a/runs))

test_monty_hall.py

import unittest

from MontyHall import Board, Game
from MontyHall import FirstNoSwitch, FirstSwitch

class TestMontyHallBoard(unittest.TestCase):

    def test_revealing_a_door_shows_its_content(self):
        input = ['goat', 'goat', 'car']
        board = Board(input)
        self.assertEqual(board.reveal(1), 'goat')
        self.assertEqual(board.reveal(2), 'goat')
        self.assertEqual(board.reveal(3), 'car')

    def test_generating_a_random_board(self):
        board = Board()
        self.assertEqual(board._doors.count('goat'), 2)
        self.assertEqual(board._doors.count('car'), 1)


class TestStratagy(unittest.TestCase):

    def test_frist_no_switch(self):
        s = FirstNoSwitch("Alice")
        self.assertEqual(s.pick(), 1)
        self.assertFalse(s.switch())

    def test_frist_switch(self):
        s = FirstSwitch("Bob")
        self.assertEqual(s.pick(), 1)
        self.assertTrue(s.switch())
        

class TestMontyHallGame(unittest.TestCase):

    def test_new_game_is_not_won(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertFalse(game.won)

    def test_picking_goat_reveals_the_other_goat(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertEqual(game._reveal_other(1), 2)

    def test_picking_other_goat_reveals_the_goat_(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertEqual(game._reveal_other(2), 1)

    def test_picking_car_reveals_a_goat(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertTrue(game._reveal_other(3) in (1, 2))

    def test_switching_one_two_gives_three(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertEqual(game._switch_pick(1, 2), 3)

    def test_switching_three_two_gives_one(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        self.assertEqual(game._switch_pick(3, 2), 1)

    def test_alice_wins(self):
        p = FirstNoSwitch("Alice")
        b = Board(['car', 'goat', 'goat'])
        game = Game(b, p)
        game.play()
        self.assertTrue(game.won)

    def test_alice_loses(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'car', 'goat'])
        game = Game(b, p)
        game.play()
        self.assertFalse(game.won)

    def test_alice_loses_again(self):
        p = FirstNoSwitch("Alice")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        game.play()
        self.assertFalse(game.won)

    def test_bob_wins(self):
        p = FirstSwitch("Bob")
        b = Board(['goat', 'goat', 'car'])
        game = Game(b, p)
        game.play()
        self.assertTrue(game.won)
    def test_bob_wins_again(self):
        p = FirstSwitch("Bob")
        b = Board(['goat', 'car', 'goat'])
        game = Game(b, p)
        game.play()
        self.assertTrue(game.won)

    def test_bob_loses(self):
        p = FirstSwitch("Bob")
        b = Board(['car', 'goat', 'goat'])
        game = Game(b, p)
        game.play()
        self.assertFalse(game.won)

To run the unittests: python -m unittest -v

This should give you a nice way to implement the remaining strategies.

The code duplication in my main.py also presents the opportunity for the fourth class.

\$\endgroup\$
1
  • \$\begingroup\$ This is excellent, a very thorough answer. I like your suggestion of starting out by thinking about the visible behaviors before diving into code. Looking back at my initial implementation with that in mind (i.e. that a player does two things - pick a door and switch/stick), it looks to me like I was writing Contestant as a mini-procedural program rather than a class. I also didn't know about abstract classes and I really like how that works with defining each player, so I'll definitely look into that further. A lot to think about but all really helpful - thanks for your time! \$\endgroup\$
    – henryn
    Commented Jun 5, 2021 at 18:05
4
\$\begingroup\$

I tried really hard to unpack your code. It's good that you've attempted some OOP, but you've misrepresented state. Only Gina, for instance, needs to know what her previous second door choice was and previous game outcome was. Your "pick" vs "door" terminology is confusing. It was honestly somewhat of a nightmare to reverse-engineer this thing, but I did, and the code I'm going to suggest is probabilistically equal. I have not evaluated whether the math is correct, but the results are the same as your original ones.

A simple polymorphic representation that captures the door decisions in overrides will make your code more clear. Do not base such decisions on name strings.

Don't store a list of all of the wins and losses; you only care about counts (and in one case, the most recent win/loss).

Use zero-based door indices instead of one-based, and don't explicitly store the prize strings at all, only the winning door index.

Do not call strategise from first_pick; this is a surprising mixup of responsibility.

Suggested

import random
from typing import Sequence, Set, Optional


class MontyHall:
    def __init__(self, n: int):
        """The Monty Hall game! Three doors, n-1 goats, one car."""
        self.n = n
        self.prize_door = random.randrange(n)

    @property
    def doors(self) -> Sequence[int]:
        return range(self.n)

    def first_reveal(self, door_number: int) -> int:
        """
        Given a door picked by a contestant, returns a random choice from the
        remaining doors that is guaranteed to lose.
        """
        doors = set(self.doors) - {door_number, self.prize_door}
        open_door = random.choice(tuple(doors))
        return open_door


class Contestant:
    """A contestant to play the Monty Hall game."""
    def __init__(self, name: str, random_first_door: bool):
        self.name = name
        self.random_first_door = random_first_door
        self.wins = 0
        self.losses = 0

    def first_door(self, game: MontyHall) -> int:
        if self.random_first_door:
            return random.randrange(game.n)
        return 0

    def second_door(self, doors: Set[int], first_door: int) -> int:
        raise NotImplementedError()

    def play(self, game: MontyHall):
        first_door = self.first_door(game)
        open_door = game.first_reveal(first_door)
        doors = set(game.doors) - {open_door}
        second_door = self.second_door(doors, first_door)
        self.save_win(second_door == game.prize_door)

    def save_win(self, won: bool) -> None:
        if won:
            self.wins += 1
        else:
            self.losses += 1

    @property
    def win_rate(self) -> float:
        return self.wins / (self.wins + self.losses)

    def __str__(self):
        return f'{self.name} win rate: {self.win_rate:.1%}'


class RepeatContestant(Contestant):
    def second_door(self, doors: Set[int], first_door: int) -> int:
        return first_door


class SwitchContestant(Contestant):
    def second_door(self, doors: Set[int], first_door: int) -> int:
        remaining = doors - {first_door}
        return next(iter(remaining))


class FavouriteContestant(Contestant):
    def second_door(self, doors: Set[int], first_door: int) -> int:
        if 1 in doors:
            return 1
        return first_door


class LastWinContestant(Contestant):
    def __init__(self, name: str, random_first_door: bool):
        super().__init__(name, random_first_door)
        self.prev_second_door: Optional[int] = None
        self.prev_outcome: Optional[bool] = None

    def second_door(self, doors: Set[int], first_door: int) -> int:
        if self.prev_outcome is None:
            second_door = first_door
        elif self.prev_outcome:
            if self.prev_second_door == first_door:
                second_door = self.prev_second_door
            else:
                remaining = doors - {first_door}
                second_door = next(iter(remaining))
        elif self.prev_second_door == first_door:
            remaining = doors - {first_door}
            second_door = next(iter(remaining))
        else:
            second_door = first_door

        self.prev_second_door = second_door
        return second_door

    def save_win(self, won: bool) -> None:
        super().save_win(won)
        self.prev_outcome = won


class RandomContestant(Contestant):
    def second_door(self, doors: Set[int], first_door: int) -> int:
        return random.choice(tuple(doors))


def main() -> None:
    random.seed(0)  # for repeatability

    contestants = (
        RepeatContestant('Alice', random_first_door=False),
        SwitchContestant('Bob', random_first_door=False),
        RandomContestant('Carol', random_first_door=True),
        RepeatContestant('Dave', random_first_door=True),
        SwitchContestant('Erin', random_first_door=True),
        FavouriteContestant('Frank', random_first_door=False),
        LastWinContestant('Gina', random_first_door=False),
    )

    for _ in range(10_000):
        for contestant in contestants:
            competition = MontyHall(n=3)
            contestant.play(competition)

    for contestant in contestants:
        print(contestant)


if __name__ == "__main__":
    main()

Output

Alice win rate: 33.7%
Bob win rate: 67.1%
Carol win rate: 50.3%
Dave win rate: 34.0%
Erin win rate: 66.8%
Frank win rate: 50.4%
Gina win rate: 55.0%
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Thanks for your answer. This sentence - "Do not call strategise from first_pick; this is a surprising mixup of responsibility" - clarified a lot for me. Specific strategies for picking that second door are things that specific contestants (or types of contestant) do, not contestants in general. More generally, I think I need to get the conceptual bit of this straight - both why we even do OOP, and also what makes most sense in this specific problem. I think this is one of those rare cases when learning to code where a bit less doing and a bit more thinking is in order. \$\endgroup\$
    – henryn
    Commented Jun 6, 2021 at 10:54
  • \$\begingroup\$ @henryn Good observations. This will come more naturally to you with practice. \$\endgroup\$
    – Reinderien
    Commented Jun 6, 2021 at 13:59
3
\$\begingroup\$

You've done a good job in writing code that is organized and clear. However, as your comments imply, one can see things slipping out of the control in this code. That's not unusual for first drafts of anything, especially when learning.

Discussion of OOP design can verge into unreality: it's tempting to focus so heavily on technique (OK, we're doing OOP now ... time to get serious) that one loses sight of more practical considerations. In that context, it's easy to take things too far, effectively over-engineering things that could be done more simply.

You have a sensible top-level design: you have a game (MontyHall) and players (Contestant). Where things go awry, I think, is that you've made the player class responsible for running the game and for handling simulation-related concerns: in software-land, decisions like that can make a certain amount of sense, but in the real world, Monty Hall never put the guests in charge of anything resembling play_the_game(). Let common-sense and reality overrule any abstract OOP-base line of reasoning. That's one piece of broad advice.

A missing piece in your program is the idea of a simulation. Your intent seems to be playing the game many times to compare strategies. This goal mostly falls outside of the idea of playing a game (you play it, there's an outcome, it's over). Aggregating the results of many games does not, narrowly speaking, fall within the boundaries of playing a game. OK, maybe each player should track its own wins and losses? That's what you currently do and, intuitively, that makes some sense: real humans do track their wins and losses. However, my first instinct (learned mostly from painful experience) would be to restrict the game and the players to their simplest implementations: deliberately narrow their focus to playing one game. Let some other part of the program concern itself with tallying results. That's another piece of broad advice: build programs from simple parts.

So if players don't have to orchestrate a game or tally results across many games, what's left? Players might need a name or label; they need a first-door strategy; and they need a switch-doors strategy. The strategies could be represented with a heavy OO-based implementation, as suggested in another review, but that strikes me as over-engineering. What does a first-door strategy require? It's just a function that needs to return 1, 2, or 3. What about switch-door strategy? Just a function that returns True/False. In other words, a player is just a data class, and strategies are just ordinary functions than can be mixed and combined flexibly across players.

from dataclasses import dataclass
from typing import Callable
from random import randint, choice

DOORS = range(1, 4)

@dataclass(frozen = True)
class Contestant:
    name: str
    select: Callable[[], int]
    switch: Callable[[], bool]

def select_random() -> int:
    return choice(DOORS)

# Example usage.
c1 = Contestant('Billy', select_random, lambda: False)
c2 = Contestant('Larry', select_random, lambda: True)

If we start from those simple building blocks, what would a game class need to do? Get a player, set up the doors, get first selection, get alternative door, get switch decision, return outcome. You could shove all of that into a class ... but for what purpose? It sounds a lot more like a simple function to me. You could have the function return just a simple won/lost boolean or you could have it return a data object exposing all of its calculations. Let's go with the latter, just for more practice and maybe because we don't know yet what we might want to check in the simulations:

def lets_make_a_deal(contestant):
    # The car, the first pick, and the other doors.
    car = choice(DOORS)
    pick = contestant.select()
    others = [d for d in DOORS if d != pick]

    # Revealed door and alternative door.
    if pick == car:
        shuffle(others)
    else:
        others.sort(key = lambda d: d == car)
    revealed, alternative = others

    # Final pick
    final = alternative if contestant.switch() else pick
    won = final == car
    return MontyHall(car, pick, revealed, alternative, final, won)

@dataclass(frozen = True)
class MontyHall:
    car: int
    pick: int
    revealed: int
    alternative: int
    final: int
    won: bool

So far, this is a bit disappointing: even though you're solving the problem at hand, you are not getting much OOP practice. So rather than forcing OOP on a problem where it might not be too beneficial, search for a different domain. Running game simulations seems to have more promise. You have different players with different strategies; you want to collection information; and you want to report that information in different ways. All of that suggests state (various attributes to store) and behavior (calculating, printing, etc). Some type of Simulation class might be useful. Give it a shot, but don't try to force full-blown OOP if it doesn't seem to help. It's possible to build very powerful programs with plain-old functions and lots of well-conceived data classes (frozen when feasible).

\$\endgroup\$
1
  • \$\begingroup\$ Thanks for your thoughts (and also wanted to say this is really well-written). It's clear to me now that I had contestants handling everything in each game, which kind of makes an object-oriented approach redundant - at that stage I could just make the contestant methods a series of functions. And that last sentence is helpful - I've not been coding for long so often fall into the trap of thinking 'complex' implies 'good', when it clearly doesn't. Thanks again \$\endgroup\$
    – henryn
    Commented Jun 6, 2021 at 10:45
1
\$\begingroup\$

Introduction

I know I am beating a dead horse, but I attempted to just study one bit of your code. I had some problems with how you defined the class MontyHall. Do note I do not disagree with any of the answers above, I just wanted to give my take on this particular class.

As others have stated it can be wise to take a step back and really think what each class should do, and what should be a class and not. I personally like to use classes for something that has a particular state and that gets updated.

So we have a few objects (whether these objects should be functions, classes, metaclasses is not yet decided) which are: The Monty Hall, The contestants and The game. In my eyes we have some doors (The Monty Hall), we have some users (the contstants) and a host (The game). Now, both the the host and the users interact with the doors in some capacity and so it is logical that at least the doors should be a class. What I call the doors is your MontyHall Class.

Specifics

self.doors = {1: "goat", 2:  "goat", 3: "goat"}

Here you define doors to be a dictionary containing goats.

  • Why not just use a list self.doors = ["goats", "goats", "goats"] ?
  • On a broader level this implementation seems strange, we first place a goat in every door, and then we swap out a goat for a car.
  • You use 1, 2, 3 but in Python we usually use a zero based index.
  • Everything is hardcoded, what if we wanted to win a trip to the moon instead, or had 4 doors instead of 3?
  • The whole check_door seems inefficient, the class should be all knowing having to do a lookup every time is a bit odd.
  • Also you seem to be mixing some information. Is it really the MontyHall (doors) that performs the reveal or should it really be some external source? In your code I get the impression that the doors are sentient and perform the reveal themselves ^^

Solutions

Here as stated in the introduction I am only focusing on improving the MontyHall class. Which in my eyes only should have responsibility for the doors. What is behind each door, which doors have been picked, hiding the prize(s), and opening doors.

Let us start with getting rid of most of the hardcoded variables, allowing for the more natural zero based indexing and switching from dict to list

ZERO_INDEX_DOORS = False
OFFSET = 0 if ZERO_INDEX_DOORS else 1

class MontyHall:
    def __init__(self, doors=3, prizes=1, non_prize="goat", prize="car", offset=OFFSET):
        """A number of doors either containing a prize, or not. Can you guess which?"""
        self.prize = prize
        self.non_prize = non_prize
        #
        self.len = doors
        self.doors = list(range(offset, self.len + offset))
        self.prizes = random.sample(self.doors, prizes)
        self.non_prizes = [door for door in self.doors if door not in self.prizes]

Note how we can choose to apply a 1 based index by setting offset=1 (this is my one over-engineering) This code is quite fundamentally different than yours as it stores information in multiple lists. For instance

  self.doors = [1,2,3]
  self.prizes = [1]
  self.non_prizes = [2,3]

If you notice we can do slightly better than this. self.non_prizes will always be the compliment between self.doors and self.non_prizes. Meaning we want every element in self.doors not in self.non_prizes. This lends itself to a useful helper function

def list_compliment(list1, list2):
    # Get every element in list1 not in list2
    if isinstance(list2, (int, float)):
        list2 = [list2]
    result = []
    for num in list1:
        if num not in list2:
            result.append(num)
    return result

The first part is to allow list2 to be an int or float. Again this code could be written more succinctly, but it is very easy to understand. As Reinderen mentions we could have implemented it using sets

def list_compliment(list1, list2):
    # Get every element in list1 not in list2
    if isinstance(list2, (int, float)):
        list2 = [list2]
    return list(set(list1) - set(list2))

However, converting between sets and lists are somewhat expensive operation. A better way would perhaps be to work with sets throughout the entire code. This, however, is left as an exercise for the reader.

Using the function just defined self.non_prizes can now be defined as

self.non_prizes = list_compliment(self.doors, self.prizes)

Neat! Now we want our class MontyHall to be able to distinguish which doors the user and the host can choose from. The host can not reveal a door with a prize behind it, and if the user want to switch doors, they can not pick the same door. These two conditions are easy to fulfill using the list_compltiment

self.available_user = list_compliment(self.doors, self._picked)
self.available_host = list_compliment(self.non_prizes, self.picked)

As we discussed previously we want a statespace, so we want to have some way of updating the state. Updating the state means to remove opened doors, and update which doors are available. An example can look like this

def update(self, door_2_remove=None):
    """Removes a door from the state, and updates available doors to the various roles"""
    if door_2_remove:
        self.doors.remove(door_2_remove)
        if door_2_remove in self.prizes:
            self.prizes.remove(door_2_remove)
        else:
            self.non_prizes.remove(door_2_remove)
    #
    self.available_user = list_compliment(self.doors, self._picked)
    self.available_host = list_compliment(self.non_prizes, self.picked)

Now we have two more points to go. 1) We want some way for the user to pick a door, 2) we need a way to open a door. Now, to open a door we can do it as follows

def pick(self, door):
    self.picked = door
    return door

@property
def picked(self):
    return self._picked

@picked.setter
def picked(self, door):
    self._picked = door
    self.update()

Now this might be overkill but it allows us to call the function both by doing door = MontyHall() and then either door.pick(1) or door.picked=1. Why we set it up as above is to make sure the proper lists are updated each time a new door is chosen. Opening a door is easy

def open(self, door):
    """Opens a given door and reveals its content. If no door, open users choice"""
    content = self.prize if door in self.prizes else self.non_prize
    self.update(door)
    return content

Opening the users chosen door can be done as follows

def open_picked(self):
    return self.open_door(self._picked)

Now a game can look like the following

doors = Doors(total=5, prizes=3)

user_doors = doors.available("user")
# Contestant picks a door from the user_doors defined above
doors.pick(1)
print(doors.prizes)
#
# The host gets a list of non_prize_doors the user has not choosen
options = doors.available("host")
choice = random.choice(options)  # Picks a door to open based on some logic
#
# Shows the user the non prize content of the opened door
content = doors.open(choice)
print(content)
#
# The user now gets a list of available doors to switch to
doors_2_switch_2 = doors.available("user")
#
# Some logic here to choose the door based on the available info
user_door = 1
doors.pick(user_door)
#
# The host now reveals what is behind the door the user choose
#
content = doors.open_picked()  #
print(content)
#
if content == doors.prize:
    print("You won")
else:
    print("You lost")

Further work

Add checks for classes as Reinderien has done in his code. Implement some error checking for IndexErrors etc. Implement the remaining classes.

Full code

import random

ZERO_INDEX_DOORS = False
OFFSET = 0 if ZERO_INDEX_DOORS else 1


def list_compliment(list1, list2):
    # Get every element in list1 not in list2
    if isinstance(list2, (int, float)):
        list2 = [list2]
    result = []
    for num in list1:
        if num not in list2:
            result.append(num)
    return result


class Doors:
    def __init__(self, total=3, prizes=1, non_prize="goat", prize="car", offset=OFFSET):
        """A number of doors either containing a prize, or not. Can you guess which?"""
        self.prize = prize
        self.non_prize = non_prize
        #
        self.len = total
        self.doors = list(range(offset, self.len + offset))
        self.prizes = random.sample(self.doors, prizes)
        self.non_prizes = list_compliment(self.doors, self.prizes)
        self._available = dict()
        self._picked = -1
        #
        self.update()

    def open(self, door):
        """Opens a given door and reveals its content. If no door, open users choice"""
        content = self.prize if door in self.prizes else self.non_prize
        self.update(door)
        return content

    def open_picked(self):
        return self.open(self._picked)

    def pick(self, door):
        self.picked = door
        return door

    @property
    def picked(self):
        return self._picked

    @picked.setter
    def picked(self, door):
        self._picked = door
        self.update()

    def update(self, door_2_remove=None):
        """Removes a door from the state, and updates available doors to the various roles"""
        if door_2_remove:
            self.doors.remove(door_2_remove)
            if door_2_remove in self.prizes:
                self.prizes.remove(door_2_remove)
            else:
                self.non_prizes.remove(door_2_remove)
        #
        self.available_user = list_compliment(self.doors, self._picked)
        self.available_host = list_compliment(self.non_prizes, self.picked)


if __name__ == "__main__":

    doors = Doors(total=5, prizes=3)

    available = doors.available_user
    # Contestant picks a door
    doors.pick(1)
    print(doors.prizes)
    #
    # The host gets a list of non_prize_doors the user has not choosen
    options = doors.available_host
    choice = random.choice(options)  # Picks a door to open based on some logic
    #
    # Shows the user the non prize content of the opened door
    content = doors.open(choice)
    print(content)
    #
    # The user now gets a list of available doors to switch to
    doors_2_switch_2 = doors.available_user
    #
    # Some logic here to choose the door based on the available info
    user_door = 1
    doors.pick(user_door)
    #
    # The host now reveals what is behind the door the user choose
    #
    content = doors.open_picked()  #
    print(content)
    #
    if content == doors.prize:
        print("You won")
    else:
        print("You lost")
\$\endgroup\$
4
  • \$\begingroup\$ use classes for something that has a particular state and that gets updated - This is slightly too narrow; immutable classes with state that does not get updated can still be a very useful encapsulated representation. For example see Python's own str or pathlib.Path. \$\endgroup\$
    – Reinderien
    Commented Jun 6, 2021 at 15:12
  • \$\begingroup\$ Why do you differ between first_reveal and second_reveal? because the OP has explicitly implemented a strategy that will vary based on the door-picking sequence. The first choice has entirely different logic than the second choice, and for Monty Hall scenarios this is kind of unavoidable. \$\endgroup\$
    – Reinderien
    Commented Jun 6, 2021 at 15:14
  • \$\begingroup\$ list_compliment really would be more naturally expressed using set subtraction. \$\endgroup\$
    – Reinderien
    Commented Jun 6, 2021 at 15:18
  • 1
    \$\begingroup\$ @Reinderien I agree that my definition is too narrow, however OP asked for advice on how to structure classes / when to use them. In that regard it can be useful to think of it in terms of states. Even if there are other cases where they are useful. I have removed my second bullet point about the reveals. My point was rather which class should be responsible for this logic. Perhaps better is to store number of doors opened. I like the set idea! however might go against the spirit of the game not knowing where the doors are. But it would certainly make the code easier <- exercise for OP. \$\endgroup\$ Commented Jun 6, 2021 at 16:05

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.