Pandas фильтрация для нескольких подстрок последовательно

Мне нужно фильтровать строки в pandas dataframe, чтобы определенный столбец строки содержал хотя бы один из списка предоставленных подстрок. Подстроки могут иметь необычные/регулярные символы. Сравнение не должно включать регулярное выражение и нечувствительно к регистру.

Например:

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]

Мой dataframe большой (~ 1mio rows) и lst имеет длину 100. Есть ли более эффективный способ? Например, если первый элемент в lst найден, нам не нужно проверять любые последующие строки для этой строки.

Ответ 1

Если вы придерживаетесь чистых панд, для производительности и практичности я думаю, что вы должны использовать регулярные выражения для этой задачи. Однако сначала вам нужно будет правильно экранировать любые специальные символы в подстрока, чтобы убедиться, что они сопоставляются буквально (и не используются в качестве метасимволов регулярных выражений).

Это легко сделать с помощью re.escape:

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

Эти экранированные подстроки могут быть объединены с помощью регулярного выражения | , Каждая из подстрок может быть проверена на соответствие строке до тех пор, пока одна из них не совпадет (или все они были проверены).

>>> 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 секунды (так что может быть до 20 секунд на 1 миллион строк):

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

Метод в вопросе занял приблизительно 5 секунд, используя те же входные данные.

Стоит отметить, что эти времена являются "наихудшими" в том смысле, что совпадений не было (поэтому были проверены все подстроки). Если есть совпадения, тогда время улучшится.

Ответ 2

Вы можете попробовать использовать алгоритм Aho-Corasick. В среднем случае это O(n+m+p) где n - длина строк поиска, а m - длина искомого текста, а p - количество совпадений на выходе.

Алгоритм Aho-Corasick часто используется для поиска нескольких шаблонов (игл) во входном тексте (стоге сена).

pyahocorasick - это оболочка Python для реализации алгоритма на языке C.


Давайте сравним, насколько быстро это против некоторых альтернатив. Ниже приведен тест, показывающий, что using_aho_corasick более чем в 30 раз быстрее, чем оригинальный метод (показанный в вопросе) в тестовом примере DataFrame с 50К строк:

|                    |     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))

Ответ 3

Используя более простой пример и игнорировать регистр (верхний или нижний регистр)

Фильтрация и получение двоичного вектора:

Я хочу найти все элементы pd.Series, v, которые содержат "at" или "Og". И получить 1, если элемент содержит шаблон, или 0, если его нет.

Я буду использовать re:
import re

Мой вектор:

v=pd.Series(['cAt','dog','the rat','mouse','froG'])

[Out]:

0        cAt
1        dog
2    the rat
3      mouse
4       froG

Я хочу найти все элементы v, которые содержат "at" или "Og". Это, я могу определить свой pattern как:

pattern='at|Og'

Поскольку я хочу вектор с 1, если элемент содержит шаблон, или 0, если нет.

Я создаю унитарный вектор такой же длины, как v:

v_binary=[1]*len(v)

Я получаю логическое значение s которое имеет значение True если один элемент v содержит pattern или False если он не содержит его.

s=v.str.contains(pattern, flags=re.IGNORECASE, regex=True)

Чтобы получить двоичный вектор, я v_binary * s:

v_binary*s

[Out]

0    1
1    1
2    1
3    0
4    1