使用pandas快速去除标点符号

85

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

在文本清洗和预处理过程中,经常需要删除标点符号。标点符号被定义为string.punctuation中的任何字符:

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

这是一个很常见的问题,并且已经被反复问过。最惯用的解决方案使用pandas str.replace。然而,当涉及到大量文本的情况时,可能需要考虑更高效的解决方案。

在处理数十万条记录时,有哪些好的、高性能的str.replace替代方案呢?

4个回答

97

设置

为了演示的目的,让我们考虑这个数据框。

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

使用re库中的sub函数,预编译正则表达式模式以提高性能,并在列表推导式内调用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 存在固有的风险(特别是自动化决定使用哪个分隔符的问题并不容易),但是这种折衷是值得冒险的。


其他考虑因素

使用列表推导式方法处理 NaNs; 需要注意的是,此方法(和下一个方法)只适用于您的数据没有NaNs的情况。当处理NaNs时,您需要确定非null值的索引并仅替换这些值。尝试像这样:

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是一种占用内存较多的解决方案,因此请谨慎使用。另一个考虑因素是正则表达式的复杂性。有时,您可能想删除任何非字母数字或空格的内容。其他情况下,您需要保留某些字符,例如连字符、冒号和句子终止符[.!?]。明确指定这些字符会增加正则表达式的复杂度,这可能会影响这些解决方案的性能。确保在使用之前在自己的数据上进行测试并决定使用什么。最后,这种解决方案将删除Unicode字符。如果使用基于正则表达式的解决方案,则可能需要微调正则表达式,否则可以使用str.translate。对于更大的N,要获得更高的性能,请查看Paul Panzer的答案。附录:函数。
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://dev59.com/YlUL5IYBdhLWcg3wYHHD#50444659)
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
非常好的解释,谢谢!是否可以将此分析/方法扩展到1.去除停用词2.词干提取3.将所有单词转换为小写? - PyRsquared
2
@killerT2333,我在这个答案中写了一篇博客文章。希望你会觉得有用。欢迎任何反馈和批评。 - cs95
2
@killerT2333 小提示:该帖子并不涉及实际调用词形还原器/词干提取器,因此您可以在这里查看代码,并根据需要进行扩展。天啊,我真的需要组织好我的事情。 - cs95
1
这是我见过的最小值范围,假设那张图表的纵轴是对数刻度。 - user2357112
1
我刚才读了什么!太神奇了。 - Amogh Mishra
显示剩余5条评论

37
使用numpy,我们可以比迄今为止最好的方法获得更快的速度。基本策略类似——制作一个大的超级字符串。但是在numpy中处理速度似乎要快得多,这可能是因为我们充分利用了无为而治替换操作的简单性。
对于较小的(总字符数小于0x110000)问题,我们会自动找到一个分隔符;对于较大的问题,我们使用一种更慢的方法,该方法不依赖于str.split。
请注意,我已将所有可预先计算的内容移出了函数。还请注意,translate和pd_translate免费获得了三个最大问题的唯一可能分隔符,而np_multi_strat必须计算它或退回到无分隔符的策略。最后,请注意,在最后三个数据点上,我切换到了一个更“有趣”的问题;由于pd_replace和re_sub与其他方法不等效,因此必须将它们排除在外。

enter image description here

关于算法:

基本策略其实很简单。Unicode字符只有0x110000个不同的。由于挑战是关于大数据集的,因此制作一个查找表格是非常值得的,该表格在我们想要保留的字符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://dev59.com/YlUL5IYBdhLWcg3wYHHD#50444659)
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()

2
我喜欢这个答案并且很感激为此付出的努力。这确实挑战了我们已知的操作性能极限。有几个小问题:1)您能否解释/记录一下您的代码,以便更清楚地了解某些子程序正在做什么?2)在N的值较低时,开销本质上超过了性能,3)我很想看看这在内存方面如何比较。总体而言,干得好! - cs95
1
@coldspeed 1) 我已经尝试过了,希望能有所帮助。2) 是的,这就是numpy。3) 内存可能会成为一个问题,因为我们正在创建超级字符串,然后将其转换为numpy数组,这会创建一个副本,然后创建相同维度的掩码,然后进行过滤,这又会创建另一个副本。 - Paul Panzer

21

有趣的是,向量化的 Series.str.translate 方法与原始的 Python str.translate() 方法相比仍然稍微慢一些:

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

在此输入图像描述


我认为原因是因为我们正在执行N个翻译,而不是连接、执行一个并分割。 - cs95
@coldspeed,是的,我也这么认为。 - MaxU - stand with Ukraine
2
尝试使用NaN并查看会发生什么。 - Jeff

0
使用用户定义函数和apply。我需要制作一个自定义的标点符号列表,所以我使用了下面的代码,并用string.punctuation替换了我的标点符号列表。
import string
def remove_punctuation(text):
    return text.translate(str.maketrans('', '', string.punctuation))

df["new_col_name"] = df["col_name"].apply(remove_punctuation)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接