アンサンブル学習の全体像 — Bagging・Boosting・Stacking

1人の専門家の意見より、100人の素人の多数決の方が正しいことがある——これは「群衆の知恵」(Wisdom of Crowds)として知られる現象です。機械学習においても同様に、1つの強力なモデルを作るよりも、複数の「弱い」モデルを組み合わせた方が高い性能を達成できることがあります。この考え方がアンサンブル学習(Ensemble Learning)です。

なぜ複数のモデルを組み合わせると性能が上がるのでしょうか。直感的には、個々のモデルが異なる種類の間違いを犯すとき、多数決によって個々の間違いが打ち消されるからです。しかし、この「異なる間違い」をどう生み出すかによって、アンサンブルの戦略は大きく異なります。

アンサンブル学習を理解すると、以下のような場面で活用できます。

  • Kaggle等のコンペ: 上位入賞者のほとんどがアンサンブル手法を使用
  • 実務の予測モデル: 単一モデルより安定した予測を実現
  • モデル選択の指針: Bagging/Boosting/Stackingの使い分けで最適な戦略を選択
  • バイアス-バリアンスの制御: 過学習・未学習を体系的に対処

本記事の内容

  • アンサンブル学習の理論的基盤(バイアス-バリアンス分解)
  • Bagging: 分散を減らす戦略
  • Boosting: バイアスを減らす戦略
  • Stacking: メタ学習による統合
  • 3手法の比較実験とPython実装

前提知識

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

アンサンブル学習の理論的基盤

なぜ組み合わせると性能が上がるのか

アンサンブル学習の効果を理論的に理解するために、まず回帰問題における最も単純なケース——独立な学習器の平均——を考えましょう。

$M$ 個の学習器 $f_1, f_2, \dots, f_M$ があり、それぞれが独立に同じ分布から学習されているとします。各学習器の予測誤差を $\epsilon_i = f_i(\bm{x}) – y$ とし、これらが互いに独立で、期待値 $0$、分散 $\sigma^2$ を持つとします。

単一の学習器の期待二乗誤差は

$$ \mathbb{E}[\epsilon_i^2] = \sigma^2 $$

です。一方、$M$ 個の学習器の平均 $\bar{f}(\bm{x}) = \frac{1}{M}\sum_{i=1}^{M} f_i(\bm{x})$ の誤差は

$$ \bar{\epsilon} = \bar{f}(\bm{x}) – y = \frac{1}{M}\sum_{i=1}^{M} \epsilon_i $$

となります。ここで誤差が互いに独立であることを利用すると、平均の分散は

$$ \mathbb{E}[\bar{\epsilon}^2] = \text{Var}\left(\frac{1}{M}\sum_{i=1}^{M} \epsilon_i\right) = \frac{1}{M^2} \sum_{i=1}^{M} \text{Var}(\epsilon_i) = \frac{\sigma^2}{M} $$

と計算できます。つまり、$M$ 個の独立な学習器を平均すると、誤差の分散が $\frac{1}{M}$ に減少します。$M = 100$ なら、分散は元の1%になります。

独立性の壁と相関の影響

しかし現実には、同じ訓練データから学習した複数のモデルは完全に独立ではありません。学習器間の誤差の相関を $\rho$ とすると、平均の分散は

$$ \mathbb{E}[\bar{\epsilon}^2] = \frac{1}{M^2}\left(\sum_{i=1}^{M} \sigma^2 + \sum_{i \neq j} \rho \sigma^2\right) = \frac{1}{M^2}\left(M\sigma^2 + M(M-1)\rho\sigma^2\right) $$

右辺を整理すると

$$ \mathbb{E}[\bar{\epsilon}^2] = \rho\sigma^2 + \frac{1-\rho}{M}\sigma^2 $$

が得られます。この式から重要な洞察が読み取れます。

  • $\rho = 0$(完全に独立)のとき: $\frac{\sigma^2}{M}$ で、$M$ を増やすほど誤差は減少
  • $\rho = 1$(完全に相関)のとき: $\sigma^2$ のままで、いくらモデルを増やしても改善なし
  • $0 < \rho < 1$(部分的相関)のとき: 第1項 $\rho\sigma^2$ は $M$ に依存せず残る下限

したがって、アンサンブルの性能を高めるには、学習器間の相関 $\rho$ を下げることが本質的に重要です。Bagging、Boosting、Stackingは、それぞれ異なる方法でこの相関を下げようとする戦略だと理解できます。

バイアス-バリアンス分解との関係

予測誤差をバイアスとバリアンスに分解すると

$$ \text{MSE} = \text{Bias}^2 + \text{Variance} + \text{Noise} $$

となります。アンサンブルの3つの戦略は、この分解の異なる成分にアプローチします。

戦略 主なターゲット メカニズム
Bagging バリアンス削減 データのランダムサンプリングで多様なモデルを作り平均化
Boosting バイアス削減 前のモデルの残差を次のモデルが学習
Stacking 両方 異種モデルの出力をメタ学習器が最適に統合

