1

Background: I have been working on the Minesweeper ai project for the HarvardX CS50AI online course for a few days. The goal is to implement AI for the minesweeper game. The problem set can be accessed here: https://cs50.harvard.edu/ai/2020/projects/1/minesweeper/

Implementation: My task is to implement two classes, MinesweeperAI and the Sentence. Sentence class is a logical statement about a Minesweeper game that consists of a set of board cells and a count of the number of those cells which are mines. MinesweeperAI class is a main handler of AI.

Issue: Although the program is running without any errors, the AI is making bad decisions, and thus, it is unable to complete the Minesweeper game successfully. From my observations, the AI is labelling potential mines as a safe space and thus, making suicidal runes.

Debugging I have tried classical debugging, printing, even talking to myself about the code. For some reason, the AI is labelling statements that are mines as safe spaces - I can not detect the reason behind it. I have documented the code with comments, and I can not see any breakdown in implemented logic. However, there must be one - I am inserting the code below with some additional materials.

Sentence class, the logical representation of in-game knowledge:

class Sentence():
    """
    Logical statement about a Minesweeper game
    A sentence consists of a set of board cells,
    and a count of the number of those cells which are mines.
    """

    def __init__(self, cells, count):
        self.cells = set(cells)
        self.count = count

    def __eq__(self, other):
        return self.cells == other.cells and self.count == other.count

    def __str__(self):
        return f"{self.cells} = {self.count}"

    def known_mines(self):
        """
        Returns the set of all cells in self.cells known to be mines.
        """
        # Because we are eliminating safe cells from the the statement, we are looking for statements
        # that would contain number of cells that is equal (or smaller) than number of mines.
        # Upon fulfilment of such condition, evaluated cells are known to be mines.
        if len(self.cells) <= self.count:
            return self.cells
        else:
            return None

    def known_safes(self):
        """
        Returns the set of all cells in self.cells known to be safe.
        """
        # There is only one case when the cells are known to be "safes" - when the number of count is 0.
        if self.count == 0:
            return self.cells
        else:
            return None

    def mark_mine(self, cell):
        """
        Updates internal knowledge representation given the fact that
        a cell is known to be a mine.
        """
        # Marking mine implies two logical consequences:
        # a) the number of counts must decrease by one (n - 1);
        # b) the cell marked as mine must be discarded from the sentence (we keep track,
        # only of the cells that are still unknown to be mines or "safes".

        if cell in self.cells:
            self.cells.discard(cell)
            self.count -= 1
            if self.count < 0:  # this is a safeguard from any improper inference set forth.
                self.count = 0
        else:
            pass

    def mark_safe(self, cell):
        """
        Updates internal knowledge representation given the fact that
        a cell is known to be safe.
        """
        # Marking "safe" implies one logical consequence:
        # a) the cell marked as safe must be discarded from the sentence.
        if cell in self.cells:
            self.cells.discard(cell)
        else:
            pass

MinesweeperAI class, the primary AI module:

