Это самостоятельный ответ. Ниже я описываю общую проблему в области НЛП и предлагаю несколько эффективных методов ее решения.
Часто возникает необходимость удалить пунктуация во время очистки и предварительной обработки текста. Пунктуация определяется как любой символ в string.punctuation:
>>> import string
string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
Это достаточно распространенная проблема, и ее задавали до тошноты. Самое идиоматическое решение использует pandas str.replace. Однако для ситуаций, которые включают много текста, может потребоваться более эффективное решение.
Какие есть хорошие и эффективные альтернативы str.replace при работе с сотнями тысяч записей?






Для демонстрации рассмотрим этот 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 в pandas, которая выполняет замену на основе регулярных выражений.
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
Примечание: Если ваши данные имеют значения NaN, этот (а также следующий ниже метод) не будет работать как есть. См. Раздел «Прочие соображения».
str.translateФункция str.translate в python реализована на C и, следовательно, является очень быстро.
Как это работает:
str.translate на большой строке, удалив знаки препинания (за исключением разделителя из шага 1).Здесь, в этом примере, мы рассматриваем трубный разделитель |. Если ваши данные содержат трубу, вы должны выбрать другой разделитель.
import string
punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{}~' # `|` 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 побеждает str.translate для действительно небольших объемов данных.)

