1 왜 A/B 심화가 필요한가
06편 A/B 실험은 실험을 돌릴 수 있게 하는 운영 토대를 다뤘다 — YAML override, sticky hash, JSONL 수집, 기본 t-test/카이제곱. 그러나 운영 도구가 갖춰지면 곧 통계 질문이 따라온다.
| 06편이 답하는 것 | 22편이 답하는 것 |
|---|---|
| 어떻게 실험을 정의·할당·기록하는가 | 표본을 얼마나 모아야 검정력이 충분한가 |
| 어떤 검정을 기본 선택하는가 (t-test·카이제곱) | 메트릭 분포·연속성·종속성에 따라 어떤 검정을 골라야 하는가 |
| SRM이 무엇인가 | A/A test·Sequential Testing·Peeking 보정을 어떻게 운영에 녹이는가 |
| 결과 분석 API | 결과 해석 — 통계 유의 vs 실용 유의, 다중 비교 보정 |
운영 시스템이 잘 돌아가는데 결론이 틀린다면 통계 단계에서 새는 것이다. 본 편은 MINERVA 운영 가능한 의사결정 시스템으로 만드는 데 필요한 통계 방법을 다룬다.
2 실험 설계 7단계
- 비즈니스 질문 → 실험 가설로 변환
- 메트릭 선정 — North Star + Guardrail
- MDE(Minimum Detectable Effect) 결정 — “이만큼은 잡아내야 한다”
- 표본 크기 계산 — Power Analysis
- 할당 설계 — 비율·계층화·기간
- 실험 실행 — sticky 할당, SRM 모니터링
- 분석과 의사결정 — 검정 선택, 다중 비교 보정, 사후 분석
각 단계가 실패할 때 어떤 해석 오류로 이어지는지 본 편의 절들이 다룬다.
3 단계 1 — 가설 정의
# experiments/exp_001.yaml
hypothesis: |
reranker를 BGE-large로 교체하면 답변 정확도(thumbs-up rate)가
baseline 대비 +3pp 이상 개선된다.
metrics:
primary: thumbs_up_rate # 단일 의사결정 메트릭
guardrail: # 악화되면 안 되는 메트릭
- p95_latency_ms # 성능 회귀 방지
- cost_per_query_usd # 비용 폭증 방지
mde: 0.03 # +3pp 절대 차이
alpha: 0.05
power: 0.8Primary는 의사결정 메트릭 1개로 제한 — 여러 개로 바꾸면 다중 비교 문제. Guardrail은 “악화 안 됨”을 확인하는 메트릭으로 별도 보정 필요.
4 단계 2 — 표본 크기와 검정력
비율(thumbs_up_rate) 비교의 표본 크기 공식:
\[ n = \frac{(z_{\alpha/2} + z_{\beta})^2 \cdot (p_1(1-p_1) + p_2(1-p_2))}{(p_1 - p_2)^2} \]
| 기호 | 의미 | 일반 값 |
|---|---|---|
| \(\alpha\) | 1종 오류율 | 0.05 |
| \(\beta\) | 2종 오류율 | 0.2 (검정력 0.8) |
| \(z_{\alpha/2}\) | 1.96 (양측) | |
| \(z_{\beta}\) | 0.84 | |
| \(p_1, p_2\) | 두 그룹 비율 (baseline·treatment) | 0.55 → 0.58 |
| \(\Delta = p_1 - p_2\) | MDE | 0.03 |
# scripts/sample_size.py — Power Analysis
from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportion_effectsize
def required_sample_per_arm(p_baseline: float, mde: float,
alpha: float = 0.05, power: float = 0.8) -> int:
p_treatment = p_baseline + mde
h = proportion_effectsize(p_treatment, p_baseline) # Cohen's h
n = zt_ind_solve_power(effect_size=h, alpha=alpha, power=power, alternative="two-sided")
return int(n) + 1
# 예: baseline 55%, MDE +3pp → 그룹당 4,343
print(required_sample_per_arm(p_baseline=0.55, mde=0.03))표본이 부족한 상태에서 t-test로 p > 0.05가 나오면 “효과 없음”이라 결론짓는 실수가 흔하다. 정답은 결정 보류 — 효과가 없는 것이 아니라 있어도 못 잡는다.
실험 결과 해석 매트릭스:
| p < 0.05 (유의) | p >= 0.05 (비유의)
-----------------+----------------------------+--------------------------
검정력 충분 (≥0.8) | 효과 있음으로 결론 | 효과 없음으로 결론 가능
검정력 부족 | 효과 있다 — 단, replicate | 결정 보류 — 표본 더 모음
검정력 보고서는 결과 분석에 항상 동봉한다.
5 단계 3 — 메트릭 분포에 맞는 검정 선택
06편의 검정 선택 표를 확장한다.
연속형 메트릭 (latency, score)
├── 정규 + 등분산 → Student's t-test
├── 정규 + 이분산 → Welch's t-test (06편 기본)
└── 비정규 → Mann-Whitney U (위치 검정) 또는 bootstrap
이분 메트릭 (thumbs_up: 0/1)
├── 그룹별 표본 ≥ 30 → z-test (정규 근사)
├── 표본 작음 → Fisher's exact test
└── 매칭/계층화된 경우 → Mantel-Haenszel
카운트 메트릭 (queries_per_user)
├── 평균 비교 → Welch's t-test (보통 대용량이라 CLT 적용)
├── 분산 큰 long-tail → Mann-Whitney 또는 bootstrap
└── 비율 분모 명확 → Poisson rate ratio test
중복 측정 (한 사용자가 여러 query)
└── 마지막 N개로 평균 또는 cluster-robust SE
5.1 Welch vs Student — 등분산 가정의 위험
# 06편 기본은 Welch (등분산 가정 안 함)
import scipy.stats as stats
t_stat, p_value = stats.ttest_ind(group_a, group_b, equal_var=False) # Welch
t_stat, p_value = stats.ttest_ind(group_a, group_b, equal_var=True) # StudentWelch가 보수적이지만 분산이 다른 경우 Student는 거짓 양성률이 명목 5%를 벗어난다. MINERVA처럼 모델 변경이 분산 자체에 영향을 줄 수 있는 환경에서는 Welch 기본.
5.2 비정규 분포 — Mann-Whitney와 Bootstrap
LLM latency는 long-tail. 평균 비교가 의미 없거나 robust 검정이 필요.
# Mann-Whitney U — 분포 위치(중앙값 근사) 비교
u_stat, p_value = stats.mannwhitneyu(group_a, group_b, alternative="two-sided")
# Bootstrap — 임의 통계량의 신뢰구간
import numpy as np
def bootstrap_diff(group_a, group_b, n_iter=10_000, statistic=np.median):
diffs = []
for _ in range(n_iter):
a_sample = np.random.choice(group_a, size=len(group_a), replace=True)
b_sample = np.random.choice(group_b, size=len(group_b), replace=True)
diffs.append(statistic(b_sample) - statistic(a_sample))
return np.percentile(diffs, [2.5, 97.5])
ci_low, ci_high = bootstrap_diff(latency_a, latency_b, statistic=np.median)p-value보다 신뢰구간이 의사결정에 직접적이다 — “0이 포함되지 않으면 유의” + “구간 폭”으로 효과 크기까지 한 번에 본다.
6 단계 4 — 분산 감소 (CUPED)
같은 표본 크기로 검정력을 높이는 기법. 사용자의 사전 메트릭(실험 시작 전 행동)을 covariate로 활용해 분산을 줄인다.
\[ Y_{\text{adj}} = Y - \theta(X - \bar{X}) \]
여기서: - \(Y\): 실험 기간 메트릭 - \(X\): 사전 기간 메트릭 (같은 사용자, 같은 종류) - \(\theta = \mathrm{Cov}(X, Y) / \mathrm{Var}(X)\) - \(Y_{\text{adj}}\): 분산 감소된 메트릭
import numpy as np
def cuped_adjust(y: np.ndarray, x: np.ndarray) -> np.ndarray:
theta = np.cov(x, y, ddof=1)[0, 1] / np.var(x, ddof=1)
x_mean = x.mean()
return y - theta * (x - x_mean)
# MINERVA 적용 예
# x: 사용자의 실험 직전 4주 thumbs_up_rate (사전 메트릭)
# y: 실험 기간 thumbs_up_rate
y_adj_a = cuped_adjust(y_a, x_a)
y_adj_b = cuped_adjust(y_b, x_b)
t_stat, p_value = stats.ttest_ind(y_adj_a, y_adj_b, equal_var=False)
# ↑ 같은 표본인데 검정력이 보통 1.5~2× 증가CUPED는 사용자 행동이 시간적으로 안정(time-invariant)일 때 효과가 크다. MINERVA의 thumbs_up_rate·queries_per_user 같은 메트릭은 안정적이라 적용 가치가 있다.
7 단계 5 — 다중 비교 보정
여러 실험 또는 여러 메트릭을 동시에 검정하면 false positive가 누적된다.
| 메트릭 개수 m | 어느 하나라도 거짓 양성 확률 (α=0.05) |
|---|---|
| 1 | 0.05 |
| 5 | 0.226 |
| 10 | 0.401 |
| 20 | 0.642 |
7.1 FWER (Family-Wise Error Rate) — Bonferroni
7.2 FDR (False Discovery Rate) — Benjamini-Hochberg
표본 많고 메트릭 많을 때 FWER은 너무 보수적. 대안:
from statsmodels.stats.multitest import multipletests
p_values = [0.001, 0.012, 0.034, 0.045, 0.078]
reject, p_adj, _, _ = multipletests(p_values, alpha=0.05, method="fdr_bh")
# reject: [True, True, True, False, False]| 상황 | 권장 |
|---|---|
| Primary metric 1개 (의사결정 핵심) | 보정 안 함 (사전 정의된 단일 검정) |
| Guardrail 5개 (안 악화 확인) | Bonferroni — 거짓 양성 회피 |
| Exploratory 10~50개 (탐색 분석) | BH-FDR — 발견을 놓치지 않으면서 통제 |
7.3 Pre-registration
가장 강력한 다중 비교 방어는 사전 등록: 어떤 메트릭을 어떤 순서로 검정할지 실험 시작 전에 적어 둠. 사후에 메트릭을 골라 보고하는 것(HARKing)을 막는다.
8 단계 6 — Sequential Testing (조기 종료)
매일 결과를 확인하고 “유의” 시점에 종료하면 거짓 양성률이 명목 5%를 크게 초과한다 (peeking 문제).
해결책 — Sequential Testing:
# Alpha Spending — Pocock 또는 O'Brien-Fleming
# 매 중간 분석 시점마다 alpha를 계산해 더 엄격하게 비교
# 간단 구현: 5번 중간 분석, Pocock-style
# (실무는 statsmodels.stats.power.NormalIndPower + 라이브러리)
import numpy as np
def pocock_boundary(n_looks: int, alpha: float = 0.05) -> float:
# Pocock 1977 — 모든 look에서 동일 boundary
# 근사: alpha_per_look ≈ alpha / sqrt(n_looks)
return alpha / np.sqrt(n_looks)
# 예: 5번 look, total alpha 5%
# → 각 look에서 p < 0.0224일 때만 reject또는 Sequential Probability Ratio Test (SPRT) / always-valid p-values:
# 라이브러리 예 — `confseq` (Howard et al. 2021)
# from confseq.boundaries import betting_mart_seq_test
# always-valid p-value: 매 시점 확인해도 5% 거짓 양성 보장| 시나리오 | 권장 |
|---|---|
| 사전에 분석 시점 5~10개 결정 | Pocock 또는 OBF (statsmodels) |
| 매일 모니터링·실시간 종료 | Always-valid p-value (SAVI, Howard et al.) |
| 단일 사후 분석만 | 보정 불필요 (전통 t-test) |
MINERVA에서 매일 thumbs_up_rate를 보고 빠르게 종료하고 싶으면 always-valid 라이브러리 도입이 가치 있다.
9 단계 7 — A/A test로 시스템 검증
A/A: 같은 모델·같은 프롬프트로 두 그룹을 만들어 “차이 없음”을 검정. 거짓 양성률이 5%로 나오면 운영 시스템이 정상.
# experiments/aa_validate.py
import numpy as np
from scipy.stats import ttest_ind
n_trials = 1000
n_per_arm = 5000
false_positives = 0
for _ in range(n_trials):
# 같은 분포에서 두 sample
a = np.random.normal(0.55, 0.15, n_per_arm)
b = np.random.normal(0.55, 0.15, n_per_arm)
_, p = ttest_ind(a, b, equal_var=False)
if p < 0.05:
false_positives += 1
rate = false_positives / n_trials
assert 0.04 <= rate <= 0.06, f"A/A 실패 — 거짓 양성률 {rate}"A/A가 맞지 않으면 의심해야 할 곳: - 할당 시드 (sticky hash 충돌) - 메트릭 수집 race condition - 같은 사용자 양쪽에 들어감 (cross-contamination) - 분포 자체의 가정 위반 (정규성 X)
06편 SRM 검정이 할당 비율의 sanity check라면, A/A는 분석 파이프라인 전체의 sanity check다.
10 자주 발생하는 함정
10.1 Peeking — 매일 보고 유의 시 종료
# WRONG — 거짓 양성률 5%가 아니라 ~25%
for day in range(30):
p = run_test(data_until_day(day))
if p < 0.05:
ship_treatment() # 종료
break해법: Sequential Testing 또는 사전 결정된 종료 시점만.
10.2 HARKing — 결과 보고 가설 후위 결정
여러 메트릭 중 유의한 것만 골라 “이것이 핵심 메트릭이었다”고 보고. 사전 등록으로 방어.
10.3 Novelty Effect — 신규성 편향
새 UI/모델 도입 직후 사용자가 호기심으로 더 사용 → 단기 유의 → 장기 사라짐. 보통 2~4주 모니터링.
10.4 Simpson’s Paradox
전체에서는 treatment 우세인데 세그먼트별로는 모두 baseline 우세. 세그먼트 비율 차이로 발생. 세그먼트별 분석 + 가중 평균.
10.5 분산 감소 없이 표본 키우기만
CUPED 같은 분산 감소를 적용하면 표본을 1.5~2× 적게 모아도 같은 검정력. 무조건 표본을 늘리는 것보다 covariate 활용이 효율적.
11 MINERVA 적용 가이드
| 단계 | 06편 (운영) | 22편 (통계) |
|---|---|---|
| 가설 정의 | YAML override | hypothesis·MDE·primary/guardrail 명시 |
| 표본 크기 | (없음) | scripts/sample_size.py로 계산 |
| 할당 | sticky hash | A/A 검증 + SRM 모니터링 |
| 메트릭 수집 | JSONL | (없음 — 06편 그대로) |
| 검정 | t-test 기본 | 분포 따라 Welch·MWU·bootstrap |
| 다중 비교 | (없음) | Bonferroni (guardrail) + FDR (탐색) |
| 조기 종료 | (없음) | Pocock 또는 always-valid |
| 사후 분석 | (없음) | A/A·세그먼트별·CUPED 보정 결과 |
운영에서 추가 구현이 필요한 부분: - scripts/sample_size.py — 사전 표본 계산기 - scripts/aa_check.py — 분기마다 A/A run - app/analysis/cuped.py — CUPED 적용 분석기 - app/analysis/sequential.py — Pocock·OBF·always-valid 옵션
이들이 모두 11-0 환경변수·12-0 테스트 fixture 구조 위에 자연스럽게 얹힌다.
12 정리
| 영역 | 핵심 |
|---|---|
| 설계 | 가설 + MDE + primary 1개 + guardrail 사전 등록 |
| 표본 | Power Analysis (zt_ind_solve_power) — 검정력 0.8 표준 |
| 검정 선택 | 분포·이분산·종속성으로 결정 트리 |
| 분산 감소 | CUPED — 사전 메트릭으로 분산 절감 |
| 다중 비교 | Primary 보정 X, Guardrail Bonferroni, 탐색 FDR-BH |
| 조기 종료 | Pocock·OBF (사전 시점) 또는 always-valid (실시간) |
| 검증 | A/A test로 false positive 5% 확인 |
| 함정 | peeking·HARKing·novelty·Simpson’s paradox 사전 정의로 방어 |
13 응용 분야
| 시나리오 | 본 편의 어느 절 |
|---|---|
| 새 reranker 도입 (정확도 +3pp 가설) | 표본 크기 + Welch + guardrail Bonferroni |
| 새 LLM 모델 (latency 분포 변화) | Welch (이분산) + MWU 보조 + bootstrap CI |
| 매일 모니터링 + 빠른 결정 | Sequential (Pocock 5 looks) 또는 always-valid |
| 다양한 메트릭 탐색 | BH-FDR + 사전 등록은 primary만 |
| 사용자 사전 행동 covariate 보유 | CUPED로 표본 절감 |
| 운영 시스템 정합성 점검 | A/A test 분기마다 |
14 관련 주제
선행 학습 (선수)
- 06편 A/B 실험 운영 — YAML·sticky hash·JSONL (운영 토대)
- 12-0편 테스트 fixture — A/A 검증 자동화
- Engineering: 분산 계산 알고리즘 — Welford로 사전·실험 분산 동시 추적
후속 (Phase C-4 다음 편)
- C16 지능형 라우팅 — Thompson Sampling·Contextual Bandit (A/B에서 Multi-Armed Bandit로 전환)
- C17 사용자 세그멘테이션 — Simpson’s paradox 방어
- C18 개인화 전략 — 세그먼트별 처치
- C19 실험 파이프라인 자동화 — 가설→실험→분석 루프
Cross-reference
- 통계 검정 일반: Statistics 카테고리 가설검정 시리즈
- LangGraph 분기: 13편 LangGraph 기초 — A/B 분기를 conditional edge로