Bayesian Bandits for Adaptive Sampling and Trial Design

Suppose you can afford to run 1,000 tests across four surveillance sites, and you care about finding cases — not about estimating every site equally well. A fixed design splits the budget evenly and never learns; a purely greedy design pours everything into whichever site looked best after the first few tests and can be fooled by noise. A Bayesian bandit threads the needle: it keeps a posterior on each site’s positivity, and each round it allocates the next test in proportion to the probability that a site is the best one — automatically shifting resources toward the hot site while still occasionally probing the others in case it was wrong.

Left: after 40 rounds of adaptive allocation the posterior on the high-positivity site (C) is sharp and correctly located, while the neglected sites stay diffuse. Right: the cumulative share of the testing budget migrates toward site C as evidence accumulates, without ever fully abandoning the alternatives.
Figure 1. Left: after 40 rounds of adaptive allocation the posterior on the high-positivity site (C) is sharp and correctly located, while the neglected sites stay diffuse. Right: the cumulative share of the testing budget migrates toward site C as evidence accumulates, without ever fully abandoning the alternatives.

This is the multi-armed bandit problem: each “arm” (site, treatment, ad, dose) pays off with an unknown probability, and every pull is both a chance to earn a reward and a chance to learn. The tension between those two goals is the exploration–exploitation trade-off, and the elegant Bayesian answer to it is Thompson sampling.

The setup: arms, rewards, and beliefs#

We have KK arms (here K=4K=4 sites). Pulling arm kk yields a Bernoulli reward — a positive test — with unknown probability θk\theta_k. Our goal over a horizon of TT pulls is to maximize the expected number of positives found, tθat\sum_{t} \theta_{a_t}, where at{1,,K}a_t \in \{1,\dots,K\} is the arm chosen at step tt.

Because each arm is a proportion, the natural belief to carry is a Beta distribution, which is conjugate to the Binomial likelihood. Start each site with a prior Beta(αk,βk)\text{Beta}(\alpha_k, \beta_k) — a uniform Beta(1,1)\text{Beta}(1,1) if you know nothing. After observing sks_k positives in nkn_k tests at that site, the posterior is simply

θkdata    Beta ⁣(αk+sk,  βk+nksk).(1)\theta_k \mid \text{data} \;\sim\; \text{Beta}\!\left(\alpha_k + s_k,\; \beta_k + n_k - s_k\right). \tag{1}

The two hyperparameters have a clean reading: αk1\alpha_k - 1 is (prior plus observed) positives, βk1\beta_k - 1 is negatives, and the posterior mean is θ^k=αk/(αk+βk)\hat\theta_k = \alpha_k / (\alpha_k + \beta_k). The total count αk+βk\alpha_k + \beta_k is the effective sample size — how sharp the belief is.

Show the Beta–Binomial conjugacy derivation

Put a Beta(α,β)\text{Beta}(\alpha,\beta) prior on a positivity θ\theta and observe ss positives in nn independent tests, a Binomial(n,θ)\text{Binomial}(n,\theta) likelihood. Bayes’ rule says the posterior is proportional to likelihood times prior:

p(θs)    (ns)θs(1θ)nslikelihood  θα1(1θ)β1B(α,β)prior.p(\theta \mid s) \;\propto\; \underbrace{\binom{n}{s}\theta^{s}(1-\theta)^{n-s}}_{\text{likelihood}} \; \underbrace{\frac{\theta^{\alpha-1}(1-\theta)^{\beta-1}}{B(\alpha,\beta)}}_{\text{prior}} .

Everything that does not depend on θ\theta — the binomial coefficient and the Beta normalizer B(α,β)B(\alpha,\beta) — folds into the proportionality constant, leaving

p(θs)    θ(α+s)1(1θ)(β+ns)1.p(\theta \mid s) \;\propto\; \theta^{(\alpha + s) - 1}\,(1-\theta)^{(\beta + n - s) - 1} .

That is the kernel of a Beta density with updated parameters α=α+s\alpha' = \alpha + s and β=β+ns\beta' = \beta + n - s. Since a probability density must integrate to one, the missing constant is forced to be 1/B(α,β)1/B(\alpha', \beta'), so

