分散分析(ANOVA)の理論と実装を解説

分散分析(Analysis of Variance, ANOVA)は、3つ以上の群の平均に差があるかどうかを検定する手法です。t検定が2群の比較に使われるのに対し、ANOVAは複数群を一度に比較できます。

例えば、3種類の肥料を使った植物の成長差を比較する、4つの異なる教授法の効果を比較するなど、実験研究で頻繁に使われます。

本記事の内容

  • 一元配置分散分析の原理
  • 群間変動と群内変動の分解
  • F検定の仕組み
  • 多重比較法
  • Pythonでの実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

なぜt検定を繰り返してはいけないのか

3群 A, B, C の平均を比較したいとき、t検定を3回(A対B、A対C、B対C)行うことが考えられます。しかし、これには多重検定問題があります。

各検定の有意水準を $\alpha = 0.05$ とすると、すべてで正しく帰無仮説を棄却しない確率は $(1-0.05)^3 = 0.857$ であり、少なくとも1回偽陽性が出る確率は $1 – 0.857 = 0.143$ に膨れ上がります。群の数が増えるとこの問題はさらに深刻になります。

ANOVAは、1回の検定で複数群の平均を同時に比較することで、この問題を回避します。

一元配置分散分析

設定

$k$ 個の群があり、第 $i$ 群から $n_i$ 個のデータを観測します。第 $i$ 群の $j$ 番目の観測値を $X_{ij}$ とします。

モデル:

$$ X_{ij} = \mu_i + \varepsilon_{ij}, \quad \varepsilon_{ij} \sim N(0, \sigma^2) $$

帰無仮説: $H_0: \mu_1 = \mu_2 = \cdots = \mu_k$

対立仮説: $H_1:$ 少なくとも1組の $\mu_i \neq \mu_j$ が存在する

仮定

  1. 各群が正規分布に従う
  2. 各群の分散が等しい(等分散性)
  3. 観測が独立

記号の定義

  • 全データ数: $N = \sum_{i=1}^{k} n_i$
  • 第 $i$ 群の平均: $\bar{X}_{i\cdot} = \frac{1}{n_i}\sum_{j=1}^{n_i} X_{ij}$
  • 全体平均: $\bar{X}_{\cdot\cdot} = \frac{1}{N}\sum_{i=1}^{k}\sum_{j=1}^{n_i} X_{ij}$

変動の分解

ANOVAの核心は、データの全変動を群間変動群内変動に分解することです。

全平方和(SST)

$$ \text{SST} = \sum_{i=1}^{k}\sum_{j=1}^{n_i} (X_{ij} – \bar{X}_{\cdot\cdot})^2 $$

群間平方和(SSB)

$$ \text{SSB} = \sum_{i=1}^{k} n_i (\bar{X}_{i\cdot} – \bar{X}_{\cdot\cdot})^2 $$

群間変動は「各群の平均が全体平均からどれだけずれているか」を測ります。

群内平方和(SSW)

$$ \text{SSW} = \sum_{i=1}^{k}\sum_{j=1}^{n_i} (X_{ij} – \bar{X}_{i\cdot})^2 $$

群内変動は「各群内のデータのばらつき」を測ります。

分解公式

$$ \boxed{\text{SST} = \text{SSB} + \text{SSW}} $$

全変動 = 群間変動 + 群内変動 です。

導出

$$ \begin{align} X_{ij} – \bar{X}_{\cdot\cdot} &= (X_{ij} – \bar{X}_{i\cdot}) + (\bar{X}_{i\cdot} – \bar{X}_{\cdot\cdot}) \end{align} $$

両辺を2乗して総和をとり、交差項が0になることを利用すると分解公式が得られます。

F検定

平均平方

$$ \text{MSB} = \frac{\text{SSB}}{k – 1}, \quad \text{MSW} = \frac{\text{SSW}}{N – k} $$

F統計量

$$ F = \frac{\text{MSB}}{\text{MSW}} $$

$H_0$ のもとで $F \sim F(k-1, N-k)$(F分布)に従います。

直感的な理解

