洋食の日記

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

データセットをNumo::NArrayであつかうLIBSVMのGemを作成した

はじめに

特徴ベクトルやラベルをNumo::NArray形式で扱えるLIBSVMのbinding gemが欲しかったので、Ruby拡張ライブラリの勉強もかねて作成した。開発に際しては、Numo::FFTWをとても参考にさせて頂いた。gem名は、勝手ながらnumo-libsvmとした。

numo-libsvm | RubyGems.org | your community gem host

使い方

準備

多くのLIBSVM関連ライブラリがLIBSVMのコード自体を同梱しているが、Numo::Libsvmでは同梱しないことにした。brewやaptなどのパッケージマネージャでインストールしたLIBSVMと、バージョンがズレるのはどうかなと思ったためである。というわけで、LIBSVMをインストールする必要がある(必要になるのはlibsvm.soとsvm.hである)。

例えば、macOSであれば、

$ brew install libsvm

Ubuntuであれば、

$ sudo apt-get install libsvm-dev

となる。

インストール

numo-libsvmは、gemコマンドでインストールできる。

$ gem install numo-libsvm

C-SVCによる分類

LIBSVM DataにあるPendigitsデータセットを使って、C-SVCによる分類を行う。データの取得にはred-datasetsを用いる。

$ gem install red-datasets-numo-narray 

では、まず、C-SVCによる分類器を訓練する。

require 'numo/narray'
require 'numo/libsvm'
require 'datasets-numo-narray'

# Pendigitsデータの訓練データセットをダウンロードする.
puts 'Download dataset.'
pendigits = Datasets::LIBSVM.new('pendigits').to_narray
x = pendigits[true, 1..-1] # 特徴ベクトル
y = pendigits[true, 0]     # ラベル

# RBFカーネルによるC-SVCを実現するパラメータを定義する.
param = {
  svm_type: Numo::Libsvm::SvmType::C_SVC,
  kernel_type: Numo::Libsvm::KernelType::RBF,
  gamma: 0.0001, # RBFカーネルのパラメータ
  C: 10, # C-SVCのパラメータ
  shrinking: true
}

# C-SVCを訓練する.
puts 'Train support vector machine.'
model = Numo::Libsvm.train(x, y, param)

# パラメータと訓練したモデルをMarshalでファイルに保存する。
puts 'Save parameters and model with Marshal.'
File.open('pendigits.dat', 'wb') { |f| f.write(Marshal.dump([param, model])) }

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

$ ruby train.rb
Download dataset.
Train support vector machine.
Save paramters and model with Marshal.

次に、訓練したモデルで、テストデータの分類を行う。

require 'numo/narray'
require 'numo/libsvm'
require 'datasets-numo-narray'

# Pendigitsデータのテストデータセットをダウンロードする.
puts 'Download dataset.'
pendigits_test = Datasets::LIBSVM.new('pendigits', note: 'testing').to_narray
x = pendigits_test[true, 1..-1]
y = pendigits_test[true, 0]

# パラメータと訓練したモデルをMarshalで読み込む.
puts 'Load parameter and model.'
param, model = Marshal.load(File.binread('pendigits.dat'))

# テストデータのラベルを推定する.
puts 'Predict labels.'
predicted = Numo::Libsvm.predict(x, param, model)

# 推定結果を正確度で評価する.
mean_accuracy = y.eq(predicted).count.fdiv(y.size)
puts "Accuracy: %.1f %%" % (100 * mean_accuracy)

これを実行すると、以下のようになる。正確度(Accuracy)が98.3%となり、上手く分類できていることがわかる。

$ ruby test.rb
Download dataset.
Load parameter and model.
Predict labels.
Accuracy: 98.3 %

おわりに

ひとまず、最低限の動きができるようになったので公開した。これから、ドキュメントの整備も含めてアップデートしていきたい。また、LIBSVMのコードを同梱していないので、RubyInstallerによるWindows環境では動作しないかもしれない(libsvmのライブラリとヘッダーが指定できればWindowsでも動くと思われる)。

Rumaleなインターフェースも用意しようかと考えたが、それは今後、例えばrumale-libsvmのような形で、Rumale側でできればと思う。

github.com

Rubyのパイプラインを考えてみた

はじめに

Rubyにパイプライン演算子が追加されるかも?というのが、少し前に話題になった。他方、R言語にはmagrittrというパイプラインを実現するパッケージがあり、これが便利だ。

