洋食の日記

洋食のことではなく、技術メモを書きます。たまにどうでも良いことも書きます。

rb-libsvmとlibsvmloaderを使ったRubyでのカーネル非線形SVMによる分類

はじめに

RubyLIBSVMバインドであるrb-libsvmと、libsvm形式のデータセットを読み書きするlibsvmloaderで、カーネル非線形SVMで分類する例です。カーネル非線形と明記するのは、libsvmの姉妹品であるliblinearが線形SVMなため。

準備

まずLIBSVMそのものをインストールする必要がある。macでhomebrewなら次のとおり。

$ brew install libsvm

次にGemをインストールする。libsvmloaderがnmatrixに依存するので、一応nmatrixつけてみました。SciRubyにようこそ的なメッセージがでるのが良いよね〜。

source 'https://rubygems.org'

gem 'nmatrix'
gem 'rb-libsvm'
gem 'libsvmloader'
$ bundle install

最後にサンプルで使うデータセットlibsvmのサイトからダウンロードする。MNISTが有名だけど、大きいので、同じ手書き文字認識データセットのpendigitsを使う。

$ 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

コード

「1. 訓練データセットで、SVMを学習する。2. テストデータセットを読み込んで、ラベルを推定する。 3. Accuracyを計算して出力する。」この一連の流れを一つにまとめた。

require 'libsvmloader'
require 'libsvm'

# 訓練データを読み込む
samples, labels = LibSVMLoader.load_libsvm_file('pendigits')

# 訓練データをrb-libsvmの特徴ベクトル形式に変換する
examples = samples.each_row.map { |v| Libsvm::Node.features(v.to_a) }

# カーネル非線形SVMのパラメータを設定する
params = Libsvm::SvmParameter.new.tap do |p|
  p.cache_size = 1000                      # キャッシュメモリサイズ [MB]
  p.svm_type = Libsvm::SvmType::C_SVC      # SVM分類器
  p.kernel_type = Libsvm::KernelType::RBF  # RBFカーネル
  p.gamma = 0.0001                         # RBFカーネルのパラメータ
  p.c = 1.0                                # SVMの正則化パラメータ
  p.eps = 0.001                            # SVMの最適化を終了する閾値
end

# カーネル非線形SVMを訓練する
problem = Libsvm::Problem.new
problem.set_examples(labels.to_flat_a, examples)
model = Libsvm::Model.train(problem, params)

# テストデータを読み込む
samples, labels = LibSVMLoader.load_libsvm_file('pendigits.t')

# テストデータのラベルを推定する
preds = samples.each_row.map { |v| model.predict(Libsvm::Node.features(v.to_a)) }

# Accuracyを出力する
n_hits = preds.map.with_index { |l, n| 1 if l == labels[n] }.compact.sum
n_samples = labels.size
accuracy = 100.0 * (n_hits / n_samples.to_f)
puts(format('Accuracy = %.4f%% (%d/%d)', accuracy, n_hits, n_samples))

これを実行すると、pendigitsデータセットの学習と分類が行われて、バーンとAccuracyが表示される。

$ ruby hoge.rb
Accuracy = 98.2847% (3438/3498)

おわりに

RubyにはLIBSVMをバインドしたGemが他にもある。それぞれに使い方が異なるので、チームやプロジェクトにあったものを選ぶのが良いと思う。Pythonのscikit-learnmみたいな、デファクトスタンダード機械学習ライブラリはRubyにはない(2017/09/16現在)。みんな自由にやってる感じ。でも、それはそれで良い雰囲気だと思う。

そんなわけで、個人的にscikit-learnインスパイアなRubyのベーシックな機械学習ライブラリを作ることを考えている(特に野心はなく単純な興味から)。深層学習は「流行ってるから、もう誰かライブラリ作ってるでしょ〜」といった気持ち。

LibSVMLoaderのゼロベクトルが読み込めないバグを修正した

タイトルのとおりです。LibSVMLoader(ver. 0.1.0)は、ゼロベクトルが含まれているとコケることがわかりました。修正してアップしておきました。申し訳ありません。

libsvmloader | RubyGems.org | your community gem host

