The Data Science Escape Hatch
Every interaction is event-sourced. Export to Parquet and evaluate policies offline.
POST /checkpoint compiles completed prediction→reward pairs into Snappy-compressed Apache Parquet files — one file per campaign — for offline analysis in Pandas or Polars.
Every prediction will eventually appear in the Parquet file even if its reward arrives hours later. BanditDB re-emits in-flight interactions at each checkpoint so delayed rewards are always captured in a future cycle.
Shards are pruned. From 2.0.0 only the most recent 50 shards
per campaign are kept (BANDITDB_EXPORT_RETAIN_SHARDS); older ones
are deleted at checkpoint. If Parquet is your analytics history, copy shards to
durable storage on a schedule — they are an export, not an archive, and nothing
in recovery reads them.
Set the variable to 0 to keep everything, but then watch the
volume: the directory previously grew without bound, and once the disk filled,
checkpointing itself began to fail.
Each row includes a propensity column — the probability that the logging policy selected the chosen arm given the context. This is the P(a | x) term required by Inverse Propensity Scoring estimators. The method differs by algorithm:
- LinUCB / NeuralLinUCB: softmax-normalised UCB scores across all arms at prediction time.
- Thompson Sampling: adaptive Monte Carlo frequency estimate. N posterior samples are drawn per arm; propensity is the fraction of draws in which the arm produced the highest score. N adapts from 64 (cold start, diffuse posterior) down to 8 (converged, concentrated posterior) based on the maximum A⁻¹ diagonal across arms — so accuracy is highest when it is most needed and cost is lowest when traffic is highest.
import polars as pl
import requests
HEADERS = {"X-Api-Key": "your-secret-key"}
# Snapshot models, export Parquet, rotate the WAL
requests.post("http://localhost:8080/checkpoint", headers=HEADERS)
# Flat schema: interaction_id | arm_id | reward | predicted_at | rewarded_at | propensity | feature_0 …
df = pl.read_parquet("/data/exports/sleep.parquet")
print(df.head())
Offline Policy Evaluation
The Python SDK ships three OPE estimators in banditdb.eval. Install with:
pip install "banditdb-python[eval]"
| Estimator | Function | When to use |
|---|---|---|
| Replay | replay(df) | Sanity check baseline. Unbiased but low coverage (~1/K interactions used). |
| IPS / SNIPS | ips(df, clip=10.0) | Primary estimator. Uses every interaction with importance weights. |
| Doubly Robust | doubly_robust(df, clip=10.0) | Best statistical efficiency. Use when comparing multiple policies or sweeping alpha. |
from banditdb.eval import replay, ips, doubly_robust
df = pl.read_parquet("/data/exports/sleep.parquet")
print(replay(df))
# OPEResult(method='replay', estimate=0.4821, std_error=0.0312, coverage=22.1% [33/149])
print(ips(df))
# OPEResult(method='ips', estimate=0.5103, std_error=0.0187, coverage=100.0% [149/149])
print(doubly_robust(df))
# OPEResult(method='doubly_robust', estimate=0.5219, std_error=0.0141, coverage=100.0% [149/149])
# Compare against the observed reward of the logging policy:
print("Observed:", df["reward"].mean())
# If observed >> estimate, the campaign has learned something real.
Inspecting the WAL
The WAL is plain JSONL — every event is human-readable on disk.
# All campaigns ever created
grep "CampaignCreated" /data/bandit_wal.jsonl | jq '.CampaignCreated.campaign_id'
# Campaigns that have been deleted
grep "CampaignDeleted" /data/bandit_wal.jsonl | jq '.CampaignDeleted.campaign_id'