Lean Baseball

No Engineering, No Baseball.

うわっ…私のpandas、遅すぎ…?って時にやるべきこと(先人の知恵より)

f:id:shinyorke:20190120111403j:plain
※あくまでもイメージです(適当)

仕事じゃなくて、趣味の方の野球統計モデルで詰まった時にやったメモ的なやつです.*1

一言で言うと、

約19万レコード(110MBちょい)のCSVの統計処理を70秒から4秒に縮めました.

# 最初のコード
$ time python run_expectancy.py events-2018.csv
       RUNS_ROI
outs          0     1     2
runner
0_000      0.49  0.26  0.10
1_001      1.43  1.00  0.35
2_010      1.13  0.68  0.32
3_011      1.94  1.36  0.57
4_100      0.87  0.53  0.22
5_101      1.79  1.21  0.50
6_110      1.42  0.93  0.44
7_111      2.35  1.47  0.77
python run_expectancy.py events-2018.csv  72.93s user 3.75s system 99% cpu 1:16.83 total

# 完成形
$ time python run_expectancy.py events-2018.csv
       RUNS_ROI
outs          0     1     2
runner
0_000      0.49  0.26  0.10
1_001      1.43  1.00  0.35
2_010      1.13  0.68  0.32
3_011      1.94  1.36  0.57
4_100      0.87  0.53  0.22
5_101      1.79  1.21  0.50
6_110      1.42  0.93  0.44
7_111      2.35  1.47  0.77
python run_expectancy.py events-2018.csv  3.68s user 0.64s system 100% cpu 4.291 total

学びの整理、自分への戒めとしてちょっとだけメモを公開します.

TL;DR

  • Dataframe処理からfor文を戦力外通告し、データを絞って処理したら70秒掛かってた処理が6秒になりました(小並)
  • df.iterrows()が許されるのは写経&実験コードぐらい.ちゃんとmapを使おう(applyは要審議),データは必要な分だけ読み取りましょう
  • テストを書こう,先人たちの知恵に感謝しよう

おしながき

やったこと

PyCon JP 2017の発表でやった、LWTSのうち、「得点期待値(Run Expectancy)*2」を以下の書籍を参考に算出コードをPythonで書きました(申し訳ないですがコードは非公開*3).

原著はタイトルの通り、Rで書かれている*4ので、

  • ひとまずRで写経する(JupyterでRカーネル動かして写経)
  • 途中計算の答え合わせをしながらPythonで書き直し、この時にpandasで実装
  • テスト(Pythonのunittest)を書いて動かし、ひたすらリファクタリング

というスタイルでやりました.

なお、対象データはメジャーリーグ1シーズン分の打撃スタッツ(公開データです*5)で、約19万レコード,100カラムくらいのCSVファイル(110MBちょい)というちょっと手ごわいデータです.

$ ls -lh events-2018.csv
-rw-r--r--@ 1 hoge fuga  113M 11 25 13:58 events-2018.csv

処理と出力の結果はこんな感じです.

$ python run_expectancy.py events-2018.csv
       RUNS_ROI
outs          0     1     2
runner
0_000      0.49  0.26  0.10
1_001      1.43  1.00  0.35
2_010      1.13  0.68  0.32
3_011      1.94  1.36  0.57
4_100      0.87  0.53  0.22
5_101      1.79  1.21  0.50
6_110      1.42  0.93  0.44
7_111      2.35  1.47  0.77

コードは見せられませんが、雰囲気(Interfaceと処理の役割)だけ伝えるとこのような感じです.

import csv

import pandas as pd


