洋食の日記

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

Rumaleにカーネル判別分析を追加した

はじめに

Rumaleにカーネル判別分析を追加して、これを version 0.18.4 としてリリースした。カーネル判別分析は、version 0.18.0 で追加したフィッシャー判別分析 (Fisher Discriminant Analysis, FDA) を、カーネル法により非線形化したものである。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。線形代数の計算にNumo::Linalg、実行結果の描画にNumo::Gnuplotを使いたいので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

Numo::Linalgのためにopenblasを、Numo::Gnuplotのためにgnuplotをインストールする。

$ brew install openblas gnuplot

カーネル判別分析は、教師あり次元削減手法とも捉えられる。一般に、カーネル判別分析により写像した部分空間で、k-近傍法で分類するとこが想定されている。Rumaleではfitメソッドで学習し、transformメソッドで次元削減を行う。

LIBSVM Dataで公開されているUCI Vowelデータセットを利用して、次元削減してk-近傍法で分類する例を示す。

カーネル判別分析では、カーネル関数の選択と正則化パラメータが、ハイパーパラメータとなる。カーネル関数自体にパラメータがあれば、それも調整する必要がある。

require 'numo/linalg/autoloader'
require 'numo/gnuplot'
require 'rumale'

# 分類器には, 最近傍法を使用する.
est = Rumale::NearestNeighbors::KNeighborsClassifier.new(n_neighbors: 1)

# データセットを読み込み, 訓練・テストデータに分割する.
x, y = Rumale::Dataset.load_libsvm_file('vowel.scale')

x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.4, random_seed: 1)

# 判別分析法で二次元空間に射影し, 部分空間で分類器の性能を評価する.
fda = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(n_components: 2)
fda.fit(x_train, y_train)
z_train = fda.transform(x_train)
z_test = fda.transform(x_test)

est.fit(z_train, y_train)
fda_score = est.score(z_test, y_test)

# カーネル判別分析法で二次元空間に写像し, 部分空間で分類器の性能を評価する.
kfda = Rumale::KernelMachine::KernelFDA.new(n_components: 2, reg_param: 1e-8)

gamma = 0.05
kmat_train = Rumale::PairwiseMetric.rbf_kernel(x_train, nil, gamma)
kfda.fit(kmat_train, y_train)
z_train = kfda.transform(kmat_train)

kmat_test = Rumale::PairwiseMetric.rbf_kernel(x_test, x_train, gamma)
z_test = kfda.transform(kmat_test)

est.fit(z_train, y_train)
kfda_score = est.score(z_test, y_test)

# それぞれの正確度を出力する.
puts "Accuracy (FDA): #{fda_score}"
puts "Accuracy (KFDA): #{kfda_score}"

# 訓練データセットの二次元空間を散布図としてプロットする.
plots = y_train.to_a.uniq.sort.map do |l|
  [z_train[y_train.eq(l), 0], z_train[y_train.eq(l), 1], t: l.to_s]
end

Numo.gnuplot do
  set(terminal: 'png')
  #set(output: 'fda.png')
  set(output: 'kfda.png')
  plot(*plots)
end

これを実行した結果が次のようになる。カーネル判別分析のほうが、優れた分類性能を得られている。

$ ruby kfda.rb
Accuracy (FDA): 0.6445497630331753
Accuracy (KFDA): 0.9146919431279621

部分空間のサンプルを可視化したものが次のようになる。Vowelデータセットは、11クラスからなるデータセットだが、判別分析 (FDA) よりも、カーネル判別分析 (KFDA) のほうが、部分空間で同一クラスのサンプル同士が集まって位置していることがわかる。

f:id:yoshoku:20200411153645p:plain
FDA

f:id:yoshoku:20200411153702p:plain
KFDA

どんなデータセットでも、このように上手く行くわけではないが、判別分析で高い分類性能が得られない場合などに、カーネル判別分析を試してみる価値はある。

おわりに

Rumaleに、version 0.18.0 で判別分析を追加したので、カーネル判別分析も追加してみた。カーネル判別分析は、非線形な教師あり次元削減手法でもあるので、多くの場合で判別分析よりも良い結果を得られると思う。一方で、カーネル関数の選択と、ハイパーパラメータの調整には気を配る必要がある。また、データセットが大きいと、カーネル関数の計算などで計算時間を必要とする。

github.com

RumaleにNeighbourhood Component Analysisを追加した

はじめに

