手元のパソコンのメモリでそこそこ大きなデータ処理をしたいが、何とかpandasで乗り切りたいとき、最後のあがきとしてできる省メモリのやり方や関連する工夫をまとめてみます。普段から意識して使うようにすれば、無駄に計算資源を消費することなく分析ができるので、色々とメリットがあるのではないかと思います。
ただ、どうしても限界がありうまくいかなければ、そもそもメモリを増設するか、AWSやGCPなどのクラウドサービスを活用することをお勧めします。
#検証データ
やり方だけ書いても現実的でないため、今回は有名なタイタニックの生存者予測のデータを使用します。データ量は全く多くないのですが、その点ご容赦ください。やり方だけ気になる場合はこのパートは読み飛ばして頂いて大丈夫です。
##分析環境
OS:市販の安いノートPCに入っているwindows 10 Home
環境:上記OSにKaggleのDockerをマウントさせて立ち上げたJupyter notebook
python:Python 3.6.6 :: Anaconda, Inc.
分析時のフォルダ構成は以下のように、notebookを配置するフォルダと、使用データを配置するフォルダにしています。
まずはダウンロードしたタイタニックのデータをinputフォルダに配置し、データを読み込む準備をします。jupyter notebookで以下のコマンドを実行します。
#ライブラリのインポート
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を読み込みます。
df = pd.read_csv(f'{INPUTPATH}/train.csv')
df.head(1)
#データ型を変える
データの最大、最小値や小数精度に大きな要求がなければ、倍精度から単精度にデータ型にすることでメモリの消費を減らすことができます。
そこで検証データを例に見てみます。まずはtrain.csvの読み込まれたデータ型を確認します。特に指定しなければ、整数値はint64、小数はfloat64で読み込まれます。
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に変えてみましょう。結果、データサイズが半分くらいになっています。
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にもかかわらずデータ型を変えると、そのデータ型で表現できる最大値に張り付くことになってしまい、数字そのものが変わってしまうので要注意です。
個人的には、よっぽど小さな小数や大きな数を扱わない限りまずは倍精度(64bit)を単精度(32bit)にします。これでも改善しない場合は個別に数字の範囲や小数点の精度要求を見てちょっと修正します。もしくは、直接後述の関数を用いることで、ここら辺の判断をしてくれます。
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でないといけない)するだけで、比較的手間をかけずに辞書が作れます。
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のコンペで使われた便利な関数を使うこともできます。私もこのコンペに参加していましたが、ありがたく使わせていただきました。こちらの記事でも紹介されています。詳細はこの記事にもありますが、データの最小・最大値を踏まえた判断をしてくれています。下あるコードは、さらに一部をカスタマイズしております。
- まずは、データ型がobject型でないnumeric型に対して倍精度を変えています。
- また、データ型の中にdatetimeやcategory型が含まれている場合はスキップするようにエラーを回避しています。
- 最後にobject型をcategory型に変えています。
#関数を使うときに下記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%減らすことができました。
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型に変更してみても、この場合約半分減らすことができます。
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というカラムには欠損値が含まれています。
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」に対応するカテゴリーがないというエラーになってしまいます。
df.Cabin = df.Cabin.astype('category')
df.Cabin = df.Cabin.fillna('null')
徹底的にメモリを減らさなくても済む、ないし、object型はデータの小数の場合は、そのままobject型にするか、reduce_mem_usage()関数のcategory型への変換部分は省略するのもありでしょう。また、必ずしもcategory型にしてメモリを減らせるとも限りません。
一方、object型もcategory型に変換して徹底的にメモリを減らしたいという場合は、欠損値補完に対するエラーは、2通り対処方法があります。普通に考えると、1.の方法のようにあらかじめ欠損値補完してからcategory型に変換すれば、エラーは出ませんが、分析途中で、必要に応じて欠損値補完したい場合は2.の方で対応するのが小回り効きそうです。
- 欠損値の保管をしてから、category型に変換する
- あらかじめcategory型に変換してしまった場合は、新たにnullに対応するカテゴリーを追加して、欠損値補完をする。pandasのadd_categoriesを使用します。
#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を使うことでメモリ削減効果が見込めます。データが0,1のみからなるダミー変数もカラム数が増えると、Sparse Data Structureにするとメモリ削減効果は結構あります。Sparse Data Structureでは1のあるデータの位置を記録し、それ以外の0の部分はデータとして保存せずに圧縮してくれているのです。ただし、Sparse Data Structureにすることで、不便になる点もあるので、併せて後述します。
##普通にダミーエンコードしてみる
その前に、タイタニックデータのカテゴリ変数で水準数が多い(カーディナリティが高い)ものをあえて選らんで効果を見てみます。
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()を使うと簡単にできます。
データの行数が891行なのに、列数が834と恐ろしくスパースなデータになりました。メモリの使用量はdf_dummies.info()
で確認すると、約726KBです。(sys.getsizeof(df_dummies)/1024
やdf_dummies.memory_usage().sum()/1024
でも同様にKB単位でデータサイズを取得できます)
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%もメモリを減らすことができました。
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の場合はデータが圧縮されていることでエラーになってしまいます。
#普通の作り方
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()というメソッドでもっとシンプルにできます。
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に上げておきました。Sparse Data Structureにしたときにgroupbyを行うときのサンプルも載せておきました。