洋食の日記

「だ・である」調ではなく「です・ます」調で書きはじめれば良かったなと後悔してる人のブログです

SVMKitにGrid Seachを実装した

はじめに

SVMKitに、ハイパーパラメータの探索手法として定番のGrid Searchを実装した。Scikit-learnのGrid Searchと同様に、交差検定をベースにした探索を行う。与えられたハイパーパラメータの値のすべての組み合わせで、交差検定を行い、テストでのスコアが最大(もしくは最小)のものを最適なハイパーパラメータとする。

svmkit | RubyGems.org | your community gem host

使い方

サンプルコードでは、LIBSVM DataのpendigitsデータセットとEunite 2001データセットを用いる。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits
$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits.t
$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/regression/eunite2001
$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/regression/eunite2001.t
決定木による分類

決定木には、枝を分岐する評価基準や、木の深さなどのハイパーパラメータがある。これをGrid Searchで最適化する。

require 'pp'
require 'svmkit'

# データを読み込む。
samples, labels = SVMKit::Dataset.load_libsvm_file('pendigits')
samples = Numo::DFloat.cast(samples)

# 決定木を定義する。
dt = SVMKit::Tree::DecisionTreeClassifier.new(random_seed: 1)

# ハイパーパラメータで確認したい値を、パラメータ名をkey、確認したい値のArrayをvalueとするHashで定義する。
pg = { criterion: ['gini', 'entropy'], max_depth: [4, 8] }

# 5-交差検定によるGrid Searchを実行する。
kf = SVMKit::ModelSelection::StratifiedKFold.new(n_splits: 5)
gs = SVMKit::ModelSelection::GridSearchCV.new(estimator: dt, param_grid: pg, splitter: kf)
gs.fit(samples, labels)

# 結果を表示する。
pp gs.cv_results
puts '---'
pp gs.best_params

# テストデータセットで推定する。
puts '---'
samples, labels = SVMKit::Dataset.load_libsvm_file('pendigits.t')
samples = Numo::DFloat.cast(samples)
puts("Test Dataset Accuracy: %.1f %%" % (gs.score(samples, labels) * 100.0))

これを実行すると次の様になる。cv_resultsには、各パラメータの組み合わせ(params)と、それに対応する交差検定のスコア(mean_test_scoreなど)が入っている。best_paramsはスコアが最大となる(この場合DecisionTreeClassifierのscoreメソッドが実行されAccuracyが最大となる)パラメータが入っている。

$ ruby svmkit_gs_example.rb
{:mean_test_score=>
  [0.7272529937832951,
   0.9286126486405282,
   0.7272529937832951,
   0.9286126486405282],
... 略
 :params=>
  [{:criterion=>"gini", :max_depth=>4},
   {:criterion=>"gini", :max_depth=>8},
   {:criterion=>"entropy", :max_depth=>4},
   {:criterion=>"entropy", :max_depth=>8}]}
---
{:criterion=>"gini", :max_depth=>8}
---
Test Dataset Accuracy: 87.4 %
PipelineでのGrid Search

Pipelineは特徴変換と分類器で構成される。それぞれのハイパーパラメータをGrid Searchで最適化するコードは以下の様になる。

require 'pp'
require 'svmkit'

# データを読み込む。
samples, labels = SVMKit::Dataset.load_libsvm_file('pendigits')
samples = Numo::DFloat.cast(samples)

# カーネル近似とサポートベクターマシンとの分類によるパイプラインを作る。
rbf = SVMKit::KernelApproximation::RBF.new(random_seed: 1)
svc = SVMKit::LinearModel::SVC.new(random_seed: 1)
pipe = SVMKit::Pipeline::Pipeline.new(steps: { foo: rbf, bar: svc })

# カーネル近似のハイパーパラメータであるガンマと成分数、
# サポートベクターマシンのハイパーパラメータである正則化係数で、
# Grid Searchで探索したい値をHashで定義する。
# それぞれアンダーバー2つで名前とパラメータを指定する。
pg = { foo__gamma: [0.1, 0.0001], foo__n_components: [512, 1024], 
       bar__reg_param: [1.0, 0.0001] }

# 5交差検定で確認するため、分割を定義する。
kf = SVMKit::ModelSelection::StratifiedKFold.new(n_splits: 5)

