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]"
EstimatorFunctionWhen to use
Replayreplay(df)Sanity check baseline. Unbiased but low coverage (~1/K interactions used).
IPS / SNIPSips(df, clip=10.0)Primary estimator. Uses every interaction with importance weights.
Doubly Robustdoubly_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'