洋食の日記

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

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

RumaleにFastICAによる独立成分分析を追加した

はじめに

RumaleにFastICAによる独立成分分析(Independent Component Analysis, ICA)を追加した。ICAは、与えられたデータを、統計的に独立な元データが混合されてできたものと考え、元データを再構成したもの(独立な成分)を求める手法である。信号処理の信号分離から発展したもので、いまでは教師なし学習のベーシックな手法の一つとして定着している。これをRumaleにも実装した。

rumale | RubyGems.org | your community gem host

使い方

信号分離を例にFastICAの使い方を示す。データのplotにNumo::Gnuplotを使いたいので、Rumaleとあわせてインストールする。FastICAの実装では、Numo::Linalgを用いたのでこれもインストールする。

$ gem install rumale numo-linalg numo-gnuplot
$ brew install openblas gnuplot # 必要に応じて

サンプルコードは以下のようになる。2つの原信号(本来は未知で事前に知ることができない)がなんやかんやで混ざり、3つのセンサで観測されたとする(これが入力データとなる)。この3つの観測信号から、FastICAにより、2つの原信号を復元(推定)する。

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

# 原信号を作成する.
# サイン波と矩形波にちょっとノイズを加えたもの(本来は未知の信号).
n_samples = 1000
t = Numo::DFloat.linspace(0, 10, n_samples)
s1 = Numo::NMath.sin(2 * t)
s2 = Numo::NMath.sin(3 * t).sign
source = Numo::NArray.vstack([s1, s2]).transpose
source += 0.03 * Rumale::Utils.rand_normal([n_samples, 2], Random.new(1))
source -= source.mean(0)
source /= source.stddev(0)

# 観測信号(原信号が混ざってマイクとかで観測された感じの信号)
# 混合された2つの原信号は、3つのセンサで、3信号として観測されたとする.
mixing_mat = 0.2 * Rumale::Utils.rand_normal([2, 3], Random.new(1))
observed = source.dot(mixing_mat)

# 独立成分分析により観測信号から原信号を復元する.
fica = Rumale::Decomposition::FastICA.new(n_components: 2, max_iter: 500, tol: 1e-8)
reconstructed = fica.fit_transform(observed)

# 各信号をプロットする.
# 原信号
Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'source.png')
  plot([source[true, 0], with: 'lines', title: '1'],
       [source[true, 1], with: 'lines', title: '2'])
end
# 観測信号
Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'observed.png')
  plot([observed[true, 0], with: 'lines', title: '1'],
       [observed[true, 1], with: 'lines', title: '2'],
       [observed[true, 2], with: 'lines', title: '3'])
end
# 復元信号
Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'reconst.png')
  plot([reconstructed[true, 0], with: 'lines', title: '1'],
       [reconstructed[true, 1], with: 'lines', title: '2'])
end

これを実行すると以下の三種類の信号の画像が得られる。

原信号のサイン波と矩形波が混合され、3つのセンサで観測された感じになっている。

f:id:yoshoku:20191014131900p:plain
原信号. ちょっとノイズが加わったサイン波と矩形波.

f:id:yoshoku:20191014132147p:plain
観測信号. 2つの原信号が混合され3つのセンサで観測されたイメージ.

この観測信号から、FastICAにより原信号に類似した信号を復元できている。

f:id:yoshoku:20191014132419p:plain
復元信号. もとのサイン波と矩形波がある程度復元できている.

ICAでは、独立性のみを頼りに推定を行う。注意点として、順序や大きさが原信号と異なるものが得られる場合があることがある(今回はわりと上手くいった)。

おわりに

ICAは、上記のサンプルで3信号から2信号を復元したように(3次元のデータから2次元の成分データを抽出したように)、次元削減として使うことも可能である。ICAには、主成分分析のような直交条件がないため、よりデータ分布にそった主軸をえることができる。ICA自体の研究は、まだまだ続いていて、様々な発展型が提案されている。

github.com

Rumaleのガウス混合モデルで共分散の種類を選べるようにした

