这是一个自我回答的帖子。下面,我概述了NLP领域中的一个常见问题,并提出了一些有效的解决方法。

经常需要在文本清理和预处理期间删除标点符号。标点符号定义为string.punctuation中的任何字符:最惯用的解决方案是使用熊猫str.replace。但是,对于涉及大量文本的情况,可能需要考虑使用性能更高的解决方案。

处理成千上万条记录时,str.replace有哪些好的性能替代品?

#1 楼

设置

出于演示目的,让我们考虑一下此DataFrame。性能顺序

str.replace

包含此选项是为了建立默认方法作为比较其他性能更高的解决方案的基准。

pandas内置的str.replace函数可以执行基于正则表达式的替换。

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




df['text'] = df['text'].str.replace(r'[^\w\s]+', '')


这非常易于编码,可读性强,但速度慢。预编译正则表达式模式以提高性能,并在列表推导内调用regex.sub。如果可以节省一些内存,请事先将sub转换为列表,这样可以使性能得到一些提升。

df
     text
0      ab
1   hgh12
2  abc123
3    1234




import re
p = re.compile(r'[^\w\s]+')
df['text'] = [p.sub('', x) for x in df['text'].tolist()]


注意:如果您的数据具有NaN值,则此方法(以及下面的下一个方法)将无法正常工作。请参阅“其他注意事项”部分。


re

python的regex.sub函数是用C实现的,因此非常快。

这是如何工作的:


首先,使用您选择的单个(或多个)字符分隔符将所有字符串连接在一起,形成一个巨大的字符串。您必须使用可以保证不会包含在数据内的字符/子字符串。
对大字符串执行df['text'],删除标点符号(不包括步骤1中的分隔符)。
将字符串拆分到分隔符上在步骤1中用于联接。结果列表的长度必须与初始列的长度相同。

在此示例中,我们考虑使用管道分隔符str.translate。如果您的数据包含管道,则必须选择另一个分隔符。

df
     text
0      ab
1   hgh12
2  abc123
3    1234




import string

punct = '!"#$%&\'()*+,-./:;<=>?@[\]^_`{}~'   # `|` is not present here
transtab = str.maketrans(dict.fromkeys(punct, ''))

df['text'] = '|'.join(df['text'].tolist()).translate(transtab).split('|')



性能

str.translate到目前为止表现最好。请注意,下图包含了来自MaxU答案的另一种变体str.translate。超过|才能获取非常少量的数据。)


使用str.translate存在固有的风险(特别是,自动化决定使用哪个分隔符的过程的问题不存在) -琐碎的事情,但权衡是值得冒险的。


其他注意事项

使用列表理解方法处理NaN;请注意,只有您的数据没有NaN时,此方法(以及下一个方法)才有效。处理NaN时,您必须确定非空值的索引并仅替换它们。尝试这样的事情:

df
     text
0      ab
1   hgh12
2  abc123
3    1234


使用DataFrames进行交易;如果要处理需要替换每一列的DataFrames,则过程很简单:

