盆暗の学習記録

データサイエンス ,エンジニアリング,ビジネスについて日々学んだことの備忘録としていく予定です。初心者であり独学なので内容には誤りが含まれる可能性が大いにあります。

スパースなデータをXGBoost.DMatrixに入れるときはpd.DataFrame/np.arrayを使ってはいけない

XGBoostにはDMatrixという独自のデータ保持用クラスがあります。Documentの説明では

optimized for both memory efficiency and training speed

と書いてあり、私は「自動で疎行列クラスとかにしてくれるんだろうなぁ」と思っていたのですが、そうでもない様子なのでメモしておきます。

以下では、「core.pyを読む」の節でxgboostのpythonで書かれた部分について調べた結果を述べ、それ以降の節ではC++で書かれた部分についてコードを動かすことで中身を推測していきます(私はC++が読めないため)。

(ここでは色々事情があってスパースなデータを使う場合のことを考えますが、決定木系のアルゴリズムならカテゴリカル変数はOne Hot Encoding(≒ダミー変数)ではなく、まずLabel Encodingを検討すべきです)

core.pyを読む

DMatrixクラスはpython-package/xgboost/core.pyの321行目あたりで定義されています。

f:id:nigimitama:20191106190007p:plain

DMatrixの__init__メソッドではデータのオブジェクトをdataで受け取るのですが、わりとすぐに_maybe_pandas_dataというメソッドに通しています。

# xgboost/core.py line 378
data, feature_names, feature_types = _maybe_pandas_data(data,
                                                        feature_names,
                                                        feature_types)

_maybe_pandas_data226行目あたりで定義されていて、pandasがインストール済みで、かつdataがDataFrameである場合にpd.DataFrameをnp.arrayに変換しています。

# xgboost/core.py line 226
def _maybe_pandas_data(data, feature_names, feature_types):
    """ Extract internal data from pd.DataFrame for DMatrix data """
    if not (PANDAS_INSTALLED and isinstance(data, DataFrame)):
        return data, feature_names, feature_types

    # (中略)
    
    data = data.values.astype('float')
    return data, feature_names, feature_types

DMatrix.__init__ではその下を見ていくと、dataのクラスに応じて別のinitメソッドを呼ぶ条件分岐があります。

scipy.sparse系の疎行列クラスであれば_init_from_csr_init_from_cscが呼ばれ、pd.DataFrameやnp.arrayであれば_init_from_npy2dが呼ばれることになります。

# xgboost/core.py line 393
if isinstance(data, (STRING_TYPES, os_PathLike)):
    handle = ctypes.c_void_p()
    _check_call(_LIB.XGDMatrixCreateFromFile(c_str(os_fspath(data)),
                                             ctypes.c_int(silent),
                                             ctypes.byref(handle)))
    self.handle = handle
elif isinstance(data, scipy.sparse.csr_matrix):
    self._init_from_csr(data)
elif isinstance(data, scipy.sparse.csc_matrix):
    self._init_from_csc(data)
elif isinstance(data, np.ndarray):
    self._init_from_npy2d(data, missing, nthread)
elif isinstance(data, DataTable):
    self._init_from_dt(data, nthread)
else:
    try:
        csr = scipy.sparse.csr_matrix(data)
        self._init_from_csr(csr)
    except:
        raise TypeError('can not initialize DMatrix from'
                        ' {}'.format(type(data).__name__))

小まとめ

_init_from_npy2dなどの中身はC++のメソッドを呼んでいて、そこから先はC++が読めない私ではわからない領域なのですが、ここまでのコードを読む限り、

(少なくともpython側では)疎行列クラスでDMatrixに投入しない限り、pd.DataFrameやnp.arrayが自動で疎行列クラスに変換されることはない

という事がわかります。

このことが分析上どう影響するかについては次節で調べてみます。

メモリ使用量の違い

私はc++で書かれている処理を読むことができないため、ここからはコードを動かしてみて 「pd.DataFrameからDMatrixオブジェクトを作った場合のメモリ使用量」と 「疎行列クラスからDMatrixオブジェクトを作った場合のメモリ使用量」 を比較して、間接的に両者の違いを見てみます。

実験方法

以下の方法で比較します。

  • 500,000行×1000列のスパースな特徴量を作る
  • DMatrixに投入して、元のデータオブジェクトを削除した時点のメモリ使用量で比較する

コード

import xgboost as xgb
import pandas as pd
import numpy as np
import psutil
import gc
import sys
from scipy.sparse import csr_matrix

# setting
nrows = 500_000
ncols = 1000
use_sparse = True if sys.argv[1] == "sparse" else False

# generate data
np.random.seed(0)
categories = pd.Series(np.random.randint(low=0, high=ncols, size=nrows))
X = pd.get_dummies(categories)

if use_sparse:
    X = csr_matrix(X)
else:
    # どうせ_init_from_npy2dでnp.float32のarrayに変換されるので、モニタリングしやすくするため予め変換しておく
    X = np.array(X, dtype=np.float32)

