交差検証

今回は機械学習モデルの作成時に必須な考え方である交差検証について学んだことをまとめます。

学習モデルの汎化性

交差検証の役割は、学習モデルの汎化性を向上させることです。汎化性とは、学習に用いていないデータ(未知データ)を予測する能力です。一般に、学習モデルの汎化性を検証するために、訓練(train)データと検証(validation)データと試験(test)データを用意します。検証データを用意しない場合、未知データに対する予測精度を確かめることができません。機械学習では過学習という訓練データにフィットし過ぎてしまう危険があり、その場合、訓練データとテストデータで予測精度に大きな乖離が生じます。 そこで、汎化性を検証するため検証データ(学習に用いず、正解ラベルがあるデータ)を用意します。しかし、単純な方法(訓練データの3割を検証データとする等)により検証データを用意しただけでは、ランダムなデータ選択であってもデータのばらつきにより十分に汎化性を評価することはできません。交差検証とは、訓練データおよび検証データの分割を反復し検証データの違いによる予測精度のばらつきを評価することであり、より正確に学習モデルの汎化性を検証することができます。
検証データを用いた汎化性の評価手法は様々あるため、主要なものを今回取り上げます。

ホールド・アウト検証(train_test_split)

sklearnライブラリのモジュールの一つであるtrain_test_splitは、データ(訓練用データ)をtrain data とtest dataに分割する機能を持ちます。実際に適用します。今回もkaggleのタイタニック号生存者予測のデータを用います。まず、特徴量を生成します。

train['Sex'] = train['Sex'].replace('male', 0)
train['Sex'] = train['Sex'].replace('female', 1)
test['Sex'] = test['Sex'].replace('male', 0)
test['Sex'] = test['Sex'].replace('female', 1)

train['Embarked'] = train['Embarked'].fillna('C') 
train['Embarked'] = train['Embarked'].map({'S':0, 'Q':1, 'C':2})
test['Embarked'] = test['Embarked'].map({'S':0, 'Q':1, 'C':2})


train['Parch'] = train['Parch'].astype(int)
train['SibSp'] = train['SibSp'].astype(int)
train['Familysize'] = train['Parch'] + train['SibSp'] + 1
test['Parch'] = test['Parch'].astype(int)
test['SibSp'] = test['SibSp'].astype(int)
test['Familysize'] = test['Parch'] + train['SibSp'] + 1

test_Fare = train[(train['Pclass'] == 3) & (train['Age'] > 40)]['Fare'].mean()
test['Fare'] = test['Fare'].fillna(test_Fare) 

train['perFare'] = train['Fare'] / train['Familysize']
test['perFare'] = test['Fare'] / test['Familysize']


train['honorific'] = train['Name'].str.split(',', expand = True)[1].str.split('.', expand = True)[0]
train['honorific'] = train['honorific'].str.replace(' ', '')
honorific_list = train[train['Age'].isnull()]['honorific'].values.tolist()
age_list = []
for honorific in honorific_list:
    age_avg = train[train['honorific'] == honorific]['Age'].mean()
    age_std = train[train['honorific'] == honorific]['Age'].std()
    age_list.append(random.uniform(age_avg - age_std, age_avg + age_std))
train['Age'][np.isnan(train['Age'])] = age_list
train['Age'] = train['Age'].astype(int)

test['honorific'] = test['Name'].str.split(',', expand = True)[1].str.split('.', expand = True)[0]
test['honorific'] = test['honorific'].str.replace(' ', '')
honorific_list = test[test['Age'].isnull()]['honorific'].values.tolist()
age_list = []
for honorific in honorific_list:
    age_avg = train[train['honorific'] == honorific]['Age'].mean()
    age_std = train[train['honorific'] == honorific]['Age'].std()
    age_list.append(random.uniform(age_avg - age_std, age_avg + age_std))
test['Age'][np.isnan(test['Age'])] = age_list
test['Age'] = test['Age'].fillna(28)
test['Age'] = test['Age'].astype(int)

学習に使用する説明変数をtrainEとし、正解ラベルをtrainOとしました。

trainE = train.loc[:, ['perFare', 'Pclass', 'Embarked', 'Sex', 'Familysize']]
trainO = train['Survived']

train_test_splitにより、訓練データと検証データに分割します。分割サイズはデフォルトの3:1の割合とします。

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(trainE, trainO, random_state=42)

データの中身(もともとの訓練データ、訓練データを分割した訓練データ、検証データ)を確認します。

print('元の訓練データ')
print(trainE.describe().round(2))
print()
print('訓練データ')
print(x_train.describe().round(2))
print()
print('検証データ')
print(x_test.describe().round(2))

