洋食の日記

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

Rumaleと同様のインターフェースでtorch.rbが使えるRumale::Torchを作った

はじめに

Rubyには深層学習をあつかうgemがいくつかある。そのなかでtorch.rbは、riceを利用してLibTorchをbinding libraryしたものである。LibTorchは、PyTorchのC++ APIといえるもので、version 1.5からstableなAPIとして提供されており、今後の発展と開発の継続が期待できる。Rumaleでも何かできないかと考え、torch.rbをwrapして、Rumaleに実装された機械学習アルゴリズムと同様に、fitとpredictで分類や回帰ができるものを作ってみた。

rumale-torch | RubyGems.org | your community gem host

使い方

torch.rbは、LibTorchを必要とする。Macでhomebrewを使っていれば、以下のコマンドでインストールできる。 ここで、一緒にインストールしているautomakeは、torch.rbが依存しているriceで必要となる。 その他の環境でのインストールは、torch.rbのREADMEに詳しく書かれている

$ brew install automake libtorch

Rumale::Torchはgemコマンドでインストールできる。Runtime依存で、Rumaleとtorch.rbがインストールされる。

$ gem install rumale-torch

torch.rbとRumale::Torchを使った、単純な多層パーセプトロンによる分類の例を示す。 まずは、実験のためのデータセットをダウンロードする。 LIBSVM Dataにある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

訓練のためのコードは、以下の通りである。

require 'rumale'
require 'rumale/torch'

# torch.rbの乱数の種を固定する.
Torch.manual_seed(1)

# 使用するデバイスを定義する. 
device = Torch.device('cpu')

# 訓練データを読み込む.
# pendigitsデータセットは16次元の特徴ベクトルが10個のクラスに分けられている.
x, y = Rumale::Dataset.load_libsvm_file('pendigits')

# torch.rbの作法に従ってニューラルネットを定義する.
class MyNet < Torch::NN::Module
  def initialize
    super
    @dropout = Torch::NN::Dropout.new(p: 0.5)
    @fc1 = Torch::NN::Linear.new(16, 128)
    @fc2 = Torch::NN::Linear.new(128, 10)
  end

  def forward(x)
    x = @fc1.call(x)
    x = Torch::NN::F.relu(x)
    x = @dropout.call(x)
    x = @fc2.call(x)
    Torch::NN::F.softmax(x)
  end
end

# 定義したニューラルネットを作成する.
net = MyNet.new.to(device)

# ニューラルネットをRumale::Torchに渡して分類器を作成する.
classifier = Rumale::Torch::NeuralNetClassifier.new(
  model: net, device: device,
  batch_size: 10, max_epoch: 50, validation_split: 0.1,
  verbose: true
)

# 分類器を学習する.
classifier.fit(x, y)

# torch.rbとRumale::Torchのものそれぞれ, 学習した分類器を保存する.
Torch.save(net.state_dict, 'pendigits.pth')
File.binwrite('pendigits.dat', Marshal.dump(classifier))

これを実行すると、torch.rbで定義した多層パーセプトロンによる分類器が学習される。 verboseをtrueとしたので、各epochでロス関数の値などが表示される。

$ ruby train.rb
epoch:  1/50 - loss: 0.2073 - accuracy: 0.3885 - val_loss: 0.2074 - val_accuracy: 0.3853
epoch:  2/50 - loss: 0.1973 - accuracy: 0.4883 - val_loss: 0.1970 - val_accuracy: 0.4893
epoch:  3/50 - loss: 0.1962 - accuracy: 0.4997 - val_loss: 0.1959 - val_accuracy: 0.5013

...

epoch: 50/50 - loss: 0.1542 - accuracy: 0.9199 - val_loss: 0.1531 - val_accuracy: 0.9293

次に、テストデータセットのラベルを推定する。コードは以下の通りである。

require 'rumale'
require 'rumale/torch'

