From A/B to RL (1/3): Bayesian A/B Testing
Before policies learn, experiments assign.
This series connects A/B testing (randomized comparisons between variants A and B) with reinforcement learning (RL) through a Bayesian view of decision-making. We start with a fixed experiment, where options are assigned in advance. Then we move to situations where each observed result helps determine what to try next.
The aim is not to give a complete introduction to RL. Instead, we use a sequence of small examples to get familiar with key RL concepts: action, reward, policy, state, episode, and delayed feedback.
This is the first post in a 3-part series:
- Part 1: Bayesian A/B Testing (this post)
- Part 2: Bandits and Thompson Sampling
- Part 3: MENACE and Delayed Rewards
Bayesian A/B Testing
We start the series with a fixed randomized experiment: two variants (A and B), an immediate binary outcome (success or failure), and one final decision about which variant to keep.
An A/B test compares two variants of a system or intervention, such as two webpages, two button labels, two recommendation policies, or two treatments in a randomized controlled trial.
This post uses the simplest version of that setup:
- two fixed variants, A and B,
- random assignment to either A or B,
- one binary outcome per assignment,
- a fixed batch of data,
- one final decision.
This small setup keeps two questions separate:
- how to model uncertainty about each variant's performance,
- how to use that uncertainty to make the final decision at the end of the experiment.
From this point on, variant and action mean the same thing. I'll use variant when discussing the A/B test and action when making the RL connection.
# Imports and setup
import importlib.metadata
import platform
from dataclasses import dataclass
from functools import lru_cache
from IPython.display import display
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
import seaborn as sns
from matplotlib.axes import Axes
from matplotlib.patches import Patch
from scipy import stats
from scipy.special import betaln
sns.set_style("darkgrid")
plt.rcParams["figure.figsize"] = (7, 4)
# Some common types
FloatArray = npt.NDArray[np.float64]
IntArray = npt.NDArray[np.int_]
def display_figure(fig, *, alt_text: str) -> None:
"""Display a Matplotlib figure with accessible alternative text."""
display(fig, metadata={"alt": alt_text})
plt.close(fig)
#
The Toy Problem
To make the setup concrete, let's start with a simple A/B-testing example. Imagine we want to test a change to a button on a website, such as a Buy or Subscribe button. We want to compare the current button (variant A) with a new design (variant B).
Our goal is to determine whether variant B produces more clicks than variant A. In this toy example, a click is the immediate reward: $1$ for a click and $0$ otherwise. We measure each variant's performance with its click-through rate (CTR), the fraction of users who click after being shown the button.
Each exposure is a single interaction: one user sees either button A or B, and we observe whether they click it. In RL terms, the choice is the action, and in this example the click outcome is the immediate reward. Every exposure starts from the same situation, so there is no changing state to track.
The causal question is whether showing button B causes more clicks than showing button A. We use the same random-assignment rule throughout the experiment. At the end, we make one final decision about which variant to ship.
This leaves one question:
Which variant should we ship?
We will simulate one randomized experiment. The true CTRs, or expected rewards, are fixed but hidden from the inference procedure. We keep them only so we can check later whether the posterior behaves sensibly.
# Define a small container for the simulated A/B test outcomes.
@dataclass(frozen=True)
class ABTestData:
"""Observed outcomes from one fixed randomized A/B test."""
observations_a: IntArray
observations_b: IntArray
true_rate_a: float
true_rate_b: float
@property
def trials_per_variant(self) -> int:
"""Return the number of observed users assigned to each variant."""
return int(len(self.observations_a))
@property
def clicks(self) -> tuple[int, int]:
"""Return observed click counts for A and B."""
return int(self.observations_a.sum()), int(self.observations_b.sum())
@property
def no_clicks(self) -> tuple[int, int]:
"""Return observed non-click counts for A and B."""
clicks_a, clicks_b = self.clicks
return self.trials_per_variant - clicks_a, self.trials_per_variant - clicks_b
#
The next cell generates one randomized experiment. For simplicity, the simulation generates an equal number of outcomes for each arm; the randomization is represented by the experimental design rather than simulated user by user.
# Simulate one fixed randomized A/B test.
TRUE_RATE_A = 0.12 # Hidden from the learner
TRUE_RATE_B = 0.15 # Hidden from the learner
NB_TRIALS = 200
experiment_rng = np.random.default_rng(20260425)
# Simulate user outcomes for each variant. Each user either clicks (1) or doesn't click (0).
observations_a = experiment_rng.binomial(n=1, p=TRUE_RATE_A, size=NB_TRIALS)
observations_b = experiment_rng.binomial(n=1, p=TRUE_RATE_B, size=NB_TRIALS)
experiment = ABTestData(
observations_a=observations_a,
observations_b=observations_b,
true_rate_a=TRUE_RATE_A,
true_rate_b=TRUE_RATE_B,
)
clicks_a, clicks_b = experiment.clicks
no_clicks_a, no_clicks_b = experiment.no_clicks
print(f"Variant A: {clicks_a} clicks, {no_clicks_a} non-clicks, observed CTR = {clicks_a / NB_TRIALS:.3f}")
print(f"Variant B: {clicks_b} clicks, {no_clicks_b} non-clicks, observed CTR = {clicks_b / NB_TRIALS:.3f}")
This chart shows the noisy click counts observed in the experiment. The Bayesian model uses these counts as evidence about the two unknown CTRs.
# Plot the simulated A/B test outcomes.
true_rate_a = experiment.true_rate_a
true_rate_b = experiment.true_rate_b
nb_trials = experiment.trials_per_variant
clicks_a, clicks_b = experiment.clicks
no_clicks_a, no_clicks_b = experiment.no_clicks
variants = ["A", "B"]
clicks = [clicks_a, clicks_b]
no_clicks = [no_clicks_a, no_clicks_b]
fig, ax = plt.subplots(figsize=(6.5, 4))
ax.barh(variants, clicks, label="Clicks", color="tab:green")
ax.barh(variants, no_clicks, left=clicks, label="No Clicks", color="tab:gray", alpha=0.7)
ax.set_title("Observed outcomes in the toy experiment")
ax.set_xlabel("user count")
ax.legend()
plt.tight_layout()
display_figure(
fig,
alt_text="Observed click and no-click counts for variants A and B in the simulated A/B experiment.",
)
#
What Is Actually Observed?
The important distinction is that we do not observe the true click-through rate (CTR) directly. Under randomized assignment, we observe only finite, noisy counts:
- how many users saw A and how many saw B,
- how many clicks each variant produced.
The observed CTR is the number of clicks divided by the number of users who were shown that variant. It estimates the variant's expected immediate reward. Since each exposure is a one-step interaction, that expected reward is also the action value. We use $\theta$ to represent the unknown long-run click probability.
That leaves us with the real question: how should we reason about the unknown click probability $\theta$ from limited observations?
A Bayesian view helps because it turns observed click counts into a posterior distribution over $\theta$ for each variant. We can then ask decision questions directly, such as how likely it is that B has the higher expected reward.
To answer those questions, we begin with a simple probabilistic model that connects each click outcome to an unknown click-through rate. This is not the only way to analyze an A/B test, but it gives us a useful starting point.
A Minimal Bayesian Model
Let's express this setup in probabilistic terms.
For one user, the outcome $x$ is binary, so we model it with a Bernoulli distribution:
$$x \sim \mathrm{Bernoulli}(\theta)$$
Here $x = 1$ means a click and $x = 0$ means no click. We use $\theta$ for the unknown click-through rate of one variant. In decision-making terms, $\theta$ is the expected immediate reward from showing that variant. In this one-step setting, it is also the action value.
If we group $n_{\mathrm{users}}$ users together and count their clicks, the total $n_{\mathrm{clicks}}$ follows a Binomial distribution:
$$n_{\mathrm{clicks}} \sim \mathrm{Binomial}(n_{\mathrm{users}}, \theta)$$
This toy model assumes that each variant has a stable click probability and that user outcomes are conditionally independent given that probability.
This describes how clicks are generated if we know $\theta$. In the A/B test, we face the inverse problem: we observe clicks and want to infer the unknown rate.
Bayesian inference uses Bayes' theorem to move from the observed clicks back to the unknown rate.
For a single observed outcome $x$, that relationship can be written as:
$$p(\theta \mid x) = \frac{p(x \mid \theta) p(\theta)}{p(x)}$$
The posterior is proportional to the likelihood times the prior. In practice, we combine the likelihood of the observed clicks with a prior over $\theta$. The result is a full posterior distribution over the unknown click-through rate, rather than a single best guess.
For the prior, we use a Beta distribution:
$$\theta \sim \mathrm{Beta}(\alpha, \beta)$$
The Beta distribution describes plausible click-through rates between 0 and 1. For a more in-depth introduction to the beta distribution, see Beta distribution: Probabilities and binary data. Here we use the uniform prior $\mathrm{Beta}(1, 1)$, which represents the assumption that, before seeing any data, we do not favor any particular rate.
This prior is convenient because the Beta distribution is a conjugate prior for the Bernoulli/Binomial likelihood. After observing clicks and non-clicks, the posterior is also a Beta distribution, with updated parameters.
For one observation, the update is simple:
- after a click, $\mathrm{Beta}(\alpha, \beta) \to \mathrm{Beta}(\alpha + 1, \beta)$,
- after a non-click, $\mathrm{Beta}(\alpha, \beta) \to \mathrm{Beta}(\alpha, \beta + 1)$.
If we summarize the observations by their total number of clicks, the same update becomes:
$$\theta \mid n_{\mathrm{clicks}}, n_{\mathrm{users}} \sim \mathrm{Beta}(\alpha + n_{\mathrm{clicks}}, \beta + n_{\mathrm{users}} - n_{\mathrm{clicks}})$$
The update has a simple interpretation: a click adds 1 to the first shape parameter, and a non-click adds 1 to the second, updating the posterior distribution in either case. Starting from $\mathrm{Beta}(1, 1)$ and observing $n_{\mathrm{clicks}}$ clicks out of $n_{\mathrm{users}}$ users gives $\mathrm{Beta}(1 + n_{\mathrm{clicks}}, 1 + n_{\mathrm{users}} - n_{\mathrm{clicks}})$. Here, however, we update once using the full experiment.
For the A/B test, we apply this model separately to variants A and B, whose unknown rates $\theta_A$ and $\theta_B$ are the expected immediate rewards of the two actions. We assume independent priors for A and B and independent outcomes within each arm, so the two posterior distributions are independent. We then compare the two posterior distributions to decide which variant to ship.
From Observed Counts to Posterior Distributions
Let's apply the model to the toy experiment. The model uses the observed click and no-click counts from variants A and B. The dashed lines mark the hidden true CTRs, or expected rewards, used to generate the simulation; they are not available to the posterior calculation, which combines the observed counts with the fixed Beta prior.
# Compute Beta posterior parameters from observed click counts.
def beta_posterior_parameters(
successes: int,
trials: int,
alpha_prior: float = 1.0,
beta_prior: float = 1.0,
) -> tuple[float, float]:
"""Return the Beta posterior shape parameters after Bernoulli or Binomial observations."""
return alpha_prior + successes, beta_prior + trials - successes
alpha_a, beta_a = beta_posterior_parameters(successes=clicks_a, trials=nb_trials)
alpha_b, beta_b = beta_posterior_parameters(successes=clicks_b, trials=nb_trials)
# Plot the Beta distribution's density
def plot_beta_density(
ax: Axes,
alpha: float,
beta: float,
label: str,
color: str,
) -> None:
"""Plot the density curve of a Beta distribution on an existing Matplotlib axis."""
x = np.linspace(0.001, 0.999, 400)
y = stats.beta(a=alpha, b=beta).pdf(x)
ax.plot(x, y, label=label, color=color)
ax.fill_between(x, y, alpha=0.20, color=color)
ax.set_xlabel("click-through rate")
ax.set_ylabel("density")
fig, ax = plt.subplots(figsize=(8, 4.5))
plot_beta_density(ax=ax, alpha=alpha_a, beta=beta_a, label="posterior of A", color="tab:blue")
plot_beta_density(ax=ax, alpha=alpha_b, beta=beta_b, label="posterior of B", color="tab:orange")
ax.axvline(true_rate_a, color="tab:blue", linestyle="--", alpha=0.6, label="true rate of A (unknown)")
ax.axvline(true_rate_b, color="tab:orange", linestyle="--", alpha=0.6, label="true rate of B (unknown)")
ax.set_xlim(0.0, 0.50)
ax.set_title(f"Posterior distributions after {nb_trials} observations per variant")
ax.legend(loc="upper right")
plt.tight_layout()
display_figure(
fig,
alt_text="Beta posterior densities for variants A and B; dashed lines show the true simulated click-through rates hidden from the learner.",
)
#
Making the Decision
With posterior distributions for variants A and B, we can ask how likely it is that B's true CTR, or expected immediate reward, exceeds A's. In terms of the unknown CTRs:
$$ P(B > A \mid \mathrm{data}) = P(\theta_B > \theta_A \mid \mathrm{data}). $$
Here $\theta_A$ and $\theta_B$ are the unknown true CTRs of variants A and B. In the one-step reward interpretation, they are also the unknown one-step action values.
For this post, $P(B > A \mid \mathrm{data})$ is a decision quantity: it helps us decide which variant to ship after the experiment. It is not an online rule for choosing variants: assignments were fixed in advance. In the next post, a similar posterior probability will become an online policy distribution.
There are two useful ways to compute this probability:
- approximate it with Monte Carlo sampling: draw posterior samples from A and B and count how often $\theta_B > \theta_A$,
- compute it exactly for Beta posteriors using the finite-sum formula for comparing two Beta distributions.
Monte Carlo is more general because it works whenever we can sample from the posterior. In the Beta-Bernoulli case, we can also compute the same probability directly. The exact calculation integrates over the two independent Beta posteriors rather than relying on Monte Carlo samples.
Estimating $P(B > A \mid \mathrm{data})$
We can estimate $P(B > A \mid \mathrm{data})$ by Monte Carlo: repeatedly draw one plausible CTR from each posterior and, for each pair of draws, record whether B's draw is larger. If B wins 90% of the paired draws, our estimate of $P(B > A \mid \mathrm{data})$ is about 0.90.
Each comparison pairs one draw of $\theta_A$ with one draw of $\theta_B$ and records whether $\theta_B > \theta_A$.
$$ P(B > A \mid \mathrm{data}) = P(\theta_B > \theta_A \mid \mathrm{data}). $$
For each pair, we can also compute the posterior difference $\Delta$: B's sampled CTR minus A's sampled CTR. In this one-step setting, it is also the sampled difference in expected reward:
$$ \Delta = \theta_B - \theta_A. $$
A positive $\Delta$ means that B is higher in that paired draw. When A is a baseline or control, this difference is often called the absolute lift of B over A.
The figure shows three views of the same paired draws. The scatter plot on the left shows a readable subset. The histogram in the center uses all paired draws to show the posterior differences $\Delta$. The bar chart on the right summarizes those comparisons as the posterior probability that B beats A. In all three panels, green indicates $\theta_B > \theta_A$.
# Estimate P(B > A | data) by posterior sampling and by an exact Beta formula.
def sample_beta_posterior(
alpha: float,
beta: float,
rng: np.random.Generator, # Random number generator for posterior sampling
size: int = 200_000,
) -> FloatArray:
"""Draw posterior samples from a Beta distribution with the given shape parameters."""
return rng.beta(a=alpha, b=beta, size=size)
def estimate_probability_b_beats_a_by_sampling(
alpha_a: float,
beta_a: float,
alpha_b: float,
beta_b: float,
rng: np.random.Generator, # Random number generator for posterior sampling
size: int = 200_000,
) -> tuple[float, FloatArray, FloatArray]:
"""Estimate P(theta_B > theta_A | data) from posterior samples for both variants."""
samples_a = sample_beta_posterior(alpha=alpha_a, beta=beta_a, rng=rng, size=size)
samples_b = sample_beta_posterior(alpha=alpha_b, beta=beta_b, rng=rng, size=size)
return float(np.mean(samples_b > samples_a)), samples_a, samples_b
def exact_probability_beta_x_exceeds_beta_y(
alpha_x: int,
beta_x: int,
alpha_y: int,
beta_y: int,
) -> float:
"""Compute P(X > Y) for independent Beta variables when alpha_x is an integer.
Uses the finite-sum Beta-function identity described in
https://www.evanmiller.org/bayesian-ab-testing.html .
"""
indices = np.arange(alpha_x)
log_terms = (
betaln(alpha_y + indices, beta_x + beta_y)
- np.log(beta_x + indices)
- betaln(1 + indices, beta_x)
- betaln(alpha_y, beta_y)
)
max_log_term = np.max(log_terms)
return float(np.exp(max_log_term) * np.sum(np.exp(log_terms - max_log_term)))
@lru_cache(maxsize=None)
def exact_posterior_probability_b_beats_a(
n: int,
clicks_a: int,
clicks_b: int,
) -> float:
"""Compute P(theta_B > theta_A | data) exactly under the uniform Beta(1, 1) prior."""
return exact_probability_beta_x_exceeds_beta_y(
alpha_x=1 + clicks_b,
beta_x=1 + n - clicks_b,
alpha_y=1 + clicks_a,
beta_y=1 + n - clicks_a,
)
posterior_sampling_rng = np.random.default_rng(20260425)
prob_b_better_sampling, samples_a, samples_b = estimate_probability_b_beats_a_by_sampling(
alpha_a=alpha_a,
beta_a=beta_a,
alpha_b=alpha_b,
beta_b=beta_b,
rng=posterior_sampling_rng,
)
prob_b_better_exact = exact_posterior_probability_b_beats_a(
n=nb_trials,
clicks_a=clicks_a,
clicks_b=clicks_b,
)
print(f"Observed A: {clicks_a}/{nb_trials} = {clicks_a / nb_trials:.3f}")
print(f"Observed B: {clicks_b}/{nb_trials} = {clicks_b / nb_trials:.3f}")
print(f"P(B > A | data), posterior sampling: {prob_b_better_sampling:.3f}")
print(f"P(B > A | data), exact Beta formula: {prob_b_better_exact:.3f}")
# Visualize P(B > A | data) as joint samples and sampled CTR differences.
visual_sampling_rng = np.random.default_rng(20260426)
nb_visual_samples = 5_000
visual_indices = visual_sampling_rng.choice(
len(samples_a),
size=nb_visual_samples,
replace=False,
)
visual_samples_a = samples_a[visual_indices]
visual_samples_b = samples_b[visual_indices]
visual_b_beats_a = visual_samples_b > visual_samples_a
# Use all paired posterior samples for a smoother posterior-difference histogram.
difference_samples = samples_b - samples_a
fig, axes = plt.subplots(
1,
3,
figsize=(12, 4),
gridspec_kw={"width_ratios": [1.0, 1.3, 0.6], "wspace": 0.28},
)
axis_min = 0.05
axis_max = 0.20
axis_min_b = 0.09
axis_max_b = 0.24
axes[0].scatter(
visual_samples_b[~visual_b_beats_a],
visual_samples_a[~visual_b_beats_a],
s=9,
alpha=0.22,
color="tab:gray",
label="A sample > B sample",
)
axes[0].scatter(
visual_samples_b[visual_b_beats_a],
visual_samples_a[visual_b_beats_a],
s=9,
alpha=0.22,
color="tab:green",
label="B sample > A sample",
)
axes[0].plot(
[axis_min_b, axis_max],
[axis_min_b, axis_max],
color="black",
linestyle="--",
linewidth=1.5,
label=r"$\theta_B = \theta_A$",
)
axes[0].set_xlim(axis_min_b, axis_max_b)
# Invert the A-axis so B-favoring samples appear in the upper-right region.
axes[0].set_ylim(axis_max, axis_min)
axes[0].set_aspect("equal", adjustable="box")
axes[0].set_anchor("E")
axes[0].set_title(r"Joint samples define $P(B > A \mid data)$")
axes[0].set_xlabel(r"sampled CTR $\theta_B$")
axes[0].set_ylabel(r"sampled CTR $\theta_A$")
axes[0].text(
0.04,
0.96,
rf"$P(B > A \mid data) \approx {prob_b_better_sampling:.3f}$",
transform=axes[0].transAxes,
va="top",
bbox={"boxstyle": "round,pad=0.35", "facecolor": "white", "alpha": 0.9},
)
axes[0].legend(loc="lower right")
difference_density, difference_edges = np.histogram(difference_samples, bins=70, density=True)
difference_centers = 0.5 * (difference_edges[:-1] + difference_edges[1:])
difference_widths = np.diff(difference_edges)
difference_colors = np.where(difference_centers > 0, "tab:green", "tab:gray")
axes[1].bar(
difference_centers,
difference_density,
width=difference_widths,
color=difference_colors,
alpha=0.55,
align="center",
)
axes[1].axvline(0, color="black", linestyle="--", linewidth=1.5)
axes[1].set_title(r"Sampled posterior difference distribution")
axes[1].set_xlabel(r"sampled difference $\Delta = \theta_B - \theta_A$")
axes[1].set_ylabel("posterior sample density")
axes[1].text(
0.04,
0.96,
rf"mass right of 0 $\approx {prob_b_better_sampling:.3f}$",
transform=axes[1].transAxes,
va="top",
bbox={"boxstyle": "round,pad=0.35", "facecolor": "white", "alpha": 0.9},
)
axes[1].legend(
handles=[
Patch(facecolor="tab:gray", alpha=0.55, label="A sample > B sample"),
Patch(facecolor="tab:green", alpha=0.55, label="B sample > A sample"),
],
loc="upper right",
)
event_probabilities = np.array([1.0 - prob_b_better_sampling, prob_b_better_sampling])
event_labels = [r"$B \leq A$", r"$B > A$"]
event_colors = ["tab:gray", "tab:green"]
axes[2].bar(event_labels, event_probabilities, color=event_colors, alpha=0.65)
axes[2].set_ylim(0.0, 1.05)
axes[2].set_title(r"Posterior probability of $B > A$")
axes[2].set_ylabel("posterior probability")
for event_index, probability in enumerate(event_probabilities):
axes[2].text(
event_index,
probability + 0.03,
f"{probability:.3f}",
ha="center",
va="bottom",
)
fig.suptitle(r"Visualizing $P(B > A \mid data)$ from posterior samples", y=0.98)
fig.subplots_adjust(top=0.82)
display_figure(
fig,
alt_text="Three views of paired posterior samples for variants A and B: their joint samples, the distribution of their difference, and the probability that B exceeds A.",
)
#
How the Posterior Tightens with More Data
In this post, the A/B test is a choose-once, fixed-policy problem, so we care most about the final posterior. It is still useful to watch the posterior change as data accumulates: the distributions narrow, uncertainty shrinks, and the overlap between A and B becomes easier to interpret.
The next cell runs a fresh, longer simulated experiment and looks at prefixes after 20, 100, 500, and 2,000 observations per variant. This illustrates how the posterior evolves in a new run; it is not a continuation of the toy experiment above.
# Explore how the posterior tightens with more data
checkpoints = [20, 100, 500, 2_000]
# Fresh simulated run used only to illustrate how the posterior tightens with more data.
posterior_tightening_rng = np.random.default_rng(20260425)
data_a = posterior_tightening_rng.binomial(n=1, p=true_rate_a, size=max(checkpoints))
data_b = posterior_tightening_rng.binomial(n=1, p=true_rate_b, size=max(checkpoints))
fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex=True)
for ax, n in zip(axes.ravel(), checkpoints, strict=False):
a_alpha, a_beta = beta_posterior_parameters(successes=int(data_a[:n].sum()), trials=n)
b_alpha, b_beta = beta_posterior_parameters(successes=int(data_b[:n].sum()), trials=n)
plot_beta_density(ax=ax, alpha=a_alpha, beta=a_beta, label=f"A after {n} observations", color="tab:blue")
plot_beta_density(ax=ax, alpha=b_alpha, beta=b_beta, label=f"B after {n} observations", color="tab:orange")
ax.axvline(true_rate_a, color="tab:blue", linestyle="--", alpha=0.6, label="true rate of A")
ax.axvline(true_rate_b, color="tab:orange", linestyle="--", alpha=0.6, label="true rate of B")
ax.set_xlim(0.0, 0.50)
ax.set_title(f"Posterior after {n} observations per variant")
ax.legend(loc="upper right")
plt.tight_layout()
display_figure(
fig,
alt_text="Beta posterior densities for variants A and B after increasing numbers of observations, showing uncertainty narrowing over time.",
)
#
How Much Evidence Do We Need?
In this post, we collect the data first and make one decision at the end, so the amount of data matters a lot. The previous section showed how the posterior distributions over $\theta_A$ and $\theta_B$ tighten as observations accumulate within one experiment, making the decision quantity $P(B > A \mid \mathrm{data})$ more decisive. Now we ask what happens across many repetitions of the same experiment.
If we repeated the same randomized A/B test many times, how often would our decision rule produce strong evidence in favor of variant B?
Under the assumed model, the heatmap below gives the exact answer to that question. We fix variant A's true CTR at 0.10 and set variant B's true CTR to 0.10 plus the absolute lift shown on the y-axis. For each combination of observations per variant (x-axis) and absolute lift (y-axis), we enumerate all possible observed click counts for variants A and B. For each possible outcome, we compute $P(B > A \mid \mathrm{data})$ and sum the probabilities of the outcomes for which this posterior probability exceeds 0.95.
Each cell gives the exact probability, under the assumed Binomial model, that an experiment with that sample size and true lift would produce strong evidence in favor of variant B, defined here as $P(B > A \mid \mathrm{data}) > 0.95$.
# Reuse exact P(B > A | data) and plot how often evidence clears a threshold.
def min_clicks_b_for_confident_win(
n: int,
threshold: float,
) -> IntArray:
"""Find the minimum B-click count needed to make P(B > A | data) exceed the threshold.
The threshold test uses the exact Beta-Beta posterior probability from
Evan Miller's Formulas for Bayesian A/B Testing:
https://www.evanmiller.org/bayesian-ab-testing.html .
"""
critical = np.full(n + 1, n + 1, dtype=int)
for clicks_a in range(n + 1):
low, high = 0, n
found = n + 1
while low <= high:
mid = (low + high) // 2
prob_b_better = exact_posterior_probability_b_beats_a(
n=n,
clicks_a=clicks_a,
clicks_b=mid,
)
if prob_b_better > threshold:
found = mid
high = mid - 1
else:
low = mid + 1
critical[clicks_a] = found
return critical
baseline_rate = 0.10
sample_sizes = [20, 50, 100, 250, 500, 1_000, 2_000]
lifts = [0.00, 0.01, 0.02, 0.03, 0.05]
posterior_threshold = 0.95
critical_clicks_by_sample_size = {
n: min_clicks_b_for_confident_win(n=n, threshold=posterior_threshold) for n in sample_sizes
}
heatmap_exact_repeated_experiments = np.zeros((len(lifts), len(sample_sizes)))
for i, lift in enumerate(lifts):
lifted_rate_b = baseline_rate + lift
for j, n in enumerate(sample_sizes):
clicks_a_values = np.arange(n + 1)
prob_clicks_a = stats.binom.pmf(k=clicks_a_values, n=n, p=baseline_rate)
critical_clicks_b = critical_clicks_by_sample_size[n]
prob_confident_b_given_clicks_a = np.array([
stats.binom.sf(k=critical - 1, n=n, p=lifted_rate_b) if critical <= n else 0.0
for critical in critical_clicks_b
])
heatmap_exact_repeated_experiments[i, j] = np.sum(
prob_clicks_a * prob_confident_b_given_clicks_a
)
fig = plt.figure(figsize=(10, 4))
sns.heatmap(
heatmap_exact_repeated_experiments,
annot=True,
fmt=".2f",
cmap="cividis",
xticklabels=sample_sizes,
yticklabels=[f"+{lift:.2f}" for lift in lifts],
)
plt.xlabel("observations per variant")
plt.ylabel("true absolute lift of B over A")
plt.title("Exact fraction of experiments where posterior P(B > A | data) > 0.95")
plt.tight_layout()
display_figure(
fig,
alt_text="Heatmap of the fraction of simulated experiments where the posterior probability that B beats A exceeds 0.95, by sample size and true lift.",
)
#
Why Random Assignment Matters
The calculations above rely on random assignment. Here is why that matters.
Random assignment gives the comparison a causal interpretation. In causal inference, it lets us treat showing A or B as an intervention rather than as a characteristic of the user. It also helps protect against confounding: differences in clicks are less likely to reflect pre-existing differences between the groups.
Short Note on Real-World Use
Real A/B tests are messier than this toy example. In practice, we need to consider data quality, sample-ratio mismatches, multiple metrics, stopping rules, business constraints, and how the change affects the people who experience it. We also need to ask whether the measured outcome is a good reward signal for the value we care about.
Summary
The toy A/B test maps to RL as follows:
- action: show either variant A or variant B,
- policy: a fixed randomized assignment rule, written as $\pi(a \mid s_0)$ in the one-state notation; randomization gives the comparison its causal interpretation,
- feedback and reward: an immediate click or no-click outcome; its expected value is the variant's CTR,
- state: the same simple state $s_0$ for every exposure; outcomes are modeled as independent given each variant's CTR,
- what we learn: posterior uncertainty about each variant's CTR,
- decision quantity: the posterior probability that variant B has the higher expected immediate reward, $P(\theta_B > \theta_A \mid \mathrm{data})$.
This post considers a fixed experiment: feedback is immediate, we collect the data first, and make one final ship decision without changing traffic allocation while the experiment runs. In the next post, the same posterior-probability idea becomes an adaptive action distribution as evidence arrives.
References
- Formulas for Bayesian A/B Testing, Evan Miller: compact reference for computing posterior probabilities in Bayesian A/B tests.
Further Reading
- Part 2: Bandits and Thompson Sampling, Peter Roelants: continues from fixed A/B testing to online learning and adaptive traffic allocation.
- Part 3: MENACE and Delayed Rewards, Peter Roelants: extends the online-learning view to state-dependent policies and delayed rewards.
- Beta distribution: Probabilities and binary data, Peter Roelants: background on why the Beta distribution is a natural model for uncertainty over probabilities.
- Introduction to Bayesian A/B testing, PyMC: a worked Bayesian A/B testing example using a probabilistic programming workflow.
- Bayesian A/B Testing at VWO, Chris Stucchio: practical discussion of Bayesian A/B testing, decision rules, and product-experimentation concerns.
# Print package versions used to execute this notebook.
print(f"Python: {platform.python_version()}")
for package in ["numpy", "scipy", "matplotlib", "seaborn"]:
print(f"{package}: {importlib.metadata.version(package)}")
#
This post is generated from an IPython notebook file. Link to the full IPython notebook file