Rumaleに計量学習のNeighbourhood Component Analysis (NCA) フィッシャー判別分析 (Fisher Discriminant Analysis, FDA) を追加した。これを version 0.18.0 としてリリースした。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。線形代数の計算にNumo::Linalg、実行結果の描画にNumo::Gnuplotを使いたいので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

Numo::Gnuplotのためにgnuplotをインストールする。

$ brew install gnuplot

NCAのような計量学習は、教師あり特徴変換・次元削減と捉えることができる。一般にk-近傍法で分類するとこが想定されていて、弁別性の高い変換・部分空間への射影を行う。Rumaleではfitメソッドで学習し、transformメソッドで変換・次元削減を行う。

合成データセットに対する次元削減の例を示す。データは以下のような、4つの円筒がならぶもので、高さがランダムになっている。k-近傍法による分類のためには、円の形が部分空間に保存されていると良い。

f:id:yoshoku:20200307112229p:plain

これを、主成分分析 (Principal Component Analysis, PCA)、FDA、NCAで2次元空間に射影するコードは次のようになる。

require 'numo/linalg/autoloader'
require 'numo/gnuplot'
require 'rumale'

# 円筒の合成データを作成する.
x1, y1 = Rumale::Dataset.make_circles(1500, random_seed: 1)
x2, y2 = Rumale::Dataset.make_circles(1500, random_seed: 2)
x = x1.class.vstack([x1, x2 * 0.6])
y = y1.class.hstack([y1, y2 + 2])
x = x.class.hstack([x, 0.6 * Rumale::Utils.rand_normal([x.shape[0], 1], Random.new(3))])