Использование translate сопряжено с внутренним риском (в частности, проблема автоматизация - процесс принятия решения о том, какой разделитель использовать, является нетривиальным), но компромиссы стоят риска.
Обработка NaN с помощью методов понимания списка; Обратите внимание, что этот метод (и следующий) будет работать только до тех пор, пока ваши данные не имеют NaN. При обработке NaN вам нужно будет определить индексы ненулевых значений и заменить только их. Попробуйте что-то вроде этого:
df = pd.DataFrame({'text': [
'a..b?!??', np.nan, '%hgh&12','abc123!!!', '$$$1234', np.nan]})
idx = np.flatnonzero(df['text'].notna())
col_idx = df.columns.get_loc('text')
df.iloc[idx,col_idx] = [
p.sub('', x) for x in df.iloc[idx,col_idx].tolist()]
df
text
0 ab
1 NaN
2 hgh12
3 abc123
4 1234
5 NaN
Работа с DataFrames; Если вы имеете дело с DataFrames, где столбец каждый требует замены, процедура проста:
v = pd.Series(df.values.ravel())
df[:] = translate(v).values.reshape(df.shape)
Или же,
v = df.stack()
v[:] = translate(v)
df = v.unstack()
Обратите внимание, что функция translate определена ниже в коде эталонного тестирования.
У каждого решения есть компромиссы, поэтому решение, какое решение лучше всего соответствует вашим потребностям, будет зависеть от того, чем вы готовы пожертвовать. Два очень распространенных фактора - это производительность (что мы уже видели) и использование памяти. str.translate - это решение, требовательное к памяти, поэтому используйте его с осторожностью.
Еще одно соображение - сложность вашего регулярного выражения. Иногда вам может потребоваться удалить все, что не является буквенно-цифровым или пробельным. В других случаях вам нужно будет сохранить определенные символы, такие как дефисы, двоеточия и терминаторы предложений [.!?]. Их указание явно увеличивает сложность вашего регулярного выражения, что, в свою очередь, может повлиять на производительность этих решений. Убедитесь, что вы протестировали эти решения
на ваших данных, прежде чем решить, что использовать.
Наконец, с помощью этого решения будут удалены символы Юникода. Вы можете настроить свое регулярное выражение (при использовании решения на основе регулярных выражений) или просто использовать 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's version (https://stackoverflow.com/a/50444659/4909087)
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()
@ killerT2333 Я написал кое-что об этом в блоге здесь, в этот ответ. Надеюсь, вы сочтете это полезным. Любые отзывы / критика приветствуются.
@ killerT2333 Небольшое примечание: этот пост фактически не включает вызов лемматизатора / стеммера, поэтому для этого кода вы можете посмотреть здесь и при необходимости расширить его. Боже, мне действительно нужно все организовать.
Спасибо @coldspeed, это отличные ресурсы. Я посмотрю на них. Было бы здорово, если бы у вас был собственный блог, в котором вы объединили бы все эти методы python NLP + pandas в одном месте - это было бы очень полезно.
@coldspeed, Итак, у меня есть вопрос. Как бы вы включили ВСЕ не алфавитные символы в punct? Что-то вроде re.compile(r"[^a-zA-Z]"). Я обрабатываю много текста специальными символами, такими как ™ и ˚ и т. д., Поэтому мне нужно избавиться от всего этого дерьма. Я думаю, что явно включать их в punct было бы слишком сложно, так как символов слишком много (и я заметил, что str.maketrans не улавливает все эти специальные символы)
Это наименьший диапазон значений, который я когда-либо видел для использования логарифмической шкалы, если предположить, что это логарифмическая шкала на вертикальной оси этого графика.
Святая корова! Спасибо!! Чрезвычайно полезно.
Достаточно интересно, что векторизованный метод Series.str.translate все еще немного медленнее по сравнению с Vanilla Python str.translate():
def pd_translate(df):
return df.assign(text=df['text'].str.translate(transtab))
Я предполагаю, что причина в том, что мы выполняем N переводов вместо объединения, выполнения одного и разделения.
@coldspeed, да, я тоже так думаю
попробуйте это с NaN и посмотрите, что произойдет
Используя numpy, мы можем получить значительное ускорение по сравнению с лучшими методами, опубликованными на данный момент. Основная стратегия аналогична - сделать одну большую суперструну. Но в numpy обработка кажется намного быстрее, по-видимому, потому, что мы полностью используем простоту операции замены ничего за что-то.
Для небольших проблем (меньше, чем 0x110000 символов) мы автоматически находим разделитель, для больших проблем мы используем более медленный метод, который не полагается на str.split.
Обратите внимание, что я переместил все предвычисляемые данные из функций. Также обратите внимание, что translate и pd_translate бесплатно узнают единственно возможный разделитель для трех самых больших проблем, тогда как np_multi_strat должен вычислить его или вернуться к стратегии без разделителя. И, наконец, обратите внимание, что для последних трех точек данных я перехожу к более «интересной» проблеме; Для этого пришлось исключить pd_replace и re_sub, поскольку они не эквивалентны другим методам.
Основная стратегия на самом деле довольно проста. Есть только разные символы Unicode 0x110000. Поскольку OP формулирует задачу с точки зрения огромных наборов данных, совершенно целесообразно создать таблицу поиска, в которой есть True в идентификаторах символа, которые мы хотим сохранить, и False в тех, которые должны быть удалены - пунктуация в нашем примере.
Такая таблица поиска может использоваться для массового поиска с использованием расширенной индексации numpy. Поскольку поиск полностью векторизован и по сути сводится к разыменованию массива указателей, он намного быстрее, чем, например, поиск по словарю. Здесь мы используем преобразование типа numpy view, которое позволяет интерпретировать символы Unicode как целые числа практически бесплатно.
Использование массива данных, который содержит только одну строку-монстр, переинтерпретированную как последовательность чисел для индексации в таблице поиска, приводит к булевой маске. Затем эту маску можно использовать для фильтрации нежелательных символов. При использовании логической индексации это тоже одна строка кода.
Пока все просто. Сложность состоит в том, чтобы разрубить нить чудовища на части. Если у нас есть разделитель, то есть один символ, которого нет в данных или списке знаков препинания, то это все равно легко. Используйте этого персонажа, чтобы присоединиться и разделиться. Однако автоматический поиск разделителя является сложной задачей и действительно составляет половину loc в приведенной ниже реализации.
В качестве альтернативы мы можем сохранить точки разделения в отдельной структуре данных, отслеживать, как они перемещаются в результате удаления нежелательных символов, а затем использовать их для разрезания обработанной строки монстра. Поскольку разделение на части неравной длины - не самая сильная сторона numpy, этот метод медленнее, чем 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's 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()
Мне нравится этот ответ и я ценю объем работы, который был вложен в него. Это, безусловно, ставит под сомнение пределы производительности для таких операций, как мы ее знаем. Пара небольших замечаний, 1) вы можете объяснить / задокументировать свой код, чтобы было немного более понятно, что делают определенные подпрограммы? 2) при низких значениях N накладные расходы существенно превышают производительность, и 3) мне было бы интересно посмотреть, как это сравнивается с точки зрения памяти. В общем, отличная работа!
@coldspeed 1) Я попробовал. Надеюсь, поможет. 2) Ага, для вас это неловко. 3) Память может быть проблемой, потому что мы создаем суперструну, затем numpyfy ее, которая создает копию, затем создает маску тех же размеров, а затем фильтрует, которая создает другую копию.
Отличное объяснение, спасибо! Можно ли расширить этот анализ / метод до 1. удаления игнорируемых слов 2. выделения слов 3. перевода всех слов в нижний регистр?