# 訓練のときと同様にtorch.rbの作法に従いニューラルネットを定義する.
class MyNet < Torch::NN::Module
  def initialize
    super
    @dropout = Torch::NN::Dropout.new(p: 0.5)
    @fc1 = Torch::NN::Linear.new(16, 128)
    @fc2 = Torch::NN::Linear.new(128, 10)
  end

  def forward(x)
    x = @fc1.call(x)
    x = Torch::NN::F.relu(x)
    # x = @dropout.call(x)
    x = @fc2.call(x)
    Torch::NN::F.softmax(x)
  end
end

# 定義したニューラルネットのインスタンスを作成し,
# 保存したものを読み込む.
net = MyNet.new
net.load_state_dict(Torch.load('pendigits.pth'))

# Rumale::Torch側も学習したものを読み込む.
# model = でニューラルネットをセットする.
classifier = Marshal.load(File.binread('pendigits.dat'))
classifier.model = net

# テストデータセットを読み込む.
x_test, y_test = Rumale::Dataset.load_libsvm_file('pendigits.t')

# テストデータのラベルを推定する.
p_test = classifier.predict(x_test)

# 評価のために正確度を計算される.
accuracy = Rumale::EvaluationMeasure::Accuracy.new.score(y_test, p_test)
puts(format("Accuracy: %2.1f%%", accuracy * 100))

これを実行すると、テストデータでの分類の正確度が出力される。

$ ruby test.rb
Accuracy: 91.2%

回帰のためのNeuralNetworkRegressorもあり、使い方は同様である。 torch.rbで定義したニューラルネットを渡すと、Rumaleと同様に、fitとpredictで学習と推定ができる。 torch.rbでは、行列などの表現にTensorクラスを利用するが、Rumale::Torchを利用している限りは、Numo::NArrayだけでよい。

おわりに

PyTorchは、柔軟性が高く、深層学習の実装において、細かいところまで作り込むことができる。そのぶん、コードの記述量は多く、多層パーセプトロンのようなシンプルなものでも、コードが冗長なものになる(torch.rbでも同様である)。これに対して、PyTorchLightningやskorchといったものが開発されている。深層学習ライブラリは、Theanoの頃から、Lasagneやnolearn、Tensorflow統合前のKeras、Tensorflowではtflearnといった、記述を容易にするライブラリが開発されている。Rumale::Torchもこの流れのなかにあるが、できることは全然ないといっていいほど少ない状態なので、今後も地道に開発を続けていく。

github.com

Rumaleに回帰分析のための計量学習手法MLKRを追加した

はじめに

Ruby機械学習ライブラリであるRumaleに、回帰分析のための計量学習手法である Metric Leaning for Kernel Regression (MLKR) を追加し、ver. 0.22.1 としてリリースした。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。主成分分析の固有値分解のためにNumo::Linalgを、実行結果の描画にNumo::Gnuplotを使うので、一緒にインストールする。

$ gem install rumale numo-linalg numo-gnuplot

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

$ brew install gnuplot openblas

MLKRは、教師あり特徴変換・次元削減と捉えることができる。後述するが、目的変数に合わせたデータ分布になるような変換・射影を行う。Rumaleではfitメソッドで学習し、transformメソッドで変換・射影を行う。

まずは、テストデータを主成分分析で変換した例を示す。

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

def make_regression(n_samples: 1000, n_features: 100, n_informative: 10, n_targets: 1)
  n_informative = [n_features, n_informative].min

  rng = Random.new(42)
  x = Rumale::Utils.rand_normal([n_samples, n_features], rng)

  ground_truth = Numo::DFloat.zeros(n_features, n_targets)
  ground_truth[0...n_informative, true] = 100 * Rumale::Utils.rand_uniform([n_informative, n_targets], rng)
  y = x.dot(ground_truth)
  y = y.flatten

  rand_ids = Array(0...n_samples).shuffle(random: rng)
  x = x[rand_ids, true].dup
  y = y[rand_ids].dup

  [x, y]
