今日は、RustのPolarsとLightGBMを使ってKaggleのTitanicデータセットを使った分類問題を解いてみたお話をシェアしようと思います。「え、そんなのできるの?」と思った方もいるかもしれませんが、PolarsはRustで開発されていますし、LightGBMもRustのバインディングがあるので、Rustでデータ処理から機械学習まで完結させることは理論的には可能です。ただ、、、実際にやってみたら、かなり大変でした。笑
- 実際のコードはこちら
モチベーション:PythonからRustへ~もっと速く、もっと安全に?
Pythonはデータ分析や機械学習の世界で王道。
特に最近は、Polarsで高速なデータ処理やLightGBMで高性能な機械学習が可能になり、Pandasを使うよりも効率的にデータ処理ができるようになりました。Pythonの地位はますます固いものとなっています。しかし、ここでひとつ問題が…。
LightGBMを使う際、毎回to_pandas
でデータを変換してしまうのが現状。これって結局、PythonのPandasに依存しちゃうってことですよね。せっかくPolarsはRustで書かれた高速なデータ処理ライブラリなのに、PythonのPandasに頼らないといけないのはちょっともったいない気がしませんか?
そこで「RustでPolarsからLightGBMまで完結させれば、もっと効率的にデータ処理ができるんじゃないか!」と甘い期待を抱いて、Rustでの実装に挑戦してみました。結果は…えぇ、結論から言うと「Rustは魔王だった…」という感じです。笑
PythonのPolarsは天使、RustのPolarsは魔王?
PythonでのPolars、スイスイ進む
まずはPythonから。PolarsはPythonでもRustでも使える超高速なデータ処理ライブラリです。Pythonでの使い勝手は抜群。以下のように、直感的なコードでデータの前処理ができちゃいます。(u++さんのコードを参考にしています:[polars] python-kaggle-start-book-ch02_05)
# 性別を数値に変換
df = df.with_columns(
pl.col("Sex").str.replace("female", "1").str.replace("male", "0").cast(pl.Int32)
)
見た目もシンプルで、なんの問題もない。
RustでのPolars、魔王のような存在
さて、ここからが本題。RustでPolarsを使って同じことをしようとしたら…。もう、頭が痛くなって泣きたくなりました。
df = df.with_column(
col("Sex")
.replace(lit("female"), lit("1"))
.replace(lit("male"), lit("0"))
.cast(DataType::Int32),
);
Rustのコードは、Pythonのコードと比べて冗長で、lit
が特に直感的ではありません。
少しずつ紐解いて説明をします。
まず、lit
とはカラムに含まれる値を表すための関数です。lit
はpolars::prelude - Rustにある通り、Into<Expr>
トレイトを実装しているため、Expr
型に変換することができます。
加えて、replace
の引数はExpr in polars::prelude - Rustにある通りInto<Expr>
トレイトを実装している必要があります。
引数にあるlit
はこれを満たしています。
というのも、lit in polars::prelude - Rustにある通り、Expr
を返す関数で、Into
トレイトはInto in std::convert - Rustにある通り、Self
型からT
型への変換を行うトレイトです。
Into
に対して少し説明をすると、以下の通り任意のSized
な型に対して自分自身へのInto
は暗黙的に実装されています。
https://doc.rust-lang.org/std/convert/trait.Into.html#generic-implementations より
Into is reflexive, which means that Into for T is implemented
ただ、&str
型はInto<Expr>
を実装していないため、lit
を使ってExpr
型に変換する必要があるのです。
Rustの型システムは素晴らしいのですが、データ処理のコードを書く際には、このような型変換の手間がかかるなと感じます。
LightGBMとの連携、これまた地獄
PythonでのLightGBM、楽々トレーニング
PythonではLightGBMを使ってモデルを訓練するのも簡単。以下のように数行のコードで完了します。
- u++さんのコードを参考にしてください。
RustでのLightGBM、地獄の始まり
Polarsのデータを直接LightGBMに渡すことはできなかったり、そもそもCSVファイルの読み込みも難しかったり…。RustでLightGBMを使うのは、Pythonとは比べものにならないほど困難でした。
- 詳細は割愛しますが、以下のようなコードを書く必要があります。
// データの読み込み
let mut df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some(train_data_path.into()))?
.finish()?;
// DataFrameをLightGBMで読み込むためにVecに変換
pub fn convert_df_to_vec(
df: &DataFrame,
) -> Vec<[f64; 7]> {
let features: Vec<&str> = vec![
"Pclass", "Sex", "Age", "Fare", "Embarked", "FamilySize", "IsAlone"
];
(0..df.height())
.map(|idx| {
features
.iter()
.map(|&col| {
df.column(col)
.unwrap()
.get(idx)
.unwrap()
.try_extract::<f64>()
.unwrap_or_else(|_| {
// もし f64 に変換できない場合は panic
panic!("Failed to convert value to f64")
})
})
.collect::<Vec<f64>>()
.try_into()
.expect("Row length mismatch")
})
.collect()
}
もう少し簡単に書ける可能性もありますが、Rustの型システムやライフタイムの問題に直面し、なかなか大変なところも多いと感じました。
Rustの文法に触れつつ、何が大変だったか
1. 所有権と借用の迷宮
Rustの所有権システムは素晴らしいですが、PolarsやLightGBMを使う際にデータの所有権や借用をどう管理するかが超難。&DataFrame
やDataFrame
自体の所有権をどう渡すか、ライフタイムをどう設定するか…まるで迷路のようです。コードが煩雑になり、エラーに頭を抱える日々でした。
2. 型変換の悪夢
Rustは静的型付けで型安全性が高いですが、その反面、PolarsのAnyValue
から具体的な型への変換が面倒です(DataFrameのcolumnなど)多用するunwrap
や?
演算子がコードを冗長担ってしまうように感じました。
結論:Rustは魅力的だが、冗長的なためデータ分析にはまだ向いていない
Rustは高性能で安全性の高い言語ですが、データ分析や機械学習の分野ではPythonのエコシステムにまだまだ及びません。PolarsやLightGBMのRustバインディングを使えば、理論的にはPandasを介さずに効率的なデータ処理が可能ですが、実際には所有権管理や型変換によってコードの冗長化が進み、開発効率が低下することが多いと感じます。
特にデータコンペなどPDCAをガンガン回す場面では、Rustの開発効率の低さがネックになることが多いなる気がします。
一方で、決まりきってあまり変更が無いような枠組みについては、Rustでの実装も検討に値するかもしれません。例えば、データ処理のパイプラインを作成しておいて、それを使い回すような場面などです。以前の記事(PythonとRustで並列処理を実装してみた ~パフォーマンス比較とその考察~ #rayon - Qiita)でRustのコードをPythonから呼び出すことも可能であることを試しているため、状況によってはRustの利用も検討したいと思います。