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