Batched bandits#
Module: statrl.settings.bandits.stochastic.batch
In the batched setting the agent must commit to a whole block of pulls before observing any of their rewards. This is the realistic regime whenever feedback is delayed — a clinical trial recruits a cohort before reading any outcome, an A/B test runs for a week before it is analysed, a physical experiment is run in plates. The agent cannot react pull by pull, so it must decide in advance how to spread a batch across arms.
The cost of batching is not the number of pulls but the staleness of the statistics used to allocate them. A large batch is committed on statistics frozen at its start, so the harm grows with the batch size — which is why schedules matter as much as algorithms here.
The environment#
BatchMAB wraps any
StochasticBanditEnv,
so every stochastic instance is available here for free:
>>> from statrl.settings.bandits.stochastic.anytime.envs.parametric import BernoulliBandit
>>> from statrl.settings.bandits.stochastic.batch.environment import BatchMAB
>>>
>>> env = BatchMAB(BernoulliBandit([0.2, 0.9, 0.5]), batchsize=[2, 4, 8])
>>> info = env.reset()
>>> info["nextbatchsize"]
2
>>> rewards, info = env.step([0, 1]) # one arm per slot of the batch
>>> len(rewards), info["nextbatchsize"]
(2, 4)
step takes a list of arms and returns a list of rewards. Each info
announces the size of the next batch, which the agent needs before it can act;
info["mean"] carries the batch’s expected score for regret accounting.
Warning
A round is a batch, not a pull. Under a growing schedule a horizon of 100
can amount to millions of pulls — "doubleexp" reaches
\(e^{2^\ell}\). Check what your schedule sums to before setting a
horizon.
Batch schedules#
The schedule maps a round index to that round’s batch size. Pass a callable, or
a plain list — which is picklable and therefore survives the process pool used
by multicoreRuns(), where a lambda would
not.
The environment factories accept a schedule by name:
Name |
Size at round \(\ell\) |
Use |
|---|---|---|
|
10 |
Baseline; batching cost stays flat. |
|
\(\ell + 1\) |
Mild growth. |
|
\((\ell+1)^2\), \((\ell+1)^3\) |
Fewer, larger batches. |
|
\(2^\ell\), \(e^{2^\ell}\) |
Very few rounds; keep the horizon small. |
|
100, then \((\ell+1)^3\) |
Tests adaptation to a sudden change. |
|
Cycles four growth rates |
Stresses agents assuming a regular schedule. |
|
The BABA epoch grid |
Required by |
The agent#
BatchBanditAgent exposes two
levels. play() and update(arm, reward) are the per-pull rules;
batchplay(batchsize) and batchupdate(batcharm, batchreward) are what the
interaction loop calls, and are the ones a subclass must implement.
The split is what allows the interesting behaviour: an agent may update its index between the pulls of a batch — using only information available when the batch began — even though no reward has arrived. Incrementing \(N_a\) after each committed pull is enough to keep a batch from pouring all of itself into one arm.
Shipped agents#
Agent |
Description |
|---|---|
IMED with the non-parametric \(K_{\inf}\), recomputed inside the
batch. Set |
|
Non-parametric Thompson sampling with a Dirichlet prior anchored at the reward bound. See [Gautron2024]. |
|
BCB without the optimistic within-batch count increment; the control isolating what that increment buys. |
|
Five-phase schedule-driven learner. See [Jin2021]. Requires the
|
|
Fills every batch with the best arm; the regret reference. |
|
Uniform exploration; the control whose regret batching does not affect. |
Every agent except BABA takes a bound — a known upper bound on the reward
support. It is what makes the non-parametric divergence well defined, so
understating it invalidates the index. Pair them with
BatchTruncatedGaussianBandit,
whose support is bounded by construction.
A full run#
>>> from statrl.settings.bandits.stochastic.batch.agents.BIMED import BIMED
>>> from statrl.settings.bandits.stochastic.batch.agents._Oracle import Oracle
>>> from statrl.settings.bandits.stochastic.batch.interaction import BatchBanditInteraction
>>>
>>> env = BatchMAB(BernoulliBandit([0.2, 0.9, 0.5]), batchsize=[8] * 100)
>>> interaction = BatchBanditInteraction()
>>>
>>> scores = interaction.run(env, BIMED(3, bound=1.0), horizon=100)
>>> oracle_scores = interaction.run(env, Oracle(env), horizon=100)
>>> bool((oracle_scores - scores)[-1] < 60) # 800 pulls, 8 at a time
True
Note the oracle’s regret is zero by construction: knowing the best arm, it loses nothing to batching. All of the regret above is the price of learning under delayed feedback.
See Running experiments to benchmark these over many replicates, and Batched bandits for the reference.