end

def mds_visualize(x, y, filename: 'tmp.png')
  # 多次元尺度構成法で二次元にマッピングする
  mds = Rumale::Manifold::MDS.new(random_seed: 2)
  z = mds.fit_transform(x)
  # Gnuplotで可視化結果を出力する
  y = (y - y.min) / (y.max - y.min)
  y = Numo::Int32.cast((y * 5.9).floor)
  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: filename)
    plot('[-6:6] [-6:6]', *plots)
    plot(*plots)
  end
end

# テストデータを主成分分析で変換する
x, y = make_regression(n_samples: 500, n_informative: 4, n_features: 10)
pca = Rumale::Decomposition::PCA.new(n_components: 10)
z = pca.fit_transform(x, y)

# 結果を可視化する
mds_visualize(z, y, filename: 'pca.png')

描画の便宜上、目的変数を5段階に区切った。異なる目的変数を持つデータが混在しているのがわかる。

f:id:yoshoku:20201205095921p:plain

同様のデータをMLKRで変換してみる。

# テストデータをMLKRで変換する
x, y = make_regression(n_samples: 500, n_informative: 4, n_features: 10)
mlkr = Rumale::MetricLearning::MLKR.new(n_components: nil, init: 'pca')
z = mlkr.fit_transform(x, y)

# 結果を可視化する
mds_visualize(z, y, filename: 'pca.png')

主成分分析の場合と比較して、目的変数にそってデータが配置されているのがわかる。このMLKRで変換したデータで回帰を行えば、決定係数などが向上する。

f:id:yoshoku:20201205100322p:plain

MLKRのアルゴリズム

MLKRの目的関数は、とてもシンプルなもので、以下のようになる。カーネル関数(論文中ではガウスカーネルが用いられる、Rumaleの実装でもそのようにした)を重みとした目的変数の重み平均を推定値とし、目的変数と推定値の自乗誤差を目的関数とする。

\mathcal{L}=\sum_{i}(y_i-\hat{y}_i)^{2}
\hat{y}_{i}=\frac{\sum_{j\neq i} y_{j} k_{ij}}{\sum_{j\neq i}k_{ij}}

ガウスカーネルの計算には、二点間の距離が必要になるが、これがMLKRにより変換・射影したベクトル間の距離となる。こうすることで、カーネル関数による回帰分析において、誤差が小さくなるような変換を得られる。

proceedings.mlr.press

おわりに

計量学習では分類を想定したものが多い。MLKRは回帰分析を対象としていて、おもしろいなと思って実装した。Rumaleの計量学習の実装は、一旦これで打ち止めとしたい。計量学習自体が、教師なし・教師あり・半教師あり学習(もちろん深層学習も)と様々あり、きりがないというか、全てをカバーするなら別gemにしたほうが良いと考えた。MLKRの最適化の実装には、lbfgsb.rbを用いた(我ながら作ってよかった)。今後もRumaleでは、最適化にL-BFGS (-B) が使えるようなアルゴリズムを追加しようと考えている。

github.com

Rumaleのロジスティック回帰の最適化手法にL-BFGSを追加した

はじめに

Rumaleのロジスティック回帰のsolverにL-BFGSを追加し、これをデフォルトとして、ver. 0.22.0 としてリリースした。Rumaleのロジスティック回帰では、最適化に確率的勾配降下法(Stochastic Gradient Descent, SGD)を用いていた。データによって、反復回数やミニバッチの大きさなど、ハイパーパラメータを調整する必要があり、手軽に使える感じではなかった。また、Scikit-learnのロジスティック回帰では、L-BFGSを用いるのがデフォルトになっている(以前はLIBLINEARによるものがデフォルトだった)。そこで、Rumaleのロジスティック回帰にもL-BFGSのものを追加した。また、L-BFGSを用いる場合には、多値分類を多項ロジスティック回帰で実現する。

rumale | RubyGems.org | your community gem host