# 合成データをプロットする.
plots = y.to_a.uniq.sort.map { |l| [x[y.eq(l), 0], x[y.eq(l), 1], x[y.eq(l), 2], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'circles.png')
  set(ticslevel: 0)
  splot(*plots)
end

# 各種手法で二次元空間に射影する.
#z = Rumale::Decomposition::PCA.new(
#  n_components: 2, solver: 'evd').fit_transform(x)
#z = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(
#  n_components: 2).fit_transform(x, y)
z = Rumale::MetricLearning::NeighbourhoodComponentAnalysis.new(
  n_components: 2, init: 'pca', max_iter: 20, verbose: true, random_seed: 1
).fit_transform(x, y)

# 射影したデータをプロットする.
plots = y.to_a.uniq.sort.map { |l| [z[y.eq(l), 0], z[y.eq(l), 1], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'nca_circles.png')
  plot(*plots)
end

これで次元削減した結果が以下のようになる。PCAは、教師なし次元削減であるためラベル情報が反映されず、円筒を横に倒したようなかたちとなっている。FDAは、教師ありでラベル情報が与えられているが、それでもPCAと同様の結果となっている。NCAだけが、円の形を捉えることができ、k-近傍法で正しくラベルを推定できるようになっている。

f:id:yoshoku:20200307114445p:plain
主成分分析

f:id:yoshoku:20200307114510p:plain
FDA

f:id:yoshoku:20200307114529p:plain
NCA

NCAが最も良いように見える。とはいえ、やはり得手不得手とするデータは存在する。離れて位置する3つの楕円体からなるデータを試す。

f:id:yoshoku:20200307114937p:plain

これを、PCA、FDA、NCAで2次元空間に射影するコードは次のようになる。

require 'numo/gnuplot'
require 'numo/linalg/autoloader'
require 'rumale'

# 3つの団子からなる合成データを作成する.
centers = Numo::DFloat[[0, 5], [-5, -5], [5, -5]]
x, y = Rumale::Dataset.make_blobs(300, centers: centers, cluster_std: 0.5, random_seed: 1)
x = x.class.hstack([x, 4 * Rumale::Utils.rand_normal([x.shape[0], 1], Random.new(1))])

# 合成データをプロットする.
plots = y.to_a.uniq.sort.map { |l| [x[y.eq(l), 0], x[y.eq(l), 1], x[y.eq(l), 2], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'blobs.png')
  set(ticslevel: 0)
  splot(*plots)
end

# 各種手法で二次元空間に射影する.
#z = Rumale::Decomposition::PCA.new(n_components: 2, solver: 'evd').fit_transform(x)
#z = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(n_components: 2).fit_transform(x, y)
z = Rumale::MetricLearning::NeighbourhoodComponentAnalysis.new(
  n_components: 2, init: 'random', max_iter: 10, verbose: true, random_seed: 1
).fit_transform(x, y)

# 射影したデータをプロットする.
plots = y.to_a.uniq.sort.map { |l| [z[y.eq(l), 0], z[y.eq(l), 1], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'nca_blobs.png')
  plot(*plots)
end

次元削減した結果は以下のようになる。PCAは、緑色と青色のデータが混在してしまっている。FDAは、3つがキレイに離れていて、正しくラベルを推定できるようになっている。NCAは、3つのデータが離れてはいるが、FDAほどはキレイに離れていない。

f:id:yoshoku:20200307115554p:plain
PCA

f:id:yoshoku:20200307115618p:plain
FDA

f:id:yoshoku:20200307115637p:plain
NCA

おわりに

開発当初は機械学習の古典的な手法である、FDAを追加することを考えていた。しかし、discriminant analysisで名前空間を切っても広がりがないと思い、FDAを教師あり次元削減と捉え、metric learningで名前空間を切って、NCAとともに追加した。NCAの最適化では、scaled conjugate gradientを使用した。Pythonでは最適化にscipy.optimizeが使えるが、Rubyではそういったライブラリはあまりない。そこで最適化の部分だけgemに切り出し、これをRumaleから使うことにした。このgemも、徐々に代表的な手法を追加していき、育てていこうと思う。

github.com

github.com

Rumaleの線形モデルの実装を修正した

はじめに

気がつくと全然ブログを書いていなかった。ver. 0.14で多層パーセプトロンを追加して細かい修正とかを重ねて、今はver. 0.17.0になった。ver. 0.17.0での大きな変更は、Rumale::LinearModel以下を刷新したことにある。

rumale | RubyGems.org | your community gem host

-->8-- 以下ただのポエムです -->8--

LinearModelの変更

Rumaleは以前はSVMKitという名前で開発を進めていた。Rubyでそれなりに動くSupport Vector MachineSVM)を実装してみようというのが始まりで、そこから色々な機械学習アルゴリズムを追加してRumaleとなった。

SVMKitは、線形SVMを実装するところから始まったので、LinearModelは、わりと実験的な実装になっていた。LinearModel以下にあるアルゴリズムは、確率的勾配降下法(Stochastic Gradient Descent, SGD)により最適化を行う。SGDの学習率に、学習率を適応的に計算するAdamというアルゴリズムに、Nesterov momentumを組み合わせたNadamをデフォルトで用いていた(AdaGradやRMSpropといった他のアルゴリズムも選択可能)。Adamなどは、深層学習の文脈で発展したもので、単層のシンプルな線形モデルでは、オーバースペックなところがあった。実際に、普通のSGD+momentumでも十分な分類精度が得られていた(データセットによっては学習率の調整が必要になるが)。

また、Elastic-net回帰の実装が難しい構成になっていた。Elastic-netは、L2正則化とL1正則化を混合したものだが、SGDで実装しようと思うと、重みベクトルを更新したのちに、L2正則化とL1正則化を順に加える形になる。実装しようと考えたSGDのL1正則化アルゴリズムでは、学習率が必要になったため、Nadamでは難しかった。

そこで、まず、AdaGradやAdamの利用は廃してシンプルなSGD+momentumだけに修正し、アルゴリズムにあわせて、L2正則化とL1正則化を加える構成に変更した。これを用いて実装したElastic-net回帰を、ver. 0.16.1でリリースした。そして、ver. 0.17.0で全ての線形モデルに、新しい実装を適用した。あわせて、学習回数を決めるmax_iterパラメータを、epoch数とした。与えられた訓練データ全体を学習プロセスに投入したものが、1 epochとなる。

LinearModelを新しくしたことで、SVMやLogistic回帰にも、L1正則化や、Elastic-net同様にL2正則化とL1正則化を混合したものを加えることができるようになった。おおよそ LIBLINEAR と同様のことができるようになったので、SVMKitとしてもよかったのかも。

おわりに

この他、ver. 0.15.0で、FeatureHasherやHashVectorizerといった、特徴抽出のためのクラスを追加した。特徴ベクトルをHashのArrayで渡すと、Rumaleで使える Numo::DFloatで返すものである。文書分類などを実装するさいに便利だと思う。

encoder = Rumale::FeatureExtraction::HashVectorizer.new
x = encoder.fit_transform([
  { foo: 1, bar: 2 },
  { foo: 3, baz: 1 }
])

# > pp x
# Numo::DFloat#shape=[2,3]
# [[2, 0, 1],
#  [0, 1, 3]]

LinearModelの修正により、Rumaleも、いい意味で、オーソドックスな機械学習ライブラリになってきた。アルゴリズムの数も多くなってきたのもあり、今はユーザーガイドの執筆に時間をかけようと考えている。

github.com

Rumaleに多層パーセプトロンなニューラルネットワークを追加した

はじめに

Rumaleに多層パーセプトロンによる分類・回帰を追加した。活性化関数にはReLUを、正則化にはDropout、最適化にはAdamというスタンダードな構成にした。あわせて、入力のバリデーションをNumo::NArrayだけでなくRuby Arrayも受け付けるように修正して、version 0.14.0としてリリースした。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。データの取得にred-datasetsを使いたいので、一緒にインストールする。

$ gem install rumale red-datasets-numo-narray

USPSという手描き数字画像によるデータセットで分類の例を示す。

require 'datasets-numo-narray'
require 'rumale'

# データを読み込む.
dataset = Datasets::LIBSVM.new('usps').to_narray
labels = Numo::Int32.cast(dataset[true, 0])
samples = Numo::DFloat.cast(dataset[true, 1..-1])

# データセットをランダムに訓練とテストに分割する.
ss = Rumale::ModelSelection::ShuffleSplit.new(
  n_splits: 1, test_size: 0.2, random_seed: 1
)
train_ids, test_ids = ss.split(samples, labels).first

train_s = samples[train_ids, true]
train_l = labels[train_ids]
test_s = samples[test_ids, true]
test_l = labels[test_ids]

# 多層パーセプトロンによる分類器を用意する.
# ※
# 隠れ層のユニット数はhidden_unitsで与える.
# 以下の例では, 2層の隠れ層を持ち, ユニット数がそれぞれ256と128になる。
# 繰り返し回数max_iterとミニバッチの大きさbatch_sizeは, 
# 隠れ層と同様に慎重に設定したほうが良い.
# verboseをtrueにすると学習過程のロス関数の値が表示される.
mlp = Rumale::NeuralNetwork::MLPClassifier.new(
  hidden_units: [256, 128],
  max_iter: 1000, batch_size: 50,
  verbose: true, 
  random_seed: 1
)

# 多層パーセプトロンによる分類器を学習する.
mlp.fit(train_s, train_l)

# テストセットで分類の正確度を計算する.
puts("Accuracy: %.4f" % mlp.score(test_s, test_l))

これを実行すると、次のようになる。学習が進むにつれてロスが小さくなり、テストセットでは96%程度の正確さで分類できている。

[MLPClassifier] Loss after 10 iterations: 2.2093608343275157
[MLPClassifier] Loss after 20 iterations: 1.8448493780493846
[MLPClassifier] Loss after 30 iterations: 1.5683160562017802
...
[MLPClassifier] Loss after 980 iterations: 0.2764881083070085
[MLPClassifier] Loss after 990 iterations: 0.1944084487714543
[MLPClassifier] Loss after 1000 iterations: 0.11276357417884826
Accuracy: 0.9616

あわせて

入力のバリデーションを緩めた。今までは、サンプルなどはNumo::NArrayでないと弾いていたが、Ruby Arrayでも受け入れるようにした(内部の計算ではNumo::NArrayに変換したものを使う)。また、実数なハイパーパラメータはFloatでないと弾いていたがこれもやめた。Scikit-learnもわりと緩い感じで、変なものが入力されたら、問答無用でコケていた。「APIドキュメントは書いてるから想定外の入力きてコケても許してね」ぐらいの気持ちで、利便性のためバリデーションを緩めた。

おわりに

Rumaleも様々な手法があり、そこそこ大きなライブラリとなってきた。一度開発の手は止めて、yardocによるAPIドキュメントだけでなく、ユーザーガイドを書いていこうと思っている。

github.com

LIBSVMとLIBLINEARをRumaleなインターフェースで使えるRumale::SVMを作った

はじめに

LIBSVMとLIBLINEARに実装されているサポートベクターマシンSupport Vector Machine, SVM)のアルゴリズムを、Rumaleのインターフェースで利用するためのGem、Rumale::SVMを作成した。

rumale-svm | RubyGems.org | your community gem host

Rumaleは、もともとSVMKitという名前だった。SVMKitは「RubySVMを実装するとしたらどうなるか?」からスタートしている。この経緯から、Rumaleに実装されているSVMによる分類器や回帰は、Ruby確率的勾配降下法により実装されている。一方で、Scikit-learnをはじめ多くの機械学習ライブラリでは、SVMの実装にはLIBSVMとLIBLINEARを利用することが多い。Rumaleは、SVMKitと異なり、SVMにこだわらない機械学習ライブリであるため、その他の機械学習ライブリと同様にLIBSVMとLIBLINEARによるSVMを扱えるようにしたい、と考えていた。

使い方

Rumale::SVMをインストールすれば、Numo::NArrayやRumaleといった必要なGemがインストールされる。別で外部ライブラリをインストールする必要もない。

$ gem install rumale-svm

Rumaleと同様の使い方ができる。LIBSVM DATAにあるpendigitsデータセットで、線形SVMによる分類器を交差検定してみる。

require 'rumale'
require 'rumale/svm'

# LIBSVM形式のpendigitsデータセットを読み込む.
samples, labels = Rumale::Dataset.load_libsvm_file('pendigits')

# LIBLINEARな線形SVM分類器を定義する.
svc = Rumale::SVM::LinearSVC.new(reg_param: 8.0, dual: false, fit_bias: true, random_seed: 1)

# 5-交差検定を行う.
ev = Rumale::EvaluationMeasure::Accuracy.new
kf = Rumale::ModelSelection::StratifiedKFold.new(n_splits: 5, shuffle: false, random_seed: 1)
cv = Rumale::ModelSelection::CrossValidation.new(estimator: svc, splitter: kf, evaluator: ev)

report = cv.perform(samples, labels)

# 結果を表示する.
mean_score = report[:test_score].inject(:+) / kf.n_splits
puts("Mean accuracy: %.4f" % mean_score)

これを実行すると、以下のようになる。LIBLINEARな線形SVM分類器を、Rumaleで交差検定できている。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits
$ ruby rumale_svm_cv.rb
Mean accuracy: 0.9493

Rumaleの他の機能との組み合わせをもう一つ。分類器の定義と交差検定の部分を以下のように変更してみる。

#... 省略

# Random featureでRBFカーネルを近似して, LIBLINEARな線形SVM分類器で分類する.
rbf = Rumale::KernelApproximation::RBF.new(gamma: 0.0001, n_components: 512, random_seed: 1)
pipeline = Rumale::Pipeline::Pipeline.new(steps: { trns: rbf, clsf: svc })

# 5-交差検定を行う.
ev = Rumale::EvaluationMeasure::Accuracy.new
kf = Rumale::ModelSelection::StratifiedKFold.new(n_splits: 5, shuffle: false, random_seed: 1)
cv = Rumale::ModelSelection::CrossValidation.new(estimator: pipeline, splitter: kf, evaluator: ev)

#... 省略

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

$ ruby rumale_svm_cv.rb
Mean accuracy: 0.9960

Rumale::SVMではこの他、カーネルSVMによる回帰やOne-class SVM、ロジスティック回帰など、LIBSVMとLIBLINEARにあるアルゴリズムは利用できるようにした。これは、sklearn.svmにあるものと同様である。

※ ちなみに最初の線形SVM分類器の例で、Rumale::LinearModel::SVCで同様の結果を得ようと思うと以下のようになる。実行速度は、LIBLINEARをベースにしたRumale::SVM::LinearSVCのほうが圧倒的に速い。

svc = Rumale::LinearModel::SVC.new(reg_param: 8e-5, max_iter: 5000, batch_size: 20, fit_bias: true, random_seed: 1)

おわりに

RumaleにあるSVMを、LIBSVMとLIBLINEARを利用したモノにすることは、以前より考えていた。ただ、RubyLIBSVMやLIBLINEARを叩くライブラリは他にもあり、どうしようかなと思っていた。それらライブラリは直接的にNumo::NArrayな配列を利用できないので、まず、Numo::NArrayでLIBSVMとLIBLINEARを扱うライブラリを作成した。

そして、これらをRumaleなインターフェースでRumaleとともに使える形にまとめた。SVMLIBSVMとLIBLINEARを利用するようになって、ある意味、Rumaleも機械学習ライブラリらしくなった。

この他「scikit-learn-contribのような拡張ライブラリをRumaleで作るとしたらどうなるか?」ということも考えており、それも合わせて達成できた。作ってみた感じ、インタフェースを合わせればRumaleと一体的に使えるので、便利だった。Rubyオープンクラスなので、案外、拡張性みたいなのが、他の言語の機械学習ライブラリと比較して強みになるかもしれない(具体的な考えはないけど)。

github.com