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行目あたりで定義されています。
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_data
は226行目あたりで定義されていて、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に変換される。scipy.sparseの疎行列クラスのデータをDMatrixに入れた場合、妙な変換はされず、疎行列クラスの特性(省メモリ・高速な計算)を持ったままXGBoostの学習に入ることができる
→スパースなデータを使う場合は、疎行列クラスのインスタンスにした後にDMatrixに入れるべき