洋食の日記

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

画像をNumo::NArrayで扱えるMagroにフィルタを追加した

はじめに

Rubyの画像処理ライブラリMagroに、フィルタをかけるメソッドを追加し、version 0.3.0としてリリースした。

magro | RubyGems.org | your community gem host

使い方

Magroは画像ファイルの読み書きにlibpngとlibjpegを必要とする。Magroをインストールすると、依存関係でNumo::NArrayがインストールされる。

$ brew install libpng libjpeg
$ gem install magro

フィルタを使うことでエンボス加工っぽいことをしてみる。

require 'magro'

img = Magro::IO.imread('sample.jpg')

kernel = Numo::DFloat[
  [ 1, 2, 1],
  [ 0, 0, 0],
  [-1,-2,-1]
]
img = Magro::Filter.filter2d(img, kernel, scale: 1, offset: 128)

Magro::IO.imsave('emboss.png', img)

Magro::Filter.filter2dメソッドに、画像とフィルタカーネルを渡す。引数としてscaleとoffsetがある。scaleはフィルタカーネルを正規化する値、offsetはフィルタ後に足す値となる。

はてなでアイコンに使用しているハンバーグの画像を、上記スクリプトに入れると次のようになる。

f:id:yoshoku:20200510013338p:plain

おわりに

追加したのは、フィルタをかけるためのメソッドのみで、ぼかしや先鋭化といったメソッドは追加しなかった。最初は、Pillowライクなものも検討したが、あれこれとフィルタを駆使するようなこともないかと思い、すっかり外してしまった。フィルタをかけるための畳み込みの実装には、いわゆるim2colを使用した(画像のブロックを並べて行列表現にすることで、画像とフィルタの畳み込みを行列積で実現する技法)。画像をNumo::NArrayで表現しているので、容易に実装できた。今後もできるだけミニマルに、できるだけRubyで実装してみようと思う。

github.com

画像をNumo::NArrayで扱えるMagroに画像のサイズ変更を追加した

はじめに

Magroという画像をNumo::NArrayで扱える(読み書きできる)Gemを作っていたが、いろいろと時間がとれず更新が滞っていた。

yoshoku.hatenablog.com

せっかくの連休なので、Bilinear補完によるサイズ変更を追加した。基本的なとこから実装するならばNearest Neighbor法であるし、クオリティを重視するならばLanczos法であるが、実装しやすさからBilinear補完にした。OpenCVのresizeのデフォルトがBliinearなのもある。

使い方

Magroは画像ファイルの読み書きにlibpngとlibjpegを必要とする。Magroをインストールすると、依存関係でNumo::NArrayがインストールされる。

$ brew install libpng libjpeg
$ gem install magro

画像の読み込み・サイズ変更・保存までは以下のようになる。

require 'magro'

img = Magro::IO.imread('hoge.png')

resized = Magro::Transform.resize(img, height: 128, width: 128)

Magro::IO.imsave('hoge_resized.png', resized)

おわりに

Bilinear補完によるサイズ変更の実装では、Extensionは使わずにすべてRubyで書いてある。画像をNumo::NArrayで扱える便利さを、自身で体験することになった。次に追加するとすればフィルタかな。

github.com

Rumaleにカーネル判別分析を追加した

はじめに

Rumaleにカーネル判別分析を追加して、これを version 0.18.4 としてリリースした。カーネル判別分析は、version 0.18.0 で追加したフィッシャー判別分析 (Fisher Discriminant Analysis, FDA) を、カーネル法により非線形化したものである。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。線形代数の計算にNumo::Linalg、実行結果の描画にNumo::Gnuplotを使いたいので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

Numo::Linalgのためにopenblasを、Numo::Gnuplotのためにgnuplotをインストールする。

$ brew install openblas gnuplot

カーネル判別分析は、教師あり次元削減手法とも捉えられる。一般に、カーネル判別分析により写像した部分空間で、k-近傍法で分類するとこが想定されている。Rumaleではfitメソッドで学習し、transformメソッドで次元削減を行う。

LIBSVM Dataで公開されているUCI Vowelデータセットを利用して、次元削減してk-近傍法で分類する例を示す。

カーネル判別分析では、カーネル関数の選択と正則化パラメータが、ハイパーパラメータとなる。カーネル関数自体にパラメータがあれば、それも調整する必要がある。

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

