洋食の日記

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

RumaleにExtra-Treesによる分類と回帰を追加した

はじめに

新しい時代になったので、Rumaleに新しいアルゴリズムを追加してバージョンを0.9.1に上げてみた。あわせて、バージョン0.9.0で導入した決定木のC拡張もリファクタリングして、少しだけ速くなっている。

rumale | RubyGems.org | your community gem host

Extra-Trees

Extra-Trees(Extremely randomized trees)はおもしろいアルゴリズムで、決定木では特徴軸を分割する際に、Gini係数やEntropyなどを基準に、利得が最大になる特徴とその分割の閾値を選択するが、Extra-Treesはそれらをランダムに選択する。このランダムな木を、Random Forestと同じように複数用意してBaggingするのだが、それぞれの木を学習する際に、Bootstrapサンプリングはせずに訓練データ全てを用いる。シンプルなので高速に動くし、分類精度もRandom Forestに匹敵する値となる。

link.springer.com

使い方

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

$ gem install rumale

Rumaleは基本的にはscikit-learnのインターフェースに合わせている。fitしてpredictする感じ。 scoreメソッドを使うとAccyracyを計算する。

require 'rumale'

# データの取得にはred-datasetsを用いた. gemコマンドでインストールできる.
#   gem install red-datasets-numo-narray
# USPSという手書き文字データセットを読み込む.
require 'datasets'
require 'datasets-numo-narray'

usps = Datasets::LIBSVM.new('usps').to_narray
labels = Numo::Int32.cast(usps[true, 0])
samples = Numo::DFloat.cast(usps[true, 1..-1])

# ランダム分割で訓練とテストに分ける.
ss = Rumale::ModelSelection::ShuffleSplit.new(n_splits: 1, test_size: 0.1, random_seed: 1)
train_ids, test_ids = ss.split(samples, labels).first

# 訓練データセットでExtra-Treesによる分類器を学習する.
est = Rumale::Ensemble::ExtraTreesClassifier.new(random_seed: 1)
est.fit(samples[train_ids, true], labels[train_ids])

# テストデータセットで分類性能を評価する.
puts("Accuracy: %.4f" % est.score(samples[test_ids, true], labels[test_ids]))

分類精度の比較

上記のコードを実行すると、次のような結果になる。手書き文字認識の正確度が95%とランダムながらそれなりの精度となる。

$ ruby tree.rb
Accuracy: 0.9547

コードを一行変えてRandom Forestを試してみる

# 訓練データセットでExtra-Treesによる分類器を学習する.
# est = Rumale::Ensemble::ExtraTreesClassifier.new(random_seed: 1)
est = Rumale::Ensemble::RandomForestClassifier.new(random_seed: 1)

これを実行すると、次のような結果になる。わずかにExtra-Treesのほうが正確度が高い。 Extra-Treesはランダムに特徴と閾値の選択をするため、本来であれば、 乱数のシードを変えつつ複数回実行した平均値を比較すべきだが、Random Forestと同程度の結果が得られることがわかる。

$ ruby tree.rb
Accuracy: 0.9438

Extra-TreesおよびRandom Forestにはハイパーパラメータがあるので、それらを調整するとまた結果が変わってくるだろう。

実行速度の比較

データセットの分割の後ろに以下のコードを追加して、実行速度を計測した。 Extra-Treesは、ランダムに選択される閾値によっては、運悪く木が深くなりすぎる可能性があるので、 木の深さを表すmax_depthパラメータを10とした(Accuracyは0.93程度となる)。

# 省略

# ランダム分割で訓練とテストに分ける.
ss = Rumale::ModelSelection::ShuffleSplit.new(n_splits: 1, test_size: 0.1, random_seed: 1)
train_ids, test_ids = ss.split(samples, labels).first

# Benchmarkを使って訓練・テストの実行速度を計測する.
require 'benchmark'

Benchmark.bm 10 do |r|
  r.report 'extra-trees' do
    est = Rumale::Ensemble::ExtraTreesClassifier.new(max_depth: 10, random_seed: 1)
    est.fit(samples[train_ids, true], labels[train_ids])
    est.predict(samples[test_ids, true])
  end

  r.report 'random forest' do
    est = Rumale::Ensemble::RandomForestClassifier.new(max_depth: 10, random_seed: 1)
    est.fit(samples[train_ids, true], labels[train_ids])
    est.predict(samples[test_ids, true])
  end
