데이터가 정규분포인지 어떻게 아나 — 통계 검정 자동추천 만들기
프로테오믹스 데이터의 분포를 자동 진단하고 최적의 통계 검정 방법을 추천하는 stat_diagnostics.py 모듈을 개발한 경험을 공유한다.
어떤 통계 검정을 써야 하는가
BioAI Market에 프로테오믹스 분석 기능을 붙이면서, 사용자들에게 가장 많이 받은 질문이 있었다. "t-test를 쓰면 되나요, 아니면 Mann-Whitney를 써야 하나요?"
정답은 "데이터에 따라 다르다"인데, 이걸 사용자에게 판단하라고 하면 대부분 그냥 t-test를 선택한다. 하지만 데이터가 정규분포를 따르지 않으면 t-test의 결과를 신뢰할 수 없다. 특히 샘플 수가 적은 프로테오믹스 실험(n=3~5)에서는 이 문제가 더 심각하다.
그래서 stat_diagnostics.py 모듈을 만들었다. 데이터를 자동으로 진단하고, 적절한 통계 검정을 추천하는 시스템이다.
Shapiro-Wilk 정규성 검정
정규성을 판별하는 방법 중 가장 검증력이 높은 것이 Shapiro-Wilk 검정이다. 특히 소표본(n < 50)에서 강력하다.
from scipy import stats
import numpy as np
def test_normality(data: dict[str, np.ndarray]) -> dict:
"""각 그룹별 정규성 검정 수행"""
results = {}
all_normal = True
for group_name, values in data.items():
if len(values) < 3:
results[group_name] = {
'test': 'Shapiro-Wilk',
'statistic': None,
'p_value': None,
'is_normal': None,
'note': 'n < 3, 정규성 검정 불가'
}
continue
stat, p_value = stats.shapiro(values)
is_normal = p_value >= 0.05
if not is_normal:
all_normal = False
results[group_name] = {
'test': 'Shapiro-Wilk',
'statistic': round(stat, 4),
'p_value': round(p_value, 4),
'is_normal': is_normal
}
return {
'per_group': results,
'all_normal': all_normal
}
핵심 기준: p < 0.05이면 정규분포가 아니다. 정확히는 "정규분포라는 귀무가설을 기각한다"는 뜻이다.
실제 프로테오믹스 데이터로 테스트해보니, raw intensity 값은 대부분 비정규였다. 하지만 log2 변환 후에는 거의 정규분포에 근접했다. 이건 단백질 발현량의 분포가 log-normal을 따르기 때문이다.
# Raw intensity: Shapiro-Wilk p = 0.0002 (비정규)
# Log2 변환 후: Shapiro-Wilk p = 0.34 (정규)
raw_values = np.array([1200, 3400, 890, 45000, 2100])
log2_values = np.log2(raw_values)
print(stats.shapiro(raw_values)) # (0.7123, 0.0002)
print(stats.shapiro(log2_values)) # (0.9456, 0.3412)
Levene 등분산성 검정
정규성이 확인되면, 다음으로 등분산성을 확인해야 한다. 두 그룹의 분산이 같은지 확인하는 것이다.
def test_homogeneity(groups: list[np.ndarray]) -> dict:
"""Levene's test로 등분산성 검정"""
if len(groups) < 2:
return {'test': 'Levene', 'p_value': None, 'equal_variance': None}
stat, p_value = stats.levene(*groups)
return {
'test': 'Levene',
'statistic': round(stat, 4),
'p_value': round(p_value, 4),
'equal_variance': p_value >= 0.05
}
p < 0.05이면 분산이 다르다는 뜻이다. 이 경우 Student's t-test 대신 Welch's t-test를 써야 한다.
자동 추천 로직
정규성과 등분산성 검정 결과를 조합해서 최적의 통계 검정을 추천하는 로직을 만들었다:
def recommend_test(
normality: dict,
homogeneity: dict,
n_groups: int,
min_replicates: int,
r_available: bool = False
) -> dict:
"""데이터 진단 결과에 기반한 통계 검정 추천"""
all_normal = normality['all_normal']
equal_var = homogeneity.get('equal_variance', True)
# 3개 이상 그룹: ANOVA 계열
if n_groups >= 3:
if all_normal and equal_var:
return {
'recommended': 'One-way ANOVA + Tukey HSD',
'reason': '정규분포 + 등분산 + 3개 이상 그룹',
'alternatives': ['Kruskal-Wallis (비모수 대안)']
}
else:
return {
'recommended': 'Kruskal-Wallis + Dunn post-hoc',
'reason': '정규성/등분산성 미충족 + 3개 이상 그룹',
'alternatives': ['Welch ANOVA']
}
# R 사용 가능 + 충분한 replicates: limma
if r_available and min_replicates >= 3:
return {
'recommended': 'limma (moderated t-test)',
'reason': 'R 사용 가능, 소표본에서 가장 강력한 방법',
'alternatives': ['Welch t-test']
}
# 비정규: 비모수 검정
if not all_normal:
return {
'recommended': 'Mann-Whitney U test',
'reason': '정규분포 미충족',
'alternatives': ['Permutation test']
}
# 등분산 위반: Welch
if not equal_var:
return {
'recommended': "Welch's t-test",
'reason': '등분산성 미충족',
'alternatives': ['Mann-Whitney U']
}
# 기본값: Welch (Student's t보다 보수적이지만 안전)
return {
'recommended': "Welch's t-test",
'reason': '기본 추천 (Student t보다 보수적, 등분산 가정 불필요)',
'alternatives': ["Student's t-test"]
}
왜 기본값이 Welch인가? Student's t-test는 등분산을 가정하는데, 실제 데이터에서 등분산이 보장되는 경우가 드물다. Welch's t-test는 등분산을 가정하지 않으므로 더 보수적이지만 안전하다. 등분산이 실제로 성립하는 경우에도 Welch의 검정력 손실은 미미하다.
API 엔드포인트와 프론트엔드
이 진단 기능을 /analyze/diagnose API 엔드포인트로 노출했다:
@app.post("/analyze/diagnose")
async def diagnose_data(request: DiagnoseRequest):
data = parse_input(request.data, request.groups)
normality = test_normality(data)
groups = list(data.values())
homogeneity = test_homogeneity(groups)
recommendation = recommend_test(
normality=normality,
homogeneity=homogeneity,
n_groups=len(groups),
min_replicates=min(len(g) for g in groups),
r_available=request.r_available
)
return {
'normality': normality,
'homogeneity': homogeneity,
'recommendation': recommendation,
'sample_sizes': {k: len(v) for k, v in data.items()}
}
프론트엔드에서는 StatisticalDiagnostics React 컴포넌트로 시각화했다:
function StatisticalDiagnostics({ result }: { result: DiagnoseResult }) {
return (
<div className="space-y-4">
<h3 className="text-lg font-bold">📊 통계 진단 결과</h3>
{/* 정규성 검정 결과 */}
<div className="grid grid-cols-2 gap-2">
{Object.entries(result.normality.per_group).map(([group, res]) => (
<div key={group} className={`p-3 rounded ${
res.is_normal ? 'bg-green-50' : 'bg-red-50'
}`}>
<span className="font-medium">{group}</span>
<span className="ml-2">
{res.is_normal ? '✅ 정규' : '❌ 비정규'}
</span>
<span className="text-sm text-gray-500 ml-1">
(p = {res.p_value})
</span>
</div>
))}
</div>
{/* 추천 결과 */}
<div className="p-4 bg-blue-50 rounded">
<p className="font-bold">추천: {result.recommendation.recommended}</p>
<p className="text-sm text-gray-600">{result.recommendation.reason}</p>
</div>
</div>
);
}
사용자가 분석 옵션에서 **"Auto"**를 선택하면, 이 진단이 자동으로 돌아가고 추천된 검정 방법이 적용된다. 수동으로 변경할 수도 있지만, 대부분의 사용자는 Auto를 그대로 사용했다.
실제 데이터 테스트 결과
5개의 서로 다른 프로테오믹스 데이터셋으로 테스트한 결과:
| 데이터셋 | 정규성 (log2 후) | 등분산성 | 추천 검정 |
|---|---|---|---|
| DIA-NN set A | ✅ 정규 | ✅ 등분산 | limma |
| DIA-NN set B | ✅ 정규 | ❌ 이분산 | Welch |
| TMT set C | ❌ 비정규 | - | Mann-Whitney |
| Label-free D | ✅ 정규 | ✅ 등분산 | limma |
| SILAC set E | ✅ 정규 | ✅ 등분산 | limma |
5개 중 4개가 log2 변환 후 정규분포를 따랐다. 프로테오믹스 데이터의 log2 변환은 거의 필수라는 걸 데이터로 확인했다.
회고
통계 검정 자동추천 시스템을 만들면서 가장 많이 참고한 자료는 SciPy 공식 문서의 통계 모듈이었다. 그리고 limma 패키지 논문(Ritchie et al., 2015)은 소표본 프로테오믹스에서 왜 limma가 gold standard인지 이해하는 데 필수적이었다.
이 시스템 덕분에 사용자가 통계를 몰라도 적절한 검정을 적용할 수 있게 되었다. "어떤 검정을 쓸지 모르겠다"는 질문이 사라진 것이 가장 큰 성과였다.
💡 FDR 보정의 중요성에 대해서는 이전 글: DIA-NN 파이프라인 코드 리뷰에서 자세히 다루었다.
BioAI Market의 전체 분석 워크플로우는 sbmlab.com에서 확인할 수 있다.