交差検証中に訓練データに対してのみAugmentationを実施する

背景

scikit-learn のGridSearchCVなど cross validation を実施する時に、.fit()メソッドに渡したXyに対して指定した cross validation の方法で training データセットと validation データセットに分割してくれます。例えば、cv=5を指定すればデータセットを 5 分割して、1 回のパラメータで 5 つモデルを作り、評価指標を計算してくれるでしょう。

ところで、Data Augmentation を実施しているときなど、training データセットは増やしたいが validation データセットは増やしたくないときがあります。しかし、そうしたときもそのまま.fit(X, y)に Augmentation で増やしたデータも渡してしまうと、正しくモデルが validation されなくなってしまいます。

具体例#

上の説明だけではよくわからないと思うので、もう少しモチベーションを書きます。実際、GridSearchCVに対して何も考えずにデータを渡すと次のように、意図しない Leakage が発生することがあります。

import numpy as np
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier

def load_augment_data(X, y):
    # ... X や y になんらかのノイズを加えて返すような処理
    return aug_X, aug_y

origin_X, origin_y = load_origin_data()
aug_X, aug_y = load_augment_data(origin_X, origin_y)  # 増やした分のデータ
# 元のデータと増やした分のデータを結合させる
X = np.concatenate((origin_X, aug_X))
y = np.concatenate((origin_y, aug_y))
params = [{...}]  # 本当にグリッドサーチするときはパラメータの探索範囲を指定する
grid_search = GridSearchCV(RandomForestClassifier(), param_grid=params)
grid_search.fit(X, y)  # LEAKAGE!!

このコードだと、.fit(X, y)の内部ではもとのデータも Augment したデータも同列に扱われ、モデルの評価にも使われてしまいます。Augmentation は学習データをかさ増しするためのテクニックで、Augment したデータを用いてモデルの性能を評価してはいけません 。したがって、このコードにおいてGridSearchCVは誤った評価に基づいて「最良」のモデルを返すことになります1

簡単に言うと、GridSearchCVを使う場合、.fit(X, y)を呼ぶ段階でX, yに Augment したデータが混じっていてはマズイわけです。

ではどうすればいいのか? というのを可能な限り SciKit Learn やその周辺のフレームワークの枠組みに則って解決しようというのがこの記事の趣旨になります。

解決方法

一言で言えば、「Data Augmentation を(オーバー)サンプリングだと思い、imbalanced-learn の API を活用する」がこの記事で記載する内容です。

imbalanced-learn は本来不均衡データに対処するために、教師ラベルが多いデータを少ないデータに合わせて少なめにサンプリングしたり、逆に少ないデータを複数回サンプリングしたりするためのライブラリです。

言い換えると、データに合わせて学習直前に多め/少なめにサンプリングすることを念頭においている処理で、当然推論直前には何もしないことが期待されています。これを利用して、学習直前だけ Data Augmentation をし、validation 直前には何もしないパイプラインを実現します。

こうした一連の処理をよしなにやってくれるのがimblearn.pipeline.Pipelineです。微妙に本家 scikit-learn のEstimatorTransformerとは API が異なるため、imbalanced-learn の Pipeline クラスを利用します。2。もし sklearn.pipeline.Pipeline を知らなければPython: scikit-learn の Pipeline を使ってみる - CUBE SUGAR CONTAINER などを参照してください

サンプル実装#

ここではおなじみ iris データセットを使って cross validation 時にデータが学習直前にだけ増えているのかを確認します。

やること#

タイトルには Data Augmentation と書きましたが、ここではオーバーサンプリングをします。具体的には、学習に使う 1 つのレコードを 2 つにするというオーバーサンプリングをします。もちろん通常の機械学習の実験で実施しても全く意味のないサンプリングです。しかし、意図通りにデータを増やせていることが確認しやすいのでこのようにします。

実装例

import numpy as np
import pandas as pd


class DoubleSmapler():
    def __init__(self, source, **kwargs):
        self.source = source
        self._indexes = None

    def _load_from_source(self):
        df = pd.read_csv(self.source)
        return df.iloc[:, :-1], df.iloc[:, -1]

    def fit(self, X, y):
        self._indexes = X.index
        return self

    def fit_resample(self, X, y):
        self.fit(X, y)
        augment_X, augment_y = self._load_from_source()
        add_X = augment_X.loc[self._indexes, :].values
        add_y = augment_y.loc[self._indexes, :].values

        return np.concatenate((X, add_X)), np.concatenate((y, add_y))