end

これを実行すると以下のようになる。Random Forestの方が速いことがわかる。しかし、Random Forestで使用している決定木の特徴軸の分割はRuby拡張(C言語)で、Extra-TreesはPure Rubyで実装していることを考えると、Extra-Treesは速いと感じる。

$ ruby tree.rb
                 user     system      total        real
extra-trees  8.780000   0.110000   8.890000 (  8.955922)
random forest  6.660000   0.090000   6.750000 (  6.781255)

おわりに

Extra-Treesは、Kaggleでもstackingの最終層の分類器などで使われる。Extra-Treesでは、決定木の分割において、ランダムに特徴と閾値を選択するが、Random Forestと同等の分類精度を得られるのが興味ぶかい。Extra-Treesだけでなく、Random recursive SVMやEcho state networkなど、ランダム要素を含む機械学習アルゴリズムは多くあり、いずれも調べていると深みにはまるおもしろさがある(うまくいく乱数のシードを探しはじめたり...)。

github.com

Rumaleの決定木をRuby拡張を使って速くした

はじめに

Rumaleの決定木で、どうしても普通にRubyを使っては高速化できない箇所があり、そこをExtensionで実装した。これをバージョン0.9.0としてリリースした。

rumale | RubyGems.org | your community gem host

決定木では、特徴軸ごとに、不純度にもとづいて最適な分割をさがす。 訓練データがN個あり、特徴量の値が重複しないとすると、N-1個の値が分割を決める閾値の候補となり、それだけforループを回す必要がある(特徴ベクトルがD次元とすると探索ループの外側にD個分のforループがある)。 Rubyに限らず、スクリプト言語の多くは、多重のforループはどうしても遅くなってしまうので、ここをExtensionで実装した。

簡単な実装の話

Ruby Extensionで、ExtDecisionTreeClassifierモジュールを作り、そこに最適な分割を探索するfind_split_paramsメソッドを生やした(回帰についても同様)。

void Init_rumale(void)
{
  VALUE mRumale = rb_define_module("Rumale");
  VALUE mTree = rb_define_module_under(mRumale, "Tree");
  VALUE mExtDTreeCls = rb_define_module_under(mTree, "ExtDecisionTreeClassifier");
  rb_define_method(mExtDTreeCls, "find_split_params", find_split_params_cls, 6);
  
  // ... (省略)
}

その上で、Ruby側のDecisionTreeClassifierクラスでincludeし、分割を探索する部分でfind_split_paramsメソッドを用いた。

module Rumale
  module Tree
    class DecisionTreeClassifier < BaseDecisionTree
      include Base::Classifier
      include ExtDecisionTreeClassifier

      # ... (省略)

これで一部だけRuby Extension化できる。

実験

0.8.4と0.9.0で簡単な決定木の実行速度の比較を行った。今回、データセットの読込には、red-datasetsを用いた。データセットのダウンロードも自動化されており、簡単にデータセットを扱うことができる。

$ gem install rumale red-datasets red-datasets-numo-narray

コードは以下のようになる。データセットには、LIBSVM DATAで提供されている、7,291個256次元のUSPSを用いた。

require 'benchmark'
require 'rumale'
require 'datasets'
require 'datasets-numo-narray'

# データセットを読み込む
usps = Datasets::LIBSVM.new('usps').to_narray
labels = Numo::Int32.cast(usps[nil, 0])
samples = Numo::DFloat.cast(usps[nil, 1..-1])

# データセットの10%をテストセットとして、ランダムに訓練とテストにわける
ss = Rumale::ModelSelection::ShuffleSplit.new(n_splits: 1, test_size: 0.1, random_seed: 1)
train_ids, test_ids = ss.split(samples, labels).first

# 決定木の訓練とテストを10回おこない実行速度を確認する
Benchmark.bm 10 do |r|
  r.report 'dtree' do
    est = Rumale::Tree::DecisionTreeClassifier.new(random_seed: 1)
    est.fit(samples[train_ids, true], labels[train_ids])
    est.predict(samples[test_ids, true])
  end
end

