web-dev-qa-db-ja.com

Pandas連続する複数の部分文字列のフィルタリング

特定の文字列列に、提供された部分文字列のリストの少なくとも1つが含まれるように、pandasデータフレームの行をフィルタリングする必要があります。部分文字列には、異常な/正規表現の文字が含まれる場合があります。比較には正規表現を使用しないでください。大文字と小文字は区別されません。

例えば:

lst = ['kdSj;af-!?', 'aBC+dsfa?\-', 'sdKaJg|dksaf-*']

現在、次のようにマスクを適用しています。

mask = np.logical_or.reduce([df[col].str.contains(i, regex=False, case=False) for i in lst])
df = df[mask]

私のデータフレームは大きく(〜1mio行)、lstの長さは100です。より効率的な方法はありますか?たとえば、lstの最初のアイテムが見つかった場合、その行の後続の文字列をテストする必要はありません。

29
jpp

純粋なパンダの使用に固執している場合は、パフォーマンスと実用性の両方のために、このタスクに正規表現を使用する必要がありますshouldただし、最初に部分文字列の特殊文字を適切にエスケープして、それらが文字どおりに一致することを確認する必要があります(正規表現のメタ文字として使用されない)。

これは re.escape

>>> import re
>>> esc_lst = [re.escape(s) for s in lst]

これらのエスケープされた部分文字列は、正規表現パイプ|。それぞれの部分文字列は、1つが一致するまで(またはすべてテストされるまで)文字列に対してチェックできます。

>>> pattern = '|'.join(esc_lst)

マスキングステージは、行を通る単一の低レベルループになります。

df[col].str.contains(pattern, case=False)

パフォーマンスの感覚を得るための簡単なセットアップを次に示します。

from random import randint, seed

seed(321)

# 100 substrings of 5 characters
lst = [''.join([chr(randint(0, 256)) for _ in range(5)]) for _ in range(100)]

# 50000 strings of 20 characters
strings = [''.join([chr(randint(0, 256)) for _ in range(20)]) for _ in range(50000)]

col = pd.Series(strings)
esc_lst = [re.escape(s) for s in lst]
pattern = '|'.join(esc_lst)

提案された方法には約1秒かかります(したがって、100万行に対して最大20秒かかる場合があります)。

%timeit col.str.contains(pattern, case=False)
1 loop, best of 3: 981 ms per loop

質問のメソッドは、同じ入力データを使用して約5秒かかりました。

一致がなかったという意味で、これらの時間は「最悪のケース」であることに注意してください(したがってall部分文字列がチェックされました)。一致する場合は、タイミングが改善されます。

36
Alex Riley

Aho-Corasickアルゴリズム を使用してみてください。平均的なケースでは、O(n+m+p)です。nは検索文字列の長さ、mは検索テキストの長さ、pは一致する出力の数。

Aho-Corasickアルゴリズムは、入力テキスト(haystack)で複数のパターン(針)を見つけるために よく使用されます です。

pyahocorasick は、PythonアルゴリズムのC実装のラッパーです。


それがどれくらい速いかをいくつかの選択肢と比較してみましょう。以下は、using_aho_corasickが50K行のDataFrameテストケースで元のメソッド(質問に表示)よりも30倍以上高速であることを示すベンチマークです。

|                    |     speed factor | ms per loop |
|                    | compared to orig |             |
|--------------------+------------------+-------------|
| using_aho_corasick |            30.7x |         140 |
| using_regex        |             2.7x |        1580 |
| orig               |             1.0x |        4300 |

In [89]: %timeit using_ahocorasick(col, lst)
10 loops, best of 3: 140 ms per loop

In [88]: %timeit using_regex(col, lst)
1 loop, best of 3: 1.58 s per loop

In [91]: %timeit orig(col, lst)
1 loop, best of 3: 4.3 s per loop

ここでは、ベンチマークに使用されるセットアップ。また、出力がorigによって返された結果と一致することを検証します。

import numpy as np
import random
import pandas as pd
import ahocorasick
import re

random.seed(321)

def orig(col, lst):
    mask = np.logical_or.reduce([col.str.contains(i, regex=False, case=False) 
                                 for i in lst])
    return mask

def using_regex(col, lst):
    """https://stackoverflow.com/a/48590850/190597 (Alex Riley)"""
    esc_lst = [re.escape(s) for s in lst]
    pattern = '|'.join(esc_lst)
    mask = col.str.contains(pattern, case=False)
    return mask

def using_ahocorasick(col, lst):
    A = ahocorasick.Automaton(ahocorasick.STORE_INTS)
    for Word in lst:
        A.add_Word(word.lower())
    A.make_automaton() 
    col = col.str.lower()
    mask = col.apply(lambda x: bool(list(A.iter(x))))
    return mask

N = 50000
# 100 substrings of 5 characters
lst = [''.join([chr(random.randint(0, 256)) for _ in range(5)]) for _ in range(100)]

# N strings of 20 characters
strings = [''.join([chr(random.randint(0, 256)) for _ in range(20)]) for _ in range(N)]
# make about 10% of the strings match a string from lst; this helps check that our method works
strings = [_ if random.randint(0, 99) < 10 else _+random.choice(lst) for _ in strings]

col = pd.Series(strings)

expected = orig(col, lst)
for name, result in [('using_regex', using_regex(col, lst)),
                     ('using_ahocorasick', using_ahocorasick(col, lst))]:
    status = 'pass' if np.allclose(expected, result) else 'fail'
    print('{}: {}'.format(name, status))
36
unutbu