.fit(X, y)が渡された時に、Xの index を保存します。これはサンプルなので単に index を保持していますが、真面目にやるならば学習データの ID 列を作って保持するのがいいでしょう。そうすると、fit(X, y)で渡されたレコードの ID をこのクラスは知っている(self._indexesに格納されている)ことになります。

そしてfit_resample(X, y)が呼ばれたときに、外部からデータを読み込みfitのときに知った index と同じ index のものを外部データから抽出しています。それとtransformにわたされた元のデータを連結させてサンプリングは終了です。

前提

ちょっと脱線ですが、動かす前提を書いておきます。

このクラスを動かすには、次のような形式の iris データを CSV ファイル形式で持っていることを想定しています。

sepal length (cm),sepal width (cm),petal length (cm),petal width (cm),target
5.1,3.5,1.4,0.2,0
4.9,3.0,1.4,0.2,0
4.7,3.2,1.3,0.2,0
4.6,3.1,1.5,0.2,0
5.0,3.6,1.4,0.2,0
5.4,3.9,1.7,0.4,0
4.6,3.4,1.4,0.3,0
5.0,3.4,1.5,0.2,0
4.4,2.9,1.4,0.2,0

また.fit(X, y)で渡されるXpd.DaraFrameであることを想定しています。(.indexで行数にアクセスしているので)

期待通り動作することを確認#

では実際にこのクラスを使って、iris データを"学習データのみ 2 倍にして"学習できているのかを確認してみましょう。上記の iris.csv を読み込み、実際に学習をさせてみます。

import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from imblearn.pipeline import Pipeline  # sklearn.pipeline ではないことに注意

from augment import DoubleSmapler

data = pd.read_csv('iris.csv')
X = data.iloc[:, :-1]
y = data.iloc[:, -1]

# データが増えていることを確認するためにデータ形式を表示するクラス
class Debugger():
    def fit(self, X, y=None):
        print('fit!')
        return self

    def transform(self, X, y=None):
        print('transform:', X.shape)
        return X

pipe = Pipeline(steps=[
    ('deb1', Debugger()),
    ('aug', DoubleSmapler(source='iris.csv')),
    ('deb2', Debugger()),
    ('rf', RandomForestClassifier()),
])

こんな感じでパイプラインを定義します。データが意図どおり増えている(パイプラインスタート時と学習直前時でちょうど 2 倍になっている)ことを確認するために、行列の形を表示するクラスを仕込みましたが、それ以外は変わったことは何もしていません。

これで cross validation を実施すると次のように表示されます。

cross_val_score(pipe, X=X, y=y, cv=5)
    fit!
    transform: (120, 4)
    fit!
    transform: (240, 4)
    transform: (30, 4)
    transform: (30, 4)
    (中略)
    array([0.96666667, 0.96666667, 0.93333333, 0.93333333, 1.        ])

こんな感じで学習のために使われるデータ(150 / 5 * 4 = 120)は倍の 240 個になってから学習が実施され、推論のための 30 個はデータは増えないで 30 個のままで推論がなされました。

まとめ

  • augmentation をして得られたデータは cross validation 時の validation セットに加えてはならない
  • cross validation をするときは imbalanced-learn の Sampler 実装を真似るなどして Augmentation を実施するクラス(もしくは関数)を作成する
  • Pipeline に任せると学習直前にのみAugmentation を実施し、validation 直前には Augmentation を実施しないという処理が簡単に実現できる

これ思いつくのに結構時間がかかった3んですが、わりと当たり前のように行われているテクニックだったりするんでしょうか。


  1. これに類似した Linkage が『Python ではじめる機械学習 』の 304-306 ページに掲載されています。なお、この記事で解説する方法はこの本の事例のコードの解決策として採用されているコードから筆者が着想し、具体化したものです。 ↩︎

  2. 具体的には.transform(X)の返り値でコケます ↩︎

  3. Pipeline が学習直前には .fit()transform() を実施し、Validation 時には transform() しか実施しないので fit() で受け取った学習データにしか augmentation を実行しないようにできないかと考え始めた。この問題ばかり考えていたわけじゃないが、この問題を意識してからこの着想を得るまでに 2 週間くらいはかかった気がする。 ↩︎