F統計量は「群間の変動と群内の変動の比」です。

  • 群の平均が大きく異なる → SSBが大きい → Fが大きい → $H_0$ を棄却
  • 群の平均がほぼ同じ → SSBが小さい → Fが1に近い → $H_0$ を棄却できない

ANOVA表

変動要因 平方和 自由度 平均平方 F値
群間 SSB $k-1$ MSB $F = \text{MSB}/\text{MSW}$
群内 SSW $N-k$ MSW
全体 SST $N-1$

多重比較法

ANOVAで帰無仮説が棄却された場合、どの群間に差があるかを特定するために多重比較(post-hoc test)を行います。

テューキーのHSD法

すべてのペアワイズ比較を同時に行い、ファミリーワイズエラー率を制御します。

$$ q = \frac{\bar{X}_{i\cdot} – \bar{X}_{j\cdot}}{\sqrt{\text{MSW}/n}} $$

ここでスチューデント化範囲分布の臨界値と比較します。

ボンフェローニ法

$k(k-1)/2$ 組のt検定を行い、有意水準を $\alpha / [k(k-1)/2]$ に補正します。

Pythonでの実装

一元配置ANOVAの手計算

import numpy as np
from scipy import stats

np.random.seed(42)

# 3群のデータ(肥料A, B, Cの効果)
group_a = np.array([23, 25, 27, 22, 26, 28])
group_b = np.array([30, 32, 29, 31, 33, 28])
group_c = np.array([25, 27, 26, 24, 28, 23])

groups = [group_a, group_b, group_c]
k = len(groups)
N = sum(len(g) for g in groups)

# 各群の平均
means = [np.mean(g) for g in groups]
grand_mean = np.mean(np.concatenate(groups))

print(f"Group A mean: {means[0]:.2f}")
print(f"Group B mean: {means[1]:.2f}")
print(f"Group C mean: {means[2]:.2f}")
print(f"Grand mean: {grand_mean:.2f}")

# 平方和の計算
SSB = sum(len(g) * (m - grand_mean)**2 for g, m in zip(groups, means))
SSW = sum(np.sum((g - m)**2) for g, m in zip(groups, means))
SST = np.sum((np.concatenate(groups) - grand_mean)**2)

print(f"\nSSB = {SSB:.2f}")
print(f"SSW = {SSW:.2f}")
print(f"SST = {SST:.2f}")
print(f"SSB + SSW = {SSB + SSW:.2f} (= SST)")

# F統計量
df_between = k - 1
df_within = N - k
MSB = SSB / df_between
MSW = SSW / df_within
F_stat = MSB / MSW
p_value = 1 - stats.f.cdf(F_stat, df_between, df_within)

print(f"\nMSB = {MSB:.2f}")
print(f"MSW = {MSW:.2f}")
print(f"F = {F_stat:.4f}")
print(f"p-value = {p_value:.4f}")

# SciPyによる検証
F_scipy, p_scipy = stats.f_oneway(group_a, group_b, group_c)
print(f"\nSciPy F = {F_scipy:.4f}")
print(f"SciPy p = {p_scipy:.4f}")

変動の分解の可視化

import numpy as np
import matplotlib.pyplot as plt

group_a = np.array([23, 25, 27, 22, 26, 28])
group_b = np.array([30, 32, 29, 31, 33, 28])
group_c = np.array([25, 27, 26, 24, 28, 23])

groups = [group_a, group_b, group_c]
labels = ['A', 'B', 'C']
colors = ['steelblue', 'orange', 'green']
grand_mean = np.mean(np.concatenate(groups))

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# (1) データのプロット
ax = axes[0]
for i, (g, label, color) in enumerate(zip(groups, labels, colors)):
    ax.scatter([i] * len(g), g, color=color, s=80, zorder=5, label=f'Group {label}')
    ax.hlines(np.mean(g), i - 0.3, i + 0.3, colors=color, linewidth=2)
ax.axhline(y=grand_mean, color='red', linestyle='--', linewidth=2, label='Grand mean')
ax.set_xticks([0, 1, 2])
ax.set_xticklabels(labels)
ax.set_ylabel('Value')
ax.set_title('Data and group means')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# (2) 群間変動
ax = axes[1]
for i, (g, label, color) in enumerate(zip(groups, labels, colors)):
    group_mean = np.mean(g)
    ax.bar(i, len(g) * (group_mean - grand_mean)**2, color=color, alpha=0.7)