θs    Beta(α+s,  β+ns),\theta \mid s \;\sim\; \text{Beta}(\alpha + s,\; \beta + n - s),

which is (1). The prior and posterior live in the same family — that is what conjugacy means — so updating never requires an integral: you just add your positives to α\alpha and your negatives to β\beta. Crucially, the update is the same whether you feed it one test at a time or a whole batch, because independent Bernoulli increments to α\alpha and β\beta commute and add.

Thompson sampling: probability matching#

Given those posteriors, how should we choose the next site? Thompson sampling (posterior sampling) is almost embarrassingly simple:

  1. Draw one sample θ~kBeta(αk,βk)\tilde\theta_k \sim \text{Beta}(\alpha_k,\beta_k) from each arm’s current posterior.
  2. Pull the arm with the largest sample, a=argmaxkθ~ka = \arg\max_k \tilde\theta_k.
  3. Observe the reward, update that arm’s posterior via (1), and repeat.

The magic is that the probability an arm gets pulled equals the posterior probability that it is genuinely the best arm. Early on, when posteriors are wide, the draws are noisy and every arm gets tried — that is exploration, and it is automatic, requiring no tuning parameter. As evidence sharpens the posteriors, the best arm wins the draw more and more often — that is exploitation, and it emerges from the same one line of code. This property is called probability matching.

Show the allocation probability and why it self-tunes

Let wkw_k be the probability that Thompson sampling pulls arm kk on the next step. By construction arm kk is pulled exactly when its draw exceeds every other arm’s draw, so

wk  =  Pr ⁣(θ~k>θ~j for all jk    data),w_k \;=\; \Pr\!\Big(\tilde\theta_k > \tilde\theta_j \ \text{for all } j \neq k \;\Big|\; \text{data}\Big),

with each θ~j\tilde\theta_j drawn independently from its posterior. Conditioning on the value of arm kk’s draw and using independence, this integral is

wk  =  01pk(θ)jkFj(θ)  dθ,(2)w_k \;=\; \int_0^1 p_k(\theta)\,\prod_{j \neq k} F_j(\theta)\; d\theta , \tag{2}

where pkp_k is arm kk’s posterior density and FjF_j is arm jj’s posterior CDF. Read (2) as: “the chance arm kk’s value lands at θ\theta, times the chance all other arms fall below θ\theta, integrated over θ\theta.” This is exactly the posterior probability that arm kk is optimal, Pr(θk=maxjθjdata)\Pr(\theta_k = \max_j \theta_j \mid \text{data}) — Thompson sampling samples an arm with the probability it is best.

Two limits make the self-tuning concrete. When all posteriors are wide and overlapping (little data), the Fj(θ)F_j(\theta) are gentle ramps and no single arm dominates the integral, so wk1/Kw_k \approx 1/K — near-uniform exploration. When one arm’s posterior separates cleanly above the rest, its jFj\prod_j F_j is near 1 over the region where its own density lives while the others’ densities sit where the product is near 0, driving wk1w_k \to 1 — exploitation. No ε\varepsilon, no temperature, no annealing schedule: the collapse from exploring to exploiting is driven entirely by how much the data have concentrated the posteriors.

There is rarely a need to evaluate the integral in (2) directly — that is the point. Drawing one sample per arm and taking the argmax is a Monte Carlo estimate of pulling each arm with probability wkw_k, and it costs one random draw per arm.

Measuring how well it does: regret#

The yardstick for a bandit policy is regret — the reward you forfeited by not always pulling the truly-best arm θ\*=maxkθk\theta^\* = \max_k \theta_k:

Regret(T)  =  t=1T(θ\*θat)  =  kΔkNk(T),Δk=θ\*θk,\text{Regret}(T) \;=\; \sum_{t=1}^{T} \big(\theta^\* - \theta_{a_t}\big) \;=\; \sum_{k} \Delta_k\, N_k(T), \qquad \Delta_k = \theta^\* - \theta_k ,