f:id:datascience_findings:20210316225239p:plain
3つのデータの特徴量ごとの平均・標準偏差に大きな差違は見られませんでした。つまり、予測精度に差は生じないのではないかと考えられます。そこで、分割後の訓練データに対し、決定木を学習させ、そのモデルを3つのデータに適用しました。

# 決定木の作成
my_tree = tree.DecisionTreeClassifier()
clf = my_tree.fit(x_train, y_train)

# 正解率
print('score(オリジナルデータ): {:.2%}'.format(clf.score(trainE, trainO)))
print('score(訓練データ): {:.2%}'.format(clf.score(x_train, y_train)))
print('score(検証データ): {:.2%}'.format(clf.score(x_test, y_test)))
[out]
score(オリジナルデータ): 89.67%
score(訓練データ): 92.66%
score(検証データ): 80.72%

学習に用いた訓練データおよび、訓練データが75%含まれるオリジナルデータはいずれも約90%の正解率でした。一方、未知データである検証データでは約80%と大幅に正解率が低下し事前の予測とは異なる結果となりました。
つまり、データフレームごとの特徴量の統計量の差異を確認しただけでは予測精度を推定することはできないということです。

予測精度と分割サイズの影響

訓練データのサイズを0.75としていましたが、0.5、0.9と変更し予測精度を比較しました。

x_train, x_test, y_train, y_test = train_test_split(trainE, trainO, test_size=0.5, random_state=42)
clf = my_tree.fit(x_train, y_train)
print('score(オリジナルデータ): {:.2%}'.format(clf.score(trainE, trainO)))
print('score(訓練データo.5): {:.2%}'.format(clf.score(x_train, y_train)))
print('score(検証データ0.5): {:.2%}'.format(clf.score(x_test, y_test)))
print()
x_train, x_test, y_train, y_test = train_test_split(trainE, trainO, test_size=0.1, random_state=42)
clf = my_tree.fit(x_train, y_train)
print('score(オリジナルデータ): {:.2%}'.format(clf.score(trainE, trainO)))
print('score(訓練データo.9): {:.2%}'.format(clf.score(x_train, y_train)))
print('score(検証データ0.1): {:.2%}'.format(clf.score(x_test, y_test)))
[out]
score(オリジナルデータ): 85.75%
score(訓練データ0.5): 94.16%
score(検証データ0.5): 77.35%

score(オリジナルデータ): 91.36%
score(訓練データ0.9): 92.26%
score(検証データ0.1): 83.33%

この結果から、train dataサイズが0.5と小さくなるとオリジナルデータと検証データの予測精度が低下し、train dataサイズが0.9と大きくなると予測精度は向上しました。これは、訓練時により多くのデータで学習することで精度が向上するためです。しかし、検証データの割合が小さいことからこの検証結果を真の汎化性と判断してよいか疑問があります。(実際にこの学習モデルでtest dataを予測しkaggleに提出した結果、正解率は80%に届かなかったことから過度な汎化性を示していると考えられます)

交差検証

上記のホールド・アウト検証(一度の検証)では、正確な汎化性を評価することができませんでした。そこで、検証を繰り返す交差検証という手法が多くの場合用いられます。 主要なK-分割交差検証と層化k分割交差検証を取り上げます。

K-分割交差検証

K-分割交差検証とは、元データをk個の訓練データと検証データに分割しk個全てのデータセットに対し検証を行う手法です。これにより、データの組み合わせにより予測精度に差が生じるかがわかります。(差が生じる場合は、データのばらつきが大きく、汎化性に乏しいと考えられます)
まず、実際にデータを分割します。

from sklearn.model_selection import KFold

kf3 = KFold(n_splits=3, shuffle=True, random_state = 42)
print('分割数:3')
for kf_train, kf_test in kf3.split(train):
    print('訓練データサイズ:{} 検証データサイズ:{}'.format(len(kf_train), len(kf_test)))
print()    
kf5 = KFold(n_splits=5, shuffle=True, random_state = 42)
print('分割数:5')
for kf_train, kf_test in kf5.split(train):
    print('訓練データサイズ:{} 検証データサイズ:{}'.format(len(kf_train), len(kf_test)))
[out]
分割数:3
訓練データサイズ:594 検証データサイズ:297
訓練データサイズ:594 検証データサイズ:297
訓練データサイズ:594 検証データサイズ:297

分割数:5
訓練データサイズ:712 検証データサイズ:179
訓練データサイズ:713 検証データサイズ:178
訓練データサイズ:713 検証データサイズ:178
訓練データサイズ:713 検証データサイズ:178
訓練データサイズ:713 検証データサイズ:178

このように設定した分割数kに応じて、1/kの大きさの検証データがランダム(shuffle = Trueの場合)に作成されます。また、検証データを入れ替えるためk回データ分割が行われていることがわかります。
実際に分割されたデータの中身を確認します。

