AIって結局何なのかよく分からないので、とりあえず100日間勉強してみた Day31
経緯についてはこちらをご参照ください。
■本日の進捗
●交差検証を理解
■はじめに
引き続き「Pythonではじめる機械学習(オライリー・ジャパン)」で学んでいきます。
これまで数々の機械学習アルゴリズムを用いて回帰やクラス分類タスクにおける予測モデル(そのどれもが実用レベルにないにせよ)を構築してきました。その度に学習では用いていないテストデータでのR2スコアやAccuracyにてそのモデルの予測精度を評価しました。
また、テストデータを用いたハイパーパラメータの最適化なども幾度となくトライしてきました。(明記していないモデルでもハイパーパラメータは都度調整していましたし、実は試行錯誤の結果上手くいかなかったものも多いです。)
今回からはより配慮の行き届いた評価手法と、本当の意味での最適化手法を学んでいきます。
■交差検証
交差検証(cross-validation)とは、機械学習モデル(だけではないがここではその用途しか興味はない)の汎化性能を評価する統計的手法です。
これまではデータセットの20~30%をテストデータとして抜き出し、残りのデータを訓練データとして学習に用いてきました。(scikit-learnではtrain_test_splitで利用でき、一般にHoldout手法と呼ばれます。)
import numpy as np import pandas as pd from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split iris = load_iris() X = pd.DataFrame(iris.data, columns=iris.feature_names) y = iris.target X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=8)
しかしこのHoldoutには大きく次の3つの問題点があります。
1.訓練データへの過剰適合
訓練データはただ一つしか存在しないので、十分に学習した場合でも新しいデータに対する予測精度に期待ができない可能性が残ります。また、訓練データに偏りがあった場合も学習モデルはその影響を強く受けます。
2.モデルの評価が十分でない
訓練データ同様にテストデータもただ一つしか存在しません。そのためテストデータ以外の新規データに対する汎化性能も実際には評価するのが難しくなります。(テストデータに対する性能がたまたま良かったという懸念を拭い切れない。)また、それ故にハイパーパラメータの最適化も実際にはそのテストデータを上手く予測するための最適解に過ぎず、汎化性能のある学習モデルとしての最適化とは言い難いということになります。
3.データの利用効率
データセットの30%をテストデータとして用いる場合、必然的に学習に使えるデータは70%ということになります。折角苦労して(またはお金をかけて)集めたデータの70%しか使えていないというのは、効率的な使い方とは言えない可能性があります。
これらの問題点をなるべく生じないように効率的に徹底したモデルの評価を可能にするのが交差検証です。
基本的な考え方は、データセットを巧みに(ここが重要)いくつかの部分(Fold)に分割して、それをその名の通りクロス(交差、代わる代わる)しながら訓練データとテストデータに交互に割り当て、評価をしていくというものです。
交差検証にはいくつかの手法(基本的な考え方は同じ)がありますが、そのほとんどが図示することが可能で、かつ分かりやすいです。scikit-learnユーザーガイドに多くの手法に対してとても良い図が載っているので詳細なプロセスはそちらに譲りたいと思います。
https://scikit-learn.org/stable/modules/cross_validation.html
■k分割交差検証
k分割交差検証(k-fold cross-validation)は、データセットをk個の同一サイズに分割し、各分割部分(fold)を一度だけテストデータとして用いて、残りのk-1個を訓練データにするという学習と評価をk回繰り返し、その平均値を返します。
まずは参考までに、ワインデータセットに対してHoldoutを行ってscoreで評価してみます。
import numpy as np import pandas as pd from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression wine = load_wine() X = wine.data y = wine.target X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=8) logreg = LogisticRegression(max_iter=10000) logreg.fit(X_train, y_train) score = logreg.score(X_test, y_test) print("score is {:2f}".format(score))
score is 0.925926
続いてk分割交差検証を行いますが、scikit-learnでは(学習前の)モデルとデータとラベルを引数にするだけで評価まですべて行ってくれるcross_val_scoreが実装されています。
import numpy as np import pandas as pd from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score wine = load_wine() X = wine.data y = wine.target logreg = LogisticRegression(max_iter=10000) score = cross_val_score(logreg, X, y, cv=3) print("result: {}".format(score)) print("mean cv score: {}".format(score.mean()))
result: [0.93333333 0.93220339 1. ]
mean cv score: 0.9551789077212806
■層化k分割交差検証
層化k分割交差検証(stratified k-fold cross-validation)は、クラス分類タスクにおいてデータセットを前から順番にk分割していくk分割交差検証とは違い、データセット内のクラスの分布まで考慮して、k個の部分に全体と同じ割合のクラス分布になるように割り当てる手法です。
例えば先ほどのワインデータセットのラベルを順番に見ていくと下記のようにっています。
from sklearn.datasets import load_wine wine = load_wine() print(wine.target)
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2]
これを前から順番にk分割(ここではk=3で想像してみてください)すると、0だけのFoldや1だけのFoldが出来上がり、訓練段階では存在しなかったクラスのみで構成されたテストデータで評価することになってしまいます。これでは全く意味を成さないことは一目瞭然です。
あれ、でも先ほどはちゃんと評価できていそうでしたね?
実は cross_val_score は回帰にはk分割交差検証を、クラス分類には層化k分割交差検証を使うようになっているので、こういった問題は起きにくくなっています。簡単に使えるだけでなく、精度面でも優秀ですね。
ただしKFold分割器クラスも用意されていて、強制的にk分割交差検証を使うこともできます。上記のようなラベルを前から順番に3分割するとどうなるのか実際に試してみます。
import numpy as np import pandas as pd from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.model_selection import KFold wine = load_wine() X = wine.data y = wine.target logreg = LogisticRegression(max_iter=10000) kfold = KFold(n_splits=3) score = cross_val_score(logreg, X, y, cv=kfold) print("result: {}".format(score)) print("mean cv score: {}".format(score.mean()))
result: [0.01666667 0.74576271 0.18644068]
mean cv score: 0.3162900188323918
スコアががくっと落ちていますが、完全に予想通りの挙動です。
実際の層化k分割交差検証の挙動は、デフォルトの cross_val_score の挙動そのもので前述の通りなので割愛します。
■シャッフル分割交差検証
このような問題を解決するには、高度な層化k分割交差検証を使うことで解決できますが、単純にあらかじめ乱数性を持たせればある程度は解消できそうです。
シャッフル分割交差検証(shuffle-split cross-validation)は、各Foldを独立してランダムに分割するので、多様なデータ構成になります。またこれを指定した回数繰り返すことで、層化k分割交差検証とは違った評価が可能です。
ただし、訓練データにもテストデータにも同じデータが繰り返し含まれることがあるため汎化性能の面で注意が必要です。
import numpy as np import pandas as pd from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.model_selection import ShuffleSplit wine = load_wine() X = wine.data y = wine.target logreg = LogisticRegression(max_iter=10000) shuffle = ShuffleSplit(n_splits=3, test_size=0.3, random_state=8) score = cross_val_score(logreg, X, y, cv=shuffle) print("result: {}".format(score)) print("mean cv score: {}".format(score.mean()))
result: [0.92592593 0.98148148 0.94444444]
mean cv score: 0.9506172839506174
平均化する前のスコアはあまり安定していないので乱数シードが必須(仕様上は任意)なのと、乱数によってモデルの評価が難しいので取り扱いの難易度は高そうです。
また、単純にKFoldの引数でshuffle=Trueとするだけでも簡易的に行うことができます。
■Leave-One-Out交差検証
Leave-One-Out交差検証(1つ抜き交差検証)とは、その名の通り1つを除いて残りすべてを訓練データにします。(つまりテストデータは1つ。)これを全通り繰り返します。
想像の通り大規模データセットに対してはかなり高い計算コストを払わないといけないが、逆に小規模なデータセットしかない場合には有効な手法と言えます。
import numpy as np import pandas as pd from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.model_selection import LeaveOneOut wine = load_wine() X = wine.data y = wine.target logreg = LogisticRegression(max_iter=10000) loo = LeaveOneOut() score = cross_val_score(logreg, X, y, cv=loo) print("result: {}".format(score)) print("mean cv score: {}".format(score.mean()))
result: [1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 1. 1. 1. 1. 1. 1. 1. 1. 0. 1.
1. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 1. 1. 1. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
mean cv score: 0.9550561797752809
テストデータが1点なので、個々の評価は正解か不正解かの2択になります。また、このような簡易的なデータセットでもそれなりに重いので実用上の出番があるかどうかは少し疑問です。
こちらもk分割交差検証でkの値をデータポイントの数に指定すれば同様の評価が可能です。
■グループ付き交差検証
一度でも学習に用いたデータはモデルに対して漏洩していると言えます。つまり人の顔を識別するアプリケーションを作ったとして、訓練データとテストデータに同一人物が含まれている場合、もし予測精度が良かったとしても汎化性能が良いとは言えないということです。(顔の特徴を学習していき、全く違う写真を使うのであればある程度の評価はできそうには思えるが、パーツで理解したモデルに対して同一人物を出題すれば汎化性能に寄らず正答率は自ずと高くなるでしょう。)
そんな時にはあらかじめデータをグループ化してしまい、同じグループが訓練データとテストデータの両方には決して出さないようにしてしまおうというのが、グループ付き交差検証の基本的な考え方です。
40人の顔画像がそれぞれ10枚ずつの400枚で構成されているolivetti facesデータセットを用いて、各人をグループ化して同一人物を一切学習させないようにしたら本当の汎化性能を評価できそうです。
まずは普通にHoldoutで学習させてみます。
import numpy as np from sklearn.datasets import fetch_olivetti_faces from sklearn.svm import SVC from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler data = fetch_olivetti_faces() X = data.data y = data.target scaler = StandardScaler() X_scaled = scaler.fit_transform(X) X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=8) model = SVC(kernel='rbf', C=1, gamma='scale') model.fit(X_train, y_train) score = model.score(X_test, y_test) print("Test score: {}".format(score))
Test score: 0.9625
恐らく訓練データの中にテストデータと同一人物がいたのでしょう。かなり良い精度で予測できるモデルが完成しました。
では、このモデルは本当に「初めて見る人物」に対しても同じように分類できるのでしょうか?
直観に従えば、「初めて見る人物」を「旧知の人物」に当てはめることなどできるはずがありません。
from sklearn.datasets import fetch_olivetti_faces from sklearn.svm import SVC from sklearn.model_selection import GroupKFold, cross_val_score from sklearn.preprocessing import StandardScaler import numpy as np data = fetch_olivetti_faces() X = data.data y = data.target groups = np.repeat(np.arange(40), 10) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) model = SVC(kernel='rbf', C=1, gamma='scale') groupkfold = GroupKFold(n_splits=5) score = cross_val_score(model, X_scaled, y, groups=groups, cv=groupkfold) print("score: {}".format(score)) print("mean cv score: {}".format(score.mean()))
score: [0. 0. 0. 0. 0.]
mean cv score: 0.0
5回検証して正解はたったの一度もありませんでした。
それもそのはず、彼はその人を知らないのですから。
■おわりに
これまで習慣的にHoldoutを実施してscoreでモデルを評価してきましたが、データの効率的な利用や訓練データにだけ適合するような学習を避けて、データセットやアルゴリズムごとに評価手法を選択する大切さを学びました。
前処理手法や特徴量エンジニアリングに加えて、モデルの作成後もあらゆる検討の上で最適化する必要がありそうです。
もしこの工程が醍醐味であり面白いからと機械学習エンジニア達がAI化しないのであれば、この世に残る最後の仕事はまさにこれなのかもしれませんね()
■参考文献
- Andreas C. Muller, Sarah Guido. Pythonではじめる機械学習. 中田 秀基 訳. オライリー・ジャパン. 2017. 392p.
- ChatGPT. 4o mini. OpenAI. 2024. https://chatgpt.com/
- API Reference. scikit-learn.org. https://scikit-learn.org/stable/api/index.html