多数決アンサンブル分類器sklearn.ensemble.Votingclassifier()の変数やメソッドまとめ

#作業環境
Jupyter Notebook(6.1.4)を用いて作業を進めました。
各versionは`pandas`(1.1.3), `scikit-learn`(0.23.2)です。

#多数決アンサンブル分類器`Votingclassifier()`とは

いくつかの分類器を使って１つのメタ分類器を作る方法を**アンサンブル**法といいます。
学習方法や予測値の出し方に工夫を与えたブースティングやスタッキング、バギングなどが有名ですが、ここでは単純に、個々の分類器がそれぞれ全データに対して学習をして、その結果を多数決で決める単純なアンサンブル分類器を考えていきます。
下は単純多数決アンサンブルのイメージ図です。

![img1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/c2aa5f87-46bf-2320-a617-7b910bdb9a51.jpeg)

このような単純多数決は`sklearn.ensemble`ライブラリの中の`VotingClassifier`クラスで実装することができます。下のリンクは公式ガイドです。

https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.VotingClassifier.html#sklearn.ensemble.VotingClassifier

この公式ガイドを見ながら実際に実装を行ってみました。(2021.8.19現在の公式ガイドのsklearnのversionは0.24.2になっています)

#`VotingClassifier()`の各変数の意味

公式ガイドを見ながら解釈した`VotingClassifier()`の変数は以下の6つです。

| 変数名 | default | 意味・詳細 |
|:-:|:-:|:-:|
| `estimators` | 必須 | 各分類器を与える変数。`[('est1', LogisticRegression()), …]`のように分類器の名前と分類器のタプルをリストにして渡す。 |
| `voting` | `hard` | `hard`の場合、各分類器の予測値の多数決をとって予測値を返す。`soft`の場合、各分類器のクラス所属確率の平均から最も所属確率の高いクラスを予測値として返す。 |
| `weights` | `None`([1, 1, …, 1]) | 予測値を与える際、各分類器のクラスラベルまたは所属確率に重みを与える。 |
| `n_jobs` | `None`(1) | 分類器の`fit`を並行処理する数を指定する。`-1`で全てのプロセッサを指定できる。 |
| `flatten_transform` | `True` | `voting='soft'`を指定し`transform`メソッドを使う際に影響する。`True`を指定すると所属確率をデータのインデックスごとにリストにして返す。`False`を指定すると所属確率を各分類器ごとにリストにして返す。
|`verbose` | `False` | `True`の場合には実行時間を表示する。 |

実際に実装しながら動作を確認してみたいと思います。

#データと分類器の準備
今回は`scikit-learn`の`datasets`の中のアヤメのデータを使って確認していきます。簡単のため2値問題にしたいので、２つのクラスのみ取り出してクラスラベルを数値化し、訓練データとテストデータに分割後、特徴量の標準化を行っておきます。

```python
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import LabelEncoder

iris = datasets.load_iris()
X, y = iris.data[50:, [1,2]], iris.target[50:]
le = LabelEncoder()
y = le.fit_transform(y)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=1, stratify=y)
sc = StandardScaler()
X_train_std = sc.fit_transform(X_train)
```

また、アンサンブルする分類器を用意します。今回は以下の３つにしてみます。

- ロジスティック回帰
- ランダムフォレスト
- 最近傍法

```python
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier

est1 = LogisticRegression(penalty='l2', C=0.01, random_state=1)
est2 = RandomForestClassifier(n_estimators=10, random_state=1)
est3 = KNeighborsClassifier(n_neighbors=1)
```

#変数を変えながら動作を確認する

予測精度に影響する変数は`estimators`, `voting`, `weights`の3つなので、まずはここを少し変えてテストデータでの正解率を求めてみます。使用したメソッドは次の2つです。

| メソッド | 意味 |
|:-:|:-:|
| `.fit(X, y)` | X, yを教師データとして学習を行う|
| `.score(X, y)` | yを正解データとして、Xから予測される値の正解率を求める |


```python
from sklearn.ensemble import VotingClassifier
#1. クラスラベルから多数決で予測(voting='hard')
vc1 = VotingClassifier(estimators=[('lr', est1), ('rf', est2), ('knn', est3)], voting='hard')
#2. クラス所属確率から予測(voting='soft')
vc2 = VotingClassifier(estimators=[('lr', est1), ('rf', est2), ('knn', est3)], voting='soft', flatten_transform=True)
#3. クラス所属確率から予測(voting='soft'), 重みを設定(ロジスティック回帰を重視する)
vc3 = VotingClassifier(estimators=[('lr', est1), ('rf', est2), ('knn', est3)], voting='soft', flatten_transform=True, weights=[100, 1, 1])

#各分類器の正解率の計算
name_list = ['Logistic', 'RandomForest', 'KNN', 'voting=hard', 'voting=soft', 'voiting=soft, weights=[20, 1, 1]']
est_list = [est1, est2, est3, vc1, vc2, vc3]
for est, name in zip(est_list, name_list):
    est.fit(X_train_std, y_train)
    print('ACC. : %.2f [%s]' % (est.score(X_test_std, y_test), name))
#--->
#ACC. : 0.84 [Logistic]
#ACC. : 0.90 [RandomForest]
#ACC. : 0.86 [KNN]
#ACC. : 0.90 [voting=hard]
#ACC. : 0.88 [voting=soft]
#ACC. : 0.86 [voiting=soft, weights=[20, 1, 1]]
```

正解率について少し差があることがわかります。次の3つのメソッドを使用して予測値や各学習器の様子を確認してもう少し違いをみてみます。