# Grid Search を実行する。
gs = SVMKit::ModelSelection::GridSearchCV.new(estimator: pipe, param_grid: pg, splitter: kf)
gs.fit(samples, labels)

# 結果を表示する。
pp gs.cv_results
puts '---'
pp gs.best_params

# テストデータセットで推定する。
puts '---'
samples, labels = SVMKit::Dataset.load_libsvm_file('pendigits.t')
samples = Numo::DFloat.cast(samples)
puts("Test Dataset Accuracy: %.1f %%" % (gs.score(samples, labels) * 100.0))

これを実行すると次の様になる。

$ ruby svmkit_gs_example.rb
{:mean_test_score=>
  [0.09995046835385611,
   0.10233753516837311,
   0.09994663406224738,
... 略
 :params=>
  [{:rbf__gamma=>0.1, :rbf__n_components=>512, :svc__reg_param=>1.0},
   {:rbf__gamma=>0.1, :rbf__n_components=>512, :svc__reg_param=>0.0001},
   {:rbf__gamma=>0.1, :rbf__n_components=>1024, :svc__reg_param=>1.0},
... 略
---
{:rbf__gamma=>0.0001, :rbf__n_components=>1024, :svc__reg_param=>0.0001}
--
Test Dataset Accuracy: 98.1 %
回帰

回帰の場合は以下の様になる。評価尺度にはMean Squared Errorを用いる。これは小さいほど回帰としては良いと判断できる。小さいほどよいという評価を使うばあいは、greater_is_better引数にfalseを与える(デフォルトではtrue)。

require 'pp'
require 'svmkit'

# データを読み込む。
samples, values = SVMKit::Dataset.load_libsvm_file('eunite2001')
samples = Numo::DFloat.cast(samples)
values = Numo::DFloat.cast(values)

# 決定木による回帰を定義する。
dt = SVMKit::Tree::DecisionTreeRegressor.new(random_seed: 1)

# 確認したいハイパーパラメータを定義する。
pg = { max_depth: [4, 8], max_features: [2, 4] }

# 評価尺度にはMean Squared Error (MSE) を用いる。
ev = SVMKit::EvaluationMeasure::MeanSquaredError.new

# 5-交差検定でGrid Searchを行う。
# MSEは小さいほどよいので、greater_is_betterにfalseを与える。
kf = SVMKit::ModelSelection::KFold.new(n_splits: 5)
gs = SVMKit::ModelSelection::GridSearchCV.new(
  estimator: dt, param_grid: pg, splitter: kf, evaluator: ev, greater_is_better: false)
gs.fit(samples, values)

# 結果を表示する。
pp gs.cv_results
puts '---'
pp gs.best_params

# テストデータセットで推定する。
puts '---'
samples, values = SVMKit::Dataset.load_libsvm_file('eunite2001.t')
samples = Numo::DFloat.cast(samples)
values = Numo::DFloat.cast(values)
puts("Test Dataset MSE: %.4f" % ev.score(values, gs.predict(samples)))

これを実行すると以下の様になる。必ずしも深い木が良いわけではないのがおもしろい。

$ ruby svmkit_gs_example.rb
{:mean_test_score=>
  [1502.7172630764019,
   989.053627383465,
   1456.5703139919217,
   1074.9447023536181],
...略
 :params=>
  [{:max_depth=>4, :max_features=>2},
   {:max_depth=>4, :max_features=>4},
   {:max_depth=>8, :max_features=>2},
   {:max_depth=>8, :max_features=>4}]}
---
{:max_depth=>4, :max_features=>4}
---
Test Dataset MSE: 779.5281

おわりに

Grid Searchの実装により、機械学習ライブラリとして基本的なことがだいたいできるようになった。一方で、少しずつSVMKitのリファクタリングを進めていて、ある程度整えば、いよいよ改名したいと考えている。改名の理由は、SVMだけでなく様々なアルゴリズムを実装してしまったことで、これ以上この名前でアルゴリズムを追加するのは難しい(もともとJavaのMALLETぐらいのサイズ感を考えていた)。ただ、改名後もSVMKitのbugfixなどのメンテナンスは続けるし、高速化などはバックポートしたい。