Pandasのソースコードを読んでみよう
2019年アドベントカレンダー第1回目の記事を担当させて頂きます、エンジニアの光山です。
現在は経営企画室に所属して、新プロダクトの企画、開発に携わっています。
デファクトスタンダードとなっているPandas
私はちょっとしたデータの確認や加工から、大規模データの分析まで、Pandasをよく利用します。
Pandasとは、Pythonユーザにとってはデファクトスタンダードとなっているライブラリで、データの
- 入出力
- 加工
- 可視化
などを簡単に行うことができる、素晴らしいライブラリです。
例えば、ファイルの入出力については、read_XXX、to_XXXという名前のメソッドで、多くのファイル形式がサポートされています。csvやjsonなどの基本的なファイル形式はもちろんのこと、HTMLやSQL(RDB)、さらにはカラムナフォーマットであるparquetの読み込みもサポートされており、なかなか面白いです。
私は業務上Excelのデータを扱うことも多いのですが、read_excelメソッドを使用すれば、ExcelのファイルをそのままDataFrame形式で読み込むことが可能なので、大変重宝しています(read_excelメソッドは内部的にはxlrdモジュールを使用しています)。
どのような入出力メソッドがあるか、興味のある方はぜひ公式ドキュメントをご覧ください。
弊社の場合、ORMを使用せず直接SQLを書く機会が多いので、SQLライクにデータを扱うことができるPandasは非常に取っつきやすいライブラリです(余談にはなりますが、昨年から2年連続でSQLを徹底的にチューニングする検索高速化インターンシップも開催されるほど、弊社はSQLのチューニングにこだわっています)。
OSSのソースコードを読む利点
前述の通りPythonでのデータ処理に必須と言っても過言ではないPandasですが、ソースコード自体は読まれたことが無い方も多いのではないでしょうか。
Pandasに限らず、ライブラリのソースコードを読むと、ドキュメントに記載の無い挙動についても理解することができるので、知識として定着しやすいです。
何より、有名なOSSは設計自体が美しく、読んでいて楽しいです。もちろん、自分自身の実装の参考にもなります。
そこで、今回はこのPandasのソースコードを読んでみようと思います。
Pandasの繰り返し処理とパフォーマンスについて
今回はPandasの繰り返し処理に着目します。Pandasのパフォーマンスについては検索すると多くの記事がヒットすることから、関心の高い領域と言えるでしょう。
Pandasにおいて、繰り返し処理をするメソッドはいくつか提供されています。代表的なものは下記でしょうか。
- iterrows(DataFrame)
- apply(Series、DataFrame)
- map(Series)
例えば、数値配列の各要素を二乗する処理は、それぞれのメソッドで下記の通り記述することができます。
import numpy as np import pandas as pd def square(x): return x ** 2 df = pd.DataFrame({'col1': np.arange(100000)}) # iterrows result = [] for idx, row in df.iterrows(): result.append(square(row['col1'])) df['col2'] = pd.DataFrame(result) # apply (for DataFrame) df['col2'] = df.apply(lambda x: square(x['col1']), axis=1) # map df['col2'] = df['col1'].map(square)
処理時間は下記の通りです。メソッド間でかなりの差が開いていますね。
- iterrows: 15.8s
- apply: 3.1s
- map: 0.1s
これらのメソッドは、それぞれどのような実装がされているのでしょうか。ソースコードの中身を読んでみようと思います。
iterrows
Pandasでは、重要なメソッドはcoreディレクトリ以下に記述されています。ソースコードをcloneして目当てのメソッドをgrep等で探すこともできますし、公式ドキュメントに記載されているソースコードへのリンク(iterrowsのドキュメントはこちら)から辿ることもできます。気になるメソッドのソースコードを簡単に確認することができるようになっているのは嬉しいですね。
iterrowsメソッドの記述は下記です。
def iterrows(self): columns = self.columns klass = self._constructor_sliced for k, v in zip(self.index, self.values): s = klass(v, index=columns, name=k) yield k, s
(ソースコードはこちら)
for文で繰り返し処理を実行して、行単位で都度Seriesを生成しています。処理としてはいたってシンプルであることが分かると同時に、都度Seriesをインスタンス化しているためパフォーマンス上問題あることも明白ですね。実際、Pandasを普段お使いの方にはお馴染みだと思いますが、iterrowsを使用する方法はパフォーマンスの観点から推奨されていません。
なお、iterrowsメソッドの挙動についてはコメント(=公式ドキュメント)にも記載があります。iterrowsメソッドだけでなく、Pandasの各メソッドは記述が非常に簡潔であることと、ドキュメントが充実していることが特徴です。
apply (DataFrame.apply)
次に、applyメソッドを読んでみましょう。
applyメソッドは、SeriesとDataFrameそれぞれに対して実装されています。今回はDataFrame.applyの方を読んでみます。
applyメソッドはソースコードを辿ればすぐに分かる通り、FrameApplyクラスを使用しています。
さらに読み進めると、(Numpyのユニバーサル関数を引数として渡した場合などは異なる処理となりますが、通常の関数を引数とした場合は)apply_series_generatorメソッドで実質的な処理を実行していることが分かります。
# apply_series_generator内 series_gen = self.series_generator # 中略 for i, v in enumerate(series_gen): results[i] = self.f(v) keys.append(v.name)
series_generator内で列単位または行単位のデータを生成し、都度関数(self.f())を呼び出しています。apply実行時には、都度関数の呼び出しコストがかかることを考慮する必要があることが分かります。
map
最後に、3つのメソッドで最速の結果が出たmapメソッドです。
mapメソッドについても同様に処理を辿っていくと、_libs/lib.pyx内のmap_inferメソッドに辿り着きます。
pyxという拡張子からも分かる通り、map_inferメソッドはCythonで記述されています。
CythonのコードはCにコンパイルされるので、C並みの実行速度を得ることができます。これがmapメソッドのパフォーマンスが圧倒的だった主な理由ですね。
DataFrameではなくSeriesに対しての処理で完結させることができるのであれば、この3つのメソッドの中ではmapメソッドがパフォーマンス的に最も優れています(なお、今回は各メソッドの相違点を際立たせるために、意図的にDataFrame.applyを登場させましたが、Series.applyであれば、今回の検証方法においてSeries.mapと同等のパフォーマンスが出ています)。
余談ですが、さらにパフォーマンスを向上させたい場合は
- Daskを使用して処理を並列化する
- 実行の遅い処理をCythonで記述する
などの方法もあります。
(公式ドキュメントには、Numbaを使用する方法なども紹介されています。)
さいごに
今回は繰り返し処理に着目してソースコードを読んでみました。実際にPandasには多くの実装上の工夫がなされており、読むべき項目はたくさんあります。
自分が使うOSSのソースコードを読むことは勉強になりますし、何か問題が発生した場合でもソースコードレベルで原因究明ができる安心感が生まれます。
Pandasについては、(if分岐が多く、参考にしない方が良さそうな記述もあるものの、)コメントは非常に丁寧に書かれており読みやすいと思いますので、自分が使用しているメソッドについてなど、気になった箇所から少しずつ読んでみてはいかがでしょうか。
Pythonの場合、機械学習であればscikit-learn、WebフレームワークであればFlaskのソースコードなども読んでみると学びが多いと思います(複数のライブラリを読み比べてみると、それぞれの違いがはっきりと現れてまた発見があります)。
光山 倫央
経営企画室 エンジニア 2013年新卒入社。
旅行業界、MRO業界それぞれの業界で検索サービス開発を経験した後、現在は新プロダクトを企画、開発中。