【问题标题】:Fast punctuation removal with pandas使用 pandas 快速删除标点符号
【发布时间】:2018-10-30 20:16:12
【问题描述】:

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

通常需要在文本清理和预处理期间删除标点符号。标点符号定义为string.punctuation中的任意字符:

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

这是一个很常见的问题,并且在令人作呕之前已经被问过。最惯用的解决方案是使用 pandas str.replace。但是,对于涉及大量文本的情况,可能需要考虑更高效的解决方案。

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

【问题讨论】:

    标签: python regex string pandas numpy


    【解决方案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

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

    这使用了 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 迄今为止表现最好。请注意,下图包含来自MaxU's answer 的另一个变体Series.str.translate

    (有趣的是,我重新运行了第二次,结果与以前略有不同。在第二次运行期间,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 是一种占用大量内存的解决方案,因此请谨慎使用。

    另一个考虑因素是您的正则表达式的复杂性。有时,您可能想要删除任何不是字母数字或空格的内容。其他时候,您需要保留某些字符,例如连字符、冒号和句子终止符[.!?]。明确指定这些会增加正则表达式的复杂性,进而可能会影响这些解决方案的性能。确保您测试这些解决方案 在决定使用什么之前先检查您的数据。

    最后,此解决方案将删除 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://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. 使所有单词小写?
    • @killerT2333 我已经在this answer 写了一些博客文章。希望对你有帮助。欢迎任何反馈/批评。
    • @killerT2333 小提示:该帖子不涉及实际调用词形还原器/词干分析器,因此对于该代码,您可以查看 here 并根据需要扩展内容。天哪,我真的需要整理东西。
    • @coldspeed,所以,我确实有一个问题。您将如何在punct 中包含所有非字母字符?像re.compile(r"[^a-zA-Z]") 这样的东西。我处理了很多带有 ™ 和 ˚ 等特殊字符的文本,所以我需要摆脱所有这些废话。我认为将它们明确包含在 punct 中会带来太多的工作,因为字符太多(我注意到 str.maketrans 并没有接受所有这些特殊字符)
    • 这是我见过的对数刻度使用的最小值范围,假设它是该图垂直轴上的对数刻度。
    【解决方案2】:

    使用 numpy,我们可以比迄今为止发布的最佳方法获得健康的加速。基本策略类似——做一个大的超级字符串。但是在 numpy 中处理似乎要快得多,大概是因为我们充分利用了无物替换操作的简单性。

    对于较小(少于0x110000 个字符总数)的问题,我们会自动找到一个分隔符,对于较大的问题,我们使用不依赖于str.split 的较慢的方法。

    请注意,我已将所有预计算从函数中移出。另请注意,translatepd_translate 免费了解三个最大问题的唯一可能分隔符,而np_multi_strat 必须计算它或退回到无分隔符策略。最后,请注意,对于最后三个数据点,我转向了一个更“有趣”的问题; pd_replacere_sub 因为它们不等同于其他方法,因此必须排除。

    关于算法:

    基本策略其实很简单。只有0x110000 不同的Unicode 字符。由于 OP 在庞大的数据集方面提出了挑战,因此非常值得制作一个查找表,其中 True 在我们想要保留的字符 id 上,False 在必须去的字符 id 上——标点符号在我们的示例中。

    这样的查找表可用于使用 numpy 的高级索引进行批量查找。由于查找是完全矢量化的并且本质上相当于取消引用指针数组,因此它比例如字典查找要快得多。在这里,我们使用 numpy 视图转换,它允许将 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)我很想看看这在内存方面是如何比较的。总的来说,很棒的工作!
    • @coldspeed 1) 我试过了。希望能帮助到你。 2)是的,这对你来说很麻木。 3)内存可能是个问题,因为我们正在创建超字符串,然后对其进行 numpyfy 以创建副本,然后创建相同尺寸的掩码,然后进行过滤以创建另一个副本。
    【解决方案3】:

    有趣的是,矢量化 Series.str.translate 方法仍然比 Vanilla Python str.translate() 慢一点:

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

    【讨论】:

    • 我认为原因是因为我们正在执行 N 次翻译,而不是加入、执行和拆分。
    • @coldspeed,是的,我也这么认为
    • 用 NaN 试试这个,看看会发生什么
    猜你喜欢
    • 1970-01-01
    • 2021-07-02
    • 1970-01-01
    • 2012-06-25
    • 2020-09-21
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多