これは自己回答の投稿です。以下に、NLPドメインの一般的な問題の概要を示し、それを解決するためのいくつかの高性能な方法を提案します。
多くの場合、テキストのクリーニングと前処理中に句読点を削除する必要が生じます。句読点は、string.punctuation
の任意の文字として定義されます。
>>> import string
string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
これは十分に一般的な問題であり、吐き気の前に尋ねられています。最も慣用的なソリューションはpandas str.replace
。を使用します。ただし、テキストのlotを含む状況では、よりパフォーマンスの高いソリューションを検討する必要がある場合があります。
数十万件のレコードを処理する場合、str.replace
に代わる優れたパフォーマンスの良い選択肢は何ですか?
デモンストレーションの目的で、この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で実装されているため、非常に高速です。
この仕組みは次のとおりです。
str.translate
を実行し、句読点を削除します(手順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の答え の別のバリアントSeries.str.translate
が含まれていることに注意してください。
(興味深いことに、私はこれを再度実行しましたが、結果は以前とは少し異なります。2回目の実行中に、re.sub
がstr.translate
に勝ち、本当に少量のデータを獲得したようです。)-
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()
Numpyを使用すると、これまでに投稿された最良の方法よりも大幅に高速化できます。基本的な戦略は似ています-1つの大きなスーパーストリングを作成します。しかし、numpyの処理ははるかに高速に見えます。これは、何もしない代替操作の単純さを完全に活用しているためと思われます。
小さい(合計0x110000
文字未満)問題の場合は自動的にセパレーターを見つけ、大きい問題の場合はstr.split
に依存しない遅いメソッドを使用します。
すべての事前計算可能関数を関数から移動したことに注意してください。また、translate
とpd_translate
は、3つの最大の問題の唯一の可能なセパレータを無料で知ることができますが、np_multi_strat
はそれを計算するか、セパレータなしの戦略にフォールバックする必要があることに注意してください。最後に、最後の3つのデータポイントについて、より「興味深い」問題に切り替えていることに注意してください。 pd_replace
およびre_sub
は、他のメソッドと同等ではないため、そのために除外する必要がありました。
基本的な戦略は実際には非常に簡単です。 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()
ベクトル化された Series.str.translate メソッドは、バニラPython str.translate()
:
def pd_translate(df):
return df.assign(text=df['text'].str.translate(transtab))