使い方

Rumaleをインストールすれと、L-BFGSのために依存でlbfgb.rbも一緒にインストールされる。

$ gem install rumale

多値分類の例として、LIBSVM DATAのpendigitsデータセットを分類する。

require 'rumale'

# LIBSVM形式のpendigitsデータを読み込む.
x, y = Rumale::Dataset.load_libsvm_file('pendigits')

# ランダム分割で訓練とテストに分割する.
ss = Rumale::ModelSelection::ShuffleSplit.new(n_splits: 1, test_size: 0.2, random_seed: 1)
train_ids, test_ids = ss.split(x, y).first
x_train = x[train_ids, true]
y_train = y[train_ids]
x_test = x[test_ids, true]
y_test = y[test_ids]

# ロジスティック回帰による分類器を学習する.
cls = Rumale::LinearModel::LogisticRegression.new
cls.fit(x_train, y_train)

# テストデータで正確度を計算する.
puts "Accuracy: %.3f" % cls.score(x_test, y_test)
$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/pendigits
$ time ruby logit.rb
Accuracy: 0.956
ruby logit.rb  1.15s user 0.16s system 99% cpu 1.317 total

従来のSGDを用いた学習を行う場合はsolverオプションに 'sgd' を渡す。

# SGDはNumo::NArrayでの行列積や内積を行うので, 
# 高速化のために, Numo::Linalgをロードして, OpenBLASを叩くようにする.
require 'numo/openblas'
# SGDでは多値分類はone-vs-rest法を用いる. 
# 各2値分類器の学習を並行して行えるようにParallel gemを使う.
require 'parallel'

...

cls = Rumale::LinearModel::LogisticRegression.new(
  solver: 'sgd',
  learning_rate: 0.001, momentum: 0.9,
  max_iter: 1000, batch_size: 10,
  n_jobs: -1,
  random_seed: 1
)

...

SGDによる最適化の実行時間は、max_iterとbatch_sizeにより変化するが、Numo::LinalgやParallelを使ってもL-BFGSのよりも遅くなっている。

$ time ruby logit.rb
Accuracy: 0.935
ruby logit.rb  394.07s user 56.87s system 574% cpu 1:18.49 total

おわりに

月に一度はRumaleの新しいバージョンをリリースしたいと思っていたが、11月はギリギリになってしまった。最適化にL-BFGSを使うアイディアは以前からあったが、そのベースとなるL-BFGS-Bをgem化するのに、思ったよりも時間がかかってしまった。Numo::NArrayとNumo::Linalg、そしてlbfgs.rbを使えば、Rubyで多くの機械学習アルゴリズムを実装できると考える。Rumaleも色々と発展を予定している。

github.com

非線形最適化手法L-BFGS-BのGemを作成した

はじめに

非線形最適化手法の一つであるL-BFGS-Bは、scipy.optimize.minimizeのデフォルト手法として採用されているなど、最適化手法ではde facto standardの様な存在である。Rではoptim関数がサポートしていたり、Juliaではbindingライブラリがあったりするが、探した限りRubyにはなかったので作成した。

lbfgsb | RubyGems.org | your community gem host

使い方

インストールは、native extensionをビルドできれば、gemコマンドでインストールできる。別で外部ライブラリをインストールする必要はない。Lbfgsb.rbでは、ベクトルをNumo::NArrayで表現するので、一緒にインストールされる。

gem install lbfgsb

使い方は簡単というか、minimizeというmodule functionが一つあるだけである。

require 'lbfgsb'

result = Lbfgsb.minimize(
  fnc: 目的関数, jcb: 目的関数を微分したもの, 
  x_init: 初期ベクトル, args: 目的関数などへの引数
)

p ressult[:x] # 最適化したベクトル

例として、二値分類なロジスティック回帰を実装してみる。ちなみに、現在のScikit-learnのLogisticRegressionも、デフォルトではL-BFGS-Bで最適化を行う。

