静かなる名辞

pythonとプログラミングのこと


ランダムフォレストはサンプル数が多いとメモリ消費量が大きい

はじめに

 表題の通りなのですが、サンプル数が多いデータに対してランダムフォレストを使うと思いの外メモリを食います。

 また、ストレージにダンプしようとすると、ストレージ容量も消費します。

現象

 なにはともあれやってみましょう。

import pickle
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report

def main():
    X, y = make_classification(
        n_samples=10**5, n_features=50,
        n_informative=10, n_classes=5)
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    rfc = RandomForestClassifier(n_estimators=100, n_jobs=-1)
    rfc.fit(X_train, y_train)
    y_pred = rfc.predict(X_test)
    print(classification_report(y_test, y_pred))
    with open("rfc.pickle", "wb") as f:
        pickle.dump(rfc, f)

if __name__ == "__main__":
    main()

 サンプル数は10**5です。ビッグデータというほどでもないけど、大抵の機械学習が問題なく使えるだけのデータがあるとします。

 結果の善し悪しにはさほど興味がないのですが、一応見ておきます。

              precision    recall  f1-score   support

           0       0.84      0.83      0.83      4926
           1       0.87      0.87      0.87      5102
           2       0.88      0.90      0.89      4963
           3       0.85      0.81      0.83      5018
           4       0.85      0.88      0.87      4991

    accuracy                           0.86     25000
   macro avg       0.86      0.86      0.86     25000
weighted avg       0.86      0.86      0.86     25000

 まあ、こんなものでしょう。

 問題はメモリ消費量です。実行中に数百MBを消費します。データそのものは10**5行50列ですから、64bit浮動小数点数型としても40MBくらいで済むはずです。ランダムフォレスト本体がやたらメモリを消費しています。

 これで出力されるrfc.pickleの容量は約210MBです。やたら大きくなっています。

原因

 ランダムフォレストは決定木です。そしてデフォルトの設定では、枝は伸び放題で過学習気味です。

 ためしに総ノード数を見てみます。この方法は正しいのかどうかはわかりませんが、スタックオーバーフローに書いてありました。

python - Is there a way to retrieve the final number of nodes generated by sklearn.tree.DecisionTreeClassifier? - Stack Overflow

    print(rfc.estimators_[0].tree_.node_count) # => 21193

 けっこうたくさんノードがありますね。これが諸悪の根源で、各ノードごとに数byte~数十byte程度は消費するでしょうから、木全体では意外と大きな記憶領域を必要とし、そんな木を100本も500本も使うランダムフォレストはとても記憶領域の消費量が大きくなる……ということです。

 こうなるとメモリを無駄に食うしキャッシュも効かないし遅いし、なかなか憂慮すべき事態です。

対策

 根本的な対策の方向性としては、

  • 性能を損なわない程度に木の複雑性を下げてノード数を減らす
  • 性能を損なわない程度に木の本数を下げる

 の2通りがありえます。今回みたいなデータの場合、木の本数はなかなか減らしづらいと思うので、木の複雑性を下げる方でやります。これなら上手くすれば過学習対策的に働き、ほとんど性能を損なわずに容量を減らせ、更に速くなります。

 やることは普通の過学習対策です。

import pickle
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report

def main():
    X, y = make_classification(
        n_samples=10**5, n_features=50,
        n_informative=10, n_classes=5)
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    rfc = RandomForestClassifier(
        n_estimators=100, min_samples_leaf=5, n_jobs=-1)
    rfc.fit(X_train, y_train)

    y_pred = rfc.predict(X_test)
    print(classification_report(y_test, y_pred))
    with open("rfc.pickle", "wb") as f:
        pickle.dump(rfc, f)

if __name__ == "__main__":
    main()
              precision    recall  f1-score   support

           0       0.86      0.85      0.85      4952
           1       0.87      0.85      0.86      5033
           2       0.86      0.85      0.86      5048
           3       0.84      0.86      0.85      4951
           4       0.87      0.89      0.88      5016

    accuracy                           0.86     25000
   macro avg       0.86      0.86      0.86     25000
weighted avg       0.86      0.86      0.86     25000

 この状態で出力されるrfc.pickleは約100MBです。ほとんど性能を損なわずに(むしろ汎化性能をあげつつ)半分になりました。

 また、メモリ空間をやたら消費する現象への対策にはならないものの、ストレージへの保存ではjoblibを使って圧縮することも可能です。上の対策を施した上で、更に圧縮も行います。

import joblib

# 中略

    joblib.dump(rfc, "rfc.pickle", 3)

 これで約21MBになりました。

まとめ

 このようにランダムフォレストは意外とメモリに優しくない手法なので、使い方を気をつけようという話です。もちろんデータ数が多いときの話で、1000未満でやるときとかは気にする必要は(ほとんど)ありません。

 ランダムフォレストってなんとなくもっさりする(すごく遅くはないけどあまり速くもない)印象でしたが、ただでさえifの嵐なのに決定木全体がキャッシュに乗らないのか……ということに気づけたのが今回の発見でした。速くない訳ですね。