読者です 読者をやめる 読者になる 読者になる

StatsFragments

Python, R, Rust, 統計, 機械学習とか

pandas でメモリに乗らない 大容量ファイルを上手に扱う

概要

分析のためにデータ集めしていると、たまに マジか!? と思うサイズの CSV に出くわすことがある。なぜこんなに育つまで放っておいたのか、、、? このエントリでは普通には開けないサイズの CSVpandas を使ってうまいこと処理する方法をまとめたい。

サンプルデータ

たまには実データ使おう、ということで WorldBankから GDPデータを落とす。以下のページ右上の "DOWNLOAD DATA" ボタンで CSV を選択し、ローカルに zip を保存する。解凍した "ny.gdp.mktp.cd_Indicator_en_csv_v2.csv" ファイルをサンプルとして使う。

http://data.worldbank.org/indicator/NY.GDP.MKTP.CD?page=1

補足 pandasRemote Data Access で WorldBank のデータは直接 落っことせるが、今回は ローカルに保存した csv を読み取りたいという設定で。

chunksize を使って ファイルを分割して読み込む

まず、pandas で普通に CSV を読む場合は以下のように pd.read_csv を使う。サンプルデータではヘッダが 3行目から始まるため、冒頭の 2行を skiprows オプションで読み飛ばす。

import pandas as pd

fname = 'ny.gdp.mktp.cd_Indicator_en_csv_v2.csv'
df = pd.read_csv(fname, skiprows=[0, 1])
df.shape
# (258, 59)

df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
#
# [5 rows x 59 columns]

では読み取り対象ファイルが メモリに乗らないほど大きい場合はどうするか? read_csvchunksize オプションを指定することでファイルの中身を 指定した行数で分割して読み込むことができる。chunksize には 1回で読み取りたい行数を指定する。例えば 50 行ずつ読み取るなら、chunksize=50

reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)

chunksize を指定したとき、返り値は DataFrame ではなく TextFileReader インスタンスとなる。

type(reader)
# pandas.io.parsers.TextFileReader

TextFileReaderfor でループさせると、ファイルの中身を指定した行数ごとに DataFrame として読み取る。TextFileReader は 現時点での読み取り位置 (ポインタ) を覚えており、ファイルの中身をすべて読み取るとループが終了する。

for r in reader:
    print(type(r), r.shape)
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (8, 59))

もしくは、TextFileReader.get_chunk で、現在の読み取り位置 (ポインタ) から N 行を読み取る。

# 上のループで全ての要素は読み取り済みになっているので、再度 reader を初期化
reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)

# 先頭から 5行を読み込み
reader.get_chunk(5)
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

# 次の4行 (6行目 - 9行目)を読み込み
reader.get_chunk(4)
#            Country Name Country Code     Indicator Name  Indicator Code  1961
# 0         Andean Region          ANR  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 1            Arab World          ARB  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 2  United Arab Emirates          ARE  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 3             Argentina          ARG  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 
# [4 rows x 59 columns]

補足 chunksize オプションは、 read_csvread_table で利用できる 。

補足 pandasread_csv はかなり速いので、パフォーマンス面でも pandas を使うのはよいと思う。

A new high performance, memory-efficient file parser engine for pandas | Wes McKinney

python - Fastest way to parse large CSV files in Pandas - Stack Overflow

でも、分割された DataFrame を扱うのはめんどうなんだけど?

分析に必要なレコードは 全データのごく一部、なんてこともよくある。chunk させて読み込んだ 各グループに対して前処理をかけると、サイズが劇的に減ってメモリに乗る。そんなときは、前処理をかけた後、各 DataFrame を結合してひとつにしたい。

DataFrame の結合には pd.concatTextFileReader から読み込んだ 各 DataFrame はそれぞれが 0 から始まる index を持つ。pd.concat は既定では index を維持して DataFrame を結合するため、そのままでは index が重複してしまう。 重複を避けるためには、結合後に index を振りなおす = ignore_index=True を指定する必要がある。

よく使う表現は、

def preprocess(x):
    # 不要な行, 列のフィルタなど、データサイズを削減する処理
    return x

reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)
df = pd.concat((preprocess(r) for r in reader), ignore_index=True)

df.shape
# (258, 59)

df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

メモリ上の DataFrame のサイズを確認したい

DataFrame を適切な大きさに分割するために、DataFrame のメモリ上の占有サイズが知りたい。これは v0.15.0 以降であれば可能。

DataFrame 全体のメモリ上のサイズを表示するには DataFrame.info()。 表示された情報の最終行、 "memory size" をみる。ただし、この表示には object 型のカラムが占めているメモリは含まれないので注意。"+" がついているのは実際の占有領域が表示よりも大きいことを示す。

