スパース(疎)なデータを非スパースに変換して、XGBoostを高速化

スポンサーリンク

 
 機械学習のモデルを作るときは、とりあえずXGBoostにしとけばよいでしょっていうぐらい、XGBoostが優秀です。ただし、XGBoostはある程度の精度のモデルを何も考えずに構築できる反面、他の機械学習モデルよりは実行時間が長くなります。モデルの学習時間が長くなると、ハイパーパラメータの探索回数を制限せざる負えなくなり、モデルの精度にも関わってきてしまいます。


 学習データのデータサイズが大きくなるときは、すべてのデータに意味があるというよりは、ほぼ0の値で埋まっているようなスパース(疎)なデータになっていることが多いです。予測モデルにとっては、0列が多いことで精度が上がることはなく、実行時間がその分かかってしまうためよくないです。


 そこで今回は、スパース(疎)なデータを非スパースなデータに変換して学習させることで、学習時間の高速化を行います。これによりモデルの予測精度が下がることはないということも確かめます。本記事のコードを実行すると、以下のようにXGBoostの高速化ができます。今回のコードはこちらにあります。



f:id:dskomei:20210415210351p:plain:w600

 



今回使用するデータ


 まずは今回使用するモジュールを先にインポートし、扱うデータの説明をします。スパースなデータを非スパースなデータに変換するために、scipyモジュールを使います。


モージュルのインポートと準備

from pathlib import Path
import sys
import scipy
import numpy as np
import pandas as pd
import time
import matplotlib.pyplot as plt
import seaborn as sns
import xgboost as xgb
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, StratifiedKFold

result_dir_path = Path('result')

if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)


使用するデータとデータのスパース化


 今回の実験で扱うデータは、sklearn.datasets.make_classification 関数を使って作ります。これ自体は特に何を使っても大丈夫でしょう。大事なのは、このベースデータを使ってスパースなデータに変換することです。とはいっても、値が0のみの列データを追加するだけですが。

X_data, y_data = make_classification(
    n_classes=4,
    n_samples=10,
    n_features=6,
    n_clusters_per_class=1,
    n_informative=4,
    random_state=42
)

X_data

f:id:dskomei:20210416104300p:plain:w400

y_data

f:id:dskomei:20210416104428p:plain:w400


 上を見てもらえば分かる通り、機械学習モデルの入力データである行列と出力データであるベクトルができています。この入力用データに0列のデータを追加することで、データをスパース化させます。


f:id:dskomei:20210415212651p:plain:w450


データの非スパース化


 今回の核心であるスパースなデータの非スパース化処理を実践します。実は非常に簡単です。これをやるために、まずはスパースなデータを作成します。

X_data, y_data = make_classification(
    n_classes=4,
    n_samples=10,
    n_features=6,
    n_clusters_per_class=1,
    n_informative=4,
    random_state=42
)

data = pd.DataFrame(
    X_data, 
    columns=['X' + str(i + 1) for i in range(X_data.shape[1])]
)

new_0_col = 10

data = pd.concat([
    data,
    pd.DataFrame(
        np.zeros((data.shape[0], new_0_col)), 
        columns=['X' + str(i + data.shape[1] + 1) for i in range(new_0_col)]
    )
], axis=1)
data

f:id:dskomei:20210416104612p:plain:w600


 変数「new_0_col」で指定した分の値が0の列をベースデータに追加しています。スパースなデータになったので、このデータを非スパース化します。そのために、scipyのcsr_matrix 関数を使います。この関数を使えば、ワンライナーで非スパース化できるのです。

data_sparsed = scipy.sparse.csr_matrix(data)


 以上で終わりです。3分クッキングよりも短いですね。この非スパース化したデータの中身を見てみます。

data_sparsed.data

f:id:dskomei:20210416104824p:plain:w500


 上のベクトルは、スパース化する前の行列データを0行目から順に格納していっており、値が0の場合は省かれています。これにより非スパースなベクトルになっているのです。ただこのデータだけでは、各値の元々の行列の場所がわからないため、元の行列に戻せなくなります。そこで、スパース化したデータでは、各値の元の列のインデックスベクトルとそのイデックスベクトルのどこまでが何行目かのデータも持っています。

data_sparsed.indices

f:id:dskomei:20210416104829p:plain:w500


 上のベクトルは、値ベクトルに対応しており、それぞれの値ベクトルがもとの行列の何列目かを保持しています。

data_sparsed.indptr

f:id:dskomei:20210416104835p:plain:w500


 このベクトルは行を管理しており、元の行列の何行目かを、列インデックスベクトルの添字で表しています。例えば、元の行列0行目に対応する列インデックスは data_sparsed.indptr[0] ~ data_sparsed.indptr[1] の範囲であり、元の行列の0行目の値を持ってくる場合は、以下のコードになります。

data_sparsed.data[data_sparsed.indices[
    data_sparsed.indptr[0]: data_sparsed.indptr[1] 
]]