where Nk(T)N_k(T) is the number of times arm kk was pulled and Δk\Delta_k is its suboptimality gap. A policy that never learns (uniform allocation) pays regret growing linearly in TT, because it keeps pulling bad arms at a fixed rate. Thompson sampling achieves logarithmic regret, E[Regret(T)]=O(logT)\mathbb{E}[\text{Regret}(T)] = O(\log T) — matching the Lai–Robbins lower bound on what any policy can achieve — because the number of pulls it wastes on each inferior arm grows only like logT\log T. In the survey setting, low regret means more positives found for the same number of tests.

The updating process, step by step#

The loop that produces Figure 1 is worth spelling out, because every adaptive design in this family is a variation on it.

  1. Initialize a posterior Beta(αk,βk)\text{Beta}(\alpha_k,\beta_k) for each of the four sites (start uniform, or encode prior surveillance data).
  2. Sample a plausible positivity θ~k\tilde\theta_k from each posterior.
  3. Allocate the next test (or batch of tests) to the site(s) with the highest sampled positivity.
  4. Observe how many of those tests come back positive.
  5. Update the chosen site’s posterior: add positives to αk\alpha_k, negatives to βk\beta_k.
  6. Repeat until the budget is spent.

Because the update in step 5 is just addition, the whole procedure is cheap enough to run on a phone in the field, and it is fully sequential: new data change the allocation on the very next round. The only real design choices are the priors (step 1) and the batch size (step 3) — larger batches update less often but parallelize the field work.

Tip

Batching changes when you learn, not what you learn. With batch size 1 the posterior updates after every test; with batch size 25 you place 25 tests, then update once. Small batches adapt faster and waste fewer tests on a lagging site; large batches are logistically simpler and lose little when arms are well-separated. A useful default is to re-draw the Thompson allocation for each test within a batch, so a single batch still spreads across sites in proportion to current belief.

Worked example: four sites#

Take four sites with true (unknown) positivities θ=(0.06,0.10,0.22,0.09)\theta = (0.06, 0.10, 0.22, 0.09) — site C is the hot spot. Start every site at Beta(1,1)\text{Beta}(1,1) and run 40 rounds of 25 tests, drawing a Thompson allocation for each test. After the first few rounds the posteriors are still wide and the budget is spread fairly evenly; by round 40 close to 80% of all tests have gone to site C, its posterior is tight around 0.22, and the neglected sites keep wide posteriors — we are confident about the winner and deliberately uncertain about the also-rans, which is exactly the right allocation of certainty when the goal is finding cases. Compare that to an even split, which would have spent 250 tests on each site and found far fewer positives.

In code#

R#

A complete Thompson-sampling loop over the four sites, with per-test re-draws inside each batch.

R
set.seed(1)
p_true <- c(A = 0.06, B = 0.10, C = 0.22, D = 0.09)  # unknown to the sampler
K <- length(p_true)
a <- rep(1, K); b <- rep(1, K)                        # Beta(1,1) priors

rounds <- 40; batch <- 25
for (t in seq_len(rounds)) {
  # Thompson sampling: each test goes to the site with the largest posterior draw
  draws  <- matrix(rbeta(batch * K, a, b), nrow = batch, byrow = TRUE)
  picks  <- max.col(draws)                            # argmax per test
  for (k in seq_len(K)) {
    n_k <- sum(picks == k)
    if (n_k > 0) {
      y_k <- rbinom(1, n_k, p_true[k])                # positives observed
      a[k] <- a[k] + y_k                              # update: add positives
      b[k] <- b[k] + n_k - y_k                        #         add negatives
    }
  }
}

post_mean <- a / (a + b)
n_tests   <- (a - 1) + (b - 1)
round(rbind(posterior_mean = post_mean, tests_used = n_tests), 3)
# site C draws most of the budget and its posterior mean sits near 0.22

Python#

The same loop, executed at build time so the numbers are real.

Python
import numpy as np

rng = np.random.default_rng(20260711)
p_true = np.array([0.06, 0.10, 0.22, 0.09])   # sites A, B, C, D — unknown
sites = ["A", "B", "C", "D"]
K = len(p_true)
a = np.ones(K)          # Beta(1,1) priors: alpha = 1 + positives
b = np.ones(K)          #                   beta  = 1 + negatives

