언제든 멈출 수 있는 A/B 테스트의 수학: mSPRT로 피킹 문제를 해결하는 순차 가설 검정과 항상 유효한 추론(Always Valid Inference)
A/B 테스트를 실행하다 보면 한 번쯤 이런 유혹을 느낀다. "지금 결과가 꽤 좋아 보이는데, 벌써 멈춰도 되지 않을까?" 전통적인 통계 방법으로는 이 행동이 치명적이다. 미리 정한 표본 크기에 도달하기 전에 결과를 들여다보는 것 — 이를 피킹(Peeking)이라 한다 — 만으로도 1종 오류율(False Positive Rate)이 α=0.05에서 0.2 이상으로 급등할 수 있다. 아무 효과도 없는 변경이 "승자"로 선언되고, 실험 결과는 쓰레기가 된다.
그렇다면 언제든 들여다봐도 통계적으로 안전한 방법은 없을까? 있다. Optimizely, Netflix, Spotify, Amplitude 같은 빅테크가 이 문제를 해결하기 위해 채택한 방법이 바로 mSPRT(Mixture Sequential Probability Ratio Test)와 항상 유효한 추론(Always Valid Inference)이다. mSPRT는 검정 통계량이 마팅게일(Martingale) 성질을 만족하도록 설계되어, 몇 번을 들여다보든 언제 실험을 멈추든 1종 오류율이 α 이하임을 수학적으로 보장한다.
이 글에서는 mSPRT의 수학적 원리, Python 구현, 그리고 실무 적용 전략을 한 번에 익힌다. 읽고 나면 τ(사전분포 분산)를 어떻게 설정해야 검정력을 잃지 않는지, 왜 하한 임계값을 설정하지 않으면 실험이 끝나지 않는지, 마팅게일 성질이 어떻게 "언제 봐도 괜찮다"는 수학적 보장을 만들어내는지 이해하게 된다.
핵심 개념
SPRT: 순서대로 판단하는 순차 가설 검정
고정 표본 검정(t-test, z-test)은 실험 전에 표본 크기 n을 정하고, 데이터를 모두 모은 다음 단 한 번 판단한다. 반면 **SPRT(Sequential Probability Ratio Test)**는 Abraham Wald(1945)가 제안한 순차 가설 검정 방법으로, 데이터가 한 건씩 들어올 때마다 가능비(Likelihood Ratio)를 계산하고 세 가지 결론 중 하나를 즉시 내린다.
가능비(Likelihood Ratio):
$$\Lambda_n = \prod_{i=1}^{n} \frac{f_{\theta_1}(X_i)}{f_{\theta_0}(X_i)}$$
판정 규칙은 두 임계값 A(상한)와 B(하한)로 결정된다.
| 조건 | 결론 |
|---|---|
| $\Lambda_n \geq A$ | H₁ 채택 → 실험 종료 |
| $\Lambda_n \leq B$ | H₀ 채택 → 실험 종료 |
| $B < \Lambda_n < A$ | 데이터 추가 수집 |
Wald의 근사식으로 임계값을 계산한다.
- 상한: $A \approx \frac{1-\beta}{\alpha}$
- 하한: $B \approx \frac{\beta}{1-\alpha}$
예를 들어 α=0.05, β=0.20이면 A≈16, B≈0.211이다.
1종 오류(α): 실제로 차이가 없는데 "차이가 있다"고 잘못 판단할 확률. A/B 테스트에서 가짜 승자를 만들어내는 오류다.
2종 오류(β): 실제 차이가 있는데 "차이가 없다"고 놓치는 확률. 검정력(Power) = 1 − β.
고전 SPRT의 한계: 단일 효과 크기 가정
고전 SPRT는 "대안 가설이 딱 하나의 효과 크기(θ₁)를 가진다"는 단순 가설을 전제로 한다. 실제 A/B 테스트에서는 클릭률이 1% 오를지 3% 오를지 미리 알 수 없다. 이 가정이 깨지면 1종 오류 통제가 무너진다.
mSPRT: 대립가설에 분포를 입히다
mSPRT의 핵심 아이디어는 단일 θ₁ 대신, 대립가설의 모수 θ에 사전분포 π(θ)를 부여하고 가능비를 그 분포에 대해 **적분(평균화)**하는 것이다. Johari et al.(2015)이 온라인 A/B 테스트에 적용하면서 산업계 표준으로 자리잡았다.
혼합 가능비(Mixture Likelihood Ratio):
$$\tilde{\Lambda}n = \int \prod{i=1}^{n} \frac{f_{\theta}(X_i)}{f_{\theta_0}(X_i)} \pi(\theta) d\theta$$
수식이 복잡해 보이더라도 걱정하지 않아도 된다 — 이 적분의 결과가 바로 아래의 닫힌 형식 수식이며, 코드로 바로 건너뛰어도 구현을 이해하는 데 지장이 없다.
정규 데이터에 정규 사전분포(평균 0, 분산 τ²)를 사용하면 닫힌 형식(closed-form) 해가 존재한다.
$$\tilde{\Lambda}_n = \sqrt{\frac{2\sigma^2}{2\sigma^2 + n\tau^2}} \cdot \exp\left(\frac{n^2\tau^2(\bar{Y}_n - \bar{X}_n)^2}{4\sigma^2(2\sigma^2 + n\tau^2)}\right)$$
- σ²: 모분산 (알려진 값 또는 추정값)
- τ²: 사전분포의 분산 — 기대 효과 크기를 반영하는 핵심 파라미터
- n: 그룹당 관측 수
- $\bar{X}_n$, $\bar{Y}_n$: 대조군/처리군의 표본 평균
τ 설정이 결과에 미치는 영향: τ가 크면 더 넓은 범위의 효과 크기를 가능한 대립가설로 포함하므로, 검정이 보수적이 되어 확신하기까지 더 많은 데이터가 필요하다. 반대로 τ를 너무 작게 설정하면 실제 효과 크기가 τ보다 훨씬 클 때 검정력이 크게 손실된다. tau = sigma × 0.5가 실무에서 무난한 출발점이다.
Always Valid Inference: "언제 봐도 괜찮다"의 수학적 근거
마팅게일(Martingale): 혼합 가능비 $\tilde{\Lambda}_n$은 귀무가설 하에서 마팅게일 성질을 만족한다. 현재 값의 기댓값이 과거 값과 같다는 의미로, 이 성질로부터 Ville의 부등식이 따른다.
마팅게일 성질로부터 다음 부등식이 성립한다.
$$P_{H_0}\left(\sup_{n \geq 1} \tilde{\Lambda}_n \geq \frac{1}{\alpha}\right) \leq \alpha$$
귀무가설이 참일 때, 실험 기간 중 어느 시점에서든 혼합 가능비가 임계값 1/α를 넘을 확률은 α 이하다. 몇 번을 들여다봐도, 언제 멈춰도, 1종 오류율은 보장된다. 이것이 항상 유효한 추론(Always Valid Inference)이다.
실전 적용
예시 1: 사용자당 수익(Revenue per User) A/B 테스트 실시간 모니터링
사용자당 수익 개선 실험을 가정한다. 수익 데이터가 정규분포를 따른다고 가정하고 mSPRT로 실시간 판단을 구현한다.
주의: 이 구현은 정규분포를 가정하므로, 사용자당 수익·체류 시간처럼 연속형 데이터에 적합하다. 이진 전환율(0/1 클릭) 데이터에는 직접 적용하지 말 것.
import numpy as np
from dataclasses import dataclass
from typing import Literal
@dataclass
class SPRTResult:
n: int
lambda_mix: float
decision: Literal["reject H0", "accept H0", "continue"]
x_bar: float
y_bar: float
def compute_msprt(
x: np.ndarray,
y: np.ndarray,
sigma: float,
tau: float,
alpha: float = 0.05,
beta: float = 0.20,
) -> list[SPRTResult]:
"""
mSPRT 혼합 가능비를 순차적으로 계산한다.
Parameters
----------
x : 대조군 관측값 배열
y : 처리군 관측값 배열
sigma : 알려진(또는 추정된) 공통 표준편차
tau : 사전분포 표준편차 — 기대 효과 크기를 반영.
"효과 크기가 sigma의 절반 정도" → tau = sigma * 0.5
alpha : 목표 1종 오류율 (기본값 0.05)
beta : 목표 2종 오류율 (기본값 0.20, 검정력 80%)
"""
threshold = 1.0 / alpha # 상한: H₁ 채택 임계값
lower = beta / (1 - alpha) # 하한: H₀ 채택 임계값 (Wald 근사)
sigma2 = sigma ** 2
tau2 = tau ** 2
results = []
for n in range(1, len(x) + 1):
x_bar = np.mean(x[:n])
y_bar = np.mean(y[:n])
# 닫힌 형식 mSPRT (정규-정규 켤레)
denom = 2 * sigma2 + n * tau2
scale = np.sqrt(2 * sigma2 / denom)
exponent = (n ** 2 * tau2 * (y_bar - x_bar) ** 2) / (4 * sigma2 * denom)
lambda_mix = scale * np.exp(exponent)
if lambda_mix >= threshold:
decision = "reject H0"
elif lambda_mix <= lower:
decision = "accept H0"
else:
decision = "continue"
results.append(
SPRTResult(n=n, lambda_mix=lambda_mix,
decision=decision, x_bar=x_bar, y_bar=y_bar)
)
if decision != "continue":
break
return results
# --- 시뮬레이션 ---
# 정규분포 가정: 사용자당 수익(Revenue per User)처럼 연속형 데이터에 적합.
np.random.seed(42)
ctrl = np.random.normal(loc=0.0, scale=1.0, size=1000)
treat = np.random.normal(loc=0.3, scale=1.0, size=1000) # 실제 효과 크기 0.3
history = compute_msprt(ctrl, treat, sigma=1.0, tau=0.5, alpha=0.05, beta=0.20)
final = history[-1]
print(f"결정: {final.decision}")
print(f"관측 수: {final.n} / 1000")
print(f"혼합 가능비: {final.lambda_mix:.4f}")
print(f"대조군 평균: {final.x_bar:.4f}, 처리군 평균: {final.y_bar:.4f}")코드 핵심 설명:
| 변수/라인 | 역할 |
|---|---|
threshold = 1/alpha |
H₁ 채택 임계값. α=0.05면 20 |
lower = beta/(1-alpha) |
H₀ 채택 임계값. α=0.05, β=0.20이면 ≈ 0.211 (Wald 근사: β/(1−α)) |
scale |
사전분포 적분 결과의 정규화 인수. n이 커질수록 작아짐 |
exponent |
두 그룹 평균 차이를 증폭하는 항. 효과가 클수록 급격히 증가 |
조기 break |
결정이 나면 즉시 중단 — 표본 효율성의 핵심 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 표본 효율성 | 고정 표본 검정 대비 평균 40~60% 적은 데이터로 동일 검정력 달성 |
| 실시간 모니터링 | 피킹 문제 없이 언제든 결과 확인 가능 (Always Valid) |
| 조기 종료 | 효과가 명확하면 계획된 기간보다 일찍 실험 종료 가능 |
| 복합 가설 처리 | 단일 효과 크기가 아닌 분포 범위를 대립가설로 설정 가능 |
| 수학적 보장 | 마팅게일 성질로 1종 오류율이 수학적으로 항상 보장됨 |
단점 및 주의사항
피킹 문제(Peeking Problem): 고정 표본 검정에서 미리 정한 표본 크기 n에 도달하기 전에 중간 결과를 확인하고 유리할 때 실험을 중단하는 행위. 반복적인 중간 확인만으로도 실제 1종 오류율이 α=0.05에서 0.2 이상으로 급등할 수 있다. mSPRT는 이 문제를 수학적으로 해결한다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 실용적 유의성 미구분 | 통계적으로 유의하나 실무에서 무의미한 미세 효과도 검출 | Truncated mSPRT 적용 (arXiv 2509.07892) |
| τ 사전 지정 필요 | 사전분포 분산 τ²를 미리 설정해야 하며 결과가 민감하게 반응 | 파일럿 실험 데이터로 τ 추정 또는 보수적으로 크게 설정 |
| 분산 알려진 가정 | 기본 공식은 σ²를 알려진 것으로 가정해 실제 적용에 제약 | t-분포 기반 anytime-valid t-test 확장 (arXiv 2310.03722) |
| 다중 지표 처리 | 여러 지표 동시 검정 시 다중 비교 문제 발생 | e-value 곱셈 결합 또는 Bonferroni 보정 |
| 구현 복잡성 | 전통 검정 대비 개념 이해와 파라미터 설정 진입 장벽 높음 | GrowthBook 등 오픈소스 플랫폼으로 시작 |
실무에서 가장 흔한 실수
- τ를 너무 작게 설정하기: τ가 실제 효과 크기보다 훨씬 작으면 검정력이 크게 손실된다. τ를 너무 크게 잡으면 더 많은 데이터가 필요해지지만, 너무 작게 잡는 것보다는 안전하다. 파일럿 데이터 없이 시작한다면
tau = sigma * 0.5를 출발점으로 삼아라. - 하한 임계값(H₀ 채택)을 생략하기: H₁ 채택 임계값(상한)만 설정하고 H₀ 채택 임계값(하한)을 무시하면, 효과가 없는 실험이 영원히 종료되지 않는다.
lower = beta / (1 - alpha)설정은 선택이 아닌 필수다. - 배치 단위 검정에서 보수성 무시: 매 관측마다 검정하지 않고 하루 단위로 검정하면 실제 1종 오류율이 α보다 낮아져 검정력이 손실된다. 이는 안전하지만 비효율적이다. 실시간 업데이트가 가능하다면 최대한 활용하라.
확장 적용
예시 2: ML 모델 드리프트 실시간 감지
mSPRT는 A/B 테스트에 국한되지 않는다. 다음은 단순 가설(single-hypothesis) SPRT를 활용해 프로덕션 ML 모델의 데이터 드리프트를 실시간으로 감지하는 예시다. 예시 1의 mSPRT가 복합 대립가설(모수 분포 전체)을 다루는 것과 달리, 여기서는 "드리프트가 발생한 상태(μ₁)"라는 단일 가설을 사전에 지정한다는 점이 핵심 차이다.
import numpy as np
from scipy.stats import norm
def drift_detector_sprt(
reference_scores: np.ndarray,
stream_scores: np.ndarray,
alert_threshold: float = 20.0,
) -> dict:
"""
단순 가설 SPRT 기반 드리프트 탐지기.
⚠️ 예시 1의 mSPRT(복합 대립가설)와 달리,
여기서는 드리프트 방향과 크기를 사전에 지정한다.
reference_scores: 기준 모델 점수 (훈련/검증셋)
stream_scores : 실시간 유입 점수
"""
mu0 = np.mean(reference_scores)
sigma0 = np.std(reference_scores)
mu1 = mu0 - 0.5 * sigma0 # 드리프트 가설: 평균이 0.5 sigma 하락
log_lambda = 0.0 # 로그 가능비 누적
for i, score in enumerate(stream_scores, 1):
log_lr = norm.logpdf(score, mu1, sigma0) - norm.logpdf(score, mu0, sigma0)
log_lambda += log_lr
if log_lambda >= np.log(alert_threshold):
return {
"drift_detected": True,
"at_sample": i,
"log_lambda": round(log_lambda, 4),
}
if log_lambda <= -np.log(alert_threshold):
# 하한 도달 시 리셋.
# ⚠️ 이 리셋은 Page's CUSUM의 재시작 개념을 SPRT에 빌려온 혼용 방식이다.
# 순수 SPRT는 리셋을 가정하지 않으므로, 고전 SPRT의 1종 오류 보장이
# 이 코드에 그대로 적용된다고 오해해서는 안 된다.
log_lambda = 0.0
return {"drift_detected": False, "samples_checked": len(stream_scores)}
# 시뮬레이션: 500번째 이후 드리프트 발생
np.random.seed(7)
ref = np.random.normal(0.8, 0.1, 1000)
normal = np.random.normal(0.8, 0.1, 500)
drifted = np.random.normal(0.75, 0.1, 300) # 평균 하락
stream = np.concatenate([normal, drifted])
result = drift_detector_sprt(ref, stream)
print(result)
# 출력 예시 (시드 고정 기준, 실제 값은 실행 환경에 따라 다소 다를 수 있음):
# {'drift_detected': True, 'at_sample': 512, 'log_lambda': 3.04...}로그 가능비(Log-Likelihood Ratio) 사용: 부동소수점 언더플로를 방지하고 곱셈을 덧셈으로 변환해 수치 안정성을 높인다. 관측 수가 많을 때 필수 처리 기법이다.
마치며
이 글을 읽은 여러분은 세 가지를 알게 되었다. τ를 어떻게 설정해야 검정력을 지킬 수 있는지, 왜 하한 임계값(H₀ 채택 기준) 없이는 효과 없는 실험이 영원히 종료되지 않는지, 그리고 마팅게일 성질이 어떻게 "언제 봐도 괜찮다"는 수학적 보장을 만들어내는지. Optimizely, Netflix, Amplitude가 이 기법 위에서 실시간 실험을 운영하는 이유가 이 세 가지에 있다.
지금 바로 시작할 수 있는 3단계:
- 위 코드 블록을 그대로 복사해
tau=sigma*0.5,alpha=0.05,beta=0.20으로 현재 진행 중인 실험 데이터에 적용해 본다. PyPI의msprt패키지를 활용해도 되지만, 이 패키지의 API는 이 글의 코드와 다를 수 있으니 공식 문서를 먼저 확인한 후 사용하라. - GrowthBook 오픈소스를 로컬에 띄워 Sequential Testing 옵션을 켜고, 기존 고정 표본 결과와 나란히 비교한다.
- 더 깊이 들어가고 싶다면, Johari et al.(2015)의 원문 논문 "Always Valid Inference"를 읽고 자신의 실험 데이터로 시뮬레이션을 돌려 1종 오류율이 실제로 보장되는지 직접 검증한다.
다음 글: e-value와 e-process — mSPRT를 넘어, 이질적인 검정을 자유롭게 결합하는 현대 순차 추론의 일반 이론
참고 자료
- Always Valid Inference: Continuous Monitoring of A/B Tests | Johari et al., arXiv
- Sequential Probability Ratio Test: SPRT and Mixture SPRT (mSPRT) | Medium, Carey Chou
- Sequential Test for Practical Significance: Truncated mSPRT | arXiv 2509.07892
- Choosing a Sequential Testing Framework | Spotify Engineering
- Sequential Testing at Booking.com | Booking.ai
- Mastering the mSPRT for A/B Testing | HackerNoon
- Sequential Probability Ratio Test | Wikipedia
- expectation: Python library for e-values and sequential testing | GitHub
- GrowthBook Sequential Testing Docs
- Statsig Sequential Testing | Statsig Blog
- Hypothesis Testing with E-values | Ramdas & Wang, 2025
- Python msprt 패키지 | PyPI
- Anytime-valid t-tests and confidence sequences | arXiv 2310.03722
- Sequential Confidence Intervals for A/B Testing | MDPI Mathematics, 2025