Быстрое удаление пунктуации с помощью панд
Это ответный ответ. Ниже я описываю общую проблему в домене NLP и предлагаю несколько эффективных методов для ее решения.
Зачастую возникает необходимость удалить пунктуацию во время очистки текста и предварительной обработки. Пунктуация определяется как любой символ в string.punctuation
:
>>> import string
string.punctuation
'!"#$%&\'()*+,-./:;<=>[email protected][\\]^_'{|}~'
Это довольно распространенная проблема, и ее спросили до аномального тошноты. Самое идиоматическое решение использует pandas str.replace
. Однако для ситуаций, которые требуют большого количества текста, может потребоваться более эффективное решение.
Какие хорошие, эффективные альтернативы str.replace
при работе с сотнями тысяч записей?
Ответы
Ответ 1
Настроить
Для демонстрации рассмотрим этот DataFrame.
df = pd.DataFrame({'text':['a..b?!??', '%hgh&12','abc123!!!', '$$$1234']})
df
text
0 a..b?!??
1 %hgh&12
2 abc123!!!
3 $$$1234
Ниже я перечисляю альтернативы, один за другим, в порядке увеличения производительности
str.replace
Эта опция включена, чтобы установить метод по умолчанию в качестве эталона для сравнения других, более эффективных решений.
Это использует встроенную функцию str.replace
которая выполняет замену на основе regex.
df['text'] = df['text'].str.replace(r'[^\w\s]+', '')
df
text
0 ab
1 hgh12
2 abc123
3 1234
Это очень легко кодировать, и оно вполне читаемо, но медленное.
regex.sub
Это включает использование sub
из библиотеки re
. Предварительно скомпилируйте шаблон регулярного выражения для производительности и вызовите regex.sub
внутри понимания списка. Преобразуйте df['text']
в список заранее, если вы можете сэкономить некоторое количество памяти, вы получите небольшое повышение производительности.
import re
p = re.compile(r'[^\w\s]+')
df['text'] = [p.sub('', x) for x in df['text'].tolist()]
df
text
0 ab
1 hgh12
2 abc123
3 1234
str.translate
Функция python str.translate
реализована в C и работает со скоростью C; поэтому очень быстро.
Как это работает,
- Сначала соедините все свои строки вместе, чтобы сформировать одну огромную строку, используя один (или более) разделитель символов, который вы выберете. Вы должны использовать символ/подстроку, которую вы можете гарантировать, не будут принадлежать вашим данным.
- Выполните
str.translate
на большой строке, удалив пунктуацию (разделитель с шага 1 исключен). - Разделите строку на разделителе, которая была использована для присоединения к шагу 1. Результирующий список должен иметь ту же длину, что и исходный столбец.
Здесь, в этом примере, рассмотрим сепаратор трубы |
, Если ваши данные содержат трубку, вы должны выбрать другой разделитель.
import string
punct = '!"#$%&\'()*+,-./:;<=>[email protected][\\]^_'{}~' # '|' is not present here
transtab = str.maketrans(dict.fromkeys(punct, ''))
df['text'] = '|'.join(df['text'].tolist()).translate(transtab).split('|')
df
text
0 ab
1 hgh12
2 abc123
3 1234
Представление
str.translate
лучше всего работает. Обратите внимание, что приведенный ниже график включает другой вариант Series.str.translate
из ответа MaxU.
(Интересно, что я повторил это во второй раз, и результаты немного отличаются от re.sub
. Во время второго запуска кажется, что re.sub
выигрывал за str.translate
за действительно небольшие объемы данных.) ![enter image description here]()
Существует неотъемлемый риск, связанный с использованием translate
(в частности, проблема автоматизации процесса принятия решения о том, какой разделитель использовать нетривиальным), но компромиссы стоят риска.
Другие соображения
Если вы имеете дело с DataFrames, где каждый столбец требует замены, процедура проста:
v = pd.Series(df.values.ravel())
df[:] = translate(v).values.reshape(df.shape)
Или,
v = df.stack()
v[:] = translate(v)
df = v.unstack()
Каждое решение имеет компромиссы, поэтому решение, какое решение наилучшим образом соответствует вашим потребностям, будет зависеть от того, что вы готовы пожертвовать. Два очень распространенных соображения - это производительность (что мы уже видели) и использование памяти. str.translate
- голодное решение, поэтому используйте его с осторожностью.
Еще одно соображение - сложность вашего регулярного выражения. Иногда вы можете удалить все, что не является буквенно-цифровым или пробельным. В других случаях вам нужно будет сохранить определенные символы, такие как дефисы, двоеточия и терминаторы предложений [.!?]
. Указание этих явно повышает сложность вашего регулярного выражения, что, в свою очередь, может повлиять на производительность этих решений. Перед тем, как решить, что использовать, убедитесь, что вы тестируете эти решения.
Наконец, символы Unicode будут удалены с помощью этого решения. Вы можете настроить свое регулярное выражение (если используете решение на основе регулярных выражений) или просто пойти с str.translate
противном случае.
Для еще большей производительности (для большего N) взгляните на этот ответ Пол Панцер.
аппендикс
функции
def pd_replace(df):
return df.assign(text=df['text'].str.replace(r'[^\w\s]+', ''))
def re_sub(df):
p = re.compile(r'[^\w\s]+')
return df.assign(text=[p.sub('', x) for x in df['text'].tolist()])
def translate(df):
punct = string.punctuation.replace('|', '')
transtab = str.maketrans(dict.fromkeys(punct, ''))
return df.assign(
text='|'.join(df['text'].tolist()).translate(transtab).split('|')
)
# MaxU version (/questions/7302839/fast-punctuation-removal-with-pandas/14293025#14293025)
def pd_translate(df):
punct = string.punctuation.replace('|', '')
transtab = str.maketrans(dict.fromkeys(punct, ''))
return df.assign(text=df['text'].str.translate(transtab))
Бенчмаркинг
from timeit import timeit
import pandas as pd
import matplotlib.pyplot as plt
res = pd.DataFrame(
index=['pd_replace', 're_sub', 'translate', 'pd_translate'],
columns=[10, 50, 100, 500, 1000, 5000, 10000, 50000],
dtype=float
)
for f in res.index:
for c in res.columns:
l = ['a..b?!??', '%hgh&12','abc123!!!', '$$$1234'] * c
df = pd.DataFrame({'text' : l})
stmt = '{}(df)'.format(f)
setp = 'from __main__ import df, {}'.format(f)
res.at[f, c] = timeit(stmt, setp, number=30)
ax = res.div(res.min()).T.plot(loglog=True)
ax.set_xlabel("N");
ax.set_ylabel("time (relative)");
plt.show()
Ответ 2
Довольно интересно, что векторизованный метод Series.str.translate все еще немного медленнее по сравнению с Vanilla Python str.translate()
:
def pd_translate(df):
return df.assign(text=df['text'].str.translate(transtab))
![enter image description here]()
Ответ 3
Используя numpy, мы можем получить здоровое ускорение по сравнению с лучшими методами, опубликованными до сих пор. Основная стратегия похожа - сделайте одну большую суперструю. Но обработка кажется намного более быстрой в numpy, по-видимому, потому, что мы полностью используем простоту замены no-for-something op.
Для более мелких (менее 0x110000
символов) проблем мы автоматически находим разделитель, для больших задач мы используем более медленный метод, который не зависит от str.split
.
Обратите внимание, что я переместил все прекомпьютеры из функций. Также обратите внимание, что translate
и pd_translate
узнают единственный возможный разделитель для трех самых больших проблем бесплатно, тогда как np_multi_strat
должен вычислить его или вернуться к стратегии без разделителей. И, наконец, обратите внимание, что для последних трех точек данных я переключаюсь на более "интересную" проблему; pd_replace
и re_sub
потому что они не эквивалентны другим методам, которые должны быть исключены для этого.
![enter image description here]()
Об алгоритме:
Основная стратегия на самом деле довольно проста. Есть только 0x110000
различных символов 0x110000
. Поскольку OP создает проблему с точки зрения огромных наборов данных, совершенно целесообразно составить таблицу поиска, которая имеет True
в символьном идентификаторе, который мы хотим сохранить, и False
в тех, которые должны пройти --- пунктуация в нашем примере.
Такая таблица поиска может использоваться для массового loookup с использованием расширенной индексации numpy. Поскольку поиск полностью векторизован и по существу сводится к разыменованию массива указателей, он намного быстрее, чем, например, поиск в словарях. Здесь мы используем numpy view casting, который позволяет переинтерпретировать символы Unicode как целые числа, по существу, бесплатно.
Использование массива данных, содержащего только одну строку монстра, переинтерпретированную как последовательность чисел для индексации в таблицу поиска, приводит к булевой маске. Затем эту маску можно использовать для фильтрации нежелательных символов. С помощью булевского индексирования это тоже одна строка кода.
Пока все так просто. Трудный бит измельчает монструю строку обратно в ее части. Если у нас есть разделитель, т.е. Один символ, который не встречается в данных или списке препинаний, тогда он все же остается простым. Используйте этот символ для присоединения и повторного набора. Однако автоматический поиск разделителя является сложным и действительно составляет половину места в реализации ниже.
В качестве альтернативы мы можем хранить точки разделения в отдельной структуре данных, отслеживать, как они перемещаются вследствие удаления нежелательных символов, а затем использовать их для нарезки обработанной строки монстра. Поскольку измельчение на части неровной длины не является самым сильным, этот метод медленнее, чем str.split
и используется только в качестве резерва, когда разделитель будет слишком дорогим для расчета, если он существовал в первую очередь.
Код (хронометраж/построение графика в основном на основе сообщения @COLDSPEED):
import numpy as np
import pandas as pd
import string
import re
spct = np.array([string.punctuation]).view(np.int32)
lookup = np.zeros((0x110000,), dtype=bool)
lookup[spct] = True
invlookup = ~lookup
OSEP = spct[0]
SEP = chr(OSEP)
while SEP in string.punctuation:
OSEP = np.random.randint(0, 0x110000)
SEP = chr(OSEP)
def find_sep_2(letters):
letters = np.array([letters]).view(np.int32)
msk = invlookup.copy()
msk[letters] = False
sep = msk.argmax()
if not msk[sep]:
return None
return sep
def find_sep(letters, sep=0x88000):
letters = np.array([letters]).view(np.int32)
cmp = np.sign(sep-letters)
cmpf = np.sign(sep-spct)
if cmp.sum() + cmpf.sum() >= 1:
left, right, gs = sep+1, 0x110000, -1
else:
left, right, gs = 0, sep, 1
idx, = np.where(cmp == gs)
idxf, = np.where(cmpf == gs)
sep = (left + right) // 2
while True:
cmp = np.sign(sep-letters[idx])
cmpf = np.sign(sep-spct[idxf])
if cmp.all() and cmpf.all():
return sep
if cmp.sum() + cmpf.sum() >= (left & 1 == right & 1):
left, sep, gs = sep+1, (right + sep) // 2, -1
else:
right, sep, gs = sep, (left + sep) // 2, 1
idx = idx[cmp == gs]
idxf = idxf[cmpf == gs]
def np_multi_strat(df):
L = df['text'].tolist()
all_ = ''.join(L)
sep = 0x088000
if chr(sep) in all_: # very unlikely ...
if len(all_) >= 0x110000: # fall back to separator-less method
# (finding separator too expensive)
LL = np.array((0, *map(len, L)))
LLL = LL.cumsum()
all_ = np.array([all_]).view(np.int32)
pnct = invlookup[all_]
NL = np.add.reduceat(pnct, LLL[:-1])
NLL = np.concatenate([[0], NL.cumsum()]).tolist()
all_ = all_[pnct]
all_ = all_.view(f'U{all_.size}').item(0)
return df.assign(text=[all_[NLL[i]:NLL[i+1]]
for i in range(len(NLL)-1)])
elif len(all_) >= 0x22000: # use mask
sep = find_sep_2(all_)
else: # use bisection
sep = find_sep(all_)
all_ = np.array([chr(sep).join(L)]).view(np.int32)
pnct = invlookup[all_]
all_ = all_[pnct]
all_ = all_.view(f'U{all_.size}').item(0)
return df.assign(text=all_.split(chr(sep)))
def pd_replace(df):
return df.assign(text=df['text'].str.replace(r'[^\w\s]+', ''))
p = re.compile(r'[^\w\s]+')
def re_sub(df):
return df.assign(text=[p.sub('', x) for x in df['text'].tolist()])
punct = string.punctuation.replace(SEP, '')
transtab = str.maketrans(dict.fromkeys(punct, ''))
def translate(df):
return df.assign(
text=SEP.join(df['text'].tolist()).translate(transtab).split(SEP)
)
# MaxU version (https://stackoverflow.com/a/50444659/4909087)
def pd_translate(df):
return df.assign(text=df['text'].str.translate(transtab))
from timeit import timeit
import pandas as pd
import matplotlib.pyplot as plt
res = pd.DataFrame(
index=['translate', 'pd_replace', 're_sub', 'pd_translate', 'np_multi_strat'],
columns=[10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000, 500000,
1000000],
dtype=float
)
for c in res.columns:
if c >= 100000: # stress test the separator finder
all_ = np.r_[:OSEP, OSEP+1:0x110000].repeat(c//10000)
np.random.shuffle(all_)
split = np.arange(c-1) + \
np.sort(np.random.randint(0, len(all_) - c + 2, (c-1,)))
l = [x.view(f'U{x.size}').item(0) for x in np.split(all_, split)]
else:
l = ['a..b?!??', '%hgh&12','abc123!!!', '$$$1234'] * c
df = pd.DataFrame({'text' : l})
for f in res.index:
if f == res.index[0]:
ref = globals()[f](df).text
elif not (ref == globals()[f](df).text).all():
res.at[f, c] = np.nan
print(f, 'disagrees at', c)
continue
stmt = '{}(df)'.format(f)
setp = 'from __main__ import df, {}'.format(f)
res.at[f, c] = timeit(stmt, setp, number=16)
ax = res.div(res.min()).T.plot(loglog=True)
ax.set_xlabel("N");
ax.set_ylabel("time (relative)");
plt.show()