specまわりの修正を除いた本体の修正はわずか1行です。バグって恐い。

RandSVDで乱数のシードを固定できるように修正した

RandSVDをマイナー(タイニー?)バージョンアップした。RandSVD(というよりも乱択アルゴリズム特異値分解)では、ランダム行列との積により行列を小さくする処理を含んでいる。なので、乱数のシードを固定できるようにしたほうが親切だと考えた。 ※ NMatrixに、numpy.random.seedとかみたいにグローバルに固定できる仕組みがあると、勝手に思い込んでいたのがそもそもの間違いでして…

require 'randsvd'

mat = NMatrix.rand [5, 5]
nb_singular_values = 2
nb_power_iterations = 3
random_seed = 1

u, s, vt = RandSVD.gesvd(mat, nb_singular_values, nb_power_iterations, random_seed)

単純にメソッド引数を後ろに増やしました。最初からキーワード引数とかなにかで、柔軟に引数増やせるように設計すれば良かったと反省…

randsvd | RubyGems.org | your community gem host

RandSVDを使った主成分分析によるMNISTデータセットの可視化

RandSVDを作ったので、特異値分解固有値分解)をベースにした機械学習アルゴリズムを実装していこうと思っている。 まずは、主成分分析によるMNISTデータセットの可視化を行った。 特異値分解と主成分分析の関係は、そのまま「主成分分析 特異値分解」でググると沢山でてくるので、ネットの海を汚さないためにも、ここでは割愛させて頂きました。

MNISTデータセットは、LIBSVMのページからダウンロードした。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/mnist.scale.t.bz2
$ bunzip2 mnist.scale.t.bz2

LibSVMLoaderで、MNISTデータセットを読み込み、中心化したデータセットを、RandSVDで特異値分解する。 得られた射影行列で2次元に射影し、GnuplotRBでプロットする。

require 'libsvmloader'
require 'randsvd'
require 'gnuplotrb'

# データを読み込む。
samples, labels = LibSVMLoader.load_libsvm_file('mnist.scale.t', stype: :dense)

# 中心化する。
nb_samples, = samples.shape
mean_vec = samples.mean(0)
centered = samples - mean_vec.repeat(nb_samples, 0)

# 特異値分解する。
nb_subspace_dimensions = 2
nb_power_iterations = 3
_u, _s, vt = RandSVD.gesvd(centered, 
                           nb_subspace_dimensions, nb_power_iterations)
projection_matrix = vt.transpose
#
# ※もちろん共分散行列を作っても良い。
# covariance_matrix = centered.transpose.dot(centered) / (nb_samples - 1)
# u, _s, _vt = RandSVD.gesvd(covariance_matrix, 
#                            nb_subspace_dimensions, nb_power_iterations)
# projection_matrix = u
#

# 低次元空間に射影する。
projected = centered.dot(projection_matrix)

# データをラベル別に色分けしてプロットする。
datasets = labels.uniq.map do |lid|
  x = projected.col(0).map.with_index { |e, n| e if labels[n] == lid }
  y = projected.col(1).map.with_index { |e, n| e if labels[n] == lid }
  GnuplotRB::Dataset.new([x.to_a, y.to_a], title: lid.to_s)
end

plot = GnuplotRB::Plot.new(*datasets, with: 'points', title: 'MNIST')
plot.to_png('mnist.png', size: [640, 480])

結果はこんな感じで上手くいってる。

f:id:yoshoku:20170908003948p:plain

自分がRubyで実現したいもので、共通化できる処理をGemにすると、自宅だけでなく会社とか他所のPCで使えて便利だし、もしかしたら同じ問題で悩んでいる遠い友人の手助けにもなるかもしれない。

乱択アルゴリズムな打ち切り特異値分解のgemを公開した

※Truncated SVD (Singular Value Decomposition) って和訳ありますか?勝手に打ち切り特異値分解としました。

はじめに

機械学習アルゴリズム特異値分解(もしくは固有値分解)をするときは、だいたい、大きい(もしくは小さい)方からk個だけ特異値/特異ベクトルを取り出すことが多い。NMatrixに実装されている特異値分解は、LAPACKのラッパーで、全ての特異値を求めるスタイル(Full SVD)なので、分解したい行列が大きい場合、実行時間が大きなものとなる。そこで、Randomized AlgorithmなTruncated SVDを実装した。

