Быстрое удаление пунктуации с помощью панд

Это ответный ответ. Ниже я описываю общую проблему в домене 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; поэтому очень быстро.

Как это работает,

  1. Сначала соедините все свои строки вместе, чтобы сформировать одну огромную строку, используя один (или более) разделитель символов, который вы выберете. Вы должны использовать символ/подстроку, которую вы можете гарантировать, не будут принадлежать вашим данным.
  2. Выполните str.translate на большой строке, удалив пунктуацию (разделитель с шага 1 исключен).
  3. Разделите строку на разделителе, которая была использована для присоединения к шагу 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()