ナイーブベイズ分類器の理論と実装

迷惑メールフィルターはどのようにスパムを判定しているのでしょうか。「無料」「当選」「限定」といった単語が含まれていればスパムの可能性が高く、「会議」「報告書」「ご確認」が含まれていれば正常なメールの可能性が高い。この直感をベイズの定理で定式化したのがナイーブベイズ分類器(Naive Bayes classifier)です。

「ナイーブ(素朴)」と名前がついている理由は、特徴量同士が条件付き独立であるという非常に強い仮定を置くからです。実際のデータではこの仮定はほぼ満たされませんが、驚くほど多くの実問題でナイーブベイズは高い性能を示します。特にテキスト分類では長年にわたって標準的な手法として使われてきました。

ナイーブベイズを理解すると、以下のような場面で活用できます。

  • テキスト分類: スパム検出、感情分析、トピック分類
  • 高速な推論: 訓練も推論も非常に高速で、リアルタイム処理に適する
  • 少量データ: パラメータが少ないため、少量のデータでも安定して動作
  • 確率的な解釈: 分類結果に確率を付与でき、不確実性の定量化が可能

本記事の内容

  • ベイズの定理と条件付き独立性仮定
  • ガウシアン・多項・ベルヌーイナイーブベイズの定式化
  • ラプラス平滑化の必要性
  • Pythonでのスクラッチ実装と可視化

前提知識

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

ナイーブベイズの理論

ベイズの定理による分類

入力 $\bm{x} = (x_1, x_2, \ldots, x_d)$ が与えられたとき、クラス $c$ の事後確率は

$$ P(C=c | \bm{x}) = \frac{P(\bm{x} | C=c) P(C=c)}{P(\bm{x})} $$

分類は事後確率を最大化するクラスを選びます。

$$ \hat{c} = \arg\max_c P(C=c | \bm{x}) = \arg\max_c P(\bm{x} | C=c) P(C=c) $$

$P(\bm{x})$ は全クラスに共通なので無視できます。

条件付き独立性仮定

尤度 $P(\bm{x} | C=c)$ は $d$ 次元の同時確率であり、直接推定するのは困難です。ナイーブベイズは条件付き独立性を仮定します。

$$ \begin{equation} P(\bm{x} | C=c) = \prod_{j=1}^d P(x_j | C=c) \end{equation} $$

これにより $d$ 次元の同時分布の推定が $d$ 個の1次元分布の推定に分解されます。パラメータ数は指数的に削減されます。

分類規則

$$ \begin{equation} \hat{c} = \arg\max_c \left[\ln P(C=c) + \sum_{j=1}^d \ln P(x_j | C=c)\right] \end{equation} $$

対数をとることで積が和に変換され、数値的にも安定します。

3種類のナイーブベイズ

ガウシアンナイーブベイズ: $P(x_j | C=c) = \mathcal{N}(x_j; \mu_{jc}, \sigma_{jc}^2)$。連続値の特徴量に適用。

多項ナイーブベイズ: $P(x_j | C=c) = \theta_{jc}$(多項分布のパラメータ)。テキスト分類の単語頻度に適用。

ベルヌーイナイーブベイズ: $P(x_j | C=c) = \theta_{jc}^{x_j}(1-\theta_{jc})^{1-x_j}$。二値特徴量(単語の有無)に適用。

ラプラス平滑化

訓練データで一度も出現しなかった特徴量-クラスの組み合わせでは $P(x_j | C=c) = 0$ となり、他の全ての特徴量の情報が無視されてしまいます。これを防ぐためにラプラス平滑化(additive smoothing)を適用します。

多項ナイーブベイズの場合:

$$ \hat{\theta}_{jc} = \frac{n_{jc} + \alpha}{n_c + \alpha d} $$

$\alpha = 1$ がラプラス平滑化で、$\alpha$ はスムージングパラメータです。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

class GaussianNaiveBayes:
    """ガウシアンナイーブベイズのスクラッチ実装"""

    def fit(self, X, y):
        self.classes = np.unique(y)
        self.n_classes = len(self.classes)
        n, d = X.shape

        self.priors = np.zeros(self.n_classes)
        self.means = np.zeros((self.n_classes, d))
        self.vars = np.zeros((self.n_classes, d))

        for i, c in enumerate(self.classes):
            X_c = X[y == c]
            self.priors[i] = len(X_c) / n
            self.means[i] = X_c.mean(axis=0)
            self.vars[i] = X_c.var(axis=0) + 1e-9  # ゼロ分散防止

        return self

    def _log_likelihood(self, X):
        """各クラスの対数尤度を計算"""
        log_likes = np.zeros((len(X), self.n_classes))
        for i in range(self.n_classes):
            log_prior = np.log(self.priors[i])
            log_cond = -0.5 * np.sum(
                np.log(2 * np.pi * self.vars[i]) +
                (X - self.means[i])**2 / self.vars[i],
                axis=1
            )
            log_likes[:, i] = log_prior + log_cond
        return log_likes

    def predict(self, X):
        log_likes = self._log_likelihood(X)
        return self.classes[np.argmax(log_likes, axis=1)]

    def predict_proba(self, X):
        log_likes = self._log_likelihood(X)
        log_likes -= log_likes.max(axis=1, keepdims=True)
        probs = np.exp(log_likes)
        return probs / probs.sum(axis=1, keepdims=True)

# テスト
np.random.seed(42)
X, y = make_classification(n_samples=500, n_features=2, n_informative=2,
                            n_redundant=0, n_classes=3, n_clusters_per_class=1,
                            random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# スクラッチ実装
gnb = GaussianNaiveBayes().fit(X_train, y_train)

# scikit-learn
from sklearn.naive_bayes import GaussianNB
gnb_sklearn = GaussianNB().fit(X_train, y_train)

fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

for ax, model, title in zip(axes,
                             [gnb, gnb_sklearn],
                             ["Scratch Implementation", "scikit-learn"]):
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
                          np.linspace(y_min, y_max, 200))
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)

    ax.contourf(xx, yy, Z, alpha=0.3, cmap="Set3")
    for c in np.unique(y):
        mask = y_test == c
        ax.scatter(X_test[mask, 0], X_test[mask, 1], s=30, alpha=0.7, label=f"Class {c}")

    acc = accuracy_score(y_test, model.predict(X_test))
    ax.set_title(f"{title} (Acc={acc:.3f})", fontsize=13)
    ax.set_xlabel("$x_1$", fontsize=12)
    ax.set_ylabel("$x_2$", fontsize=12)
    ax.legend(fontsize=9)
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("naive_bayes.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、ガウシアンナイーブベイズの決定境界の特徴が読み取れます。スクラッチ実装とscikit-learnが同じ結果を返しており、実装の正しさが確認できます。決定境界が曲線(二次曲線)になっているのは、各クラスのガウス分布のパラメータ(平均と分散)の違いを反映しています。ナイーブベイズは条件付き独立性仮定により、決定境界の形状が制限されますが、実用的には十分な精度を示しています。

まとめ

本記事では、ナイーブベイズ分類器の理論とスクラッチ実装について解説しました。

  • ナイーブベイズはベイズの定理と条件付き独立性仮定に基づく確率的分類器
  • 条件付き独立性仮定により、高次元の同時分布推定が1次元分布の積に分解される
  • ガウシアン・多項・ベルヌーイの3種類があり、データの性質に応じて使い分ける
  • ラプラス平滑化は未観測の特徴量-クラス組み合わせのゼロ確率問題を解決する
  • 訓練・推論が非常に高速で、テキスト分類や少量データの場面で実用的

次のステップとして、以下の記事も参考にしてください。