Naoism's Blog

データサイエンス、機械学習、競技プログラミング、音楽、勉強

Stratified Group KFoldを実装してみた

お久しぶりです。

前までは仕事が忙しく、その上kaggleにも取り組むという追い込みプレイをしていたので、ブログを書く暇がありませんでした。
最近では仕事も落ち着いて来たので、参加していたkaggleのコンペを振り返っています。

そんな中、気になることがあったので本記事を書きました。

2019 Data Science Bowl

参加していたコンペは、「2019 Data Science Bowl」です。
2019 Data Science Bowl | Kaggle


コンペの概要はざっくり、
子供向け教育ゲームアプリの行動ログから、最終テストにおける各ユーザーの問題の正答率(0,1,2,3の4段階)がどれになるかを予測する
というものです。
全問正解だと正答率は3、全問不正解だと正答率は0という感じです。


私が気になったこととしては、K-Fold CV(K-分割交差検証)をするときのデータの分割方法についてです。

このコンペでは、パブリックLBとローカルでのCV値の違いが大きいことから、CVの方法についてしばしば議論がありました。

具体的には、
「StratifiedKFold と GroupKFold のどっちで分割すればいいの??」
https://www.kaggle.com/c/data-science-bowl-2019/discussion/124732
というものです。


なぜこのような議論が生まれたかを知るために、 StratifiedKFold と GroupKFold の違いについてまず見ていきましょう。

StratifiedKFold

f:id:mashkun:20200130214624p:plain
(引用:3.1. Cross-validation: evaluating estimator performance — scikit-learn 0.22.1 documentation)

StratifiedKFoldとは、「CVの際に、目的変数のラベルの比率が揃うように訓練データと検証データを分ける」分割方法です。
データセット全体の目的変数の偏りを保持することで、訓練データにはなかった目的変数が検証データに含まれてしまうことを防ぎます。
分類問題は基本的にこの切り方で問題ないです。

GroupKFold


f:id:mashkun:20200130231547p:plain
(引用:3.1. Cross-validation: evaluating estimator performance — scikit-learn 0.22.1 documentation)

GroupKFoldとは、「CVの際に、 訓練データと検証データに同じグループが現れないように分ける」分割方法です。
これはデータセットがグループ化されているときに汎化性能を確保するために用います。

例えば、人の顔を分類する際に訓練データと検証データに同じ人の顔がある場合、求めたCV値が未知データに対するものではなくなってしまい、適切な検証ができなくなるという問題が起こります。
その問題を解決するためには、GroupKFoldによって人をグループをみなして分割するのが好ましいです。

なぜ StratifiedKFold と GroupKFold で議論が起きたか

では、なぜ今回のコンペで上記の2つのK-Fold法のうちどちらが良いのか、という議論が起きたのでしょうか。
その理由は、今回のコンペが目的変数がラベル、且つデータセットに同じユーザーのプレイ履歴が複数含まれていたからです。


つまり、

StratifiedKFold で分割すると、同ユーザー情報が訓練データにも検証データにも含まれてしまう。
でも、GroupKFold だと目的変数の値が各データセットで偏ってしまう。

という問題が発生したということです。

私も初めのうちはどちらを使うか迷っていました。
最終的にはCV値から考察して、GroupKFoldを使いましたが。

上位陣は StratifiedGroupKFold を使っていた

さぁコンペが終わって、いざ上位陣の方たちが投稿した各々の Solution を見てみたところ、

「K-Fold CVの分割には、 StratifiedGroupKFold を使った。」

という方が多数いました。

目からウロコでしたね。
議論によるバイアスか、はたまた単純に知識不足か。
2つの分割方法を混ぜて使おうという発想がありませんでした。

「勉強になったなぁ。どれどれ使ってみるか。」
と思い探してみたところ、どのライブラリにも実装されていないことがわかりました。

「試してみたいのに、困ったなぁ」と思いつつも、
「まだ実装されてないなら自分で実装すればいいやん」
と思い、実装してみました。

以下、実装とその検証結果です。


使用するデータ

