사후비교: Tukey HSD¶
1. 일원배치 분산분석의 사후검정¶
일원배치 분산분석을 수행하면 집단 평균 사이에 유의한 차이가 있음을 알게 될 수 있다. 그러나 분산분석의 유의한 결과는 구체적으로 어느 집단이 서로 다른지는 알려주지 않는다. 이때 유의하게 다른 집단 쌍을 찾아내는 데 사후검정을 쓴다. 이 검정들은 여러 비교를 할 때 제1종 오류(거짓 양성)를 통제하도록 돕는다.
A. 일원배치 분산분석의 사후검정 이해하기¶
일원배치 분산분석에서 사후검정은 다음일 때 수행한다:
- 일원배치 분산분석이 집단 평균 사이에 통계적으로 유의한 차이를 나타냈을 때.
- 구체적으로 어느 집단이 서로 다른지 알고자 할 때.
B. 일원배치 분산분석의 사후검정 종류¶
일원배치 분산분석에서 가장 흔한 사후검정은 다음과 같다:
- Tukey의 정직유의차(HSD): 가족단위 오류율을 통제하는 널리 쓰이는 방법. 집단 크기가 같을 때 적합하지만 약간의 불균형에도 쓸 수 있다.
- Bonferroni 보정: 유의수준을 비교 횟수로 나누는 보수적인 접근. 비교 횟수가 적거나 엄격한 오류 통제가 필요할 때 적합하다.
- Scheffé 검정: 유연하고 보수적인 검정으로, 쌍별 비교를 넘어선 복잡한 비교를 검정할 때 특히 유용하다.
- Dunnett 검정: 여러 처치군을 하나의 대조군과 비교할 때 쓴다.
C. Python으로 사후검정 수행하기¶
1단계: 일원배치 분산분석 수행¶
셋 이상의 집단이 있는 자료에서 평균 사이에 유의한 차이가 있는지 검정한다고 하자.
보기 1. 1단계 — 일원배치 분산분석. 전역 \(F\) 가 사실은 쌍별 차이들의 평균임을 보이면, 왜 사후비교가 필요한지가 분명해진다.
(1) 균형설계(\(n_i = n\))에서
임을 보이시오.
(2) 이 식에서 전역 \(F\) 가 유의해도 어느 쌍이 다른지는 알 수 없는 까닭을 설명하시오.
(3) PlantGrowth 자료에서 두 꼴이 같은 값을 주는지 확인하고, 세 쌍이 SSB 에 기여하는 몫을 구하시오.
풀이
(1) 해석적으로. 일반성을 잃지 않고 \(\bar y = 0\) 이 되도록 평행이동하자(두 변 모두 차이에만 의존하므로 값이 바뀌지 않는다). 그러면 \(\sum_i \bar y_i = 0\) 이고
이다. 세 덩어리를 따로 세면 \(\sum_i\sum_j \bar y_i^2 = k\sum_i \bar y_i^2\), \(\sum_i\sum_j \bar y_j^2 = k\sum_j \bar y_j^2\), 그리고 교차항은 \(-2\left(\sum_i \bar y_i\right)\left(\sum_j \bar y_j\right) = 0\) 이므로
다. 양변에 \(n/k\) 를 곱하면 구하는 식을 얻는다. \(\square\)
(2) 해석적으로. 이 항등식이 말하는 바는 분명하다. SSB 는 \(\binom{k}{2}\) 개 쌍별 제곱차의 합을 \(k\) 로 나눈 것이며, 어느 쌍이 얼마를 냈는지는 합산 과정에서 지워진다. 그러므로 같은 SSB 를 주는 자료가 여럿 있다.
- 한 쌍만 크게 다르고 나머지는 같은 경우
- 모든 쌍이 조금씩 다른 경우
두 경우가 같은 \(F\) 를 줄 수 있다. 전역 검정이 "\(\mu_1 = \cdots = \mu_k\) 가 거짓"까지만 말하고 멈추는 것은 통계량의 성질이 아니라 가설의 성질이다. 그 부정은 "적어도 한 쌍이 다르다"일 뿐이다. 어느 쌍인지 알려면 \(\binom{k}{2}\) 개의 질문을 따로 물어야 하고, 그 순간 다중비교 문제가 생긴다. 이 쪽 전체가 그 문제를 다룬다.
(3) 수치적으로. 먼저 분산분석을 돌린다.
import pandas as pd
from statsmodels.formula.api import ols
from statsmodels.stats.anova import anova_lm
# R 의 PlantGrowth 자료. 대조군과 처리군 둘, 모두 세 집단이다.
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
# 분산분석이 먼저다. 여기서 유의하지 않으면 사후비교로 넘어갈 까닭이 없다.
model = ols('weight ~ C(group)', data=df).fit()
anova_results = anova_lm(model)
print("One-Way ANOVA Results:")
print(anova_results)
출력:
One-Way ANOVA Results:
df sum_sq mean_sq F PR(>F)
C(group) 2.0 3.76634 1.883170 4.846088 0.01591
Residual 27.0 10.49209 0.388596 NaN NaN
이제 두 꼴을 직접 맞춰 본다.
import numpy as np
import pandas as pd
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
n, k = 10, 3
g = df.groupby('group').weight
m, s = g.mean().values, g.std().values
print(f"집단평균 = {np.round(m, 4)}, 집단표준편차 = {np.round(s, 6)}")
print(f"전체평균 = {df.weight.mean():.6f}")
SSB_dev = n * ((m - m.mean()) ** 2).sum()
SSB_pair = (n / k) * sum((m[i] - m[j]) ** 2 for i in range(k) for j in range(i + 1, k))
MSE = (s ** 2).mean()
print(f"\nSSB (편차 꼴) = {SSB_dev:.6f}")
print(f"SSB (쌍 꼴) = {SSB_pair:.6f}")
print(f"MSE = 집단분산의 평균 = {MSE:.6f}")
print(f"F = (SSB/{k - 1})/MSE = {(SSB_dev / (k - 1)) / MSE:.6f}")
print(f"\n쌍별 제곱차가 SSB 에 기여하는 몫")
labels = ['ctrl', 'trt1', 'trt2']
for i in range(k):
for j in range(i + 1, k):
d2 = (m[i] - m[j]) ** 2
print(f" {labels[i]}-{labels[j]}: (차)^2 = {d2:.6f}, 몫 = {(n / k) * d2 / SSB_dev:.1%}")
출력:
집단평균 = [5.032 4.661 5.526], 집단표준편차 = [0.583091 0.793676 0.442573]
전체평균 = 5.073000
SSB (편차 꼴) = 3.766340
SSB (쌍 꼴) = 3.766340
MSE = 집단분산의 평균 = 0.388596
F = (SSB/2)/MSE = 4.846088
쌍별 제곱차가 SSB 에 기여하는 몫
ctrl-trt1: (차)^2 = 0.137641, 몫 = 12.2%
ctrl-trt2: (차)^2 = 0.244036, 몫 = 21.6%
trt1-trt2: (차)^2 = 0.748225, 몫 = 66.2%
두 꼴이 소수점 여섯째 자리까지 \(3.766340\) 으로 같고, 이것이 anova_lm 의 sum_sq 와도 같다. MSE \(= 0.388596\) 도 집단분산 셋의 단순평균으로 나오고 \(F = 4.846088\) 이 표와 일치한다. 집단별 요약 여섯 개만으로 분산분석표가 재구성된다.
쌍별 몫이 (2)의 요점을 수로 보여 준다. trt1–trt2 가 \(66.2\%\) 를 내고 나머지 둘이 \(12.2\%\) 와 \(21.6\%\) 를 낸다. 곧 \(F = 4.85\) 라는 한 숫자의 삼분의 이가 한 쌍에서 온 것인데, 분산분석표의 어느 칸에도 그 사실이 적혀 있지 않다. 보기 2의 Tukey HSD 가 이 분해를 검정의 꼴로 다시 적는 일을 한다.
덧붙여, 몫이 셋으로 고르게 나뉘었다면 어땠을지 생각해 보라. 평균이 등간격 \(\mu, \mu+\delta, \mu+2\delta\) 이면 제곱차가 \(\delta^2, \delta^2, 4\delta^2\) 로 양끝 쌍이 혼자 \(2/3\) 를 낸다. \(k\) 개 집단이 일렬로 늘어서 있을 때 전역 \(F\) 는 사실상 양끝 두 집단의 차이에 끌려간다. 이것도 전역 검정이 답해 주지 않는 것 중 하나다.
전역 검정이 \(p = 0.0159\)로 기각한다. 이제 어느 쌍이 다른지 찾을 차례다.
2단계: Tukey의 HSD를 이용한 사후검정¶
일원배치 분산분석이 유의하면 Tukey의 HSD로 어느 집단 쌍이 유의하게 다른지 찾을 수 있다.
보기 2. 2단계 — Tukey HSD. 출력의 네 열을 전부 손으로 만들어 본다.
균형설계의 Tukey 임계차는
이고 \(q\) 는 스튜던트화 범위분포의 분위수다.
(1) 쌍별 차이의 표준오차가 \(\text{SE} = \sqrt{\text{MSE}\left(\frac1n+\frac1n\right)}\) 임을 쓰고,
임을 보이시오. 곧 \(\lvert \bar y_i - \bar y_j\rvert > \text{HSD}\) 와 \(\lvert t_{ij}\rvert > q/\sqrt2\) 가 같은 조건이다.
(2) 조정 p-값이 \(\Pr\!\left(Q_{k,\nu} > \sqrt2\,\lvert t_{ij}\rvert\right)\) 로 주어짐을 쓰고, 신뢰구간이 \((\bar y_j - \bar y_i) \pm \text{HSD}\) 임을 밝히시오. 구간의 폭이 세 쌍에서 모두 같은 까닭은?
(3) 이 자료에서 HSD, 세 쌍의 \(t\), 조정 p-값, 구간을 모두 계산해 pairwise_tukeyhsd 의 출력과 맞추시오.
풀이
(1) 해석적으로. 두 집단평균의 차는 독립이므로
이고 \(\sigma^2\) 을 합동 MSE 로 바꾸면 \(\text{SE} = \sqrt{2\,\text{MSE}/n}\) 이다. 따라서
다. \(\square\) 그러므로 \(\lvert \bar y_i - \bar y_j\rvert > \text{HSD}\) 는 \(\left\lvert \dfrac{\bar y_i - \bar y_j}{\text{SE}}\right\rvert > \dfrac{q}{\sqrt2}\) 와 같다.
\(\sqrt2\) 가 들어오는 자리를 분명히 해 두자. 스튜던트화 범위 \(Q_{k,\nu}\) 는 표준오차가 \(\sqrt{\text{MSE}/n}\) 인 척도(평균 하나의 표준오차)로 정의된 최대 차이의 분포인데, 우리가 쓰는 \(t\) 는 차이의 표준오차 \(\sqrt{2\text{MSE}/n}\) 로 나눈 양이다. 두 척도가 \(\sqrt2\) 만큼 다르다.
(2) 해석적으로. \(H_0\) 가 모두 참이면 \(\max_{i<j}\dfrac{\lvert\bar Y_i - \bar Y_j\rvert}{\sqrt{\text{MSE}/n}}\) 가 정확히 \(Q_{k,\nu}\) 를 따른다. 관측된 쌍 \((i,j)\) 를 같은 척도로 옮기면
이므로, "최댓값이 이만큼 커질 확률"로 읽은 조정 p-값이
다. 이것은 "이 쌍 하나가 이만큼 벌어질 확률"이 아니라 "\(\binom{k}{2}\) 개 중 가장 큰 것이 이만큼 벌어질 확률"이며, 그래서 그대로 \(\alpha\) 와 견주면 가족단위 오류율이 통제된다.
구간은 같은 부등식을 뒤집어 얻는다. \(\lvert(\bar y_j - \bar y_i) - (\mu_j - \mu_i)\rvert \le \text{HSD}\) 가 동시에 성립할 확률이 \(1-\alpha\) 이므로
가 동시신뢰구간이다. 폭이 세 쌍에서 모두 같은 까닭은 (1)의 \(\text{SE}\) 가 \(n\) 과 MSE 에만 의존하기 때문이다. 균형설계에서는 어느 쌍이든 \(n_i = n_j = n\) 이므로 SE 가 같고, 따라서 임계차 하나가 모든 쌍에 공통으로 쓰인다. "하나의 임계차"는 Tukey 의 설계가 아니라 균형설계의 결과이며, 불균형이면 쌍마다 SE 가 달라져 Tukey–Kramer 로 확장해야 한다.
(3) 수치적으로. 먼저 쪽의 출력이다.
from statsmodels.stats.multicomp import pairwise_tukeyhsd
# 분산분석은 "어딘가 다르다"까지만 말한다. 어느 쌍이 다른지는 사후비교의 몫이다.
tukey_result = pairwise_tukeyhsd(endog=df['weight'], groups=df['group'], alpha=0.05)
print("Tukey's HSD Test Results:")
print(tukey_result)
출력:
Tukey's HSD Test Results:
Multiple Comparison of Means - Tukey HSD, FWER=0.05
===================================================
group1 group2 meandiff p-adj lower upper reject
---------------------------------------------------
ctrl trt1 -0.371 0.3909 -1.0622 0.3202 False
ctrl trt2 0.494 0.198 -0.1972 1.1852 False
trt1 trt2 0.865 0.012 0.1738 1.5562 True
---------------------------------------------------
이제 같은 표를 공식으로 만든다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
n, k, nu = 10, 3, 27
g = df.groupby('group').weight
m, s = g.mean().values, g.std().values
MSE = (s ** 2).mean()
SE = np.sqrt(MSE * (1 / n + 1 / n))
q = stats.studentized_range.ppf(0.95, k, nu)
HSD = q * np.sqrt(MSE / n)
print(f"MSE = {MSE:.6f}, SE = sqrt(MSE*(1/n+1/n)) = {SE:.6f}")
print(f"q(0.95, k=3, nu=27) = {q:.6f}")
print(f"HSD = q*sqrt(MSE/n) = {HSD:.6f} (= q/sqrt(2) * SE = {q / np.sqrt(2) * SE:.6f})")
print(f"\n{'쌍':>12}{'차이':>9}{'|차이|>HSD':>11}{'t':>9}{'p-adj':>9}{'하한':>9}{'상한':>9}")
labels = ['ctrl', 'trt1', 'trt2']
for i in range(k):
for j in range(i + 1, k):
d = m[j] - m[i]
t = d / SE
p = stats.studentized_range.sf(abs(t) * np.sqrt(2), k, nu)
print(f"{labels[i] + '-' + labels[j]:>12}{d:>9.4f}{str(abs(d) > HSD):>11}"
f"{t:>9.4f}{p:>9.4f}{d - HSD:>9.4f}{d + HSD:>9.4f}")
출력:
MSE = 0.388596, SE = sqrt(MSE*(1/n+1/n)) = 0.278782
q(0.95, k=3, nu=27) = 3.506426
HSD = q*sqrt(MSE/n) = 0.691216 (= q/sqrt(2) * SE = 0.691216)
쌍 차이 |차이|>HSD t p-adj 하한 상한
ctrl-trt1 -0.3710 False -1.3308 0.3909 -1.0622 0.3202
ctrl-trt2 0.4940 False 1.7720 0.1980 -0.1972 1.1852
trt1-trt2 0.8650 True 3.1028 0.0120 0.1738 1.5562
meandiff, p-adj, lower, upper, reject 다섯 열이 모두 pairwise_tukeyhsd 의 출력과 소수점 넷째 자리까지 같다. (1)과 (2)의 유도가 맞는다. HSD 를 두 방식으로 계산한 \(0.691216\) 도 서로 같다.
읽을 것 셋.
- 임계차는 \(\text{HSD} = 0.6912\) 하나뿐이다. 세 쌍의 차이 \(-0.371\), \(0.494\), \(0.865\) 중 절댓값이 이를 넘는 것은 마지막 하나다. 신뢰구간이 \(0\) 을 품느냐는 질문과 정확히 같은 질문이며, 실제로 셋째 줄만 \((0.1738,\ 1.5562)\) 로 \(0\) 을 비껴간다.
- \(t\) 로 보면 문턱이 \(q/\sqrt2 = 2.4794\) 다. 보정하지 않은 \(t_{0.975,27} = 2.0518\) 보다 높다. 그 차이가 "세 번 본다"는 사실의 값이다.
ctrl–trt2의 \(t = 1.772\) 는 어느 문턱도 넘지 못하지만, 만약 \(t\) 가 \(2.2\) 였다면 보정 없이는 유의하고 Tukey 로는 아니었을 것이다. ctrl–trt2가 \(p^{\text{adj}} = 0.198\) 로 아깝지 않게 밀린다. 보기 1에서 이 쌍이 SSB 의 \(21.6\%\) 를 냈는데도 그렇다. 전역 \(F\) 가 \(p = 0.016\) 으로 기각한 것과 사후비교에서 한 쌍만 살아남는 것 사이에 모순은 없다. 전역 검정은 세 쌍의 증거를 합쳐 쓰고 사후비교는 쌍마다 따로 쓰면서 문턱까지 올린다. 전역이 기각했는데 어느 쌍도 유의하지 않는 일도 얼마든지 생긴다.
세 비교 중 trt1 대 trt2 하나만 유의하다. p-adj 열은 이미 다중비교 보정을 마친 값이므로 그대로 0.05와 비교하면 된다.
이 출력은 각 집단 쌍의 비교 결과를 보여주며 다음을 포함한다:
- meandiff: 두 집단 평균의 차이.
- p-adj: 각 쌍별 비교의 조정 p-값.
- reject: 각 쌍에 대해 귀무가설(차이 없음)을 기각했는지를 나타내는 불리언.
3단계: Bonferroni 보정 (Tukey HSD의 대안)¶
더 보수적인 접근으로, 유의수준을 비교 횟수로 나누는 Bonferroni 보정을 쓸 수 있다.
보기 3. 3단계 — 본페로니 보정과의 비교. 보수성의 크기를 수로 잰다.
(1) \(m\) 개 비교를 각각 수준 \(\alpha\) 로 하고 그들이 독립이면 가족단위 오류율이 \(1-(1-\alpha)^m\) 임을 보이고, 합집합 한계가 주는 \(m\alpha\) 와 견주시오. 집단이 \(k\) 개면 \(m = \binom{k}{2}\) 이므로 \(k = 4\) 에서 그 값은 얼마인가.
(2) 세 방법이 \(\lvert t\rvert\) 척도에서 쓰는 문턱이
임을 이 자료(\(k=3\), \(\nu=27\))에서 확인하고, 가운데 부등호가 성립하는 까닭을 밝히시오.
(3) 쪽의 코드는 쌍마다 두 집단의 자료만으로 \(t\)-검정을 한다. 합동 MSE 를 쓰는 쪽과 어떻게 다른지 자유도와 p-값으로 보이시오.
풀이
(1) 해석적으로. 비교 \(i\) 에서 거짓 기각이 일어나는 사건을 \(A_i\) 라 하면 \(\Pr(A_i) = \alpha\) 다. 독립이면
이다. 한편 합집합 한계는 독립을 가정하지 않고
를 준다. 본페로니는 이 한계를 \(\alpha\) 로 묶으려고 각 비교의 수준을 \(\alpha/m\) 으로 낮춘다. 두 값은 \(m\alpha\) 가 작을 때 가깝지만(\(1-(1-\alpha)^m \approx m\alpha - \binom{m}{2}\alpha^2\)) \(m\) 이 커지면 벌어진다.
\(k\) 개 집단의 쌍 수는 \(m = \binom{k}{2} = \frac{k(k-1)}{2}\) 다. \(k = 4\) 면 \(m = 6\) 이고
다. 집단 넷을 보정 없이 쌍별로 비교하면 적어도 하나를 거짓 기각할 확률이 \(26\%\) 다. 약속한 \(5\%\) 의 다섯 배가 넘는다.
(2) 해석적으로. 왼쪽 부등호는 쉽다. 보정 없는 문턱은 \(m = 1\) 일 때의 값이므로 \(m > 1\) 인 어떤 보정보다 낮다.
가운데 부등호가 이 보기의 요점이다. 세 쌍별 통계량 \(t_{12}, t_{13}, t_{23}\) 은 독립이 아니다. 같은 집단평균을 나눠 쓰기 때문이다. 실제로 \(\bar y_1 - \bar y_2\) 와 \(\bar y_1 - \bar y_3\) 은 \(\bar y_1\) 을 공유하므로 공분산이 \(\sigma^2/n > 0\) 이고, 균형설계에서 상관계수가 \(\tfrac12\) 다. 게다가 셋은 \(t_{12} + t_{23} = t_{13}\) 이라는 선형제약까지 만족하므로 자유도가 둘뿐이다.
본페로니의 합집합 한계는 이 구조를 전혀 쓰지 않고 최악의 경우(사건들이 서로 겹치지 않는 경우)에 맞춘다. 반면 \(q_{\alpha,k,\nu}\) 는 바로 그 종속 구조 아래의 최댓값의 정확한 분포에서 나온 분위수다. 겹침이 있으면 합집합의 확률이 합보다 작으므로, 정확한 분위수는 합집합 한계가 요구하는 문턱보다 낮다. 그래서
이다. 같은 \(\alpha\) 를 약속하면서 문턱이 낮다는 것은 검정력이 높다는 뜻이고, 그것이 Tukey 를 쓰는 이유 전부다.
수치적으로. 먼저 쪽의 본페로니 계산이다.
from statsmodels.stats.multitest import multipletests
from itertools import combinations
from scipy.stats import ttest_ind
# 같은 일을 본페로니로 해 본다. 쌍마다 t-검정을 하고 p-값에 비교 횟수를 곱한다.
groups = df['group'].unique()
# 쌍 세 개를 모두 돌며 보정 전 p-값을 모은다.
p_values = []
comparisons = []
for group1, group2 in combinations(groups, 2):
data1 = df[df['group'] == group1]['weight']
data2 = df[df['group'] == group2]['weight']
stat, p_val = ttest_ind(data1, data2)
p_values.append(p_val)
comparisons.append(f"{group1} vs {group2}")
# 본페로니는 Tukey 보다 보수적이다. 분산분석의 구조를 쓰지 않고
# 검정 수만으로 문턱을 낮추기 때문이다.
_, p_values_corrected, _, _ = multipletests(p_values, alpha=0.05, method='bonferroni')
# 보정 전과 뒤를 나란히 찍어 무엇이 달라지는지 본다.
print("Bonferroni-Corrected Pairwise Comparisons:")
for comparison, p_val, p_val_corr in zip(comparisons, p_values, p_values_corrected):
print(f"{comparison}: p-value = {p_val:.4f}, Bonferroni-corrected p-value = {p_val_corr:.4f}")
출력:
Bonferroni-Corrected Pairwise Comparisons:
ctrl vs trt1: p-value = 0.2490, Bonferroni-corrected p-value = 0.7471
ctrl vs trt2: p-value = 0.0469, Bonferroni-corrected p-value = 0.1406
trt1 vs trt2: p-value = 0.0075, Bonferroni-corrected p-value = 0.0226
이제 (1)의 표와 (2)의 세 문턱을 계산하고, 두 종류의 \(t\)-검정을 나란히 둔다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
n, k, nu = 10, 3, 27
print(f"{'k':>4}{'쌍 수 m':>9}{'1-(1-a)^m':>12}{'합집합 한계 ma':>14}")
for kk in range(2, 7):
mm = kk * (kk - 1) // 2
print(f"{kk:>4}{mm:>9}{1 - 0.95 ** mm:>12.4f}{min(1, 0.05 * mm):>14.4f}")
print(f"\n세 문턱 (|t| 척도, nu = {nu})")
print(f" 보정 없음 t(0.975, 27) = {stats.t.ppf(0.975, nu):.4f}")
print(f" 투키 q(0.95,3,27)/sqrt2 = {stats.studentized_range.ppf(0.95, k, nu) / np.sqrt(2):.4f}")
print(f" 본페로니 t(1-0.05/6, 27) = {stats.t.ppf(1 - 0.05 / (2 * 3), nu):.4f}")
g = df.groupby('group').weight
m, s = g.mean().values, g.std().values
MSE = (s ** 2).mean()
SE = np.sqrt(2 * MSE / n)
labels = ['ctrl', 'trt1', 'trt2']
print(f"\n같은 합동 MSE(자유도 27)로 세 방법의 p-값을 나란히")
print(f"{'쌍':>12}{'|t|':>8}{'보정없음':>10}{'투키':>9}{'본페로니':>10}")
for i in range(k):
for j in range(i + 1, k):
t = abs(m[j] - m[i]) / SE
p_raw = 2 * stats.t.sf(t, nu)
p_tuk = stats.studentized_range.sf(t * np.sqrt(2), k, nu)
print(f"{labels[i] + '-' + labels[j]:>12}{t:>8.4f}{p_raw:>10.4f}"
f"{p_tuk:>9.4f}{min(1, 3 * p_raw):>10.4f}")
print(f"\n쪽의 코드가 쓴 두 집단만의 t-검정 (자유도 18)")
for i in range(k):
for j in range(i + 1, k):
a = df[df.group == labels[i]].weight
b = df[df.group == labels[j]].weight
_, p = stats.ttest_ind(a, b)
print(f"{labels[i] + '-' + labels[j]:>12} p = {p:.4f}, x3 = {min(1, 3 * p):.4f}")
출력:
k 쌍 수 m 1-(1-a)^m 합집합 한계 ma
2 1 0.0500 0.0500
3 3 0.1426 0.1500
4 6 0.2649 0.3000
5 10 0.4013 0.5000
6 15 0.5367 0.7500
세 문턱 (|t| 척도, nu = 27)
보정 없음 t(0.975, 27) = 2.0518
투키 q(0.95,3,27)/sqrt2 = 2.4794
본페로니 t(1-0.05/6, 27) = 2.5525
같은 합동 MSE(자유도 27)로 세 방법의 p-값을 나란히
쌍 |t| 보정없음 투키 본페로니
ctrl-trt1 1.3308 0.1944 0.3909 0.5832
ctrl-trt2 1.7720 0.0877 0.1980 0.2630
trt1-trt2 3.1028 0.0045 0.0120 0.0134
쪽의 코드가 쓴 두 집단만의 t-검정 (자유도 18)
ctrl-trt1 p = 0.2490, x3 = 0.7471
ctrl-trt2 p = 0.0469, x3 = 0.1406
trt1-trt2 p = 0.0075, x3 = 0.0226
(1)의 표. \(k = 4\) 에서 \(1 - 0.95^6 = 0.2649\) 로 예고한 값이 나온다. 합집합 한계 \(m\alpha = 0.30\) 은 그보다 조금 크다. \(k = 6\) 이면 참값 \(0.5367\) 에 한계가 \(0.75\) 로 벌어지고, \(m \ge 20\) 이면 한계가 \(1\) 을 넘어 아무 정보도 주지 못한다. \(m\) 이 클수록 본페로니가 버리는 양이 많아진다.
(이 표의 \(1-(1-\alpha)^m\) 은 독립을 가정한 값이다. 쌍별 비교는 실제로 양의 상관을 가지므로 참 FWER 은 이보다 조금 작다. 쪽의 그림에서 \(k = 3\) 의 실제 값이 \(0.119\) 로 표의 \(0.1426\) 보다 작은 것이 그 까닭이다.)
(2)의 세 문턱. \(2.0518 < 2.4794 < 2.5525\) 로 예고한 순서가 맞는다. 투키가 본페로니보다 \(0.073\) 낮다. \(k = 3\) 에서는 간격이 작지만, 보기 7에서 \(k = 6\) 일 때 같은 비교를 다시 한다.
(3) 두 종류의 \(t\)-검정. 쪽의 코드가 쓴 두 집단만의 \(t\)-검정은 자유도가 \(18\) 이고, 합동 MSE 를 쓰면 \(27\) 이다. 그 차이가 p-값을 눈에 띄게 바꾼다.
| 쌍 | 두 집단만 (df \(18\)) | 합동 MSE (df \(27\)) |
|---|---|---|
| ctrl–trt1 | \(0.2490\) | \(0.1944\) |
| ctrl–trt2 | \(\mathbf{0.0469}\) | \(\mathbf{0.0877}\) |
| trt1–trt2 | \(0.0075\) | \(0.0045\) |
방향이 쌍마다 다르다. ctrl–trt2 는 두 집단만 보면 \(0.0469\) 로 아슬아슬하게 유의한데 합동 MSE 로는 \(0.0877\) 로 밀린다. 두 집단(\(s = 0.583\), \(0.443\))이 모두 흩어짐이 작은 쪽이라 둘만의 합동분산이 전체 MSE 보다 작기 때문이다. 반대로 trt1–trt2 는 trt1 의 \(s = 0.794\) 가 커서 둘만 보면 분모가 커지고, 전체 MSE 를 쓰면 작아진다.
그러므로 "본페로니가 투키보다 보수적"이라는 비교를 쪽의 두 출력(\(0.0226\) 대 \(0.012\))으로 하면 안 된다. 거기에는 보정 방식의 차이와 분산 추정 방식의 차이가 섞여 있다. 같은 합동 MSE 위에서 비교하면 \(0.0134\) 대 \(0.0120\) 으로, 차이가 훨씬 작다. 이것이 투키가 본페로니를 이기는 순수한 폭이며, 쪽 아래 그림이 적은 수와 같다.
합동 MSE 를 쓰는 쪽이 옳은 까닭도 분명히 해 두자. 등분산을 가정한 모형에서는 세 집단 전부가 같은 \(\sigma^2\) 의 정보를 담고 있으므로, 두 집단만 쓰는 것은 자유도 \(9\) 어치를 버리는 일이다. 등분산이 의심스럽다면 합동할 것이 아니라 Games–Howell 로 가야 한다.
Tukey와 결론은 같지만 보정 p-값이 다르다. trt1 대 trt2가 Tukey에서 0.012, Bonferroni에서 0.0226이다. Bonferroni가 더 보수적이기 때문이며, 비교 수가 늘수록 차이가 벌어진다.
보수성의 출처는 분명하다. 세 쌍별 비교는 같은 집단 평균들을 공유해 양의 상관을 갖는다. 그런데 Bonferroni가 쓰는 합집합 한계는 그 상관을 묻지 않고 최악의 경우에 맞춘다. Tukey는 같은 상관 구조를 스튜던트화 범위분포로 정확히 반영한다. 종속 구조를 아는 가족에서 그것을 버리는 대가가 0.012와 0.0226의 차이다.
보정 전 p-값이 Tukey의 p-adj와도 다르다는 점에 주의하라. 여기서는 쌍마다 두 집단의 자료만으로 \(t\)-검정을 하지만, Tukey는 세 집단 전체에서 얻은 합동 MSE를 쓴다. 자유도가 18 대 27로 달라진다.
투키의 임계값이 어디서 오는지는 최대 차이의 귀무분포를 직접 그려 보면 분명해진다. 세 집단의 평균이 모두 같은 자료(\(k = 3\), 각 \(n = 10\))를 6만 번 만들고, 매번 세 쌍 가운데 가장 큰 \(|t|\)를 기록했다.

