洋食の日記

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

乱択アルゴリズムな打ち切り特異値分解の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)

おわりに

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

LIBSVM/SVMLight形式のデータの読み込み/書き込みをするgemを公開した

はじめに

LIBSVM形式のデータセットファイルを読み込み、データとラベルをNMatrixで返すライブラリが欲しかったので、作成してgemパッケージを公開した。

libsvmloader | RubyGems.org | your community gem host

Pythonのsvmlight-loaderにお世話になっているので、リスペクトのつもりで「LibSVMLoader」としたけど、Wekaに同名のクラスがあった。Loaderと名付けたけど、svmlight-loaderと同じく書き込みもできる。

インストール

読み込んだデータやラベルをNMatrixで返すのでNMatrixに依存する。

$ gem install libsvmloader

使い方

読み込みにはload_libsvm_fileメソッドを、書き込みにはdump_libsvm_fileメソッドを用いる。ドキュメントはこちら

require 'libsvmloader'

# 分類タスクを読み込む場合:
# samplesは、サンプル数x次元数のfloat64のNMatrix
# labelsは、サンプル数x1のint32のNMatrix
samples, labels = LibSVMLoader.load_libsvm_file('hoge.t')

# 回帰タスクを読み込む場合:
# ※ラベルに相当するNMatrixのdtypeを指定できるのでfloat64として実数とする
# samplesは、サンプル数x次元数のfloat64のNMatrix
# target_valuesは、サンプル数x1のfloat64のNMatrix
samples, target_values = LibSVMLoader.load_libsvm_file('hoge.t', label_dtype: :float64)

# libsvmファイルの特徴ベクトルの添字が「0」から始まる場合:
samples, labels = LibSVMLoader.load_libsvm_file('hoge.t', zero_based: true)

# 書き込み:
LibSVMLoader.dump_libsvm_file(samples, labels, 'foo.t')

# 書き込み(特徴ベクトルの添字を「0」から始めたい場合):
LibSVMLoader.dump_libsvm_file(samples, labels, 'foo.t', zero_based: true)

線形SVMによる分類のサンプル

例として、libsvmloaderでデータを読み込んで、liblinear-rubyで線形SVMの訓練・テストをするのは以下の様になる。

require 'libsvmloader'
require 'liblinear'

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

# 線形SVMによる分類器を訓練する.
Liblinear.quiet_mode
model = Liblinear.train(
  { solver_type: Liblinear::L2R_L2LOSS_SVC },
  tr_labels.to_flat_a,
  tr_samples.to_a
)

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

# テストデータのラベルを推定する.
pr_labels = ts_samples.to_a.map { |fv| Liblinear.predict(model, fv).to_i }

# 正解数をカウントする.
n_hits = pr_labels.map.with_index { |l, n| 1 if l == ts_labels[n] }.compact.sum

# Accuracyを出力する.
n_ts_samples = ts_labels.size
accuracy = 100.0 * (n_hits / n_ts_samples.to_f)
puts(format('Accuracy = %.4f%% (%d/%d)', accuracy, n_hits, n_ts_samples))

おわりに

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

Liblinear-Rubyによる線形SVMを試すついでにカーネル近似も試した

はじめに

Liblinearは、線形SVMの実装として有名なライブラリ/ツールである。 これをRubyから叩くライブラリとして、Liblinear-Rubyがある。 Rubyの配列で表現された、特徴ベクトルとラベルをわたすだけで、線形SVMの訓練・テストが行える。 これで線形SVMによる分類器が簡単にできるので、ついでにカーネル近似も試した。

インストール

Liblinear-Rubyでは、内部的にswigを使っているので、HomebrewでLiblinearだけでなくswigもインストールする。

$ brew install liblinear swig
$ gem install liblinear-ruby

libsvm形式ファイルの読み込み

Rubylibsvmフォーマットのファイルを読み込むメソッド書いていたが、どうにも汚くなってしまったので、 PyCallでScikit-Learnのload_svmlight_fileを叩くことにした。 トリッキーだけど、PyCallで得られたnumpy.arrayをNMatrixに変換することを、考えてみたかったのもある。 あんまりいい感じにならなかったけど。