これをRumaleのバージョン0.8.4と0.9.0で実行すると、次のようになる。だいたい100倍ほど速くなっているのがわかる(※注: データ数や特徴ベクトルの次元数が小さい場合これほどの差はつかない)。

# ver. 0.8.4
                 user     system      total        real
dtree      4923.530000 198.290000 5121.820000 (7917.143121)
# ver. 0.9.0
                 user     system      total        real
dtree       35.210000   0.310000  35.520000 ( 37.066986)

決定木が速くなると、Random ForestやAdaboost(Rumaleでは弱学習器に決定木を使用している)も速くなるので、この改善は大きい。

おわりに

当初Extensionを、C99なC言語で書いていたが、Travis CIでコケてしまった。コンパイルオプションで「-std=gnu99」とか付けることも考えたが、環境によってどうなるかわからなかったので、C90で書き直した。ExtensionはC90で書くのが安全と思われる。またMakefileなどのビルドの部分は、rake-compilerが良い感じにしてくれるので、簡単だった。たぶん、MacとかLinuxでは無事にインストールできる...と思う。

Rumaleの0.9系は、どうしても高速化できない部分(主に行列・ベクトル演算で書くことが難しくNumo::NArrayの恩恵が得られない部分)をExtensionで書き直すことを考えている。また、並列化できる箇所もあるので、Parallel gemなどを導入することを考えている。

Numo::NArrayを扱ったRubyの拡張ライブラリの作りかた

はじめに

簡単なNumo::NArrayを利用した拡張ライブラリを作った。拡張ライブリの作成については、公式のドキュメントが充実しているので、挑戦してみた。

準備

まずは、bundlerでgemの雛形を作る。ここで、--extオプションをつけると、拡張ライブラリ用のディレクトリやファイルも作られるし、gemspecファイルもいい感じに作られる。しかし今回は、勉強のために、そのあたりも手作業で行う。

$ bundle gem foo
Creating gem 'foo'...
MIT License enabled in config
Code of conduct enabled in config
      create  foo/Gemfile
      create  foo/lib/foo.rb
      create  foo/lib/foo/version.rb
      create  foo/foo.gemspec
      create  foo/Rakefile
...(省略)

$ cd foo

gemspecファイルを編集する。spec.extensionsを追加し、rutime_dependencyにNumo::NArrayを、development_dependencyにrake-compilerを追加した。また、TODOの文字があると、bundle installでコケるので、適宜、変更したり、コメントアウトしたりした。

$ vim foo.gemspec
...(省略)

  spec.summary       = %q{T: Write a short summary, because RubyGems requires one.}

...(省略)

  spec.extensions    = ['ext/foo/extconf.rb']

  spec.add_runtime_dependency 'numo-narray', '~> 0.9.1'

  spec.add_development_dependency 'rake-compiler', '~> 1.0'

...(省略)

Numo::NArrayやrake-compilerといった、必要なライブラリをインストールする。

$ bundle install
Fetching gem metadata from https://rubygems.org/..........

...(省略)

Rakefileにrake-compilerの設定を書く。ひとまず、最低限を追記した。

$ vim Rakefile
...(省略)

require 'rake/extensiontask'

Rake::ExtensionTask.new('foo')

...(省略)

拡張ライブラリの設定ファイルを作る。このとき、Numo::NArrayのヘッダーファイルをincludeパスに含める必要がある。これは本家のNumo::FFTWのものを参考にした。

$ mkdir -p ext/foo/
$ vim ext/foo/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('foo/foo')

拡張の作成

今回作るのは、与えられたベクトルの要素を二乗したベクトルを返す、簡単なメソッドを持つクラスである。Numo::NArrayを利用した拡張では、イテレータ関数を定義して、ndfunc_t構造体にイテレータ関数や入出力を定義し、na_ndloop関数に渡す、というのが一連の流れとなる。このあたりは、公式のドキュメントに詳しくある。また、Numo関連のライブラリのソースを読むのも勉強になる。

$ vim ext/foo/foo.c
#include <stddef.h>
#include <ruby.h>
#include <numo/narray.h>
#include <numo/template.h>

/**
 * イテレータ関数を定義する
 * 配列aが入力で、配列bが出力である
 */