今回は分類問題の定番、irisデータを使います。
ですが、irisデータにはグループがないため GroupKFold ができません。
そのため、A~Eまでのアルファベットからランダムに選んで、IDとしてデータセットに付け加えておきます。

import numpy as np
import pandas as pd
from sklearn.datasets import load_iris


def Read_data():
    # Read iris dataset
    iris = load_iris()
    df = pd.DataFrame(iris.data, columns=iris.feature_names)
    df["target"] = iris.target

    # Define ID
    list_id = ["A", "B", "C", "D", "E"]
    df["ID"] = np.random.choice(list_id, len(df))

    # Extract feature names
    features = iris.feature_names

    return df, features

上記関数を実行すると、以下のようにIDを含んだデータフレームになります。

df_iris, features = Read_data()
print(df_iris.head())


   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)  target ID
0                5.1               3.5                1.4               0.2       0  D
1                4.9               3.0                1.4               0.2       0  B
2                4.7               3.2                1.3               0.2       0  B
3                4.6               3.1                1.5               0.2       0  A
4                5.0               3.6                1.4               0.2       0  E

StratifiedGroupKFoldを試してみる

さて、早速ですが StratifiedGroupKFold を試してみます。

コードは以下です。

import random
from sklearn.model_selection import GroupKFold
from collections import Counter, defaultdict


def Count_y(y, groups):
    # y counts per group
    unique_num = np.max(y) + 1
    y_counts_per_group = defaultdict(lambda: np.zeros(unique_num))
    for label, g in zip(y, groups):
        y_counts_per_group[g][label] += 1

    return y_counts_per_group


def StratifiedGroupKFold(X, y, groups, features, k, seed = None):
    # Preparation
    max_y = np.max(y)
    y_counts_per_group = Count_y(y, groups)
    kf = GroupKFold(n_splits=k)

    for train_idx, val_idx in kf.split(X, y, groups):
        # Training dataset and validation dataset
        x_train = X.iloc[train_idx, :]
        id_train = x_train["ID"].unique()
        x_train = x_train[features]

        x_val, y_val = X.iloc[val_idx, :], y.iloc[val_idx]
        id_val = x_val["ID"].unique()
        x_val = x_val[features]

        # y counts of training dataset and validation dataset
        y_counts_train = np.zeros(max_y+1)
        y_counts_val = np.zeros(max_y+1)
        for id_ in id_train:
            y_counts_train += y_counts_per_group[id_]
        for id_ in id_val:
            y_counts_val += y_counts_per_group[id_]

        # Determination ratio of validation dataset
        numratio_train = y_counts_train / np.max(y_counts_train)
        stratified_count = np.ceil(y_counts_val[np.argmax(y_counts_train)] * numratio_train)
        stratified_count = stratified_count.astype(int)

        # Select validation dataset randomly
        val_idx = np.array([])
        np.random.seed(seed) 
        for num in range(max_y+1):
            val_idx = np.append(val_idx, np.random.choice(y_val[y_val==num].index, stratified_count[num]))
        val_idx = val_idx.astype(int)
        
        yield train_idx, val_idx


コードは、下のURLを少し参考にしました。

Stratified Group k-Fold Cross-Validation | Kaggle

「もう実装されてるやん。」
って思われるかと思いますが、irisデータで上記URLに書かれているStratifiedGroupKFoldを使ってみたところ、StratifiedKFoldの特徴である「データセット全体の目的変数の偏りを保持した分割」ができてなかったんですよね。。

そもそも、StratifiedKFoldの目的は「訓練データにはなかった目的変数が検証データに含まれてしまうことを防ぐ」ことなので、そこまで比率の保持に拘る必要もないのかもしれませんが 。

少し違和感を感じたため、自分の実装では、検証データの一部を捨ててでも訓練データと検証データの目的変数のラベルの比率が等しくなるような分割を目指しました

(上記URLに書かれているStratifiedGroupKFoldでの分割結果は、本記事の一番下に掲載しました。)


話がそれてしまいましたね。
では、上記の関数を実行してみましょう。

その際、各Foldにおける訓練データと検証データの目的変数の比率を確認するために、Get_distribution関数を定義しています。

