가설 검정 (Hypothesis Testing)¶
표본 데이터를 바탕으로 모집단에 대한 가설을 검증하는 통계적 추론 방법. A/B 테스트, 모델 비교, 특성 선택 등 ML 전반에 활용됨.
왜 가설 검정이 필요한가¶
문제: 표본에서 관측된 차이가 "진짜"인가, 아니면 우연인가?
- 모델 A의 정확도 85%, 모델 B의 정확도 87% → B가 정말 더 좋은가?
- 새 기능 도입 후 전환율 3% 증가 → 실제 효과인가, 노이즈인가?
통계적 가설 검정은 이 질문에 확률적으로 답함.
가설 검정의 기본 개념¶
가설 설정¶
- 귀무 가설 (\(H_0\)): 효과가 없다, 차이가 없다 (기본 가정, "지루한" 가설)
- 대립 가설 (\(H_1\) 또는 \(H_a\)): 효과가 있다, 차이가 있다 (입증하려는 것)
예시: 새로운 모델이 기존 모델보다 성능이 좋은가?
H₀: μ_new = μ_old (차이 없음)
H₁: μ_new ≠ μ_old (양측 검정) 또는
H₁: μ_new > μ_old (단측 검정)
핵심 논리: 귀무 가설을 기각하거나, 기각하지 못함. "채택"이 아님!
오류 유형¶
| \(H_0\) 참 (효과 없음) | \(H_0\) 거짓 (효과 있음) | |
|---|---|---|
| \(H_0\) 기각 (효과 있다 판정) | Type I 오류 (α) | 올바른 결정 (Power) |
| \(H_0\) 기각 못함 | 올바른 결정 | Type II 오류 (β) |
- Type I 오류 (α): 없는 효과를 있다고 판단 (False Positive, 거짓 양성)
- Type II 오류 (β): 있는 효과를 놓침 (False Negative, 거짓 음성)
- 검정력 (Power): \(1 - \beta\) (실제 효과를 탐지할 확률)
트레이드오프: α를 낮추면 β가 높아지고, 그 반대도 마찬가지.
# 직관적 비유
# α = 무고한 사람을 유죄 판결 (억울한 처벌)
# β = 범인을 무죄 판결 (범인 놓침)
# 과학에서는 보통 α = 0.05 (Type I을 더 경계)
# 의료에서는 상황에 따라 β도 중요 (암 놓치면 안 됨)
검정 절차¶
1. 가설 설정 (H₀, H₁)
2. 유의수준 결정 (α, 보통 0.05)
3. 검정 통계량 계산 (t, z, χ², F 등)
4. p-value 계산 또는 기각역 확인
5. 결론 도출 (기각 or 기각 실패)
p-value 이해하기¶
정의¶
p-value: 귀무 가설이 참일 때, 관측된 결과 이상으로 극단적인 값이 나올 확률.
직관: "효과가 없다고 가정하면, 이런 결과가 나올 확률이 얼마나 되나?"
p-value 해석¶
def interpret_pvalue(p, alpha=0.05):
if p < alpha:
return f"p={p:.4f} < {alpha}: H₀ 기각 (통계적으로 유의함)"
else:
return f"p={p:.4f} >= {alpha}: H₀ 기각 실패 (유의하지 않음)"
# 주의: "기각 실패" ≠ "H₀가 참"
# 증거 부족일 뿐, H₀가 맞다는 증거가 아님
p-value의 흔한 오해 (중요!)¶
| 잘못된 해석 | 올바른 해석 |
|---|---|
| p = 0.03이면 H₀가 참일 확률이 3% | H₀가 참일 때 이런 극단적 데이터가 나올 확률이 3% |
| p = 0.03이면 효과가 97% 확률로 존재 | p-value는 효과 존재 확률이 아님 |
| p > 0.05면 효과가 없다 | 효과가 없다는 증거가 아님, 탐지 못했을 뿐 |
| p < 0.05면 효과가 크다 | 통계적 유의성 ≠ 실질적 중요성 |
# 예시: 표본이 매우 크면 사소한 차이도 "유의"함
import numpy as np
from scipy.stats import ttest_ind
# 두 그룹의 평균 차이가 0.01인 경우
np.random.seed(42)
# 작은 표본
n_small = 30
group1_small = np.random.normal(100, 10, n_small)
group2_small = np.random.normal(100.1, 10, n_small) # 차이 0.1
_, p_small = ttest_ind(group1_small, group2_small)
print(f"n={n_small}: p={p_small:.4f}")
# 큰 표본
n_large = 100000
group1_large = np.random.normal(100, 10, n_large)
group2_large = np.random.normal(100.1, 10, n_large) # 같은 차이 0.1
_, p_large = ttest_ind(group1_large, group2_large)
print(f"n={n_large}: p={p_large:.4f}")
# 같은 효과 크기인데 n이 크면 p가 작아짐!
# → 효과 크기(effect size)를 함께 보고해야 함
효과 크기 (Effect Size)¶
통계적 유의성과 별개로 효과의 크기를 측정.
def cohens_d(group1, group2):
"""Cohen's d: 표준화된 평균 차이"""
n1, n2 = len(group1), len(group2)
var1, var2 = np.var(group1, ddof=1), np.var(group2, ddof=1)
pooled_std = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2))
return (np.mean(group1) - np.mean(group2)) / pooled_std
# 해석 기준 (Cohen, 1988)
# |d| < 0.2: 작은 효과
# |d| ≈ 0.5: 중간 효과
# |d| > 0.8: 큰 효과
d = cohens_d(group1_large, group2_large)
print(f"Cohen's d: {d:.4f}") # 매우 작은 효과
평균 비교 검정¶
단일 표본 t-검정 (One-sample t-test)¶
표본 평균이 특정 값과 다른지 검정.
from scipy.stats import ttest_1samp
# 모델 정확도가 0.85보다 높은지 검정
accuracies = np.array([0.87, 0.88, 0.86, 0.89, 0.87, 0.90, 0.86])
t_stat, p_value = ttest_1samp(accuracies, 0.85)
print(f"t-statistic: {t_stat:.4f}")
print(f"p-value (양측): {p_value:.4f}")
# 단측 검정 (μ > 0.85)
p_value_one_sided = p_value / 2 if t_stat > 0 else 1 - p_value / 2
print(f"p-value (단측, >): {p_value_one_sided:.4f}")
# 신뢰구간
from scipy.stats import sem, t
n = len(accuracies)
mean = accuracies.mean()
se = sem(accuracies)
ci = t.interval(0.95, n-1, loc=mean, scale=se)
print(f"95% CI: [{ci[0]:.4f}, {ci[1]:.4f}]")
독립 표본 t-검정 (Independent two-sample t-test)¶
두 독립 그룹의 평균 차이 검정.
from scipy.stats import ttest_ind
# 모델 A vs 모델 B 성능 비교
model_a = np.array([0.85, 0.87, 0.86, 0.88, 0.84, 0.87])
model_b = np.array([0.82, 0.84, 0.83, 0.85, 0.81, 0.84])
# Welch's t-test (등분산 가정 안 함, 더 robust)
t_stat, p_value = ttest_ind(model_a, model_b, equal_var=False)
print(f"t-statistic: {t_stat:.4f}")
print(f"p-value: {p_value:.4f}")
print(f"Cohen's d: {cohens_d(model_a, model_b):.4f}")
등분산 가정 검정 (선택적):
from scipy.stats import levene
stat, p = levene(model_a, model_b)
print(f"Levene's test p-value: {p:.4f}")
# p > 0.05면 등분산 가정 괜찮음
# 하지만 Welch's t-test를 기본으로 쓰는 게 안전
대응 표본 t-검정 (Paired t-test)¶
동일 대상에서 두 조건의 차이 검정.
from scipy.stats import ttest_rel
# 동일 데이터셋에서 두 모델 비교 (paired)
# 예: 10개 데이터셋, 각각에서 두 모델 성능 측정
before = np.array([0.80, 0.82, 0.79, 0.83, 0.81, 0.85, 0.78, 0.84, 0.80, 0.82])
after = np.array([0.85, 0.87, 0.84, 0.88, 0.86, 0.89, 0.83, 0.88, 0.85, 0.87])
t_stat, p_value = ttest_rel(after, before)
print(f"t-statistic: {t_stat:.4f}")
print(f"p-value: {p_value:.4f}")
# 차이의 신뢰구간
diff = after - before
ci = t.interval(0.95, len(diff)-1, loc=diff.mean(), scale=sem(diff))
print(f"평균 차이: {diff.mean():.4f}")
print(f"95% CI for difference: [{ci[0]:.4f}, {ci[1]:.4f}]")
언제 paired test? - 같은 대상의 전후 비교 - 같은 데이터셋에서 다른 모델 비교 - 매칭된 쌍 비교
분산 분석 (ANOVA)¶
세 개 이상 그룹의 평균 비교. "어딘가에 차이가 있는가?"
일원 분산 분석 (One-way ANOVA)¶
from scipy.stats import f_oneway
# 세 가지 학습률에서의 성능 비교
lr_001 = [0.85, 0.86, 0.84, 0.87, 0.85]
lr_01 = [0.82, 0.83, 0.81, 0.84, 0.82]
lr_1 = [0.75, 0.76, 0.74, 0.77, 0.75]
f_stat, p_value = f_oneway(lr_001, lr_01, lr_1)
print(f"F-statistic: {f_stat:.4f}")
print(f"p-value: {p_value:.4f}")
# 효과 크기 (eta-squared)
groups = [lr_001, lr_01, lr_1]
all_data = np.concatenate(groups)
grand_mean = all_data.mean()
ss_between = sum(len(g) * (np.mean(g) - grand_mean)**2 for g in groups)
ss_total = sum((x - grand_mean)**2 for x in all_data)
eta_squared = ss_between / ss_total
print(f"η² (eta-squared): {eta_squared:.4f}")
# η² > 0.14: 큰 효과
ANOVA의 한계: "어딘가 다르다"만 알려줌. 어떤 그룹이 다른지는 사후 검정 필요.
사후 검정 (Post-hoc Tests)¶
from scipy.stats import tukey_hsd
import statsmodels.stats.multicomp as mc
# Tukey HSD
result = tukey_hsd(lr_001, lr_01, lr_1)
print(result)
# statsmodels 사용 (더 자세한 결과)
import pandas as pd
data = pd.DataFrame({
'score': lr_001 + lr_01 + lr_1,
'group': ['lr_001']*5 + ['lr_01']*5 + ['lr_1']*5
})
comp = mc.MultiComparison(data['score'], data['group'])
tukey_result = comp.tukeyhsd()
print(tukey_result)
# reject=True인 쌍만 유의한 차이
비모수 검정¶
분포 가정이 필요 없는 검정. 데이터가 정규성을 만족하지 않을 때.
Mann-Whitney U 검정¶
두 독립 그룹 비교 (t-검정의 비모수 대안).
from scipy.stats import mannwhitneyu
group1 = [85, 87, 86, 88, 84]
group2 = [82, 84, 83, 85, 81]
stat, p_value = mannwhitneyu(group1, group2, alternative='two-sided')
print(f"U statistic: {stat}")
print(f"p-value: {p_value:.4f}")
언제 사용? - 정규성 가정 위반 - 순서형 데이터 - 이상치가 있을 때
Wilcoxon 부호 순위 검정¶
대응 표본 비교 (paired t-test의 비모수 대안).
from scipy.stats import wilcoxon
before = [80, 82, 79, 83, 81]
after = [85, 87, 84, 88, 86]
stat, p_value = wilcoxon(after, before)
print(f"W statistic: {stat}")
print(f"p-value: {p_value:.4f}")
Kruskal-Wallis 검정¶
세 그룹 이상 비교 (ANOVA의 비모수 대안).
from scipy.stats import kruskal
stat, p_value = kruskal(lr_001, lr_01, lr_1)
print(f"H statistic: {stat}")
print(f"p-value: {p_value:.4f}")
검정 선택 가이드¶
범주형 데이터 검정¶
카이제곱 검정 (Chi-square Test)¶
적합도 검정: 관측 빈도가 기대 빈도와 일치하는가?
from scipy.stats import chisquare
# 예: 모델 예측이 세 클래스에 균등하게 분포하는가?
observed = [50, 30, 20] # 관측 빈도
expected = [33.3, 33.3, 33.3] # 기대 빈도 (균등분포)
stat, p_value = chisquare(observed, expected)
print(f"χ² statistic: {stat:.4f}")
print(f"p-value: {p_value:.4f}")
독립성 검정: 두 범주형 변수가 독립인가?
from scipy.stats import chi2_contingency
# 예: 모델 예측과 실제 레이블의 연관성 (혼동 행렬)
contingency_table = np.array([
[45, 5], # 실제 Positive
[10, 40] # 실제 Negative
])
chi2, p_value, dof, expected = chi2_contingency(contingency_table)
print(f"χ² statistic: {chi2:.4f}")
print(f"p-value: {p_value:.4f}")
print(f"Expected frequencies:\n{expected}")
Fisher의 정확 검정¶
소표본에서 카이제곱 대안. 2x2 표에 적합.
from scipy.stats import fisher_exact
# 예: 작은 표본에서 두 모델의 성공/실패 비교
table = [[8, 2], # Model A: 8 성공, 2 실패
[3, 7]] # Model B: 3 성공, 7 실패
odds_ratio, p_value = fisher_exact(table)
print(f"Odds ratio: {odds_ratio:.4f}")
print(f"p-value: {p_value:.4f}")
다중 비교 문제 (Multiple Testing)¶
여러 검정을 동시에 수행할 때 전체 오류율 증가.
문제 상황¶
# 10개 검정 수행 시 (각각 α=0.05):
# 적어도 하나의 False Positive 확률:
# 1 - (1-0.05)^10 ≈ 0.40
n_tests = 10
individual_alpha = 0.05
family_error_rate = 1 - (1 - individual_alpha)**n_tests
print(f"전체 Type I 오류율: {family_error_rate:.2%}")
# 100개 검정이면? ~99.4%로 거의 확실히 False Positive 발생
보정 방법¶
from statsmodels.stats.multitest import multipletests
# 여러 p-value
p_values = [0.01, 0.03, 0.04, 0.08, 0.15, 0.22, 0.45]
# Bonferroni 보정 (가장 보수적)
# adjusted p = original p * n
reject_bonf, pvals_bonf, _, _ = multipletests(p_values, method='bonferroni')
print("Bonferroni:")
print(f" adjusted p: {pvals_bonf.round(3)}")
print(f" reject: {reject_bonf}")
# Holm-Bonferroni (덜 보수적)
reject_holm, pvals_holm, _, _ = multipletests(p_values, method='holm')
print("\nHolm:")
print(f" adjusted p: {pvals_holm.round(3)}")
print(f" reject: {reject_holm}")
# Benjamini-Hochberg (FDR 제어)
reject_bh, pvals_bh, _, _ = multipletests(p_values, method='fdr_bh')
print("\nBenjamini-Hochberg:")
print(f" adjusted p: {pvals_bh.round(3)}")
print(f" reject: {reject_bh}")
| 방법 | 제어 목표 | 특징 |
|---|---|---|
| Bonferroni | FWER | 가장 보수적, α/m으로 조정 |
| Holm-Bonferroni | FWER | Bonferroni보다 검정력 높음 |
| Benjamini-Hochberg | FDR | 덜 보수적, 탐색적 분석에 적합 |
FWER: Family-Wise Error Rate (적어도 하나의 False Positive) FDR: False Discovery Rate (False Positive의 비율)
표본 크기와 검정력¶
검정력 분석 (Power Analysis)¶
from statsmodels.stats.power import TTestIndPower
analysis = TTestIndPower()
# 필요 표본 크기 계산
# effect_size: Cohen's d
# power: 1 - β (보통 0.8)
# alpha: 유의수준 (0.05)
n = analysis.solve_power(effect_size=0.5, power=0.8, alpha=0.05,
ratio=1, alternative='two-sided')
print(f"각 그룹당 필요 표본 크기: {int(np.ceil(n))}")
# 검정력 계산 (표본 크기가 주어졌을 때)
power = analysis.power(effect_size=0.5, nobs1=64, ratio=1, alpha=0.05)
print(f"검정력: {power:.4f}")
효과 크기별 필요 표본¶
effect_sizes = [0.2, 0.5, 0.8] # 작은, 중간, 큰 효과
for es in effect_sizes:
n = analysis.solve_power(effect_size=es, power=0.8, alpha=0.05)
print(f"d={es}: n={int(np.ceil(n))} per group")
A/B 테스트 표본 크기¶
from statsmodels.stats.proportion import proportion_effectsize
from statsmodels.stats.power import NormalIndPower
# 기준 전환율 10%, 탐지하고 싶은 차이 2%p (절대)
baseline = 0.10
mde = 0.02 # Minimum Detectable Effect
effect_size = proportion_effectsize(baseline, baseline + mde)
print(f"효과 크기 (h): {effect_size:.4f}")
analysis = NormalIndPower()
n = analysis.solve_power(effect_size=effect_size, power=0.8, alpha=0.05)
print(f"각 그룹당 표본 크기: {int(np.ceil(n))}")
ML에서의 응용¶
모델 비교: McNemar's Test¶
동일 데이터에서 두 분류기의 오류 패턴 비교.
from statsmodels.stats.contingency_tables import mcnemar
# 예측 결과 분할표
# Model B 정답 | Model B 오답
# Model A 정답 a=85 b=10
# Model A 오답 c=5 d=0
table = [[85, 10], [5, 0]]
result = mcnemar(table, exact=True)
print(f"p-value: {result.pvalue:.4f}")
# p < 0.05면 두 모델의 오류 패턴이 다름
# b와 c의 차이를 검정 (둘 다 맞거나 틀린 케이스는 무시)
교차 검증 결과 비교¶
from scipy.stats import ttest_rel
# 10-fold CV 결과
model_a_scores = [0.85, 0.87, 0.86, 0.88, 0.84, 0.86, 0.85, 0.87, 0.88, 0.86]
model_b_scores = [0.82, 0.84, 0.83, 0.85, 0.81, 0.83, 0.82, 0.84, 0.85, 0.83]
# Paired t-test (같은 fold에서 비교)
t_stat, p_value = ttest_rel(model_a_scores, model_b_scores)
print(f"p-value: {p_value:.4f}")
# 주의: fold 간 상관이 있으므로 가정 위반 가능
# → 더 엄밀하게는 corrected resampled t-test 사용
부트스트랩 신뢰구간¶
분포 가정 없이 신뢰구간 추정.
from scipy.stats import bootstrap
# 모델 성능의 신뢰구간
scores = np.array([0.85, 0.87, 0.86, 0.88, 0.84, 0.87, 0.86, 0.89, 0.85, 0.88])
result = bootstrap((scores,), np.mean, n_resamples=10000,
confidence_level=0.95, random_state=42)
ci = result.confidence_interval
print(f"95% CI: [{ci.low:.4f}, {ci.high:.4f}]")
# 수동 구현
def bootstrap_ci(data, stat_func=np.mean, n_bootstrap=10000, ci=0.95):
stats = []
n = len(data)
for _ in range(n_bootstrap):
sample = np.random.choice(data, size=n, replace=True)
stats.append(stat_func(sample))
alpha = 1 - ci
lower = np.percentile(stats, alpha/2 * 100)
upper = np.percentile(stats, (1 - alpha/2) * 100)
return lower, upper
print(f"수동 계산: {bootstrap_ci(scores)}")
순열 검정 (Permutation Test)¶
분포 가정 없는 가설 검정.
from scipy.stats import permutation_test
def statistic(x, y, axis):
return np.mean(x, axis=axis) - np.mean(y, axis=axis)
group1 = np.array([0.85, 0.87, 0.86, 0.88, 0.84])
group2 = np.array([0.82, 0.84, 0.83, 0.85, 0.81])
result = permutation_test((group1, group2), statistic, n_resamples=10000,
alternative='two-sided')
print(f"Permutation test p-value: {result.pvalue:.4f}")
흔한 실수와 오해¶
1. p-value를 "효과 존재 확률"로 해석¶
# 틀림: "p=0.03이므로 97% 확률로 효과가 있다"
# 맞음: "효과가 없다면, 이 정도 극단적 결과가 나올 확률이 3%"
# p-value는 P(Data | H0)이지, P(H0 | Data)가 아님!
# 후자를 원하면 베이지안 방법 사용
2. "유의하지 않음" = "효과 없음"¶
# 틀림: "p=0.15이므로 효과가 없다"
# 맞음: "효과가 있다는 충분한 증거를 찾지 못했다"
# 표본이 작으면 실제 효과도 탐지 못함 (낮은 검정력)
# "absence of evidence is not evidence of absence"
3. p-hacking¶
# 유의한 결과가 나올 때까지 다양한 분석 시도:
# - 변수를 이것저것 바꿔봄
# - 이상치 제거 기준을 조정
# - 여러 종속 변수 중 유의한 것만 보고
# - 결과 보고 "사후"에 가설 조정
# 해결책:
# - 사전 등록 (pre-registration)
# - 모든 분석 보고
# - 다중 비교 보정
4. HARKing (Hypothesizing After Results are Known)¶
# 데이터 분석 후 "가설"을 세운 척 하기
# 탐색적 분석은 괜찮지만, 확증적 분석인 것처럼 보고하면 안 됨
# 탐색적: "이런 패턴을 발견했다" (가설 생성)
# 확증적: "사전 가설을 검정했다" (가설 검증)
5. 실질적 유의성 무시¶
# n=100000에서 p<0.001이지만 d=0.05면?
# 통계적으로는 유의하지만, 실무적으로는 무의미
# 항상 효과 크기 + 신뢰구간 함께 보고
print("보고 예시:")
print("Model A가 Model B보다 유의하게 높은 정확도를 보였다")
print("(t(198)=2.5, p=0.013, d=0.35, 95% CI [0.005, 0.042])")
print("→ 통계적으로 유의하나, 효과 크기는 중간 수준이며")
print(" 실제 차이는 0.5%~4.2% 범위로 추정됨")
참고 자료¶
- SciPy Statistical Functions
- Statsmodels Documentation
- P-value Misconceptions (NCBI) - p-value 오해 정리
- Statistics Done Wrong - 통계적 오류 사례집
- Cohen, J. (1988). Statistical Power Analysis for the Behavioral Sciences
- ASA Statement on p-values - 미국통계학회 p-value 성명