静かなる名辞

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


【python】sklearnのRandomizedSearchCVを使ってみる

はじめに

 RandomizedSearchCVなるものがあるということを知ったので、使ってみます。うまく使うとグリッドサーチよりよい結果を生むかもしれないということです。

sklearn.model_selection.RandomizedSearchCV — scikit-learn 0.21.3 documentation

 グリッドサーチでは最初に探索するパラメータの空間を決め打ちにしますが、Randomized Searchではパラメータを確率分布に基づいて決定します。

比較実験

 とりあえず、先に使い慣れたグリッドサーチでやってみます。digitsデータをSVMで分類するという、100回くらい見た気がするネタです。

コード

import time

import pandas as pd

from sklearn.svm import SVC
from sklearn.model_selection import GridSearchCV
from sklearn.datasets import load_digits

def main():
    digits = load_digits()
    svm = SVC()
    params = {"C":[0.1, 1, 10], "gamma":[0.001, 0.01, 0.1]}
    clf = GridSearchCV(svm, params, cv=5, iid=True,
                       return_train_score=False)
    t1 = time.time()
    clf.fit(digits.data, digits.target)
    t2 = time.time()
    print("{:.2f} 秒かかった".format(t2 - t1))
    result_df = pd.DataFrame(clf.cv_results_)
    result_df.sort_values(
        by="rank_test_score", inplace=True)
    print(result_df[["rank_test_score", 
                     "params", 
                     "mean_test_score"]])

if __name__ == "__main__":
    main()

結果

16.67 秒かかった
   rank_test_score                      params  mean_test_score
6                1   {'C': 10, 'gamma': 0.001}         0.972732
3                2    {'C': 1, 'gamma': 0.001}         0.971619
0                3  {'C': 0.1, 'gamma': 0.001}         0.943239
7                4    {'C': 10, 'gamma': 0.01}         0.705620
4                5     {'C': 1, 'gamma': 0.01}         0.695047
1                6   {'C': 0.1, 'gamma': 0.01}         0.111297
5                7      {'C': 1, 'gamma': 0.1}         0.104062
8                7     {'C': 10, 'gamma': 0.1}         0.104062
2                9    {'C': 0.1, 'gamma': 0.1}         0.102393

 まあ、これはこんなもんでしょう。しっかり最適パラメータを探せている気がします。3*3=9通りのグリッドサーチで、計算には16.67秒かかったとのことです。

RandomizedSearchCVにしてみる

 RandomizedSearchCVの特色は、scipyで作れる確率分布のオブジェクトを渡せることです。パラメータのリストを渡すことも可能ですが、それだと特色を活かした使い方にはなりません。

 scipyで確率分布のオブジェクトを作る方法については、以前の記事で説明したのでこちらを見てください。

scipyで確率分布のサンプルと確率密度関数を生成する - 静かなる名辞

 ここで期待されている「確率分布のオブジェクト」はrvs(分布に従うサンプルを得るメソッド)さえ使えれば何でも良いです。連続分布も離散分布も渡せます。なんなら自作でも行けると思います。

 といってみても、パラメータ設定を確率分布に落とし込むまでは少し頭の体操が要ると思います。

 次のように考えます。たとえば、SVMでパラメータチューニングするなら、とりあえずこんな感じでざっくり見る人が多いのではないかと思います。

[0.001, 0.01, 0.1, 1, 10, 100]

 ここで、0.001が出る頻度に対して0.002はそこそこ出てきてほしいけど、100の頻度に対して100.001は出てきてほしくなくて200とかになってほしい訳です。要するに、上の方になるほど出てくる確率が下がる分布がほしい訳で、こういうのは指数分布が向いている気がします。ということを踏まえて、scipy.stats.exponを使うことにします。

scipy.stats.expon — SciPy v1.3.0 Reference Guide

 scipyのこの辺のドキュメントの説明はお世辞にもわかりやすいとは言えないんですが、指数分布のパラメータは基本的に \lambda一つだけであり、ドキュメントの記述によると