f:id:dskomei:20210416104847p:plain:w500
 

 これでデータの非スパース化は完成です。次からは、非スパース化データにすることで、XGBoostの学習時間が短くなるのかを見ていきます。スパース化/非スパース化による学習時間を比較するために、先にスパースなデータを使いXGBoostの学習時間を計測します。


スパースなデータでのXGBoostの学習時間の計測


 非スパースなデータの作り方がわかったので、そのデータを使うことにより学習時間が短くなるかを確認したいのですが、先にスパースなデータでXGBoostの学習時間がどれだけかかるかを見ておきます。以下のコードでは、ベースデータを10,000と20,000、追加する0列の件数を [0, 10, 100, 1000, 10000] とし、それぞれの設定で交差検証法により複数回学習しています。交差検証法を使っているのは、それぞれの設定での処理時間を平均値にして比較するためです。

n_splits = 5

cv_results = []
for n_samples in [10000, 20000]:

    X_data, y_data = make_classification(
        n_classes=10,
        n_samples=n_samples,
        n_features=100,
        n_clusters_per_class=1,
        n_informative=4,
        random_state=42
    )

    X_data = pd.DataFrame(X_data, columns=['x' + str(i + 1) for i in range(X_data.shape[1])])

    for n_new_0_col in [0, 10, 100, 1000, 10000]:

        X_data_ = pd.concat([
            X_data, 
            pd.DataFrame(
                np.zeros((X_data.shape[0], n_new_0_col)), 
                columns=['x' + str(X_data.shape[1] + i + 1) for i in range(n_new_0_col)]
            )
        ], axis=1)

        X_train, X_test, y_train, y_test = train_test_split(
            X_data_, y_data, test_size=0.3, random_state=42, shuffle=True
        )

        kf = StratifiedKFold(n_splits=n_splits)

        loop = 1
        for indexes_train, indexes_valid in kf.split(X_train, y_train):

            X_train_cv = X_train.iloc[indexes_train]
            y_train_cv = y_train[indexes_train]
            X_valid_cv = X_train.iloc[indexes_valid]
            y_valid_cv = y_train[indexes_valid]

            start_time = time.time()
            model = xgb.XGBClassifier(random_state=42, use_label_encoder=False)
            model.fit(
                X_train_cv, 
                y_train_cv,
                early_stopping_rounds=30,
                eval_set=[(X_valid_cv, y_valid_cv)],
                eval_metric='mlogloss',
                verbose=0
            )
            end_time = time.time()

            score = model.score(X_test, y_test)
            print('Samples: {}, New 0 Col: {}, [{}/{}], Score: {:.0f}%, {:.0f}s'.format(
                n_samples, n_new_0_col, loop, n_splits, score * 100,
                end_time - start_time
            ))

            cv_results.append([n_samples, n_new_0_col, loop, end_time - start_time, score])
            loop += 1

cv_results = pd.DataFrame(
    cv_results, 
    columns=['n_samples', 'new_n_0_col', 'loop', 'elapsed_time', 'score']
)
cv_results.to_csv(result_dir_path.joinpath('model_result_sparse.csv'), index=False)
cv_results

f:id:dskomei:20210416105110p:plain:w500


 上のコードはそこまで難しくないと思います。pd.concatにより、指定した件数の0列を追加してデータをスパース化し、交差検証法により複数回の学習をしています。筆者の環境では、ベースデータ20,000件、追加する0列のデータ10,000件にすると、学習時間は688秒でした。結構な処理時間でしたね。


 次に非スパース化したデータを使ったXGBoostの学習時間を見ていきます。


非スパースなデータでのXGBoostの学習時間の計測


 スパースなままのデータでXGBoostを学習させると、結構な時間がかかることが確認できました。同じ設定で、スパースなデータを非スパース化して学習させて、処理時間を測定します。

n_splits = 5

