洋食の日記

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

SVMKitをRumaleに改名した

SVMKitをRumale (Ruby machine learning) に改名した。SVMKitにサポートベクターマシン以外のアルゴリズムを実装するようになってから、ずっと考えていたが、いい名前が思いつかず放置していた。Rumaleの命名には、 Red Data Toolsid:mrkn さんや kou さんにご協力頂いた(というか私は本当にノーアイディアだった...) 🙏 改めて感謝申し上げます 🙏

rubygems.org

github.com

改名に向かってやるべきこと(改名したよのメッセージとかリンクはったりとか)は、RubyのFactoryBotやTerrapin、JavascriptのPugを参考にした。

SVMKitもRumaleもバージョン0.8.0では同様の内容となっている。ただ、RumaleはRuby 2.3以上での利用を想定している。これはsafe navigation演算子とかを使いたかったことなどがある。今後SVMKitは、bugfixのみをリリースする予定。

Rumaleを育てていくぞ〜 💪

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などのメンテナンスは続けるし、高速化などはバックポートしたい。

Rubyでキーワード引数の引数名と値の一覧をHashで得る

はじめに

Ruby 1.9以前のHashでキーワード引数を再現してたような感じで、Ruby 2.0以降のキーワード引数でも、引数の名前とその値の一覧をHashで得たいときがある。

コード

def foo(a: 'yes', b: 'no')
  keywd_args = method(__callee__).parameters.map { |_t, arg| [arg, binding.local_variable_get(arg)] }.to_h
  p keywd_args
end

これをirbで動かすと以下の様な感じ。キーワード引数の名前とその値を、Hashで得ることができている。

irb(main):005:0> foo
{:a=>"yes", :b=>"no"}
=> {:a=>"yes", :b=>"no"}

irb(main):006:0> foo(a: '123', b: '456')
{:a=>"123", :b=>"456"}

irb(main):007:0> foo(a: 'bar')
{:a=>"bar", :b=>"no"}
=> {:a=>"bar", :b=>"no"}

簡単な解説

  • __callee__が返すメソッド名のシンボルをmethodメソッドに渡してMethodオブジェクトを得る
  • Methodオブジェクトのparametersメソッドが返す引数の種類と名前によるArrayをmapで展開する
  • bindingのlocal_variable_getメソッドに引数の名前を与えて引数の値を得る
  • 引数名と値のペアによるArrayをto_hでHashにする

おわりに

「前にどうしたっけな〜」とよく忘れてしまう自分のためのメモ 📝

SVMKitにPipeline機能を追加した

はじめに

SVMKitで、一通りベーシックな機械学習アルゴリズムの実装を終えたので、しばらく便利機能の追加を予定している。バージョン0.7.2ではPipelineを実装した。Pipelineを使うことで、正規化して主成分分析してSVMで分類といった連結処理を定義できる。

svmkit | RubyGems.org | your community gem host

使い方

gemコマンドでSVMKitをインストールする。

$ gem install svmkit

例で使うデータセットLIBSVM Dataからpendigitsデータセットを取得する。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits

RBFカーネル近似を行い、線形SVM分類器で分類することをPipelineを使って実装すると次のようになる。

require 'svmkit'

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

# カーネル近似と線形SVMによる分類器を構成する。
# Pipelineの各ステップはHashで定義する。
rbf = SVMKit::KernelApproximation::RBF.new(gamma: 0.0001, n_components: 800, random_seed: 1)
svc = SVMKit::LinearModel::SVC.new(reg_param: 0.0001, max_iter: 1000, random_seed: 1)
pipeline = SVMKit::Pipeline::Pipeline.new(steps: { hoge: rbf, fuga: svc })

# 5-交差検定を実施する。
kf = SVMKit::ModelSelection::StratifiedKFold.new(n_splits: 5, shuffle: true, random_seed: 1)
cv = SVMKit::ModelSelection::CrossValidation.new(estimator: pipeline, splitter: kf)
report = cv.perform(samples, labels)

# 結果を出力する。
mean_accuracy = report[:test_score].inject(:+) / kf.n_splits
puts("5-CV mean accuracy: %.1f %%" % (mean_accuracy * 100.0))

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