この分類を念頭に置いて、各手法の詳細を見ていきましょう。

Bagging(Bootstrap Aggregating)

直感的理解

Baggingの考え方は「同じデータでも、見る角度を変えれば異なるパターンが見える」というものです。元のデータセットからブートストラップサンプル(復元抽出)を複数作成し、それぞれのサンプルで独立にモデルを学習し、最後に予測を平均(回帰)または多数決(分類)で統合します。

ブートストラップサンプルでは、各データ点が選ばれない確率は $(1 – \frac{1}{n})^n$ です。$n$ が大きいとき、これは $e^{-1} \approx 0.368$ に近づきます。つまり、各ブートストラップサンプルには元のデータの約63.2%が含まれ、残りの36.8%は含まれません。この「含まれないデータ」がモデル間の多様性を生み出します。

アルゴリズム

Baggingの手順を整理します。

入力: 訓練データ $\mathcal{D} = \{(\bm{x}_i, y_i)\}_{i=1}^{n}$、基本学習器 $\mathcal{A}$、アンサンブルサイズ $M$

  1. $m = 1, 2, \dots, M$ について: – $\mathcal{D}$ から $n$ 個のサンプルを復元抽出してブートストラップサンプル $\mathcal{D}_m$ を作成 – $\mathcal{D}_m$ で基本学習器を学習: $f_m = \mathcal{A}(\mathcal{D}_m)$
  2. 予測時: – 回帰: $\hat{y} = \frac{1}{M}\sum_{m=1}^{M} f_m(\bm{x})$ – 分類: $\hat{y} = \text{mode}\{f_1(\bm{x}), f_2(\bm{x}), \dots, f_M(\bm{x})\}$

Baggingが分散を削減する理由

Baggingがなぜ分散を削減するのかを数式で確認しましょう。各ブートストラップモデル $f_m$ の予測の分散を $\sigma^2$、モデル間の相関を $\rho$ とすると、先ほど導出した式から

$$ \text{Var}(\bar{f}) = \rho\sigma^2 + \frac{1-\rho}{M}\sigma^2 $$

です。$M$ を大きくすると第2項は $0$ に近づきますが、第1項 $\rho\sigma^2$ は残ります。ランダムフォレストが各分岐でランダムに特徴量を選ぶのは、この相関 $\rho$ をさらに下げるための工夫です。

一方、Baggingはバイアスをほぼ変えません。アンサンブルのバイアスは

$$ \text{Bias}(\bar{f}) = \mathbb{E}[\bar{f}(\bm{x})] – y^* = \frac{1}{M}\sum_{m=1}^{M}\mathbb{E}[f_m(\bm{x})] – y^* = \mathbb{E}[f_m(\bm{x})] – y^* $$

となり、各学習器のバイアスと同じです。ここで $y^*$ は真の値を表します。したがって、Baggingの効果は「バイアスは維持したまま分散を削減する」ことに集約されます。

OOB(Out-of-Bag)誤差

ブートストラップサンプルに含まれなかった約36.8%のデータは、そのモデルの検証に使えます。各データ点 $(\bm{x}_i, y_i)$ について、それを含まないブートストラップサンプルで学習されたモデルのみで予測し、その予測誤差を平均することで、交差検証に近い推定が得られます。これがOOB誤差です。

$$ \text{OOB Error} = \frac{1}{n}\sum_{i=1}^{n} L\left(y_i, \frac{1}{|S_i|}\sum_{m \in S_i} f_m(\bm{x}_i)\right) $$

ここで $S_i = \{m : (\bm{x}_i, y_i) \notin \mathcal{D}_m\}$ は、データ点 $i$ を含まないブートストラップサンプルの集合です。OOB誤差を使うことで、別途検証データを用意せずにモデルの汎化性能を推定できます。

Baggingの理論を理解したところで、次はバイアスを削減する戦略であるBoostingを見ていきましょう。

Boosting

直感的理解

Boostingの考え方は「間違えた問題を重点的に復習する」というものです。Baggingが独立にモデルを学習するのに対し、Boostingは逐次的にモデルを学習します。前のモデルが間違えたデータに重点を置いて、次のモデルが学習するのです。

これは勉強法に例えると分かりやすいでしょう。Baggingは「教科書のランダムなページを各々が独立に勉強して、テストの答えを多数決する」方式です。一方Boostingは「1人目が間違えた問題を2人目が重点的に勉強し、2人目も間違えた問題を3人目がさらに重点的に勉強する」方式です。

AdaBoost(Adaptive Boosting)

Boostingの最も基本的なアルゴリズムであるAdaBoostを見ていきましょう。AdaBoostは分類問題のためのアルゴリズムで、各データ点に重みを付け、誤分類されたデータの重みを増やしていきます。

入力: 訓練データ $\{(\bm{x}_i, y_i)\}_{i=1}^{n}$($y_i \in \{-1, +1\}$)、弱学習器 $\mathcal{A}$、反復回数 $M$

初期化: 全データの重み $w_i^{(1)} = \frac{1}{n}$($i = 1, \dots, n$)