| メソッド | 意味 |
|:-:|:-:|
| `.predict(X)` | 学習済みモデルについて、Xに対する予測値をNumpy配列で返す |
| `.transform(X)` | 各分類器のXの各行に対するクラスラベルや所属確率をNumpy配列にして返す(`flatten_transform`によって配列の形が異なる) |
| `.predict_proba(X)` | `voting='soft'`を指定している場合、Xの各行に対するクラス所属確率の全分類器の平均値をNumpy配列にして返す(`voting='hard'`のときは使用不可) |

```python
#X_trainのindex=8のデータについて各アンサンブル分類器の予測結果を出力する
print('vc1 predict : ', vc1.predict(X_train_std)[8])
print('vc2 predict : ', vc2.predict(X_train_std)[8])
print('vc3 predict : ', vc3.predict(X_train_std)[8])
#--->
#vc1 predict :  1
#vc2 predict :  1
#vc3 predict :  0

#各分類器の予測結果の確認
#transformの結果は
#[est1の予測結果, est2の予測結果, est3の予測結果]
#の順になっている
print(vc1.transform(X_train_std)[8])
#--->
#[0 1 1]

#transformの結果は
#[est1で0に所属する確率, est1で1に所属する確率, est2で0に所属する確率, est2で1に所属する確率, est3で0に所属する確率, est3で1に所属する確率]
#の順になっている
print(vc2.transform(X_train_std)[8])
print(vc2.predict_proba(X_train_std)[8])
#--->
#[0.52310858 0.47689142 0.2        0.8        0.         1.        ]
#[0.24103619 0.75896381]

print(vc3.transform(X_train_std)[8])
print(vc3.predict_proba(X_train_std)[8])
#--->
#[0.52310858 0.47689142 0.2        0.8        0.         1.        ]
#[0.51481234 0.48518766]
```

まず予測結果について、vc3のみがほか2つと違った予測をしていることがわかります。予測の過程を見てみると、特に所属確率について、ランダムフォレストや最近傍法は片方のクラスに偏った所属確率を持つことがわかります。`voting='soft'`の場合はこれらの所属確率の平均をとるので、`predict_proba`の結果をみてわかる通り、重みをつけなければ予測はランダムフォレストと最近傍法の結果にほとんど支配されてしまうことがわかります。
各分類器で返される`predict_proba`の値があまり確率論的に意味を持たない分類器を使用する場合には`voting='hard'`を指定する方がアンサンブルの効果が高いように思います。

`transform`メソッドの結果について、変数の中で`flatten_transform=False`とすることで出力の形を変えることができます。また、`verbose=True`とすることで各分類器の学習にかかった時間が表示されます。

```python
vc2_2 = VotingClassifier(estimators=[('lr', est1), ('rf', est2), ('knn', est3)], voting='soft', flatten_transform=False, verbose=True)
vc2_2.fit(X_train_std, y_train)
print(vc2_2.transform(X_train_std)[0, 8])
print(vc2_2.transform(X_train_std)[1, 8])
print(vc2_2.transform(X_train_std)[2, 8])
#---> 
#[Voting] ....................... (1 of 3) Processing lr, total=   0.0s
#[Voting] ....................... (2 of 3) Processing rf, total=   0.0s
#[Voting] ...................... (3 of 3) Processing knn, total=   0.0s
#[0.52310858, 0.47689142]
#[0.2       , 0.8       ]
#[0.        , 1.        ]
```

また、各分類器のハイパーパラメータについて、次のメソッドを使うことでアンサンブル分類器から直接確認・変更することができます。

| メソッド | 意味 |
|:-:|:-:|
| `.get_params()` | 現在の各分類器のハイパーパラメータを返す |
| `set_params(name__parameter=new_value)` | 指定した名前の分類器のハイパーパラメータを新しい値に変更する |

```python
vc1.get_params()
#--->
#{'estimators': [('lr', LogisticRegression(C=0.01, random_state=1)),
#  ('rf', RandomForestClassifier(n_estimators=10, random_state=1)),
#  ('knn', KNeighborsClassifier(n_neighbors=1))],
# 'flatten_transform': True,
# 'n_jobs': None,
# 'verbose': False,
# 'voting': 'hard',
# 'weights': None,
# 'lr': LogisticRegression(C=0.01, random_state=1),
# 'rf': RandomForestClassifier(n_estimators=10, random_state=1),
# 'knn': KNeighborsClassifier(n_neighbors=1),
# 'lr__C': 0.01,
# 'lr__class_weight': None,
# 'lr__dual': False,
# 'lr__fit_intercept': True,
# 'lr__intercept_scaling': 1,
# 'lr__l1_ratio': None,
# 'lr__max_iter': 100,
# 'lr__multi_class': 'auto',
# 'lr__n_jobs': None,
# 'lr__penalty': 'l2',
# 'lr__random_state': 1,
# 'lr__solver': 'lbfgs',
# 'lr__tol': 0.0001,
# 'lr__verbose': 0,
# 'lr__warm_start': False,
# 'rf__bootstrap': True,
# 'rf__ccp_alpha': 0.0,
# 'rf__class_weight': None,
# 'rf__criterion': 'gini',
# :
# 'knn__p': 2,
# 'knn__weights': 'uniform'}

vc1.set_params(lr__C=0.0001)
#--->
#VotingClassifier(estimators=[('lr',
#                              LogisticRegression(C=0.0001, random_state=1)),
#                             ('rf',
#                              RandomForestClassifier(n_estimators=10,
#                                                     random_state=1)),
#                             ('knn', KNeighborsClassifier(n_neighbors=1))])
```