一般的に、時系列データを扱うタスクでは過去のデータを使って未来のデータを予測することになる。 そのため、交差検証するときも過去のデータを使ってモデルを学習させた上で未来のデータを使って検証しなければいけない。 もし、未来のデータがモデルの学習データに混入すると、本来は利用できないデータにもとづいた楽観的な予測が得られてしまう。 今回は、そんな時系列データの交差検証と scikit-learn の TimeSeriesSplit の改良について書いてみる。
使った環境は次のとおり。
$ sw_vers ProductName: Mac OS X ProductVersion: 10.14.6 BuildVersion: 18G3020 $ python -V Python 3.8.1
下準備
あらかじめ、必要なパッケージをインストールしておく。
$ pip install scikit-learn pandas seaborn
scikit-learn の TimeSeriesSplit を使った時系列データの交差検証
scikit-learn には、時系列データの交差検証に使うための KFold 実装として TimeSeriesSplit
というクラスがある。
このクラスは、データが時系列にもとづいてソートされているという前提でリークが起こらないようにデータを分割できる。
試しに、航空機の旅客数を記録したデータセットを使って動作を確かめてみよう。 以下にサンプルコードを示す。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- from calendar import month_name import pandas as pd from matplotlib import pyplot as plt import seaborn as sns from sklearn.model_selection import TimeSeriesSplit from pandas.plotting import register_matplotlib_converters register_matplotlib_converters() def main(): # 航空機の旅客数を記録したデータセットを読み込む df = sns.load_dataset('flights') # 時系列のカラムを用意する month_name_mappings = {name: str(n).zfill(2) for n, name in enumerate(month_name)} df['month'] = df['month'].apply(lambda x: month_name_mappings[x]) df['year-month'] = df.year.astype(str) + '-' + df.month.astype(str) df['year-month'] = pd.to_datetime(df['year-month'], format='%Y-%m') # データの並び順を元に分割する folds = TimeSeriesSplit(n_splits=5) # 5 枚のグラフを用意する fig, axes = plt.subplots(5, 1, figsize=(12, 12)) # 学習用のデータとテスト用のデータに分割するためのインデックス情報を得る for i, (train_index, test_index) in enumerate(folds.split(df)): # 生のインデックス print(f'index of train: {train_index}') print(f'index of test: {test_index}') print('----------') # 元のデータを描く sns.lineplot(data=df, x='year-month', y='passengers', ax=axes[i], label='original') # 学習用データを描く sns.lineplot(data=df.iloc[train_index], x='year-month', y='passengers', ax=axes[i], label='train') # テスト用データを描く sns.lineplot(data=df.iloc[test_index], x='year-month', y='passengers', ax=axes[i], label='test') # グラフを表示する plt.legend() plt.show() if __name__ == '__main__': main()
上記のサンプルコードを実行する。
TimeSeriesSplit#split()
から得られた添字が表示される。
$ python flights.py index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23] index of test: [24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47] index of test: [48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] index of test: [72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] index of test: [ 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119] index of test: [120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143] ----------
また、次のような折れ線グラフが得られる。
グラフを見ると、時系列で過去のデータが学習用、未来のデータが検証用として分割されていることがわかる。
TimeSeriesSplit の使いにくさについて
ただ、TimeSeriesSplit
は使ってみると意外と面倒くさい。
なぜかというと、データが時系列にもとづいてソートされていないと使えないため。
たとえば、先ほどのサンプルコードに次のようなデータをシャッフルするコードを挿入してみよう。 こうすると、データが時系列順ではなくなる。
# データの並び順をシャッフルする (時系列順ではなくなる) df = df.sample(frac=1.0, random_state=42)
コードを挿入した状態で実行してみよう。
$ python shuffledflights.py index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23] index of test: [24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47] index of test: [48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] index of test: [72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] index of test: [ 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119] ---------- index of train: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119] index of test: [120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143] ----------
得られるグラフは次のとおり。 当たり前だけど、先ほどとは違って分割の仕方が時系列とは関係ないぐちゃぐちゃな状態になっている。 これでは、学習用データが検証用データよりも未来にあることがあるので、適切な交差検証ができない。
TimeSeriesSplit を改良してみる
そこで、TimeSeriesSplit
を改良してみることにした。
具体的には、次のようなポイント。
DataFrame
を受け取れる- 時系列にもとづいてソートされていなくても良い
- 代わりに、特定のカラムを時系列の情報として指定できる
以下にサンプルコードを示す。
MovingWindowKFold
というクラスがそれで、TimeSeriesSplit
のラッパーとして振る舞うように作ってある。
MovingWindowKFold#split()
は DataFrame#iloc
で使える添字のリストを返す。
DataFrame
そのもののインデックスを返すことも考えたけど、まあそこは必要に応じて直してもらえば。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import sys from calendar import month_name import pandas as pd from matplotlib import pyplot as plt import seaborn as sns from sklearn.model_selection import TimeSeriesSplit from pandas.plotting import register_matplotlib_converters register_matplotlib_converters() class MovingWindowKFold(TimeSeriesSplit): """時系列情報が含まれるカラムでソートした iloc を返す KFold""" def __init__(self, ts_column, clipping=False, *args, **kwargs): super().__init__(*args, **kwargs) # 時系列データのカラムの名前 self.ts_column = ts_column # 得られる添字のリストの長さを過去最小の Fold に揃えるフラグ self.clipping = clipping def split(self, X, *args, **kwargs): # 渡されるデータは DataFrame を仮定する assert isinstance(X, pd.DataFrame) # clipping が有効なときの長さの初期値 train_fold_min_len, test_fold_min_len = sys.maxsize, sys.maxsize # 時系列のカラムを取り出す ts = X[self.ts_column] # 元々のインデックスを振り直して iloc として使える値 (0, 1, 2...) にする ts_df = ts.reset_index() # 時系列でソートする sorted_ts_df = ts_df.sort_values(by=self.ts_column) # スーパークラスのメソッドで添字を計算する for train_index, test_index in super().split(sorted_ts_df, *args, **kwargs): # 添字を元々の DataFrame の iloc として使える値に変換する train_iloc_index = sorted_ts_df.iloc[train_index].index test_iloc_index = sorted_ts_df.iloc[test_index].index if self.clipping: # TimeSeriesSplit.split() で返される Fold の大きさが徐々に大きくなることを仮定している train_fold_min_len = min(train_fold_min_len, len(train_iloc_index)) test_fold_min_len = min(test_fold_min_len, len(test_iloc_index)) yield list(train_iloc_index[-train_fold_min_len:]), list(test_iloc_index[-test_fold_min_len:]) def main(): df = sns.load_dataset('flights') month_name_mappings = {name: str(n).zfill(2) for n, name in enumerate(month_name)} df['month'] = df['month'].apply(lambda x: month_name_mappings[x]) df['year-month'] = df.year.astype(str) + '-' + df.month.astype(str) df['year-month'] = pd.to_datetime(df['year-month'], format='%Y-%m') # データの並び順をシャッフルする df = df.sample(frac=1.0, random_state=42) # 特定のカラムを時系列としてソートした分割 folds = MovingWindowKFold(ts_column='year-month', n_splits=5) fig, axes = plt.subplots(5, 1, figsize=(12, 12)) # 元々のデータを時系列ソートした iloc が添字として得られる for i, (train_index, test_index) in enumerate(folds.split(df)): print(f'index of train: {train_index}') print(f'index of test: {test_index}') print('----------') sns.lineplot(data=df, x='year-month', y='passengers', ax=axes[i], label='original') sns.lineplot(data=df.iloc[train_index], x='year-month', y='passengers', ax=axes[i], label='train') sns.lineplot(data=df.iloc[test_index], x='year-month', y='passengers', ax=axes[i], label='test') plt.legend() plt.show() if __name__ == '__main__': main()
上記を実行してみよう。 もちろん、先ほどと同じようにデータはランダムにシャッフルされている。
$ python movwin.py index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49] index of test: [47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60] ---------- index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60] index of test: [113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139] ---------- index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60, 113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139] index of test: [94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54] ---------- index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60, 113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139, 94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54] index of test: [40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21] ---------- index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60, 113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139, 94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54, 40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21] index of test: [95, 136, 99, 85, 29, 18, 115, 39, 123, 86, 130, 79, 6, 13, 127, 70, 108, 64, 120, 69, 59, 106, 67, 137] ----------
得られるグラフは次のとおり。 ランダムにシャッフルされたデータでも、ちゃんと時系列でデータが分割されていることがわかる。
ちなみに、学習データを等幅に切りそろえる機能も用意した。
この手法に世間的にはなんて名前がついているのかわからないけど、とりあえず clipping
オプションを有効にすれば使える。
folds = MovingWindowKFold(ts_column='year-month', clipping=True, n_splits=5)
有効にした状態で実行するとこんな感じ。 リストの長さが揃っている。
$ python movwin.py index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49] index of test: [47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60] ---------- index of train: [47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60] index of test: [113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139] ---------- index of train: [113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139] index of test: [94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54] ---------- index of train: [94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54] index of test: [40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21] ---------- index of train: [40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21] index of test: [95, 136, 99, 85, 29, 18, 115, 39, 123, 86, 130, 79, 6, 13, 127, 70, 108, 64, 120, 69, 59, 106, 67, 137] ----------
グラフにするとこうなる。
時系列版 train_test_split() も作ってみる
あとは、データが大きかったり Nested CV する状況ならホールドアウトも必要だろうなーと思うので train_test_split()
も作ってみた。
サンプルコードは次のとおり。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- from calendar import month_name import pandas as pd from matplotlib import pyplot as plt import seaborn as sns from pandas.plotting import register_matplotlib_converters from sklearn.model_selection import train_test_split register_matplotlib_converters() def ts_train_test_split(df, ts_column, **options): """時系列情報が含まれるカラムでソートした iloc を返す Hold-Out""" # シャッフルしない options['shuffle'] = False # 時系列のカラムを取り出す ts = df[ts_column] # 元々のインデックスを振り直して iloc として使える値 (0, 1, 2...) にする ts_df = ts.reset_index() # 時系列でソートする sorted_ts_df = ts_df.sort_values(by=ts_column) # 添字を計算する train_index, test_index = train_test_split(sorted_ts_df.index, **options) return list(train_index), list(test_index) def main(): df = sns.load_dataset('flights') month_name_mappings = {name: str(n).zfill(2) for n, name in enumerate(month_name)} df['month'] = df['month'].apply(lambda x: month_name_mappings[x]) df['year-month'] = df.year.astype(str) + '-' + df.month.astype(str) df['year-month'] = pd.to_datetime(df['year-month'], format='%Y-%m') # データの並び順をシャッフルする df = df.sample(frac=1.0, random_state=42) # 学習データとテストデータに分割する train_index, test_index = ts_train_test_split(df, ts_column='year-month', test_size=0.33) # 添字 print(f'index of train: {train_index}') print(f'index of test: {test_index}') # グラフに描いてみる sns.lineplot(data=df.iloc[train_index], x='year-month', y='passengers', label='train') sns.lineplot(data=df.iloc[test_index], x='year-month', y='passengers', label='test') plt.legend() plt.show() if __name__ == '__main__': main()
上記を実行してみよう。
$ python tsholtout.py index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60, 113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139, 94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54] index of test: [40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21, 95, 136, 99, 85, 29, 18, 115, 39, 123, 86, 130, 79, 6, 13, 127, 70, 108, 64, 120, 69, 59, 106, 67, 137]
次のようなグラフが得られる。
train_test_split()
関数のラッパーとして動作するので、オプションはそのまま使える。
たとえば、検証用データの比率を 50% まで増やしてみよう。
train_index, test_index = ts_train_test_split(df, ts_column='year-month', test_size=0.5)
上記のコードにしたモジュールを実行してみる。
$ python tsholtout.py index of train: [42, 128, 98, 91, 27, 72, 96, 80, 87, 26, 34, 20, 5, 88, 141, 53, 33, 92, 9, 1, 138, 116, 56, 49, 47, 48, 28, 16, 44, 125, 61, 30, 119, 65, 78, 76, 32, 124, 93, 55, 45, 110, 37, 81, 52, 25, 103, 60, 113, 75, 101, 10, 129, 71, 100, 24, 4, 117, 111, 121, 41, 105, 68, 122, 15, 7, 8, 51, 57, 17, 82, 139] index of test: [94, 19, 135, 118, 63, 77, 11, 107, 58, 66, 2, 84, 43, 73, 46, 134, 114, 83, 112, 109, 142, 35, 12, 54, 40, 3, 31, 132, 14, 97, 143, 131, 102, 104, 140, 126, 89, 74, 22, 36, 62, 23, 90, 50, 133, 0, 38, 21, 95, 136, 99, 85, 29, 18, 115, 39, 123, 86, 130, 79, 6, 13, 127, 70, 108, 64, 120, 69, 59, 106, 67, 137]
得られるグラフは次のとおり。
いじょう。

- 作者:もみじあめ
- 発売日: 2020/02/29
- メディア: Kindle版