def Get_distribution(y_vals):
    # Get distribution
    y_distr = Counter(y_vals)
    y_vals_sum = sum(y_distr.values())

    return [f"{y_distr[i] / y_vals_sum:.2%}" for i in range(np.max(y_vals) + 1)]


X = df_iris.drop("target", axis=1)
y = df_iris["target"]
groups = df_iris["ID"]

distrs = [Get_distribution(y)]
index = ["all dataset"]

for fold, (train_idx, val_idx) in enumerate(StratifiedGroupKFold(X, y, groups, features, k=3)):

    print(f"TRAIN_ID - fold {fold}:", groups[train_idx].unique(), 
          f"TEST_ID - fold {fold}:", groups[val_idx].unique())
        

    distrs.append(Get_distribution(y[train_idx]))
    index.append(f"training set - fold {fold}")
    distrs.append(Get_distribution(y[val_idx]))
    index.append(f"validation set - fold {fold}")

print(pd.DataFrame(distrs, index=index, columns=[f"Label {l}" for l in range(np.max(y) + 1)]))
# 出力結果

TRAIN_ID - fold 0: ['D' 'B' 'E' 'C'] TEST_ID - fold 0: ['A']
TRAIN_ID - fold 1: ['D' 'A' 'C'] TEST_ID - fold 1: ['E' 'B']
TRAIN_ID - fold 2: ['B' 'A' 'E'] TEST_ID - fold 2: ['C' 'D']

                        Label 0 Label 1 Label 2
all dataset              33.33%  33.33%  33.33%
training set - fold 0    34.55%  29.09%  36.36%
validation set - fold 0  35.71%  28.57%  35.71%
training set - fold 1    28.42%  35.79%  35.79%
validation set - fold 1  28.89%  35.56%  35.56%
training set - fold 2    36.84%  35.79%  27.37%
validation set - fold 2  35.71%  35.71%  28.57%

出力結果より、各Foldにおいて、訓練データと検証データに同じID(グループ)が含まれておらず、目的変数のラベルの比率もほぼ等しく分割できていることがわかります。


おまけ(上記URLに書かれているStratifiedGroupKFoldでの分割結果)

Stratified Group k-Fold Cross-Validation | Kaggle
での分割方法を同様のデータセットに対して実行した結果を以下に示します。

TRAIN_ID - fold 0: ['D' 'E' 'C' 'B'] TEST_ID - fold 0: ['A']
TRAIN_ID - fold 1: ['D' 'C' 'A'] TEST_ID - fold 1: ['E' 'B']
TRAIN_ID - fold 2: ['E' 'A' 'B'] TEST_ID - fold 2: ['D' 'C']

                        Label 0 Label 1 Label 2
all dataset              33.33%  33.33%  33.33%
training set - fold 0    33.33%  36.84%  29.82%
validation set - fold 0  33.33%  22.22%  44.44%
training set - fold 1    35.96%  33.71%  30.34%
validation set - fold 1  29.51%  32.79%  37.70%
training set - fold 2    30.93%  28.87%  40.21%
validation set - fold 2  37.74%  41.51%  20.75%

各Foldにおいて、訓練データと検証データに同じID(グループ)は含まれていませんが、目的変数のラベルの比率が等しいとは言い難いです。

例えば、Fold 0 では、ラベル1の割合が訓練データでは37%であるのに対し、検証データでは22%となっています。

訓練データにはなかった目的変数が検証データに含まれてしまうことを防ぐ、という目的は達成していますが、データセット全体の目的変数の偏りは保持できていません。
(偏りの保持が、どこまで大事かはわかりませんが。)

大規模なデータにとっては誤差の範囲内だと思いますが、irisのデータ(150行)でははっきりとズレていることがわかります。

最後に

こんな記事を書いておいて、正直偏りの保持はそこまで大事ではないと思っています。(もちろん大きなズレはダメです。)
そのため、検証データをより多く残すことができる上記URLのStratifiedGroupKFoldのほうが実装したものより有用かもしれません。

ですが、私と同じように何か違和感を感じた方は、是非私が実装した方を使ってみてください。