Панды фильтрации для нескольких подстрок в серии
Мне нужно отфильтровать строки в 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]
Мой фрейм данных большой (~1 миллион строк) и lst
имеет длину 100. Есть ли более эффективный способ? Например, если первый элемент в lst
найдено, нам не нужно проверять любые последующие строки для этой строки.
2 ответа
Если вы придерживаетесь чистых панд, для производительности и практичности я думаю, что вы должны использовать регулярные выражения для этой задачи. Однако сначала вам нужно будет правильно экранировать любые специальные символы в подстроках, чтобы обеспечить их буквальное совпадение (и не использовать в качестве метасимволов регулярных выражений).
Это легко сделать с помощью 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 секунд, используя те же входные данные.
Стоит отметить, что эти времена являются "наихудшими" в том смысле, что совпадений не было (поэтому были проверены все подстроки). Если есть совпадения, тогда время улучшится.
Вы можете попробовать использовать алгоритм 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://stackru.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))
Используя более простой пример и игнорировать регистр (верхний или нижний регистр)
Фильтрация и получение двоичного вектора:
Я хочу найти все элементы 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