NeuralLinUCB

An MLP preprocessing stage that gives LinUCB a learnable, non-linear view of the context.

Standard LinUCB assumes the expected reward is a linear function of the raw context β€” which breaks down when features interact non-linearly or when the context vector carries redundant dimensions. NeuralLinUCB adds a small MLP that projects the caller's context into a compact fixed-size embedding; LinUCB then operates entirely in that embedding space. The network is retrained in batches on a background schedule β€” keeping the hot predict/reward path purely online with no ML overhead per request.

context x (context_dim floats) β”‚ β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ hidden_layers Γ— hidden_dim Γ— ReLUβ”‚ MLP h(x ; W) β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ retrained in background, off the request path β”‚ L2-normalise β–Ό embedding h (embed_dim floats) β”‚ ΞΈα΅€h + Ξ± Β· √(hα΅€ A⁻¹ h) ← LinUCB (Sherman-Morrison online update)

Two algorithms run in tandem:

  • Algorithm 1 (online, every request): Runs the same Sherman-Morrison LinUCB update used by "linucb", but on the MLP embedding rather than raw context. No added latency β€” the MLP forward pass is a single matrix multiply chain.
  • Algorithm 2 (batch, background): Once retrain_every rewards have accumulated, AdamW minimises Β½Β·MSE(ΞΈα΅€h(x;W), r) + (mΒ·Ξ»/2)Β·β€–W βˆ’ Wβ‚€β€–Β² for retrain_steps steps. The L2 penalty toward the initialisation weights Wβ‚€ prevents catastrophic forgetting across retrain cycles. Arm matrices are then re-accumulated β€” replayed through the new embedding via Sherman-Morrison.

Thompson Sampling variant: "neural_thompson_sampling" uses the identical MLP embedding and Algorithm 2 batch retrain described above β€” the only difference is Algorithm 1. Instead of scoring by the UCB bound ΞΈα΅€h + α·√(hα΅€A⁻¹h), it draws w ~ N(ΞΈ, Ξ±Β²Β·A⁻¹) and scores by wα΅€h. Same config schema, same table below. Tends toward better long-run convergence with higher early regret than NeuralLinUCB.

Configuration

Per-campaign, set at creation:

ParameterDefaultMeaning
context_dimrequiredLength of the context vector the caller sends at predict time. Fixed at campaign creation.
embed_dim32Embedding (arm matrix) dimension. Arm matrices are embed_dim Γ— embed_dim. Smaller = faster online updates; larger = more expressive. 16–64 covers most use cases.
hidden_dim128Width of each hidden layer.
hidden_layers2Number of hidden layers before the final embedding projection.
retrain_every200Rewards to accumulate before triggering Algorithm 2. Larger = fewer retrains, more stable arm matrices in between.
retrain_steps100AdamW gradient-descent steps per retrain cycle.
learning_rate0.001AdamW learning rate.
lambda1.0L2 regularization coefficient toward initial weights Wβ‚€. Higher = slower adaptation, more stable. Lower = allows faster weight drift between cycles.

Process-wide, set by environment variable:

VariableDefaultMeaning
BANDITDB_RETRAIN_POLL_SECS2How often the background worker checks whether any campaign is due for a retrain. 0 restores the pre-2.0.0 behaviour of retraining only at checkpoint.
BANDITDB_NEURAL_BUFFER_CAP50000Replay-buffer entries held per campaign. This bounds memory: entries × context_dim × 8 bytes, per campaign.
BANDITDB_NEURAL_BATCH_SIZE4000Minibatch sampled per gradient step. Decouples the cost of a step from buffer size, so a large buffer no longer makes every retrain proportionally slower.
db.create_campaign(
    "content",
    arms=["article_a", "article_b", "article_c"],
    algorithm={
        "neural_lin_ucb": {
            "context_dim": 32,    # must match the context vector you send at predict time
            "embed_dim": 16,      # arm matrices are 16 Γ— 16
            "hidden_dim": 128,
            "hidden_layers": 2,
            "retrain_every": 500, # first retrain after 500 rewards
            "retrain_steps": 100,
            "learning_rate": 1e-3,
            "lambda": 1.0,
        }
    },
    alpha=1.0,
)

# context must be exactly context_dim=32 floats
arm, interaction_id = db.predict("content", context)
db.reward(interaction_id, reward)

Feature flag: NeuralLinUCB is compiled in only when the neural feature is enabled. The default binary contains no Candle or ML dependencies.

cargo build --release --features neural
cargo build --release --features neural,cuda
cargo build --release --features neural,metal

Hardware Acceleration

The compute device is selected at startup via the BANDITDB_DEVICE environment variable. When unset, BanditDB auto-detects: CUDA β†’ Metal β†’ CPU.

ValueRequiresEffect
unset or autoβ€”CUDA if available, then Metal, then CPU.
cuda--features neural,cudaCUDA GPU 0.
cuda:N--features neural,cudaSpecific CUDA device ordinal.
metal--features neural,metalApple Silicon GPU.
cpu--features neuralForce CPU regardless of available hardware.

Cold Start and Warm Restart

Retraining never blocks a prediction. The worker trains on a private copy of the network and swaps in the finished weights atomically when it is done; predictions read whichever snapshot is published and take no lock on the model at all.

This was a genuine outage in earlier versions, not a theoretical concern. A prediction held the arm lock and then wanted the neural lock, while a retrain held the neural lock and then wanted the arm lock. With a writer queued behind them, the task-fair lock closed the cycle and the server stopped serving β€” process alive, health check green, every request hung.

Cold start: a freshly created campaign uses randomly-initialised MLP weights until the first Algorithm 2 retrain fires at retrain_every rewards. Random projections are a usable baseline β€” LinUCB adapts even in a random embedding space β€” but expect lower sample efficiency than a trained network during this initial phase.

Warm restart: after each retrain, arm matrices (A⁻¹, b, θ) are re-accumulated by replaying the reward buffer through the updated network. This preserves most of the learned signal across retrains instead of resetting LinUCB statistics from scratch.

Recovery: MLP weights are saved as a .safetensors sidecar at {DATA_DIR}/neural/{campaign_id}.safetensors at each checkpoint and reloaded automatically on startup. If the sidecar is missing, the campaign recovers gracefully with fresh random weights and relearns from the WAL tail β€” model quality degrades temporarily but no data is lost.