class MinesweeperAI():
    """
    Minesweeper game player
    """

    def __init__(self, height=8, width=8):

        # Set initial height and width
        self.height = height
        self.width = width

        # Keep track of which cells have been clicked on
        self.moves_made = set()

        # Keep track of cells known to be safe or mines
        self.mines = set()
        self.safes = set()

        # List of sentences about the game known to be true
        self.knowledge = []

    def mark_mine(self, cell):
        """
        Marks a cell as a mine, and updates all knowledge
        to mark that cell as a mine as well.
        """
        self.mines.add(cell)
        for sentence in self.knowledge:
            sentence.mark_mine(cell)

    def mark_safe(self, cell):
        """
        Marks a cell as safe, and updates all knowledge
        to mark that cell as safe as well.
        """
        self.safes.add(cell)
        for sentence in self.knowledge:
            sentence.mark_safe(cell)

    def add_knowledge(self, cell, count):
        """
        Called when the Minesweeper board tells us, for a given
        safe cell, how many neighboring cells have mines in them.

        This function should:
            1) mark the cell as a move that has been made
            2) mark the cell as safe
            3) add a new sentence to the AI's knowledge base
               based on the value of `cell` and `count`
            4) mark any additional cells as safe or as mines
               if it can be concluded based on the AI's knowledge base
            5) add any new sentences to the AI's knowledge base
               if they can be inferred from existing knowledge
        """
        # 1) mark the cell as a move that has been made.
        self.moves_made.add(cell)

        # 2) mark the cell as safe. By this we are also updating our internal knowledge base.
        self.mark_safe(cell)

        # 3) add a new sentence to the AI's knowledge base based on the value of `cell` and `count`
        sentence_prep = set()

        # Sentence must include all the adjacent tiles, but do not include:
        # a) the revealed cell itself;
        # b) the cells that are known to be mines;
        # c) the cell that are known to be safe.
        for i in range(cell[0] - 1, cell[0] + 2):
            for j in range(cell[1] - 1, cell[1] + 2):  # Those two cover all the adjacent tiles.
                if (i, j) != cell:
                    if (i, j) not in self.moves_made and (i, j) not in self.mines and (i, j) not in self.safes:
                        if 0 <= i < self.height and 0 <= j < self.width:  # The cell must be within the game frame.
                            sentence_prep.add((i, j))

        new_knowledge = Sentence(sentence_prep, count)  # Adding newly formed knowledge to the KB.
        self.knowledge.append(new_knowledge)

        # 4) mark any additional cells as safe or as mines,
        #   if it can be concluded based on the AI's knowledge base
        # 5) add any new sentences to the AI's knowledge base
        #    if they can be inferred from existing knowledge.

        while True:  # iterating knowledge base in search for new conclusions on safes or mines.
            amended = False  # flag indicates that we have made changes to the knowledge, new run required.
            knowledge_copy = copy.deepcopy(self.knowledge)  # creating copy of the database.
            for sentence in knowledge_copy:  # cleaning empty sets from the database.
                if len(sentence.cells) == 0:
                    self.knowledge.remove(sentence)

            knowledge_copy = copy.deepcopy(self.knowledge)  # creating copy once again, without empty sets().
            for sentence in knowledge_copy:
                mines_check = sentence.known_mines()  # this should return: a set of mines that are known mines or None.
                safes_check = sentence.known_safes()  # this should return: a set of safes that are known safes or None
                if mines_check is not None:
                    for cell in mines_check:
                        self.mark_mine(cell)  # marking cell as a mine, and updating internal knowledge.
                        amended = True  # raising flag.
                if safes_check is not None:
                    for cell in safes_check:
                        self.mark_safe(cell)  # marking cell as a safe, and updating internal knowledge.
                        amended = True  # raising flag.

            # the algorithm should infer new knowledge,
            # basing on reasoning: (A.cells - B.cells) = (A.count - B.count), if
            # B is the subset of A.
            knowledge_copy = copy.deepcopy(self.knowledge)  # creating copy once again, updated checks.
            for sentence_one in knowledge_copy:
                for sentence_two in knowledge_copy:
                    if len(sentence_one.cells) != 0 and len(sentence_two.cells) != 0:  # In case of the empty set
                        if sentence_one.cells != sentence_two.cells:  # Comparing sentences (if not the same).
                            if sentence_one.cells.issubset(sentence_two.cells):  # If sentence one is subset of sen_two.
                                new_set = sentence_two.cells.difference(sentence_one.cells)
                                if len(new_set) != 0:  # if new set is not empty (in case of bug).
                                    new_counts = sentence_two.count - sentence_one.count
                                    if new_counts >= 0:  # if the counts are equal or bigger than 0 (in case of bug).
                                        new_sentence = Sentence(new_set, new_counts)
                                        if new_sentence not in self.knowledge:  # if the sentence is not already in
                                            # the KB.
                                            self.knowledge.append(new_sentence)
                                            amended = True  # raising flag.

            if not amended:
                break  # If the run resulted in no amendments, then we can not make any additional amendments,
                # to our KB.

    def make_safe_move(self):
        """
        Returns a safe cell to choose on the Minesweeper board.
        The move must be known to be safe, and not already a move
        that has been made.

        This function may use the knowledge in self.mines, self.safes
        and self.moves_made, but should not modify any of those values.
        """
        for cell in self.safes:
            if cell not in self.moves_made:
                return cell

        return None

    def make_random_move(self):
        """
        Returns a move to make on the Minesweeper board.
        Should choose randomly among cells that:
            1) have not already been chosen, and
            2) are not known to be mines
        """

        for i in range(self.height):
            for j in range(self.width):
                cell = (i, j)
                if cell not in self.moves_made and cell not in self.mines:
                    return cell

        return None