class LogisticRegression
  def fit(x, y)
    # 切片を得るために特徴ベクトルに1を継ぎ足す.
    xb = Numo::DFloat.hstack([
      x, Numo::DFloat.ones(x.shape[0]).expand_dims(1)
    ])
    # Lbfgsb.rbで最適化する.
    result = Lbfgsb.minimize(
      fnc: method(:obj_fnc), jcb: method(:d_obj_fnc), 
      x_init: xb.mean(0), args: [xb, y]
    )
    # 重みベクトルを得る.
    @weight_vector = result[:x]
  end

  def predict_proba(x)
    # どちらのクラスに属するかの確率を推定する.
    xb = Numo::DFloat.hstack([
      x, Numo::DFloat.ones(x.shape[0]).expand_dims(1)
    ])
    1.0 / (Numo::NMath.exp(-xb.dot(@weight_vector)) + 1.0)
  end

  def predict(x)
    # クラス確率をもとにラベルを付与する.
    probs = predict_proba(x)
    predicted = Numo::Int32.zeros(x.shape[0])
    predicted[probs >= 0.5] = 1
    predicted[probs <  0.5] =-1
    predicted
  end

  private

  # 目的関数
  def obj_fnc(w, x, y)
    Numo::NMath.log(1 + Numo::NMath.exp(-y * x.dot(w))).sum
  end

  # 目的関数を微分したもの. 勾配ベクトルを得る.
  def d_obj_fnc(w, x, y)
    (y / (1 + Numo::NMath.exp(-y * x.dot(w))) - y).dot(x)
  end
end

このロジスティック回帰に、適当なデータを与えて分類を行ってみる。今回は、LIBSVM Dataからsvmguide3というデータを取得した。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/svmguide3
$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/svmguide3.t

LIBSVM形式の読み込みと、正確度の計算にはRumaleを使う。

$ gem install rumale
require 'rumale'

# 訓練・テストデータを読み込む.
x, y = Rumale::Dataset.load_libsvm_file('svmguide3')
x_test, y_test = Rumale::Dataset.load_libsvm_file('svmguide3.t')

# ロジスティック回帰による分類器を学習する.
logit = LogisticRegression.new
logit.fit(x, y)

# ラベルを推定する.
predicted = logit.predict(x_test)

# 正確度を計算し, 結果を出力する.
evaluator = Rumale::EvaluationMeasure::Accuracy.new
puts 'ground truth:'
pp y_test
puts 'predicted:'
pp predicted
puts("\nAccuracy: %.1f%%" % (100.0 * evaluator.score(predicted, y_test)))

実行すると以下のようになる。 同様のことを、scikit-learnのLogisticRegressionでも行うと、正確度が61.0%となった。 L-BFGS-Bにより最適化を行うロジスティック回帰を実装できた。 ※scikit-learnの場合、L2正則化項がつくので、ハイパーパラメータを C=1000, max_iter=1000 と調整した。

$ ruby logit.rb
ground truth:
Numo::DFloat#shape=[41]
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, ...]
predicted:
Numo::Int32#shape=[41]
[1, -1, 1, 1, 1, -1, 1, 1, -1, 1, 1, 1, 1, 1, -1, 1, -1, 1, -1, 1, -1, -1, ...]

Accuracy: 61.0%

苦労話

L-BFGS-B自体は、FORTRAN 77で書かれている。Pythonには、F2PYという、PythonFortranとをつなぐinterfaceを生成するものがあり、わりと簡単にFortranで書かれた科学計算ライブラリを扱える。Rubyにはそういったものがないので、L-BFGS-BをC言語に翻訳して、native extensionsで叩くことにした。C言語への翻訳には、まず定番のF2Cを使用した。F2Cで生成したC言語のコードは、当然ながらF2Cに依存する箇所がある。例えば、文字列の比較に、strncmp関数を使わずに、s_cmpという独自の関数を使用する。これらF2C独自の関数を、標準的なC言語で書き換えて、F2C依存を消していった。機械的に生成されたコードなため可読性が低く、なかなか骨の折れる作業であった。

