Multi-Armed Bandit
Multi-armed bandit (MAB) experiments dynamically reallocate traffic toward better-performing variants as data accumulates, rather than maintaining fixed allocations throughout the experiment.
Algorithms
Each algorithm turns every variant's pulls and successes into a weight; the weights add up
to 1 and decide where new users go (see How the weights affect traffic).
An experiment is a bandit only when it names one of the three; optimization_type defaults
to fixed.
Thompson Sampling
Each variant has a Beta posterior, Beta(1 + successes, 1 + failures). A weight update draws 10,000 samples from every posterior, and each variant's weight is the share of draws in which it is highest.
UCB1 (Upper Confidence Bound)
Each variant's score is mean + sqrt(2 * ln(total_pulls) / variant_pulls), and its weight is
its score divided by the sum of the scores, so every variant keeps some traffic. While any
variant has no pulls, the variants with none share all the weight equally.
Epsilon-Greedy
The variant with the highest conversion rate gets the weight 0.9 + 0.1 / n, and each of
the other variants 0.1 / n, for n variants: epsilon is 0.1, and it is not a setting.
Creating a MAB Experiment
Run the commands on this page in one terminal, in order, against the stack from the Quick Start. Each uses the shell variables set by the ones before it. Log in first; creating an experiment takes the DEVELOPER or ADMIN role:
TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"admin@demo.com","password":"Demo1234!"}' | jq -r .access_token)
curl -s localhost:8000/api/v1/auth/me -H "Authorization: Bearer $TOKEN" | jq .role
It prints "ADMIN".
Set optimization_type to one of thompson_sampling, ucb1 or epsilon_greedy when you
create the experiment. The bandit counts a conversion as an event matching the experiment's primary
metric. This saves the experiment's id in $EXP_ID:
EXP=$(curl -s -X POST localhost:8000/api/v1/experiments/ \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"name": "CTA Button Bandit",
"optimization_type": "thompson_sampling",
"variants": [
{"name": "control", "is_control": true, "traffic_allocation": 34},
{"name": "red_button", "traffic_allocation": 33},
{"name": "green_button", "traffic_allocation": 33}
],
"metrics": [
{"name": "CTA click", "event_name": "cta_click", "is_primary": true}
]
}')
EXP_ID=$(jq -r .id <<<"$EXP")
jq '{name, status}' <<<"$EXP"
The experiment is created in draft. Variants' traffic_allocations are whole percentages
that add up to 100; they decide the split until the bandit's first weight update.
API Reference
GET /api/v1/bandit/{experiment_id}
Returns the current bandit state: the algorithm, and each variant's weight, pulls, successes and conversion rate. Any logged-in user can read it. This also saves each variant's id, for the weight override further down:
STATUS=$(curl -s localhost:8000/api/v1/bandit/$EXP_ID \
-H "Authorization: Bearer $TOKEN")
CONTROL_ID=$(jq -r '.current_weights[] | select(.variant_name == "control") | .variant_id' <<<"$STATUS")
RED_ID=$(jq -r '.current_weights[] | select(.variant_name == "red_button") | .variant_id' <<<"$STATUS")
GREEN_ID=$(jq -r '.current_weights[] | select(.variant_name == "green_button") | .variant_id' <<<"$STATUS")
jq '{algorithm, total_pulls, recommendation}' <<<"$STATUS"
Before any update has run, every variant has an equal weight. With traffic, a response looks like this:
{
"experiment_id": "0bdda20f-2cd5-4e99-8ba0-0ce5fc378761",
"algorithm": "thompson_sampling",
"current_weights": [
{
"variant_id": "104a0022-083b-4e22-a793-096788bbe49b",
"variant_name": "control",
"current_weight": 0.18,
"successes": 820,
"pulls": 2246,
"conversion_rate": 0.365
},
{
"variant_id": "1f1f863f-38ae-49c9-b995-60290d58def3",
"variant_name": "red_button",
"current_weight": 0.61,
"successes": 3890,
"pulls": 6382,
"conversion_rate": 0.61
},
{
"variant_id": "6f07b272-cbcc-47f9-8336-c2b58992949a",
"variant_name": "green_button",
"current_weight": 0.21,
"successes": 1240,
"pulls": 3852,
"conversion_rate": 0.322
}
],
"total_pulls": 12480,
"regret_reduction_pct": 2.1,
"recommendation": "CONVERGING",
"last_updated": "2026-09-26T14:32:00+00:00",
"seed": 6346510783624545786,
"n_samples": 10000,
"engine_version": "1.2.0"
}
Response Fields
| Field | Description |
|---|---|
algorithm |
The experiment's optimization_type |
current_weight |
Current traffic allocation fraction (0–1, sums to 1.0 across variants) |
pulls |
Total assignments to this variant |
successes |
Converting users in this variant: users assigned to it with at least one conversion event, each counted once |
regret_reduction_pct |
Estimated regret reduction compared with a uniform split; null until there is data, and while the weights do no better than a uniform split |
recommendation |
EXPLORING, CONVERGING, or DEPLOYING_<variant name> once one variant dominates |
last_updated |
When the weights were last computed; null before the first update |
seed, n_samples |
The random seed and draws per variant of the last Thompson-sampling update; null for the other algorithms |
An experiment whose optimization_type is fixed answers 404 here.
POST /api/v1/bandit/{experiment_id}/update
Recalculates the weights now, outside the scheduler cycle. Requires the DEVELOPER or ADMIN role:
curl -s -X POST localhost:8000/api/v1/bandit/$EXP_ID/update \
-H "Authorization: Bearer $TOKEN" | jq '{algorithm, n_samples}'
It returns the new state, which now has a last_updated time. Thompson sampling draws
n_samples (10000) samples per variant, so with no data yet the weights move a little away
from equal at random.
PUT /api/v1/bandit/{experiment_id}/weights
Overrides the variant weights. Requires the ADMIN role. The body maps each variant's id to
its weight; the weights must be at least 0 and add up to 1.0, or the API answers 422.
Use with caution: the scheduler replaces an override on its next cycle while the experiment
is active.
curl -s -X PUT localhost:8000/api/v1/bandit/$EXP_ID/weights \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d "{
\"weights\": {
\"$CONTROL_ID\": 0.1,
\"$RED_ID\": 0.8,
\"$GREEN_ID\": 0.1
}
}" | jq -c '[.current_weights[] | {variant_name, current_weight}]'
It prints the new weights:
[{"variant_name":"control","current_weight":0.1},{"variant_name":"red_button","current_weight":0.8},{"variant_name":"green_button","current_weight":0.1}]
Scheduler
The bandit scheduler (BanditSchedulerRunner in backend/app/core/bandit_scheduler.py) starts with the API
process and recalculates weights for every ACTIVE experiment whose optimization_type is not fixed.
The cadence is BANDIT_UPDATE_INTERVAL_MINUTES (default 5). It does not run under APP_ENV=test.
Where the counts come from
For each variant the scheduler needs pulls (assignments) and successes (conversions). Sources are tried in order:
- DynamoDB real-time counters (
get_experiment_counters), when the counters stack is deployed and every variant has at least as many pulls and successes there as in PostgreSQL. A partial DynamoDB count (a counter that started late, or a manual increment) is ignored in favour of PostgreSQL's complete one. - PostgreSQL: pulls =
assignmentsrows per variant; successes = converting users, counted exactly as/resultscounts them: users assigned to the variant with at least one event, tagged with that variant, whoseevent_namematches the experiment's primary metric (see the event-matching rule in the tracking API docs). A user who converts three times counts once. With no metric configured, every event other than an experiment view is a success. - The previously persisted
BanditState. - Zero-count priors (equal weights).
How the weights affect traffic
POST /api/v1/tracking/assign reads the latest BanditState for bandit experiments and routes new users
according to the current weights (a deterministic hash of the user id is looked up in the cumulative weight
distribution, so repeated calls agree before the assignment row exists). Users who already have an assignment
keep it. Variants whose weight is 0 receive no new traffic. Until the first refresh has run, new users are
split by the variants' traffic_allocation.
Permissions
| Action | Minimum Role |
|---|---|
| View bandit status | VIEWER |
| Trigger weight update | DEVELOPER |
| Override weights | ADMIN |