web-dev-qa-db-ja.com

pandasを使用した句読点の高速削除

これは自己回答の投稿です。以下に、NLPドメインの一般的な問題の概要を示し、それを解決するためのいくつかの高性能な方法を提案します。

多くの場合、テキストのクリーニングと前処理中に句読点を削除する必要が生じます。句読点は、string.punctuationの任意の文字として定義されます。

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

これは十分に一般的な問題であり、吐き気の前に尋ねられています。最も慣用的なソリューションはpandas str.replace。を使用します。ただし、テキストのlotを含む状況では、よりパフォーマンスの高いソリューションを検討する必要がある場合があります。

数十万件のレコードを処理する場合、str.replaceに代わる優れたパフォーマンスの良い選択肢は何ですか?

48
cs95

セットアップ

デモンストレーションの目的で、このDataFrameを考えてみましょう。

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

以下に、パフォーマンスの高い順に選択肢を1つずつリストします。

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. 最初に、すべての文字列を結合して、1つ(またはそれ以上)の文字を使用して1つのhuge文字列を形成しますseparator thatyou選択してください。あなたはmustを使用する必要があり、データ内に属さないことを保証できる文字/部分文字列を使用します。
  2. 大きい文字列に対してstr.translateを実行し、句読点を削除します(手順1の区切り文字は除外されます)。
  3. 手順1で結合するために使用されたセパレータで文字列を分割します。結果のリストmustは、最初の列と同じ長さです。

ここで、この例では、パイプセパレーター|を検討します。データにパイプが含まれている場合は、別のセパレーターを選択する必要があります。

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の答え の別のバリアントSeries.str.translateが含まれていることに注意してください。

(興味深いことに、私はこれを再度実行しましたが、結果は以前とは少し異なります。2回目の実行中に、re.substr.translateに勝ち、本当に少量のデータを獲得したようです。)- enter image description here

translateの使用には固有のリスクがあります(特に、使用するセパレータを決定するプロセスをautomatingする問題は自明ではありません) -offsはリスクに値します。


その他の考慮事項

リスト内包メソッドを使用したNaNの処理;このメソッド(および次のメソッド)は、データにNaNがない場合にのみ機能することに注意してください。 NaNを処理する場合、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

DataFramesの処理; DataFramesを扱っている場合、every列の置換が必要な場合、手順は簡単です。

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

または、

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

translate関数は、以下のベンチマークコードで定義されていることに注意してください。

すべてのソリューションにはトレードオフがあります。そのため、ニーズに最適なソリューションを決定するかどうかは、何を犠牲にするかによって決まります。 2つの非常に一般的な考慮事項は、パフォーマンス(既に見た)とメモリ使用量です。 str.translateはメモリを大量に消費するソリューションなので、注意して使用してください。

別の考慮事項は、正規表現の複雑さです。場合によっては、英数字でも空白でもないものをすべて削除したい場合があります。また、ハイフン、コロン、文末記号などの特定の文字を保持する必要があります[.!?]。これらを明示的に指定すると、正規表現に複雑さが加わり、これらのソリューションのパフォーマンスに影響を及ぼす可能性があります。使用するものを決定する前に、これらのソリューションをデータでテストしてください。

最後に、このソリューションではユニコード文字が削除されます。正規表現を微調整する(正規表現ベースのソリューションを使用している場合)か、それ以外の場合はstr.translateを使用します。

偶数moreパフォーマンス(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()
50
cs95

Numpyを使用すると、これまでに投稿された最良の方法よりも大幅に高速化できます。基本的な戦略は似ています-1つの大きなスーパーストリングを作成します。しかし、numpyの処理ははるかに高速に見えます。これは、何もしない代替操作の単純さを完全に活用しているためと思われます。

小さい(合計0x110000文字未満)問題の場合は自動的にセパレーターを見つけ、大きい問題の場合はstr.splitに依存しない遅いメソッドを使用します。

すべての事前計算可能関数を関数から移動したことに注意してください。また、translatepd_translateは、3つの最大の問題の唯一の可能なセパレータを無料で知ることができますが、np_multi_stratはそれを計算するか、セパレータなしの戦略にフォールバックする必要があることに注意してください。最後に、最後の3つのデータポイントについて、より「興味深い」問題に切り替えていることに注意してください。 pd_replaceおよびre_subは、他のメソッドと同等ではないため、そのために除外する必要がありました。

enter image description here

アルゴリズムについて:

基本的な戦略は実際には非常に簡単です。 0x110000の異なるUnicode文字のみがあります。 OPは巨大なデータセットの観点から課題をフレーム化するため、保持したい文字IDにTrueを持ち、必要なものにFalseを持つルックアップテーブルを作成することは完全に価値があります。 go ---この例の句読点。

このようなルックアップテーブルは、numpyの高度なインデックス作成を使用したバルクルックアップに使用できます。ルックアップは完全にベクトル化されており、基本的にポインターの配列の逆参照に相当するため、たとえば辞書ルックアップよりもはるかに高速です。ここでは、基本的に無料でUnicode文字を整数として再解釈できるnumpyビューのキャストを利用します。

ルックアップテーブルにインデックスを付けるために一連の数字として再解釈された1つのモンスター文字列のみを含むデータ配列を使用すると、ブールマスクが生成されます。このマスクを使用して、不要な文字を除外できます。ブールインデックスを使用することも、1行のコードです。

これまでのところとても簡単です。トリッキーなビットは、モンスターストリングを切り刻んでその部分に戻します。区切り文字、つまりデータまたは句読点リストに出現しない1文字がある場合、それはまだ簡単です。この文字を使用して、結合および再分割します。ただし、セパレータを自動的に見つけるのは難しく、実際に以下の実装では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()
29
Paul Panzer

ベクトル化された Series.str.translate メソッドは、バニラPython str.translate()

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

enter image description here

18
MaxU