Documentation of the issue: Documentation of the issue - the AI is making a safe move that it should now have labelled as the safe

Some comments: Generally speaking, the cell is known to be safe when the sentence.count is zero (it means, that all the cells in the sentence are known to be "safes"). On the other hand, the cell is known as a mine, if the (len) of cells is equal to the sentence.count. The logic behind it is rather straightforward, still, I am missing something big when it comes to the implementation.

Thank you for all your help. Please do not be too harsh on my code - I am still learning, and to be honest, it's the first time when I am struggling hard with a piece of code that I have prepared. It's giving me little rest because I just can not crack down on what I am doing wrong. If there is something that I could provide (any more additional data) - please, just let me know!

MegaIng
  • 7,361
  • 1
  • 22
  • 35
Scolpe
  • 81
  • 1
  • 6
  • 1
    The code does look correct and I can't replicate. Can you maybe provide a board configuration that leads to this error? – MegaIng Jun 20 '21 at 14:42
  • NVM, the image you posted is enough of an example. – MegaIng Jun 20 '21 at 14:45
  • Ok, the problem is that the AI wrongly categorizes a safe space as a mine (in the image the cell at (4, 3). It then wrongly assumes that the 2 is completed and guess the mine as safe) – MegaIng Jun 20 '21 at 15:01

1 Answers1

1

Ok, after a lot debugging I found the root of the issue: When new knowledge is added via add_knowledge, the AI does only half account for cells it knows to be mines: It does not added those to the new Sentence, but one also needs to reduce the count by one for each already known cell:



        for i in range(cell[0] - 1, cell[0] + 2):
            for j in range(cell[1] - 1, cell[1] + 2):  # Those two cover all the adjacent tiles.
                if (i, j) != cell:
                    if (i, j) not in self.moves_made and (i, j) not in self.mines and (i, j) not in self.safes:
                        if 0 <= i < self.height and 0 <= j < self.width:  # The cell must be within the game frame.
                            sentence_prep.add((i, j))
                    elif (i, j) in self.mines: # One of the neighbors is a known mine. Reduce the count.
                        count -= 1

        new_knowledge = Sentence(sentence_prep, count)  # Adding newly formed knowledge to the KB.
        self.knowledge.append(new_knowledge)

This should now work (Unless there is another edge case somewhere)


Here a bit about my journey. I wrote these Tools to help with debugging:


def get_neighbours(size, x, y):
    for i in range(x - 1, x + 2):
        for j in range(y - 1, y + 2):  # Those two cover all the adjacent tiles.
            if (i, j) != (x, y):
                if 0 <= i < size[0] and 0 <= j < size[1]:
                    yield i, j


class SimpleBoard:
    def __init__(self, size, grid):

        self.size = size
        self.grid = grid
        self.calc()

    def calc(self):
        for x in range(self.size[0]):
            for y in range(self.size[1]):
                if self.grid[x][y] != 9:
                    self.grid[x][y] = sum(1 for i, j in get_neighbours(self.size, x, y) if self.grid[i][j] == 9)

    @classmethod
    def random(cls, size, count):
        self = cls(size, [[0] * size[1] for _ in range(size[0])])
        options = list(product(range(size[0]), range(size[1])))
        shuffle(options)
        mines = options[:count]
        for x, y in mines:
            self.grid[x][y] = 9
        self.calc()
        return self