はじめに

Rumaleのガウス混合モデル(Gaussian Mixture Model, GMM)によるクラスタリングでは、各クラスタの共分散行列には、対角要素のみの対角行列を使用していた。

yoshoku.hatenablog.com

これは、逆行列行列式の計算に特別なアルゴリズムを使用しないためであった。Rumaleのver. 0.13系では、Numo::Linalgへの対応を進めており、逆行列行列式の計算が可能になったため、共分散行列の全要素を用いる方法を実装した。

rumale | RubyGems.org | your community gem host

使い方

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

$ gem install rumale

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

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

Numo::Linalgもインストールする。Numo::Linalgがロードされていなければ、共分散行列の全要素を使ったバージョンは使用できない。

$ brew install openblas
$ gem install numo-linalg

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

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

# 楕円の大きさを求める
def ellipse_size_full(covar)
  v, = Numo::Linalg.eigh(covar)
  nv = 2 * Math.sqrt(2) * Numo::NMath.sqrt(v)
  nv.to_a
end

# 楕円の回転角を求める
def ellipse_angle_full(covar)
  _v, w = Numo::Linalg.eigh(covar)
  u = w[0, true] / Numo::Linalg.norm(w[0, true])
  angle = Numo::NMath.atan2(u[1], u[0])
  (180.0 * angle[0] / Math::PI).to_i
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でクラスタリングする.
# covariance_typeで共分散行列の要素をどう使うかを指定できる.
gmm = Rumale::Clustering::GaussianMixture.new(n_clusters: 3, covariance_type: 'full', 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_full(gmm.covariances[2, 0..1, 0..1]),
      angle: ellipse_angle_full(gmm.covariances[2, 0..1, 0..1 ]),
      front: true, fs: true, empty: true, bo: 1)
  set(:object, 2, 'ellipse',
      center: [gmm.means[0, 0], gmm.means[0, 1]],
      size: ellipse_size_full(gmm.covariances[0, 0..1, 0..1]),
      angle: ellipse_angle_full(gmm.covariances[0, 0..1, 0..1]),
      front: true, fs: true, empty: true, bo: 2)
  set(:object, 3, 'ellipse',
      center: [gmm.means[1, 0], gmm.means[1, 1]],
      size: ellipse_size_full(gmm.covariances[1, 0..1, 0..1]),
      angle: ellipse_angle_full(gmm.covariances[1, 0..1, 0..1]),
      front: true, fs: true, empty: true, bo: 3)
  plot(*plots)
end

これを実行すると、以下のような画像を得られる。以前の対角要素のみを使う方法(covariance_type: 'diag')と比較して、楕円の角度が考慮されており、よりクラスタの形状を捉えることができている。

f:id:yoshoku:20191003011327p:plain
共分散行列の全要素を使用する場合

f:id:yoshoku:20190615132952p:plain
共分散行列の対角要素のみを使用する場合

共分散行列の全要素を使用する場合、各クラスタの共分散行列をすべて保持する必要があるためメモリをより多く使用する。また、逆行列行列式の計算のため実行時間も遅くなる。

おわりに

その他、因子分析(Factor Analysis)を追加したり、スペクトルクラスタリング(Spectral Clustering)を追加したり、HDBSCANを追加したりしている。これで、ひととおり、Rumaleに実装したアルゴリズムで、Numo::Linalgを使用してパワーアップできるものは対応できた気がする。

ちなみに、会社ではマネ〜ジャ〜兼エンジニアで、タスクフォースな仕事が忙しくなりそうで、これから平日のOSS開発は難しくなる。これまで、月に4回ほどのペースでRumaleをバージョンアップしてきたが、月に1〜2回とかに絞ろうと思う。Rumaleをはじめ、Rubyでデータ分析・機械学習なライブラリの開発が、昼間の仕事としてできれば良いのだけど。幸いベーシックなアルゴリズムは実装できているので、ガウス過程か、計量学習か、あらたにModuleを切るようなものを、じっくり作ろうかな〜といったところ。

github.com