randsvd | RubyGems.org | your community gem host

NMatrixがARPACKとかに対応すると、存在意義を失うかも。

インストール

行列の扱いにはSciRubyのNMatrixを使用する。また、アルゴリズム中で特異値分解QR分解を行うため、NMatirx-Lapackeに依存する。

$ gem install randsvd

使い方

内部の特異値分解で、NMatrix::LAPACK.gesvdを使うものと、NMatrix::LAPACK.gesddを使うもので二種類のメソッドを用意した。わかりやすさのため、メソッド名はNMatrixのものと同じにした。gesddの方が、分割統治法で速いらしいっすよ。

require 'randsvd'

# 適当に、分解する行列として、m x nの大きさの行列を用意する。
m = 1000
n = 100
input_matrix = NMatrix.rand([m, n])

# 欲しい特異値の数を定義する。
k = 10

# 特異値分解を実行する。分解したい行列と、欲しい特異値の数を指定する。
# 特異値が大きいほうからk個の特異値と特異ベクトルが得られる。
# ここが u, s, vt = RandSVD.gesdd(input_matrix, k) でも良い。
u, s, vt = RandSVD.gesvd(input_matrix, k)

# 上の例では、
#   u は m x kの大きさの左特異ベクトルが並ぶ行列、
#   s は k個の特異値が並ぶベクトル、
#   vt は k x nの大きさの右特異ベクトルが並ぶ行列となる。
# これら特異値と特異ベクトルで元の行列を復元する場合、以下の様になる。
reconstructed_matrix = u.dot(NMatrix.diag(s).dot(vt))

ベンチマーク

Core m3 1.1GHzで8GBメモリのMacBookで、ベンチマークとってみた。 10,000 x 1,000の大きさでランクが10な行列の特異値分解を行う。 欲しいのは特異値の大きい方から10個となる。 NMatrix::LAPACK特異値分解は、実際には、必要な特異値を取り出す処理(例えば u=u[0..m - 1, 0..k - 1] みたいなの)が必要だけど、 速度に大した影響ないだろうと思って省略した。

require 'benchmark'
require 'randsvd'

m = 10000
n = 1000
k = 10

mat = NMatrix.rand([m,k]).dot(NMatrix.rand([k,n]))

Benchmark.bm 10 do |r|
  r.report 'NMatrix' do
    u, s, vt = mat.gesvd
  end

  r.report 'RandSVD' do
    u, s, vt = RandSVD.gesvd(mat, k)
  end
end

実行結果は以下のとおり。totalを見ると、RandSVDの方が約10倍くらい速い。

$ ruby bench_svd.rb
                 user     system      total        real
NMatrix     64.910000   1.300000  66.210000 ( 31.076849)
RandSVD      6.790000   0.860000   7.650000 (  5.485305)

アルゴリズム

乱択アルゴリズム特異値分解は、色んなバリエーションが提案されている。 NIPSのワークショップの論文に、良い感じに要約されたアルゴリズムが掲載されていたので、主にこれを参考にして実装した。

P.-G. Martinsson et. al, “Normalized power iterations for the computation of SVD,” In Proc. of NIPS Workshop on Low-Rank Methods for Large-Scale Machine Learning, 2011.

乱択アルゴリズム特異値分解では、ランダム行列由来の直交行列との積で、元の行列のサイズを縮めるということをやっている。 上記の論文では、(近似精度の高い?)直交行列を得るのに normalized power iterations というのを行う。 が、これをしなくても、そんなに精度的には変わらなかった(けど実行速度は落ちる)ので、RandSVDでは、デフォルトでは切っている。 normalized power iterationsを試したければ、gesvd/gesddメソッドで、繰り返し回数を指定するとよい(デフォルトでは0回になっている)。

# 例えば5回おこなう場合:
t = 5
RandSVD.gesvd(mat, k, t)

おわりに

つまらないものですが、よろしくお願い致します。