1 정의
하나의 실험 변수만 변경하고 나머지는 모두 고정한 상태에서, 두 변형(Control vs Treatment)의 성능 차이를 통계적으로 검정하는 실험이다.
- 실험 변수: 프롬프트, top-k, chunk size, 모델 등 하나만 변경
- 대조군(Control): 현재 운영 중인 구성
- 처치군(Treatment): 변경된 구성
- 목적: “이 변경이 메트릭을 유의하게 개선하는가?”에 답한다
- 역학: Randomized Controlled Trial (2-arm, 단일 개입)
- IT: A/B Test, Split Test
2 실험 설계 5단계
2.1 실험 변수 선정
원칙: 한 번에 하나의 변수만 바꾼다. 두 개 이상을 동시에 바꾸면 어느 변수가 효과의 원인인지 알 수 없다.
MINERVA QnA Chatbot 예시:
| 실험 | 변수 | Control | Treatment |
|---|---|---|---|
| 실험 1 | 시스템 프롬프트 | v1 (기본) | v2 (도메인 용어집 포함) |
| 실험 2 | top-k | k=5 | k=10 |
| 실험 3 | 검색 전략 | Hybrid(BM25+Vector) | Vector-only |
| 실험 4 | 청킹 | 512 tokens | 1024 tokens |
프롬프트도 바꾸고 top-k도 바꾸면, 성능이 개선되었을 때 프롬프트 때문인지 top-k 때문인지 분리할 수 없다. 이를 교란(confounding)이라 한다. 다중 변수를 동시에 실험하려면 다중 비교 실험 설계의 Factorial design을 사용해야 한다.
2.2 무작위 배정 단위 결정
Agent A/B에서 무엇을 기준으로 무작위 배정하는가는 핵심 설계 결정이다.
| 배정 단위 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Query-level | 각 질의를 독립적으로 A 또는 B에 배정 | 표본 크기 최대화, 빠른 수렴 | 같은 사용자가 A와 B를 번갈아 경험 → 혼란 |
| Session-level | 세션 단위로 배정. 한 세션 내 모든 질의는 같은 변형 | 일관된 사용자 경험 | 세션 수가 질의 수보다 적음 |
| User-level | 사용자 단위로 배정. 한 사용자는 항상 같은 변형 | 가장 깨끗한 설계, 사용자 간 비교 가능 | 가장 적은 표본, 수렴 느림 |
MINERVA 권장: 내부 Agent는 사용자 수가 제한적이므로 Query-level 배정을 기본으로 한다. 단, 멀티턴 대화에서 맥락 일관성이 중요한 경우 Session-level로 전환한다.
import hashlib
def assign_variant(unit_id: str, experiment_id: str, traffic_ratio: float = 0.5) -> str:
"""결정적 해싱 기반 변형 배정
동일한 unit_id + experiment_id 조합은 항상 같은 변형에 배정된다.
이를 통해 재현성을 보장한다.
Args:
unit_id: 배정 단위 ID (query_id, session_id, 또는 user_id)
experiment_id: 실험 식별자
traffic_ratio: Control 비율 (0.5 = 50:50)
"""
hash_input = f"{experiment_id}:{unit_id}"
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
bucket = (hash_value % 10000) / 10000 # 0~1 사이 값
return "control" if bucket < traffic_ratio else "treatment"2.3 가설과 메트릭 확정
실험 시작 전에 문서화한다. 이전 포스트의 메트릭 문서화 템플릿을 사용한다.
예시: 프롬프트 v2 실험
가설: 시스템 프롬프트에 도메인 용어집을 포함하면, 표준화 관련 질의에 대한
Relevance Score가 0.3점 이상 개선된다.
H₀: μ_treatment - μ_control = 0
H₁: μ_treatment - μ_control ≠ 0
1차 메트릭: Relevance Score (LLM-as-Judge, 1-5)
2차 메트릭: Faithfulness Score, Hit Rate@5
가드레일: Hallucination Rate < 10%, Error Rate < 2%
유의 수준: α = 0.05
검정력: 1-β = 0.80
MDE: 0.3점 (5점 척도 기준)
2.4 트래픽 분할과 실험 기간
from statsmodels.stats.power import TTestIndPower
def calculate_sample_size(
effect_size: float,
alpha: float = 0.05,
power: float = 0.80,
ratio: float = 1.0 # treatment/control 비율
) -> int:
"""독립 2표본 t-검정 기반 표본 크기 계산"""
analysis = TTestIndPower()
n = analysis.solve_power(
effect_size=effect_size,
alpha=alpha,
power=power,
ratio=ratio,
alternative="two-sided"
)
return int(np.ceil(n))
# 예시: Relevance Score (1-5, σ≈1.0), MDE=0.3
# Cohen's d = 0.3 / 1.0 = 0.3
n_per_group = calculate_sample_size(effect_size=0.3)
# → 약 176 질의/그룹
# MINERVA QnA Chatbot: 일일 ~50건 질의 가정
# 50:50 분할 → 그룹당 25건/일
# 필요 기간: 176 / 25 ≈ 8일일일 50건으로도 부족하면:
- MDE를 키운다: 0.3 → 0.5 (큰 효과만 감지). n이 줄어든다
- 분산 감소: 질의 유형별 층화(stratification)로 분산을 줄인다
- 오프라인 스크리닝: 오프라인에서 명확한 후보만 온라인으로 보낸다
- Sequential testing: 중간에 조기 종료할 수 있게 설계한다 → Sequential Testing 포스트
2.5 실험 실행과 모니터링
import pandas as pd
import numpy as np
from scipy import stats
class SimpleABExperiment:
"""단순 A/B 실험 실행 및 모니터링"""
def __init__(self, experiment_id: str, config_control: dict, config_treatment: dict):
self.experiment_id = experiment_id
self.configs = {"control": config_control, "treatment": config_treatment}
self.results = []
def run_query(self, query: str, query_id: str) -> dict:
"""단일 질의 실행: 변형 배정 → Agent 실행 → 결과 기록"""
variant = assign_variant(query_id, self.experiment_id)
config = self.configs[variant]
# Agent 실행
response = run_agent(config, query)
# 자동 평가
scores = auto_evaluate(query, response)
result = {
"query_id": query_id,
"variant": variant,
"query": query,
"response": response["answer"],
"relevance": scores["relevance"],
"faithfulness": scores["faithfulness"],
"latency_ms": response["latency_ms"],
"timestamp": pd.Timestamp.now()
}
self.results.append(result)
return result
def get_interim_report(self) -> dict:
"""중간 결과 리포트 (실험 중 모니터링용)"""
df = pd.DataFrame(self.results)
report = {}
for metric in ["relevance", "faithfulness", "latency_ms"]:
ctrl = df[df["variant"] == "control"][metric]
treat = df[df["variant"] == "treatment"][metric]
report[metric] = {
"control_mean": ctrl.mean(),
"treatment_mean": treat.mean(),
"diff": treat.mean() - ctrl.mean(),
"control_n": len(ctrl),
"treatment_n": len(treat),
}
# 가드레일 체크
treat_df = df[df["variant"] == "treatment"]
report["guardrail"] = {
"hallucination_rate": (treat_df["faithfulness"] <= 2).mean(),
"error_rate": (treat_df["latency_ms"] < 0).mean(), # 오류 = 음수 플래그
}
return report3 결과 검정
실험이 목표 표본 크기에 도달하면, 사전 정의한 방법으로 검정한다.
결과 검정의 타당성은 다음 가정에 의존한다:
- SUTVA (Stable Unit Treatment Value Assumption): 한 질의의 배정이 다른 질의의 결과에 영향을 주지 않는다. Agent 실험에서는 같은 사용자가 Control과 Treatment를 번갈아 경험할 때 학습 효과(carryover)가 발생할 수 있어 위반될 수 있다. 직관적으로, SUTVA는 “옆 테이블 손님의 주문이 내 음식 맛에 영향을 주지 않는다”는 가정이다. 그런데 Agent 실험에서는 같은 사용자가 Treatment의 좋은 응답을 본 뒤 Control의 응답에 더 불만을 느낄 수 있다 — 옆 테이블의 음식을 맛본 뒤 내 음식이 더 별로 느껴지는 것과 같다. Query-level 배정에서 이 위험이 가장 크고, User-level 배정에서 가장 작다.
- Ignorability (무작위 배정): 결정적 해싱(deterministic hashing)이 올바르게 구현되어 배정이 결과와 독립이다. SRM 검정으로 이를 확인한다. 비유하면, 동전 던지기가 공정한지 확인하는 것이다. 해싱 함수에 버그가 있어 특정 질의 유형이 한쪽에 몰리면, 동전이 기울어진 상태에서 실험한 것과 같다.
- Positivity: 모든 질의 유형이 두 변형 모두에 배정될 양의 확률을 가진다. 특정 질의 유형이 한쪽에만 배정되면 비교 자체가 불가하다. 예를 들어, “코드 분석” 질의가 전부 Treatment에만 배정되면, Treatment의 낮은 점수가 처치 효과인지 코드 질의의 본래 난이도인지 분리할 수 없다.
3.1 연속형 메트릭 (Relevance Score 등)
def test_continuous_metric(control_scores, treatment_scores, alpha=0.05):
"""독립 2표본 t-검정 (Welch's t-test)"""
stat, p_value = stats.ttest_ind(
treatment_scores, control_scores,
equal_var=False, # Welch's t-test: 등분산 가정 안 함
alternative="two-sided"
)
diff = np.mean(treatment_scores) - np.mean(control_scores)
# Cohen's d
pooled_std = np.sqrt(
(np.std(control_scores, ddof=1)**2 + np.std(treatment_scores, ddof=1)**2) / 2
)
cohens_d = diff / pooled_std
return {
"diff": diff,
"cohens_d": cohens_d,
"p_value": p_value,
"significant": p_value < alpha,
"ci_95": (
diff - 1.96 * pooled_std * np.sqrt(1/len(control_scores) + 1/len(treatment_scores)),
diff + 1.96 * pooled_std * np.sqrt(1/len(control_scores) + 1/len(treatment_scores))
)
}3.2 이진형 메트릭 (Hit Rate, Error Rate 등)
from statsmodels.stats.proportion import proportions_ztest
def test_binary_metric(control_hits, control_n, treatment_hits, treatment_n, alpha=0.05):
"""2표본 비율 검정"""
stat, p_value = proportions_ztest(
count=[treatment_hits, control_hits],
nobs=[treatment_n, control_n],
alternative="two-sided"
)
p_ctrl = control_hits / control_n
p_treat = treatment_hits / treatment_n
diff = p_treat - p_ctrl
return {
"control_rate": p_ctrl,
"treatment_rate": p_treat,
"diff": diff,
"relative_lift": diff / p_ctrl if p_ctrl > 0 else float("inf"),
"p_value": p_value,
"significant": p_value < alpha,
}4 주의사항
4.1 Peeking Problem
실험 도중 결과를 확인하고 “유의하니까 중단하자”고 결정하면 1종 오류(false positive)가 증가한다. 고정 표본 설계에서는 목표 표본 수에 도달할 때까지 결과로 의사결정하지 않는다.
중간 확인이 필요하면 Sequential Testing을 사용한다.
4.2 SRM (Sample Ratio Mismatch)
50:50으로 배정했는데 실제 수집된 데이터가 55:45라면, 배정 메커니즘이나 로깅에 문제가 있는 것이다. 결과를 해석하기 전에 반드시 SRM 검정을 먼저 수행한다.
from scipy.stats import chisquare
def check_srm(n_control, n_treatment, expected_ratio=0.5):
"""Sample Ratio Mismatch 검정"""
total = n_control + n_treatment
expected = [total * expected_ratio, total * (1 - expected_ratio)]
observed = [n_control, n_treatment]
stat, p_value = chisquare(observed, expected)
return {
"actual_ratio": n_control / total,
"expected_ratio": expected_ratio,
"p_value": p_value,
"srm_detected": p_value < 0.01, # 매우 보수적 기준
"action": "실험 중단 후 원인 조사" if p_value < 0.01 else "정상"
}5 관련 주제
선행 지식
- Agent 실험 메트릭 설계 — 무엇을 측정할 것인가
- A/B 테스트의 핵심 메커니즘 — 무작위 배정, 가설 검정 기본
시리즈 다음 포스트
- 표본 크기와 검정력 — 적은 트래픽에서의 검정력 확보 전략
- Sequential Testing과 조기 종료 — 중간 확인이 필요할 때
다른 카테고리 연결
- RCT와 A/B 테스트의 설계 원칙 — 무작위 배정의 이론적 기반
- 가설 검정 — 1종/2종 오류, 검정력의 통계적 기초