おわりに

最適化手法のライブラリがあると、機械学習手法を実装するのが簡単になることが多い。Lbfgs.rbも、Rumaleの開発であるといいな、と思ったので作成した。以前に作成した最適化手法gemのMopti内のアルゴリズムの一種として実装しようかと思ったが、binding libraryとして単体であったほうが、取り回しが良いかなと思って、このような形にした。L-BFGS-B自体がバージョンアップすることはなさそうなので、今後の開発は、bugfixやC言語なコードの修正が、主となる予定である。

github.com

Rubyな形態素解析器Suikaのトライをdarts-clone.rbにした

はじめに

Ruby形態素解析Suikaの単語検索のためのトライを、先日リリースしたdarts-clone.rbに変更してバージョンを上げた。

suika | RubyGems.org | your community gem host

使い方

変更は内部的なものなので、Suikaの使い方自体は変わらない。

gem install suika

Suika::Taggerをnewして、parseメソッドに文字列を与えると形態素解析の結果をArrayで返す。今のところ、これ以外の機能はない。辞書はIPADICをベースに、令和の年号を足したものである。

irb(main):001:0> require 'suika'
=> true
irb(main):002:0> tagger = Suika::Tagger.new
irb(main):003:0> puts tagger.parse('時代は平成から令和に変わる')
時代    名詞,一般,*,*,*,*,時代,ジダイ,ジダイ
は      助詞,係助詞,*,*,*,*,は,ハ,ワ
平成    名詞,固有名詞,一般,*,*,*,平成,ヘイセイ,ヘイセイ
から    助詞,格助詞,一般,*,*,*,から,カラ,カラ
令和    名詞,固有名詞,一般,*,*,*,令和,レイワ,レイワ
に      助詞,格助詞,一般,*,*,*,に,ニ,ニ
変わる  動詞,自立,*,*,五段・ラ行,基本形,変わる,カワル,カワル
=> nil
irb(main):004:0> tagger.parse('時代は平成から令和に変わる')
=> ["時代\t名詞,一般,*,*,*,*,時代,ジダイ,ジダイ", "\t助詞,係助詞,*,*,*,*,は,ハ,ワ", "平成\t名詞,固有名詞,一般,*,*,*,平成,ヘイセイ,ヘイ
セイ", "から\t助詞,格助詞,一般,*,*,*,から,カラ,カラ", "令和\t名詞,固有名詞,一般,*,*,*,令和,レイワ,レイワ", "\t助詞,格助詞,一般,*,*,*,
に,ニ,ニ", "変わる\t動詞,自立,*,*,五段・ラ行,基本形,変わる,カワル,カワル"]

変更箇所

大きな変更は、トライをrambling-trieからdarts-clone.rbにしたことである。Suikaでは、辞書の読み込みの遅さが、問題になっていた。darts-clone.rbはサイズがコンパクトになる利点があり、効果があると期待した。私のメインPCは、MacBook Early 2016だが、雑に読み込み時間を数回測ってみて平均したところ、ver. 0.1.4で8.4秒、ver. 0.2.0で5.7秒だった。 その他、Arrayのappendメソッドを使っていたところをpushにしたり、keyword_init引数を使ったStructを普通のクラスに置き換えたりした。これで、Ruby 2.4以前でも動く様になったと思う(Ruby本体の公式サポートが切れているのでCIではテストしていない)。

おわりに

darts-clone.rbはnative extensionsを使用しているので、それに依存するSuikaがPure Rubyなのか怪しいが、辞書の読込時間が改善したので良しとしたい。ひとまず辞書まわりはコレくらいにして、基本的なところはできた。こうなってくると、Ruby自然言語処理ライブラリも欲しくなってくるが、どういうものが良いんだろうか?といったところ。