Import を変更するだけで高速化!? Pandas 互換ライブラリ FireDucks を検証する

# 1. はじめに

　本記事では、Import を書き換えるだけで高速化できる、Pandas 互換のライブラリ **FireDucks** が公開されたので、実行速度やメモリ消費量等を検証してみたいと思います！

https://fireducks-dev.github.io/ja/

（NECさんが提供されているので日本語のドキュメントが豊富です。）

# 2. 環境準備

　FireDucks のライブラリは現在 Linux のみで提供されているようなので、Windows PC上の WSL2+Docker 環境で実行を行いました。

* [pip のfireducksライブラリページ](https://pypi.org/project/fireducks/)

使用した Dockerfile はこちらです。

```dockerfile
FROM python:3.10-slim

# OSのパッケージ更新およびtimeのインストール
RUN apt-get update && \
    apt-get install -y time && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# pipのアップグレードおよび必要なPythonパッケージのインストール
RUN pip install --upgrade pip && \
    pip install --no-cache-dir pandas fireducks scikit-learn
```

<details><summary> :pushpin: 詳しい Docker 環境での実行方法</summary><div>

以下の docker-compose.yml ファイルを準備し，

```yml: docker-compose.yml
version: '3'
services:
  playground:                 
    container_name: 'fireducks-python'  # コンテナ名
    build:
      dockerfile: ./Dockerfile

    restart: always           
    working_dir: '/workspace' 
    tty: true                 
    volumes:
      - ./workspace:/workspace
```

以下を実行してください。

1. docker-composeのビルド&起動
     ```bash
     docker-compose up -d --build
     ```
2. コンテナの中に入る (Pythonファイルはworkspaceディレクトリに置いてください)。
     ```bash
     docker exec -it fireducks-python /bin/bash
     ```

</div></details>

# 3. 実行時間を比較する

　ローカル環境で実行している場合は、以下のコマンドから fireducks ライブラリのインストールをお願いします。

```
pip install fireducks
```

## 3.1. 検証用のコード

　実行速度の計算用に、テーブルデータの様々な操作を行うコードを作成しました。このコードで、使用するライブラリを Pandas から FireDucks に変更した場合に、どのくらい速くなるのかを検証します。

まず Pandas 用のコードはこちらです。

```python: speed_test.py
import pandas as pd
import numpy as np
import time

def create_random_dataframe(N):
    return pd.DataFrame({
        'A': np.random.randn(N),
        'B': np.random.randn(N),
        'C': np.random.choice(['X', 'Y', 'Z'], N),
    })

def perform_filtering(df):
    start_time = time.time()
    df_filtered = df[df['A'] > 0.5]
    print("pandas filtering:", time.time() - start_time)

def perform_groupby(df):
    start_time = time.time()
    grouped = df.groupby('C').mean()
    print("pandas groupby:", time.time() - start_time)

def perform_sorting(df):
    start_time = time.time()
    sorted_df = df.sort_values(by='B')
    print("pandas sorting:", time.time() - start_time)

def perform_join(df):
    start_time = time.time()
    df1 = df[['A', 'B']].copy()
    df2 = df[['C']].copy()
    joined_df = df1.join(df2)
    print("pandas join:", time.time() - start_time)

def perform_fillna(df):
    start_time = time.time()
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    filled_df = df_na.fillna(0)
    print("pandas fillna:", time.time() - start_time)

def perform_dropna(df):
    start_time = time.time()
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    dropped_df = df_na.dropna()
    print("pandas dropna:", time.time() - start_time)

def perform_calculation(df):
    start_time = time.time()
    df['D'] = df['A'] + df['B']
    print("pandas calculation:", time.time() - start_time)

def main():
    N = 50000000
    df = create_random_dataframe(N)
    perform_filtering(df)
    perform_groupby(df)
    perform_sorting(df)
    perform_join(df)
    perform_fillna(df)
    perform_dropna(df)
    perform_calculation(df)

if __name__ == "__main__":
    main()
```

FireDucks 用のコードはこちらです。

<details><summary>:page_facing_up: コード（長くなるので折りたたんでいます）</summary><div>

```python: speed_test_fd.py
import fireducks.pandas as pd
import numpy as np
import time

def create_random_dataframe(N):
    return pd.DataFrame({
        'A': np.random.randn(N),
        'B': np.random.randn(N),
        'C': np.random.choice(['X', 'Y', 'Z'], N),
    })._evaluate()

def perform_filtering(df):
    start_time = time.time()
    df_filtered = df[df['A'] > 0.5]._evaluate()
    print("pandas filtering:", time.time() - start_time)

def perform_groupby(df):
    start_time = time.time()
    grouped = df.groupby('C').mean()._evaluate()
    print("pandas groupby:", time.time() - start_time)

def perform_sorting(df):
    start_time = time.time()
    sorted_df = df.sort_values(by='B')._evaluate()
    print("pandas sorting:", time.time() - start_time)

def perform_join(df):
    start_time = time.time()
    df1 = df[['A', 'B']].copy()
    df2 = df[['C']].copy()
    joined_df = df1.join(df2)._evaluate()
    print("pandas join:", time.time() - start_time)

def perform_fillna(df):
    start_time = time.time()
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    filled_df = df_na.fillna(0)._evaluate()
    print("pandas fillna:", time.time() - start_time)

def perform_dropna(df):
    start_time = time.time()
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    dropped_df = df_na.dropna()._evaluate()
    print("pandas dropna:", time.time() - start_time)

def perform_calculation(df):
    start_time = time.time()
    df['D'] = (df['A'] + df['B'])._evaluate()
    print("pandas calculation:", time.time() - start_time)

def main():
    N = 50000000
    df = create_random_dataframe(N)
    perform_filtering(df)
    perform_groupby(df)
    perform_sorting(df)
    perform_join(df)
    perform_fillna(df)
    perform_dropna(df)
    perform_calculation(df)

if __name__ == "__main__":
    main()
```

</div></details>

**FireDucksを使用する場合の変更点**

* import文を `import pandas as pd` から `import fireducks.pandas as pd` に変更してください。
    * (使い方はこれだけでOKなので，すごく楽です。)
* ユーザガイドの [実行モデル](https://fireducks-dev.github.io/ja/docs/user-guide/02-exec-model/) に説明があるのですが、FireDucksは複数の処理をあとからまとめて実行するような機能となっているようです。そのためメソッドの処理時間を計測するには、 `pd.read_csv("data.csv")._evaluate()` のように、`_evaluate()`をつけて即時実行するように変更する必要があります。

## 3.2. テーブルデータの操作時間の比較

　先ほどの2種類のコードを以下のコマンドから実行し、PandasとFireDucksの各テーブル操作の処理時間を比較したのが以下の表になります。

```
/usr/bin/time -v python speed_test.py
```

:::note info
上記の Linux関数 `time` は、メモリ使用量等を計るために使用しています。
:::

| 処理・統計情報                   | pandas                 | fireducks              | 比較結果                           |
|--------------------------------|-----------------------|-----------------------|----------------------------------|
| Filtering                     | 0.426秒               | 0.075秒               | fireducksの方が約5.7倍高速       |
| Groupby                       | 1.409秒               | 0.170秒               | fireducksの方が約8.3倍高速       |
| Sorting                     | 15.506秒              | 2.101秒               | fireducksの方が約7.4倍高速       |
| Join                        | 0.938秒               | 2.469秒               | pandasの方が約2.6倍高速          |
| Fillna                       | 6.245秒               | 5.155秒               | fireducksの方が約1.2倍高速       |
| Dropna                       | 6.779秒               | 5.146秒               | fireducksの方が約1.3倍高速       |
| Calculation                 | 0.111秒               | 0.029秒               | fireducksの方が約3.8倍高速       |
||||
| ファイル全体の実行時間                  | 0:34.64               | 0:19.31               | fireducksの方が約1.8倍高速       |
| 最大メモリ使用量                | 4,678,272KB (4.47GB)  | 13,576,508KB (13GB)   | fireducksの方が約2.9倍メモリを使用|

**結果**

1. Joinを除く各機能で、FireDucksの方が大幅に実行速度が速くなりました。また、コード全体の実行時間も 34s → 19sと **FireDucks の方が1.8倍速い** 結果になりました。
    * CPUの使用量をモニタしたところ、Pandasは実行時に1コアしか使用していないですが、FireDucksは複数コアを使用していて CPU 使用率が100%になることがありました。FireDucksは複数コアを効率よく使用することにより、高速化しているのだと思います。
2. 一方で、メモリ使用量は **FireDucks の方が3倍近く多く消費** しています (実際に、FireDucksの方だけメモリ不足でプログラムが落ちたので、一度データサイズ N の数を減らしました)。

FireDucks を使う場合はメモリ使用量が増えるため、良いスペックのPCやサーバを用意する必要がありますが、データサイエンティストの給料の方がメモリより高いと思うので、メモリを増設してでも、時間が半分に短縮できればすごく有用ではないかと思います。

::: note info
上記の結果は、当方のPC環境およびサンプルコードによるものです。FireDucks の性能を十分に引き出せていない可能性があります。あくまで参考でお願いします。
:::

## 3.3. ファイル全体の実行結果

　先ほどは各関数の処理結果を計測するために、FireDucks のコードの方で `_evaluate()` により細切れに関数を実行していました。それにより遅くなっている可能性があるため、計測用のコードをすべて除いて、ファイル全体での実行時間を比較しました。

<details><summary>:page_facing_up: コード（折りたたんでいます）</summary><div>

```python
import pandas as pd
import numpy as np

def create_random_dataframe(N):
    return pd.DataFrame({
        'A': np.random.randn(N),
        'B': np.random.randn(N),
        'C': np.random.choice(['X', 'Y', 'Z'], N),
    })

def perform_filtering(df):
    df_filtered = df[df['A'] > 0.5]

def perform_groupby(df):
    grouped = df.groupby('C').mean()

def perform_sorting(df):
    sorted_df = df.sort_values(by='B')

def perform_join(df):
    df1 = df[['A', 'B']].copy()
    df2 = df[['C']].copy()
    joined_df = df1.join(df2)

def perform_fillna(df):
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    filled_df = df_na.fillna(0)

def perform_dropna(df):
    df_na = df.copy()
    df_na.loc[df_na.sample(frac=0.1).index, 'A'] = np.nan
    dropped_df = df_na.dropna()

def perform_calculation(df):
    df['D'] = df['A'] + df['B']

def main():
    N = 50000000
    df = create_random_dataframe(N)
    perform_filtering(df)
    perform_groupby(df)
    perform_sorting(df)
    perform_join(df)
    perform_fillna(df)
    perform_dropna(df)
    perform_calculation(df)

if __name__ == "__main__":
    main()
```

FireDucksの方は1行目を `import fireducks.pandas as pd` に変更してください。

</div></details>

| 処理・統計情報                   | pandas                 | fireducks              | 比較結果                           |
|----------------------------------------|-----------------------|-----------------------|-------------------------------|
| ファイル全体の実行時間             | 0:35.19               | 0:14.15              | fireducksの方が約2.5倍高速       |
| 最大メモリ使用量       | 4,679,576KB (4.46GB)  | 12,816,680KB (12.2GB)| fireducksの方が約2.7倍メモリを使用|

Pandasは実行時間はあまり変化していませんが、FireDucksはさらに速くなり、Pandas よりも **2.5倍高速** になりました！

## 3.4. モデルの訓練の実行

　ChatGPTに相談したところ、機械学習のモデル（例えばランダムフォレスト）の訓練には numpy を使っているため、おそらく FireDucks に変更しても速度には影響ないとのことでしたが、念のため比較を実施しました。

　使用したコードはこちらです。データセットには kaggle の [Santander Customer Transaction Prediction Dataset](https://www.kaggle.com/datasets/lakshmi25npathi/santander-customer-transaction-prediction-dataset/) を使用しました。

```python
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
import time

# 1. データの読み込み
start_time = time.time()
train = pd.read_csv('data/train.csv')
X = train.drop(columns=['ID_code', 'target'])
y = train['target']
print("Data loading:", time.time() - start_time, "seconds")

# 2. モデルの学習
start_time = time.time()
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
clf = RandomForestClassifier(n_estimators=100, random_state=42)
clf.fit(X_train, y_train)
print("Model training:", time.time() - start_time, "seconds")

# 3. モデルの評価
start_time = time.time()
preds = clf.predict_proba(X_val)[:, 1]
roc_score = roc_auc_score(y_val, preds)
print(f"Validation ROC-AUC: {roc_score}")
print("Model evaluation:", time.time() - start_time, "seconds")
```

実行結果の比較はこちらです。

| 処理・統計情報                   | pandas                 | fireducks              | 比較結果                           |
|--------------------------------|-----------------------|-----------------------|----------------------------------|
| データ読み込み時間                 | 2.27秒                | 0.80秒                | fireducksの方が約2.8倍高速       |
| モデル訓練時間                   | 595.72秒              | 596.69秒              | ほぼ同じ                          |
| モデル評価時間                   | 1.10秒                | 1.03秒                |  ほぼ同じ      |
||||
| ファイル全体の実行時間            | 9:59.69               | 9:59.14               | ほぼ同じ                          |
| 最大メモリ使用量                  | 1,572,952KB (1.5GB)   | 2,331,356KB (2.2GB)   | fireducksの方が約1.5倍メモリを使用|

ChatGPTが言っていたようにモデルの訓練時間の短縮には影響がありませんでした。

メモリ消費量が増えることを考えると、FireDucks は大規模データの前処理や探索的データ分析（EDA）などのフェーズで使用するのがよいと思います。

# 4. おわりに

　Importをちょっと変えるだけで、倍以上速くなるのはすごく便利だと思います（エラーなどは一切出ませんでした）。ただ私のPCが、ただのゲーミングPCのため、能力不足により十分な真価を引き出せなかったようにも思います。
　ちょっとした計算機で、毎日 Pandas をぶん回している方はぜひ試していただければ！

::: note info
（今回使ったサンプルコードは、ChatGPTと相談しつつすべて自作しましたが、そういえばどこかの論文に比較用のサンプルコードが公開されているかもと、書き終わってから気づきました。）
:::