def build_ai_view(ai: MinesweeperAI, board: SimpleBoard):
    out = []
    for x in range(ai.height):
        out.append(l :=[])
        for y in range(ai.width):
            cell = x,y
            if cell in ai.mines:
                assert cell not in ai.safes
                l.append("X" if board.grid[x][y] == 9 else "%")
            elif cell in ai.safes:
                l.append(str(board.grid[x][y]) if cell in ai.moves_made else "_")
            else:
                l.append("?")
    cells_to_sentence = defaultdict(list)
    for i, sentence in enumerate(ai.knowledge):
        for c in sentence.cells:
            cells_to_sentence[c].append(sentence)
    unique_groups = []
    for c, ss in cells_to_sentence.items():
        if ss not in unique_groups:
            unique_groups.append(ss)
    labels = "abcdefghijklmnopqrstuvxyz"
    for (x, y), ss in cells_to_sentence.items():
        i = unique_groups.index(ss)
        l = labels[i]
        assert out[x][y] == "?"
        out[x][y] = l
    for i, ss in enumerate(unique_groups):
        out.append(l := [labels[i]])
        if len(ss) > 1:
            l.append("overlap of")
            for s in ss:
                if [s] not in unique_groups:
                    unique_groups.append([s])
                l.append(labels[unique_groups.index([s])])
            # l.extend(labels[unique_groups.index([s])] for s in ss)
        else:
            l.append(str(ss[0].count))
    out.append([repr(ai)])
    return "\n".join(map(str, out))

They might not be pretty code, but they work and display all relevant information from the perspective of the AI. I then used this together with the given failing case:

board = SimpleBoard((8, 8), [
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 9, 0, 0, 0, 9, 0, 0],
    [0, 0, 0, 9, 0, 0, 0, 0],
    [0, 0, 0, 9, 0, 0, 0, 0],
    [0, 9, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 9, 0, 9, 0, 9, 0, 0],
])

and this simple loop:

pprint(board.grid)
start = next((x, y) for x in range(board.size[0]) for y in range(board.size[1]) if board.grid[x][y] == 0)
ai = MinesweeperAI(*board.size)
ai.add_knowledge(start, 0)
print(build_ai_view(ai, board))
while True:
    target = ai.make_safe_move()
    print(target)
    x, y = target
    if board.grid[x][y] == 9:
        print("FOUND MINE", x, y)
        break
    else:
        ai.add_knowledge((x, y), board.grid[x][y])
    print(build_ai_view(ai, board))

to be able to backwards figure out at which point the AI starts to make false assumptions.

This came in multiple steps: figure out when the first % (e.g. wrongly marked mine) appears, figure out which Sentences lead to that conclusion, figure out which of those is wrong and finally figure out why that assumption is made.

MegaIng
  • 7,361
  • 1
  • 22
  • 35
  • Amazing work @MegaIng . Thank you very much for all your help! I could not grasp where the error is. I am now testing it - it should be fine. If there are no other issues, the code should be fine. Thank you very much also for sharing how you get there. It will be slightly offtopic, but do you have any recommended site/ books on writing tests? I have some knowledge when it comes to simple individual tests, but I have a hard time debugging complex issues like one at hand. – Scolpe Jun 20 '21 at 16:00
  • @Scolpe I don't really have any recommendations, no. I also don't write that many tests. But as a general tip for debugging this kind of stuff: Visualize the State of the AI in some way. I didn't have a pygame rendering setup, otherwise I would have used that. Instead I just checked for each cell what the AI believes to know about it and display it as text for me. – MegaIng Jun 20 '21 at 16:17