$m = 1, 2, \dots, M$ について:

Step 1: 重み付きデータで弱学習器 $f_m$ を学習

Step 2: 重み付き誤り率を計算

$$ \epsilon_m = \sum_{i=1}^{n} w_i^{(m)} \cdot \mathbb{1}[f_m(\bm{x}_i) \neq y_i] $$

Step 3: 学習器の重み $\alpha_m$ を計算

$$ \alpha_m = \frac{1}{2}\ln\frac{1 – \epsilon_m}{\epsilon_m} $$

この式は重要な性質を持ちます。$\epsilon_m < 0.5$(ランダムより良い)のとき $\alpha_m > 0$ となり、$\epsilon_m$ が小さいほど $\alpha_m$ は大きくなります。つまり、正確な学習器ほど最終予測での発言力が大きくなります。

Step 4: データの重みを更新

$$ w_i^{(m+1)} = \frac{w_i^{(m)} \cdot \exp(-\alpha_m y_i f_m(\bm{x}_i))}{Z_m} $$

ここで $Z_m$ は正規化定数です。$y_i f_m(\bm{x}_i) = 1$(正解)のとき $\exp(-\alpha_m)$ で重みが減少し、$y_i f_m(\bm{x}_i) = -1$(不正解)のとき $\exp(\alpha_m)$ で重みが増加します。

最終予測:

$$ F(\bm{x}) = \text{sign}\left(\sum_{m=1}^{M} \alpha_m f_m(\bm{x})\right) $$

AdaBoostと指数損失の関係

AdaBoostの重み更新が天から降ってきたように見えるかもしれません。実は、AdaBoostは指数損失関数

$$ L(y, F(\bm{x})) = \exp(-y F(\bm{x})) $$

を逐次的に最小化するアルゴリズムとして解釈できます。各ステップで $F_m(\bm{x}) = F_{m-1}(\bm{x}) + \alpha_m f_m(\bm{x})$ と更新するとき、$\alpha_m$ と $f_m$ を同時に最適化すると、先ほどの更新式が自然に導出されます。

具体的に確認しましょう。ステップ $m$ での目的関数は

$$ \sum_{i=1}^{n} \exp\left(-y_i \left(F_{m-1}(\bm{x}_i) + \alpha_m f_m(\bm{x}_i)\right)\right) $$

です。$w_i^{(m)} = \exp(-y_i F_{m-1}(\bm{x}_i))$ と置くと、上式は

$$ \sum_{i=1}^{n} w_i^{(m)} \exp(-\alpha_m y_i f_m(\bm{x}_i)) $$

となります。$y_i f_m(\bm{x}_i)$ は正解なら $+1$、不正解なら $-1$ なので、正解集合と不正解集合に分けて

$$ e^{-\alpha_m}\sum_{i: \text{correct}} w_i^{(m)} + e^{\alpha_m}\sum_{i: \text{wrong}} w_i^{(m)} $$

と書けます。これを $\alpha_m$ で微分して $0$ と置くと

$$ -e^{-\alpha_m}(1 – \epsilon_m) + e^{\alpha_m}\epsilon_m = 0 $$

となり、ここで $\epsilon_m = \sum_{i:\text{wrong}} w_i^{(m)} / \sum_i w_i^{(m)}$ は重み付き誤り率です。これを $\alpha_m$ について解くと

$$ \alpha_m = \frac{1}{2}\ln\frac{1 – \epsilon_m}{\epsilon_m} $$

が得られます。これはまさにAdaBoostの $\alpha_m$ の式です。

Boostingがバイアスを削減する理由

Boostingは弱学習器(例: 深さ1の決定木=決定株)を逐次的に加えていくことで、モデルの表現力を段階的に高めます。各ステップで前のモデルの「残差」(間違い)を学習するため、ステップを重ねるごとにバイアスが減少します。

一方、Baggingとは異なり、Boostingは分散を増やすリスクがあります。特にステップ数 $M$ が大きすぎると、訓練データのノイズまで学習してしまい、過学習が起こります。これがBoostingで正則化(学習率、早期停止、木の深さ制限)が重要な理由です。

勾配ブースティングの概要

AdaBoostは指数損失に限定されますが、任意の微分可能な損失関数に対してBoostingを一般化したのが勾配ブースティングです。勾配ブースティングでは、各ステップで損失関数の負の勾配(擬似残差)を計算し、それにフィットするように弱学習器を学習します。

$$ r_{im} = -\left[\frac{\partial L(y_i, F(\bm{x}_i))}{\partial F(\bm{x}_i)}\right]_{F = F_{m-1}} $$

二乗損失の場合、擬似残差は $r_{im} = y_i – F_{m-1}(\bm{x}_i)$ となり、文字通り「残差」を学習することになります。

勾配ブースティングの詳細は勾配ブースティングの理論と実装で解説しています。

Baggingとboostingの2つの戦略を理解したところで、次はこれらを統合するStackingについて見ていきましょう。

Stacking(Stacked Generalization)

直感的理解