require 'nmatrix/lapacke'

require 'pycall/import'
include PyCall::Import
pyfrom 'sklearn.datasets', import: 'load_svmlight_file'

def load_libsvm_file(filename)
  # PyCallで呼び出したload_svmlight_fileでlibsvm形式のデータを読み込む。
  py_samples, py_labels = load_svmlight_file.(filename, zero_based: false)
  # サンプル数と次元数を得て、サンプル用のNMatrixとラベル用のNVectorを用意する。
  n_samples, n_dimensions = py_samples.shape
  samples = NMatrix.zeros [n_samples, n_dimensions]
  labels = NVector.zeros n_samples
  # NMatrixとNVectorに変換する。
  n_samples.times do |r|
    labels[r] = py_labels[r].to_i
    n_dimensions.times do |c|
      samples[r,c] = py_samples[r,c].to_f
    end
  end
  return samples, labels
end

Liblinear-Rubyでの分類

まず、動作確認のためのサンプルデータとして、libsvmのサイトから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

これらを読み込んで、訓練・テストを行うコードは次の様になる。

# 訓練データを読み込む。
samples, labels = load_libsvm_file 'pendigits'

# Liblinear-Rubyで線形SVMな分類器の訓練を行う。
# データの入力にはRubyの配列を用いるので、
# to_aやto_flat_aでNMatrixを配列に変換する。
Liblinear.quiet_mode
model = Liblinear.train(
  { solver_type: Liblinear::L2R_L2LOSS_SVC },
  labels.to_flat_a,
  samples.to_a)

# テストデータを読み込む。
test_samples, test_labels = load_libsvm_file 'pendigits.t'

# テストデータのラベルを推定する。
# predictメソッドでは、テストデータをまとめて与えるのではなく、
# 特徴ベクトルの一つ一つを与える形になる。
pred_labels = test_samples.to_a.map do |smpl|
  Liblinear.predict(model, smpl).to_i
end

# 正解しているラベルをカウントする。
n_correctly_preds = 0
n_test_samples = test_labels.size
n_test_samples.times do |n|
  n_correctly_preds += 1 if test_labels[n] == pred_labels[n]
end

# Accuracyを計算して出力する。
accuracy = n_correctly_preds / n_test_samples.to_f
puts sprintf("Accuracy = %.4f%% (%d/%d)", 
  accuracy * 100.0, n_correctly_preds, n_test_samples)

これを実行すると、次の様な結果を得られた。 これは、当然ながら、Liblinearのコマンドで訓練・テストした場合のAccuracyと一致した。

$ ruby hoge.rb
Accuracy = 87.8788% (3074/3498)

カーネル近似による非線形分類

Liblinearで得られるのは線形分類器なので、特徴空間上で非線形に分布するデータは、正しく分類できない。 これを解決する方法で有名なのが、カーネル法だが、データ数が多いと重くなったり、下手をすると動かなかったりする。 この問題に対して、カーネルを近似するような変換を考えよう、というアプローチがある。 最も有名なのが、ガウスカーネルを近似した、Random Fourier Features(RFF)である。 RFFでは、D個の正規ランダムベクトルwと特徴ベクトルxとの積による変換で表される。

 \displaystyle
\phi(\mathbf{x})=\sqrt{1/D}[ \sin(\mathbf{w}_{1}^{\top}\mathbf{x}),\ldots,sin(\mathbf{w}_{D}^{\top}\mathbf{x}), \cos(\mathbf{w}_{1}^{\top}\mathbf{x}),\ldots,cos(\mathbf{w}_{D}^{\top}\mathbf{x}) ]

これは、正規ランダムベクトルを並べた正規ランダム行列Wを考えると、処理自体は行列-ベクトル積となるので重くない。

 \displaystyle
W=[\mathbf{w}_{1},\ldots,\mathbf{w}_{D}]^{\top}

このRFFの改良版が、NIPS'16においてGoogle ResearchのF X. Yuらが提案した、Orthogonal Random Features(ORF)である。 ORFでは、ランダム行列Wを、カイ分布からサンプルされた要素からなる対角行列S、一様分布なランダム直交行列Qで表す。 ※Qは正規ランダム行列GをQR分解することで得られるそうな。

 \displaystyle
