洋食の日記

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

Rumaleに投票によるアンサンブル法を追加した

はじめに

Ruby機械学習ライブラリであるRumaleに、投票(voting)によるアンサンブル法を利用した分類器・回帰分析を追加して、ver. 0.22.4としてリリースした。

https://rubygems.org/gems/rumale/versions/0.22.4

使い方

Rumaleはgemコマンドでインストールできる。

$ gem install rumale

投票は、複数の推定器の推定結果を、多数決によって結合し、最終的な推定結果を得る。回帰分析を例に示す。まずは、線形回帰とランダム森による回帰分析を試す。データセットにはLIBSVM Dataのabaloneを用いた。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/regression/abalone_scale
require 'rumale'

# データセットを読み込む.
x, y = Rumale::Dataset.load_libsvm_file('abalone_scale')

# 訓練とテストに分割する.
x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.2, random_seed: 1)

# ランダム森を学習する.
reg = Rumale::Ensemble::RandomForestRegressor.new(random_seed: 1)
reg.fit(x_train, y_train)

# 決定係数 (1に近づくほどよい) により回帰の精度を評価する.
puts(format("R2-Score: %.4f", reg.score(x_test, y_test)))

# 同様に線形回帰を用いる.
reg = Rumale::LinearModel::LinearRegression.new(solver: 'lbfgs', random_seed: 1)
reg.fit(x_train, y_train)
puts(format("R2-Score: %.4f", reg.score(x_test, y_test)))

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

$ ruby regression.rb
R2-Score: 0.5209
R2-Score: 0.5353

意外なことに線形回帰のほうがスコアが高い。この二つを投票により組み合わせる。

# 新たに投票を加える. 組み合わせる推定器はestimatorsパラメータにHashで与える.
reg = Rumale::Ensemble::VotingRegressor.new(
  estimators: {
    rnd: Rumale::Ensemble::RandomForestRegressor.new(random_seed: 1),
    lin: Rumale::LinearModel::LinearRegression.new(solver: 'lbfgs', random_seed: 1)
  }
)
reg.fit(x_train, y_train)
puts('---')
puts(format("R2-Score: %.4f", reg.score(x_test, y_test)))

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

$ ruby regression.rb
R2-Score: 0.5209
R2-Score: 0.5353
---
R2-Score: 0.5664

組み合わせることで、スコアが大きく向上していることがわかる。 weightsパラメータで重みを与えることによる重み付き投票や、分類器(VotingClassifier)では、クラス確率によるソフト投票にも対応している。

Class: Rumale::Ensemble::VotingRegressor — Documentation by YARD 0.9.26

おわりに

投票による推定器の結合は、直感的にはたくさん推定器を用意すれば上手くいく気がするが、実際には、同じ問題を解いていることから推定器間に相関があり限界がある。しかし、ロジスティック回帰と決定木など、異なる仕組みの手法を組み合わせると効果が得られることがある。スタッキングと同様に、性能に伸び悩んでいる場合に試してみると良いと思う。投票(に限らずアンサンブル手法全般)については以下の書籍が詳しい。Rumaleのアンサンブル手法の実装でも参考にしている。

Rumaleに非負最小二乗法による回帰を追加した

はじめに

Ruby機械学習ライブラリであるRumaleに、非負最小二乗法(Non-nagtive Least Squares, NNLS)による線形回帰を追加して、ver. 0.22.3としてリリースした。

https://rubygems.org/gems/rumale/versions/0.22.3

使い方

Rumaleはgemコマンドでインストールできる。

$ gem install rumale

非負最小二乗法は、その名の通り、回帰係数の値が負にならないという制限がついた回帰手法である。 与えられた説明変数や目的変数が、負の値を含まない場合に有効であったりする。 普通の線形回帰で当てはまりが悪い場合に、検討してみると良い。 簡単なサンプルプログラムを、以下に示す。

require 'rumale'

rng = Random.new(1)
n_samples = 200
n_features = 100

# ランダムなデータを用意する.
x = Rumale::Utils.rand_normal([n_samples, n_features], rng)

# 非負の係数を用意する (本来は未知).
coef = Rumale::Utils.rand_normal([n_features, 1], rng)
coef[coef.lt(0)] = 0.0

# ノイズを加えつつダミーの目的変数を用意する.
noise = Rumale::Utils.rand_normal([n_samples, 1], rng)
y = x.dot(coef) + noise

# 訓練とテストに分割する.
x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.4, random_seed: 1)

# 非負最小二乗法を訓練し, 決定係数を計算する.
nnls = Rumale::LinearModel::NNLS.new(reg_param: 1e-4, random_seed: 1).fit(x_train, y_train)
puts(format("NNLS R2-Score: %.4f", nnls.score(x_test, y_test)))