# h(g(f(x)))
x %>% f %>% g %>% h

データ分析では、カテゴリ変数を整数値化して欠損値を補ってアレしてコレして〜など、データを段階的に変換する処理がよく発生する。そういった処理が、magrittrでスッキリ書ける。これがRubyにも欲しい。

やってみた

パイプラインを実現するために、ひとまず「自らを第一引数としてメソッドを呼び出す」ことができれば良さそうだ(magrittrではドットで任意の引数に入れることができるが、今回は見送る)。 これは、以下のようにシンプルに書ける。受け取ったメソッドfnを、自らを第一引数としてsendで呼び出す。メソッド名は、絵文字でそれっぽいのにした。

module Pipeline
  def 👉(fn, *args)
    send(fn, self, *args)
  end
end

これをStringで使ってみる。scatメソッドは、スペースをはさんで文字列を連結する。

class String
  include Pipeline
end

def scat(a, b)
  "#{a} #{b}"
end

"Hello"
  .👉(:scat, "world")
  .👉(:scat, "!!")
  .👉(:puts)

これを実行すると、次のようになる。Helloにworldが連結され、それにさらに!!が連結され、putsで出力されている。

Hello world !!

数値もいける。

class Numeric
  include Pipeline
end

def add(a, b)
  a + b
end

1
  .👉(:add, 2)
  .👉(:add, 0.5)
  .👉(:puts)

# 1 + 2 + 0.5 で 3.5 が表示される

もうKernelにぶっこんじゃえばいいかも。

module Kernel
  def 👉(fn, *args)
    send(fn, self, *args)
  end
end

メソッド名の指定にシンボルを使っているのと、👉の手前にドットがあるのが、演算子っぽくない。TracePointとか黒魔術を試みたけど、なんか上手くいかなかった。やってるうちに、そもそも演算子じゃないからコレで良いかな、という心になってしまった。途中に、パイプラインと関係ないメソッドを挟んでも、自然に見えるので。

"Hello"
  .👉(:scat, "world")
  .👉(:scat, "!!")
  .upcase
  .👉(:puts)

# HELLO WORLD !! と表示される

おわりに

こだわれば、もっと複雑で高度なモノにできると思う。ひとまず、データの前処理がmagrittrぽく書けるからOK 👌

Rumaleに多次元尺度構成法を追加した

はじめに

Rumaleに多次元尺度構成法(Multidimensional Scaling: MDS)による次元削減を実装した。MDSには様々なアルゴリズムがあるが、Rなどでも実装されている Scaling by MAjorizing a COmplicated Function(SMACOF)による方法を採用した。

rumale | RubyGems.org | your community gem host

MDSは、データの可視化に用いられることが多い。距離で表されるデータの関係を、低次元空間に保存する(nonmetricなMDSもあるが今回はmetricなものだけを実装した)。MDSは、古典的な可視化手法で、t-SNEが人気な現在でも広く利用される。

使い方

Rumaleはgemコマンドでインストールできる。Numo::NArrayに依存している。データのplotにNumo::Gnuplotを使いたいので、これもインストールする。※別途gnuplotをインストールする必要がある

$ brew install gnuplot
$ gem install rumale numo-gnuplot 

LIBSVM Dataにあるデータセットを可視化したいので、red-datasetsもインストールする。

$ brew install red-datasets

USPSという手書きの数字データセットをMDSにより可視化してみる。

require 'rumale'
require 'numo/linalg/autoloader'
require 'datasets-numo-narray'
require 'numo/gnuplot'

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

# データ数が多いと時間がかかるので適当にサブサンプリングする.
rand_id = Array.new(samples.shape[0]) { |n| n }.sample(1000)
labels = labels[rand_id]
samples = samples[rand_id, true]

# デフォルトではデータ間の距離にはユークリッド距離を用いる.
# verboseでtrueを指定すると, 最適化過程の数値を出力する.
mds = Rumale::Manifold::MDS.new(max_iter: 500, verbose: true, random_seed: 1)
low_samples = mds.fit_transform(samples)

# Numo::GnuplotでPNGファイルに書き出す.
x = low_samples[true, 0]
y = low_samples[true, 1]
plots = labels.to_a.uniq.sort.map { |l| [x[labels.eq(l)], y[labels.eq(l)], t: l.to_s] }

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