Stackingの考え方は「専門家の意見を、別の専門家が取りまとめる」というものです。BaggingやBoostingが同種のモデル(通常は決定木)を組み合わせるのに対し、Stackingは異種のモデル(ランダムフォレスト、SVM、ニューラルネットなど)の予測を入力として、メタ学習器が最終予測を行います。

例えば、天気予報を考えてみましょう。気象モデルA(数値予報)、モデルB(統計手法)、モデルC(AI予報)の3つがあるとき、これらの予報を人間の気象予報士が総合的に判断して最終予報を出す——これがStackingのイメージです。

アルゴリズム

Stackingは2つの層(レベル)で構成されます。

Level 0(ベース学習器): 複数の異なる学習アルゴリズムで訓練されたモデル群

Level 1(メタ学習器): Level 0の予測を入力として、最終予測を出力するモデル

単純にLevel 0の予測を訓練データに対して計算し、それをLevel 1の入力にすると情報漏洩(data leakage)が発生します。なぜなら、Level 0のモデルは同じ訓練データで学習されているため、訓練データに対する予測は過度に楽観的になるからです。

この問題を回避するために、交差検証ベースのStackingを使います。

入力: 訓練データ $\mathcal{D}$、$K$ 個のベース学習器 $\mathcal{A}_1, \dots, \mathcal{A}_K$、メタ学習器 $\mathcal{A}_{\text{meta}}$、$J$-分割交差検証

Step 1: Level 0のメタ特徴量を生成

$j = 1, \dots, J$(各foldについて): – $\mathcal{D}$ を訓練部分 $\mathcal{D}_{-j}$ とホールドアウト部分 $\mathcal{D}_j$ に分割 – 各ベース学習器 $k = 1, \dots, K$ について: – $\mathcal{D}_{-j}$ で $\mathcal{A}_k$ を学習 – $\mathcal{D}_j$ のデータに対して予測 $\hat{y}_{i,k}$($i \in \mathcal{D}_j$)を出力

全foldの予測を結合して、メタ特徴量行列 $\bm{Z} \in \mathbb{R}^{n \times K}$ を構築

Step 2: メタ学習器を学習

メタ特徴量 $\bm{Z}$ と真のラベル $\bm{y}$ を使ってメタ学習器を学習: $f_{\text{meta}} = \mathcal{A}_{\text{meta}}(\bm{Z}, \bm{y})$

Step 3: テスト時の予測

  • 全訓練データで各ベース学習器を再学習
  • テストデータに対する各ベース学習器の予測をメタ学習器に入力

なぜ交差検証が必要か

交差検証を使わずにStackingを行うとどうなるか考えてみましょう。ベース学習器が訓練データを完全に記憶(過学習)した場合、訓練データに対する予測は完璧になります。すると、メタ学習器は「ベース学習器の予測をそのまま信じればよい」と学習してしまい、テスト時に性能が大幅に低下します。

交差検証を使うことで、各データ点に対する予測は「そのデータ点を見ていないモデル」から得られるため、テスト時と同じ条件での予測品質がメタ学習器に伝わります。

Stackingの拡張

Stackingにはいくつかの拡張があります。

Blending: 交差検証の代わりにホールドアウト検証を使う簡易版。交差検証よりデータ効率は悪いが、実装が簡単で高速です。

Multi-level Stacking: Level 0、Level 1だけでなく、Level 2、Level 3と層を重ねる方式。ただし、層を増やすほど過学習のリスクが高まり、計算コストも増大するため、実務では2層(Level 0 + Level 1)で十分なことが多いです。

Stackingでのメタ学習器の選択: メタ学習器には過学習しにくい単純なモデルを選ぶのが定石です。ロジスティック回帰やリッジ回帰がよく使われます。複雑なメタ学習器を使うと、ベース学習器の予測パターンのノイズまで学習してしまいます。

ここまでで3つの戦略を理論的に理解しました。次に、これらをPythonで実装して比較してみましょう。

Pythonでの実装

Baggingのスクラッチ実装

まず、Baggingを決定木を基本学習器として一から実装します。

import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt

