Statistics

데이터가 정규분포인지 어떻게 아나 — 통계 검정 자동추천 만들기

프로테오믹스 데이터의 분포를 자동 진단하고 최적의 통계 검정 방법을 추천하는 stat_diagnostics.py 모듈을 개발한 경험을 공유한다.

·9 min read
#통계#정규분포#Shapiro-Wilk#프로테오믹스#자동추천#Python

Statistical distribution charts on a dashboard

어떤 통계 검정을 써야 하는가

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에서 확인할 수 있다.