kf3 = KFold(n_splits=89, shuffle=True, random_state = 42)

print('分割数:89')
for i, (kf_train, kf_test) in enumerate (kf3.split(train)):
    print('{}番目の検証データ:{}'.format((i+1), kf_test))
    if i == 9: 
        break
[out]
分割数:89
1番目の検証データ:[ 39 136 137 208 290 300 333 439 709 720 840]
2番目の検証データ:[110 244 294 344 485 621 653 696 853 886]
3番目の検証データ:[ 30  63 192 396 447 538 673 682 819 877]
4番目の検証データ:[ 23 120 141 198 204 235 620 739 793 842]
5番目の検証データ:[ 67  86 210 350 362 448 477 659 790 837]
6番目の検証データ:[ 44  70 211 280 299 360 408 585 802 820]
7番目の検証データ:[ 72 168 196 312 422 426 446 532 591 772]
8番目の検証データ:[254 266 311 357 539 605 767 833 835 889]
9番目の検証データ:[ 66 165 174 215 250 309 319 493 778 822]
10番目の検証データ:[ 76 281 321 327 338 388 541 545 625 712]

このように分割された中身には要素番号のみが格納されています。また、検証データが重ならないように抽出されていることがわかります。(89分割のため最初の10個のみ表示)

続いて交差検証による予測精度のばらつきおよび予測の過程(決定木の可視化)を行います。

# 可視化に必要なモジュールをインポート
from sklearn.model_selection import GridSearchCV
import pydotplus as pdp

kf3 = KFold(n_splits=3, shuffle=True, random_state = 42)
print('分割数:3')
for i, (kf_train, kf_test) in enumerate (kf3.split(train)):
    # 訓練データ
    x_train = trainE.loc[kf_train]
    y_train = trainO.loc[kf_train]
    # 検証データ
    x_test = trainE.loc[kf_test]
    y_test = trainO.loc[kf_test]
    # 決定木を学習し予測
    clf = my_tree.fit(x_train, y_train)
    print('{}番目のscore:{:.2%}'.format((i+1), clf.score(x_test, y_test)))

    # 学習した決定木を可視化
     dot_data = tree.export_graphviz(clf, 
                                    out_file=None, # ファイルは介さずにGraphvizにdot言語データを渡すのでNone
                                    filled=True, # Trueにすると、分岐の際にどちらのノードに多く分類されたのか色で示してくれる
                                    rounded=True, # Trueにすると、ノードの角を丸く描画する
                                    feature_names=trainE.columns[:5], # 分類の基準となった特徴量を示す
                                    class_names=['Survived', 'Dead'], # 生存したか否かを示す
                                    )
    graph = pdp.graph_from_dot_data(dot_data)
    file_name = "./tree_visualization{}.png".format(i+1)
    graph.write_png(file_name)
[out]
分割数:3
1番目のscore:79.12%
2番目のscore:76.43%
3番目のscore:80.47%

<一度目の分割>決定木1 f:id:datascience_findings:20210320092917p:plain <1度目の分割(トップ切り取り)> f:id:datascience_findings:20210320094018p:plain <2度目の分割>決定木2 f:id:datascience_findings:20210320105209p:plain <3度目の分割>決定木3 f:id:datascience_findings:20210320105222p:plain

Kfoldにより3度分割した決定木を示しました。決定木の要素であるノードの一番上には分類する特徴量の基準が示されます。基準はジニ不純度により決定します。不純度が低い場合ジニ不純度は0に、高い場合には1に近づきます。不純度は下記の式で表されます。 f:id:datascience_findings:20210320094951p:plain
ここで、tは不純度を求めたいノード、cは目的変数(Survived or Dead)のクラス数、Nはデータのサンプル数、niはクラスごとに属するデータ数を表します。 つまり、トップのノードにおける不純度は下記の式で表されます。

# 不純度
gini = 1-((372/594)**2 + (222/594)**2)
gini
[out]
0.46811549841852873

そして、訓練データを最初にSex≧0.5(男性か女性か)で分類することで、2層目のノードではジニ不純度が0.305(女性)、0.4(男性)となり元の不純度より低くなっていることがわかります。決定木では、ジニ不純度が最も小さくなるような条件を選定し学習します。今回は、Kfoldにより3度データを分割していますがどの場合でも最初の分類では、Sexとなりました。つまり、SexはSurvivedと最も相関が高い特徴量であるということを示しており、これまでの記事で示した相関係数とも一致します。
また、2層目のノードで分類する特徴量は女性ではperFare、男性ではPclassと異なりました。特に男性のPclas≦2.5での分類は決定木の収束が最も早くSurvivedに強く寄与する特徴量であることがわかりました。 多変量解析の記事で示したように、説明変数をデータ全体に対し決定するのではなく、性別で分類した後に決定することが予測精度の向上に繋がった結果とも一致します。

