Spaces:
Running
Running
File size: 5,370 Bytes
4256820 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | """
Bayesian A/B testing using conjugate priors.
Conversion rates β Beta-Binomial model (exact posterior)
Continuous metrics β Normal-Normal model (posterior on mean difference)
Key outputs:
- P(B > A) : probability treatment is better (decision metric)
- Expected loss : expected cost of the wrong decision (risk metric)
- Credible interval: Bayesian analogue of confidence interval
"""
from __future__ import annotations
import numpy as np
from dataclasses import dataclass, asdict
from typing import Tuple
@dataclass
class BayesianResult:
model: str
prob_b_beats_a: float # P(ΞΈ_B > ΞΈ_A | data)
expected_loss_choosing_b: float # E[max(ΞΈ_A - ΞΈ_B, 0)] β loss if B is wrong choice
expected_loss_choosing_a: float # E[max(ΞΈ_B - ΞΈ_A, 0)] β loss if A is wrong choice
ci_lower: float # 95 % credible interval for (ΞΈ_B - ΞΈ_A)
ci_upper: float
posterior_mean_a: float
posterior_mean_b: float
posterior_std_a: float
posterior_std_b: float
n_samples: int
def to_dict(self) -> dict:
return asdict(self)
# ββ Beta-Binomial (conversion rates) βββββββββββββββββββββββββββββββββββββββββ
def bayesian_proportion_test(
n_a: int, conv_a: int,
n_b: int, conv_b: int,
prior_alpha: float = 1.0, # Beta(Ξ±, Ξ²) prior β default Uniform
prior_beta: float = 1.0,
n_samples: int = 100_000,
seed: int = 42,
) -> BayesianResult:
"""
Bayesian A/B test for binary conversion rates.
Prior: ΞΈ ~ Beta(Ξ±β, Ξ²β)
Likelihood: X | ΞΈ ~ Binomial(n, ΞΈ)
Posterior: ΞΈ | X ~ Beta(Ξ±β + conv, Ξ²β + n - conv)
The Beta-Binomial is a conjugate model β no MCMC required.
"""
rng = np.random.default_rng(seed)
# Posterior parameters
a_a = prior_alpha + conv_a
b_a = prior_beta + (n_a - conv_a)
a_b = prior_alpha + conv_b
b_b = prior_beta + (n_b - conv_b)
# Monte-Carlo samples from posteriors
samples_a = rng.beta(a_a, b_a, n_samples)
samples_b = rng.beta(a_b, b_b, n_samples)
lift = samples_b - samples_a
prob_b_beats_a = float(np.mean(lift > 0))
# Expected loss (decision-theoretic risk)
# loss_B = cost of deploying B when A was better
# loss_A = cost of keeping A when B was better
expected_loss_b = float(np.mean(np.maximum(-lift, 0)))
expected_loss_a = float(np.mean(np.maximum(lift, 0)))
ci = (float(np.percentile(lift, 2.5)), float(np.percentile(lift, 97.5)))
# Posterior summary statistics (Beta moments)
mean_a = a_a / (a_a + b_a)
mean_b = a_b / (a_b + b_b)
std_a = np.sqrt(a_a * b_a / ((a_a + b_a) ** 2 * (a_a + b_a + 1)))
std_b = np.sqrt(a_b * b_b / ((a_b + b_b) ** 2 * (a_b + b_b + 1)))
return BayesianResult(
model="Beta-Binomial",
prob_b_beats_a=round(prob_b_beats_a, 5),
expected_loss_choosing_b=round(expected_loss_b, 6),
expected_loss_choosing_a=round(expected_loss_a, 6),
ci_lower=round(ci[0], 6),
ci_upper=round(ci[1], 6),
posterior_mean_a=round(float(mean_a), 6),
posterior_mean_b=round(float(mean_b), 6),
posterior_std_a=round(float(std_a), 6),
posterior_std_b=round(float(std_b), 6),
n_samples=n_samples,
)
# ββ Normal-Normal (continuous metrics, e.g. revenue) βββββββββββββββββββββββββ
def bayesian_mean_test(
mean_a: float, std_a: float, n_a: int,
mean_b: float, std_b: float, n_b: int,
prior_mean: float = 0.0,
prior_std: float = 1000.0, # diffuse prior
n_samples: int = 100_000,
seed: int = 42,
) -> BayesianResult:
"""
Bayesian test for difference in means with known-variance Normal model.
Prior: ΞΌ ~ N(ΞΌβ, ΟβΒ²)
Likelihood: XΜ | ΞΌ ~ N(ΞΌ, ΟΒ²/n) (known Ο approximated by sample std)
Posterior: ΞΌ | XΜ ~ N(ΞΌβ, ΟβΒ²) (standard conjugate update)
"""
rng = np.random.default_rng(seed)
def normal_posterior(obs_mean, obs_std, n):
prior_var = prior_std ** 2
obs_var = obs_std ** 2 / n
post_var = 1.0 / (1.0 / prior_var + 1.0 / obs_var)
post_mean = post_var * (prior_mean / prior_var + obs_mean / obs_var)
return post_mean, np.sqrt(post_var)
pm_a, ps_a = normal_posterior(mean_a, std_a, n_a)
pm_b, ps_b = normal_posterior(mean_b, std_b, n_b)
samples_a = rng.normal(pm_a, ps_a, n_samples)
samples_b = rng.normal(pm_b, ps_b, n_samples)
lift = samples_b - samples_a
prob_b_beats_a = float(np.mean(lift > 0))
expected_loss_b = float(np.mean(np.maximum(-lift, 0)))
expected_loss_a = float(np.mean(np.maximum(lift, 0)))
ci = (float(np.percentile(lift, 2.5)), float(np.percentile(lift, 97.5)))
return BayesianResult(
model="Normal-Normal",
prob_b_beats_a=round(prob_b_beats_a, 5),
expected_loss_choosing_b=round(expected_loss_b, 6),
expected_loss_choosing_a=round(expected_loss_a, 6),
ci_lower=round(ci[0], 4),
ci_upper=round(ci[1], 4),
posterior_mean_a=round(pm_a, 4),
posterior_mean_b=round(pm_b, 4),
posterior_std_a=round(ps_a, 6),
posterior_std_b=round(ps_b, 6),
n_samples=n_samples,
)
|