class BaggingClassifierScratch:
    """Bagging分類器のスクラッチ実装"""

    def __init__(self, base_estimator=None, n_estimators=10, random_state=42):
        self.base_estimator = base_estimator or DecisionTreeClassifier(max_depth=None)
        self.n_estimators = n_estimators
        self.random_state = random_state
        self.estimators_ = []
        self.oob_score_ = None

    def fit(self, X, y):
        rng = np.random.RandomState(self.random_state)
        n_samples = X.shape[0]
        self.classes_ = np.unique(y)
        self.estimators_ = []

        # OOB予測を蓄積する配列
        oob_predictions = np.zeros((n_samples, len(self.classes_)))
        oob_counts = np.zeros(n_samples)

        for i in range(self.n_estimators):
            # ブートストラップサンプルを作成
            indices = rng.randint(0, n_samples, size=n_samples)
            oob_indices = np.setdiff1d(np.arange(n_samples), np.unique(indices))

            X_boot, y_boot = X[indices], y[indices]

            # 基本学習器を学習
            from sklearn.base import clone
            estimator = clone(self.base_estimator)
            estimator.fit(X_boot, y_boot)
            self.estimators_.append(estimator)

            # OOB予測を蓄積
            if len(oob_indices) > 0:
                oob_pred = estimator.predict(X[oob_indices])
                for idx, pred in zip(oob_indices, oob_pred):
                    class_idx = np.where(self.classes_ == pred)[0][0]
                    oob_predictions[idx, class_idx] += 1
                    oob_counts[idx] += 1

        # OOBスコアを計算
        valid = oob_counts > 0
        if valid.sum() > 0:
            oob_pred_labels = self.classes_[np.argmax(oob_predictions[valid], axis=1)]
            self.oob_score_ = np.mean(oob_pred_labels == y[valid])

        return self

    def predict(self, X):
        """多数決で予測"""
        predictions = np.array([est.predict(X) for est in self.estimators_])
        # 各サンプルについて最頻値を取得
        result = np.zeros(X.shape[0], dtype=self.classes_.dtype)
        for i in range(X.shape[0]):
            values, counts = np.unique(predictions[:, i], return_counts=True)
            result[i] = values[np.argmax(counts)]
        return result

    def predict_proba(self, X):
        """確率予測(各学習器の予測の平均)"""
        all_proba = np.array([
            est.predict_proba(X) for est in self.estimators_
        ])
        return np.mean(all_proba, axis=0)