df = pd.DataFrame({'text': [
    'a..b?!??', np.nan, '%hgh&12','abc123!!!', '$$34', 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


或者,
请注意,下面的Series.str.translate函数是用基准测试代码定义的。

每种解决方案都需要权衡取舍,因此决定哪种解决方案最适合您的需求将取决于您愿意付出的代价。两个非常常见的注意事项是性能(我们已经看过)和内存使用情况。 re.sub是需要大量内存的解决方案,因此请谨慎使用。

另一个需要考虑的问题是正则表达式的复杂性。有时,您可能希望删除任何不是字母数字或空格的内容。有时,您将需要保留某些字符,例如连字符,冒号和句子终止符str.translate。明确指定这些选项会给您的正则表达式增加复杂性,进而可能影响这些解决方案的性能。在决定使用什么之前,请确保在数据上测试了这些解决方案。

最后,此解决方案将删除unicode字符。您可能要调整您的正则表达式(如果使用基于正则表达式的解决方案),否则请直接使用translate

要获得更高的性能(对于较大的N),请查看Paul Panzer的回答。


附录

功能

v = pd.Series(df.values.ravel())
df[:] = translate(v).values.reshape(df.shape)





性能基准测试代码

v = df.stack()
v[:] = translate(v)
df = v.unstack()


评论


很好的解释,谢谢!是否有可能将此分析/方法扩展到1.删除停用词2.提取词干3.使所有词都变为小写?

– PyRsquared
18年5月31日在14:29

@ killerT2333在此答案中,我为此写了一些博客文章。希望对你有帮助。欢迎任何反馈/批评。

–cs95
18年5月31日在14:41



@ killerT2333小提示:该帖子实际上不涉及调用lemmatizer / stemmer,因此对于该代码,您可以在此处查看并根据需要扩展内容。哎呀,我真的需要整理东西。

–cs95
18年5月31日在14:43

@coldspeed,所以,我有一个问题。您将如何在punct中包含所有非字母字符?类似于re.compile(r“ [^ a-zA-Z]”)。我处理很多带有特殊字符(例如™和˚等)的文本,因此我需要摆脱所有这些废话。我认为将它们明确地包含在punct中会因为太多的字符而导致工作量过多(而且我注意到str.maketrans不会选择所有这些特殊字符)

– PyRsquared
18 Jun 1'在10:04



天啊!谢谢!!非常有帮助。

– GbG
7月13日21:09

#2 楼

使用numpy,我们可以获得目前为止发布的最佳方法的健康加速。基本策略是相似的-制作一个大的超级弦。但是处理速度在numpy中似乎要快得多,大概是因为我们充分利用了无用替换操作的简单性。

对于较小的问题(总共少于0x110000个字符),我们会自动找到一个分隔符,对于较大的问题,我们使用了一种较慢的方法,该方法不依赖于str.split。还请注意,translatepd_translate免费了解了三个最大问题的唯一可能的分隔符,而np_multi_strat必须对其进行计算或采用无分隔符策略。最后,请注意,对于最后三个数据点,我切换到一个更“有趣”的问题。 pd_replacere_sub因为它们与其他方法不等效,因此必须排除它们。



关于算法:策略实际上很简单。只有0x110000个不同的unicode字符。由于OP面对着庞大数据集的挑战,因此非常值得制作一个查找表,该表在我们想要保留的字符ID处有True,在必须去的字符ID处有False-在本示例中为标点符号。

使用numpy的高级索引,此类查找表可用于批量查找。由于查找是完全矢量化的,并且实质上相当于取消对指针数组的引用,因此它比例如字典查找要快得多。在这里,我们利用numpy视图强制转换,它实际上允许免费将Unicode字符重新解释为整数。

使用仅包含一个被重新解释为数字序列的怪兽字符串的数据数组以索引到查找表中会导致布尔掩码。然后可以使用此掩码过滤掉不需要的字符。使用布尔索引也只需一行代码。

到目前为止非常简单。棘手的一点是将怪物弦切碎。如果我们有一个分隔符,即数据或标点符号列表中未出现一个字符,那么它仍然很容易。使用此字符可以加入并拆分。但是,自动查找分隔符具有挑战性,并且确实占据了下面实现中的一半位置。删除不需要的字符,然后使用它们来切片处理后的怪物字符串。由于切成长度不均的部分并不是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!!!', '$$34'] * 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
18年5月24日在23:07

@coldspeed 1)我已经尝试过了。希望能帮助到你。 2)是的,这对您来说很麻木。 3)内存可能是一个问题,因为我们正在创建超字符串,然后用numpyfy创建一个超副本,然后创建相同尺寸的掩码,然后使用过滤器创建另一个副本。

– Paul Panzer
18年5月25日在0:12

#3 楼

足够有趣的是,与Vanilla Python str.translate()相比,矢量化Series.str.translate方法仍然稍慢:

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

评论


我认为原因是因为我们执行N次翻译,而不是加入,进行一次翻译和拆分。

–cs95
18年5月21日在8:21

@coldspeed,是的,我也这么认为

– MaxU
18年5月21日在8:22

用NaN尝试一下,看看会发生什么

–杰夫
18年5月25日在1:20