df.info()
# <class 'pandas.core.frame.DataFrame'>
# Int64Index: 258 entries, 0 to 257
# Data columns (total 59 columns):
# Country Name      258 non-null object
# Country Code      258 non-null object
# Indicator Name    258 non-null object
# Indicator Code    258 non-null object
# 1961              127 non-null float64
# ...
# 2012              222 non-null float64
# 2013              216 non-null float64
# 2014              0 non-null float64
# Unnamed: 58       0 non-null float64
# dtypes: float64(55), object(4)
# memory usage: 116.9+ KB

ちゃんと表示する場合は DataFrame.memory_usage(index=True)index=Trueindex の占有メモリも表示に含めるオプション。表示はカラム別になるため、全体のサイズを見たい場合は sum をとる。

df.memory_usage(index=True)
# Index             2064
# Country Name      1032
# Country Code      1032
# Indicator Name    1032
# Indicator Code    1032
# 1961              2064
# ...
# 2013              2064
# 2014              2064
# Unnamed: 58       2064
# Length: 60, dtype: int64

df.memory_usage(index=True).sum()
# 119712

DataFrame を複製せずに処理する

DataFrameメソッドを呼び出した場合、一般には DataFrame をコピーしてから処理を行って返す (非破壊的な処理を行う)。大容量の DataFrame ではこのコピーによって 実メモリのサイズを超え MemoryError になる / もしくはコピーに非常に時間がかかることがある。

例えば、欠損値の補完を fillna で行う処理をみてみる。fillna の返り値では NaN が 0 でパディングされているが、元データは変更されていない。fillna では、コピーした DataFrame に対して処理を行っていることが "1961" カラムの値を比較するとわかる。

df.fillna(0).head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00

# dfはそのまま
df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

このコピー処理を避けたい場合は inplace=True を指定する。こいつが指定されると、各メソッドDataFrame のコピーを行わず、呼び出し元の DataFrame を直接処理する (破壊的な処理を行う)。また、inplace=True が指定されたメソッドは返り値をもたない ( None を返す ) ため、代入を行ってはならない。

# 返り値はないため、代入してはダメ
df.fillna(0, inplace=True)

# df 自体が変更された
df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 
# [5 rows x 59 columns]

やりたいメソッドinplace オプションを持つかどうかは API ガイド で確認。

eval による処理の最適化

numexpr のインストール

pd.eval を使うには numexpr パッケージが必要なので、入っていなければインストールする。

pip install numexpr

eval のメリット

pd.eval では文字列表現された式を受け取って評価できる。pd.eval を使うと以下 2つのメリットがある。

  • 大容量の DataFrame を効率的に処理できる
  • 数値演算を一括して処理できる

補足 numexpr のソースは読んだことがないので詳細不明だが、 pandas では連続するオペレータは逐次処理されていく。例えば "A + B + C" であれば まず "A + B" の結果から DataFrame を作って、さらに "+ C" して DataFrame を作る。DataFrame 生成にはそれなりにコストがかかるので この辺の内部処理が最適化されるのかな、と思っている。

補足 逆に、小さい処理では eval から numexpr を呼び出すコストの方が メリットよりも大きくなるため、pd.eval を使う場合は通常処理とのパフォーマンス比較を行ってからにすべき。今回のサンプルくらいのデータサイズであれば eval よりも 通常の演算のほうが速い。

eval でサポートされる式表現は、公式ドキュメントにあるとおり、

  • 数値演算オペレータ。ただし、シフト演算 <<>> を除く。
  • 比較演算オペレータ。連結も可能 ( 2 < df < df2 とか)
  • 論理演算オペレータ ( not も含む)。
  • listtupleリテラル表現 ( [1, 2](1, 2) )
  • プロパティアクセス ( df.a )
  • __getitem__ でのアクセス ( df[0] )

eval の利用

例えばサンプルデータの "2011" 〜 "2013" カラムの値を足し合わせたいときはこう書ける。

pd.eval("df['2011'] + df['2012'] + df['2013']")
# 0     2.584464e+09
# 1     0.000000e+00
# 2     5.910162e+10
# 3     3.411516e+11
# 4     3.813926e+10
# ...
# 256    6.218183e+10
# 257    3.623061e+10
# Length: 258, dtype: float64

また、pd.eval ではなく DataFrame.eval として呼んだ場合、式表現中はデータのカラム名を変数名としてもつ名前空間で評価される ( DataFrame.query と同じ )。DataFrame.query については以下の記事参照。

まとめ

pandas で大容量ファイルを扱うときは、

  • chunksize で分割して読み込み
  • メソッド呼び出しは inplace=True
  • オペレータを使う処理は pd.eval

これさえ覚えておけば、大容量ファイルが送りつけられた場合でも安心。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理