rounds, batch = 40, 25
for t in range(rounds):
    # one posterior draw per test, then send each test to its argmax site
    draws = rng.beta(a[None, :], b[None, :], size=(batch, K))
    picks = draws.argmax(axis=1)
    for k in range(K):
        n_k = int((picks == k).sum())
        if n_k:
            y_k = rng.binomial(n_k, p_true[k])   # positives found
            a[k] += y_k                          # conjugate update
            b[k] += n_k - y_k

post_mean = a / (a + b)
tests = (a - 1) + (b - 1)
for k in range(K):
    print(f"site {sites[k]}: {int(tests[k]):4d} tests, "
          f"posterior mean {post_mean[k]:.3f}")
print(f"total positives found: {int((a - 1).sum())} / {int(tests.sum())} tests")
site A:   58 tests, posterior mean 0.100
site B:  108 tests, posterior mean 0.136
site C:  797 tests, posterior mean 0.247
site D:   37 tests, posterior mean 0.103
total positives found: 218 / 1000 tests

Julia#

Julia
using Random, Distributions
Random.seed!(1)

p_true = [0.06, 0.10, 0.22, 0.09]          # sites A, B, C, D
K = length(p_true)
a = ones(K); b = ones(K)                   # Beta(1,1) priors

rounds, batch = 40, 25
for t in 1:rounds
    picks = [argmax(rand.(Beta.(a, b))) for _ in 1:batch]  # Thompson per test
    for k in 1:K
        n_k = count(==(k), picks)
        if n_k > 0
            y_k = rand(Binomial(n_k, p_true[k]))           # positives
            a[k] += y_k                                    # add positives
            b[k] += n_k - y_k                              # add negatives
        end
    end
end

println("posterior means: ", round.(a ./ (a .+ b), digits = 3))
println("tests per site:  ", Int.((a .- 1) .+ (b .- 1)))

The allocation probability directly#

If you want the exact probability each site is best — the wkw_k of (2) — a few thousand joint posterior draws estimate it without evaluating the integral. This is also how you report “there is a 94% chance site C has the highest positivity.”

Python
def prob_best(a, b, draws=20000, seed=0):
    rng = np.random.default_rng(seed)
    samples = rng.beta(a, b, size=(draws, len(a)))   # joint posterior draws
    winners = samples.argmax(axis=1)
    return np.bincount(winners, minlength=len(a)) / draws

w = prob_best(a, b)
for k in range(K):
    print(f"P(site {sites[k]} is best) = {w[k]:.3f}")
P(site A is best) = 0.002
P(site B is best) = 0.002
P(site C is best) = 0.986
P(site D is best) = 0.010

A week-by-week playbook: 100 tests over 8 weeks#

The abstract loop above updates after every test; in the field you usually update on a fixed cadence. Here is the concrete operating procedure for a program with 100 tests every week for an 8-week season across the same four sites, and it is exactly what a field team would follow with a spreadsheet.

Left: the weekly allocation of 100 tests, stacked by site. Week 1 is an even 25/25/25/25 split; a lucky week-1 result makes site B look best, so week 2 chases B — but as evidence accumulates the budget self-corrects toward the truly-best site C by week 5, while the floor keeps every site monitored. Right: each site’s posterior-mean positivity converging toward its true value (dotted), with site C’s 90% credible band shrinking as it accrues tests.
Figure 2. Left: the weekly allocation of 100 tests, stacked by site. Week 1 is an even 25/25/25/25 split; a lucky week-1 result makes site B look best, so week 2 chases B — but as evidence accumulates the budget self-corrects toward the truly-best site C by week 5, while the floor keeps every site monitored. Right: each site’s posterior-mean positivity converging toward its true value (dotted), with site C’s 90% credible band shrinking as it accrues tests.

Running this with true positivities θ=(0.05,0.15,0.22,0.08)\theta = (0.05, 0.15, 0.22, 0.08) for sites A–D — a deliberately harder season where sites B and C are close — produces the allocation below.

Python
import numpy as np

rng = np.random.default_rng(1)
theta = np.array([0.05, 0.15, 0.22, 0.08])   # sites A, B, C, D — unknown
K, weeks, weekly, floor = 4, 8, 100, 5
flex = weekly - floor * K                     # 80 tests steered by Thompson
a, b = np.ones(K), np.ones(K)