ax.set_xticks([0, 1, 2])
ax.set_xticklabels(labels)
ax.set_ylabel('Contribution to SSB')
ax.set_title(f'Between-group variation (SSB = {sum(len(g)*(np.mean(g)-grand_mean)**2 for g in groups):.1f})')
ax.grid(True, alpha=0.3)

# (3) 群内変動
ax = axes[2]
for i, (g, label, color) in enumerate(zip(groups, labels, colors)):
    ax.bar(i, np.sum((g - np.mean(g))**2), color=color, alpha=0.7)
ax.set_xticks([0, 1, 2])
ax.set_xticklabels(labels)
ax.set_ylabel('Contribution to SSW')
ax.set_title(f'Within-group variation (SSW = {sum(np.sum((g-np.mean(g))**2) for g in groups):.1f})')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('anova_decomposition.png', dpi=150, bbox_inches='tight')
plt.show()

F分布の可視化

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

fig, ax = plt.subplots(figsize=(10, 6))

x = np.linspace(0, 6, 500)

params = [(2, 10), (5, 10), (10, 10), (5, 30)]
for df1, df2 in params:
    ax.plot(x, stats.f.pdf(x, df1, df2), linewidth=2, label=f'$F({df1}, {df2})$')

ax.set_xlabel('$x$')
ax.set_ylabel('Density')
ax.set_title('F-distribution')
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_ylim(0, 1)

plt.tight_layout()
plt.savefig('f_distribution.png', dpi=150, bbox_inches='tight')
plt.show()

テューキーのHSD法

import numpy as np
from scipy import stats
from itertools import combinations

def tukey_hsd(groups, labels, alpha=0.05):
    """テューキーのHSD法による多重比較"""
    k = len(groups)
    N = sum(len(g) for g in groups)
    SSW = sum(np.sum((g - np.mean(g))**2) for g in groups)
    MSW = SSW / (N - k)

    print("Tukey's HSD pairwise comparisons:")
    print("-" * 50)

    for (i, gi), (j, gj) in combinations(enumerate(groups), 2):
        mean_diff = abs(np.mean(gi) - np.mean(gj))
        se = np.sqrt(MSW * (1/len(gi) + 1/len(gj)) / 2)
        q = mean_diff / se

        # statsmodelsを使わない場合の近似
        # スチューデント化範囲分布の代わりにt分布で近似
        df = N - k
        t_val = mean_diff / np.sqrt(MSW * (1/len(gi) + 1/len(gj)))
        p_val = 2 * (1 - stats.t.cdf(abs(t_val), df)) * k * (k-1) / 2  # ボンフェローニ補正
        p_val = min(p_val, 1.0)

        sig = "*" if p_val < alpha else "n.s."
        print(f"{labels[i]} vs {labels[j]}: diff = {mean_diff:.2f}, p = {p_val:.4f} {sig}")

group_a = np.array([23, 25, 27, 22, 26, 28])
group_b = np.array([30, 32, 29, 31, 33, 28])
group_c = np.array([25, 27, 26, 24, 28, 23])

tukey_hsd([group_a, group_b, group_c], ['A', 'B', 'C'])

まとめ

本記事では、分散分析(ANOVA)の理論と実装を解説しました。

  • ANOVA: 3群以上の平均の差を1回の検定で判定する手法
  • 変動の分解: $\text{SST} = \text{SSB} + \text{SSW}$(全変動 = 群間変動 + 群内変動)
  • F検定: $F = \text{MSB}/\text{MSW}$ でF分布に基づく検定を行う
  • 多重比較: ANOVAで有意差が出た後、テューキーのHSD法などでペアワイズ比較
  • t検定の繰り返しではなくANOVAを使う理由は多重検定問題の回避

ANOVAは実験計画法や品質管理の基礎となる重要な手法です。二元配置分散分析(交互作用の分析)へと発展することもできます。