static void
iter_f_bar(na_loop_t* const lp)
{
  size_t  n = lp->n[0];
  double* a = (double*)(lp->args[0].ptr + lp->args[0].iter[0].pos);
  double* b = (double*)(lp->args[1].ptr + lp->args[1].iter[0].pos);
  size_t  i;

  for (i = 0; i < n; i++) b[i] = a[i] * a[i];
}

/**
 * 与えれたNumo::DFloatのベクトルを二乗するメソッドを定義した関数
 * 第二引数のnaryがメソッドに与えられたNumo::DFloatのベクトル
 * 処理としては入出力を定義しイテレータ関数とともにna_ndloopにわたす
 */
static VALUE
f_bar(VALUE self, VALUE nary)
{
  ndfunc_arg_in_t ain[1] = {{numo_cDFloat, 0}};
  ndfunc_arg_out_t aout[1] = {{numo_cDFloat, 0}};
  ndfunc_t ndf = { iter_f_bar, NDF_STRIDE_LOOP, 1, 1, ain, aout };

  return na_ndloop(&ndf, 1, nary);
}

/* Init_xxxが最初に呼ばれる関数となる */
void Init_foo()
{
  /**
   * 今回はFooモジュール以下にBarというクラスを用意した
   * そのBarクラスにNumo::DFloatなベクターの要素を二乗するbarメソッドを追加した
   */
  VALUE mFoo, cBar;

  /* Fooモジュールを用意する */
  mFoo = rb_define_module("Foo");

  /* Fooモジュール以下にBarクラスを定義する */
  cBar = rb_define_class_under(mFoo, "Bar", rb_cObject);

  /* Barクラスに引数を1つ受け取るメソッドbarを定義する */
  rb_define_method(cBar, "bar", f_bar, 1);
}

lib/foo.rbで拡張をrequireする。

$ vim lib/foo.rb
...(省略)

require 'numo/narray' 
require 'foo/foo'

...(省略)

動作確認のためのspecを書く。

$ vim spec/bar_spec.rb
RSpec.describe Foo::Bar do
  let(:x) { Numo::DFloat[2, 3,  4,  5] }
  let(:y) { Numo::DFloat[4, 9, 16, 25] }

  it 'returns an array consisting of the squared of elements of the given array.' do
    bar = described_class.new
    expect(bar.bar(x)).to eq(y)
  end
end

動作確認

rakeコマンドでコンパイルして、rspecで動作を確認する。

$ rake compile:foo
mkdir -p tmp/x86_64-darwin18/foo/2.3.8
cd tmp/x86_64-darwin18/foo/2.3.8
/Users/atatsuma/.rbenv/versions/2.3.8/bin/ruby -I. ../../../../ext/foo/extconf.rb
creating Makefile

...(省略)

$ rspec spec/bar_spec.rb

Foo::Bar
  returns an array consisting of the squared of elements of the given array.

Finished in 0.00432 seconds (files took 0.47124 seconds to load)
1 example, 0 failures

無事にspecが通って、拡張によりNumo::DFloatのベクトルが二乗されていることがわかる。 実際にgemをインストールしてみての動作確認も行った。gemspecファイルのspec.filesにextディレクトリ以下のファイルが追加されている必要がある。bundleコマンドで自動的に作成されたgemspecファイルでは「git ls-files」した結果からspec.filesを作成している。そのため、extディレクトリ以下をgit addする必要がある。

$ git add ext/foo/extconf.rb
$ git add ext/foo/foo.c
$ rake install
foo 0.1.0 built to pkg/foo-0.1.0.gem.
foo (0.1.0) installed.
$ irb

与えたNumo::DFloatのベクトルの要素を二乗した、新たなNumo::DFloatのベクトルが返ってきているのがわかる。

irb(main):001:0> require 'foo'
=> true
irb(main):002:0> a=Foo::Bar.new
=> #<Foo::Bar:0x00007fd08885f560>
irb(main):003:0> v=Numo::DFloat[10, 20, 30]
=> Numo::DFloat#shape=[3]
[10, 20, 30]
irb(main):004:0> a.bar(v)
=> Numo::DFloat#shape=[3]
[100, 400, 900]
irb(main):005:0>

おわりに