class RunExpectancy:

    def __init__(self):
        pass

    def calc_csv(self, filename: str) -> pd.DataFrame:
        # 何かしらの処理
        return self.calc_df(pd.read_csv(filename))

    def calc_df(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Calc Run Expectancy for Dataframe
        :param df: Pandas Dataframe(Retrosheet Events Data)
        :return: Pandas Dataframe
        """
        df['A'] = df['B'] + df['C']
        # ひたすら前処理と集計

        # 複数カラムにまたがる処理を愚直にforでやってるところ(イメージ)
        for i, row in df.iterrows():
            status = 0
            if row.D == 'chiba' or row.E == 'chiba':
                status = 33
            elif row.D == 'nishinomiya' or row.E == 'nishinomiya':
                status = 4

            df.at[i, 'NHK'] = status
        # ↑のようなforで回すやつが2箇所


        # いよいよ算出&出力
        return self._output(df)

    @classmethod
    def _output(cls, df: pd.DataFrame) -> pd.DataFrame:
        """
        Export Run Expectancy Data
        :param df: Pandas Dataframe(RUNS ROI Data)
        :return: Pandas Dataframe
        """
        # ひたすら整形するそして算出
        # ピボットしてRun Expectancyを出す
        return pd.pivot_table(df, index='runner', columns='outs')


if __name__ == '__main__':
    import sys

    run_ex = RunExpectancy()
    values = run_ex.calc_csv(sys.argv[1])
    print(values)

70秒を4秒に縮めた際にやったこと(ふたつ)

もういきなり結論書いちゃいますが、

  • df.iterrows(forでグリングリン回ってるところ)を戦力外通告してmapに置き換え
  • 計算に必要なカラムを定義、pd.read_csvのパラメータで「usecols」をカラムを指定(読み込むデータを限定)

これで一気に時間が縮まりました.

df.iterrowsを戦力外通告してmapを使う

pandasのDataframeに新しいSeries(カラム)を作りたい!でも複数のSeriesから計算しなくちゃ...!

っていう時に、人は(私は)どうしてもforで回すことを考えがちです.

def calc_df(self, df: pd.DataFrame) -> pd.DataFrame:

    # 中略
    # 複数カラムにまたがる処理を愚直にforでやってるところ(イメージ)
    for i, row in df.iterrows():
        status = 0
        if row.D == 'chiba' or row.E == 'chiba':
            status = 33
        elif row.D == 'nishinomiya' or row.E == 'nishinomiya':
            status = 4
        df.at[i, 'NHK'] = status

for文書いたら負けかなと思っている」という有名な格言?もあるとおり、19万レコードを全部forで舐め回しているこのコードは負けのコードです.*6

人のコードを写経してまず動かす!って時はいいのですが、たかだか100MBちょいのCSVの計算集計に70秒もかかるのはって思いはじめた段階で、真面目に考え、mapで処理を書き直しました.

mapで書き直す

上記の例をmap(Seriesの値から計算)するように書き換えるとこんな感じです.

def _nhk(self, status_text: str) -> int:
    # デリミタ(_)で分ける
    params = status_text.split('_')
    status = 0
    if params[0] == 'chiba' or params[1] == 'chiba':
        status = 33
    elif params[0] == 'nishinomiya' or params[1] == 'nishinomiya':
        status = 4
    return status

def calc_df(self, df: pd.DataFrame) -> pd.DataFrame:

    # 中略
    df['STATUS_TEXT'] = df['D'].astype(str) + '_' + df['E'].astype(str)   # 中間列を作ってデリミタでつなぐ
    df['NHK'] = df['STATUS_TEXT'].map(self._nhk)

STATUS_TEXTという名前で中間列を作り、これをmap関数内で分解(str split)して処理するようにしました.

applyはさほど速くない

なお、pandasにはDataframe(≒複数のSeries)を元に処理するapplyという関数もあり、上記のイケてないfor文コードをこういう書き方にもできます.

def _nhk2(self, row: pd.Series) -> int:
    status = 0
    if row.D == 'chiba' or row.E == 'chiba':
        status = 33
    elif row.D == 'nishinomiya' or row.E == 'nishinomiya':
        status = 4
    return status

def calc_df(self, df: pd.DataFrame) -> pd.DataFrame:

    # 中略
    df['NHK'] = df.apply(self._nhk2, axis=1)

コードの可読性・綺麗さ(かっこよさ)でいうと圧倒的にapplyを使ったほうがお得*7なのですが、残念ながらこのコードのパフォーマンスは、forを使うときよりだいぶ良かったのですが、mapよりダメな子でした.

なんでやろうな?と思い調べたらpandasでお馴染みsinhrksさん*8のブログに解説がありました.

sinhrks.hatenablog.com

pandas.DataFrame は列ごとに異なる型を持つことができる。DataFrame は内部的に 同じ型の列をまとめて np.ndarray として保持している。列ごとに連続したデータを持つことになるため、そもそも行に対するループには向かない。また、DataFrame.iterrows でのループの際には 異なる型を持つ列の値を Series として取り出すため、そのインスタンス化にも時間がかかる。

また 行ごと / 列ごとに 関数を適用するメソッドに DataFrame.apply があるが、このメソッドでは Python の関数を繰り返し呼び出すためのコストがかかる。apply は利便性を重視したメソッドのため、パフォーマンスを気にする場合は避けたほうがよい。

※「1. 行に対するループ / DataFrame.apply は 使わない」より引用

forで回すのはアレだよ!、for文は負けだよ!っていう件も含めて明確な答え、ありました.

for文を戦力外通告した結果(70s->20s)

最初に書いたコードはなんとforで回したところが二箇所、19万レコードのDataframeを二回も回すという恐ろしいことをしていました(反省).

33-4どころの負けじゃありません.*9

これらをひたすらリファクタリングし、もう一度計測した結果、

$ time run_expectancy.py events-2018.csv
       RUNS_ROI
outs          0     1     2
runner
0_000      0.49  0.26  0.10
1_001      1.43  1.00  0.35
2_010      1.13  0.68  0.32
3_011      1.94  1.36  0.57
4_100      0.87  0.53  0.22
5_101      1.79  1.21  0.50
6_110      1.42  0.93  0.44
7_111      2.35  1.47  0.77
python run_expectancy.py events-2018.csv  15.58s user 3.40s system 100% cpu 18.950 total

70秒が約20秒、7割ちかく処理時間をカットしました(震え).

そもそものデータを減らす

とはいえ20秒もまだかかり過ぎだな...と思い、データを見直した所、

  • CSVは100カラムちょいある
  • しかし、使ってるカラムは10個も無い
  • 必要なものだけ読めばよいのでは?(震え)

って事に気が付きました.

いつもは何も考えずに無邪気にread_csvしてDataframe作ってましたが、これを絞ることにしました.

カラム指定する(read_csv)

もう一度ソースコードと計算式を読み直し、必要なカラムのみ特定し、read_csvを書き直しました.

# 書き直す前
#    def calc_csv(self, filename: str) -> pd.DataFrame:
#        return self.calc_df(pd.read_csv(filename))

# 書き直したやつ
    COLUMNS = ('A', 'B', 'C', 'D', 'E') 
    def calc_csv(self, filename: str) -> pd.DataFrame:
        # 書き直した後
        return self.calc_df(pd.read_csv(filename, usecols=self.COLUMNS))

個人的にはread_sqlからやるときは明確に必要なカラムのみ指定(select文をちゃんと書けば出来る)して意識してやってたのですが、csvの時はあまり考えずにやってたので、「もしや!?」と思い調べてやってみました.

read_csvをいい感じにした結果(20s->4s)

参考にさせてもらったこちらのブログのベンチマークでも明確に結果が出ていたので期待してベンチをとりました.

starpentagon.net

$ time python run_expectancy.py events-2018.csv
       RUNS_ROI
outs          0     1     2
runner
0_000      0.49  0.26  0.10
1_001      1.43  1.00  0.35
2_010      1.13  0.68  0.32
3_011      1.94  1.36  0.57
4_100      0.87  0.53  0.22
5_101      1.79  1.21  0.50
6_110      1.42  0.93  0.44
7_111      2.35  1.47  0.77
python run_expectancy.py events-2018.csv  3.68s user 0.64s system 100% cpu 4.291 total

見事、処理時間が1/4近く短縮されました!

「うわ...私のpandas、遅すぎ...?」という懸念が見事に解消されました、めでたしめでたし.

なお、ここまでの高速化は調べながらリファクタリングとテストを繰り返して半日で達成しました.*10

高速化をちゃんとやる前に

テストを書きましょう!!!リファクタリングしましょう!!!データを把握しましょう!!!!

ホント、これに尽きます.

テストを書いてからリファクタリング

何故テストが重要か?という話はこちらの名著に譲るとして*11

テスト駆動Python

テスト駆動Python

  • 作者:Brian Okken
  • 発売日: 2018/08/29
  • メディア: 単行本(ソフトカバー)

この手のデータ分析のリファクタリングでありがちな失敗は

リファクタリングしてる内に、オリジナルのコードから正解がズレる(=コードを壊してしまう)

ことです.

手法やモデルが決まってるということは当然再現性あるはずなのに、リファクタリングの結果再現しなくなったらそれはバグです(やってることの意味がなくなる).

ある程度完成してprintやlogで書き出していい感じになったら、

  • インテグレーション(結合)レベルでいいのでテストを書く、ちゃんと書く
  • ややこしい処理や計算をしている関数は個別にユニット(単体)レベルで書く
  • 書いたテストを常に動かす仕組みを用意する(エディタの設定なりCIなり)

pytestとかじゃなくて、標準のunittestでも十分なクオリティを出せるので、早い段階で用意することをおすすめします.

import unittest

class TestRunExpectancy(unittest.TestCase):

    # テストデータはpy-retrosheetから別途ダウンロード

    def test_2016(self):
        """
        2016年データでのLWTS
        """
        run_ex = RunExpectancy()
        df = run_ex.calc_csv('./data/events-2016.csv')
        dict_run_ex = df.to_dict()

        # 0アウト
        dict_0_outs = dict_run_ex[('RUNS_ROI', '0')]
        self.assertEqual(dict_0_outs['0_000'], 0.5)     # ランナー無し

    def test_2017(self):
        # 省略

    def test_2018(self):
        # 省略

なお、個人的にはテスト駆動開発がしっくりこない*12為、必要と判断したとき以外はやってません(これは好みの問題).

必要なデータを認識する(でかいデータにビビらない)

統計モデルや計算手法をちゃんと確認して必要なデータだけに絞る.

この辺雑にやってついつい手を抜きがちですが、最初のコードを書いて動いた時点で吟味しても良かったのかな?と思っています.

最初の全体像がイマイチ不明でも、後ほどコードをgrepするとかで絞れると思う(今回はその方式でカラムを吸い出して決めました)ので、これは手を抜かずやっていきたい.

先人たちの知恵に感謝

mapやapplyの具体例などは定番のJupyter本を参考にしつつ.

改訂版 Pythonユーザのための Jupyter[実践]入門

改訂版 Pythonユーザのための Jupyter[実践]入門

今回はpandasのコントリビューターのsinhrksさんはじめ、有志のブログやドキュメントが大変参考になりました.

sinhrks.hatenablog.com

starpentagon.net

例を示した以外、私のこのエントリーでは新しいネタ・Tipsはほぼ無いわけですが、こういう知恵を少しでも残せる・継承できるようになれたらいいなあと改めて思いました.

以上、今年初の技術エントリーでした.

*1:もはやどっちだか見境つかない感あるが今回は間違いなく趣味

*2:LWTSはプレーを評価する方法の概念的な考えで、具体的な手法として得点期待値や得点価値があります.詳しくはウィキペディアもしくは私の過去発表を御覧ください(野球のエントリーではないのでここでは解説しません)

*3:趣味とはいえ、野球のコードを気軽にOSSにするのはちょっとですね(震え)なお、過去公開のコードやライブラリはそのままです&必要に応じてメンテをするお気持ちはあります.

*4:このRのやつに色々と苦労しましたがこれはどこかで書き残したい

*5:RETROSHEETという、公開データセットです.なお使い方には多少クセがあります(このブログのどこかに解説あったような) https://www.retrosheet.org/

*6:このブログは昨日個人的にやってたもくもく会で書き始めたのですが、ちょうど著者のはむかず氏からこのコメントが出たので引用させてもらいましたw

*7:この辺は明確すぎますね笑

*8:pandasで困ったらsinhrksさんですよねと、ホントいつも参考にしています

*9:NHK.

*10:これが2019年、私のプログラミング書き初めでした(仕事始めの前の日にリハビリでやった)

*11:元の本が素晴らしいのと、アジャイル・テスト駆動・Pythonのプロ、やっとむさん監訳で非常に安心と信頼ができる名著です

*12:テスト駆動開発、悪くはないんだけど、動くものがそこそこできてからテストを書き始める方がやりやすいと思っている