決定木と予測精度の関係

また、決定木と予測精度の関係を考察します。予測精度は決定木2が決定木1,3と比較し低い結果となりました。これは決定木の幅に関係すると考えます。決定木の幅が広いということは、決定木の深さが増してもノードが収束していかないということを意味します。実際に決定木2では深さ8でノードが40ありましたが決定木3ではノードは28でした。つまり、決定木2の訓練データは全体的に不純度が高く分割されたデータであると解釈できます。 このように、分割するデータセットによって、予測精度に差が生じることがわかりました。モデルを学習する際には、交差検証により汎化性を検証することが重要であることが実際に確かめられました。

層化k分割交差検証

続いて、層化k分割交差検証を適用します。これは、分割するデータセットの目的変数の出現比率を一定とし分割する手法です。タイタニック号の全乗客の生存率は約38%でした。

np.round(train['Survived'].mean(),2)

[out]
0.38

先程の単純なK個への分割ではデータセットごとの生存率に差が生じてしまいます。仮に分割した訓練データ全てが生存者となった場合にはモデルが学習を行う必要がなくなります。そこで、層化k分割交差検証を適用することで、正解ラベルが揃っている場合の予測精度のばらつきを確認することができます。ばらつきが小さくなれば、選定した説明変数によってデータ全体をざっくり網羅できていると考えられます。 説明変数は先程までと同様perFare、Pclass'、Embarked、Sex、Familysizeの場合(CASE1)とEmbarkedのみとした場合(CASE2)で比較しました。

skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
score = []
print('CASE1')
for i, (skf_train, skf_test) in enumerate (skf.split(trainE, trainO)):
    # 訓練データ
    x_train = trainE.loc[skf_train]
    y_train = trainO.loc[skf_train]
    # 検証データ
    x_test = trainE.loc[skf_test]
    y_test = trainO.loc[skf_test]
    # 決定木を学習し予測
    clf = my_tree.fit(x_train, y_train)
    score.append(clf.score(x_test, y_test))

    print('{}番目のscore:{:.2%}'.format((i+1), clf.score(x_test, y_test)))
    print('{}番目の訓練データの生存者の比率:{:.2%}'.format((i+1), y_train.mean()))   
    print('{}番目の検証データの生存者の比率:{:.2%}'.format((i+1), y_test.mean()))       
    print()
print('予測精度の標準偏差:{:.2}'.format(np.std(score)))
CASE1
1番目のscore:78.11%
1番目の訓練データの生存者の比率:38.38%
1番目の検証データの生存者の比率:38.38%

2番目のscore:78.79%
2番目の訓練データの生存者の比率:38.38%
2番目の検証データの生存者の比率:38.38%

3番目のscore:79.12%
3番目の訓練データの生存者の比率:38.38%
3番目の検証データの生存者の比率:38.38%

予測精度の標準偏差:0.42
trainE2 = train.loc[:, ['Embarked']]
trainO2 = train['Survived']
score = []
skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
print('CASE2)
for i, (skf_train, skf_test) in enumerate (skf.split(trainE2, trainO2)):
    # 訓練データ
    x_train = trainE2.loc[skf_train]
    y_train = trainO2.loc[skf_train]
    # 検証データ
    x_test = trainE2.loc[skf_test]
    y_test = trainO2.loc[skf_test]
    # 決定木を学習し予測
    clf = my_tree.fit(x_train, y_train)
    score.append(clf.score(x_test, y_test))
    
    print('{}番目のscore:{:.2%}'.format((i+1), clf.score(x_test, y_test)))
    print('{}番目の訓練データの生存者の比率:{:.2%}'.format((i+1), y_train.mean()))   
    print('{}番目の検証データの生存者の比率:{:.2%}'.format((i+1), y_test.mean()))       
    print()
print('予測精度の標準偏差:{:.2}'.format(np.std(score)))
CASE2
1番目のscore:64.98%
1番目の訓練データの生存者の比率:38.38%
1番目の検証データの生存者の比率:38.38%

2番目のscore:61.95%
2番目の訓練データの生存者の比率:38.38%
2番目の検証データの生存者の比率:38.38%

3番目のscore:64.65%
3番目の訓練データの生存者の比率:38.38%
3番目の検証データの生存者の比率:38.38%

予測精度の標準偏差:1.36

この結果から、CASE2の場合は正解率が劣ることはもちろん、標準偏差もCASE1と比較し約3倍となりました。例え、目的変数が含まれる割合を等しく分割したとしても、説明変数が不十分であれば分割されるデータセットによって精度に差が生じるということだと思われます。反対に、予測精度のばらつきを十分に小さくなるまでは説明変数を再考する余地があるのではないかと考えます。