簡単なNumo::NArrayを使った拡張ライブラリを作ってみた。今回は、勉強のために、準備の部分を手作業で行ったが、bundle gemで--extオプションをつけるのが良いと思う。

Ruby機械学習ライブラリRumaleの開発で、基本的なアルゴリズムは実装できたので、実行速度の向上を計画している。決定木などの行列・ベクトル演算で書けないアルゴリズムでは、どう工夫しても実行速度を上げられない部分があり、そのあたり、一部だけ拡張ライブラリ化することを考えている。scikit-learnもCythonで書かれているので、避けては通れない道なのかも。

github.com

DaruとRumaleを使ってKaggleのTitanicコンペにRubyで挑戦する

はじめに

Kaggleのチュートリアルコンペぐらいなら、Rubyでもイケるんじゃないかと思って、データ分析ライブラリのDaruと機械学習ライブラリのRumaleでTitanicコンペに挑戦してみた。

Titanic: Machine Learning from Disaster | Kaggle

DaruはPythonでいうところのPandasで、これで特徴エンジニアリングを行い、RumaleのRandom Forestで推定を行う。 DaruもRumaleもgemコマンドでインストールできる。

$ gem install daru rumale

データの読み込みと欠損値の補完

まず、KaggleのTitanicコンペからダウンロードしてきた訓練データセット(train.csv)とテストデータセット(test.csv)を読み込む。Daruでは、from_csvメソッドで、CSVファイルをDataFrameにできる。

require 'daru'
require 'rumale'

# データセットを読み込む
train_df = Daru::DataFrame.from_csv('train.csv', headers: false)
test_df = Daru::DataFrame.from_csv('test.csv', headers: false)

Titanicコンペでは、乗客が生存したかどうかを推定する。評価のために提出するCSVファイルは、テストデータセットの乗客IDに生存したかどうかのラベルを紐づけたものとなる。※ちなみに、コンペのもとになったタイタニック号沈没事故は痛ましい事故なので、Titanicコンペの特徴エンジニアリングを考えるときは感情移入し過ぎるとよくない。

# 訓練データから生存に関するラベルを取り出す
target_vals = train_df['Survived']
train_df.delete_vectors('Survived', 'PassengerId')

# テストデータから乗客IDを取り出す
test_pids = test_df['PassengerId']
test_df.delete_vectors('PassengerId')

データセットの一部には欠損値があり、これはDaruのDataFrame上ではnilとなる。replace_nils!メソッドにより任意の値で埋めることができる。

# カテゴリデータの欠損値をUnknownを意味するUで埋める
train_df['Cabin'].replace_nils!('U')
test_df['Cabin'].replace_nils!('U')
train_df['Ticket'].replace_nils!('U')
test_df['Ticket'].replace_nils!('U')
train_df['Embarked'].replace_nils!('U')
test_df['Embarked'].replace_nils!('U')

# 数量データの欠損値を平均で埋める
mean_age = train_df['Age'].mean
train_df['Age'].replace_nils!(mean_age)
test_df['Age'].replace_nils!(mean_age)
mean_fare = train_df['Fare'].mean
train_df['Fare'].replace_nils!(mean_fare)
test_df['Fare'].replace_nils!(mean_fare)

カテゴリデータを数値に変換する

RumaleのLabelEncoderを使用してカテゴリに数値を割り当てていく。Rumaleはデータの表現にNumo::NArrayを利用しているが、DaruはNumo::NArrayに対応していない。Rumaleとのデータの受け渡しでは、適宜to_aメソッドでArrayに変換した。

# Rumaleのラベルエンコーダーを作成する
encoder = Rumale::Preprocessing::LabelEncoder.new

# DaruのVectorをto_aメソッドでArrayにして、Rumaleのfit_trasformやtransformメソッドにわたす
# fit_transformやtransformメソッドは、Numo::Int32型を返すので、to_aメソッドでArrayにする
train_df['Embarked'] = encoder.fit_transform(train_df['Embarked'].to_a).to_a
test_df['Embarked'] = encoder.transform(test_df['Embarked'].to_a).to_a