# リッジ回帰を訓練し, 決定係数を計算する.
ridge = Rumale::LinearModel::Ridge.new(solver: 'lbfgs', reg_param: 1e-4, random_seed: 1).fit(x_train, y_train)
puts(format("Ridge R2-Score: %.4f", ridge.score(x_test, y_test)))

これを実行すると、以下の様になる。 決定係数が1に近いほど、回帰の当てはまりが良いといえる。 非負最小二乗法のほうが、リッジ回帰よりも決定係数の値が大きい。 わざと、回帰係数が非負のものを用意したので、当たり前なのだが、 非負値の制限を加えたほうが、当てはまりが良い場合がある。

$ ruby nnls.rb
NNLS R2-Score: 0.9478
Ridge R2-Score: 0.8602

また、非負最小二乗法による回帰は、スタッキングによる回帰において、 最終段階のメタ推定器に使用するのが有効であるという研究もある。

Breiman, L., "Stacked Regressions," Machine Learning, 24, pp. 49-64, 1996.

おわりに

非負最小二乗法による線形回帰を追加した。非負最小二乗法は、回帰係数の値域に制限を加えた回帰(Bounded-Variable Least-Squares)の一種といえる。Rumaleでは、非負最小二乗法を、制限付きの最適化ができるL-BFGS-Bで実装した。L-BFGS-Bのgemを作ったことで、実装できるアルゴリズムが広がった。その他、ver. 0.22.3では、native extensionsに、ガーベージコレクションによるsegmentation faultが起きる恐れのある箇所があり、それに対処したりした。

github.com

Rumaleにスタッキングによる分類器と回帰分析を追加した

はじめに

Ruby機械学習ライブラリであるRumaleに、スタッキング(stacking / stacked generalization)を用いた分類器と回帰分析を追加して、ver. 0.22.2としてリリースした。

rumale | RubyGems.org | your community gem host

使い方

Rumaleはgemコマンドでインストールできる。

$ gem install rumale

今回は高速化のためにparallelとnumo-openblasもインストールする(必須ではない)。

$ gem install parallel numo-openblas

スタッキングは、アンサンブル手法の一種で、一般的に学習器を二段階構成でつなげる。複数の学習器(first-level learner)を訓練し、それらの推定結果を特徴量として、最終的な推定をおこなう学習器(second-level learner / meta-leaner)を訓練する。これを実装しようとすると、割と大変というか、コードが冗長なものになる。そこで、専用のものを作った(scikit-learnでも ver. 0.22 で追加された)。

回帰分析を例に使ってみる。まずは、ランダム森による回帰分析を試す。データセットにはLIBSVM Dataのabaloneを用いた。

$ wget https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/regression/abalone_scale
require 'numo/openblas'
require 'parallel'
require 'rumale'

# データセットを読み込む.
x, y = Rumale::Dataset.load_libsvm_file('abalone_scale')

# 訓練とテストに分割する.
x_train, x_test, y_train, y_test = Rumale::ModelSelection.train_test_split(x, y, test_size: 0.2, random_seed: 1)

# ランダム森を学習する.
reg = Rumale::Ensemble::RandomForestRegressor.new(n_jobs: -1, random_seed: 1)
reg.fit(x_train, y_train)

# 決定係数 (1に近づくほどよい) により回帰の精度を評価する.
puts reg.score(x_test, y_test)

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

$ ruby regression.rb
R2-Score: 0.5224

これがスタッキングにより、どれだけスコアが向上するか試してみる。ランダム森による回帰を、以下のスタッキングに置き換える。一段目の学習機の種類やハイパーパラメータは適当なものである。

# ランダム森のところをスタッキングに置き換える.
# estimatorsにHashで特徴抽出に使う一段目の推定器を指定する.
# meta_estimatorに実際に推定結果を返す二段目の推定器を指定する.
reg = Rumale::Ensemble::StackingRegressor.new(
  estimators: {
    rnd: Rumale::Ensemble::RandomForestRegressor.new(n_jobs: -1, random_seed: 1),
    ext: Rumale::Ensemble::ExtraTreesRegressor.new(n_jobs: -1, random_seed: 1),
    grd: Rumale::Ensemble::GradientBoostingRegressor.new(n_jobs: -1, random_seed: 1),
    rdg: Rumale::LinearModel::LinearRegression.new
  },
  meta_estimator: Rumale::LinearModel::Ridge.new(reg_param: 10),
  random_seed: 1
)

これを実行すると、以下のようになる。ランダム森単体のときよりも、スコアが向上していることがわかる。

$ ruby regression.rb
R2-Score: 0.5751

さらにもう一歩スコアを伸ばしたい場合に、スタッキングを試してみるのは、有効であると考える。

おわりに

スタッキングはKaggleでよく使われ、アンサンブル手法として有名になった。研究自体は90年代初頭からあり、回帰分析では、二段目のメタ学習器の係数に、非負の制限をつけたほうが良いという知見もあったりする。次のバージョンで、非負最小二乗法による回帰を実装しても良いかなとか思ったり。

github.com

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