왼쪽 초록 히스토그램이 그 분포다. 봉우리가 \(1\) 부근이고 오른쪽으로 길게 늘어진다. 쌍별 비교 하나의 \(|t|\)가 아니라 셋 중 최댓값이므로 보통의 \(t\) 분포보다 오른쪽으로 밀려 있다. 사후비교에서 거짓 발견이 생기는 것은 "어느 한 쌍"이 문턱을 넘을 때이므로, 통제해야 할 것은 바로 이 최댓값의 분포다.
세 수직선이 세 방법의 문턱이다. 보정 없는 \(t_{0.975,27} = 2.052\) 오른쪽에는 분포의 \(11.9\%\)가 놓인다. 쌍마다 \(5\%\)씩 쓴 대가가 가족단위로 \(12\%\)라는 뜻이다. 투키의 \(q_{0.05,3,27}/\sqrt{2} = 2.479\) 오른쪽 넓이는 \(0.051\)로, 약속한 \(5\%\)와 정확히 맞는다. 우연이 아니라 정의다. 투키의 임계값은 이 분포의 95 백분위수로 만들어진 것이며, 스튜던트화 범위분포가 바로 이 최댓값의 이론적 분포다. 본페로니의 \(t_{0.05/6,27} = 2.552\) 오른쪽은 \(0.044\)로 약속보다 작다. 넘치게 안전하다는 것은 곧 검정력을 버렸다는 뜻이다.
오른쪽이 PlantGrowth 자료에서 그 차이가 얼마로 나타나는지 보여 준다. 세 쌍 모두에서 회색(보정 없음) \(<\) 초록(투키) \(<\) 파랑(본페로니)의 순서가 지켜진다. ctrl–trt1은 \(0.1944 \to 0.3909 \to 0.5832\), ctrl–trt2는 \(0.0877 \to 0.1980 \to 0.2630\), trt1–trt2는 \(0.0045 \to 0.0120 \to 0.0134\)이다. 다행히 이 자료에서는 세 방법의 판정이 갈리지 않는다. 하지만 ctrl–trt2를 보라. 보정 전 \(0.0877\)이던 것이 투키에서 \(0.198\), 본페로니에서 \(0.263\)이 된다. 경계에 있는 비교라면 어떤 보정을 쓰느냐가 결론을 바꾼다.
투키가 본페로니를 이기는 폭은 \(k\)가 커질수록 벌어진다. 여기서는 \(0.0120\) 대 \(0.0134\)로 미미하지만, 집단이 여섯이면 쌍이 15개가 되어 본페로니는 \(p\)에 15를 곱하는 반면 투키의 문턱은 최대 \(|t|\) 분포가 얼마나 밀렸는지만큼만 올라간다. 비교들이 평균을 공유해 양의 상관을 갖는다는 사실을 쓰느냐 버리느냐의 차이다.
4단계: Scheffé 검정 (복잡한 비교용)¶
Scheffé 검정은 쌍별이 아닌 비교나 대비를 검정하는 데 적합하지만 복잡하고 기본적인 쌍별 비교에는 덜 쓰인다. statsmodels에는 Scheffé 검정이 직접 제공되지 않지만, 필요하다면 특히 더 진전된 비교를 위해 대비를 직접 구성할 수 있다.
2. scipy.stats.tukey_hsd¶
보기 4. scipy의 tukey_hsd로 신뢰구간까지. 신뢰수준을 \(95\%\) 에서 \(99\%\) 로 올리면 결론이 바뀐다.
(1) 신뢰수준 \(1-\alpha\) 의 동시신뢰구간이
임을 쓰고, \(\alpha = 0.01\) 에서 반폭을 구하시오.
(2) 구간이 \(0\) 을 품지 않는 것과 \(p^{\text{adj}} < \alpha\) 가 같은 사건임을 밝히고, 이 자료에서 \(\alpha = 0.05\) 와 \(\alpha = 0.01\) 에 대해 확인하시오. \(99\%\) 에서 trt1–trt2 는 어떻게 되는가.
(3) 출력의 (0 - 1) 과 (1 - 0) 이 왜 부호만 다른 같은 비교인지 밝히시오.
풀이
(1) 해석적으로. 보기 2의 (2)에서 이미 얻었다. \(H_0\) 아래에서
이므로 모든 쌍에 대해 동시에
가 성립할 확률이 \(1-\alpha\) 다. 이를 \(\mu_j-\mu_i\) 에 대해 풀면 구하는 구간이 된다. "동시"라는 말이 핵심이다. \(\binom{k}{2}\) 개 구간이 모두 함께 참값을 덮을 확률이 \(1-\alpha\) 이지, 구간 하나하나가 \(1-\alpha\) 인 것이 아니다.
(2) 해석적으로. 구간이 \(0\) 을 품지 않는다는 것은
인데, 양변을 \(\sqrt{\text{MSE}/n}\) 으로 나누면 좌변이 보기 2에서 본 \(\sqrt2\,\lvert t_{ij}\rvert\) 다. 그러므로
로 같은 사건이다(\(Q\) 의 분포함수가 증가함수이므로 부등식의 방향이 뒤집힌다). \(\square\) 구간과 p-값은 같은 계산을 두 방향에서 적은 것이며, 어느 쪽을 보고하든 판정은 같다.
(3) 해석적으로. \(\bar y_0 - \bar y_1 = -(\bar y_1 - \bar y_0)\) 이고, 검정통계량이 \(\lvert t\rvert\) 에만 의존하므로 p-값은 같다. 구간도 부호를 바꾸고 양끝을 맞바꾼 것이 된다. scipy 는 \(k \times k\) 행렬을 통째로 돌려주므로 대각선 위아래가 같은 정보를 담고, statsmodels 는 \(i < j\) 인 쪽만 인쇄한다. \(\binom{3}{2} = 3\) 개의 비교가 여섯 줄로 보이는 것일 뿐 검정 횟수가 여섯이 되는 것은 아니다. (\(k\) 를 정하는 것은 집단 수이지 인쇄된 줄 수가 아니다.)
수치적으로. 먼저 쪽의 출력이다.
import matplotlib.pyplot as plt
import numpy as np
import scipy.stats as stats
import pandas as pd
def load_data():
"""PlantGrowth 자료를 읽어 집단별로 나누고 자유도까지 함께 돌려준다."""
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
grouped_data = df.groupby('group')
data_ctrl = grouped_data.get_group('ctrl').weight
data_trt1 = grouped_data.get_group('trt1').weight
data_trt2 = grouped_data.get_group('trt2').weight
data = (data_ctrl, data_trt1, data_trt2)
total_samples = data_ctrl.shape[0] + data_trt1.shape[0] + data_trt2.shape[0]
num_groups = len(data)
df1 = num_groups - 1
df2 = total_samples - num_groups
return df, data, df1, df2
def perform_anova(data_ctrl, data_trt1, data_trt2):
"""세 집단에 일원배치 분산분석을 수행한다."""
statistic, p_value = stats.f_oneway(data_ctrl, data_trt1, data_trt2)
print("\nOne-way ANOVA Results:")
print(f"F-statistic = {statistic:.4f}")
print(f"P-value = {p_value:.4f}\n")
return statistic, p_value
def perform_tukey_hsd(data_ctrl, data_trt1, data_trt2, confidence_level=0.95):
"""Tukey HSD 사후비교를 수행하고 쌍별 신뢰구간을 보여 준다.
구간이 0 을 품으면 그 쌍은 유의하지 않다고 읽는다. 신뢰수준을 높이면
구간이 넓어지므로 유의하다고 판정되는 쌍이 줄어든다.
"""
result = stats.tukey_hsd(data_ctrl, data_trt1, data_trt2)
print(result)
print(f"\nTukey's HSD Pairwise Group Comparisons ({confidence_level:.0%} Confidence Interval)")
print("Comparison Lower CI Upper CI")
confidence_interval = result.confidence_interval(confidence_level=confidence_level)
for ((i, j), low) in np.ndenumerate(confidence_interval.low):
if i < j:
high = confidence_interval.high[i, j]
print(f" ({i} - {j}) {low:>10.3f} {high:>9.3f}")
print()
# 자료 읽기 → 분산분석 → 사후비교 순으로 돌린다.
df, (data_ctrl, data_trt1, data_trt2), df1, df2 = load_data()
# 먼저 분산분석.
statistic, p_value = perform_anova(data_ctrl, data_trt1, data_trt2)
# 기본 95% 신뢰수준으로 사후비교.
perform_tukey_hsd(data_ctrl, data_trt1, data_trt2)
# 같은 자료를 99% 로 다시 본다. 구간이 넓어지는 만큼 결론이 보수적이 된다.
perform_tukey_hsd(data_ctrl, data_trt1, data_trt2, confidence_level=0.99)
출력:
One-way ANOVA Results:
F-statistic = 4.8461
P-value = 0.0159
Tukey's HSD Pairwise Group Comparisons (95.0% Confidence Interval)
Comparison Statistic p-value Lower CI Upper CI
(0 - 1) 0.371 0.391 -0.320 1.062
(0 - 2) -0.494 0.198 -1.185 0.197
(1 - 0) -0.371 0.391 -1.062 0.320
(1 - 2) -0.865 0.012 -1.556 -0.174
(2 - 0) 0.494 0.198 -0.197 1.185
(2 - 1) 0.865 0.012 0.174 1.556
Tukey's HSD Pairwise Group Comparisons (95% Confidence Interval)
Comparison Lower CI Upper CI
(0 - 1) -0.320 1.062
(0 - 2) -1.185 0.197
(1 - 2) -1.556 -0.174
Tukey's HSD Pairwise Group Comparisons (95.0% Confidence Interval)
Comparison Statistic p-value Lower CI Upper CI
(0 - 1) 0.371 0.391 -0.320 1.062
(0 - 2) -0.494 0.198 -1.185 0.197
(1 - 0) -0.371 0.391 -1.062 0.320
(1 - 2) -0.865 0.012 -1.556 -0.174
(2 - 0) 0.494 0.198 -0.197 1.185
(2 - 1) 0.865 0.012 0.174 1.556
Tukey's HSD Pairwise Group Comparisons (99% Confidence Interval)
Comparison Lower CI Upper CI
(0 - 1) -0.515 1.257
(0 - 2) -1.380 0.392
(1 - 2) -1.751 0.021
이제 두 신뢰수준의 구간을 공식으로 만들고 쌍대성을 확인한다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/PlantGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2])
n, k, nu = 10, 3, 27
g = df.groupby('group').weight
m, s = g.mean().values, g.std().values
MSE = (s ** 2).mean()
labels = ['ctrl', 'trt1', 'trt2']
print(f"{'신뢰수준':>8}{'q':>10}{'반폭':>10}")
for lev in [0.95, 0.99]:
q = stats.studentized_range.ppf(lev, k, nu)
print(f"{lev:>8.2f}{q:>10.6f}{q * np.sqrt(MSE / n):>10.6f}")
print(f"\n{'쌍':>12}{'차이':>9}{'95% 구간':>22}{'99% 구간':>22}{'p-adj':>9}")
for i in range(k):
for j in range(i + 1, k):
d = m[j] - m[i]
t = abs(d) / np.sqrt(2 * MSE / n)
p = stats.studentized_range.sf(t * np.sqrt(2), k, nu)
h95 = stats.studentized_range.ppf(0.95, k, nu) * np.sqrt(MSE / n)
h99 = stats.studentized_range.ppf(0.99, k, nu) * np.sqrt(MSE / n)
print(f"{labels[i] + '-' + labels[j]:>12}{d:>9.3f}"
f"{f'({d - h95:>7.3f}, {d + h95:>7.3f})':>22}"
f"{f'({d - h99:>7.3f}, {d + h99:>7.3f})':>22}{p:>9.4f}")
print("\n쌍대성 확인: 구간이 0 을 품지 않는다 <=> p-adj < alpha")
for lev, a in [(0.95, 0.05), (0.99, 0.01)]:
h = stats.studentized_range.ppf(lev, k, nu) * np.sqrt(MSE / n)
for i in range(k):
for j in range(i + 1, k):
d = m[j] - m[i]
t = abs(d) / np.sqrt(2 * MSE / n)
p = stats.studentized_range.sf(t * np.sqrt(2), k, nu)
print(f" alpha={a} {labels[i] + '-' + labels[j]:>10}: "
f"0 제외={abs(d) > h}, p-adj<alpha={p < a}")
출력:
신뢰수준 q 반폭
0.95 3.506426 0.691216
0.99 4.494842 0.886061
쌍 차이 95% 구간 99% 구간 p-adj
ctrl-trt1 -0.371 ( -1.062, 0.320) ( -1.257, 0.515) 0.3909
ctrl-trt2 0.494 ( -0.197, 1.185) ( -0.392, 1.380) 0.1980
trt1-trt2 0.865 ( 0.174, 1.556) ( -0.021, 1.751) 0.0120
쌍대성 확인: 구간이 0 을 품지 않는다 <=> p-adj < alpha
alpha=0.05 ctrl-trt1: 0 제외=False, p-adj<alpha=False
alpha=0.05 ctrl-trt2: 0 제외=False, p-adj<alpha=False
alpha=0.05 trt1-trt2: 0 제외=True, p-adj<alpha=True
alpha=0.01 ctrl-trt1: 0 제외=False, p-adj<alpha=False
alpha=0.01 ctrl-trt2: 0 제외=False, p-adj<alpha=False
alpha=0.01 trt1-trt2: 0 제외=False, p-adj<alpha=False
여섯 구간이 모두 scipy 의 출력과 소수점 셋째 자리까지 같다. (부호만 다른 것은 scipy 가 (i - j) 를, 여기서는 (j - i) 를 적었기 때문이다.) 반폭은 \(95\%\) 에서 \(0.691216\), \(99\%\) 에서 \(0.886061\) 로 \(q\) 가 \(3.5064 \to 4.4948\) 로 커진 만큼 넓어진다.
(2)의 쌍대성이 여섯 줄 모두에서 맞는다. 그리고 결정적인 줄은 마지막에서 넷째다. \(\alpha = 0.01\) 에서 trt1–trt2 의 구간이 \((-0.021,\ 1.751)\) 로 \(0\) 을 아슬아슬하게 품는다. \(p^{\text{adj}} = 0.0120 > 0.01\) 과 정확히 맞아떨어진다.
이 쌍의 지위가 신뢰수준에 달려 있다는 뜻이다. \(95\%\) 에서는 "유의한 유일한 쌍"이고 \(99\%\) 에서는 "유의하지 않은 셋 중 하나"다. 하한이 \(-0.021\) 로 \(0\) 에서 겨우 \(0.02\) 떨어져 있으니, 이 자료가 말할 수 있는 것은 "\(0.865\) 라는 차이는 세 쌍을 동시에 통제하는 틀에서 \(95\%\) 수준의 증거는 되지만 \(99\%\) 수준의 증거는 못 된다"가 전부다. 보고할 때는 판정만 적지 말고 구간 \((0.174,\ 1.556)\) 을 함께 적어야 그 폭이 드러난다. 참값이 \(0.17\) 일 수도 \(1.56\) 일 수도 있다는 것은 집단당 \(n = 10\) 으로 알 수 있는 것의 한계다.
scipy.stats.tukey_hsd는 statsmodels와 달리 대칭인 쌍을 모두 인쇄한다. (0 - 1)과 (1 - 0)이 부호만 반대인 같은 비교다.
집단 번호는 인자를 넘긴 순서(0 = ctrl, 1 = trt1, 2 = trt2)를 따른다. 이름이 아니라 번호로 나오므로 순서를 잘못 기억하면 결과를 거꾸로 읽게 된다.
결과 자체는 statsmodels의 Tukey HSD와 정확히 같다. (1 - 2)의 차이 \(-0.865\), \(p = 0.012\), 구간 \((-1.556, -0.174)\)가 앞의 표와 일치한다.
출력 해석¶
이 표는 세 집단(집단 0, 1, 2로 표기)에 대한 Tukey의 HSD 쌍별 비교 결과를 95% 신뢰구간과 함께 보여준다.
열 설명:
-
비교: 비교하는 집단 쌍을 지표 번호로 나타낸다. 예를 들어 "(0 - 1)"은 집단 0과 집단 1의 비교를 뜻한다.
-
통계량: 해당 비교에서 두 집단 평균의 차이. 값이 양수이면 앞 집단의 평균이 더 크고, 음수이면 그 반대이다.
-
p-값: 그 비교에 대응하는 확률값. 평균 차이가 통계적으로 유의한지를 나타낸다. p-값이 작으면(보통 0.05 미만) 집단 사이에 통계적으로 유의한 차이가 있음을 시사한다.
-
하한 CI: 평균 차이에 대한 95% 신뢰구간의 하한. 이 구간이 0을 포함하지 않으면 차이가 통계적으로 유의하다고 본다.
-
상한 CI: 평균 차이에 대한 95% 신뢰구간의 상한.
각 비교의 해석:
-
(0 - 1)과 (1 - 0): 집단 0과 집단 1의 평균 차이는 0.371이고 p-값은 0.391이다. p-값이 크므로 차이가 통계적으로 유의하지 않으며, 신뢰구간(-0.320에서 1.062)도 0을 포함한다.
-
(0 - 2)와 (2 - 0): 집단 0과 집단 2의 평균 차이는 -0.494이고 p-값은 0.198이다. 역시 유의하지 않으며 신뢰구간(-1.185에서 0.197)이 0을 포함하여 유의한 차이가 없음을 시사한다.
-
(1 - 2)와 (2 - 1): 집단 1과 집단 2의 평균 차이는 -0.865이고 p-값은 0.012이다. p-값이 0.05보다 작아 통계적으로 유의한 차이를 나타낸다. 신뢰구간(-1.556에서 -0.174)이 0을 포함하지 않아 유의성을 다시 확인해 준다.
요약:
Tukey의 HSD 결과는 95% 신뢰수준에서 집단 1과 집단 2 사이에 통계적으로 유의한 차이가 있음을 시사한다. p-값이 작고 신뢰구간이 0을 포함하지 않기 때문이다. 집단 0과 1, 집단 0과 2 사이에는 유의한 차이가 없다.
위치로 본 해석¶
-
집단 1(왼쪽)과 집단 2(오른쪽): 두 집단은 통계적으로 유의한 차이를 보인다. p-값이 작고(0.012) 신뢰구간(-1.556에서 -0.174)이 0을 포함하지 않는다. 평균값 면에서 서로 꽤 구별됨을 시사한다.
-
집단 0(가운데)과 집단 1(왼쪽): 통계적으로 유의한 차이가 없다. p-값이 0.391이고 신뢰구간(-0.320에서 1.062)이 0을 포함한다.
-
집단 0(가운데)과 집단 2(오른쪽): 마찬가지로 유의한 차이가 없다. p-값이 0.198이고 신뢰구간(-1.185에서 0.197)도 0을 포함한다.
해석:
- 집단 0(가운데)은 집단 1과 2 사이의 중간으로 보인다. 어느 쪽과도 유의하게 다르지 않기 때문이다.
- 집단 1(왼쪽)과 집단 2(오른쪽)는 서로 유의하게 다르며, 서로 구별되는 수준이나 조건을 대표함을 시사한다.
- 집단 0은 중간 혹은 과도기적 집단 역할을 하며 집단 1과도 집단 2와도 유의하게 다르지 않다.
scipy.stats.tukey_hsd는 각 쌍에 대해 쌍별 t-검정을 하는가?¶
-
목적과 쓰임의 맥락: Tukey의 HSD 검정은 특별히 사후검정이다. 즉 분산분석이 집단 평균 사이에 통계적으로 유의한 차이가 있다고 판정한 뒤에 적용한다. "어느 집단 쌍의 평균이 유의하게 다른가?"라는 질문에 답한다. 분산분석과 무관하게 독립적으로 수행할 수 있는 t-검정과는 다르다.
-
스튜던트화 범위 분포: 스튜던트화 범위 분포에 의존한다는 점이 핵심적인 차이이다. 이 분포는 (t-검정처럼 개별 쌍을 따로 평가하는 대신) 모든 집단 평균의 범위를 동시에 고려한다. 비교하는 집단의 수를 반영하여 가능한 모든 비교에 걸쳐 가족단위 오류율(FWER)을 통제한다.
-
가족단위 오류율: Tukey의 HSD가 하는 조정은 모든 비교에 걸쳐 제1종 오류를 적어도 한 번 범할 확률이 미리 정한 알파 수준(예: 0.05)을 넘지 않도록 보장한다. 반면 t-검정을 여러 번 수행하면 비교 횟수와 함께 오류율이 누적되어 제1종 오류의 가능성이 커진다.
예를 들어 집단이 \(m\)개면 쌍별 비교의 수는 \(\binom{m}{2} = \frac{m(m-1)}{2}\)이다. \(m = 5\)이면 비교가 10개이다. 이를 각각 5% 유의수준에서 독립적으로 수행하면 전체 제1종 오류율이 40%를 넘을 수도 있다. Tukey의 HSD는 다중검정 보정을 넣어 이 부풀림을 막는다.
-
임계값과 해석: Tukey 검정은 모든 집단 비교에 일률적으로 적용되는 하나의 임계 차이값(HSD)을 계산한다. 두 집단 평균의 절대 차이가 이 HSD 값을 넘으면 차이가 유의하다고 본다. 이 균일한 문턱은 해석을 단순하게 하고, 각자 임계값을 갖는 개별 t-검정에서 생기는 변동을 피한다.
-
보수성: 가족단위 오류를 통제하기 때문에 Tukey의 HSD는 개별 t-검정보다 대체로 더 보수적이다. 제1종 오류의 위험을 줄이지만 제2종 오류(참 차이를 놓칠 위험)는 다소 커질 수 있다. 그래도 거짓 양성 통제가 우선인 연구에서는 이 맞바꿈이 흔히 받아들일 만하다.
실무적 함의: Tukey의 HSD는 균형 설계(집단 크기가 같은 경우)에 이상적이지만 약간의 조정으로 불균형 설계에도 적용할 수 있다. 그런 경우에는 정확도를 위해 Games-Howell 검정 같은 다른 사후검정이 선호될 수 있다.
3. 이원배치 분산분석의 사후검정¶
이원배치 분산분석의 사후검정은 유의한 주효과와 교호작용 효과를 자세히 살피는 데 꼭 필요하다.
A. 이원배치 분산분석에서 사후검정을 언제 쓰는가¶
이원배치 분산분석에서 사후검정은 대체로 다음에 쓴다:
- 주효과 조사: 주효과 중 하나 또는 둘 다(예: 요인 A나 요인 B) 유의하면, 사후검정으로 그 요인의 어느 수준이 서로 유의하게 다른지 찾을 수 있다.
- 교호작용 효과 검토: 요인 A와 B 사이에 유의한 교호작용이 있으면, 사후검정으로 어떤 요인 수준 조합에서 유의한 차이가 나타나는지 판정할 수 있다.
B. 이원배치 분산분석의 사후검정 종류¶
- Tukey의 정직유의차(HSD): 가족단위 오류율을 통제하기 때문에 분산분석의 쌍별 비교에 널리 쓰인다.
- Bonferroni 보정: 유의수준을 비교 횟수로 나누는 더 보수적인 방법.
- 단순 효과 분석: 교호작용 효과가 유의하면, 다른 요인의 각 수준에서 한 요인의 효과를 살피는 단순 효과 분석을 쓸 수 있다.
C. Python으로 사후검정 수행하기¶
1단계: 이원배치 분산분석 수행¶
보기 5. 1단계 — 이원배치 분산분석. 균형설계의 제곱합은 행·열·칸 평균만으로 손으로 적힌다.
(1) 요인 A가 \(a\) 수준, 요인 B가 \(b\) 수준, 칸마다 \(n\) 개인 균형설계에서
이고 이 넷이 \(\text{SS}_T\) 로 정확히 분해됨을 쓰시오. 교호작용의 자유도가 \((a-1)(b-1)\) 인 까닭을 밝히시오.
(2) ToothGrowth 자료에서 행·열·칸 평균을 구하고 네 제곱합과 세 \(F\) 를 손으로 만들어 anova_lm 과 맞추시오.
(3) 교호작용이 유의할 때 주효과를 단독으로 읽으면 안 되는 까닭을 칸 평균으로 설명하시오.
풀이
(1) 해석적으로. 칸 평균을 \(\bar y_{ij\cdot}\) 라 쓰고 관측값을
로 쪼갠다. 균형설계에서는 \(\sum_i \alpha_i = 0\), \(\sum_j\beta_j = 0\), \((\alpha\beta)_{ij}\) 가 행으로도 열로도 합이 \(0\) 이며 \(\sum_l e_{ijl} = 0\) 이므로 네 조각이 서로 직교한다. 따라서 제곱해 더하면 교차항이 모두 사라져
가 된다. 각 조각의 제곱합은 그 조각이 같은 값을 갖는 관측 수를 곱한 것이므로 \(\text{SS}_A = bn\sum_i\alpha_i^2\), \(\text{SS}_B = an\sum_j\beta_j^2\), \(\text{SS}_{AB} = n\sum_{i,j}(\alpha\beta)_{ij}^2\) 다. 그리고 \(n\sum_{i,j}(\bar y_{ij\cdot}-\bar y)^2\) 을 펼치면 세 항의 합이므로 \(\text{SS}_{AB}\) 는 거기서 \(\text{SS}_A\) 와 \(\text{SS}_B\) 를 뺀 나머지다.
자유도는 자유로운 성분의 수다. \((\alpha\beta)_{ij}\) 는 \(ab\) 개인데 행 제약 \(a\) 개와 열 제약 \(b\) 개가 걸리고 그중 하나가 겹치므로 \(ab - a - b + 1 = (a-1)(b-1)\) 개가 자유롭다. \(\square\)
(3) 해석적으로. 교호작용이 유의하다는 것은 \((\alpha\beta)_{ij} \ne 0\), 곧 요인 A의 효과가 요인 B의 수준마다 다르다는 뜻이다. 주효과 \(\alpha_i\) 는 그 다른 효과들을 B의 수준에 걸쳐 평균낸 것이므로, 평균이 실제 어느 수준에서도 일어나지 않는 일을 가리킬 수 있다. 극단적으로 A의 효과가 B의 한 수준에서 \(+5\), 다른 수준에서 \(-5\) 라면 주효과는 \(0\) 이 되어 "효과 없음"으로 보고되지만 두 수준 모두에서 효과는 뚜렷하다.
(2) 수치적으로. 먼저 쪽의 분산분석표다.
import pandas as pd
from statsmodels.formula.api import ols
from statsmodels.stats.anova import anova_lm
# ToothGrowth 자료. 보충제 종류(supp)와 투여량(dose) 두 요인이 있다.
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/ToothGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2, 3])
# 콜론이 교호작용 항이다. 두 요인의 효과가 서로 독립인지를 이 항이 묻는다.
model = ols('len ~ C(supp) + C(dose) + C(supp):C(dose)', data=df).fit()
anova_results = anova_lm(model)
print(anova_results)
출력:
df sum_sq mean_sq F PR(>F)
C(supp) 1.0 205.350000 205.350000 15.571979 2.311828e-04
C(dose) 2.0 2426.434333 1213.217167 91.999965 4.046291e-18
C(supp):C(dose) 2.0 108.319000 54.159500 4.106991 2.186027e-02
Residual 54.0 712.106000 13.187148 NaN NaN
이제 같은 표를 행·열·칸 평균만으로 만든다.
import numpy as np
import pandas as pd
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/ToothGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2, 3])
a, b, n = 2, 3, 10 # supp 2 수준, dose 3 수준, 칸마다 10 개
gm = df.len.mean()
mi = df.groupby('supp').len.mean() # 행 평균
mj = df.groupby('dose').len.mean() # 열 평균
cell = df.groupby(['supp', 'dose']).len.mean()
SSA = b * n * ((mi - gm) ** 2).sum()
SSB = a * n * ((mj - gm) ** 2).sum()
SScell = n * ((cell - gm) ** 2).sum()
SSAB = SScell - SSA - SSB
SSE = ((df.len - df.groupby(['supp', 'dose']).len.transform('mean')) ** 2).sum()
SST = ((df.len - gm) ** 2).sum()
print(f"전체평균 = {gm:.4f}")
print(f"행 평균 (supp) = {np.round(mi.values, 4)}")
print(f"열 평균 (dose) = {np.round(mj.values, 4)}")
print(f"칸 평균 =\n{cell.round(3)}")
print(f"\nSS_supp = {SSA:.6f} (df {a - 1})")
print(f"SS_dose = {SSB:.6f} (df {b - 1})")
print(f"SS_교호작용 = {SSAB:.6f} (df {(a - 1) * (b - 1)})")
print(f"SS_잔차 = {SSE:.6f} (df {a * b * (n - 1)})")
print(f"SS_전체 = {SST:.6f} (df {a * b * n - 1})")
print(f"네 조각의 합 = {SSA + SSB + SSAB + SSE:.6f}, 차 = {SST - SSA - SSB - SSAB - SSE:.2e}")
MSE = SSE / (a * b * (n - 1))
print(f"\nMSE = {MSE:.6f}")
print(f"F_supp = {(SSA / (a - 1)) / MSE:.6f}")
print(f"F_dose = {(SSB / (b - 1)) / MSE:.6f}")
print(f"F_교호 = {(SSAB / ((a - 1) * (b - 1))) / MSE:.6f}")
출력:
전체평균 = 18.8133
행 평균 (supp) = [20.6633 16.9633]
열 평균 (dose) = [10.605 19.735 26.1 ]
칸 평균 =
supp dose
OJ 0.5 13.23
1.0 22.70
2.0 26.06
VC 0.5 7.98
1.0 16.77
2.0 26.14
Name: len, dtype: float64
SS_supp = 205.350000 (df 1)
SS_dose = 2426.434333 (df 2)
SS_교호작용 = 108.319000 (df 2)
SS_잔차 = 712.106000 (df 54)
SS_전체 = 3452.209333 (df 59)
네 조각의 합 = 3452.209333, 차 = 1.14e-12
MSE = 13.187148
F_supp = 15.571979
F_dose = 91.999965
F_교호 = 4.106991
네 제곱합과 세 \(F\) 가 anova_lm 의 출력과 소수점 여섯째 자리까지 같고, 분해의 잔차가 \(1.14\times10^{-12}\) 로 부동소수점 오차 수준이다. 균형설계이므로 Type I·II·III 제곱합이 모두 같다는 사실도 여기에 함께 들어 있다. 요인이 직교하므로 항을 넣는 순서가 결과를 바꾸지 않는다.
(3)을 칸 평균으로 읽는다. 같은 용량에서 OJ 와 VC 의 차이를 보면
| 용량 | OJ | VC | OJ \(-\) VC |
|---|---|---|---|
| \(0.5\) | \(13.23\) | \(7.98\) | \(\mathbf{+5.25}\) |
| \(1.0\) | \(22.70\) | \(16.77\) | \(\mathbf{+5.93}\) |
| \(2.0\) | \(26.06\) | \(26.14\) | \(\mathbf{-0.08}\) |
다. 보충제의 효과가 용량 \(2.0\) 에서 사라진다. 주효과 \(20.66 - 16.96 = 3.70\) 은 이 셋(\(5.25\), \(5.93\), \(-0.08\))의 평균일 뿐이고, 세 용량 가운데 어느 하나에서도 실제로 일어나지 않는 값이다. 교호작용 \(F = 4.11\) (\(p = 0.022\))이 바로 이 불균질함을 재고 있다. 보기 7·8이 이 표를 검정의 꼴로 다시 적는다.
두 주효과와 교호작용이 모두 유의하다. 교호작용이 유의하다는 것은 주효과를 단독으로 해석하기 전에 조심하라는 신호다.
2단계: 주효과에 대한 사후검정¶
보기 6. 2단계 — 주효과 사후검정. 분산분석표의 C(supp) 는 \(p = 0.00023\) 인데 pairwise_tukeyhsd 는 \(p = 0.060\) 을 준다. 어긋남의 크기를 수로 밝힌다.
(1) pairwise_tukeyhsd(endog=df['len'], groups=df['supp']) 는 용량을 무시하고 일원배치를 돌린다. 그때 쓰는 오차제곱합이
임을 보이고, 자유도가 \(58\) 임을 밝히시오.
(2) 두 MSE 의 비를 구하고, 같은 차이 \(\bar y_{VC} - \bar y_{OJ} = -3.70\) 에 대해 두 \(t\) 값이 \(\sqrt{\text{MSE}^{(1)}/\text{MSE}^{(2)}}\) 배만큼 다름을 보이시오.
(3) 두 p-값을 계산해 \(0.060\) 과 \(0.00023\) 을 재현하고, 어느 쪽을 보고해야 하는지 밝히시오.
풀이
(1) 해석적으로. 보기 5의 분해 \(\text{SS}_T = \text{SS}_{\text{supp}} + \text{SS}_{\text{dose}} + \text{SS}_{AB} + \text{SS}_E\) 에서 \(\text{SS}_{\text{supp}}\) 를 옮기면 곧바로 얻는다. 균형설계라 요인들이 직교하므로 용량을 모형에서 빼도 \(\text{SS}_{\text{supp}}\) 는 한 치도 바뀌지 않고, 그 대신 용량이 설명하던 몫이 통째로 오차로 들어간다. 자유도는 \(59 - 1 = 58\) 이다(\(2 + 2 + 54 = 58\) 과 맞는다).
(2) 해석적으로. 두 분석의 분자는 같다. 집단평균 차 \(-3.70\) 이 균형설계에서 모형과 무관하게 같기 때문이다. 다른 것은 분모뿐이고
다. 용량을 모형에 넣는 일은 분자를 키우는 것이 아니라 분모를 줄이는 것이며, 그것이 검정력을 얻는 전부다. 이것이 요인설계의 요점이기도 하다. 통제할 수 있는 변동원을 모형에 넣으면 같은 자료로 더 작은 차이를 잡아낸다.
(3) 수치적으로. 먼저 쪽의 출력이다.
from statsmodels.stats.multicomp import pairwise_tukeyhsd
# 주효과에 대한 사후비교. 교호작용이 유의하면 주효과를 이렇게 읽는 것이
# 오해를 부를 수 있어, 아래 단순효과 분석으로 넘어가는 편이 낫다.
tukey_dose = pairwise_tukeyhsd(endog=df['len'], groups=df['dose'], alpha=0.05)
print("Post-Hoc Test for Dose:")
print(tukey_dose)
# 보충제 종류에 대한 주효과 사후비교.
tukey_supp = pairwise_tukeyhsd(endog=df['len'], groups=df['supp'], alpha=0.05)
print("Post-Hoc Test for Supplement:")
print(tukey_supp)
출력:
Post-Hoc Test for Dose:
Multiple Comparison of Means - Tukey HSD, FWER=0.05
===================================================
group1 group2 meandiff p-adj lower upper reject
---------------------------------------------------
0.5 1.0 9.13 0.0 5.9018 12.3582 True
0.5 2.0 15.495 0.0 12.2668 18.7232 True
1.0 2.0 6.365 0.0 3.1368 9.5932 True
---------------------------------------------------
Post-Hoc Test for Supplement:
Multiple Comparison of Means - Tukey HSD, FWER=0.05
=================================================
group1 group2 meandiff p-adj lower upper reject
-------------------------------------------------
OJ VC -3.7 0.0604 -7.567 0.167 False
-------------------------------------------------
이제 두 분석의 분모를 나란히 놓는다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/ToothGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2, 3])
gm = df.len.mean()
mi = df.groupby('supp').len.mean()
SST = ((df.len - gm) ** 2).sum()
SSA = 3 * 10 * ((mi - gm) ** 2).sum()
SSE2 = ((df.len - df.groupby(['supp', 'dose']).len.transform('mean')) ** 2).sum()
MSE1 = (SST - SSA) / 58 # supp 만 넣은 일원배치
MSE2 = SSE2 / 54 # 이원배치 + 교호작용
print(f"일원배치 MSE = (SST - SS_supp)/58 = {MSE1:.6f} (df 58)")
print(f"이원배치 MSE = SS_E/54 = {MSE2:.6f} (df 54)")
print(f"비 = {MSE1 / MSE2:.4f}, 제곱근 = {np.sqrt(MSE1 / MSE2):.4f}")
d = mi['VC'] - mi['OJ']
print(f"\nVC - OJ = {d:.4f} (어느 쪽에서나 같다)")
for lab, mse, nu in [("일원배치", MSE1, 58), ("이원배치", MSE2, 54)]:
se = np.sqrt(mse * (1 / 30 + 1 / 30))
t = d / se
print(f" {lab}: SE = {se:.4f}, t = {t:.4f}, p = {2 * stats.t.sf(abs(t), nu):.6f}")
print(f"\n일원배치 MSE 안에 들어 있는 것")
SSB = 2 * 10 * ((df.groupby('dose').len.mean() - gm) ** 2).sum()
SSAB = SST - SSA - SSB - SSE2
print(f" SS_dose = {SSB:.4f} ({SSB / (SST - SSA):.1%})")
print(f" SS_교호 = {SSAB:.4f} ({SSAB / (SST - SSA):.1%})")
print(f" SS_잔차 = {SSE2:.4f} ({SSE2 / (SST - SSA):.1%})")
print(f" 합 = {SSB + SSAB + SSE2:.4f} = SST - SS_supp = {SST - SSA:.4f}")
출력:
일원배치 MSE = (SST - SS_supp)/58 = 55.980333 (df 58)
이원배치 MSE = SS_E/54 = 13.187148 (df 54)
비 = 4.2451, 제곱근 = 2.0604
VC - OJ = -3.7000 (어느 쪽에서나 같다)
일원배치: SE = 1.9318, t = -1.9153, p = 0.060393
이원배치: SE = 0.9376, t = -3.9461, p = 0.000231
일원배치 MSE 안에 들어 있는 것
SS_dose = 2426.4343 (74.7%)
SS_교호 = 108.3190 (3.3%)
SS_잔차 = 712.1060 (21.9%)
합 = 3246.8593 = SST - SS_supp = 3246.8593
두 p-값 \(0.060393\) 과 \(0.000231\) 이 각각 pairwise_tukeyhsd 의 \(0.0604\) 와 분산분석표의 \(2.311828\times10^{-4}\) 를 재현한다. 집단이 둘뿐이라 Tukey 가 보통의 \(t\)-검정과 같아진 것도 확인된다(\(q_{\alpha,2,\nu}/\sqrt2 = t_{1-\alpha/2,\nu}\)).
어긋남의 정체는 분모 하나다. 분자는 양쪽에서 똑같이 \(-3.70\) 이고, MSE 가 \(55.98\) 대 \(13.19\) 로 \(4.245\) 배 다르다. 그 제곱근 \(2.0604\) 가 그대로 \(t\) 의 비다. 실제로 \(1.9153 \times 2.0604 = 3.946\) 이다.
마지막 표가 그 \(4.245\) 배의 출처다. 일원배치 오차제곱합 \(3246.86\) 가운데
- \(74.7\%\) 가 용량이 만든 변동(\(\text{SS}_{\text{dose}} = 2426.43\))
- \(3.3\%\) 가 교호작용
- \(21.9\%\) 만이 진짜 잔차
다. 용량을 모형에 넣지 않으면 그 압도적인 변동이 통째로 "잡음"으로 셈해지고, 보충제의 \(3.70\) 이라는 차이가 그 안에 묻힌다. 설계상 통제된 요인을 분석에서 빼는 것은 자료를 버리는 일이다.
(3) 어느 쪽을 보고할 것인가. 자료가 \(2\times3\) 요인설계로 수집되었으므로 이원배치 쪽, 곧 \(p = 0.00023\) 이 옳다. 쪽의 pairwise_tukeyhsd(groups=df['supp']) 호출은 설계를 모르는 채 돌아간 것이다.
그러나 여기에 한 겹이 더 있다. 보기 5에서 보았듯 교호작용이 유의하므로(\(p = 0.022\)) supp 의 주효과 \(-3.70\) 자체를 보고하는 일이 적절하지 않다. 그 값은 용량별 차이 \(+5.25\), \(+5.93\), \(-0.08\) 의 평균이고 어느 용량에서도 실제로 일어나지 않는다. 그러므로 올바른 답은 "\(0.060\) 이 아니라 \(0.00023\) 을 보고하라"가 아니라 "주효과 대신 보기 8의 단순효과를 보고하라"다.
용량은 세 수준이 서로 모두 다르지만, 보충제는 \(p = 0.060\)으로 유의하지 않다. 분산분석표에서 C(supp)가 \(p = 0.00023\)이었던 것과 어긋나 보이는데, 이 Tukey가 용량을 무시하고 OJ 30개와 VC 30개를 통째로 비교하기 때문이다. 용량이 만드는 큰 변동이 잡음으로 남아 보충제의 차이를 덮는다.
3단계: 교호작용 효과에 대한 사후검정¶
보기 7. 3단계 — 교호작용 사후검정. 칸이 여섯이 되면서 치르는 값을 잰다.
(1) 두 요인을 붙여 \(K = ab = 6\) 개 칸으로 보면 쌍이 몇 개인가. 보정하지 않았을 때의 가족단위 오류율(독립 가정)을 구하시오.
(2) 투키의 임계차 \(q_{0.05,6,54}\sqrt{\text{MSE}/n}\) 와 본페로니의 \(t_{1-0.05/30,\,54}\sqrt{2\text{MSE}/n}\) 를 계산해 견주시오. 보기 3의 \(k=3\) 일 때보다 차이가 벌어지는가.
(3) 같은 용량끼리의 세 비교(OJ_d 대 VC_d)를 뽑아 교호작용이 무엇인지 수로 적으시오.
(4) 이 접근이 잃는 것을 하나 지적하시오. 여섯 칸을 아무 구조 없는 여섯 집단으로 다루면 무엇이 사라지는가.
풀이
(1) 해석적으로. \(K = 6\) 이므로 \(m = \binom{6}{2} = 15\) 다. 보정하지 않고 각각 \(\alpha = 0.05\) 로 검정하면(독립 가정)
로 절반이 넘는 확률로 적어도 하나를 거짓 기각한다. 보기 3의 표 마지막 줄이 이것이다. 실제로는 비교들이 양의 상관을 가져 참값이 이보다 조금 작지만, 어느 쪽이든 보정 없이 쓸 수 있는 수치가 아니다.
(2)–(3) 수치적으로. 먼저 쪽의 출력이다.
# 두 요인을 붙여 하나의 요인으로 만든다. 이러면 여섯 칸을 서로 견줄 수 있다.
df['supp_dose'] = df['supp'].astype(str) + "_" + df['dose'].astype(str)
# 칸 여섯 개의 모든 쌍을 견주므로 비교 횟수가 15 로 늘어난다. 그만큼 보수적이 된다.
tukey_interaction = pairwise_tukeyhsd(endog=df['len'], groups=df['supp_dose'], alpha=0.05)
print("Post-Hoc Test for Interaction (Supplement x Dose):")
print(tukey_interaction)
출력:
Post-Hoc Test for Interaction (Supplement x Dose):
Multiple Comparison of Means - Tukey HSD, FWER=0.05
======================================================
group1 group2 meandiff p-adj lower upper reject
------------------------------------------------------
OJ_0.5 OJ_1.0 9.47 0.0 4.6719 14.2681 True
OJ_0.5 OJ_2.0 12.83 0.0 8.0319 17.6281 True
OJ_0.5 VC_0.5 -5.25 0.0243 -10.0481 -0.4519 True
OJ_0.5 VC_1.0 3.54 0.264 -1.2581 8.3381 False
OJ_0.5 VC_2.0 12.91 0.0 8.1119 17.7081 True
OJ_1.0 OJ_2.0 3.36 0.3187 -1.4381 8.1581 False
OJ_1.0 VC_0.5 -14.72 0.0 -19.5181 -9.9219 True
OJ_1.0 VC_1.0 -5.93 0.0074 -10.7281 -1.1319 True
OJ_1.0 VC_2.0 3.44 0.2936 -1.3581 8.2381 False
OJ_2.0 VC_0.5 -18.08 0.0 -22.8781 -13.2819 True
OJ_2.0 VC_1.0 -9.29 0.0 -14.0881 -4.4919 True
OJ_2.0 VC_2.0 0.08 1.0 -4.7181 4.8781 False
VC_0.5 VC_1.0 8.79 0.0 3.9919 13.5881 True
VC_0.5 VC_2.0 18.16 0.0 13.3619 22.9581 True
VC_1.0 VC_2.0 9.37 0.0 4.5719 14.1681 True
------------------------------------------------------
이제 두 문턱을 견주고 같은 용량끼리의 세 비교를 뽑는다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/ToothGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2, 3])
n, K, nu = 10, 6, 54
cell = df.groupby(['supp', 'dose']).len.mean()
MSE = ((df.len - df.groupby(['supp', 'dose']).len.transform('mean')) ** 2).sum() / nu
m = K * (K - 1) // 2
print(f"칸 수 K = {K}, 쌍 수 m = {m}, 보정 없는 FWER (독립 가정) = {1 - 0.95 ** m:.4f}")
q = stats.studentized_range.ppf(0.95, K, nu)
HSD = q * np.sqrt(MSE / n)
SE = np.sqrt(2 * MSE / n)
tb = stats.t.ppf(1 - 0.05 / (2 * m), nu)
print(f"\nMSE = {MSE:.6f}, SE = {SE:.6f}")
print(f"투키 q(0.95,6,54) = {q:.4f} -> |t| 문턱 {q / np.sqrt(2):.4f}, 임계차 {HSD:.4f}")
print(f"본페로니 t(1-0.05/30, 54) = {tb:.4f} -> 임계차 {tb * SE:.4f}")
print(f"투키가 낮은 폭: |t| 에서 {tb - q / np.sqrt(2):.4f}, 임계차에서 {tb * SE - HSD:.4f}")
print(f"\n같은 용량끼리의 세 비교")
for d in [0.5, 1.0, 2.0]:
diff = cell[('VC', d)] - cell[('OJ', d)]
t = abs(diff) / SE
p = stats.studentized_range.sf(t * np.sqrt(2), K, nu)
print(f" dose={d}: OJ-VC 차 = {-diff:>6.2f}, |t| = {t:.4f}, p-adj = {p:.4f}, "
f"구간 ({diff - HSD:>7.3f}, {diff + HSD:>7.3f})")
출력:
칸 수 K = 6, 쌍 수 m = 15, 보정 없는 FWER (독립 가정) = 0.5367
MSE = 13.187148, SE = 1.624017
투키 q(0.95,6,54) = 4.1783 -> |t| 문턱 2.9545, 임계차 4.7981
본페로니 t(1-0.05/30, 54) = 3.0714 -> 임계차 4.9880
투키가 낮은 폭: |t| 에서 0.1169, 임계차에서 0.1899
같은 용량끼리의 세 비교
dose=0.5: OJ-VC 차 = 5.25, |t| = 3.2327, p-adj = 0.0243, 구간 (-10.048, -0.452)
dose=1.0: OJ-VC 차 = 5.93, |t| = 3.6514, p-adj = 0.0074, 구간 (-10.728, -1.132)
dose=2.0: OJ-VC 차 = -0.08, |t| = 0.0493, p-adj = 1.0000, 구간 ( -4.718, 4.878)
임계차 \(4.7981\) 과 p-값·구간이 pairwise_tukeyhsd 의 출력과 소수점 넷째 자리까지 같다. OJ_0.5 VC_0.5 줄의 \((-10.0481,\ -0.4519)\), \(p = 0.0243\) 이 그대로 재현된다.
(2) 두 문턱의 차이. \(\lvert t\rvert\) 척도에서 투키가 \(2.9545\), 본페로니가 \(3.0714\) 로 \(0.1169\) 낮다. 보기 3의 \(k = 3\) 에서는 \(2.4794\) 대 \(2.5525\) 로 \(0.0731\) 이었으니 차이가 \(1.6\) 배로 벌어졌다. 임계차로 보면 \(4.798\) 대 \(4.988\) 로 \(0.19\) 단위만큼 투키가 유리하다. 쪽의 본문이 "\(k\) 가 커질수록 벌어진다"고 한 것의 수치다.
다만 솔직히 적자면 그 폭은 여전히 작다. \(m = 15\) 에서도 본페로니가 투키보다 \(4\%\) 쯤 높은 문턱을 쓸 뿐이다. 투키를 쓰는 진짜 이유는 검정력의 큰 차이가 아니라 임계차가 하나로 떨어져 해석이 간단하고, \(\alpha\) 를 정확히 쓰며, 동시신뢰구간이 자동으로 따라온다는 데 있다.
(3) 교호작용의 내용. 같은 용량끼리의 세 비교가 \(+5.25\) (\(p = 0.024\)), \(+5.93\) (\(p = 0.007\)), \(-0.08\) (\(p = 1.000\))이다. 용량 \(0.5\) 와 \(1.0\) 에서는 OJ 가 앞서고 용량 \(2.0\) 에서는 둘이 구별되지 않는다. 마지막 줄의 구간 \((-4.718,\ 4.878)\) 은 \(0\) 을 품되 폭이 \(9.6\) 이나 되므로, "차이가 없다"가 아니라 "\(\pm 4.8\) 안쪽의 차이는 이 자료로 가려낼 수 없다"로 읽어야 한다. 앞 두 용량의 차이 \(5.25\), \(5.93\) 이 그 폭보다 겨우 큰 정도임을 생각하면, 용량 \(2.0\) 에서 효과가 "사라졌다"는 결론도 조심스럽게 적어야 한다.
(4) 이 접근이 잃는 것. 여섯 칸을 아무 구조 없는 여섯 집단으로 다루면 요인구조가 통째로 사라진다. 구체적으로
- \(15\) 개 비교 가운데 뜻이 분명한 것은 일부뿐이다.
OJ_0.5대VC_2.0같은 비교는 보충제와 용량이 함께 바뀌므로 어느 쪽 탓인지 말할 수 없다. 그런 비교가 \(15\) 개 중 \(6\) 개다. 그런데 보정은 그 \(6\) 개까지 모두 세어 문턱을 올린다. - 교호작용 자체를 검정하지 않는다. 교호작용은 "차이의 차이"(\(5.25\) 와 \(-0.08\) 의 차)인데, 쌍별 비교의 목록에는 그 대비가 아예 들어 있지 않다. 분산분석표의 \(F = 4.11\) 이 재는 것과 이 표가 재는 것은 다른 양이다.
- 용량의 순서가 쓰이지 않는다. \(0.5 < 1.0 < 2.0\) 이라는 순서 정보를 버리고 세 범주로만 다룬다. 추세 대비를 쓰면 자유도 하나로 같은 질문을 더 강하게 물을 수 있다.
그래서 다음에 볼 단순효과 분석이 흔히 더 낫다. 비교 수를 \(15\) 에서 \(3 + 3\) 으로 줄이면서 물음을 뚜렷하게 만든다.
같은 용량끼리 비교한 세 줄(OJ_0.5 VC_0.5, OJ_1.0 VC_1.0, OJ_2.0 VC_2.0)을 보면 차이가 각각 \(-5.25\)(\(p = 0.024\)), \(-5.93\)(\(p = 0.007\)), \(-0.08\)(\(p = 1.000\))이다. 낮은 용량에서는 OJ가 앞서지만 용량 2.0에서는 차이가 사라진다. 이것이 교호작용의 내용이다.
4단계: 단순 효과 분석 (교호작용 사후검정의 대안)¶
교호작용 효과가 유의하면 단순 효과 분석으로 다른 요인의 각 수준에서 한 요인의 효과를 살펴 자세히 나눠 볼 수 있다.
보기 8. 4단계 — 단순효과 분석. 두 조각으로 나누면 제곱합이 정확히 어디로 가는지 적을 수 있다.
(1) 보충제를 고정하고 용량 효과를 재는 두 단순효과 제곱합의 합이
임을 보이시오. 두 잔차제곱합의 합은 무엇이 되는가.
(2) 자유도도 맞아떨어짐을 확인하시오(\(2 + 2\) 대 \(2 + 2\), \(27 + 27\) 대 \(54\)).
(3) 단순효과의 Tukey 반폭을 보기 7의 여섯 칸 Tukey 반폭과 견주시오. 자유도를 \(54\) 에서 \(27\) 로 잃는데도 구간이 좁아지는가.
(4) OJ 와 VC 안에서 각각 용량 \(1.0\) 대 \(2.0\) 을 비교해 교호작용의 내용을 적으시오.
풀이
(1) 해석적으로. 보충제 \(i\) 안에서 용량 효과의 제곱합은
이다. 보기 5의 분해 \(\bar y_{ij\cdot} - \bar y_{i\cdot\cdot} = \beta_j + (\alpha\beta)_{ij}\) 를 넣으면
이고, \(i\) 에 대해 더하면 가운데 항이 \(2n\sum_j \beta_j \sum_i (\alpha\beta)_{ij} = 0\) (교호작용의 열 합이 \(0\))으로 사라진다. 남는 것은
다. \(\square\) 단순효과는 주효과와 교호작용을 합쳐 다시 나눈 것이며, 자른 방향만 다를 뿐 같은 제곱합을 다루고 있다.
잔차제곱합은 더 간단하다. 두 분석 모두 칸 평균에서 재므로
로 이원배치의 잔차제곱합과 정확히 같다.
(2) 해석적으로. 단순효과 쪽은 보충제마다 자유도 \(b-1 = 2\) 이므로 합이 \(4\) 이고, 오른쪽은 \(\text{SS}_{\text{dose}}\) 의 \(2\) 와 \(\text{SS}_{AB}\) 의 \((a-1)(b-1) = 2\) 로 역시 \(4\) 다. 잔차는 보충제마다 \(b(n-1) = 27\) 이므로 합이 \(54 = ab(n-1)\) 로 맞는다. 다만 각 단순효과 분석은 자기 \(27\) 만 쓰고 상대편의 \(27\) 을 쓰지 않는다. 이것이 (3)에서 치르는 값이다.
(3)–(4) 수치적으로. 먼저 쪽의 출력이다.
# 단순효과 분석: 보충제를 하나로 고정해 두고 투여량 효과만 본다.
# 교호작용이 있을 때 결과를 말이 되게 읽는 방법이다.
oj_data = df[df['supp'] == 'OJ']
vc_data = df[df['supp'] == 'VC']
# 보충제별로 따로 일원배치 분산분석을 돌린다.
oj_model = ols('len ~ C(dose)', data=oj_data).fit()
vc_model = ols('len ~ C(dose)', data=vc_data).fit()
# 두 결과를 견주면 교호작용이 무엇을 뜻하는지 드러난다.
print("ANOVA for Dose within Supplement OJ:")
print(anova_lm(oj_model))
print("ANOVA for Dose within Supplement VC:")
print(anova_lm(vc_model))
# 보충제별 사후비교.
print("Tukey HSD for Dose within Supplement OJ:")
print(pairwise_tukeyhsd(endog=oj_data['len'], groups=oj_data['dose'], alpha=0.05))
print("Tukey HSD for Dose within Supplement VC:")
print(pairwise_tukeyhsd(endog=vc_data['len'], groups=vc_data['dose'], alpha=0.05))
출력:
ANOVA for Dose within Supplement OJ:
df sum_sq mean_sq F PR(>F)
C(dose) 2.0 885.264667 442.632333 31.441504 8.887164e-08
Residual 27.0 380.105000 14.077963 NaN NaN
ANOVA for Dose within Supplement VC:
df sum_sq mean_sq F PR(>F)
C(dose) 2.0 1649.488667 824.744333 67.072379 3.357317e-11
Residual 27.0 332.001000 12.296333 NaN NaN
Tukey HSD for Dose within Supplement OJ:
Multiple Comparison of Means - Tukey HSD, FWER=0.05
====================================================
group1 group2 meandiff p-adj lower upper reject
----------------------------------------------------
0.5 1.0 9.47 0.0 5.3096 13.6304 True
0.5 2.0 12.83 0.0 8.6696 16.9904 True
1.0 2.0 3.36 0.1309 -0.8004 7.5204 False
----------------------------------------------------
Tukey HSD for Dose within Supplement VC:
Multiple Comparison of Means - Tukey HSD, FWER=0.05
===================================================
group1 group2 meandiff p-adj lower upper reject
---------------------------------------------------
0.5 1.0 8.79 0.0 4.9018 12.6782 True
0.5 2.0 18.16 0.0 14.2718 22.0482 True
1.0 2.0 9.37 0.0 5.4818 13.2582 True
---------------------------------------------------
이제 (1)의 항등식과 (3)의 반폭을 확인한다.
import numpy as np
import pandas as pd
from scipy import stats
url = 'https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/1dcc2bf5f955cc1224a3e1307256e1fe86b68dae/csv/datasets/ToothGrowth.csv'
df = pd.read_csv(url, usecols=[1, 2, 3])
n = 10
gm = df.len.mean()
SSB = 2 * n * ((df.groupby('dose').len.mean() - gm) ** 2).sum()
SSE = ((df.len - df.groupby(['supp', 'dose']).len.transform('mean')) ** 2).sum()
SSA = 3 * n * ((df.groupby('supp').len.mean() - gm) ** 2).sum()
SST = ((df.len - gm) ** 2).sum()
SSAB = SST - SSA - SSB - SSE
tot_ss, tot_e = 0.0, 0.0
print(f"{'보충제':>6}{'SS_dose':>12}{'SSE':>12}{'MSE':>11}{'df':>5}{'Tukey 반폭':>12}")
for s in ['OJ', 'VC']:
sub = df[df.supp == s]
mu = sub.groupby('dose').len.mean()
ss = n * ((mu - sub.len.mean()) ** 2).sum()
sse = ((sub.len - sub.groupby('dose').len.transform('mean')) ** 2).sum()
mse = sse / 27
half = stats.studentized_range.ppf(0.95, 3, 27) * np.sqrt(mse / n)
tot_ss += ss
tot_e += sse
print(f"{s:>6}{ss:>12.4f}{sse:>12.4f}{mse:>11.6f}{27:>5}{half:>12.4f}")
print(f"\n단순효과 SS 의 합 = {tot_ss:.4f}")
print(f"SS_dose + SS_교호 = {SSB:.4f} + {SSAB:.4f} = {SSB + SSAB:.4f}")
print(f"단순효과 SSE 의 합 = {tot_e:.4f} (= 이원배치 SSE = {SSE:.4f})")
MSE2 = SSE / 54
half6 = stats.studentized_range.ppf(0.95, 6, 54) * np.sqrt(MSE2 / n)
print(f"\n여섯 칸 Tukey 의 반폭 = {half6:.4f} (비교 15 개, df 54)")
print(f"단순효과 Tukey 의 반폭 = 위 표 (비교 3 개씩, df 27)")
print(f"\nOJ 안에서 dose 1.0 vs 2.0")
sub = df[df.supp == 'OJ']
mu = sub.groupby('dose').len.mean()
mse = ((sub.len - sub.groupby('dose').len.transform('mean')) ** 2).sum() / 27
d = mu[2.0] - mu[1.0]
t = d / np.sqrt(2 * mse / n)
print(f" 차 = {d:.2f}, |t| = {abs(t):.4f}, p-adj = {stats.studentized_range.sf(abs(t) * np.sqrt(2), 3, 27):.4f}")
sub = df[df.supp == 'VC']
mu = sub.groupby('dose').len.mean()
mse = ((sub.len - sub.groupby('dose').len.transform('mean')) ** 2).sum() / 27
d = mu[2.0] - mu[1.0]
t = d / np.sqrt(2 * mse / n)
print(f"VC 안에서 dose 1.0 vs 2.0")
print(f" 차 = {d:.2f}, |t| = {abs(t):.4f}, p-adj = {stats.studentized_range.sf(abs(t) * np.sqrt(2), 3, 27):.6f}")
출력:
보충제 SS_dose SSE MSE df Tukey 반폭
OJ 885.2647 380.1050 14.077963 27 4.1604
VC 1649.4887 332.0010 12.296333 27 3.8882
단순효과 SS 의 합 = 2534.7533
SS_dose + SS_교호 = 2426.4343 + 108.3190 = 2534.7533
단순효과 SSE 의 합 = 712.1060 (= 이원배치 SSE = 712.1060)
여섯 칸 Tukey 의 반폭 = 4.7981 (비교 15 개, df 54)
단순효과 Tukey 의 반폭 = 위 표 (비교 3 개씩, df 27)
OJ 안에서 dose 1.0 vs 2.0
차 = 3.36, |t| = 2.0024, p-adj = 0.1309
VC 안에서 dose 1.0 vs 2.0
차 = 9.37, |t| = 5.9750, p-adj = 0.000007
(1)의 항등식이 소수점 넷째 자리까지 맞는다. \(885.2647 + 1649.4887 = 2534.7533\) 이고 \(\text{SS}_{\text{dose}} + \text{SS}_{AB} = 2426.4343 + 108.3190 = 2534.7533\) 이다. 잔차제곱합의 합도 \(712.1060\) 으로 이원배치의 \(\text{SS}_E\) 와 정확히 같다. 두 단순효과 분석이 자료를 쪼개 쓸 뿐 새로 만들거나 버리는 것이 없음을 보여 준다.
(3) 반폭 비교가 뜻밖이다.
| 분석 | 비교 수 | 자유도 | Tukey 반폭 |
|---|---|---|---|
| 여섯 칸 전부 | \(15\) | \(54\) | \(4.7981\) |
| OJ 안에서만 | \(3\) | \(27\) | \(\mathbf{4.1604}\) |
| VC 안에서만 | \(3\) | \(27\) | \(\mathbf{3.8882}\) |
자유도를 \(54\) 에서 \(27\) 로 절반이나 잃는데도 구간이 좁아진다. 비교 수가 \(15\) 에서 \(3\) 으로 줄어 \(q\) 가 \(4.1783\) 에서 \(3.5064\) 로 내려오는 효과가 자유도 손실보다 크기 때문이다. VC 쪽은 MSE 까지 \(12.30\) 으로 전체 \(13.19\) 보다 작아 \(3.89\) 로 더 좁다. 묻는 질문을 좁히면 답이 선명해진다는 다중비교의 일반 원리가 수로 나타난 것이다.
(반대로 OJ 안의 MSE 는 \(14.08\) 로 전체보다 크다. 등분산을 믿는다면 \(54\) 자유도의 합동 MSE 를 쓰고 비교 수만 \(3\) 으로 줄이는 쪽이 가장 좋다. 그 경우 반폭은 \(3.5064\sqrt{13.187/10} = 4.027\) 이 된다. 다만 pairwise_tukeyhsd 를 부분자료에 돌리면 자동으로 그 집단만의 MSE 를 쓰므로, 그렇게 하려면 대비를 직접 짜야 한다.)
(4) 교호작용의 내용. 용량 \(1.0 \to 2.0\) 의 효과가
- OJ 에서 \(+3.36\), \(\lvert t\rvert = 2.00\), \(p^{\text{adj}} = 0.131\) — 유의하지 않다.
- VC 에서 \(+9.37\), \(\lvert t\rvert = 5.98\), \(p^{\text{adj}} = 0.000007\) — 강하게 유의하다.
반면 용량 \(0.5 \to 1.0\) 은 OJ 에서 \(+9.47\), VC 에서 \(+8.79\) 로 둘 다 크고 둘 다 유의하다. 그러므로 교호작용 \(F = 4.11\) (\(p = 0.022\))이 요약한 이야기는 "OJ 는 용량 \(1.0\) 에서 이미 천장에 닿고 VC 는 \(2.0\) 까지 계속 오른다"이다. 보기 5의 칸 평균 표를 다시 보면 OJ 가 \(13.23 \to 22.70 \to 26.06\), VC 가 \(7.98 \to 16.77 \to 26.14\) 로 용량 \(2.0\) 에서 두 곡선이 만난다.
이것이 보기 6의 주효과 \(-3.70\) 이 왜 쓸모없는 보고인지에 대한 최종 답이다. 보충제의 효과는 \(5.25\), \(5.93\), \(-0.08\) 로 용량에 따라 달라지고, 그 평균 하나를 적으면 "두 보충제 중 OJ 가 조금 낫다"는 쪽으로 읽히는데 실제 이야기는 "낮은 용량에서는 OJ 가 확실히 낫고 충분한 용량에서는 어느 쪽이든 같다"이다. 실무적으로 전혀 다른 결론이다.
단순 효과 분석이 교호작용을 가장 또렷하게 보여준다. OJ 안에서는 용량 1.0과 2.0의 차이가 \(p = 0.131\)로 유의하지 않은 반면, VC 안에서는 같은 비교가 \(p < 0.001\)로 강하게 유의하다.
즉 용량을 0.5에서 1.0으로 올리는 것은 두 보충제 모두에서 효과가 있지만, 1.0에서 2.0으로 더 올리는 것은 VC에서만 효과가 있다. 교호작용 항의 \(p = 0.022\)가 요약한 것이 이 이야기다.
D. 단계 요약¶
- 이원배치 분산분석 수행: 유의한 주효과와 교호작용 효과를 파악한다.
- 주효과의 사후검정: 유의한 각 주효과에 대해 Tukey의 HSD나 다른 쌍별 검정을 쓴다.
- 교호작용의 사후검정: 교호작용 효과가 유의하면 결합된 요인 수준에 Tukey의 HSD를 적용하거나 단순 효과 분석을 수행한다.
연습문제¶
연습문제 1. 운동: HIIT \(\bar Y = 8.8\), 근력 \(6.4\), 요가 \(4.4\), 각 \(n = 5\), \(\mathrm{MSW} = 1.43\). (a) 전체 분산분석. (b) Tukey HSD. (c) 해석.
풀이
(a) \(\mathrm{SSB} = 5 \cdot [(8.8-6.53)^2 + (6.4-6.53)^2 + (4.4-6.53)^2] \approx 48.53\).
\(\mathrm{SSW} = 17.20\). \(F = (48.53/2)/(17.20/12) = 24.27/1.43 \approx 16.93\).
\(F_{0.05, 2, 12} = 3.89\)이므로 \(H_0\)을 기각한다.
(b) HSD \(= q_{0.05, 3, 12} \cdot \sqrt{\mathrm{MSW}/n} = 3.77 \cdot \sqrt{1.43/5} \approx 2.02\).
쌍별: HIIT–근력 = 2.4 > 2.02 ✓; HIIT–요가 = 4.4 > 2.02 ✓; 근력–요가 = 2.0 < 2.02 ✗.
(c) HIIT는 다른 두 방식보다 유의하게 낫다. 근력 대 요가는 유의하지 않아 둘을 구별할 수 없다.
연습문제 2. Bonferroni 쌍별 t-검정 대신 Tukey HSD를 쓰는 이유는?
풀이
Tukey HSD는 평균의 쌍별 비교에 정확히 맞춰 보정된 스튜던트화 범위 분포를 쓴다.
Bonferroni는 쌍별 \(t\)-검정에 Bonferroni 보정을 적용한다. \(k = \binom{g}{2}\)일 때 \(\alpha/k\)에서 검정한다.
비교:
- Tukey: 평균의 쌍별 비교에서 더 강력하며 가족단위 오류를 정확히 통제한다.
- Bonferroni: 간단하고 매우 일반적이지만(어떤 검정에도 쓸 수 있지만) 비교가 많으면 보수적이다.
균형 잡힌 일원배치 분산분석의 쌍별 비교에는 Tukey가 표준이다. 복잡한 대비나 혼합 설계에는 Bonferroni나 Scheffé 방법이 필요할 수 있다.
연습문제 3. 다른 사후검정들. Bonferroni, Scheffé, Dunnett을 간략히 설명하라.
풀이
Bonferroni: 각 비교를 \(\alpha/k\)에서 검정한다. 어떤 검정에도 쓸 수 있지만 보수적이다.
Scheffé: 쌍별에 국한되지 않고 임의의 대비(평균의 선형결합)를 허용한다. 가장 보수적이지만 가장 일반적이다.
Dunnett: 모든 집단을 하나의 대조군과 비교한다. 대조군과의 비교만 중요할 때 Tukey보다 강력하다.
Fisher의 LSD: 분산분석의 합동분산을 쓰지만 다중검정 보정을 하지 않는다. 관대하므로 빠른 선별용으로만 유용하다.
가설에 따라 고른다:
- 모든 쌍별 비교: Tukey.
- 모든 집단 대 대조군: Dunnett.
- 임의의 선형 대비: Scheffé.
연습문제 4. 가족단위 오류와 비교단위 오류.
풀이
비교단위: 개별 비교 하나의 제1종 오류율.
가족단위: 한 가족에 속한 모든 비교 중 적어도 하나에서 오류를 범할 확률.
보정하지 않으면 비교 횟수와 함께 가족단위 오류가 커진다. Tukey, Bonferroni 등은 가족단위를 통제한다.
대안: 거짓발견율(FDR)은 유의하다고 선언한 것 중 거짓 기각의 기대 비율을 통제한다. 덜 보수적이어서 비교가 아주 많을 때(예: 유전체학) 유용하다.
연습문제 5. 스튜던트화 범위 분포. 간단히 소개하라.
풀이
스튜던트화 범위 \(q\): \(H_0\) 아래에서 \((\max \bar Y_i - \min \bar Y_i)/\sqrt{\mathrm{MSW}/n}\)의 분포.
다음에 의존한다:
- 집단의 수 \(g\).
- 집단 내 자유도(\(N - g\)).
표: 임계값 \(q_{\alpha, g, df}\)가 표로 제공된다.
Tukey HSD와의 연결: HSD \(= q_{\alpha} \sqrt{\mathrm{MSW}/n}\). 각 쌍별 차이를 HSD와 비교한다.
연습문제 6. 불균형 설계와 Tukey.
풀이
\(n_i\)가 서로 다르면 Tukey-Kramer 수정을 쓴다:
\(\mathrm{HSD}_{ij} = q_{\alpha} \sqrt{(\mathrm{MSW}/2)(1/n_i + 1/n_j)}\).
각 쌍이 (\(n_i, n_j\)에 따라) 자신의 HSD 문턱을 갖는다. 보수적이지만 타당하다.
순수한 Tukey는 \(n\)이 같다고 가정한다. Tukey-Kramer가 표준 확장이다. R의 TukeyHSD와 Python의 statsmodels는 크기가 다르면 Tukey-Kramer를 쓴다.
연습문제 7.
연습문제 5의 스튜던트화 범위 분포를 직접 만들어 qsturng과 대조하고, 임계값이 \(k\)에 따라 어떻게 자라는지 보라.
풀이
정의. \(Z_1,\dots,Z_k\)가 독립 표준정규이고 \(S^2\sim\chi^2_\nu/\nu\)가 독립이면
가 스튜던트화 범위 분포를 따른다. "\(k\)개 평균의 범위를 표준오차로 나눈 것"이다.
import warnings
warnings.filterwarnings("ignore")
import numpy as np
from scipy import stats
from statsmodels.stats.libqsturng import qsturng
rng = np.random.default_rng(13001)
M = 200_000
print("모의실험으로 만든 0.95 분위수와 qsturng 비교")
print(f"{'k':>3s} {'df':>4s} {'모의 0.95 분위수':>14s} {'qsturng':>9s} "
f"{'√2·본페로니 z':>14s}")
for k, df in [(3, 12), (4, 20), (5, 30), (8, 40), (10, 60)]:
z = rng.standard_normal((M, k))
s = np.sqrt(rng.chisquare(df, M) / df)
q = (z.max(1) - z.min(1)) / s
zb = stats.norm.ppf(1 - 0.05 / (2 * (k * (k - 1) // 2)))
print(f"{k:3d} {df:4d} {np.quantile(q, 0.95):14.4f} "
f"{qsturng(0.95, k, df):9.4f} {np.sqrt(2) * zb:14.4f}")
print("\nq 임계값이 k 에 따라 얼마나 자라는가 (df=60 고정)")
print(f"{'k':>4s} {'비교 수':>7s} {'q(0.95)':>9s} {'q/√2':>8s} "
f"{'본페로니 t':>10s} {'차이':>7s}")
for k in [2, 3, 4, 5, 6, 8, 10, 15, 20]:
M2 = k * (k - 1) // 2
q = qsturng(0.95, k, 60)
bc = stats.t.ppf(1 - 0.05 / (2 * M2), 60)
print(f"{k:4d} {M2:7d} {q:9.4f} {q / np.sqrt(2):8.4f} {bc:10.4f} "
f"{100 * (bc - q / np.sqrt(2)) / (q / np.sqrt(2)):6.2f}%")
모의실험으로 만든 0.95 분위수와 qsturng 비교
k df 모의 0.95 분위수 qsturng √2·본페로니 z
3 12 3.7771 3.7711 3.3856
4 20 3.9532 3.9585 3.7311
5 30 4.1114 4.1020 3.9697
8 40 4.5204 4.5206 4.4176
10 60 4.6474 4.6461 4.6114
q 임계값이 k 에 따라 얼마나 자라는가 (df=60 고정)
k 비교 수 q(0.95) q/√2 본페로니 t 차이
2 1 2.8288 2.0003 2.0003 0.00%
3 3 3.3986 2.4032 2.4630 2.49%
4 6 3.7372 2.6426 2.7286 3.25%
5 10 3.9774 2.8124 2.9146 3.63%
6 15 4.1630 2.9437 3.0573 3.86%
8 28 4.4408 3.1401 3.2697 4.13%
10 45 4.6461 3.2853 3.4260 4.28%
15 105 5.0010 3.5362 3.6960 4.52%
20 190 5.2412 3.7061 3.8789 4.66%
모의실험이 qsturng과 소수점 셋째 자리까지 일치한다.
\(k=2\)에서 \(q/\sqrt2\)와 본페로니 \(t\)가 정확히 같다(둘 다 2.0003). 연습문제 3(사후비교 개요 페이지)이 증명한 동치 관계가 수치로 확인된다.
임계값이 \(k\)에 따라 아주 천천히 자란다.
| \(k\) | 비교 수 | \(q/\sqrt2\) | 증가율 |
|---|---|---|---|
| 2 | 1 | 2.000 | — |
| 4 | 6 | 2.643 | \(+32\%\) |
| 10 | 45 | 3.285 | \(+64\%\) |
| 20 | 190 | 3.706 | \(+85\%\) |
비교 수가 190배가 되는 동안 임계값은 1.85배만 커진다. 최댓값의 분위수가 \(\sqrt{2\ln M}\) 정도로 자라기 때문이다.
본페로니와의 차이도 매우 작다(최대 4.66%).
| \(k\) | 튜키 대비 본페로니의 초과 |
|---|---|
| 3 | 2.49% |
| 10 | 4.28% |
| 20 | 4.66% |
본페로니가 생각만큼 나쁘지 않다. 임계값이 5% 이내로 크다는 것은 검정력 손실이 몇 퍼센트 수준이라는 뜻이다(사후비교 개요 페이지 연습문제 6의 FWER 차이와 일관된다).
정규근사 \(\sqrt2 z\)는 \(k\)가 클 때만 쓸 만하다. \(k=3\)에서 3.386 대 정확값 3.771로 10% 작지만, \(k=10\)에서 4.611 대 4.646으로 0.8% 차이다. 자유도가 유한한 것을 무시했기 때문이고, \(\nu\)가 커질수록 나아진다.
qsturng의 구현. statsmodels.stats.libqsturng은 보간표를 쓴다. 스튜던트화 범위 분포의 CDF는 닫힌 식이 없고 이중적분이므로, 정밀한 표를 미리 계산해 두고 보간한다. 모의실험으로도 충분히 정확한 값을 얻을 수 있다는 것이 위 결과다.
연습문제 8. 연습문제 6의 불균형 설계에서 튜키-크레이머가 실제로 FWER을 지키는지 재라.
풀이
튜키-크레이머 수정. 집단 크기가 다르면 표준오차를
로 쓴다. 조화평균 형태이며, \(n_i=n_j=n\)이면 \(\sqrt{\text{MSE}/n}\)으로 환원된다.
이 수정이 정확한 통제를 주는지는 이론적으로 미해결이었다. 하이터(1984)가 보수적임(FWER \(\leq\alpha\))을 증명했다.
import warnings
warnings.filterwarnings("ignore")
import numpy as np
from statsmodels.stats.libqsturng import qsturng
def tk_any(gs, alpha=0.05):
k = len(gs)
n = np.array([len(g) for g in gs], float)
m = np.array([g.mean() for g in gs])
v = np.array([g.var(ddof=1) for g in gs])
MSE = ((n - 1) * v).sum() / (n.sum() - k)
qc = qsturng(1 - alpha, k, n.sum() - k)
for i in range(k):
for j in range(i + 1, k):
if abs(m[i] - m[j]) / np.sqrt(MSE / 2 * (1 / n[i] + 1 / n[j])) > qc:
return True
return False
rng = np.random.default_rng(13002)
B = 5_000
print("등분산·완전 귀무, 명목 0.05")
print(f"{'집단 크기':>26s} {'k':>3s} {'FWER':>8s}")
for ns in [[15, 15, 15], [5, 15, 25], [3, 10, 30],
[20, 20, 20, 20], [5, 10, 20, 40], [4, 4, 4, 40]]:
a = sum(tk_any([rng.normal(0, 1, n) for n in ns]) for _ in range(B))
print(f"{str(ns):>26s} {len(ns):3d} {a / B:8.4f}")
등분산·완전 귀무, 명목 0.05
집단 크기 k FWER
[15, 15, 15] 3 0.0502
[5, 15, 25] 3 0.0506
[3, 10, 30] 3 0.0444
[20, 20, 20, 20] 4 0.0514
[5, 10, 20, 40] 4 0.0408
[4, 4, 4, 40] 4 0.0420
모든 설계에서 0.041~0.051로 명목을 넘지 않는다. 하이터의 정리가 확인된다.
| 설계 | FWER | 판정 |
|---|---|---|
| 균형 \(k=3\) | 0.050 | 정확 |
| 완만한 불균형 | 0.051 | 정확 |
| 심한 불균형 \((3,10,30)\) | 0.044 | 보수적 |
| 균형 \(k=4\) | 0.051 | 정확 |
| \((5,10,20,40)\) | 0.041 | 보수적 |
| \((4,4,4,40)\) | 0.042 | 보수적 |
불균형이 심할수록 보수적이 된다. 0.050에서 0.041로 떨어진다.
왜 보수적인가. 균형이면 모든 쌍의 표준오차가 같아 "최대 차이"가 곧 스튜던트화 범위다. 불균형이면 쌍마다 표준오차가 달라, 표준오차로 나눈 뒤의 최댓값이 원래의 범위 통계량보다 덜 극단적이 된다.
손실은 크지 않다. FWER이 0.041이면 명목의 82%로, 검정력 손실은 몇 퍼센트 수준이다.
주의 — 이것은 등분산일 때의 이야기다. 불균형 + 이분산이면 튜키-크레이머가 무너진다(사후비교 개요 페이지 연습문제 9에서 FWER 0.362). 불균형 자체는 괜찮지만 이분산과 겹치면 위험하다.
| 상황 | 튜키-크레이머 |
|---|---|
| 균형 · 등분산 | 정확 |
| 불균형 · 등분산 | 보수적(안전) |
| 균형 · 이분산 | 0.104 (위험) |
| 불균형 · 이분산 | 0.362 (매우 위험) |
실무 지침 셋.
- 불균형만이면 튜키-크레이머를 그대로 쓴다.
statsmodels의pairwise_tukeyhsd가 자동 적용한다. - 분산비를 반드시 확인한다. 불균형 + 이분산이면 게임스-하웰로.
- 작은 집단의 \(s_i\)는 매우 부정확하므로, 분산비 판단에 신중해야 한다.
연습문제 9. 튜키보다 검정력이 높다고 선전되는 단계적 절차(뉴먼-쿨스)가 실제로 FWER을 지키는지 확인하라.
풀이
뉴먼-쿨스(SNK) 절차. 평균을 정렬한 뒤, 순위 간격이 \(p\)인 쌍에는 \(q_{\alpha,p,\nu}\)를 쓴다.
| 비교 | 튜키 | 뉴먼-쿨스 |
|---|---|---|
| 가장 먼 쌍(\(p=k\)) | \(q_{\alpha,k,\nu}\) | \(q_{\alpha,k,\nu}\) |
| 이웃한 쌍(\(p=2\)) | \(q_{\alpha,k,\nu}\) | \(q_{\alpha,2,\nu}\)(훨씬 작다) |
가까운 쌍에 느슨한 문턱을 쓰므로 검정력이 높아 보인다.
import warnings
warnings.filterwarnings("ignore")
import numpy as np
from scipy import stats
from statsmodels.stats.libqsturng import qsturng
def tk_any(gs, alpha=0.05):
k = len(gs)
n = np.array([len(g) for g in gs], float)
m = np.array([g.mean() for g in gs])
v = np.array([g.var(ddof=1) for g in gs])
MSE = ((n - 1) * v).sum() / (n.sum() - k)
qc = qsturng(1 - alpha, k, n.sum() - k)
return any(abs(m[i] - m[j]) / np.sqrt(MSE / 2 * (1 / n[i] + 1 / n[j])) > qc
for i in range(k) for j in range(i + 1, k))
def snk_any(gs, alpha=0.05):
"""뉴먼-쿨스: 순위 간격 p 에 따라 q(α, p, df) 를 쓴다."""
k = len(gs)
n = np.array([len(g) for g in gs], float)
m = np.array([g.mean() for g in gs])
v = np.array([g.var(ddof=1) for g in gs])
MSE = ((n - 1) * v).sum() / (n.sum() - k)
dfe = n.sum() - k
o = np.argsort(m)
for a in range(k):
for b in range(a + 1, k):
i, j = o[a], o[b]
p = b - a + 1
if (abs(m[i] - m[j]) / np.sqrt(MSE / 2 * (1 / n[i] + 1 / n[j]))
> qsturng(1 - alpha, p, dfe)):
return True
return False
def lsd_any(gs, alpha=0.05):
k = len(gs)
n = np.array([len(g) for g in gs], float)
m = np.array([g.mean() for g in gs])
v = np.array([g.var(ddof=1) for g in gs])
MSE = ((n - 1) * v).sum() / (n.sum() - k)
tc = stats.t.ppf(1 - alpha / 2, int(n.sum() - k))
return any(abs(m[i] - m[j]) / np.sqrt(MSE * (1 / n[i] + 1 / n[j])) > tc
for i in range(k) for j in range(i + 1, k))
rng = np.random.default_rng(13002)
B = 5_000
print("등분산·균형·완전 귀무에서의 FWER, 명목 0.05")
print(f"{'k':>3s} {'n':>4s} {'튜키':>8s} {'뉴먼-쿨스':>10s} {'LSD(무보정)':>12s}")
for k, n in [(3, 12), (4, 12), (6, 12), (8, 12)]:
a = b = c = 0
for _ in range(B):
gs = [rng.normal(0, 1, n) for _ in range(k)]
a += tk_any(gs)
b += snk_any(gs)
c += lsd_any(gs)
print(f"{k:3d} {n:4d} {a / B:8.4f} {b / B:10.4f} {c / B:12.4f}")
등분산·균형·완전 귀무에서의 FWER, 명목 0.05
k n 튜키 뉴먼-쿨스 LSD(무보정)
3 12 0.0534 0.0606 0.1276
4 12 0.0476 0.0548 0.1984
6 12 0.0484 0.0528 0.3550
8 12 0.0520 0.0538 0.4920
뉴먼-쿨스가 완전 귀무에서는 거의 명목을 지킨다(0.053~0.061). 약간 초과하지만 심각하지 않다.
| \(k\) | 튜키 | 뉴먼-쿨스 | LSD |
|---|---|---|---|
| 3 | 0.053 | 0.061 | 0.128 |
| 4 | 0.048 | 0.055 | 0.198 |
| 6 | 0.048 | 0.053 | 0.355 |
| 8 | 0.052 | 0.054 | 0.492 |
그런데 완전 귀무가 뉴먼-쿨스의 최선의 경우다. 문제는 부분 귀무에서 나타난다.
왜 부분 귀무가 문제인가. 집단 평균이 \((0,0,0,0,10)\)처럼 한 무리와 떨어진 하나로 이루어져 있다고 하자.
정렬된 평균: G1 G2 G3 G4 G5
●───●───●───● ●
└──── 참으로 같음 ────┘ (멀리 떨어짐)
뉴먼-쿨스는 G1~G4 안의 이웃 쌍에 q(α, 2, ν) 를 쓴다
→ 사실상 보정 없는 LSD
→ G1~G4 안에서의 FWER 이 통제되지 않는다
\(m\)개의 동일한 평균이 무리를 이루면, 그 무리 안의 FWER이 \(m\)개 집단에 대한 LSD 수준이 된다. \(m=4\)이면 0.20 수준이다.
이것이 뉴먼-쿨스가 현대 교과서에서 권장되지 않는 이유다. "부분 귀무에서 FWER을 통제하지 못한다"는 성질을 강한 의미의 FWER 통제 실패라 한다.
| 절차 | 완전 귀무 | 부분 귀무 |
|---|---|---|
| 튜키 | 통제 | 통제 |
| 뉴먼-쿨스 | 통제 | 실패 |
| LSD(\(k\geq4\)) | 실패 | 실패 |
| 던컨 | 통제 안 함 | 실패 |
LSD의 완전 귀무 FWER은 \(k=8\)에서 0.492다. 보호된 LSD(피셔의 LSD)는 \(F\) 검정을 먼저 요구해 완전 귀무는 막지만 부분 귀무는 막지 못한다.
현대적 대안.
| 원하는 것 | 절차 |
|---|---|
| 강한 FWER 통제 + 단계적 검정력 | 홀름, 셰페-홀름 |
| 쌍별 + 정확한 통제 | 튜키 |
| FDR 통제(FWER보다 느슨) | 벤자미니-호흐베르크 |
마지막이 실용적 대안이다. 비교가 아주 많으면 FWER 대신 거짓발견율(FDR)을 통제하는 것이 합리적일 수 있다. 다만 보장하는 것이 다르다는 점을 밝혀야 한다.
연습문제 10. 튜키 HSD의 사용 지침을 정리하라.
풀이
한 줄 정의. 스튜던트화 범위 분포를 기준으로 모든 쌍별 차이를 동시에 검정한다.
핵심 수치 다섯.
| 사실 | 값 |
|---|---|
| 균형·등분산에서의 FWER | 0.046~0.054(정확) |
| 불균형·등분산 | 0.041~0.051(보수적) |
| 이분산 | 0.104~0.362(위험) |
| \(k=2\)에서 \(q/\sqrt2\) | \(=t_{\alpha/2,\nu}\)(정확히 같음) |
| 본페로니 임계값과의 차이 | 최대 4.66%(\(k=20\)) |
사용 조건.
| 조건 | 필요성 | 위반 시 |
|---|---|---|
| 정규성 | 보통 | 순열 튜키 |
| 등분산 | 결정적 | 게임스-하웰 |
| 독립성 | 결정적 | 혼합효과 모형 |
| 균형 | 불필요 | 튜키-크레이머(자동) |
왜 쓰는가 — 다른 절차와 비교.
| 튜키 | 본페로니 | 셰페 | |
|---|---|---|---|
| 통제 대상 | 모든 쌍 | 지정한 \(M\)개 | 모든 대비 |
| 정확도(쌍별) | 정확 | 보수적 | 매우 보수적 |
| 신뢰구간 | 쉽다 | 쉽다 | 쉽다 |
| 이분산 | 취약 | 취약 | 취약 |
쌍별 비교를 모두 볼 것이라면 튜키가 최적이다. 다른 절차는 모두 보수적이다.
파이썬 사용법.
from statsmodels.stats.multicomp import pairwise_tukeyhsd
res = pairwise_tukeyhsd(endog=y, groups=g, alpha=0.05)
print(res) # 차이, 보정 p, 95% 구간, 기각 여부
res.plot_simultaneous() # 동시 신뢰구간 그림
결과를 읽는 법.
| 열 | 의미 |
|---|---|
meandiff |
\(\bar y_j-\bar y_i\) |
p-adj |
튜키 보정된 \(p\) |
lower, upper |
동시 95% 신뢰구간 |
reject |
구간이 0을 포함하지 않는가 |
lower·upper가 "동시" 구간이라는 점이 중요하다. 모든 구간이 동시에 참값을 덮을 확률이 95%다. 개별 구간보다 넓다.
자주 하는 실수 넷.
| 실수 | 대가 |
|---|---|
| 이분산인데 튜키 | FWER 0.36 |
| 웰치 + 튜키 조합 | 가정이 모순 |
| 뉴먼-쿨스로 "검정력을 높임" | 부분 귀무에서 통제 실패 |
| 유의한 쌍만 보고 | 구간과 효과크기를 함께 |
"\(F\)가 유의해야 튜키를 하는가." 불필요하다. 튜키가 스스로 FWER을 통제하므로, \(F\) 검정을 관문으로 두면 두 관문을 통과해야 해 오히려 보수적이 된다.
실제로 \(F\)가 유의하지 않은데 튜키가 유의한 쌍을 찾는 일이 가능하다. 드물지만 모순이 아니다. 두 검정이 다른 대립가설을 본다.
보고 형식.
일원배치 분산분석: F(2, 12) = 21.4, p < 0.001
사후검정: 튜키 HSD (FWER = 0.05)
비교 차이 95% 동시구간 p-adj
HIIT–근력 2.40 [0.36, 4.44] 0.021
HIIT–요가 4.40 [2.36, 6.44] 0.001
근력–요가 2.00 [-0.04, 4.04] 0.054
근력–요가의 \(p=0.054\)를 "차이 없음"으로 읽지 않는다. 구간 \([-0.04,\,4.04]\)가 0을 간신히 포함할 뿐, 2.0의 차이를 배제하지 못한다.
한 문장. 튜키 HSD는 모든 쌍별 비교를 위한 정확한 절차이며, 그 정확성은 등분산 위에서만 성립한다.
정리하며¶
사후검정은 \(F\) 가 기각한 뒤에 어느 쌍이 다른지 찾는다.
- 투키 HSD 가 쌍별 비교의 표준이다. 모든 쌍을 동시에 다루면서 FWER 을 정확히 \(\alpha\) 로 통제하며, 스튜던트화 범위분포를 쓴다.
- 본페로니보다 검정력이 높다. 쌍별 비교들은 하나의 합동 MSE 와 하나의 오차 자유도 \(N-k\) 를 공유하고 같은 집단 평균을 함께 쓴다 — 균형 설계에서 두 비교의 상관이 정확히 \(1/2\) 이다. 투키는 이 알려진 종속 구조를 스튜던트화 범위분포로 그대로 쓰고, 본페로니는 합집합 한계를 쓰느라 그것을 버린다. 쌍별 비교가 목적이라면 투키가 낫다.
- 표본크기가 같을 때 가장 잘 작동한다. 불균형이면 투키–크레이머 수정을 쓴다.
- 등분산을 가정한다. 합동 MSE 를 쓰므로 분산이 다르면 게임스–하월로 가야 한다.
- "\(F\) 가 유의할 때만 사후검정"이라는 관행이 널리 쓰이지만, 투키는 그 자체로 FWER 을 통제하므로 논리적으로 필수는 아니다. 다만 관행을 따르는 편이 보고에 안전하다.
다음 절 Bonferroni와 Scheffé 방법으로 넘어간다. 목적이 다르면 도구도 달라진다.