train_df['Cabin'] = train_df['Cabin'].map { |v| v[0] }
test_df['Cabin'] = test_df['Cabin'].map { |v| v[0] }
train_df['Cabin'] = encoder.fit_transform(train_df['Cabin'].to_a).to_a
test_df['Cabin'] = encoder.transform(test_df['Cabin'].to_a).to_a

train_df['Ticket'] = train_df['Ticket'].map { |v| v.to_s[0] }
test_df['Ticket'] = test_df['Ticket'].map { |v| v.to_s[0] }
train_df['Ticket'] = encoder.fit_transform(train_df['Ticket'].to_a).to_a
test_df['Ticket'] = encoder.transform(test_df['Ticket'].to_a).to_a

バイナリ変数を追加する

Wikipediaの記事やKaggleのkernelを見ると、女性や少年は生存しているようだ。

# 女性であるか
train_df['IsFemale'] = train_df['Sex'].map { |v| v == 'female' ? 1 : 0 }
test_df['IsFemale'] = test_df['Sex'].map { |v| v == 'female' ? 1 : 0 }

# 少年(敬称がMaster)であるか
train_df['IsMaster'] = train_df['Name'].map { |v| v.split(',')[1].split('.')[0].strip == 'Master' ? 1 : 0 }
test_df['IsMaster'] = test_df['Name'].map { |v| v.split(',')[1].split('.')[0].strip == 'Master' ? 1 : 0 }

数量データの一部を量子化する

特徴量としては、実数値より、ざっくりとヒストグラムで表現したほうが良い場合がある。ただ、Daruには、Pandasのqcutやcutがないようなので、なにかしらの方法で量子化する必要がある。

# 家族の人数を計算する
train_df['FamilySize'] = train_df['Parch'] + train_df['SibSp'] + 1
test_df['FamilySize'] = test_df['Parch'] + test_df['SibSp'] + 1

# 料金を家族の人数で割る
train_df['MeanFare'] = train_df['Fare'] / train_df['FamilySize']
test_df['MeanFare'] = test_df['Fare'] / test_df['FamilySize']

# 料金に関する変数を50段階にする
max_fare = train_df['Fare'].max.to_f
min_fare = train_df['Fare'].min.to_f
train_df['Fare'] = (((train_df['Fare'] - min_fare) / (max_fare - min_fare)) * 50.0).round
test_df['Fare'] = (((test_df['Fare'] - min_fare) / (max_fare - min_fare)) * 50.0).round

max_mean_fare = train_df['MeanFare'].max.to_f
min_mean_fare = train_df['MeanFare'].min.to_f
train_df['MeanFare'] = (((train_df['MeanFare'] - min_mean_fare) / (max_mean_fare - min_mean_fare)) * 50.0).round
test_df['MeanFare'] = (((test_df['MeanFare'] - min_mean_fare) / (max_mean_fare - min_mean_fare)) * 50.0).round

# 年齢に関する変数を10段階にする
# ※50段階や10段階にしたのは単に「思いついた数字」で特に意図はない
max_age = train_df['Age'].max.to_f
min_age = train_df['Age'].min.to_f
train_df['Age'] = (((train_df['Age'] - min_age) / (max_age - min_age)) * 10.0).round
test_df['Age'] = (((test_df['Age'] - min_age) / (max_age - min_age)) * 10.0).round

不要な特徴量を削除してNumo::NArray形式に変換する

Rumaleでは、データの表現にNumo::NArrayを利用している。DaruのDataFrameはto_matrixメソッドでMatrixに、Vectorはto_aメソッドでArrayに変換し、これらをNumo::NArrayにわたすことで、Numo::DFloatやNumo::Int32に変換する。

# 名前などはもう使わないので削除する
del_cols = ['Name', 'SibSp', 'Parch', 'Sex']
train_df.delete_vectors(*del_cols)
test_df.delete_vectors(*del_cols)

# DataFrameをMatrixに、VectorをArrayに変換して、Numo::NArray形式にする
samples = Numo::DFloat[*train_df.to_matrix]
labels = Numo::Int32[*target_vals.to_a]
test_samples = Numo::DFloat[*test_df.to_matrix]

交差検定で確認する

Rumaleを使って交差検定を行う。特徴量の重要度も確認したいので、分類器にはRandom Forestを用いる。

# 結果を保存する変数を初期化する
imp = Numo::DFloat.zeros(n_features)
sum_accuracy = 0.0