A common parameterization for expon is in terms of the rate parameter lambda, such that pdf = lambda * exp(-lambda * x). This parameterization corresponds to using scale = 1 / lambda.

 ということらしいので、これに従って \lambdaを設定します。指数分布の期待値は \frac{1}{\lambda}なので、要するに平均にしたいあたりをscaleに指定すればいいことになります。

 ちょっと確認してみます。

>>> import matplotlib.pyplot as plt
>>> result = stats.expon.rvs(scale=0.1, size=1000)
>>> plt.figure()
<matplotlib.figure.Figure object at 0x7fe9e5e4b6a0>
>>> plt.hist(result)
(array([639., 222.,  76.,  44.,  11.,   3.,   3.,   1.,   0.,   1.]), array([7.33160559e-07, 9.56411007e-02, 1.91281468e-01, 2.86921836e-01,
       3.82562203e-01, 4.78202571e-01, 5.73842938e-01, 6.69483306e-01,
       7.65123673e-01, 8.60764041e-01, 9.56404408e-01]), <a list of 10 Patch objects>)
>>> plt.savefig("result1.png")
>>> result = stats.expon.rvs(scale=1, size=1000)
>>> plt.figure()
<matplotlib.figure.Figure object at 0x7fe9e5e1bba8>
>>> plt.hist(result)
(array([490., 246., 133.,  66.,  30.,  23.,   2.,   6.,   3.,   1.]), array([2.21631197e-04, 6.36283260e-01, 1.27234489e+00, 1.90840652e+00,
       2.54446814e+00, 3.18052977e+00, 3.81659140e+00, 4.45265303e+00,
       5.08871466e+00, 5.72477629e+00, 6.36083791e+00]), <a list of 10 Patch objects>)
>>> plt.savefig("result2.png")

result1.png
result1.png
result2.png
result2.png

 上手く行っているようですが、指数分布の場合は分散が \frac{1}{\lambda ^2}で要するに期待値の2乗なので、場合によってはいわゆる中央値より少し大きめにしたり、逆に小さめにしたいと思うこともあるかもしれません。

 というあたりを踏まえて、いよいよRandomizedSearchCVでやってみます。基本的な使い方はコードを見れば分かる通りで、paramsの値にscipyの確率分布を渡すこと、n_iterで試す回数を指定できること以外大きな違いはありません。n_iterですが、今回は30回やってみます。

コード

import time

import pandas as pd

from scipy import stats
from sklearn.svm import SVC
from sklearn.model_selection import RandomizedSearchCV
from sklearn.datasets import load_digits

def main():
    digits = load_digits()
    svm = SVC()
    params = {"C":stats.expon(scale=1), 
              "gamma":stats.expon(scale=0.01)}
    clf = RandomizedSearchCV(svm, params, cv=5, iid=True,
                             return_train_score=False, n_iter=30)
    t1 = time.time()
    clf.fit(digits.data, digits.target)
    t2 = time.time()
    print("{:.2f}秒かかった".format(t2 - t1))
    result_df = pd.DataFrame(clf.cv_results_)
    result_df.sort_values(
        by="rank_test_score", inplace=True)
    print(result_df[["rank_test_score", 
                     "param_C",
                     "param_gamma",
                     "mean_test_score"]])

if __name__ == "__main__":
    main()

 大きな相違点は、

    params = {"C":stats.expon(scale=1), 
              "gamma":stats.expon(scale=0.01)}

 のところで、見てわかるようにscipyの確率分布オブジェクトを渡しています。

結果

63.04秒かかった
    rank_test_score     param_C  param_gamma  mean_test_score
