お前らのpandasの使い方は間違っている

この記事は[株式会社Nuco Advent Calendar 2022](https://qiita.com/advent-calendar/2022/nuco)の9日目の記事です。

# はじめに

いきなりお馴染みの「キャッチーでウィットでセンセーショナルな」タイトルで失礼します。

私自身、業務の中でpandasに大変お世話になっており、自戒も込めてpandasの「アンチパターン」をまとめてみました。

この記事を読んで、より快適なpandasライフを送っていただけると嬉しいです。

## 対象読者

- Pythonを使ったデータ分析や機械学習に携わる方
    - この記事はpandasの基本的な使い方を解説するものではないので注意してください。
- 表形式ファイルを加工する必要がある方
    - pandasの強みはリレーショナルなデータ全般です。必ずしもデータ分析や機械学習だけが守備範囲ではありません。

## pandasとは

- pandasの[公式ドキュメント](https://pandas.pydata.org/pandas-docs/stable/)の概要には、以下のように記載してあります。
>pandas is a Python package that provides fast, flexible, and expressive data structures designed to make working with "relational" or "labeled" data both easy and intuitive. It aims to be the fundamental high-level building block for doing practical, real world data analysis in Python. Additionally, it has the broader goal of becoming the most powerful and flexible open source data analysis / manipulation tool available in any language. It is already well on its way towards this goal.

日本語訳すると以下のような記載となります。
>pandasは、高速かつ柔軟で表現力豊かなデータ構造を提供するPythonのパッケージで、「リレーショナル」または「ラベル付き」データを簡単かつ直感的に扱えるように設計されています。pandasは、Pythonで実用的なデータ解析を行うための基本的で高いレベルの構成要素になることを目指しています。さらに、あらゆる言語で利用可能な最も強力で柔軟なオープンソースデータ分析・操作ツールになることをより大きな目標としています。すでに、この目標に向かって順調に進んでいます。

超絶ざっくりいうと **「Pythonで表形式のデータ扱う時に便利だよ〜」** ってことですね。

# 頻出編

使用した環境は以下の通りです。(pandasのアップデートにより挙動が変わる可能性があります。)

Python 3.8.12
numpy 1.20.3
pandas 1.3.4
scipy 1.5.3

データはレコメンドシステムの開発・評価に使われる[MovieLens](https://files.grouplens.org/datasets/movielens/ml-1m.zip)のデータセットを活用します。

```py
import pandas as pd

rating_file = pd.read_csv('ratings.csv') 

rating_file.head()
```

```sh
userId	movieId	rating	timestamp
0	1	1	4.0	964982703
1	1	3	4.0	964981247
2	1	6	4.0	964982224
3	1	47	5.0	964983815
4	1	50	5.0	964982931
```

## DataFrameをforループで使うな

### 処理が破滅的に遅くなる

小規模なデータであれば誤差レベルかもしれませんが、分析や機械学習へのデータ活用となると、繰り返し処理がボディーブローのように効いてきます。

数時間かかっていたfor文の処理が処理を変えるだけで数分で終わった、などよくある話です。

どのように直していくか、簡単な例で具体的にみてみます。

評価に応じて、「Excellent(4.5~5)・Average(2.5~4)・Poor(~2.5)」のラベルを付与したいとします。

```py
# NG
ratings = []
for idx, row in rating_file.iterrows():
    rating = row['rating']
    if rating > 4:
        ratings.append('Excellent')
    elif rating > 2:
        ratings.append('Average')
    else:
        ratings.append('Poor')

rating_file['evalation'] = ratings
```

時間を計測してみると、平均約**3.5秒**でした。
```sh
3.44 s ± 5.17 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

### iterrow以外にもapplyなど注意しろ

applyメソッドを使えば、上記のコードをfor文を排除して書き換えることができます。

```py
# Better
def map_rating_category(rating):
    if rating > 4:
        return 'Excellent'
    elif rating > 2:
        return 'Average'
    else:
        return 'Poor'

rating_file = pd.read_csv('ratings.csv')
rating_file["evalation"] = rating_file["rating"].apply(map_rating_category)
```
こちら時間を測ると、なんと**0.016秒**まで短くなります。最初の処理はこの約200倍の時間がかかっていたということですね。

```sh
16.7 ms ± 55.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

applyは非常に便利で汎用性が高く、処理もスッキリするため、非常に重宝されます。

しかし、for文の記載がなくなっただけで、本質的に **「行」をループしている点に変わりはありません**。

データ量が増大するにつれ、NumPyの利用が適切な場合も多々あるので、頼りすぎには注意です。

### ベクトル・行列演算であればNumPyの多次元配列を使え

DataFrameの「行」ではなく、NumPyの多次元配列を使うことでループ処理の速度を上げることができます。

```py
# Better
import numpy as np

def rating_category(df_):
    return np.select(
        condlist=(
            df_["rating"] > 4,
            df_["rating"] > 2,
        ),
        choicelist=("Excellent", "Average"),
        default="Poor"
    )

rating_file = pd.read_csv('ratings.csv')
rating_df = rating_file.assign(
    evaluation = lambda df_: rating_category(df_)
)
```

時間を測ってみると、**約0.008秒**とさらにapplyの半分程度の時間になりました。

```sh
8.35 ms ± 98.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

上記の例では数値比較の処理しかしていませんが、複雑な計算であればNumPyが圧倒的に便利です。

また、データ量が増えるにつれてこの差は数分・数時間と広がってきます。

### 複雑な計算であればSciPyも便利

特に機械学習のデータ処理の中では、疎行列を取り扱うことがあり、そういった際にはSciPyの出番です。

(疎行列とは、端的に言うと値のほとんどがゼロの行列のこと。)

```py
rating_file = pd.read_csv('ratings.csv')
rating_matrix = csr_matrix(
    pd.pivot_table(
    rating_file[['userId', 'movieId', 'rating']],
    index='userId',
    columns='movieId'
    ).fillna(0).values
)
```

これは疎行列の中でも圧縮行格納方式（Compressed Sparse Row: CSR）というやり方ですが、他にも圧縮列格納方式（Compressed Sparse Column：CSC)というやり方(行か列かの違い)などもあります。

とてつもなく処理が速くなるため、疎行列の取り扱いの際にはぜひ使ってみてほしいですが、本筋から外れそうなので詳細は割愛します。

## メモリを浪費するな

### データ処理で頻発するMemoryError

大規模なデータセットを取り扱っていると、このようなエラーに遭遇します。

```sh
MemoryError: Unable to allocate 3.26 GiB for an array with shape (323, 2710542) and data type float32
```

いくつか要因が考えられるので、みていきましょう。

### むやみやたらにDataFrameを生成するな

以下のように変数をどんどん増やしていくと、メモリ容量を圧迫するとともに、バグの要因になります。

下の方で扱いますが、うまく連鎖処理を使ってスッキリまとめましょう。

```py
# NG
rating_file1 = pd.read_csv('ratings.csv')
rating_file2 = rating_file1["rating"].apply(map_rating_category)
rating_file3 = ...
```

新たに生成されたDataFrameは、メモリを消費します。

```py
import sys

print(sys.getsizeof(rating_file1)/(1024**2), sys.getsizeof(rating_file2)/(1024**2),...
```

```sh
(3.0774078369140625, 9.246039390563965 ...
```

`sys.getsizeof()` 関数はオブジェクトのメモリサイズをバイト単位で返すので、新たに生成した`rating_file2`が約**9.24MB**消費していることが分かります。


### 加工に必要なカラムだけ使え

SQLを書くときなども、アスタリスク(*)で全データを取得しないよう注意されたことはありませんか？

基本的にデータ参照・加工の際には使用するカラムを指定することでメモリを節約しつつ、可読性も上げることができます。

```py
# Better
rating_file = pd.read_csv('ratings.csv')[['userId', 'movieId', 'rating']]

rating_file.head()
```

```sh
   userId  movieId  rating
0       1        1     4.0
1       1        3     4.0
2       1        6     4.0
3       1       47     5.0
4       1       50     5.0
```

上記の例だと、これだけでメモリ消費量は**3.0MB**から**2.4MB**まで減らすことができます。

### DataFrame生成時にカラムの型(dtype)を指定しろ

(上記までは本筋に焦点を当てるためにあえて指定していなかったのですが、)大規模データを取り扱うにあたり、DataFrameの各カラムのデータ型を最適化することで、パフォーマンスとメモリ使用量を改善することができます。

上記ラベル付与処理後のDataFrameのデータ型は、デフォルトだとこんな感じです。

```py
print(rating_df.dtypes, sys.getsizeof(rating_df)/(1024**2))
```

(合わせてメモリ消費量も出力してみると、約**9.2MB**でした。)

```sh
userId          int64
movieId         int64
rating        float64
timestamp       int64
evaluation     object
dtype: object 9.234766960144043
```

大きなデータセットを扱う場合、デフォルトの float64 や int64 のデータ型を float16 や int8 のような小さなデータ型に縮小することで、メモリ節約ができます。

また、特定の離散値を取る(値のユニーク数が少ない)データには、Category型を用いることで大きな効果が期待できます。


```py
# Better
dtyp = {
    'userId': 'int32',
    'movieId': 'int32',
    'rating': 'float16', 
    'timestamp': 'int64'
}
rating_file = pd.read_csv('ratings.csv', dtype=dtyp)
eval_rating_file = rating_file.assign(
    evaluation = lambda df_: rating_category(df_)
).assign(
    evaluation=lambda df_: df_["evaluation"].astype("category")
)

print(eval_rating_file.dtypes, sys.getsizeof(eval_rating_file)/(1024**2))
```

```sh
userId          int32
movieId         int32
rating       float16
timestamp       int64
evaluation    category
dtype: object 1.8275518417358398
```

dtype指定後の結果は**1.8MB**と、たったこれだけで約5分の1まで減らすことができました。

### 不要になったオブジェクトは削除しろ

こちらはpandasに限った話ではなく、Pythonでの処理全般に言えることですね。

必要なDataFrameのみを生成して行ってもメモリ不足に陥ることはあるので、使い終わったデータのメモリ領域を解放することが大事です。

```py
# Better
import gc

del rating_file
gc.collect()  
```

※Pythonのガベージコレクションについては説明を割愛します。詳細はPythonの[公式ドキュメント](https://docs.python.org/ja/3/library/gc.html)を参照ください。

# 応用編

## 警告を無視するな

これはpandasに限った話ではありません。

Warningは予期せぬ挙動やエラーを引き起こす原因となりますので、基本的にはWarningの指示に従って処理の見直しを行いましょう。

ここではpandasのWarningについてみていきます。

### 無視されがちなSettingWithCopyWarning

以下の作成済みデータに変更を加えてみたいと思います。

```py
print(rating_df.head())
```

「Average」となっている評価のみを「Good」に手っ取り早く変えてみます。

```
   userId  movieId  rating  timestamp evaluation
0       1        1     4.0  964982703    Average
1       1        3     4.0  964981247    Average
2       1        6     4.0  964982224    Average
3       1       47     5.0  964983815  Excellent
4       1       50     5.0  964982931  Excellent
```

```py
# NG
rating_df[rating_df['evaluation'] == 'Average']['evaluation'] = 'Good'
```

Warningが発生しました。

```sh
SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
```

警告はPythonの標準ライブラリwarningsモジュールで非表示にすることもできますが、やめてください。

以下、詳しく説明します。

### 「エラー」ではなく「警告」が伝えようとしているもの

- chained indexing / assignment（連鎖インデクシング・代入）

pandasはDataFrameを処理するときにViewまたはCopyを返しますが、この警告が出ている時は、「value」を「Copy」に「Setting」しようとしています。

つまり変更を加えたはずが、**オリジナルである「View」は変わらない**ということです。

ですので、先ほどの処理実行後も「View」は変化していないため、「Average」は「Good」に変わっていません。

```sh
   userId  movieId  rating  timestamp evaluation
0       1        1     4.0  964982703    Average
1       1        3     4.0  964981247    Average
2       1        6     4.0  964982224    Average
3       1       47     5.0  964983815  Excellent
4       1       50     5.0  964982931  Excellent
```

Warningの言う通りlocを使うと、「View」への処理であることが明示されるため、警告は消えます。

```py
rating_df.loc[rating_df['evaluation'] == 'Average', 'evaluation'] = 'Good'
```

そして、「Average」が「Good」に変わっています。

```sh
   userId  movieId  rating  timestamp evaluation
0       1        1     4.0  964982703       Good
1       1        3     4.0  964981247       Good
2       1        6     4.0  964982224       Good
3       1       47     5.0  964983815  Excellent
4       1       50     5.0  964982931  Excellent
```

このような警告が発生する原因を**chained assignment（連鎖代入）** と言います。

`rating_df[rating_df['evaluation'] == 'Average']` で「View」を作成、 
`['evaluation'] = 'Good'` で代入を行っていますが、
このような連鎖的な代入処理はNGということです。

- hidden chaining(隠れ連鎖)

連鎖代入はNGで代わりにlocを使えばOK！...あれば良いのですが、そういった単純な話ではありません。

以下のコードを実行し、「Average」の中でも評価3.0以下のものを「Good」としてみます。

```py
# NG
rating_df_view = rating_df.loc[rating_df['evaluation'] == 'Average'] 
rating_df_view.loc[rating_df_view['rating'] <= 3.0, 'evaluation'] = 'Good'
```

すると、またしてもWarningが出てしまいました。

```sh
SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
```

...いやloc使ってるじゃん！と思った方も多いと思います(少なくとも私は最初思いました)。

先ほどと異なるのは、変更が反映されている点です。

```py
print(rating_df_view.head())
```

「Average」の中で評価3.0以下のものだけが「Good」に変わっています。

```sh
   userId  movieId  rating  timestamp evaluation
0       1        1     4.0  964982703    Average
1       1        3     4.0  964981247    Average
2       1        6     4.0  964982224    Average
5       1       70     3.0  964982400       Good
7       1      110     4.0  964982176    Average
```

警告の指示通り[公式ドキュメント](https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy)をみてみると、詳細な説明がありますが、かなり込み入った話になるので端的に説明します。

locを使うと、「View」への処理であることが明示されるという話をしましたが、これは単純なケースのみです。

`rating_df.loc[rating_df['evaluation'] == 'Average']`
`rating_df_view.loc[rating_df_view['rating'] <= 3.0, 'evaluation']`

という連鎖は処理を複雑化します。そうした処理の中で、「View」を返すか「Copy」を返すかをpandasは保証してくれません。

予期しない挙動をとる可能性がある、ということで「エラー」ではなく「警告」を発しているのです。

どうすればいいかというと「View」か「Copy」か明確にしてあげることです。

例えば「Copy」であると明確化してあげることで、警告は消えます。

```py
# Better
rating_df_view = rating_df.loc[rating_df['evaluation'] == 'Average'].copy()
rating_df_view.loc[rating_df_view['rating'] <= 3.0, 'evaluation'] = 'Good'
```

### mutating(変異)とchaining(連鎖)

「むやみやたらにDataFrameを生成するな」では、別の変数を使いながら元々のDataFrameに処理を加えていきました。

変数を以下のように更新してしまえばメモリ使用量を節約できますが、これは**mutating(変異)** と言われ、推奨されない処理です。

特にノートブックで作業をしている時には、データ操作の順序が守られていないため、mutatingによる処理はバグが発生しやすく、またコードが煩雑になって読みにくいからです。

```py
# NG
excellent_rating_df = rating_file.assign(
    evaluation = lambda df_: rating_category(df_)
)
excellent_rating_df = excellent_rating_df.assign(
    evaluation=lambda df_: df_["evaluation"].astype("category")
)
excellent_rating_df = excellent_rating_df.groupby('evaluation').get_group('Excellent')
```

groupbyによる集計処理やpipeによるイテレーション処理など、**chaining(連鎖)** を使って一気通貫で処理してしまうようにしましょう。

```py
# Better
excellent_rating_df = rating_file.assign(
    evaluation = lambda df_: rating_category(df_)
).assign(
    evaluation=lambda df_: df_["evaluation"].astype("category")
).groupby('evaluation').get_group('Excellent')
```

このようにDataFrameを変換することで、各pandasメソッドの適切な適用が保証され、バグのリスクを軽減することができます。

先ほど「連鎖代入」「隠れ連鎖」のリスクを説明しましたが、「連鎖」自体は悪ではないので、どんどん活用してください。

## 「とりあえずpandasで処理しよう」はやめろ

### 代わりにjsonとかcsv使えばいいのでは？

json形式のファイルであれば、Pythonの辞書を使って処理する方が高速かつメモリ容量も消費しません。

簡単な処理であれば、普通のファイル処理でパパッと処理してしまうのもありです。

```py
with open('ratings.csv', 'r') as f:
    rating_file = f.read()
```

また、csvファイルであれば、Pythonの標準ライブラリにcsvというモジュールがあるので、そちらを使うのも手です。

```py
import csv

with open('ratings.csv', 'r') as f:
    rating_reader = csv.DictReader(f)
    ratings = [row for row in rating_reader]

# ratingsに処理を加えたのち、json形式で保存する

with open('ratings.json', 'w') as f:  
    json.dump(ratings, f)
```

### SQLでも集計・ソートなどの処理は可能

データをファイルではなくSQLを使って抽出する場合、カラムにキーが設定されていれば集計やソート処理も高速に行ってくれます。

`GROUP BY` や `ORDER BY`など効率的に使いましょう。

この場合、わざわざpandasで処理しなくとも良いはずです。

## pandasでデータを扱う際にはNaN(欠損値)を見逃すな

実データの中には、定義書に「文字列型」と書いてあってもpandasによってfloat型にされてしまうケースがあります。

特に注意が必要なのが、数値を文字列にした形式のIDで、「1234356」のような数値の文字列は、そのカラムに欠損があると「NaN」が入ってきます。(意外にこのケースは多いです。)

NaNがあると文字列型からfloat型に自動変換されてしまうため、「1234356.0」のような形になり、ID同士が紐づかなくなってしまう可能性があります。

接頭部分が「0」から始まる「0000101」などの文字列も自動的に「101.0」のようなfloat型に変換されてしまうので、注意が必要です。

pandasに限った話ではありませんが、データ型には注意を払うようにしましょう。

# まとめ

## pandasが力を発揮する場面

さんざんアンチパターンを挙げてきたので、「pandasは使わない方が良いのでは？」と考える方もいらっしゃるかもしれませんが、そんなことはありません。むしろどんどん使っていただきたいくらいです。

pandasはデータ解析に必要な汎用的な操作をまとめて提供してくれる優秀なインターフェースであり、Pythonでデータを加工・可視化する上では欠かせないツールですが、
あらゆる処理を全て行うことのできる「銀の弾丸」ではありません。

用法を守って、正しく効率的に利用することを心がけましょう。

# 最後に

弊社では、経験の有無を問わず、社員やインターン生の採用を行っています。

興味のある方は[こちら](https://www.recruit.nuco.co.jp/)をご覧ください。