$ ruby pipeline.rb
5-CV mean accuracy: 99.2 %

Pipelineは、他の機械学習アルゴリズム同様にMarshal.dumpとloadで、モデルの書き出しと読み込みが行える。

おわりに

SVMKitも多機能になり、名前と内容が一致していないため、改名を考えている。0.7系でユーティリティ的なのを実装したら変えるつもりです。

Red DatasetsとSVMKitを使ってIrisデータセットでの線形SVMの分類精度を確認する

はじめに

Red Datasetsは、IrisやMNISTといった公開されているデータセットを、Rubyで簡単に扱えるようにするプロジェクトである(Pythonでいえば、scikit-learnのsklearn.datasetsや、Kerasのkeras.datasetsに近い)。本記事では、Red DatasetsでIrisデータセットを読み込み、SVMKitで線形SVMによる分類精度の交差検定を行う。SVMKitは、データをNumo::NArrayで扱うので、そこの変換が必要になる。

インストール

Red DatasetsとSVMkitともに、gemで簡単にインストールできる。

$ gem install red-datasets svmkit

Red Datasetsの簡単な使い方

使い方は、Red DatasetsのUsageがわかりやすい。 Red Datasetsは、データセットを、関係データベースの様な複数レコードからなるテーブルで表現する。

require 'datasets'

# Irisデータセットをnewする。
iris = Datasets::Iris.new

# Irisデータセットは、花のアヤメの種類を表すラベルと、ガク片と花びらの長さ・幅による特徴量からなる。
# eachでそれらを1つずつ見ていく。
iris.each do |r| 
  puts "#{r.label}, #{r.sepal_length}, #{r.sepal_width}, #{r.petal_length}, #{r.petal_width}"
end

これを実行すると以下のようになる。便利!!

$ ruby red_test.rb
Iris-setosa, 5.1, 3.5, 1.4, 0.2
Iris-setosa, 4.9, 3.0, 1.4, 0.2
Iris-setosa, 4.7, 3.2, 1.3, 0.2

... (省略)

コード

Red Datasetsで読み込んだIrisデータセットで、線形SVMの分類精度の交差検定を行う。テーブルによるデータの取り出しを試してみた。

require 'datasets'
require 'svmkit'
require 'numo/narray'

# Irisデータセットを読み込む。
iris = Datasets::Iris.new

# テーブルを取得する。
iris_table = iris.to_table

# ラベルと特徴量に分けてとりだす。
iris_labels = iris_table[:label]
iris_attrs = iris_table.fetch_values(
  :sepal_length, :sepal_width, :petal_length, :petal_width).transpose

# Irisデータセットの文字列によるラベルを整数値のラベル (Numo::Int32) に変換する。
encoder = SVMKit::Preprocessing::LabelEncoder.new
labels = encoder.fit_transform(iris_labels)

# Irisデータセットの特徴量をNumo::DFloatに変換する。
samples = Numo::DFloat[*iris_attrs]

# 線形SVMの5-fold分割による交差検定を定義する。
svc = SVMKit::LinearModel::SVC.new(
  reg_param: 0.0001, fit_bias: true, max_iter: 3000, random_seed: 1)
kf = SVMKit::ModelSelection::StratifiedKFold.new(n_splits: 5, random_seed: 1)
cv = SVMKit::ModelSelection::CrossValidation.new(estimator: svc, splitter: kf)

# 交差検定を実行する。
report = cv.perform(samples, labels)

# 平均Accuracyを出力する。
mean_accuracy = report[:test_score].inject(:+) / kf.n_splits
puts("Mean Accuracy: %.1f%%" % (100.0 * mean_accuracy))

これを実行すると以下のとおり。Irisデータセットにおける線形SVMの分類精度を、5交差検定で確認すると94.7%であるとわかる。

Mean Accuracy: 94.7%

おわりに

公開されているデータセットは、ファイル形式が独自のバイナリだったりして、まず扱えるようにするまでが大変だったりするものも多い。Red Datasetsのように、データセットを統一された使い方で扱えるのはとてもありがたい。何かデータセットを追加して貢献できたらな〜。