CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 層化抽出法を使ったK-分割交差検証 (Stratified K-Fold CV)

K-分割交差検証 (K-Fold CV) を用いた機械学習モデルの評価では、元のデータセットを K 個のサブセットに分割する。 そして、分割したサブセットの一つを検証用に、残りの K - 1 個を学習用に用いる。

上記の作業で、元のデータセットを K 個のサブセットに分割する工程に着目してみよう。 果たして、どのようなルールにもとづいて分割するのが良いのだろうか? このとき、誤ったやり方で分割すると、モデルの学習が上手くいかなかったり、汎化性能を正しく評価できない恐れがある。

今回は、分割方法として層化抽出法を用いたK-分割交差検証 (Stratified K-Fold CV) について書いてみる。 この方法を使うと、学習用データと検証用データで目的変数の偏りが少なくなる。 実装には scikit-learn の sklearn.model_selection.StratifiedKFold を用いた。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V
Python 3.6.6
$ pip list --format=columns | grep -i scikit-learn
scikit-learn 0.19.2

下準備

まずは今回使う Python のパッケージをインストールしておこう。

$ pip install scikit-learn numpy scipy

本当は怖い KFold CV

セクションのタイトルはちょっと煽り気味になっちゃったけど、実際のところ知っていないと怖い。 例えば scikit-learn が実装している KFold は、データの分割になかなか大きな落とし穴がある。

次のサンプルコードでは sklearn.model_selection.KFold を使ってデータセットを分割している。 問題を単純化するために、データセットには 4 つの要素しか入っていない。 そして、目的変数に相当する変数 y には 01 が 2 つずつ入っている。 このデータセットを 2 つに分割 (2-Fold) したとき、どのような結果が得られるだろうか?

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np

from sklearn.model_selection import KFold


def main():
    # 目的変数のつもり
    y = np.array([0, 0, 1, 1])
    # 説明変数のつもり
    X = np.arange(len(y))

    # デフォルトでは先頭からの並びで分割される
    # 目的変数の並びに規則性があると確実に偏りが生じる
    kf = KFold(n_splits=2)
    for train_index, test_index in kf.split(X, y):
        # どう分割されたか確認する
        print('TRAIN:', y[train_index], 'TEST:', y[test_index])


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 学習用とテスト用のサブセットで、目的変数が偏ってしまっている。

$ python kfold.py                                 
TRAIN: [1 1] TEST: [0 0]
TRAIN: [0 0] TEST: [1 1]

仮に、上記のような偏ったデータを機械学習モデルに学習させて評価させた場合を考えてみよう。 最初の試行では学習データの目的変数に 1 しかないので 0 のパターンをモデルは覚えることができない。 そして、覚えていない 0 だけのデータでモデルが評価されることになる。 もちろん、次の試行でも同様に学習データと検証用データが偏ることになる。 これでは正しくモデルを学習させて評価することはできない。

どうしてこんなことが起こるかというと、デフォルトで K-Fold はデータの並び順にもとづいて分割するため。 例えば、先ほどの例と同じようにデータの並び順に規則性のある Iris データセットを使って検証してみよう。

>>> from sklearn import datasets
>>> dataset = datasets.load_iris()
>>> dataset.target
array([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, 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, 2, 2])

上記のように Iris データセットはあやめの各品種 (目的変数) ごとに規則性を持ってデータが並んでいる。

このように並び順に規則性を持ったデータセットを scikit-learn の KFold のデフォルトパラメータで分割してみよう。

>>> from sklearn.model_selection import KFold
>>> kf = KFold(n_splits=2)
>>> ite = kf.split(dataset.data, dataset.target)
>>> train_index, test_index = next(ite)
>>> dataset.target[train_index]
array([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, 2, 2])
>>> dataset.target[test_index]
array([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])

データが順番通りに真っ二つにされていることが上記から確認できる。

無作為抽出法を用いたK-分割交差検証

上記のようなデータの偏りを減らす方法として無作為抽出法 (Random Sampling) を使うやり方がある。 これは、順番に依存せず無作為にデータを選んでサブセットを作るというもの。

例えば scikit-learn の KFold であれば、オプションに shuffle=True を渡すと無作為抽出になる。 次のサンプルコードで試してみよう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np

from sklearn.model_selection import KFold


def main():
    # 目的変数のつもり
    y = np.array([0, 0, 1, 1])
    # 説明変数のつもり
    X = np.arange(len(y))

    # 無作為抽出法を使って分割する (実行結果は試行によって異なる)
    kf = KFold(n_splits=2, shuffle=True)
    for train_index, test_index in kf.split(X, y):
        # どう分割されたか確認する
        print('TRAIN:', y[train_index], 'TEST:', y[test_index])


if __name__ == '__main__':
    main()

上記を実行してみよう。 試行にもよるけど、ちゃんとデータが偏らずに分割されるパターンもある。

$ python rndkfold.py
TRAIN: [0 1] TEST: [0 1]
TRAIN: [0 1] TEST: [0 1]
$ python rndkfold.py
TRAIN: [0 0] TEST: [1 1]
TRAIN: [1 1] TEST: [0 0]

層化抽出法を用いたK-分割交差検証

先ほどの無作為抽出法では試行によってはサブセットに偏りができる場合もあった。 もちろん、データセットが大きければ大きいほど大数の法則に従って偏りはできにくくなる。 とはいえゼロではないので、そこで登場するのが今回紹介する層化抽出法 (Stratified Sampling) を用いる方法となる。

層化抽出法を使うと、サブセットを作るときに目的変数の比率がなるべく元のままになるように分割できる。 次のサンプルコードでは、実装に StratifiedKFold を使うことで層化抽出法を使った分割を実現している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np

from sklearn.model_selection import StratifiedKFold


def main():
    # 目的変数のつもり
    y = np.array([0, 0, 1, 1])
    # 説明変数のつもり
    X = np.arange(len(y))

    # 層化抽出法を使って分割する
    kf = StratifiedKFold(n_splits=2, shuffle=True)
    for train_index, test_index in kf.split(X, y):
        # どう分割されたか確認する
        print('TRAIN:', y[train_index], 'TEST:', y[test_index])


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python skfold.py  
TRAIN: [0 1] TEST: [0 1]
TRAIN: [0 1] TEST: [0 1]
$ python skfold.py
TRAIN: [0 1] TEST: [0 1]
TRAIN: [0 1] TEST: [0 1]

何度実行しても偏りができないように分割されることが分かる。

試しに Iris データセットを使ったパターンも確認しておこう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np

from sklearn.model_selection import StratifiedKFold
from sklearn import datasets


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target
    
    # 層化抽出法を使って分割する
    kf = StratifiedKFold(n_splits=2, shuffle=True)
    for train_index, test_index in kf.split(X, y):
        # どう分割されたか確認する
        print('TRAIN:', y[train_index], 'TEST:', y[test_index])


if __name__ == '__main__':
    main()

上記を実行すると分割した結果に偏りがないことが分かる。

$ python skfoldiris.py
TRAIN: [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 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] TEST: [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 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]
TRAIN: [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 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] TEST: [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 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]

めでたしめでたし。