目次

イテレートによる速度低下 - pandas

pandas(の内部で動くNumPy)は、SIMD命令を使って同じ演算を一斉に行うことで高速化を実現している。

それに従わない書き方(for文を使ったイテレートや、df.apply())をすると途端に遅くなる。

どれくらい違うのか、多少非効率的に見えても一気に計算させた方が速くなるのか、調べたメモ。

ケース1: IDごとにxyから移動距離を算出

こんな、IDごとにxy座標が記録されたデータがあるとする。データはID→timeの順にソート済みであるとする。

  ID  time   x   y   ...other columns...
   1     0  10  20   ...
   1     1  11  21
   1     2  11  25
   ...
   1  1000 123  67
   2     0  57  21
   2     1  55  19
   ...

各IDについて、時系列的に隣り合う2点間距離を合計し、総移動距離を算出したい。

データ: およそ7000ID, 100万レコード

方法1:二重iterate

groupbyごとにイテレートし、さらにIDごとのdfでも1行ずつイテレート。結果はリストに溜める。


buf = []
for cid, cdf in df.groupby('ID'):

    dist = 0
    px, py = cdf['x'].iloc[0], cdf['y'].iloc[0]
    for idx, row in islice(cdf.iterrows(), 1, None):
        x, y = row['x'], row['y']
        dist += ((x - px) ** 2 + (y - py) ** 2) ** 0.5
        px, py = x, y
    buf.append([cid, dist])

方法2:イテレート+ベクトル計算(Numpy)

groupbyごとにイテレートし、IDごとではNumpyのベクトル計算で距離を算出。結果はリストに溜める。

buf = []
for cid, cdf in df.groupby('ID'):
    xs, ys = cdf['x'].as_matrix(), cdf['y'].as_matrix()
    dists = (np.diff(xs) ** 2 + np.diff(ys) ** 2) ** 0.5
    buf.append((cid, dists.sum()))

方法3:イテレート+ベクトル計算(pandas)

groupbyごとにイテレートし、IDごとではpandasのベクトル計算で距離を算出。結果はリストに溜める。

buf = []
for cid, cdf in df.groupby('ID'):
    dists = (cdf['x'].diff() ** 2 + cdf['y'].diff() ** 2) ** 0.5
    buf.append((cid, dists.sum()))

方法4:ベクトル計算(Numpy)+微修正

Numpyで一気にベクトル計算し、IDの切り替わりの部分だけ修正する。結果はDataFrameで得る。

xs, ys = df['x'].as_matrix(), df['y'].as_matrix()
dists = (np.diff(xs) ** 2 + np.diff(ys) ** 2) ** 0.5
dists = np.append(dists, 0)  # pandasに戻すにあたって長さを揃える
df['dist'] = dists
df.loc[df.groupby('ID').cumcount(ascending=False) == 0, 'dist'] = 0
sdf = df.groupby('ID')['dist'].sum()

DataFrameに新規カラムdistを追加し、「次のレコードとの距離」を埋めている。

5行目について、GroupBy.cumcount()は、グループ化前のレコードに対して自身が同一グループ内で何番目か(0-indexed)を与える。ascending=Falseにすることで逆順になり、末尾レコードが0になる。つまり、IDの切り替わりである末尾レコードを抽出してdistを0にしている。

単純に末尾レコードを得るだけならGroupBy.last()が使えるが、元のDataFrameに代入操作を行うにはindex情報が必要で、last()の結果からはそれをどう取得すればいいかわからなかった。

方法5:ベクトル計算(pandas)+微修正

pandasのdiffを用いてベクトル計算し、IDの切り替わりの部分だけ修正する。結果はDataFrameで得る。

df['dist'] = (df['x'].diff() ** 2 + df['y'].diff() ** 2) ** 0.5
df.loc[df.groupby('ID').cumcount() == 0, 'dist'] = 0
sdf = df.groupby('ID')['dist'].sum()

pandasのdiffは「前のレコードとの差分」を計算する(最初のレコードはNaNになる)。そのため方法4とは異なり、各グループ最初のレコードが除去すべきレコードとなる。

結果

方法秒数
1:二重イテレート88.860
2:イテレート+ベクトル演算(Numpy)2.371~2.465
3:イテレート+ベクトル演算(pandas)6.115
4:ベクトル演算(Numpy)0.562~0.608
5:ベクトル演算(pandas)0.484~0.499

イテレートをすると途端に遅くなっている。特に1で用いたiterrows()は致命的に遅い。多少無駄はあっても、一気にまとめて計算できるならその方がよいと判断できる。

方法4と5では、5の方が速い。やっぱり用意されてる関数はできるだけ使った方がいいっぽい。ただし方法4も微差。

方法2と3を比較すると、2の方が明確に速かった。完全なDataFrameに対するdiffはpandasのdiffを使った方が速いが、そこから切り出した一部に対するdiffは苦手なのだろうか。この辺は注意すべきかも知れない。

まとめて計算するデメリットは、一度に必要なメモリが多い点。これは物理的にメモリをいっぱい積もう。

ケース2:前の値を継承

各レコードについて、所定の条件に当てはまるなら値が決まるが、当てはまらないなら前の値を継承させたい。(所定の条件は、現レコードの情報のみで判別が可能)

この場合は、条件に当てはまらないレコードにはNaNを入れておき、fillna(method='ffill')を使うことで、自動的に前の値で埋めてくれる。

……というfillna()の使い方メモだが、リンク先の例の場合はそもそも条件に当てはまるかの判別でapply()を使わざるを得なくて、そこでイテレートが走っちゃってるんで、それなら前の値を保持しながらfor文を回すのと大差ない気もする。

ケース3:IDごとに移動平均

ケース1の距離計算のように直前の値しか参照しなければ、一括計算した後の微修正も楽だが、数個前後の値まで必要となる場合、修正すべきデータも増えるし、枠の長さによって落とすべきデータが異なるので処理もだんだん複雑になっていく。

移動平均の定義を、畳み込み積分や移動平均を求めるnumpy.convolve関数の使い方 - DeepAgeに倣って'full', 'same', 'valid'に分けて考える。

'valid'(計算に当たってデータがない部分の値は算出しない)とすると、前後のデータと被る部分の値をすっきり削除するだけで済む。

'full'(枠と一部でも被る部分の値を全て算出する)は、そもそもデータの長さが元のデータ以上になってしまうため、まとめて算出するのは難しい気がする。

'same'(先頭はfull, 末尾はvalidに従い、移動平均後のデータ数が元のデータと等しくなる)の場合、うまいこと前後のデータと被る部分を再計算してやる必要がある。

枠の長さが5の場合、IDごとの先頭4レコードを修正の必要がある。

枠の長さがwの場合、先頭w-1レコードを修正の必要がある。

ただ、これをどうやってforなしに実装するかを考えあぐねる。特に減算の部分。