print("week   A   B   C   D   positives")
total_pos = 0
for w in range(weeks):
    if w == 0:
        alloc = np.full(K, weekly // K)       # even warm start: 25 each
    else:
        alloc = np.full(K, floor)             # floor: keep watching every site
        picks = rng.beta(a[None, :], b[None, :], size=(flex, K)).argmax(axis=1)
        for k in range(K):
            alloc[k] += int((picks == k).sum())   # Thompson-allocate the 80
    pos = np.array([rng.binomial(int(alloc[k]), theta[k]) for k in range(K)])
    a += pos                                   # conjugate update: + positives
    b += alloc - pos                           #                   + negatives
    total_pos += int(pos.sum())
    row = " ".join(f"{int(alloc[k]):>3d}" for k in range(K))
    print(f"  {w + 1:>2}  {row}   {int(pos.sum()):>4d}")

print(f"adaptive: {total_pos} positives found in {weeks * weekly} tests")
print(f"even 25/site/week would expect ~{int(weeks * weekly / K * theta.sum())}")
week   A   B   C   D   positives
   1   25  25  25  25     15
   2    5  68  12  15     19
   3    5  30  51  14     20
   4    6  31  46  17     19
   5    7  11  77   5     16
   6    5  21  69   5     21
   7    5  23  67   5     16
   8    5  21  69   5     26
adaptive: 152 positives found in 800 tests
even 25/site/week would expect ~100

Read the table top to bottom and you can watch the algorithm think:

The key lesson is that a single noisy week did not lock in the wrong answer: the floor kept C alive when it looked mediocre in week 2, and continued updating steered the budget back to the truth. Over the full season the adaptive design finds 152 positives against the even split’s expected ~100 — roughly a 50% larger yield from the very same 800 tests — because it spent most of its budget on the two genuinely higher-positivity sites instead of burning a quarter of every week on the two quiet ones.

The same weekly loop in R#

R
set.seed(1)
theta <- c(A = 0.05, B = 0.15, C = 0.22, D = 0.08)   # unknown to the sampler
K <- 4; weeks <- 8; weekly <- 100; floor <- 5
flex <- weekly - floor * K                            # 80 steered by Thompson
a <- rep(1, K); b <- rep(1, K)

alloc_tbl <- matrix(0L, weeks, K, dimnames = list(paste0("wk", 1:weeks), names(theta)))
for (w in seq_len(weeks)) {
  if (w == 1) {
    alloc <- rep(weekly %/% K, K)                     # even warm start: 25 each
  } else {
    alloc <- rep(floor, K)                            # floor for every site
    draws <- matrix(rbeta(flex * K, a, b), nrow = flex, byrow = TRUE)
    picks <- max.col(draws)                           # Thompson for the 80
    for (k in seq_len(K)) alloc[k] <- alloc[k] + sum(picks == k)
  }
  pos <- rbinom(K, alloc, theta)                      # positives per site
  a <- a + pos; b <- b + alloc - pos                  # conjugate update
  alloc_tbl[w, ] <- alloc
}
alloc_tbl        # rows = weeks, columns = tests sent to each site

And in Julia#

Julia
using Random, Distributions
Random.seed!(1)

theta = [0.05, 0.15, 0.22, 0.08]                 # sites A, B, C, D
K, weeks, weekly, floor = 4, 8, 100, 5
flex = weekly - floor * K                         # 80 steered by Thompson
a = ones(K); b = ones(K)

for w in 1:weeks
    if w == 1
        alloc = fill(weekly ÷ K, K)               # even warm start: 25 each
    else
        alloc = fill(floor, K)                    # floor for every site
        picks = [argmax(rand.(Beta.(a, b))) for _ in 1:flex]
        for k in 1:K
            alloc[k] += count(==(k), picks)       # Thompson-allocate the 80
        end
    end
    pos = [rand(Binomial(alloc[k], theta[k])) for k in 1:K]
    a .+= pos; b .+= alloc .- pos                 # conjugate update
    println("week $w: ", alloc, "  positives ", sum(pos))
end
Tip

Three knobs turn this from a demonstration into a program you can defend. The floor (here 5/site/week) buys insurance against a bad warm start and keeps a usable estimate at every site — raise it if the quiet sites still matter for monitoring. The warm-start length (here one week) sets how much you learn before adapting — a longer warm start is safer when weekly counts are small. And the flexible fraction (here 80/100) controls how aggressively the budget chases the leader — shrinking it toward an even split trades positives-found for better estimates everywhere.

From surveillance to the clinic: adaptive trial design#

Swap “sites” for “treatment arms” and “positive test” for “patient responded,” and the identical machinery becomes response-adaptive randomization (RAR) — the engine of Bayesian adaptive clinical trials. Instead of fixing a 1:1:1:1 allocation for the whole study, you update a posterior on each arm’s success probability as outcomes arrive and steer new patients toward the arms that are winning. The ethical appeal is direct: fewer trial participants are assigned to inferior treatments, because the allocation follows the accumulating evidence.

The clinical setting adds a few refinements to the bare bandit:

Show the two-arm superiority probability

For a head-to-head trial with a treatment arm and a control, put independent Beta posteriors on the two response rates, θABeta(αA,βA)\theta_A \sim \text{Beta}(\alpha_A,\beta_A) and θBBeta(αB,βB)\theta_B \sim \text{Beta}(\alpha_B,\beta_B). The decision quantity is the posterior probability that the treatment beats control:

Pr(θA>θBdata)  =  010θApA(θA)pB(θB)  dθBdθA  =  01pA(θ)FB(θ)  dθ,\Pr(\theta_A > \theta_B \mid \text{data}) \;=\; \int_0^1 \int_0^{\theta_A} p_A(\theta_A)\,p_B(\theta_B)\; d\theta_B \, d\theta_A \;=\; \int_0^1 p_A(\theta)\,F_B(\theta)\; d\theta ,

which is exactly the two-arm case of the allocation integral (2) — arm AA “wins” when its draw exceeds arm BB’s. There is a classical closed form as a sum,

Pr(θB>θA)  =  i=0αB1B(αA+i, βA+βB)(βB+i)B(1+i, βB)B(αA, βA),\Pr(\theta_B > \theta_A) \;=\; \sum_{i=0}^{\alpha_B - 1} \frac{B(\alpha_A + i,\ \beta_A + \beta_B)}{(\beta_B + i)\,B(1 + i,\ \beta_B)\,B(\alpha_A,\ \beta_A)},

for integer parameters, but in practice you estimate it with a handful of Monte Carlo draws — the same prob_best routine above with two arms. A trial declares superiority when this probability exceeds a pre-specified threshold chosen (by simulation) to control the frequentist type-I error at the desired level.

Response-adaptive randomization has real history: the 1980s ECMO trial for newborns used a “play-the-winner” adaptive rule and assigned all but one infant to the treatment that was working, and modern platform trials — I-SPY 2 in breast cancer, REMAP-CAP in pneumonia and COVID-19 — run many arms under exactly this Bayesian-updating framework, dropping and adding treatments as posteriors evolve. The survey-sampling bandit and the adaptive trial are the same algorithm pointed at different rewards.

Warning

Adaptivity is not free. Response-adaptive randomization can confound the treatment effect with time drift (patient characteristics changing over the trial), inflates variance for the arms it starves, and complicates the final analysis — the allocation depends on the data, so naive standard errors are wrong. Real trials fix the operating characteristics (type-I error, power, expected sample size) by simulation before enrolling anyone, and often block on calendar time to protect against drift. The same cautions apply to surveillance: a bandit optimized to find cases produces a biased estimate of each site’s true prevalence, because it deliberately under-samples the quiet sites.

Why it matters#

The Bayesian bandit turns a static allocation problem into a learning loop: carry a posterior on each option, act in proportion to the probability each option is best, and let the data reshape the allocation. For surveillance it means finding more cases per test by concentrating limited diagnostic capacity where the signal is, without a human re-deciding the split each week. For clinical research it means trials that expose fewer patients to worse treatments and can stop as soon as the evidence is decisive. The whole apparatus rests on two ideas this site returns to again and again — conjugate updating to make learning cheap, and posterior probabilities as the currency of decisions — which is what lets one short loop serve both the field epidemiologist and the trialist.