サイコロを600回投げて各目の出現回数を数えたところ、1の目が120回、2の目が85回、3の目が95回、4の目が110回、5の目が90回、6の目が100回でした。このサイコロは公平(各目の確率が1/6)と言えるでしょうか。
あるいは、新薬の臨床試験で「改善あり/改善なし」の結果が、治療群とプラセボ群で以下のようになったとします。
| 改善あり | 改善なし | |
|---|---|---|
| 治療群 | 45 | 55 |
| プラセボ群 | 30 | 70 |
治療の効果と改善の有無は関連しているのでしょうか。
これらの問いに答えるのがカイ二乗検定(chi-squared test、$\chi^2$ 検定)です。t検定が連続値の平均を比較するのに対し、カイ二乗検定はカテゴリカルデータ(数えられるデータ)を扱います。
カイ二乗検定を理解すると、以下の分析が可能になります。
- 品質管理: 不良品の種類が理論分布に従うかの検定
- 遺伝学: メンデルの法則(分離比)の検定
- マーケティング: 購買行動と顧客属性の独立性の検定
- 疫学: リスク因子と疾患の関連の検定
本記事の内容
- カイ二乗統計量の定義と直感
- 適合度検定(1変量のカテゴリ)
- 独立性検定(2変量の分割表)
- イェーツの連続修正
- 効果量(クラメールのV)
- Pythonでの実装と可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
カイ二乗統計量の直感
観測度数と期待度数
カイ二乗検定の核心は「観測された度数」と「理論的に期待される度数」の比較です。
サイコロの例では、各目の期待度数は $600/6 = 100$ 回です。観測度数と期待度数のずれを測るのがカイ二乗統計量です。
ピアソンのカイ二乗統計量
$$ \begin{equation} \chi^2 = \sum_{i=1}^{k} \frac{(O_i – E_i)^2}{E_i} \end{equation} $$
ここで $O_i$ は観測度数、$E_i$ は期待度数、$k$ はカテゴリ数です。
なぜ $(O_i – E_i)^2$ を $E_i$ で割るのでしょうか。期待度数が大きいカテゴリでは多少のずれは当然であり、期待度数が小さいカテゴリでは同じずれでも相対的に大きな意味を持ちます。$E_i$ で割ることで、この相対的な重要度を調整しています。
帰無仮説のもとで、サンプルサイズが大きいとき $\chi^2 \approx \chi^2_{k-1-p}$ に従います($p$ は推定したパラメータの数)。
この統計量を使って、まず適合度検定を見ていきましょう。
適合度検定
問題設定
ある離散分布に対して「データがこの分布に従うか」を検定します。
$$ H_0: P(X = c_i) = p_i, \quad i = 1, \ldots, k $$
検定統計量
各カテゴリの期待度数は $E_i = np_i$ です。
$$ \chi^2 = \sum_{i=1}^{k} \frac{(O_i – np_i)^2}{np_i} $$
$H_0$ のもとで $\chi^2 \approx \chi^2_{k-1}$ です。自由度が $k-1$ なのは、$\sum O_i = n$(合計が固定)の制約で1つの自由度が失われるためです。
適用条件
カイ二乗近似が良好なための条件として、すべての期待度数が5以上であることが一般的な目安です。期待度数が小さいカテゴリがある場合は、隣接するカテゴリを統合するか、フィッシャーの正確検定を使います。
適合度検定を理解したところで、2変量の独立性検定に進みましょう。
独立性検定
問題設定
$r \times c$ の分割表(contingency table)が与えられたとき、2つのカテゴリカル変数が独立かどうかを検定します。
$$ H_0: p_{ij} = p_{i\cdot} \times p_{\cdot j} \quad \text{(独立)} $$
期待度数の計算
独立の仮定のもとで、セル $(i, j)$ の期待度数は
$$ \begin{equation} E_{ij} = \frac{R_i \times C_j}{n} \end{equation} $$
ここで $R_i$ は第 $i$ 行の合計、$C_j$ は第 $j$ 列の合計、$n$ は全体の合計です。
検定統計量
$$ \chi^2 = \sum_{i=1}^{r}\sum_{j=1}^{c} \frac{(O_{ij} – E_{ij})^2}{E_{ij}} $$
自由度は $(r-1)(c-1)$ です。行の合計と列の合計が固定されるため、$r – 1$ 行 $\times$ $c – 1$ 列の自由度が残ります。
$2 \times 2$ 分割表の場合
最もよく使われる $2 \times 2$ 分割表では、自由度は $(2-1)(2-1) = 1$ です。
| B = 1 | B = 0 | 合計 | |
|---|---|---|---|
| A = 1 | $a$ | $b$ | $a+b$ |
| A = 0 | $c$ | $d$ | $c+d$ |
| 合計 | $a+c$ | $b+d$ | $n$ |
カイ二乗統計量は
$$ \chi^2 = \frac{n(ad – bc)^2}{(a+b)(c+d)(a+c)(b+d)} $$
$2 \times 2$ の場合はイェーツの連続修正を適用することが推奨されます。
$$ \chi^2_{\text{Yates}} = \frac{n(|ad – bc| – n/2)^2}{(a+b)(c+d)(a+c)(b+d)} $$
これは離散分布(二項分布)を連続分布(カイ二乗分布)で近似することによるバイアスを補正するものです。
効果量:クラメールのV
カイ二乗統計量はサンプルサイズに依存するため、効果の大きさの指標としては不適切です。効果量としてクラメールのVを使います。
$$ \begin{equation} V = \sqrt{\frac{\chi^2}{n \cdot \min(r-1, c-1)}} \end{equation} $$
$V$ は0から1の値を取り、0は完全な独立、1は完全な関連を示します。
Pythonで適合度検定と独立性検定を実装しましょう。
Pythonでの実装と可視化
適合度検定の実装
サイコロの公平性検定を実装します。
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
# サイコロのデータ
observed = np.array([120, 85, 95, 110, 90, 100])
n = np.sum(observed)
expected_prob = np.ones(6) / 6
expected = n * expected_prob
# カイ二乗統計量の手動計算
chi2_manual = np.sum((observed - expected)**2 / expected)
df = len(observed) - 1
p_value = 1 - stats.chi2.cdf(chi2_manual, df)
# SciPyで検証
chi2_scipy, p_scipy = stats.chisquare(observed)
print(f"カイ二乗統計量: {chi2_manual:.4f} (scipy: {chi2_scipy:.4f})")
print(f"自由度: {df}")
print(f"p値: {p_value:.4f} (scipy: {p_scipy:.4f})")
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# (a) 観測度数 vs 期待度数
ax = axes[0]
x_pos = np.arange(6)
width = 0.35
ax.bar(x_pos - width/2, observed, width, color='steelblue', alpha=0.8,
label='Observed', edgecolor='gray')
ax.bar(x_pos + width/2, expected, width, color='salmon', alpha=0.8,
label='Expected', edgecolor='gray')
ax.set_xlabel('Die face', fontsize=12)
ax.set_ylabel('Count', fontsize=12)
ax.set_title(f'Goodness of Fit Test\n$\\chi^2 = {chi2_manual:.2f}$, p = {p_value:.4f}', fontsize=12)
ax.set_xticks(x_pos)
ax.set_xticklabels([1, 2, 3, 4, 5, 6])
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis='y')
# (b) 各セルの寄与
ax = axes[1]
contributions = (observed - expected)**2 / expected
colors = ['red' if c > chi2_manual/6 else 'blue' for c in contributions]
ax.bar(x_pos, contributions, color=colors, alpha=0.7, edgecolor='gray')
ax.axhline(chi2_manual/6, color='gray', linewidth=1.5, linestyle='--',
label=f'Average contribution = {chi2_manual/6:.2f}')
ax.set_xlabel('Die face', fontsize=12)
ax.set_ylabel('$(O_i - E_i)^2 / E_i$', fontsize=12)
ax.set_title('Contribution to $\\chi^2$ by Category', fontsize=13)
ax.set_xticks(x_pos)
ax.set_xticklabels([1, 2, 3, 4, 5, 6])
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis='y')
# (c) カイ二乗分布上でのp値
ax = axes[2]
x_chi2 = np.linspace(0, 25, 300)
chi2_pdf = stats.chi2.pdf(x_chi2, df)
ax.plot(x_chi2, chi2_pdf, 'b-', linewidth=2, label=f'$\\chi^2_{{{df}}}$')
ax.fill_between(x_chi2, chi2_pdf, where=(x_chi2 >= chi2_manual),
alpha=0.3, color='red', label=f'p-value = {p_value:.4f}')
ax.axvline(chi2_manual, color='green', linewidth=2, linestyle='--',
label=f'$\\chi^2_{{obs}} = {chi2_manual:.2f}$')
crit = stats.chi2.ppf(0.95, df)
ax.axvline(crit, color='black', linewidth=1.5, linestyle=':',
label=f'Critical value = {crit:.2f}')
ax.set_xlabel('$\\chi^2$', fontsize=12)
ax.set_ylabel('Density', fontsize=12)
ax.set_title(f'$\\chi^2$ Distribution (df={df})', fontsize=13)
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('chi_squared_goodness.png', dpi=150, bbox_inches='tight')
plt.show()
このグラフから適合度検定の手順と結果がわかります。
-
左図: 観測度数(青)と期待度数(赤)の比較です。面2と面5が期待値100より15ほど少なく、面1が20多いことがわかります。しかしp値は約0.077でありp < 0.05の基準では帰無仮説は棄却されません
-
中央図: 各面のカイ二乗統計量への寄与を示しています。面1(120回)と面2(85回)の寄与が特に大きく、全体のカイ二乗統計量の大部分を占めています
-
右図: カイ二乗分布上で観測された統計量の位置とp値(赤い塗りつぶし)を示しています。臨界値(黒の点線)の手前に位置しており、5%水準では棄却されません
独立性検定の実装
$2 \times 2$ 分割表と一般的な分割表での独立性検定を実装します。
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
import matplotlib.colors as mcolors
# 2x2 分割表
observed_2x2 = np.array([[45, 55], [30, 70]])
row_labels = ['Treatment', 'Placebo']
col_labels = ['Improved', 'Not improved']
# 独立性検定
chi2, p_val, dof, expected = stats.chi2_contingency(observed_2x2)
chi2_yates, p_yates, _, _ = stats.chi2_contingency(observed_2x2, correction=True)
# 効果量(クラメールのV)
n_total = observed_2x2.sum()
cramers_v = np.sqrt(chi2 / (n_total * min(observed_2x2.shape[0]-1, observed_2x2.shape[1]-1)))
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# (a) 分割表のヒートマップ
ax = axes[0]
im = ax.imshow(observed_2x2, cmap='YlOrRd', aspect='auto')
for i in range(2):
for j in range(2):
text = f'O={observed_2x2[i,j]}\nE={expected[i,j]:.1f}'
ax.text(j, i, text, ha='center', va='center', fontsize=11,
fontweight='bold')
ax.set_xticks([0, 1])
ax.set_yticks([0, 1])
ax.set_xticklabels(col_labels, fontsize=11)
ax.set_yticklabels(row_labels, fontsize=11)
ax.set_title(f"$\\chi^2 = {chi2:.3f}$, p = {p_val:.4f}\n"
f"Yates: $\\chi^2 = {chi2_yates:.3f}$, p = {p_yates:.4f}\n"
f"Cramer's V = {cramers_v:.3f}", fontsize=11)
plt.colorbar(im, ax=ax, shrink=0.8)
# (b) モザイクプロット風の表示
ax = axes[1]
proportions = observed_2x2 / observed_2x2.sum(axis=1, keepdims=True)
x_start = [0, 0.55]
bar_width = 0.4
for i, (label, prop) in enumerate(zip(row_labels, proportions)):
bottom = 0
for j, (col, p) in enumerate(zip(col_labels, prop)):
color = ['#2196F3', '#FF9800'][j]
ax.bar(x_start[i], p, bar_width, bottom=bottom, color=color,
alpha=0.7, edgecolor='gray',
label=col if i == 0 else None)
ax.text(x_start[i] + bar_width/2, bottom + p/2,
f'{p:.1%}', ha='center', va='center', fontsize=10)
bottom += p
ax.set_xticks([x + bar_width/2 for x in x_start])
ax.set_xticklabels(row_labels, fontsize=11)
ax.set_ylabel('Proportion', fontsize=12)
ax.set_title('Proportions by Group', fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis='y')
# (c) サンプルサイズと検出力
ax = axes[2]
n_range = np.arange(20, 501, 10)
effect_sizes = [0.1, 0.2, 0.3, 0.5]
for w in effect_sizes:
powers = []
for n_test in n_range:
# カイ二乗検定の検出力
ncp = n_test * w**2 # 非心度パラメータ
crit = stats.chi2.ppf(0.95, 1)
power = 1 - stats.ncx2.cdf(crit, 1, ncp)
powers.append(power)
ax.plot(n_range, powers, linewidth=2, label=f"w = {w} ({'small' if w==0.1 else 'medium' if w==0.3 else 'large' if w==0.5 else ''})")
ax.axhline(0.8, color='red', linewidth=1.5, linestyle='--', alpha=0.5)
ax.set_xlabel('Total sample size $n$', fontsize=12)
ax.set_ylabel('Power', fontsize=12)
ax.set_title('Power of $\\chi^2$ Test (df=1)', fontsize=13)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.savefig('chi_squared_independence.png', dpi=150, bbox_inches='tight')
plt.show()
このグラフから独立性検定の実践的な側面がわかります。
-
左図: 分割表の各セルに観測度数 $O$ と期待度数 $E$ を表示しています。治療群では改善ありが45人(期待値37.5人)、プラセボ群では30人(期待値37.5人)と、差が確認できます。p値は有意水準5%を下回っており、治療と改善の関連が統計的に有意です
-
中央図: 各群における改善率の棒グラフです。治療群は45%が改善しているのに対しプラセボ群は30%であり、15ポイントの差があります
-
右図: サンプルサイズと検出力の関係です。効果量 $w = 0.3$(中程度の効果)を検出力80%で検出するには約100人のサンプルが必要であることがわかります
まとめ
本記事では、カイ二乗検定の理論と実装を解説しました。
- カイ二乗統計量 $\chi^2 = \sum(O_i – E_i)^2/E_i$ は観測度数と期待度数のずれを測る
- 適合度検定はデータが指定された分布に従うかを検定する(自由度 $k – 1$)
- 独立性検定は分割表の2変数が独立かを検定する(自由度 $(r-1)(c-1)$)
- $2 \times 2$ 分割表ではイェーツの連続修正が推奨される
- 効果量にはクラメールのVを使い、検定結果と合わせて報告する
次のステップとして、以下の記事も参考にしてください。
- 一元配置分散分析 — 3群以上の連続変数の比較
- ノンパラメトリック検定 — 分布の仮定を置かない検定
- 多重検定問題 — 複数の検定を同時に行う際の注意