CSC111 / assignments / a2 / a2_part3.py
a2_part3.py
Raw
"""CSC111 Winter 2023 Assignment 2: Trees, Wordle, and Artificial Intelligence (Part 3)

Instructions (READ THIS FIRST!)
===============================

This Python module contains the start of functions and/or classes you'll define
for Part 3 of this assignment. You should NOT make any changes to a2_adversarial_wordle.py.

Copyright and Usage Information
===============================

This file is provided solely for the personal and private use of students
taking CSC111 at the University of Toronto St. George campus. All forms of
distribution of this code, whether as given or with any changes, are
expressly prohibited. For more information on copyright for CSC111 materials,
please consult our Course Syllabus.

This file is Copyright (c) 2023 Mario Badr, David Liu, and Isaac Waller.
"""
import random
from typing import Optional

import a2_game_tree
import a2_adversarial_wordle as aw


class ExploringGuesser(aw.Guesser):
    """A Guesser player that sometimes plays greedily and sometimes plays randomly.

    See assignment handout for details.

    Representation Invariants:
        - 0.0 <= self._exploration_probability <= 1.0
    """
    # Private Instance Attributes:
    #   - _game_tree:
    #       The GameTree that this player uses to make its moves. If None, then this
    #       player just makes random moves.
    #   - _exploration_probability:
    #       The probability that this player ignores its game tree and makes a random move.
    _game_tree: Optional[a2_game_tree.GameTree]
    _exploration_probability: float

    def __init__(self, game_tree: a2_game_tree.GameTree, exploration_probability: float) -> None:
        """Initialize this player."""
        self._game_tree = game_tree
        self._exploration_probability = exploration_probability

    def make_move(self, game: aw.AdversarialWordle) -> str:
        """Make a move given the current game.

        Preconditions:
            - game.is_guesser_turn()
        """
        if not (self._game_tree is None or game.statuses == []):
            # Update to subtree to opponent_move
            opponent_move = game.statuses[-1]
            subtree = self._game_tree.find_subtree_by_move(opponent_move)
            self._game_tree = subtree

        if not (self._game_tree is None or self._game_tree.get_subtrees() == []):
            x = random.uniform(0, 1)
            if x < self._exploration_probability:
                possible_answers = game.get_possible_answers()
                if possible_answers:
                    save = random.choice(list(possible_answers))
                    self._game_tree = self._game_tree.find_subtree_by_move(save)
                    return save
                else:
                    self._game_tree = None
            else:
                best_subtree = max(self._game_tree.get_subtrees(), key=lambda each: each.guesser_win_probability)
                self._game_tree = best_subtree
                return best_subtree.move  # Return guess

        # Game tree None or no subtrees
        self._game_tree = None
        possible_answers = game.get_possible_answers()
        return random.choice(list(possible_answers))


def run_learning_algorithm(
        word_set_file: str,
        max_guesses: int,
        exploration_probabilities: list[float],
        show_stats: bool = True) -> a2_game_tree.GameTree:
    """Play a sequence of AdversarialWordle games using an ExploringGuesser and RandomAdversary.

    This algorithm first initializes an empty GameTree. All ExploringGuessers will use this
    SAME GameTree object, which will be mutated over the course of the algorithm!
    Return this object.

    There are len(exploration_probabilities) games played, where at game i (starting at 0):
        - The Guesser is an ExploringGuesser (using the game tree) whose exploration probability
            is equal to exploration_probabilities[i].
        - The Adversary is a RandomAdversary.
        - AFTER the game, the move sequence from the game is inserted into the game tree,
          with a guesser win probability of 1.0 if the Guesser won the game, and 0.0 otherwise.

    Preconditions:
        - word_set_file and max_guesses satisfy the preconditions of aw.run_game
        - all(0.0 <= p <= 1.0 for p in exploration_probabilities)
        - exploration_probabilities != []

    Implementation notes:
        - A NEW ExploringGuesser instance should be created for each loop iteration.
          However, each one should use the SAME GameTree object.
        - You should call aw.run_game, NOT aw.run_games. This is because you need more control
          over what happens after each game runs, which you can get by writing your own loop
          that calls run_game. However, you can base your loop on the implementation of run_games.
        - Note that aw.run_game returns the AdversarialWordle game instance. You may need to review
          the documentation for that class to figure out what methods are useful here.
        - You may call print in this function to report progress made in each game.
        - This function must return the final GameTree object. You can inspect the
          guesser_win_probability of its nodes, calculate its size, or use it in a
          RandomTreeGuesser or GreedyTreeGuesser to see how they do with it.
    """
    stats = {'Guesser': 0, 'Adversary': 0}
    results = []
    game_tree = a2_game_tree.GameTree()
    for i in range(len(exploration_probabilities)):
        guesser = ExploringGuesser(game_tree, exploration_probabilities[i])
        adversary = aw.RandomAdversary()
        game = aw.run_game(guesser, adversary, word_set_file, max_guesses)
        if game.get_winner() == 'Guesser':
            guesser_win_probability = 1.0
        else:
            guesser_win_probability = 0.0
        game_tree.insert_move_sequence(game.get_move_sequence(), guesser_win_probability)
        winner = game.get_winner()
        stats[winner] += 1
        results.append(winner)

        print(f'Game {i} winner: {winner}. Moves: {game.get_move_sequence()}')

    for outcome in stats:
        print(f'{outcome}: {stats[outcome]}/{len(exploration_probabilities)}'
              f' ({100.0 * stats[outcome] / len(exploration_probabilities):.2f}%)')

    if show_stats:
        aw.plot_game_statistics(results)

    return game_tree


def part3_runner() -> a2_game_tree.GameTree:
    """Run example for Part 3.

    Please note that unlike part1_runner and part2_runner, this function is NOT tested.
    We encourage you to experiment with different exploration probability sequences
    to see how quickly you can develop a "winning" GameTree!
    """
    word_set_file = 'data/words/official_wordle_100.txt'
    max_guesses = 4

    n = 2000
    m = 2000
    probabilities = [1.0 - i / n for i in range(n)]
    probabilities.extend([0.0] * m)

    return run_learning_algorithm(word_set_file, max_guesses, probabilities, show_stats=True)
    # word_set_file = 'data/words/official_wordle_100.txt'
    # max_guesses = 3
    #
    # probabilities = [0.5] * 1000
    #
    # return run_learning_algorithm(word_set_file, max_guesses, probabilities, show_stats=True)


if __name__ == '__main__':
    # When you are ready to check your work with python_ta, uncomment the following lines.
    # (In PyCharm, select the lines below and press Ctrl/Cmd + / to toggle comments.)
    # You can use "Run file in Python Console" to run PythonTA,
    # and then also test your methods manually in the console.
    import python_ta
    python_ta.check_all(config={
        'max-line-length': 120,
        'max-nested-blocks': 4,
        'extra-imports': ['random', 'a2_adversarial_wordle', 'a2_game_tree'],
        'allowed-io': ['run_learning_algorithm']
    })

    part3_runner()