""" Multi-Armed Bandit (MAB) is a problem in reinforcement learning where an agent must learn to choose the best action from a set of actions to maximize its reward. learn more here: https://en.wikipedia.org/wiki/Multi-armed_bandit The MAB problem can be described as follows: - There are N arms, each with a different probability of giving a reward. - The agent must learn to choose the best arm to pull in order to maximize its reward. Here 3 optimising strategies have been implemented: - Epsilon-Greedy - Upper Confidence Bound (UCB) - Thompson Sampling There are two other strategies implemented to show the performance of the optimising strategies: - Random strategy (full exploration) - Greedy strategy (full exploitation) The performance of the strategies is evaluated by the cumulative reward over a number of rounds. """ from abc import ABC, abstractmethod import matplotlib.pyplot as plt import numpy as np class Bandit: """ A class to represent a multi-armed bandit. """ def __init__(self, probabilities: list[float]) -> None: """ Initialize the bandit with a list of probabilities for each arm. Args: probabilities: List of probabilities for each arm. Example: >>> bandit = Bandit([0.1, 0.5, 0.9]) >>> bandit.num_arms 3 """ self.probabilities = probabilities self.num_arms = len(probabilities) def pull(self, arm_index: int) -> int: """ Pull an arm of the bandit. Args: arm_index: The arm to pull. Returns: The reward for the arm. Example: >>> bandit = Bandit([0.1, 0.5, 0.9]) >>> isinstance(bandit.pull(0), int) True """ rng = np.random.default_rng() return 1 if rng.random() < self.probabilities[arm_index] else 0 # Epsilon-Greedy strategy class Strategy(ABC): """ Base class for all strategies. """ @abstractmethod def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull. """ @abstractmethod def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. """ class EpsilonGreedy(Strategy): """ A class for a simple implementation of the Epsilon-Greedy strategy. Follow this link to learn more: https://medium.com/analytics-vidhya/the-epsilon-greedy-algorithm-for-reinforcement-learning-5fe6f96dc870 """ def __init__(self, epsilon: float, num_arms: int) -> None: """ Initialize the Epsilon-Greedy strategy. Args: epsilon: The probability of exploring new arms. num_arms: The number of arms. """ self.epsilon = epsilon self.num_arms = num_arms self.counts = np.zeros(num_arms) self.values = np.zeros(num_arms) def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull. Example: >>> strategy = EpsilonGreedy(epsilon=0.1, num_arms=3) >>> 0 <= strategy.select_arm() < 3 True """ rng = np.random.default_rng() if rng.random() < self.epsilon: return int(rng.integers(self.num_arms)) else: return int(np.argmax(self.values)) def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. Example: >>> strategy = EpsilonGreedy(epsilon=0.1, num_arms=3) >>> strategy.update(0, 1) >>> strategy.counts[0] == 1 np.True_ """ self.counts[arm_index] += 1 n = self.counts[arm_index] self.values[arm_index] += (reward - self.values[arm_index]) / n # Upper Confidence Bound (UCB) class UCB(Strategy): """ A class for the Upper Confidence Bound (UCB) strategy. Follow this link to learn more: https://people.maths.bris.ac.uk/~maajg/teaching/stochopt/ucb.pdf """ def __init__(self, num_arms: int) -> None: """ Initialize the UCB strategy. Args: num_arms: The number of arms. """ self.num_arms = num_arms self.counts = np.zeros(num_arms) self.values = np.zeros(num_arms) self.total_counts = 0 def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull. Example: >>> strategy = UCB(num_arms=3) >>> 0 <= strategy.select_arm() < 3 True """ if self.total_counts < self.num_arms: return self.total_counts ucb_values = self.values + np.sqrt(2 * np.log(self.total_counts) / self.counts) return int(np.argmax(ucb_values)) def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. Example: >>> strategy = UCB(num_arms=3) >>> strategy.update(0, 1) >>> strategy.counts[0] == 1 np.True_ """ self.counts[arm_index] += 1 self.total_counts += 1 n = self.counts[arm_index] self.values[arm_index] += (reward - self.values[arm_index]) / n # Thompson Sampling class ThompsonSampling(Strategy): """ A class for the Thompson Sampling strategy. Follow this link to learn more: https://en.wikipedia.org/wiki/Thompson_sampling """ def __init__(self, num_arms: int) -> None: """ Initialize the Thompson Sampling strategy. Args: num_arms: The number of arms. """ self.num_arms = num_arms self.successes = np.zeros(num_arms) self.failures = np.zeros(num_arms) def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull based on the Thompson Sampling strategy which relies on the Beta distribution. Example: >>> strategy = ThompsonSampling(num_arms=3) >>> 0 <= strategy.select_arm() < 3 True """ rng = np.random.default_rng() samples = [ rng.beta(self.successes[i] + 1, self.failures[i] + 1) for i in range(self.num_arms) ] return int(np.argmax(samples)) def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. Example: >>> strategy = ThompsonSampling(num_arms=3) >>> strategy.update(0, 1) >>> strategy.successes[0] == 1 np.True_ """ if reward == 1: self.successes[arm_index] += 1 else: self.failures[arm_index] += 1 # Random strategy (full exploration) class RandomStrategy(Strategy): """ A class for choosing an arm uniformly at random at each round to give a better comparison with the other optimised strategies. """ def __init__(self, num_arms: int) -> None: """ Initialize the Random strategy. Args: num_arms: The number of arms. """ self.num_arms = num_arms def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull. Example: >>> strategy = RandomStrategy(num_arms=3) >>> 0 <= strategy.select_arm() < 3 True """ rng = np.random.default_rng() return int(rng.integers(self.num_arms)) def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. Example: >>> strategy = RandomStrategy(num_arms=3) >>> strategy.update(0, 1) """ # Greedy strategy (full exploitation) class GreedyStrategy(Strategy): """ A class for the Greedy strategy to show how full exploitation can be detrimental to the performance of the strategy. """ def __init__(self, num_arms: int) -> None: """ Initialize the Greedy strategy. Args: num_arms: The number of arms. """ self.num_arms = num_arms self.counts = np.zeros(num_arms) self.values = np.zeros(num_arms) def select_arm(self) -> int: """ Select an arm to pull. Returns: The index of the arm to pull. Example: >>> strategy = GreedyStrategy(num_arms=3) >>> 0 <= strategy.select_arm() < 3 True """ return int(np.argmax(self.values)) def update(self, arm_index: int, reward: int) -> None: """ Update the strategy. Args: arm_index: The index of the arm to pull. reward: The reward for the arm. Example: >>> strategy = GreedyStrategy(num_arms=3) >>> strategy.update(0, 1) >>> strategy.counts[0] == 1 np.True_ """ self.counts[arm_index] += 1 n = self.counts[arm_index] self.values[arm_index] += (reward - self.values[arm_index]) / n def test_mab_strategies() -> None: """ Deterministic behavioural tests for the MAB strategies. These checks feed each strategy a fixed sequence of rewards and assert on the resulting internal state and arm selection, so a regression in the update/select logic will fail the suite instead of only being visible in the (stochastic) plotted demo. """ num_arms = 3 # After repeatedly rewarding arm 2, a purely greedy strategy must # settle on arm 2. greedy = GreedyStrategy(num_arms=num_arms) for _ in range(10): greedy.update(2, 1) greedy.update(0, 0) greedy.update(1, 0) assert greedy.select_arm() == 2 # Epsilon-Greedy with epsilon=0 behaves like the greedy strategy. epsilon_greedy = EpsilonGreedy(epsilon=0.0, num_arms=num_arms) for _ in range(10): epsilon_greedy.update(1, 1) epsilon_greedy.update(0, 0) epsilon_greedy.update(2, 0) assert epsilon_greedy.select_arm() == 1 # UCB must exhaustively try every arm once before repeating any of them. ucb = UCB(num_arms=num_arms) first_round_arms = set() for _ in range(num_arms): arm = ucb.select_arm() first_round_arms.add(arm) ucb.update(arm, 1) assert first_round_arms == set(range(num_arms)) # Thompson Sampling should heavily favor an arm with only successes # over arms with only failures. thompson = ThompsonSampling(num_arms=num_arms) for _ in range(20): thompson.update(0, 1) thompson.update(1, 0) thompson.update(2, 0) selections = [thompson.select_arm() for _ in range(50)] assert selections.count(0) > len(selections) // 2 # RandomStrategy.update is a no-op and select_arm always returns a # valid arm index. random_strategy = RandomStrategy(num_arms=num_arms) random_strategy.update(0, 1) assert 0 <= random_strategy.select_arm() < num_arms def demo_mab_strategies() -> None: """ Run a stochastic simulation of the MAB strategies and plot their cumulative reward over time for visual comparison. """ # Simulation num_arms = 4 arms_probabilities = [0.1, 0.3, 0.5, 0.8] # True probabilities bandit = Bandit(arms_probabilities) strategies: dict[str, Strategy] = { "Epsilon-Greedy": EpsilonGreedy(epsilon=0.1, num_arms=num_arms), "UCB": UCB(num_arms=num_arms), "Thompson Sampling": ThompsonSampling(num_arms=num_arms), "Full Exploration(Random)": RandomStrategy(num_arms=num_arms), "Full Exploitation(Greedy)": GreedyStrategy(num_arms=num_arms), } num_rounds = 1000 results = {} for name, strategy in strategies.items(): rewards = [] total_reward = 0 for _ in range(num_rounds): arm = strategy.select_arm() current_reward = bandit.pull(arm) strategy.update(arm, current_reward) total_reward += current_reward rewards.append(total_reward) results[name] = rewards # Plotting results plt.figure(figsize=(12, 6)) for name, rewards in results.items(): plt.plot(rewards, label=name) plt.title("Cumulative Reward of Multi-Armed Bandit Strategies") plt.xlabel("Round") plt.ylabel("Cumulative Reward") plt.legend() plt.grid() plt.show() if __name__ == "__main__": import doctest doctest.testmod() test_mab_strategies() demo_mab_strategies()