PolarsのEagerモードとLazyモードの違いについて

みなさん、こんにちは！

今回は、「新時代のデータ操作ライブラリ」として話題のPolarsについて解説します。
「pandasより早いらしい」という話はデータエンジニアであれば耳にしたことがある方が多いのではないでしょうか。

公式[https://pola.rs/](https://pola.rs/)によると、
"エンジンはRustで書かれており、マルチスレッドでの効率的な処理が可能。また、カラム指向の処理とベクトル化技術を駆使して、最新のプロセッサ上で高いパフォーマンスを実現。"
..だそうです。

色々な魅力がありそうなPolarsですが、今回はトピックを絞ってPolarsの機能の中でも大きな特徴ともいえる「**Lazy(遅延実行)モード**」について検証していきたいと思います。

## ２つのデータ処理モード
Polarsでは、**Eagerモード**と**Lazyモード**の2つの異なる実行モードが提供されています。
Eagerモードでは、**操作が即座に実行**され結果がすぐに得られます。
一方Lazyモードでは**操作が蓄積**され、実際の計算は明示的にcollect()を呼び出すまで遅延され、処理全体を最適化します。

## Polarsのscan_csv()とread_csv()の違い
Polarsには、CSVファイルを読み込むための2つの主要な関数があります。
それがscan_csv()とread_csv()です。この2つの関数は同じCSVファイルを読み込むためのものですが、どうやってデータを扱うかに違いがあります。
それぞれの違いは以下の通りです。

### read_csv()： すぐにデータを見たい時に
read_csv()は、CSVファイルを「DataFrameオブジェクト」として読み込んで結果を得るための関数です。
この関数を使うと、ファイル全体が一気にメモリに読み込まれます。
小規模なデータやすぐにデータを確認したい時に便利です。

### scan_csv()：大きなデータを効率よく扱いたい時に
一方、scan_csv()は、データを「LazyFrameオブジェクト」で読み込むための関数です。これは、ファイル全体を一気に読み込むのではなく、必要な部分だけを後で計算するというものです。大規模なデータセットを扱いたい時に効率的です。

では実際にデータフレーム操作を行い、2つのモードの違いを確認しましょう。
使用するデータセットは、Kaggleの[タイタニックコンペ](https://www.kaggle.com/competitions/titanic/data?select=test.csv )のtrainデータセットを使用します。

## 操作
①欠損値が存在する列の取得（各列毎の欠損値の割合の導出）
②数値列に存在する欠損値の補完（※今回は中央値を使用）
の２つの操作をLazyモードとEagerモードでそれぞれ実行し、データの読み込みを含めた実行時間の確認も合わせて行っていきます。
まずはライブラリをインポートしてから実行してみましょう！
```ruby:qiita.rb
import polars as pl
```

### ①-1：欠損値が存在する列の取得（各列毎の欠損値の割合の導出）/Lazyモード
```ruby:qiita.rb
%%time

lazy_train = pl.scan_csv("//content//train.csv")

column_names = lazy_train.collect_schema().names()

# データ処理の定義

query = (

lazy_train

# 各列の欠損値の数を取得

.with_columns([pl.col(column).null_count().alias(column + '_na_count') for column in column_names])

# 各列の欠損値の割合を計算

.with_columns([

(pl.col(column + '_na_count') / pl.len()).alias(column + '_na_ratio')

for column in column_names

])

# 欠損値の割合が0でない列をフィルタリング

.select([

pl.col(column + '_na_ratio')

for column in column_names

if lazy_train.select((pl.col(column).null_count() / pl.len()).alias(column + '_na_ratio')).collect()[0, 0] > 0

])

# 重複行を削除

.unique()

)

# 実行して結果を取得

lazy_result = query.collect()

# 結果の表示

print("LAZYモード：欠損値の割合が0以上の列")

lazy_result
```
#### ①-1実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/a13f99c0-6160-4eb8-8577-760683a356a2.png)

#### ①-2：欠損値が存在する列の取得（各列毎の欠損値の割合の導出）/Eagerモード
```ruby:qiita.rb
%%time

eager_train = pl.read_csv("//content//train.csv")

# データフレームの行数を取得

total_rows = eager_train.shape[0]

# 各列の欠損値の数を取得

na_counts = eager_train.null_count()

# 各列の欠損値の割合を計算

na_ratios = na_counts.select([(pl.col(column) / total_rows).alias(column) for column in na_counts.columns])

# 欠損値の割合が0でない列をフィルタリング

eager_result = na_ratios.select([column for column in na_ratios.columns if na_ratios[column][0] > 0])

print("EAGERモード：欠損値の割合が0以上の列")

eager_result
```
#### ①-2実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/6494f3eb-4069-4f63-b1a7-1194c9ac8047.png)

### ②-1：数値列に存在する欠損値の補完（※今回は中央値を使用）/Lazyモード
```ruby:qiita.rb
%%time

lazy_train = pl.scan_csv("//content//train.csv")

# 欠損値補完の対象列を指定

columns_to_fill = ['Age', 'Fare']

# 各列の中央値を計算し、欠損値を中央値で補完、そして各列の欠損値の数を取得する処理をまとめる

filled_and_na_counts = (

lazy_train

.with_columns([

pl.col(col).fill_null(pl.col(col).median()).alias(col)

for col in columns_to_fill

])

)

# 計算を実行

na_counts_filled = filled_and_na_counts.collect()

print("LAZYモード：['Age', 'Fare']の欠損値補完後のdf")

na_counts_filled.describe()
```

#### ②-1実行結果（describeの上部のみ切り取り）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/d96ede32-3656-47fe-9167-b60b7cef9c7f.png)

#### ②-2：数値列に存在する欠損値の補完（※今回は中央値を使用）/Eagerモード
```ruby:qiita.rb
%%time

eager_train = pl.read_csv("//content//train.csv")

# 欠損値補完の対象列を指定

columns_to_fill = ['Age','Fare']

# 各列の中央値を計算

median = eager_train.select([pl.col(col).median().alias(col) for col in columns_to_fill])

# 欠損値を中央値で補完するためのマッピングを作成

fill_values = {col: median[col][0] for col in columns_to_fill}

# 欠損値を中央値で補完

df_filled = eager_train.with_columns([pl.col(col).fill_null(fill_values[col]).alias(col) for col in columns_to_fill])

print("EAGERモード：['Age', 'Fare']の欠損値補完後のdf")

df_filled.describe()
```

#### ②-2実行結果（describeの上部のみ切り取り）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/e796c6b4-831f-4357-a4f0-f62e545fc573.png)

**●実行時間の比較**
| 処理 | 実行モード | total実行時間(ms) |
|:-|:-|:-:|
|  ①-1 | Lazy  |  	13.4 |
| ①-2  | 	Eager  |  43.2 |
| ①-1  | Lazy  | 	12.8  |
| ①-2  | 	Eager  |  	46.4 |

Lazyモードのほうが、通常の処理よりも約3倍早くなっていますね！

### 記法について
2つのモードの記述の違いはシンプルで、Lazyモードでは、**データ処理を１つにまとめて記述**しその処理を"**collect()**"で呼び出します。


また今回はcsvの読込みも含めた実行時間の確認を行いましたが、read_csv()で読み込んだデータフレームは".lazy()"関数でLazyFraemに変換することも可能です。
DataFrameオブジェクトに対しcollect()を使用すると下記のようにエラーになってしまいますので、ご注意ください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/bfb06861-77e8-43dc-adb4-b02b73d8b595.png)

以上、Polarsのモードについての検証でした。