W=\frac{1}{\sigma}SQ

また、次の簡単化したモノも提案されている。

 \displaystyle
W=\frac{\sqrt{d}}{\sigma}Q

直交化することで、カーネル近似の精度が上がるとのこと。 さらに論文では、Structured ORFとしてWalsh-Hadamard行列を用いたものも提案されている。

ORFの実装

今回、ORFを試そうと思ったのは、NMatrixのQR分解を試したかったからである。 NMatrixには、正規ランダム行列を生成するメソッドがないので、まずこれを実装する。よくあるBox-Muller法を用いた。

def randn(shape, mu=0.0, sigma=1.0)
  x = NMatrix.random shape
  y = NMatrix.random shape
  ((x.log * -2.0).sqrt * (y * 2.0 * Math::PI).sin) * sigma + mu
end

さらに、正規ランダム行列をQR分解して直交行列を得るメソッドと、特徴変換を行うメソッドは、次のようになる。

# Random Fourier Featuresのための変換行列を得る。
def rff_transform_mat(samples, sigma=100.0)
  n_samples, n_dimensions = samples.shape
  rand_mat = randn [n_dimensions, n_dimensions]
  transform_mat = rand_mat * (1.0 / sigma)
end

# Orthogonal Random Featuresのための変換行列を得る。
def orf_transform_mat(samples, sigma=100.0)
  n_samples, n_dimensions = samples.shape
  rand_mat = randn [n_dimensions, n_dimensions]
  orth_rand_mat, r = rand_mat.factorize_qr
  transform_mat = orth_rand_mat * (Math.sqrt(n_dimensions) / sigma)
end

# 特徴変換を行う。
def mapping(samples, transform_mat)
  n_samples, n_dimensions = samples.shape
  features = samples.dot transform_mat.transpose
  mapped = (features.sin.hconcat features.cos) * Math.sqrt(1.0 / n_dimensions.to_f)
end

これを、訓練データとテストデータに適応する。

# 訓練データを読み込む。
samples, labels = load_libsvm_file 'pendigits'

# 変換行列を計算し、特徴変換を行う。
trans_mat = orf_transform_mat samples
samples = mapping samples, trans_mat

...

# テストデータを読み込む。
test_samples, test_labels = load_libsvm_file 'pendigits.t'

# 特徴変換を行う。
test_samples = mapping test_samples, trans_mat

...

これで実験してみると、RFFのAccuracyが91.84%±1.41で、ORFのAccuracyが93.94%±0.67となった。 たしかに、ORFの方がわずかに良い。 データセットによりけりだろうけど、 元の特徴ベクトルのAccuracyが87.88%なので、カーネル近似による特徴変換により分類精度が向上することが確認できた。

おわりに

思いのほか長くなってしまったので、Liblinear-Rubyカーネル近似の話で、記事を分けたほうが良かった感。

はてなブログProにしても広告を消すのには設定が必要だった

はてなブログProにすれば勝手に広告が消えると思ってたら、設定が必要だった。だいたい3ステップぐらい必要だった。広告を消したのは「広告って読み込み重いから鬱陶しいよね」という軽い気持ちから。

  • 設定から、詳細設定タブを選択する。

f:id:yoshoku:20170730094852p:plain

  • 広告を非表示で、はてなによる広告を表示しない、を選択する。

f:id:yoshoku:20170730094908p:plain

  • 画面最下部の変更するボタンを押すことを忘れずに。

f:id:yoshoku:20170730094923p:plain

以上。※画像がドーンってデカくなったので記事を後で修正しました。

MacではRubyのNMatrixでOpenBLASを使わなくてもLAPACKで十分に速かった

はじめに