# 10-交差検定の分割をおこなう
kf = Rumale::ModelSelection::StratifiedKFold.new(n_splits: 10, shuffle: true, random_seed: 1)

kf.split(samples, labels).each do |train_ids, valid_ids|
  # 訓練データと検証データを得る
  train_samples = samples[train_ids, true]
  train_labels = labels[train_ids]
  valid_samples = samples[valid_ids, true]
  valid_labels = labels[valid_ids]
  # Random Forestで学習する
  clf = Rumale::Ensemble::RandomForestClassifier.new(n_estimators: 100, max_features: 2, random_seed: 1)
  clf.fit(train_samples, train_labels)
  # 特徴量の重要度を得る
  imp += clf.feature_importances
  # 正確度を得る
  sum_accuracy += clf.score(valid_samples, valid_labels)
end

# 平均の正確度を出力する
mean_accuracy = sum_accuracy / kf.n_splits
puts
puts sprintf("Mean Accuracy: %.5f", mean_accuracy)

# 特徴量の重要度を出力する
puts
puts "Feature importances:"
train_df.vectors.to_a.each_with_index { |col, i| puts("#{col}: #{imp[i] / kf.n_splits}") }

これを実行すると次のようになる。8割ほどの正確度が得られ、最も重要な特徴は女性であるかどうかになった。部屋や料金も重要らしい。

Mean Accuracy: 0.82270

Feature importances:
Pclass: 0.08757945953461778
Age: 0.05991353734583812
Ticket: 0.06425842380708825
Fare: 0.08939140299941103
Cabin: 0.09128477545493736
Embarked: 0.02360076253503096
IsFemale: 0.41341081597428586
IsMaster: 0.027338294741599423
FamilySize: 0.07416848338510751
MeanFare: 0.06905404422208355

提出用CSVファイルを作成する

Daruはwrite_csvメソッドで、DataFrameをCSVファイルに書き出せる。乗客IDと推定結果を結びつけて、提出用ファイルを作成する。

# テストデータセットのラベルを推定する
clf = Rumale::Ensemble::RandomForestClassifier.new(n_estimators: 100, max_features: 2, random_seed: 1)
clf.fit(samples, labels)
prediction = clf.predict(test_samples)

# 提出用ファイルを作成する
submission = Daru::DataFrame.new({'PassengerId': test_pids, 'Survived': prediction })
submission.write_csv('submission.csv')

これを提出すると、Public Leaderboardで0.79425だった。サンプルのgender_submission.csvがたしか0.76とかだったので、それよりは良い感じ。

おわりに

「Rumaleを作るだけじゃなく使ってみないと」と思ってTitanicコンペに挑戦してみた。特徴エンジニアリングとかは、思いつきで適当に行ったが、Titanicコンペのような小さいデータセットであれば、DaruとRumaleで十分にデータ分析などができる印象を持った。ただ、データセットが大きくなると、実行速度の面で厳しい様に思う。Rumaleは、並列に処理できる箇所があるので、Parallel gemで高速化できないか考えている。Daruは、Daru::Viewに力をいれている様子で、Daru本体のリリースが一年近くないのがちょっと気がかり 🤔

ちなみにKaggleだが、それなりのスペックのマシンを用意することをオススメする。私はEarly 2016なMacBookしかもっておらず、一時期KaggleのKernelでガンバったが、タイムアウトとかメモリ超過で強制終了になることがほとんどで、結局あきらめてしまった 😓 いつかお金と時間に余裕ができたら再挑戦したい。

Rumaleに改名するときにやらかしたコト

はじめに

Rumaleに改名する際に以下のページをまずみた。Googleで「gem rename」で上位に来たので。

stackoverflow.com

ざっと読んで、旧名のGemのリポジトリとかREADMEとかに説明をつけて、新しいの出せば良いんだな〜と思った。FactoryBot(旧FactoryGirl)はなんかGithubリポジトリも引き継いでる感じだけど、なんか特殊な方法をやってるのだろうな〜と思った。 だが、実際は特殊な方法ではなくて、GithubRubyGemsがいい感じにやってくれるというコトを教わった。またまた、id:mrkn さんにお世話になりつつ作業をすすめた 🙏 改めて感謝申し上げます 🙏