# データ生成と実験
np.random.seed(42)
X, y = make_moons(n_samples=500, noise=0.3, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

# 単一の決定木
single_tree = DecisionTreeClassifier(max_depth=None, random_state=42)
single_tree.fit(X_train, y_train)
print(f"単一決定木 - 訓練精度: {single_tree.score(X_train, y_train):.4f}")
print(f"単一決定木 - テスト精度: {single_tree.score(X_test, y_test):.4f}")

# Bagging(スクラッチ実装)
bagging = BaggingClassifierScratch(
    base_estimator=DecisionTreeClassifier(max_depth=None),
    n_estimators=50,
    random_state=42
)
bagging.fit(X_train, y_train)
bagging_train_acc = np.mean(bagging.predict(X_train) == y_train)
bagging_test_acc = np.mean(bagging.predict(X_test) == y_test)
print(f"\nBagging(50本) - 訓練精度: {bagging_train_acc:.4f}")
print(f"Bagging(50本) - テスト精度: {bagging_test_acc:.4f}")
print(f"Bagging(50本) - OOBスコア: {bagging.oob_score_:.4f}")

実行すると、単一の決定木は訓練精度100%(過学習)に対しテスト精度が低く、Baggingでは訓練精度がやや下がる代わりにテスト精度が向上することが確認できます。OOBスコアもテスト精度に近い値を示し、交差検証なしで汎化性能を推定できています。これがBaggingの分散削減効果です。

AdaBoostのスクラッチ実装

次に、AdaBoostを一から実装します。弱学習器として決定株(深さ1の決定木)を使います。

class AdaBoostClassifierScratch:
    """AdaBoost分類器のスクラッチ実装"""

    def __init__(self, n_estimators=50, random_state=42):
        self.n_estimators = n_estimators
        self.random_state = random_state
        self.estimators_ = []
        self.alphas_ = []

    def fit(self, X, y):
        n_samples = X.shape[0]
        # ラベルを{-1, +1}に変換
        self.classes_ = np.unique(y)
        y_binary = np.where(y == self.classes_[0], -1, 1)

        # 重みの初期化
        weights = np.ones(n_samples) / n_samples

        self.estimators_ = []
        self.alphas_ = []

        for m in range(self.n_estimators):
            # 弱学習器(決定株)を重み付きデータで学習
            stump = DecisionTreeClassifier(max_depth=1, random_state=self.random_state + m)
            stump.fit(X, y_binary, sample_weight=weights)

            # 予測
            pred = stump.predict(X)

            # 重み付き誤り率
            incorrect = (pred != y_binary)
            epsilon = np.sum(weights * incorrect) / np.sum(weights)

            # εが0.5以上なら学習を停止
            if epsilon >= 0.5:
                break

            # 学習器の重み
            alpha = 0.5 * np.log((1 - epsilon) / (epsilon + 1e-10))

            # データの重み更新
            weights = weights * np.exp(-alpha * y_binary * pred)
            weights = weights / np.sum(weights)

            self.estimators_.append(stump)
            self.alphas_.append(alpha)

        return self

    def predict(self, X):
        """加重投票で予測"""
        weighted_sum = np.zeros(X.shape[0])
        for alpha, estimator in zip(self.alphas_, self.estimators_):
            weighted_sum += alpha * estimator.predict(X)
        predictions = np.sign(weighted_sum)
        return np.where(predictions == -1, self.classes_[0], self.classes_[1])

    def staged_predict(self, X):
        """各ステップでの予測を返す(学習過程の可視化用)"""
        weighted_sum = np.zeros(X.shape[0])
        for alpha, estimator in zip(self.alphas_, self.estimators_):
            weighted_sum += alpha * estimator.predict(X)
            predictions = np.sign(weighted_sum)
            yield np.where(predictions == -1, self.classes_[0], self.classes_[1])


# AdaBoostの実験
adaboost = AdaBoostClassifierScratch(n_estimators=100, random_state=42)
adaboost.fit(X_train, y_train)
ada_train_acc = np.mean(adaboost.predict(X_train) == y_train)
ada_test_acc = np.mean(adaboost.predict(X_test) == y_test)
print(f"\nAdaBoost(100ステップ) - 訓練精度: {ada_train_acc:.4f}")
print(f"AdaBoost(100ステップ) - テスト精度: {ada_test_acc:.4f}")

# 学習曲線(ステップ数 vs 精度)
train_scores = []
test_scores = []
for pred_train in adaboost.staged_predict(X_train):
    train_scores.append(np.mean(pred_train == y_train))
for pred_test in adaboost.staged_predict(X_test):
    test_scores.append(np.mean(pred_test == y_test))

plt.figure(figsize=(8, 5))
steps = range(1, len(train_scores) + 1)
plt.plot(steps, train_scores, label="訓練精度", linewidth=2)
plt.plot(steps, test_scores, label="テスト精度", linewidth=2)
plt.xlabel("ステップ数(弱学習器の数)", fontsize=12)
plt.ylabel("精度", fontsize=12)
plt.title("AdaBoostの学習曲線", fontsize=14)
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("adaboost_learning_curve.png", dpi=150, bbox_inches="tight")
plt.show()

AdaBoostの学習曲線を見ると、ステップ数を増やすにつれて訓練精度・テスト精度の両方が向上していきます。決定株(深さ1の決定木)という非常に弱い学習器でも、多数組み合わせることで複雑な決定境界を学習できることが分かります。ただし、ステップ数が多すぎると訓練精度が100%に近づき、テスト精度が横ばいまたは低下する過学習の兆候が見られることもあります。

Stackingのスクラッチ実装

最後に、Stackingを交差検証ベースで実装します。

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import KFold

class StackingClassifierScratch:
    """Stacking分類器のスクラッチ実装(交差検証ベース)"""

    def __init__(self, base_estimators, meta_estimator, cv=5, random_state=42):
        self.base_estimators = base_estimators  # (名前, モデル) のリスト
        self.meta_estimator = meta_estimator
        self.cv = cv
        self.random_state = random_state
        self.fitted_base_ = []

    def fit(self, X, y):
        from sklearn.base import clone
        kf = KFold(n_splits=self.cv, shuffle=True, random_state=self.random_state)

        n_samples = X.shape[0]
        n_estimators = len(self.base_estimators)

        # メタ特徴量行列
        meta_features = np.zeros((n_samples, n_estimators))

        # 交差検証でメタ特徴量を生成
        for train_idx, val_idx in kf.split(X):
            X_tr, X_val = X[train_idx], X[val_idx]
            y_tr = y[train_idx]

            for k, (name, estimator) in enumerate(self.base_estimators):
                est = clone(estimator)
                est.fit(X_tr, y_tr)
                meta_features[val_idx, k] = est.predict(X_val)

        # メタ学習器を学習
        self.meta_estimator_ = clone(self.meta_estimator)
        self.meta_estimator_.fit(meta_features, y)

        # 全データでベース学習器を再学習(テスト時用)
        self.fitted_base_ = []
        for name, estimator in self.base_estimators:
            est = clone(estimator)
            est.fit(X, y)
            self.fitted_base_.append((name, est))

        return self

    def predict(self, X):
        """テスト時の予測"""
        n_samples = X.shape[0]
        meta_features = np.zeros((n_samples, len(self.fitted_base_)))

        for k, (name, estimator) in enumerate(self.fitted_base_):
            meta_features[:, k] = estimator.predict(X)

        return self.meta_estimator_.predict(meta_features)


# ベース学習器の定義
base_estimators = [
    ("RandomForest", RandomForestClassifier(n_estimators=50, random_state=42)),
    ("SVM", SVC(kernel="rbf", random_state=42)),
    ("KNN", KNeighborsClassifier(n_neighbors=5)),
    ("DecisionTree", DecisionTreeClassifier(max_depth=5, random_state=42)),
]

# メタ学習器(ロジスティック回帰)
meta_estimator = LogisticRegression(random_state=42)

# Stacking
stacking = StackingClassifierScratch(
    base_estimators=base_estimators,
    meta_estimator=meta_estimator,
    cv=5,
    random_state=42
)
stacking.fit(X_train, y_train)
stack_train_acc = np.mean(stacking.predict(X_train) == y_train)
stack_test_acc = np.mean(stacking.predict(X_test) == y_test)
print(f"\nStacking - 訓練精度: {stack_train_acc:.4f}")
print(f"Stacking - テスト精度: {stack_test_acc:.4f}")

# 各ベース学習器の単体性能
print("\n--- ベース学習器の単体性能 ---")
for name, estimator in base_estimators:
    from sklearn.base import clone
    est = clone(estimator)
    est.fit(X_train, y_train)
    print(f"{name}: 訓練={est.score(X_train, y_train):.4f}, テスト={est.score(X_test, y_test):.4f}")

Stackingの結果を見ると、メタ学習器が各ベース学習器の強みを組み合わせることで、単体のどのベース学習器よりも高い(または同等の)テスト精度を達成しています。特に、SVMは決定境界が滑らかだが非線形に弱い場合があり、決定木は非線形に強いが不安定——Stackingはこれらの相補的な性質を活かしています。

3手法の決定境界を比較

3手法の決定境界を可視化して、違いを直感的に理解しましょう。

fig, axes = plt.subplots(1, 4, figsize=(20, 5))

# 決定境界描画用のグリッド
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
                      np.linspace(y_min, y_max, 200))