これを実行すると以下のような可視化結果が得られる。 ※sampleメソッドによるランダムなサブサンプリングが、実行毎に変わるので、必ず同じものが得られるとは限らない。

f:id:yoshoku:20190628202026p:plain

手書き数字のデータセットが、同じクラスではある程度かたまりながら分布していることがわかる。ただし、異なるクラスのデータが重なっている部分も多い。単純な、ユークリッド距離によるk-近傍法による分類では、誤分類がある程度発生することが予想できる。といった感じで、特徴空間でのデータ分布構造を確認することができる。

おわりに

ここしばらく、Rumaleでは、教師なし学習アルゴリズムの追加を行った。一度手を休めて、リファクタリングや性能向上を進めたいと思う。

github.com

RumaleにPower Iterationによる非線形クラスタリングを追加した

はじめに

Rumaleに、Power Iteration Clustering(PIC)を追加した。PICは、データ間の類似度をもとにクラスタリングする。類似度に、例えばRBFカーネルを選択すると、非線形なデータ分布構造を捉えたクラスタリングができる。 

rumale | RubyGems.org | your community gem host

PICはSpectral Clusteringの一種とみなせる。データ間の類似度を保存した低次元表現をもとめ、その低次元空間でK-means法を実行することでクラスタリングをおこなう。低次元表現は、正規化した類似度行列の固有ベクトルから得られるが、このときベキ乗法(power method)を用いて固有ベクトルを求める(ことからpower iteration clusteringと呼ぶのだろう)。求める固有ベクトルは最大の固有値に対応するものだけである。シンプルなアルゴリズムで、非線形クラスタリングを実現できることから、Rumaleでも実装した。

使い方

Rumaleはgemコマンドでインストールできる。Numo::NArrayに依存している。データのplotにNumo::Gnuplotを使いたいので、これもインストールする。※別途gnuplotをインストールする必要がある

$ brew install gnuplot
$ gem install rumale numo-gnuplot 

オマケというか、ver. 0.12.3から合成データセットを作成するメソッドを追加した。コレをクラスタリングしてプロットする。

require 'rumale'
require 'numo/gnuplot'

# 合成テストデータを作成する.
samples, labels = Rumale::Dataset.make_circles(500, factor: 0.4, noise: 0.05, random_seed: 1)
# samples, labels = Rumale::Dataset.make_moons(500, noise: 0.05, random_seed: 1)

# Power Iteration Clusteringでクラスタリングする.
# 類似度にはRBFカーネルを用いる. gammaはRBFカーネルのパラメータで調整が必要となる.
# ※独自の類似度を与えることもできる.
pic = Rumale::Clustering::PowerIteration.new(n_clusters: 2, gamma: 32.0, random_seed: 1)
clabels = pic.fit_predict(samples)

