Быстрое удаление пунктуации с пандами - PullRequest
0 голосов
/ 21 мая 2018

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

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

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

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

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

Ответы [ 3 ]

0 голосов
/ 21 мая 2018

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

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

enter image description here

0 голосов
/ 25 мая 2018

Используя numpy, мы можем ускорить работу над лучшими методами, опубликованными до сих пор.Основная стратегия похожа - сделай одну большую суперструну.Но обработка кажется намного быстрее в NumPy, по-видимому, потому что мы полностью используем простоту замены ничего не для чего-то.

Для небольших (менее 0x110000 символов) задач мы автоматически находим разделитель,для более крупных задач мы используем более медленный метод, который не полагается на str.split.

Обратите внимание, что я переместил все предкомпьютерные функции из функций.Также обратите внимание, что translate и pd_translate узнают единственный возможный разделитель для трех самых больших проблем бесплатно, тогда как np_multi_strat должен вычислить его или вернуться к стратегии без разделителей.И наконец, обратите внимание, что для последних трех точек данных я переключаюсь на более «интересную» проблему;pd_replace и re_sub потому что они не эквивалентны другим методам, для этого пришлось исключить.

enter image description here

Об алгоритме:

Основная стратегия на самом деле довольно проста.Есть только 0x110000 различных символов Юникода.Поскольку OP создает проблему с точки зрения огромных наборов данных, совершенно целесообразно создать справочную таблицу, в которой True содержит идентификаторы символов, которые мы хотим сохранить, и False - те, которые должны идти - пунктуацияв нашем примере.

Такая таблица поиска может быть использована для массового запуска с использованием расширенной индексации numpy.Поскольку поиск полностью векторизован и по существу сводится к разыменованию массива указателей, он выполняется намного быстрее, чем, например, поиск по словарю.Здесь мы используем приведение к типу numpy, которое позволяет по-разному интерпретировать символы Юникода как целые числа бесплатно.

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

Пока все просто.Хитрый бит разбивает монстру обратно на его части.Если у нас есть разделитель, т. Е. Один символ, которого нет в данных или списке пунктуации, то это все еще просто.Используйте этот символ, чтобы присоединиться и переделать.Однако автоматический поиск разделителя представляет собой сложную задачу, и на самом деле в приведенной ниже реализации на него приходится половина местоположения.

В качестве альтернативы, мы можем хранить точки разделения в отдельной структуре данных, отслеживая их перемещение в результате удаления нежелательныхсимволы, а затем использовать их, чтобы нарезать обработанную строку монстра.Поскольку нарезка на части неравной длины не является самой сильной стороной 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()
0 голосов
/ 21 мая 2018

Настройка

В целях демонстрации давайте рассмотрим этот фрейм данных.

df = pd.DataFrame({'text':['a..b?!??', '%hgh&12','abc123!!!', '$$$1234']})
df
        text
0   a..b?!??
1    %hgh&12
2  abc123!!!
3    $$$1234

Ниже я перечислю альтернативы, одну за другой, в порядке увеличения производительности

str.replace

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

В нем используется встроенная в * pandas функция str.replace, которая выполняет регулярное выражениезамена на основе.

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

Функция Python str.translate реализована на 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 для действительно небольших объемов данных.) enter image description here

Существует риск, связанный с использованием 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

Работа с фреймами данных; Если вы имеете дело с фреймами данных, где каждый столбец требует замены, процедура проста:

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()
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...