🧭 Do not search for the first 15 minutes. When stuck: re-read the requirements → define I/O → choose the data structure → trace a small example by hand → write code.
Build a single Bandit class that both routes traffic across alternatives (arms)
while learning — a multi-armed bandit — and decides whether the conversion-rate
gap between two arms is statistically meaningful, an A/B significance test.
bandit = Bandit(n_arms=3, epsilon=0.1, seed=0, strategy="epsilon_greedy", c=2.0)
The constructor takes all five arguments from the start. strategy and c gain
meaning at L2, record and is_significant at L3, traffic_split and winner at
L4 — but the argument order never changes across levels.
Each arm holds a count (times selected) and a value (estimated mean reward).
Both start at 0.
update(arm, reward):
count[arm] += 1
value[arm] += (reward - value[arm]) / count[arm]
This is an incremental mean — do not recompute sum/count. When several arms
tie for the maximum, pick the lowest index (a rule that recurs throughout this
problem).
epsilon_greedy strategyself.rng = np.random.default_rng(seed) # exactly once, in the constructor
Every call to select_arm() calls self.rng.random() exactly once first and
compares it against epsilon.
< epsilon → explore: call self.rng.integers(0, n_arms) exactly once
more and return that value as the arm.value (ties go to the
lowest index). Do not make the second call in this case. Calling twice every
time regardless of the branch shifts every subsequent draw and produces a
completely different sequence.| Level | Added requirement |
|---|---|
| 1 | select_arm and update — ε-greedy only |
| 2 | strategy="ucb1" — a second selection strategy |
| 3 | record and is_significant — conversion-rate A/B significance |
| 4 | traffic_split and winner — declaring a winner and routing traffic |
Each level must preserve the behavior of the previous ones.
At this level treat strategy as always "epsilon_greedy" (ucb1 arrives at L2).
Bandit(n_arms: int, epsilon: float, seed: int, strategy="epsilon_greedy", c=2.0)
select_arm() -> int
update(arm: int, reward: float) -> None
count and value all start at 0. When exploiting, ties for the maximum go to
the lowest index.