洋食の日記

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

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でガンバったが、タイムアウトとかメモリ超過で強制終了になることがほとんどで、結局あきらめてしまった 😓 いつかお金と時間に余裕ができたら再挑戦したい。