cv_results = []
for n_samples in [10000, 20000]:

    X_data, y_data = make_classification(
        n_classes=10,
        n_samples=n_samples,
        n_features=100,
        n_clusters_per_class=1,
        n_informative=4,
        random_state=42
    )

    X_data = pd.DataFrame(X_data, columns=['x' + str(i + 1) for i in range(X_data.shape[1])])
    
    for n_new_0_col in [0, 10, 100, 1000, 10000]:

        X_data_ = pd.concat([
            X_data, 
            pd.DataFrame(
                np.zeros((X_data.shape[0], n_new_0_col)), 
                columns=['x' + str(X_data.shape[1] + i + 1) for i in range(n_new_0_col)]
            )
        ], axis=1)

        X_train, X_test, y_train, y_test = train_test_split(
            X_data_, y_data, test_size=0.3, random_state=42, shuffle=True
        )

        kf = StratifiedKFold(n_splits=n_splits)

        loop = 1
        for indexes_train, indexes_valid in kf.split(X_train, y_train):

            X_train_cv = X_train.iloc[indexes_train]
            y_train_cv = y_train[indexes_train]
            X_valid_cv = X_train.iloc[indexes_valid]
            y_valid_cv = y_train[indexes_valid]

            start_time = time.time()
            model = xgb.XGBClassifier(random_state=42, use_label_encoder=False)
            model.fit(
                scipy.sparse.csr_matrix(X_train_cv),
                y_train_cv,
                early_stopping_rounds=30,
                eval_set=[(scipy.sparse.csr_matrix(X_valid_cv), y_valid_cv)],
                eval_metric='mlogloss',
                verbose=0
            )
            end_time = time.time()

            score = model.score(scipy.sparse.csr_matrix(X_test), y_test)
            print('Samples: {}, New 0 Col: {}, [{}/{}], Score: {:.0f}%, {:.0f}s'.format(
                n_samples, n_new_0_col, loop, n_splits, score * 100,
                end_time - start_time
            ))

            cv_results.append([n_samples, n_new_0_col, loop, end_time - start_time, score])
            loop += 1

cv_results = pd.DataFrame(
    cv_results, 
    columns=['n_samples', 'new_n_0_col', 'loop', 'elapsed_time', 'score']
)
cv_results.to_csv(result_dir_path.joinpath('model_result_non_sparse.csv'), index=False)
cv_results

f:id:dskomei:20210416105115p:plain:w500


 上のコードにおいて、スパースなデータを使った実験と変わったところは、「csr_matrix」関数を使いデータを非スパース化してから、XGBoostを学習させていることです。筆者の環境では、ベースデータ20,000、追加する0列のデータ10,000での学習時間は、74秒でした。つまり、非スパース化することで9倍も短縮されています。


実験結果をグラフ化


 上記の実験で、データを非スパース化することで、XGBoostの学習時間の短縮を確認できました。このことが視覚的に裏付けるために、グラフ化して見てみましょう。

target_data = pd.concat([
    pd.read_csv(result_dir_path.joinpath('model_result_non_sparse.csv')).assign(
        category='non_sparse'
    ),
    pd.read_csv(result_dir_path.joinpath('model_result_sparse.csv')).assign(
        category='sparse'
    )
], axis=0)

plot_data = target_data.groupby(['category', 'new_n_0_col', 'n_samples']).agg({
    'elapsed_time': ['mean', 'std'], 'score': ['mean', 'std']
})
plot_data.columns = ['elapsed_time_mean', 'elapsed_time_std', 'score_mean', 'score_std']
plot_data.reset_index(inplace=True, drop=False)

g = sns.relplot(
    data=plot_data.assign(new_n_0_col=lambda x: x.new_n_0_col.astype(str)),
    x='new_n_0_col',
    y='elapsed_time_mean',
    hue='category',
    col='n_samples',
    kind='line',
    marker='o',
    markersize=10
)
g.fig.suptitle('スパース/非スパースデータによるXGBoostの学習時間', fontsize=18, y=0.98, weight='bold')
g.fig.set_figwidth(12)
g.fig.set_figheight(6)
for ax in g.axes.flat:
    ax.set_xlabel('値0の列追加件数', size=14)
    ax.set_title(ax.get_title(), size=14)
g.set_ylabels('')

leg = g._legend
leg.set_bbox_to_anchor([0.17, 0.8])

for i, ax in enumerate(g.axes.flat):
    for tick in ax.get_yticks()[1:-1]:
        ax.axhline(tick, alpha=0.1, color='grey')
        
plt.tight_layout()
plt.savefig(result_dir_path.joinpath('model_result_sparse_vs_non_sparse_slapsed_time.png'), dpi=300)


f:id:dskomei:20210416080705p:plain:w500


 上のグラフを見て分かる通り、スパースなデータは非スパースなデータに比べて、値0の列が増えるに連れ、学習時間が顕著に増加しています。スパースなままXGBoostを学習させることは、多大な時間を浪費するのです。


 非スパースにより学習時間が短くなることが証明されましたが、これにより予測精度が落ちていたら元も子もありません。そこで、予測精度が影響を受けているかを確認しておきます。

result = pd.pivot_table(
    data=plot_data[['n_samples', 'category', 'new_n_0_col', 'score_mean', 'score_std']],
    index=['n_samples', 'new_n_0_col'],
    columns='category'
)
result

f:id:dskomei:20210416105325p:plain:w400


 予測精度に変化はありません。つまり、スパースなデータのままXGBoostを学習させることは、損しかないと言えそうです。


終わりに


 今回はXGBoostというか機械学習における学習時間の高速化の話をしました。ワンライナーで学習時間が大幅に変わるので、やらない手はないと思います。今回取り上げませんでしたが、XGBoostのハイパーパラメータの調整や機械学習の精度向上に関しては、以下の本が参考になりました。