# 結果をGnuplotで出力する.
x = samples[true, 0]
y = samples[true, 1]
plots = clabels.to_a.uniq.sort.map { |l| [x[clabels.eq(l)], y[clabels.eq(l)], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'circles.png')
  unset(:xtics)
  unset(:ytics)
  unset(:border)
  plot(*plots)
end

これを実行すると以下の画像を得られる。2つの円をうまくクラスタリングできている。

f:id:yoshoku:20190622084249p:plain

他手法との比較としては、例えば、K-Means法では非線形構造を捉えることができず、半分に真っ二つにしてしまう。

f:id:yoshoku:20190622084454p:plain

Rumaleで実装したPICでは、デフォルトでは類似度にRBFカーネルを用いる。Rumaleで実装したRBFカーネルの式は以下で表される。

 k(\mathbf{x}, \mathbf{y}) = \exp(-\gamma ||\mathbf{x}-\mathbf{y}||^{2})

ハイパーパラメータとして \gammaをもつが、この値をデータに合わせて適切に調整する必要がある。

おわりに

Rumaleのver. 0.12.3では、非線形クラスタリング手法のPower Iteration Clustering(PIC)を実装した。PICは、類似度をもとに低次元表現を求め、その低次元空間でK-Means法を実施することで、非線形クラスタを得る。あわせて、合成データセットを作成するメソッドを追加した。

PICのアルゴリズムでは行列積を繰り返すので、Intel MKLやOpenBLASとともに、Numo::Linalgをインストールすることで高速化ができる。そのあたりはコチラを参考に。

qiita.com

Ruby拡張でNumo::NArrayのデータをポインタで取得する

はじめに

Ruby拡張で、Numo::NArrayのデータをC言語の配列のように扱えないかな〜と思っていたら、na_get_pointer_for_read(for_writeもある)という関数が用意されていて簡単にできた。

準備

もろもろ自動で用意されて便利なので、Ruby拡張を含むgemを作る形で進める。bundle install前に、gemspecファイル中のTODOを削除したり、URLの部分はコメントアウトしたりする必要がある。

$ bundle gem hoge --ext
Creating gem 'hoge'...
MIT License enabled in config
Code of conduct enabled in config
      create  hoge/Gemfile
      create  hoge/lib/hoge.rb
... (省略)

$ cd hoge
$ vim hoge.gemspec
... 「TODO:」を削除する
... spec.homepageなどURLを書く部分をコメントアウトする
... Numo::NArrayを依存関係に追加する
spec.add_runtime_dependency 'numo-narray'
... (省略)

$ bundle install
...

extconfにNumo::NArray関連の記述を追加する。

$  vim ext/hoge/extconf.rb
require 'mkmf'
require 'numo/narray'

$LOAD_PATH.each do |lp|
  if File.exist?(File.join(lp, 'numo/numo/narray.h'))
    $INCFLAGS = "-I#{lp}/numo #{$INCFLAGS}"
    break
  end
end

create_makefile('hoge/hoge')

ライブラリでもNumo::NArrayを読み込むようにする。

$ vim lib/hoge.rb
require 'numo/narray'
require 'hoge/version'
require 'hoge/hoge'

module Hoge
  class Error < StandardError; end
  # Your code goes here...
end

拡張の作成

関連ファイルのinclude

まずは、Numo::NArray関連のヘッダーファイルをincludeする。

$ vim ext/hoge/hoge.h
#ifndef HOGE_H
#define HOGE_H 1

#include "ruby.h"
#include "numo/narray.h"
#include "numo/template.h"

#endif /* HOGE_H */

Numo::NArrayのデータをポインタで取得する

Numo::DFloatの中身を表示するメソッドをもつモジュールを作る。

$ vim ext/hoge/hoge.c
#include "hoge.h"

VALUE rb_mHoge;

static VALUE
print_mat(VALUE self, VALUE vnary)
{
  narray_t *nary;
  size_t i, j;
  size_t n_rows, n_cols;
  double* data;

  /* VALUEなNArrayからnarray_t構造体を得る */
  GetNArray(vnary, nary);

  /* ホントはNumo::DFloatか確認するコードがあると良い
   * if (CLASS_OF(vnary) != numo_cDFloat) { ... とか
   */

  /* ホントは次元数が2か(行列かどうか)のコードがあると良い
   * if (NA_NDIM(na) != 2) { ... とか
   */

  /* NArrayの行数・列数を得る */
  n_rows = NA_SHAPE(nary)[0];
  n_cols = NA_SHAPE(nary)[1];
  printf("(%zd, %zd)\n", n_rows, n_cols);

  /* Numo::DFloatから配列の先頭ポインタを得る */
  data = (double*)na_get_pointer_for_read(vnary);

  /* 配列の中身を表示する */
  for (i = 0; i < n_rows; i++) {
    printf("[ ");
    for (j = 0; j < n_cols; j++) {
      printf("%.4f ", data[i * n_cols + j]);
    }
    printf("]\n");
  }

  return Qnil;
}

void
Init_hoge(void)
{
  rb_mHoge = rb_define_module("Hoge");
  rb_define_module_function(rb_mHoge, "print_mat", print_mat, 1);
}

動作確認

コンパイルして動作を確認する。渡したNumo::DFloatの中身を取得して表示することができた。

$ rake compile
...

$ bin/console
irb(main):001:0> a = Numo::DFloat.new(3,2).rand
=> Numo::DFloat#shape=[3,2]
[[0.0617545, 0.373067],
 [0.794815, 0.201042],
 [0.116041, 0.344032]]
irb(main):002:0> Hoge.print_mat(a)
(3, 2)
[ 0.0618 0.3731 ]
[ 0.7948 0.2010 ]
[ 0.1160 0.3440 ]
=> nil

おわりに

Ruby拡張を作っていると、外部ライブラリとアレコレするとか、完全にC言語の世界に行きたい場合があって、Arrayがポインターで取れたら良いのにな〜と思ってたら簡単にできた。