Ruby線形代数機械学習アルゴリズムの実装を行う場合、SciRubyのNMatrixが最適だろう。 このNMatrixで、 RやPythonのNumpyと同様に、後ろで叩いているBLAS/LAPACKを、OpenBLASやIntel MKLに変えることで、高速化することを考えた。 しかし、Macでは、BLAS/LAPACKはAccelerate Frameworkに含まれていて、OpenBLASに変えるまでもなく十分に速かった。 環境は、MacBookIntel Core m3 1.1Ghz、メモリ8G、macOS Sierra 10.12.6 である。

行列積での実行速度の計測

まずは、素のNMatrixと、MacBLAS/LAPACKを使う場合の実行速度を計測した。 インストールは、gemからもできるが、測定条件を一致させるため最新版をビルドする方法を採用した。

$ git clone https://github.com/SciRuby/nmatrix.git
$ cd nmatrix/
$ bundle install
$ bundle exec rake compile nmatrix_plugins=lapacke
$ bundle exec rake spec nmatrix_plugins=lapacke
$ bundle exec rake install nmatrix_plugins=lapacke

単純な行列積で、実行速度を計測する。行列積が速くなると色々と嬉しい。

require 'nmatrix'
require 'benchmark'

Benchmark.bm do |x|
  x.report do
    50.times do
      a = NMatrix.random [1000, 1000]
      b = NMatrix.random [1000, 1000]
      c = a.dot b
    end
  end
end

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

$ ruby test_nmat.rb
       user     system      total        real
  53.790000   1.400000  55.190000 ( 56.935680)

さらにLAPACKプラグインの実行速度を確認するため、require ‘nmatrix'を、

require 'nmatrix/lapacke'

と変更する。これを実行すると、素のNMatrixと比較してグンと速くなった。

$ ruby test_nmat.rb
       user     system      total        real
  23.050000   1.090000  24.140000 ( 18.840261)

OpenBLASに差し替えた場合での実行速度

OpenBLASは、Homebrewでインストールできる。 「–build-from-source」や「–with-openmp」といったオプションも指定できるが、 何も指定しないものが、行列積では最も速かった。

brew install openblas

「ext/nmatrix_lapacke/extconf.rb」を編集して、OpenBLASを使用するようにする。

...
#ldefaults = {lapack: ["/usr/local/lib"].delete_if { |d| !Dir.exists?(d) } }
ldefaults = {lapack: ["/usr/local/opt/openblas/lib"] }
...
#$libs += " -llapack "
$libs += " -L/usr/local/opt/openblas/lib -lopenblas "
...

NMatrixを再インストールする。

$ gem uninstall namtrix nmatix-lapacke
$ bundle exec rake clean
$ bundle exec rake compile nmatrix_plugins=lapacke
$ bundle exec rake spec nmatrix_plugins=lapacke
$ bundle exec rake install nmatrix_plugins=lapacke

OpenBLASを叩くLAPACKプラグインという状態なので、

require 'nmatrix/lapacke'

とする。実行してみると、LAPACKの結果と大差ないものとなった。

$ ruby test_nmat.rb
       user     system      total        real
  27.400000   4.530000  31.930000 ( 17.920017)

特異値分解での実行速度の計測

以下の特異値分解を含むプログラムでも比較を行った。

require 'nmatrix/lapacke'
require 'benchmark'

Benchmark.bm do |x|
  x.report do
    50.times do
      a = NMatrix.random [200, 2000]
      b = a.dot a.transpose
      #
      b.gesvd
    end
  end
end

LAPACKを使用した場合では、

$ ruby test_nmat2.rb
       user     system      total        real
   6.040000   0.300000   6.340000 (  5.908795)

となり、OpenBLASを使用した場合では、

$ ruby test_nmat2.rb
       user     system      total        real
  27.220000   4.920000  32.140000 ( 16.595134)

となった。OpenBLASよりもLAPACKの方が速く、 MacではAccelerate FrameworkのBLAS/LAPACKを使用した方が良いことがわかる。

ちなみに、Intel MKLでも試したが、Accelerate FrameworkのBLAS/LAPACKの方が速かった。 また、Intel MKLでは、rake spec した際にコケてしまった。

おわりに

MacでNMatrixを使用する場合は、

gem install nmatrix nmatrix-lapacke

として、

require 'nmatrix/lapacke'

とすれば良い。