# 分類器には, 最近傍法を使用する.
est = Rumale::NearestNeighbors::KNeighborsClassifier.new(n_neighbors: 1)

# データセットを読み込み, 訓練・テストデータに分割する.
x, y = Rumale::Dataset.load_libsvm_file('vowel.scale')

x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.4, random_seed: 1)

# 判別分析法で二次元空間に射影し, 部分空間で分類器の性能を評価する.
fda = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(n_components: 2)
fda.fit(x_train, y_train)
z_train = fda.transform(x_train)
z_test = fda.transform(x_test)

est.fit(z_train, y_train)
fda_score = est.score(z_test, y_test)

# カーネル判別分析法で二次元空間に写像し, 部分空間で分類器の性能を評価する.
kfda = Rumale::KernelMachine::KernelFDA.new(n_components: 2, reg_param: 1e-8)

gamma = 0.05
kmat_train = Rumale::PairwiseMetric.rbf_kernel(x_train, nil, gamma)
kfda.fit(kmat_train, y_train)
z_train = kfda.transform(kmat_train)

kmat_test = Rumale::PairwiseMetric.rbf_kernel(x_test, x_train, gamma)
z_test = kfda.transform(kmat_test)

est.fit(z_train, y_train)
kfda_score = est.score(z_test, y_test)

# それぞれの正確度を出力する.
puts "Accuracy (FDA): #{fda_score}"
puts "Accuracy (KFDA): #{kfda_score}"

# 訓練データセットの二次元空間を散布図としてプロットする.
plots = y_train.to_a.uniq.sort.map do |l|
  [z_train[y_train.eq(l), 0], z_train[y_train.eq(l), 1], t: l.to_s]
end

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

これを実行した結果が次のようになる。カーネル判別分析のほうが、優れた分類性能を得られている。

$ ruby kfda.rb
Accuracy (FDA): 0.6445497630331753
Accuracy (KFDA): 0.9146919431279621

部分空間のサンプルを可視化したものが次のようになる。Vowelデータセットは、11クラスからなるデータセットだが、判別分析 (FDA) よりも、カーネル判別分析 (KFDA) のほうが、部分空間で同一クラスのサンプル同士が集まって位置していることがわかる。

f:id:yoshoku:20200411153645p:plain
FDA

f:id:yoshoku:20200411153702p:plain
KFDA

どんなデータセットでも、このように上手く行くわけではないが、判別分析で高い分類性能が得られない場合などに、カーネル判別分析を試してみる価値はある。

おわりに

Rumaleに、version 0.18.0 で判別分析を追加したので、カーネル判別分析も追加してみた。カーネル判別分析は、非線形な教師あり次元削減手法でもあるので、多くの場合で判別分析よりも良い結果を得られると思う。一方で、カーネル関数の選択と、ハイパーパラメータの調整には気を配る必要がある。また、データセットが大きいと、カーネル関数の計算などで計算時間を必要とする。

github.com

RumaleにNeighbourhood Component Analysisを追加した

はじめに

Rumaleに計量学習のNeighbourhood Component Analysis (NCA) フィッシャー判別分析 (Fisher Discriminant Analysis, FDA) を追加した。これを version 0.18.0 としてリリースした。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。線形代数の計算にNumo::Linalg、実行結果の描画にNumo::Gnuplotを使いたいので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

Numo::Gnuplotのためにgnuplotをインストールする。

$ brew install gnuplot

NCAのような計量学習は、教師あり特徴変換・次元削減と捉えることができる。一般にk-近傍法で分類するとこが想定されていて、弁別性の高い変換・部分空間への射影を行う。Rumaleではfitメソッドで学習し、transformメソッドで変換・次元削減を行う。

合成データセットに対する次元削減の例を示す。データは以下のような、4つの円筒がならぶもので、高さがランダムになっている。k-近傍法による分類のためには、円の形が部分空間に保存されていると良い。

f:id:yoshoku:20200307112229p:plain

これを、主成分分析 (Principal Component Analysis, PCA)、FDA、NCAで2次元空間に射影するコードは次のようになる。

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

# 円筒の合成データを作成する.
x1, y1 = Rumale::Dataset.make_circles(1500, random_seed: 1)
x2, y2 = Rumale::Dataset.make_circles(1500, random_seed: 2)
x = x1.class.vstack([x1, x2 * 0.6])
y = y1.class.hstack([y1, y2 + 2])
x = x.class.hstack([x, 0.6 * Rumale::Utils.rand_normal([x.shape[0], 1], Random.new(3))])

