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

Это самостоятельный ответ. Ниже я описываю общую проблему в области НЛП и предлагаю несколько эффективных методов ее решения.

Часто возникает необходимость удалить пунктуация во время очистки и предварительной обработки текста. Пунктуация определяется как любой символ в string.punctuation:

>>> import string
string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'

Это достаточно распространенная проблема, и ее задавали до тошноты. Самое идиоматическое решение использует pandas str.replace. Однако для ситуаций, которые включают много текста, может потребоваться более эффективное решение.

Какие есть хорошие и эффективные альтернативы str.replace при работе с сотнями тысяч записей?

Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
69
0
15 517
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Ответ принят как подходящий

Настраивать

Для демонстрации рассмотрим этот 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 и, следовательно, является очень быстро.

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

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

Отличное объяснение, спасибо! Можно ли расширить этот анализ / метод до 1. удаления игнорируемых слов 2. выделения слов 3. перевода всех слов в нижний регистр?

PyRsquared 31.05.2018 16:29

@ killerT2333 Я написал кое-что об этом в блоге здесь, в этот ответ. Надеюсь, вы сочтете это полезным. Любые отзывы / критика приветствуются.

cs95 31.05.2018 16:41

@ killerT2333 Небольшое примечание: этот пост фактически не включает вызов лемматизатора / стеммера, поэтому для этого кода вы можете посмотреть здесь и при необходимости расширить его. Боже, мне действительно нужно все организовать.

cs95 31.05.2018 16:43

Спасибо @coldspeed, это отличные ресурсы. Я посмотрю на них. Было бы здорово, если бы у вас был собственный блог, в котором вы объединили бы все эти методы python NLP + pandas в одном месте - это было бы очень полезно.

PyRsquared 31.05.2018 16:48

@coldspeed, Итак, у меня есть вопрос. Как бы вы включили ВСЕ не алфавитные символы в punct? Что-то вроде re.compile(r"[^a-zA-Z]"). Я обрабатываю много текста специальными символами, такими как ™ и ˚ и т. д., Поэтому мне нужно избавиться от всего этого дерьма. Я думаю, что явно включать их в punct было бы слишком сложно, так как символов слишком много (и я заметил, что str.maketrans не улавливает все эти специальные символы)

PyRsquared 01.06.2018 12:04

Это наименьший диапазон значений, который я когда-либо видел для использования логарифмической шкалы, если предположить, что это логарифмическая шкала на вертикальной оси этого графика.

user2357112 supports Monica 22.06.2018 20:32

Святая корова! Спасибо!! Чрезвычайно полезно.

GbG 13.07.2020 23:09

Достаточно интересно, что векторизованный метод Series.str.translate все еще немного медленнее по сравнению с Vanilla Python str.translate():

def pd_translate(df):
    return df.assign(text=df['text'].str.translate(transtab))

Я предполагаю, что причина в том, что мы выполняем N переводов вместо объединения, выполнения одного и разделения.

cs95 21.05.2018 10:21

@coldspeed, да, я тоже так думаю

MaxU 21.05.2018 10:22

попробуйте это с NaN и посмотрите, что произойдет

Jeff 25.05.2018 03:20

Используя 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) мне было бы интересно посмотреть, как это сравнивается с точки зрения памяти. В общем, отличная работа!

cs95 25.05.2018 01:07

@coldspeed 1) Я попробовал. Надеюсь, поможет. 2) Ага, для вас это неловко. 3) Память может быть проблемой, потому что мы создаем суперструну, затем numpyfy ее, которая создает копию, затем создает маску тех же размеров, а затем фильтрует, которая создает другую копию.

Paul Panzer 25.05.2018 02:12

Другие вопросы по теме