grid = np.c_[xx.ravel(), yy.ravel()]

models = [
    ("単一決定木", single_tree),
    ("Bagging (50本)", bagging),
    ("AdaBoost (100ステップ)", adaboost),
    ("Stacking (4種)", stacking),
]

for ax, (name, model) in zip(axes, models):
    Z = model.predict(grid).reshape(xx.shape)
    ax.contourf(xx, yy, Z, alpha=0.3, cmap="RdBu")
    ax.scatter(X_test[:, 0], X_test[:, 1], c=y_test, cmap="RdBu",
               edgecolors="k", s=20)
    test_acc = np.mean(model.predict(X_test) == y_test)
    ax.set_title(f"{name}\nテスト精度: {test_acc:.3f}", fontsize=11)
    ax.set_xlim(x_min, x_max)
    ax.set_ylim(y_min, y_max)

plt.suptitle("アンサンブル手法の決定境界比較", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("ensemble_decision_boundaries.png", dpi=150, bbox_inches="tight")
plt.show()

4つの決定境界を比較すると、それぞれの特徴が視覚的に分かります。単一決定木は複雑で不規則な境界(過学習)、Baggingは決定木の平均化により滑らかで安定した境界、AdaBoostは決定株の組み合わせにより段階的に複雑さを増した境界、Stackingは異なるモデルの融合により最も柔軟な境界を示します。

アンサンブルサイズの影響

モデルの数(アンサンブルサイズ)が性能に与える影響を実験で確認しましょう。

# アンサンブルサイズの影響
ensemble_sizes = [1, 2, 5, 10, 20, 50, 100, 200]

bagging_scores = []
adaboost_scores = []

for size in ensemble_sizes:
    # Bagging
    bag = BaggingClassifierScratch(
        base_estimator=DecisionTreeClassifier(max_depth=None),
        n_estimators=size, random_state=42
    )
    bag.fit(X_train, y_train)
    bagging_scores.append(np.mean(bag.predict(X_test) == y_test))

    # AdaBoost
    ada = AdaBoostClassifierScratch(n_estimators=size, random_state=42)
    ada.fit(X_train, y_train)
    adaboost_scores.append(np.mean(ada.predict(X_test) == y_test))

plt.figure(figsize=(8, 5))
plt.plot(ensemble_sizes, bagging_scores, "o-", label="Bagging", linewidth=2)
plt.plot(ensemble_sizes, adaboost_scores, "s-", label="AdaBoost", linewidth=2)
plt.axhline(y=single_tree.score(X_test, y_test), color="gray",
            linestyle="--", label="単一決定木")
plt.xlabel("アンサンブルサイズ(モデル数)", fontsize=12)
plt.ylabel("テスト精度", fontsize=12)
plt.title("アンサンブルサイズと性能の関係", fontsize=14)
plt.legend(fontsize=11)
plt.xscale("log")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("ensemble_size_effect.png", dpi=150, bbox_inches="tight")
plt.show()

この実験から重要な知見が得られます。Baggingはモデル数を増やしても性能が単調に改善し、過学習のリスクが低いことが分かります。これは理論的にも裏付けられています——Baggingは分散の削減のみを行い、モデルを増やしても第1項 $\rho\sigma^2$ 以下にはならないだけで、性能が悪化することはありません。一方、AdaBoostはモデル数を増やしすぎると過学習する可能性があり、適切な停止点の選択が重要です。

Voting(投票法)

アンサンブルの最もシンプルな形としてVotingも重要です。Stackingのようにメタ学習器を使わず、単純にベース学習器の予測を多数決(分類)または平均(回帰)で統合します。

Hard Voting vs Soft Voting

  • Hard Voting: 各学習器のクラス予測の多数決
  • Soft Voting: 各学習器の確率予測の平均を取り、最も確率の高いクラスを選択

Soft Votingの方がHard Votingより一般的に良い性能を示します。なぜなら、「クラス1が51%」と「クラス1が99%」の予測は、Hard Votingでは同じ「クラス1」として扱われますが、Soft Votingでは確信度の違いが反映されるからです。

from sklearn.ensemble import VotingClassifier

# Votingの実験
voting_hard = VotingClassifier(
    estimators=[
        ("rf", RandomForestClassifier(n_estimators=50, random_state=42)),
        ("knn", KNeighborsClassifier(n_neighbors=5)),
        ("dt", DecisionTreeClassifier(max_depth=5, random_state=42)),
    ],
    voting="hard"
)
voting_hard.fit(X_train, y_train)

voting_soft = VotingClassifier(
    estimators=[
        ("rf", RandomForestClassifier(n_estimators=50, random_state=42)),
        ("knn", KNeighborsClassifier(n_neighbors=5)),
        ("dt", DecisionTreeClassifier(max_depth=5, random_state=42)),
    ],
    voting="soft"
)
voting_soft.fit(X_train, y_train)

print(f"Hard Voting - テスト精度: {voting_hard.score(X_test, y_test):.4f}")
print(f"Soft Voting - テスト精度: {voting_soft.score(X_test, y_test):.4f}")

Hard VotingとSoft Votingの結果を比較すると、Soft Votingがわずかに良い精度を示すのが典型的です。これは確率値を活用することで、より情報量の多い統合が行われるためです。

3手法の特性比較

最後に、Bagging・Boosting・Stackingの特性を整理します。

# 特性比較表の作成
comparison_data = {
    "特性": [
        "学習方式", "主な効果", "基本学習器",
        "過学習リスク", "並列化", "計算コスト",
        "ハイパーパラメータ", "代表的手法"
    ],
    "Bagging": [
        "並列(独立)", "分散削減", "高バリアンス(深い木)",
        "低い", "容易", "中程度",
        "モデル数、サンプル率", "ランダムフォレスト"
    ],
    "Boosting": [
        "逐次(依存)", "バイアス削減", "高バイアス(浅い木)",
        "中〜高", "困難", "高い",
        "モデル数、学習率、木の深さ", "XGBoost, LightGBM"
    ],
    "Stacking": [
        "2段階", "バイアス+分散", "異種モデル",
        "中程度", "Level 0は可能", "最も高い",
        "ベース学習器の選択、CV数", "StackingClassifier"
    ]
}

# テーブル表示
print(f"{'特性':<20} {'Bagging':<25} {'Boosting':<25} {'Stacking':<25}")
print("=" * 95)
for i, prop in enumerate(comparison_data["特性"]):
    print(f"{prop:<20} {comparison_data['Bagging'][i]:<25} "
          f"{comparison_data['Boosting'][i]:<25} "
          f"{comparison_data['Stacking'][i]:<25}")

この比較表から、各手法の使い分けの指針が見えてきます。

Baggingを選ぶべき場面: 過学習が問題のとき(高バリアンスの学習器を使うとき)。並列計算が可能で、モデル数を増やしても安全。

Boostingを選ぶべき場面: バイアスが高い(アンダーフィッティングしている)とき。最高精度を追求するとき。ただし、ハイパーパラメータの調整が必要。

Stackingを選ぶべき場面: 異なる特性を持つ複数のモデルが手元にあるとき。コンペ等で最終的な精度を搾り出したいとき。ただし計算コストが最も高い。

実務での使い分けガイド

実際のプロジェクトでアンサンブル手法を選ぶ際のフローチャートを示します。

print("""
=== アンサンブル手法の選択フローチャート ===

1. まず単一モデル(決定木、ロジスティック回帰等)でベースライン
   ↓
2. バイアスが高い(訓練誤差が大きい)?
   → YES: Boosting(XGBoost, LightGBM)を試す
   → NO: ステップ3へ
   ↓
3. バリアンスが高い(訓練-テスト誤差の差が大きい)?
   → YES: Bagging(ランダムフォレスト)を試す
   → NO: ステップ4へ
   ↓
4. さらに精度を上げたい?
   → YES: 異種モデルでStacking
   → NO: 現在のモデルで十分

=== 実務でのTips ===
- ランダムフォレスト: 最初に試すべきアンサンブル。ほぼチューニング不要
- LightGBM/XGBoost: 表形式データの最高性能。ただし要チューニング
- Stacking: コンペ向け。実務では計算コストとメンテナンスコストを考慮
- Voting: 最もシンプル。既存の複数モデルを手軽に統合
""")

まとめ

本記事では、アンサンブル学習の3大手法であるBagging、Boosting、Stackingの理論と実装を解説しました。

重要なポイントを振り返ります。

  • アンサンブルの本質: 学習器間の相関 $\rho$ を下げることで、分散を効果的に削減する
  • Bagging: ブートストラップサンプリングで独立に学習し、分散を削減する。過学習に強い
  • Boosting: 前のモデルの残差を逐次学習し、バイアスを削減する。過学習に注意が必要
  • Stacking: 異種モデルの予測をメタ学習器が最適統合する。最も柔軟だが計算コストが高い
  • AdaBoost: 指数損失の逐次最小化として理論的に解釈できる

実務では、まずランダムフォレストやLightGBMを試し、必要に応じてStackingで精度を追い込むのが効率的な戦略です。

次の記事では、教師なし学習の代表的手法であるDBSCAN密度ベースクラスタリングについて解説します。