洋食の日記

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

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がポインターで取れたら良いのにな〜と思ってたら簡単にできた。

Rumaleにガウス混合モデルによるクラスタリングを追加した

はじめに

Rumaleにガウス混合モデル(Gaussain Mixture Model, GMM)によるクラスタリングを追加して、ver. 0.12.2としてリリースした。

rumale | RubyGems.org | your community gem host

GMMは、データ分布をいくつかの正規分布の重み付き線形和で表現しようというもので、このいくつかの正規分布クラスタとなる。GMMは、与えられたデータから、混合比・平均ベクトル・共分散行列で表されるクラスタを解析する。共分散行列の表現方法でいくつかパターンがあるが、Rumaleでは対角要素のみを用いる方法を実装した。これは、混合比を求めるために、共分散行列の行列式逆行列を計算する必要があり、共分散行列が対角行列であると、これら計算を簡略化できることから選択した。

使い方

Rumaleはgemコマンドでインストールできる。Numo::NArrayに依存している。

$ gem install rumale

データセットの読み込みでred-datasets、データのplotにNumo::Gnuplotを使いたいので、これらもインストールする。※別途gnuplotをインストールする必要がある

$ brew install gnuplot
$ gem install numo-gnuplot red-datasets-numo-narray

Scikit-LearnのGMMの例を試してみる。アヤメデータをGMMでクラスタリングして、1次元目と2次元目の特徴を使って散布図で可視化する。

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

# 楕円の大きさを求める.
def ellipse_size(cov11, cov22)
  cov = Numo::DFloat.zeros(2, 2)
  cov[0, 0] = cov11
  cov[1, 1] = cov22
  v, = Numo::Linalg.eigh(cov)
  sz = 2 * Math.sqrt(2) * Numo::NMath.sqrt(v)
  sz.to_a
end

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

# GMMでクラスタリングする.
gmm = Rumale::Clustering::GaussianMixture.new(n_clusters: 3, random_seed: 5)
cluster_ids = gmm.fit_predict(samples) # ※クラスタラベルは今回の可視化では使用しない.

# Numo::GnuplotでPNGファイルに書き出す.
## サンプル
x = samples[true, 0]
y = samples[true, 1]
plots = labels.to_a.uniq.sort.map { |l| [x[labels.eq(l)], y[labels.eq(l)], t: l.to_s] }
## 各クラスタの平均ベクトル
plots.push([Numo::DFloat[gmm.means[2, 0]], Numo::DFloat[gmm.means[2, 1]], t: 'center 1'])
plots.push([Numo::DFloat[gmm.means[0, 0]], Numo::DFloat[gmm.means[0, 1]], t: 'center 2'])
plots.push([Numo::DFloat[gmm.means[1, 0]], Numo::DFloat[gmm.means[1, 1]], t: 'center 3'])
## 各クラスタの共分散による楕円とともにプロットする.
Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'iris.png')
  set(:object, 1, 'ellipse',
      center: [gmm.means[2, 0], gmm.means[2, 1]],
      size: ellipse_size(gmm.covariances[2, 0], gmm.covariances[2, 1]),
      angle: 0.0, front: true, fs: true, empty: true, bo: 1)
  set(:object, 2, 'ellipse',
      center: [gmm.means[0, 0], gmm.means[0, 1]],
      size: ellipse_size(gmm.covariances[0, 0], gmm.covariances[0, 1]),
      angle: 0.0, front: true, fs: true, empty: true, bo: 2)
  set(:object, 3, 'ellipse',
      center: [gmm.means[1, 0], gmm.means[1, 1]],
      size: ellipse_size(gmm.covariances[1, 0], gmm.covariances[1, 1]),
      angle: 0.0, front: true, fs: true, empty: true, bo: 3)
  plot(*plots)
end

これを実行すると、以下のような画像を得られる。Scikit-LearnのGMMの例でいえば、covariance_typeをdiagにした図に相当する。対角要素のみで当てはめると、楕円の回転が考慮されない(例えば左上のクラスタの楕円は斜め45度くらい傾いていると分布によりフィットした感じになるがそれがない)。それだけ分布にフィットできていないことになるわけだが、親戚的な手法であるK-Meansがクラスタ中心のみによる表現であったり、あんまりフィットし過ぎても良くない場合もあったりすることから、クラスタリングの実利用としては割とこれで十分だったりもする。

f:id:yoshoku:20190615132952p:plain

おわりに

Rumaleのver. 0.12.2では、確率モデルによるクラスタリング手法であるガウス混合モデル(Gaussain Mixture Model, GMM)を実装した。また、GMMの他に、オマケでカテゴリ変数を整数値に変換するOrdinalEncoderも追加した。こういったfeature engineeringな前処理クラスを足していっても良いかもな〜とも思ったり。

github.com