SVMKitからRumaleへ

Githubでは、リポジトリ名はSettingsでRenameできる。改名前のURLでアクセスしたとしても、Githubがいい感じにリダイレクトしてくれる。新しい名前のリポジトリを用意するのに比べて、Starとかのステータスが維持されるのが良い。

Gitコマンドレベルの作業では次のようにした。手元にSVMKitとRumaleの作業コピーがあるとする。Rumaleの内容でSVMKitを上書きするかたちになる。

$ cd svmkit
$ git rm -rf --ignore-unmatch *
$ cp -pr ../rumale/* .
$ cp ../rumale/.{.coveralls.yml,.gitignore,.rspec*,.rubocop*,.travis.yml} .
$ git add .
$ git commit -m ':rocket: Rename to Rumale'

置き換えたのは version 0.8.0 なので、v0.8.0のタグを付け替えてpushしようと思ったが、なにか手違いがあったみたいで、tagがforceな感じで置き換えれなかった。というわけで、まず普通にpushした。

$ git push origin master

これでSVMKitの中身がRumaleのものになる。 次に、Github上でrumaleリポジトリを削除して、svmkiitリポジトリをrumaleにRenameした。 このとき、Githubで「rumaleはすでにあります」みたいなエラーが出たり、アクセスしたら前のモノが残ってるなど、変なコトは起きなかった。意図通りの変更ができた。

そして、pendingしてたタグ付けだが、以下の準備をしておいて、

$ cd ../
$ mv rumale rumale.old
$ git clone https://github.com/yoshoku/rumale.git
$ cd rumale
$ git tag -a v0.8.0 -m 'Version 0.8.0' 最新のコミット

そしてGithub上で v0.8.0 のタグを削除して、速攻pushした。我ながらヤベーヤツだ。

$ git push origin v0.8.0

これでGithub上でSVMKitがRumaleになった。RubyGemsのRumaleのページに行くと、SVMKitをRumaleに改名したものが紐付いていた。

SVMKitの後始末

SVMKitをRumaleと置き換えるだけで、SVMKitのコードはそのまま動く。なので、実質 SVMKit = Rumale という定数定義だけのRumaleに依存する空Gemを作って、それをSVMKitのversion 0.8.1としてリリースした。

$ bundle gem svmkit
$ cd svmkit

旧SVMKitからlibやspecディレクトリ以外のものをコピーしてきた。svmkit.gemspecファイルは以下のようにした。versionを直打ちにして、rumaleに依存させる。

lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)

Gem::Specification.new do |spec|
  spec.name          = 'svmkit'
  spec.version       = '0.8.1'
... 略
  spec.add_runtime_dependency 'rumale', '~> 0.8'
... 略

その上でsvmkit/version.rbを削除した。

$ cd lib
$ rm -rf svmkit

そして、svmkit.rbを次のようにした。

warn 'SVMKit has been deprecated; You should migrate to Rumale.'

require 'rumale'

SVMKit = Rumale

これで、gemファイルを作り、RubyGemsに上げる。Githubにpushされたりすると困るといういか、リポジトリがないので、rake releaseではなく、gem pushを使った。

$ rake build
svmkit 0.8.1 built to pkg/svmkit-0.8.1.gem.

$ gem push pkg/svmkit-0.8.1.gem
Pushing gem to https://rubygems.org...
Successfully registered gem: svmkit (0.8.1)

追記:

念のため、version 0.8.0はyankした...

$ gem yank svmkit -v0.8.0
Yanking gem from https://rubygems.org...
Successfully deleted gem: svmkit (0.8.0)

Travis CIやCoveralls

Travis CIはSettingsページにある、Sync accountを押して、ページをリロードすると、svmkitが消えていた。 Coverallsは、SVMKitのSettingsページに行き、DELETE REPOSITORYした。そして、RumaleのSettingsページで、Resync with githubした。 画面を見た感じ上手く切り替えれたっぽい。

追記:

結局、RumaleもDELETE REPOSITORYして、ADD REPOSページでSYNC REPOSボタンを押したあとで、改めてRumaleのリポジトリを追加した。そのうえでTravis CIでRebuildした。

おわりに

やってしまいました...気をつけたいと思います...