Pandasでデータを省メモリで扱う工夫

手元のパソコンのメモリでそこそこ大きなデータ処理をしたいが、何とかpandasで乗り切りたいとき、最後のあがきとしてできる省メモリのやり方や関連する工夫をまとめてみます。普段から意識して使うようにすれば、無駄に計算資源を消費することなく分析ができるので、色々とメリットがあるのではないかと思います。
ただ、どうしても限界がありうまくいかなければ、そもそもメモリを増設するか、AWSやGCPなどのクラウドサービスを活用することをお勧めします。

#検証データ
やり方だけ書いても現実的でないため、今回は有名な[タイタニックの生存者予測のデータ](https://www.kaggle.com/c/titanic/data)を使用します。データ量は全く多くないのですが、その点ご容赦ください。やり方だけ気になる場合はこのパートは読み飛ばして頂いて大丈夫です。
##分析環境
OS：市販の安いノートPCに入っているwindows 10 Home
環境：上記OSに[KaggleのDocker](https://qiita.com/ssl_ds_sps/items/66bf07af483b34f7fe1a)をマウントさせて立ち上げたJupyter notebook
python：Python 3.6.6 :: Anaconda, Inc.
分析時のフォルダ構成は以下のように、notebookを配置するフォルダと、使用データを配置するフォルダにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328424/6c1e6ef2-8e6b-6268-0e15-f6e890364fb3.png)

まずはダウンロードしたタイタニックのデータをinputフォルダに配置し、データを読み込む準備をします。jupyter notebookで以下のコマンドを実行します。

```python
#ライブラリのインポート
import os, gc, pickle, datetime, sys
import numpy as np
import pandas as pd

#使用データのパスと、データ確認
INPUTPATH = '../input'
print(os.listdir(INPUTPATH))

#実行結果
#> ['gender_submission.csv', 'test.csv', 'train.csv']
```
するとデータが確認できました。train.csvを読み込みます。

```python

df = pd.read_csv(f'{INPUTPATH}/train.csv')
df.head(1)
```

#データ型を変える
データの最大、最小値や小数精度に大きな要求がなければ、倍精度から単精度にデータ型にすることでメモリの消費を減らすことができます。
そこで検証データを例に見てみます。まずはtrain.csvの読み込まれたデータ型を確認します。特に指定しなければ、整数値はint64、小数はfloat64で読み込まれます。

```python

df.dtypes
#以下出力結果
#PassengerId      int64
#Survived         int64
#Pclass           int64
#Name            object
#Sex             object
#Age            float64
#SibSp            int64
#Parch            int64
#Ticket          object
#Fare           float64
#Cabin           object
#Embarked        object
#dtype: object
```
早速データ型を変えてみましょう。
## .astype()
一番簡単な方法は.astype()を使用することです。
例えばタイタニックのチケット料金であるFareをfloat64からfloat32に変えてみましょう。結果、データサイズが半分くらいになっています。

```python

dfare1 = df.Fare.nbytes
print('float64の時のFareのデータサイズは：{:.2f} KB'.format(dfare1/1024))
dfare2 = df.Fare.astype('float32').nbytes
print('float32の時のFareのデータサイズは：{:.2f} KB'.format(dfare2/1024))
print('データ型を変えることで{:.2f}%ファイルサイズを減らせました'.format(100*(1-dfare2/dfare1)))

##以下出力結果
#float64の時のFareのデータサイズは：6.96 KB
#float32の時のFareのデータサイズは：3.48 KB
#データ型を変えることで50.00%ファイルサイズを減らせました
```
ただし、データ型を変える時にもともとのデータの最大、最小値や小数点の精度に分析上影響がないことを確かめてから行う必要があります。例えば、float型であれば、小数点以下の桁数が少なくなっても分析上影響は無視できる範囲である、int型であれば、整数の範囲がしっかりとデータ型を変えても収まるようにする必要があります。そうでなければ、極端な例ですが、以下のようにPassengerIdというカラムの本来の最大値は891にもかかわらずデータ型を変えると、その[データ型で表現できる最大値](https://docs.scipy.org/doc/numpy-1.10.0/user/basics.types.html)に張り付くことになってしまい、数字そのものが変わってしまうので要注意です。
個人的には、よっぽど小さな小数や大きな数を扱わない限りまずは倍精度（64bit）を単精度(32bit)にします。これでも改善しない場合は個別に数字の範囲や小数点の精度要求を見てちょっと修正します。もしくは、直接後述の関数を用いることで、ここら辺の判断をしてくれます。

```python

df.PassengerId.max()
#出力結果
#891

df.PassengerId.astype('int8').max()
#出力結果
#127
```
##read_csvで一気にデータ型を指定する
pandasのread_csvにdtypeというデータ読み込み時に各カラムのデータ型を指定できるオプションがあるので、それを使うと便利です。ただしそれには、あらかじめカラム名と対応するデータ型を定義する辞書を作成する必要があります。そのため一度データを読み込んでおいて、`df.describe()`でデータ型を変えても影響がないことをチェックしたら、`df.dtypes`の結果を辞書にします。64を32に置換、また、dtypeをnp.dtypeと置換（表示するときはdtypeと表示されるが、入力時はnp.dtypeでないといけない）するだけで、比較的手間をかけずに辞書が作れます。

```python

dtype_dict=df.dtypes.to_dict()
print(dtype_dict)
dtype_dict ={'PassengerId': np.dtype('int32'),
 'Survived': np.dtype('int32'),
 'Pclass': np.dtype('int32'),
 'Name': np.dtype('O'),
 'Sex': np.dtype('O'),
 'Age': np.dtype('float32'),
 'SibSp': np.dtype('int32'),
 'Parch': np.dtype('int32'),
 'Ticket': np.dtype('O'),
 'Fare': np.dtype('float32'),
 'Cabin': np.dtype('O'),
 'Embarked': np.dtype('O')}
df = pd.read_csv(f'{INPUTPATH}/train.csv',dtype=dtype_dict)
df.dtypes
##出力結果
#PassengerId      int32
#Survived         int32
#Pclass           int32
#Name            object
#Sex             object
#Age            float32
#SibSp            int32
#Parch            int32
#Ticket          object
#Fare           float32
#Cabin           object
#Embarked        object
#dtype: object
```

##便利な関数を使う
約1年前の[Kaggleのコンペ](https://www.kaggle.com/fabiendaniel/elo-world)で使われた便利な関数を使うこともできます。私もこのコンペに参加していましたが、ありがたく使わせていただきました。[こちらの記事](https://qiita.com/hiroyuki_kageyama/items/02865616811022f79754)でも紹介されています。詳細はこの記事にもありますが、データの最小・最大値を踏まえた判断をしてくれています。下あるコードは、さらに一部をカスタマイズしております。

- まずは、データ型がobject型でないnumeric型に対して倍精度を変えています。
- また、データ型の中にdatetimeやcategory型が含まれている場合はスキップするようにエラーを回避しています。
- 最後にobject型をcategory型に変えています。

```python

#関数を使うときに下記importが必要
from pandas.api.types import is_datetime64_any_dtype as is_datetime
from pandas.api.types import is_categorical_dtype
#関数の定義
def reduce_mem_usage(df, use_float16=False):
    """ iterate through all the columns of a dataframe and modify the data type
        to reduce memory usage.        
    """
    start_mem = df.memory_usage().sum() / 1024**2
    print('Memory usage of dataframe is {:.2f} MB'.format(start_mem))
    for col in df.columns:
        if is_datetime(df[col]) or is_categorical_dtype(df[col]):
            # skip datetime type or categorical type
            continue
        col_type = df[col].dtype
        if col_type != object:
            c_min = df[col].min()
            c_max = df[col].max()
            if str(col_type)[:3] == 'int':
                if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max:
                    df[col] = df[col].astype(np.int8)
                elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max:
                    df[col] = df[col].astype(np.int16)
                elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max:
                    df[col] = df[col].astype(np.int32)
                elif c_min > np.iinfo(np.int64).min and c_max < np.iinfo(np.int64).max:
                    df[col] = df[col].astype(np.int64)  
            else:
                if use_float16 and c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max:
                    df[col] = df[col].astype(np.float16)
                elif c_min > np.finfo(np.float32).min and c_max < np.finfo(np.float32).max:
                    df[col] = df[col].astype(np.float32)
                else:
                    df[col] = df[col].astype(np.float64)
        #else:
            #df[col] = df[col].astype('category')
    end_mem = df.memory_usage().sum() / 1024**2
    print('Memory usage after optimization is: {:.2f} MB'.format(end_mem))
    print('Decreased by {:.1f}%'.format(100 * (start_mem - end_mem) / start_mem))
    return df
```

関数を使ってみます。この場合読み込んだtrain.csvのメモリ使用量を約44%減らすことができました。

```python
df = reduce_mem_usage(df, use_float16=False)
#出力結果
#Memory usage of dataframe is 0.08 MB
#Memory usage after optimization is: 0.05 MB
#Decreased by 43.7%
```

##Category型に変更
上で紹介したreduce_mem_usage()関数のカスタマイズした分の最後のポイントのcategory型に変えるところもメモリ節約になります。
試しにタイタニックデータの性別をobject型からcategory型に変更してみても、この場合約半分減らすことができます。

```python
dsex1 = df.Sex.nbytes
print('objectの時のFareのデータサイズは：{:.2f} KB'.format(dsex1/1024))
dsex2 = df.Fare.astype('category').nbytes
print('categoryの時のFareのデータサイズは：{:.2f} KB'.format(dsex2/1024))
print('データ型を変えることで{:.2f}%ファイルサイズを減らせました'.format(100*(1-dsex2/dsex1)))
##出力結果
#objectの時のFareのデータサイズは：6.96 KB
#categoryの時のFareのデータサイズは：3.68 KB
#データ型を変えることで47.17%ファイルサイズを減らせました
```
ただし、category型に変える時は注意点があり、欠損値があると追加の処理が必要になる点です。例えば、乗客客室のCabinというカラムには欠損値が含まれています。

```python
df.isnull().sum()
##出力結果
#PassengerId      0
#Survived         0
#Pclass           0
#Name             0
#Sex              0
#Age            177
#SibSp            0
#Parch            0
#Ticket           0
#Fare             0
#Cabin          687
#Embarked         2
#dtype: int64
```
これをcategory型に変更してから欠損値を埋めるとします。すると、「ValueError: fill value must be in categories」と置換しようとしている「null」に対応するカテゴリーがないというエラーになってしまいます。

```python
df.Cabin = df.Cabin.astype('category')
df.Cabin = df.Cabin.fillna('null')
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328424/7c618896-6aac-0a17-6986-dc2b7b8404f0.png)
徹底的にメモリを減らさなくても済む、ないし、object型はデータの小数の場合は、そのままobject型にするか、reduce_mem_usage()関数のcategory型への変換部分は省略するのもありでしょう。また、必ずしもcategory型にしてメモリを減らせるとも限りません。
一方、object型もcategory型に変換して徹底的にメモリを減らしたいという場合は、欠損値補完に対するエラーは、2通り対処方法があります。普通に考えると、1.の方法のようにあらかじめ欠損値補完してからcategory型に変換すれば、エラーは出ませんが、分析途中で、必要に応じて欠損値補完したい場合は2.の方で対応するのが小回り効きそうです。

1. 欠損値の保管をしてから、category型に変換する
2. あらかじめcategory型に変換してしまった場合は、新たにnullに対応するカテゴリーを追加して、欠損値補完をする。pandasの[add_categories](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.cat.add_categories.html)を使用します。

```python
#1.の方法
df.Cabin = df.Cabin.fillna('null')
df.Cabin = df.Cabin.astype('category')

#2.の方法
df.Cabin = df.Cabin.cat.add_categories('null').fillna('null')
```

#Sparse Data Structureを使う
カテゴリ型の変数をダミー変数にエンコードするときに、メモリを減らすためにpandasの[Sparse Data Structure](https://pandas.pydata.org/pandas-docs/stable/user_guide/sparse.html)を使うことでメモリ削減効果が見込めます。データが0,1のみからなるダミー変数もカラム数が増えると、Sparse Data Structureにするとメモリ削減効果は結構あります。Sparse Data Structureでは1のあるデータの位置を記録し、それ以外の0の部分はデータとして保存せずに圧縮してくれているのです。ただし、Sparse Data Structureにすることで、不便になる点もあるので、併せて後述します。

##普通にダミーエンコードしてみる
その前に、タイタニックデータのカテゴリ変数で水準数が多い（カーディナリティが高い）ものをあえて選らんで効果を見てみます。

```python
for var in df.dtypes[df.dtypes =='object'].index.tolist():
    print('Unique level of '+var+' is {:}'.format(len(df[var].unique())))
#出力結果
#Unique level of Name is 891
#Unique level of Sex is 2
#Unique level of Ticket is 681
#Unique level of Cabin is 148
Unique level of Embarked is 4
```
さすがにNameは名前なので、対象から外しましょう（笑）Ticket, Cabin, Embarkedをエンコードの対象にしてみます。ダミーエンコードはpandasの[get_dummies()](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.get_dummies.html)を使うと簡単にできます。
データの行数が891行なのに、列数が834と恐ろしくスパースなデータになりました。メモリの使用量は`df_dummies.info()`で確認すると、約726KBです。(`sys.getsizeof(df_dummies)/1024`や`df_dummies.memory_usage().sum()/1024`でも同様にKB単位でデータサイズを取得できます)

```python
dummy_list = ['Ticket', 'Cabin', 'Embarked']
df_dummies = pd.get_dummies(df[dummy_list], dummy_na=True, sparse=False, prefix = dummy_list)
df_dummies.shape

##出力結果
#(891, 834)

df_dummies.info()
##出力結果
#<class 'pandas.core.frame.DataFrame'>
#RangeIndex: 891 entries, 0 to 890
#Columns: 834 entries, Ticket_110152 to Embarked_nan
#dtypes: uint8(834)
#memory usage: 725.8 KB
```
##Sparse Data Structureにエンコードする
次に、Sparse Data Structureを活用してメモリを減らしてみます。get_dummies()にSparseというオプションがあり、通常はFalseにしていますが、これをTrueに変えます。結果726KBから13KBとおよそ98%もメモリを減らすことができました。

```python
df_dummies2 = pd.get_dummies(df[dummy_list], dummy_na=True, sparse=True, prefix = dummy_list)
df_dummies2.info()
##出力結果
#<class 'pandas.core.frame.DataFrame'>
#RangeIndex: 891 entries, 0 to 890
#Columns: 834 entries, Ticket_110152 to Embarked_nan
#dtypes: Sparse[uint8, 0](834)
#memory usage: 13.2 KB
```
Sparse Data Structureを使うことで大きくメモリを圧縮することができました。ただ、注意することがあります。それは、圧縮することによって通常のデータ構造で使えていたpandasのメソッドが使えなくなる場合があるという点です。例えば、作成したダミー変数にTicket_110152というものがあり、全データのうちで1になっている数の合計を取りたいとします。普通のDataFrameであれば.sum()で済むのですが、Sparse Data Structureの場合はデータが圧縮されていることでエラーになってしまいます。

```python
#普通の作り方
df_dummies.Ticket_110152.sum()
#出力結果
#3

#Sparse Data Structure (Sparse Data Structureで作成したのはdf_dummies2)
df_dummies2.Ticket_110152.sum()
#出力結果
#TypeError: sum() got an unexpected keyword argument 'min_count'
```

このようなエラーを回避するためには、一度Sparse Data Structureから元のデータ構造に戻した方がよいです。今回はpython3.6ということで、np.asarrayで元に戻しますが、ptyhon3.7以降は.to_dense()というメソッドでもっとシンプルにできます。

```python
np.asarray(df_dummies2.Ticket_110152).sum()
#出力結果
#3
#python 3.7以降は、
#df_dummies2.Ticket_110152.to_dense().sum()でもいけるはずです
```

#まとめ
pandasのdataframeを省メモリで扱うときにできる工夫と、その時の注意点をまとめてみました。


- 倍精度(64bit)から単精度以下(~32bit)にとデータ型を変えることでメモリを減らせますが、小数の精度やデータ値の範囲に注意して、データ型を選ぶ必要があります。
- また、ダミー変数などスパースなデータ構造を持つときは、Sparse Data Structureを活用することで大きくデータを圧縮することができます。一方でデータを圧縮することで通常使えるはずのpandasのメソッドが使えなくなるので、密なデータ構造に戻すという工夫が必要になります。

ここまでご覧頂きありがとうございました。他にもっといい方法がありましたら、コメントを頂ければと思います。
簡単ですが、データとコードを[github](https://github.com/shinsei66/Reduce-memory-usage-in-padas-dataframe)に上げておきました。Sparse Data Structureにしたときにgroupbyを行うときのサンプルも載せておきました。