print(f"data size: {sys.getsizeof(X) / 1024**2:.2f} MB, shape: {X.shape}")
print(f"used memory before make DMatrix: {psutil.virtual_memory().used / 1024**3:.2f} GB")
dtrain = xgb.DMatrix(X)
del X
gc.collect()
print(f"used memory after delete X: {psutil.virtual_memory().used / 1024**3:.2f} GB")

結果

$ python3 DMatrix.py DataFrame
data size: 1907.35 MB, shape: (500000, 1000)
used memory before make DMatrix: 7.04 GB
used memory after delete X: 8.89 GB

$ python3 DMatrix.py sparse
data size: 0.00 MB, shape: (500000, 1000)
used memory before make DMatrix: 5.16 GB
used memory after delete X: 5.17 GB

考察

pd.DataFrame/np.arrayを使ったほうは、元のデータが約1.9GBあり、DMatrix変換後にメモリの使用量が約1.9GB増えています。DMatrixに投入したデータがそのままメモリに乗っかったような数値で推移しています。

つまり、python側からC++で書かれたメソッドにデータが渡った後も、特にnp.arrayを疎行列クラスに変換するような処理は行われていないと考えられます。

一方で疎行列クラスを使用した方は、DMatrixにデータを投じる前後でメモリ使用量がほとんど変化していません。

疎行列クラスのデータをDMatrixに投入すると、その先でも疎行列クラスとして受け取ってくれるのだと考えられます。

すなわち、スパースデータを使う場合は疎行列クラスに変換した後にDMatrixに投入すべきであると考えられます。

計算時間の違い

再びコードを動かしてみて 「pd.DataFrameから作ったDMatrixオブジェクトでの学習時間」と 「疎行列クラスから作ったDMatrixオブジェクトでの学習時間」 を比較して、間接的に両者の違いを見てみます。

実験方法

以下の条件で行います。

  • 回帰問題
  • 100,000行×100列の二値変数のみの特徴量
  • 予測を100回繰り返して、平均的な計算時間を測る
    • その際の時間を比較する
  • 疎行列クラスはscipy.sparse.csr_matrixを使用

なお、計測環境は以下のとおりです

コード

import xgboost as xgb
import pandas as pd
import numpy as np
import sys
from scipy.sparse import csr_matrix
import timeit

# setting
nrows = 100_000
ncols = 100
use_sparse = True if sys.argv[1] == "sparse" else False

# generate data
np.random.seed(0)
categories = pd.Series(np.random.randint(low=0, high=ncols, size=nrows))
X = pd.get_dummies(categories)
w = np.random.uniform(size=ncols)
y = np.dot(X, w) + np.random.normal(size=nrows)

if use_sparse:
    X = csr_matrix(X)
print(f"type of X: {type(X)}")

print(f"data size: {sys.getsizeof(X) / 1024**2:.2f} MB, shape: {X.shape}")
dtrain = xgb.DMatrix(X, label=y)

param = {"tree_method": "exact"}
t = timeit.Timer("xgb.train(param, dtrain)", globals=globals())
results = t.repeat(repeat=100, number=1)
print(f"train time: {np.mean(results):.1f} ± {np.std(results):.3f} sec. (n={len(results)})")

結果

$ python3 performance.py DataFrame
type of X: <class 'pandas.core.frame.DataFrame'>
data size: 9.54 MB, shape: (100000, 100)
train time: 1.5 ± 0.237 sec. (n=100)

$ python3 performance.py sparse
type of X: <class 'scipy.sparse.csr.csr_matrix'>
data size: 0.00 MB, shape: (100000, 100)
train time: 0.1 ± 0.015 sec. (n=100)
  • DataFrameからDMatrixを作って学習した場合は平均1.5秒程度
  • 疎行列クラスからDMatrixを作って学習した場合は平均0.1秒程度

の学習時間になりました。

考察

疎行列クラスはスパースデータの計算が高速化されることが知られています。

DataFrameを使ってDMatrixを作った場合は、xgboostの内部で疎行列クラスに変換されるようなことが無いため、疎行列クラスからDMatrixを作った場合に比べて圧倒的に計算が遅いという結果になったのだと思われます。

計算時間の点からも、スパースデータを使う場合は疎行列クラスに変換した後にDMatrixに投入すべきであると考えられます。

まとめ

  • pd.DataFrameやnp.arrayのデータをxgboost.DMatrixに入れると、DMatrix.__init__()ですべての要素がnp.float32のnp.arrayに変換される。

    • これの何が問題か:pd.get_dummies()の直後はuint8だがfloat32になるとメモリ使用量が約4倍増加することになる →DataFrame時点ではわからないがひっそりと大量のメモリを食う
    • np.arrayに変換されたデータはその後C++で書かれたAPIにデータが渡った後も疎行列クラスなどに変換されている様子はなく、float32のarrayのままであると考えられる。
      • (スパースなデータの場合、無駄にメモリ使用量と計算時間が多くなる)
  • scipy.sparseの疎行列クラスのデータをDMatrixに入れた場合、妙な変換はされず、疎行列クラスの特性(省メモリ・高速な計算)を持ったままXGBoostの学習に入ることができる

→スパースなデータを使う場合は、疎行列クラスのインスタンスにした後にDMatrixに入れるべき