# 合成データをプロットする.
plots = y.to_a.uniq.sort.map { |l| [x[y.eq(l), 0], x[y.eq(l), 1], x[y.eq(l), 2], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'circles.png')
  set(ticslevel: 0)
  splot(*plots)
end

# 各種手法で二次元空間に射影する.
#z = Rumale::Decomposition::PCA.new(
#  n_components: 2, solver: 'evd').fit_transform(x)
#z = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(
#  n_components: 2).fit_transform(x, y)
z = Rumale::MetricLearning::NeighbourhoodComponentAnalysis.new(
  n_components: 2, init: 'pca', max_iter: 20, verbose: true, random_seed: 1
).fit_transform(x, y)

# 射影したデータをプロットする.
plots = y.to_a.uniq.sort.map { |l| [z[y.eq(l), 0], z[y.eq(l), 1], t: l.to_s] }

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

これで次元削減した結果が以下のようになる。PCAは、教師なし次元削減であるためラベル情報が反映されず、円筒を横に倒したようなかたちとなっている。FDAは、教師ありでラベル情報が与えられているが、それでもPCAと同様の結果となっている。NCAだけが、円の形を捉えることができ、k-近傍法で正しくラベルを推定できるようになっている。

f:id:yoshoku:20200307114445p:plain
主成分分析

f:id:yoshoku:20200307114510p:plain
FDA

f:id:yoshoku:20200307114529p:plain
NCA

NCAが最も良いように見える。とはいえ、やはり得手不得手とするデータは存在する。離れて位置する3つの楕円体からなるデータを試す。

f:id:yoshoku:20200307114937p:plain

これを、PCA、FDA、NCAで2次元空間に射影するコードは次のようになる。

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

# 3つの団子からなる合成データを作成する.
centers = Numo::DFloat[[0, 5], [-5, -5], [5, -5]]
x, y = Rumale::Dataset.make_blobs(300, centers: centers, cluster_std: 0.5, random_seed: 1)
x = x.class.hstack([x, 4 * Rumale::Utils.rand_normal([x.shape[0], 1], Random.new(1))])

# 合成データをプロットする.
plots = y.to_a.uniq.sort.map { |l| [x[y.eq(l), 0], x[y.eq(l), 1], x[y.eq(l), 2], t: l.to_s] }

Numo.gnuplot do
  set(terminal: 'png')
  set(output: 'blobs.png')
  set(ticslevel: 0)
  splot(*plots)
end

# 各種手法で二次元空間に射影する.
#z = Rumale::Decomposition::PCA.new(n_components: 2, solver: 'evd').fit_transform(x)
#z = Rumale::MetricLearning::FisherDiscriminantAnalysis.new(n_components: 2).fit_transform(x, y)
z = Rumale::MetricLearning::NeighbourhoodComponentAnalysis.new(
  n_components: 2, init: 'random', max_iter: 10, verbose: true, random_seed: 1
).fit_transform(x, y)

# 射影したデータをプロットする.
plots = y.to_a.uniq.sort.map { |l| [z[y.eq(l), 0], z[y.eq(l), 1], t: l.to_s] }

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

次元削減した結果は以下のようになる。PCAは、緑色と青色のデータが混在してしまっている。FDAは、3つがキレイに離れていて、正しくラベルを推定できるようになっている。NCAは、3つのデータが離れてはいるが、FDAほどはキレイに離れていない。

f:id:yoshoku:20200307115554p:plain
PCA

f:id:yoshoku:20200307115618p:plain
FDA

f:id:yoshoku:20200307115637p:plain
NCA

おわりに

開発当初は機械学習の古典的な手法である、FDAを追加することを考えていた。しかし、discriminant analysisで名前空間を切っても広がりがないと思い、FDAを教師あり次元削減と捉え、metric learningで名前空間を切って、NCAとともに追加した。NCAの最適化では、scaled conjugate gradientを使用した。Pythonでは最適化にscipy.optimizeが使えるが、Rubyではそういったライブラリはあまりない。そこで最適化の部分だけgemに切り出し、これをRumaleから使うことにした。このgemも、徐々に代表的な手法を追加していき、育てていこうと思う。

github.com

github.com

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