Files
Sep Aminianpre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>Christian Clausscclausssephml
624d2075f9 added multi armed bandit problem with three strategies to solve it (#12668)
* added multi arm bandit alg with three strategies to solve it

* added doctest tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* corrected test cases

* added return type hinting

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* return typehint for test func updated

* fixed variable name k

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fixed formatting

* fix1

* fixed issues with mypy, ruff

* updating DIRECTORY.md

* Address Copilot review comments on MAB PR

- Cast rng.integers() results to Python int in EpsilonGreedy and
  RandomStrategy select_arm, fixing doctest flakiness from np.int64
- Fix grammar in module docstring and RandomStrategy docstring
- Add missing doctest for Bandit.__init__
- Split test_mab_strategies into a real deterministic assertion-based
  test and a separate demo_mab_strategies for the stochastic plot
- Revert DIRECTORY.md to upstream (auto-generated, out of scope here)

* updating DIRECTORY.md

* updating DIRECTORY.md

* updating DIRECTORY.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Christian Clauss <cclauss@me.com>
Co-authored-by: cclauss <cclauss@users.noreply.github.com>
Co-authored-by: sephml <sephml@users.noreply.github.com>
2026-09-15 19:20:58 +02:00

482 lines
13 KiB
Python

"""
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()