23                1     2.62231  0.000465122         0.972732
15                2     1.85208   0.00195101         0.967168
14                3    0.716893   0.00232262         0.963829
26                4    0.633723  0.000583885         0.960490
20                5    0.534343   0.00280065         0.952699
24                6     1.33912   0.00344179         0.949360
2                 7     4.06141   0.00355782         0.947691
21                8    0.460892   0.00307683         0.945465
19                9     1.78068   0.00481671         0.912632
10               10     3.26205   0.00679844         0.817474
29               11    0.288305   0.00525321         0.738453
1                12     1.01437   0.00874084         0.731219
3                13    0.385384   0.00590435         0.725097
5                14     0.61161   0.00702304         0.701169
12               15    0.317451   0.00597252         0.626600
6                16    0.122253   0.00444626         0.521425
4                17      1.1791    0.0187758         0.373400
28               18    0.960299    0.0174957         0.277685
7                19     1.91593    0.0219393         0.272120
18               20     1.17152    0.0264625         0.209794
13               21     1.48332    0.0297287         0.180301
22               22    0.496221    0.0110038         0.178631
11               23    0.747978    0.0178759         0.130217
17               24    0.549571    0.0147944         0.123539
0                25   0.0357021   0.00627669         0.116305
25               26   0.0551073   0.00739485         0.114079
27               27   0.0852409   0.00982068         0.111297
9                27    0.148459    0.0101576         0.111297
16               29    0.818395    0.0521121         0.102393
8                29  0.00447628    0.0442941         0.102393

 最良スコアそのものは実はGridSearchCVと(たまたま)同じですし、時間がケチれるということもないのですが、見ての通りある程度アタリのついている状態でうまい分布を指定してあげれば「最良パラメータの周囲を細かく探索する」といったカスタマイズが可能になります。

 とりあえずこれでどの辺りがいいのかは大体わかったので、今度はその辺りを平均にしてやってみます。2行、次のように書き換えただけです。

改変箇所

    params = {"C":stats.expon(scale=3), 
              "gamma":stats.expon(scale=0.001)}

結果

22.06秒かかった
    rank_test_score      param_C  param_gamma  mean_test_score
28                1      4.17858  0.000512401         0.974402
3                 2      3.64468  0.000429315         0.973845
14                3      6.71633  0.000842716         0.973289
22                3      2.69443   0.00116543         0.973289
6                 3      5.40707  0.000698026         0.973289
13                3      2.80969   0.00118559         0.973289
1                 7      3.00941   0.00135159         0.972732
11                7      2.10982  0.000993251         0.972732
12                7      3.73687   0.00112881         0.972732
2                10      1.18243   0.00144449         0.971063
24               11      1.86039   0.00167388         0.970506
16               11      1.25451   0.00150173         0.970506
10               13      2.45206  0.000338623         0.968837
0                14     0.827324   0.00171363         0.967724
25               15     0.651184   0.00175566         0.965498
29               16      1.24774  0.000424962         0.964385
20               17     0.625333  0.000705793         0.963829
15               18      7.14503  0.000158783         0.962716
27               19     0.461709   0.00106818         0.962159
5                20      2.82219   0.00280609         0.961046
4                21      1.47712  0.000229419         0.959377
7                22     0.536423    0.0005259         0.958264
8                23      2.39408  9.90463e-05         0.953812
18               24      3.53976  6.63105e-05         0.953255
21               24      3.46599  6.85291e-05         0.953255
23               26      3.43756  5.60631e-05         0.952142
26               27       12.188  2.79396e-05         0.951586
9                28     0.331784   0.00038362         0.951029
17               29      0.73821   4.9347e-05         0.930440
19               30  0.000110158  0.000966082         0.141347

 今度はGridSearchCVのときより良いスコアになるパラメータがいくつか出ました。まあ、digitsの大して多くもないデータ数で0.001の差を云々しても「誤差じゃね?」って感じですが・・・

 二段グリッドサーチでやっても同じことはできますが、グリッドを手打ちで入力したりといった手間がかかります。その点、RandomizedSearchCVは分布のパラメータを打ち込めば勝手にやってくれるので、大変助かるものがあります。

結論

 使えます。特に、グリッドサーチのグリッドを手打ちで入れるのが面倒くさいという人に向いています。ただし、どういう分布が良いのかは知識として持っていないといけません。まあ、わからなければ一様分布でアタリをつけて正規分布にして・・・とかでもなんとかなるでしょう。

 ただし、それなりに工夫する要素があるので、玄人向きです。kaggleで使われたりするのも納得がいきます。また、確率である以上ある程度の回数は回さないと安定しないので、そのへんにも注意した方が良いと思います(それでも数が増えてくるとグリッドサーチできめ細かくやるより全然マシですが・・・)。