文本預處理對於NLP領域的從業者或研究者來說並不陌生,也是很多人剛接觸NLP領域面臨的第一個問題。今天我想對文本預處理所涉及的知識進行一個整理,作為個人的筆記,同時分享一些個人思考與心得。文章以我對文本預處理認知順序展開,如果文中有錯誤,歡迎指正。


為什麼要有文本預處理?

1. 計算機無法直接理解文字

其實對於程序員來說,文本預處理不應該感到陌生。因為在我看來,程序編譯器就是一種「語言預處理」的過程。比如gcc把C語言的代碼轉化為機器語言,計算機才能執行程序。 所以將「自然語言」轉化為「符號語言」是文本預處理最本質最核心的一個目的。2. 在下游任務中,發現轉化的「符號語言」不夠好

本文預處理中所涉及的操作絕對不是一蹴而就的,而是在做下游任務的時候,發現了一些問題,然後我們再回過頭來,通過預處理的方式來解決掉。

我猜測,人類在第一次做文本預處理的時候,只做了一件事,把所有「文字」轉成了「ASCII碼」(甚至那時候是不是ASCII碼都沒有),丟進計算機去處理。然後,程序員們在下游任務中發現了種種不滿意,而回過頭來優化。 比如我們覺得有些單詞對我的任務沒有作用,浪費計算力和空間,就定義了「stop word」。 比如我們覺得「one-hot」的表示方式太過於「龐大」而且沒有任何詞義表示,所以我們想辦法把龐大的「one-hot」轉化為稠密的「詞向量」。 所以,這一部分預處理的原因是「人工智慧還不夠智能」,我們通過「人工」去彌補「智能」。和我們教兒童識字會將很多生僻詞拿走是一個道理。希望有朝一日,我們可以少一點人工幫助,把最原始的文字丟給計算機,讓他去處理。

根據上面的兩個原因,我得到了兩個結論。第一,文本預處理必不可少。第二,文本預處理沒有絕對的「必備操作」,我們應該根據自己下游任務來思考需要哪些操作。而不是把文本預處理與下游任務孤立開,不能所有情況都用一套流程來處理。

所以,我下面整理思路也是從「為什麼做」到「怎麼做」最後「反思」來梳理。反思部分有很多個人思考,僅供交流,拋磚引玉。


分詞 —— Tokenization

分詞這個稱呼其實是不準確,容易引起誤導。Tokenization這裡更加準確的翻譯應該是標記化。是對句子中的詞語和標點進行合理的分割。

一般NLP處理都是以詞語為粒度的切割為前提。例如傳統的bow是對詞頻的統計形成文本向量。RNN是一個詞一個詞的輸入。 大概是由於漢字或者字母的粒度,無法表達語義,例如給你一個『s』,完全不知所云。而以句子為粒度分析,句子千變萬化,兩篇同樣描述大熊貓的文章,甚至可能沒有任何兩個句子相同。不利於統計模型或者機器學習模型的分析。所以,詞語粒度的切割是最為合適的。

分詞在英語和中文的考慮是不同的,分開來說。

1. 英文分詞

英文單詞相對分詞會容易一些,但還是有一些細節需要注意。

英文單詞天生會用空格間隔開,所以很多人會採用以下方式分詞。

sentence.split() # split()默認以空格分詞

考慮分詞「Its your cat!」這句話。這樣分詞的最後一個詞會是「cat!」。就會把「cat」和「cat!」當作兩個詞來看待。於是有的人選擇先去掉標點符號再做分詞,那樣「its」和「its」就都會成為「its」。

這就是為什麼即使是簡單的英文分詞,nltk還是出了word_tokenize的工具。

from nltk import word_tokenize
print(word_tokenize("its your cat!"))
# 列印結果:[it, "s", your, cat, !]

可以看到nltk很好的兼顧了英文中標點符號的情況。值得讓人高興的是,GloVe等預訓練詞向量是有「『s」、「『re」等標記的向量的。

2. 中文分詞

先說一下結論:中文分詞可以當做是一個已解決的問題,也就是我們可以通過調用jieba等分詞庫來實現。

import jieba
seg_list = jieba.cut("我來到南京長江大橋")
print(list(seg_list)) # jieba.cut() 返回的是一個生成器
# 列印結果:[我, 來到, 南京長江大橋]

深究一下分詞演算法大抵有以下幾個方面,篇幅問題不做展開。

1. 基於規則分詞。簡單的有正向最大匹配法和逆向最大匹配法。據有外國學者1995年的研究表明,這兩種方法分詞完全一致且正確的句子佔90%左右,這兩種方法分詞不一樣但會至少有一種是正確的句子佔9%。也就是只有1%左右的句子,這兩種方法是分不出來的。

2. 基於統計分詞。這種分詞方法需要建立語言模型,然後通過Viterbi演算法進行規劃尋找概率最大的分詞方法。 3. 基於深度學習分詞。

對於這3種分詞方法,前2種我進行過編程實現都不算複雜,但第3種還沒有接觸過,這裡存疑,找時間再研究下。


大小寫統一

發現幾乎所有預處理代碼都會將英文單詞轉化為小寫,想了一下原因。

1. 一個句子中的首字母大寫消除。 2. 預訓練詞向量中轉化為了小寫。GloVe中沒有「USA」,但是有usa。 3. 雖然有的單詞首字母大寫和小寫代表了不同的含義。但是一詞多義本身就是需要解決的問題。也不差這幾個了。

實現起來比較簡單,python自帶的lower函數就可以解決。

sentence = sentence.lower()

停用詞 —— stop word

其實為什麼要有停用詞,我覺得主要是因為傳統的NLP基於統計。我們統計到了大量「is」、「a」、「what」這樣的單詞,但是這樣的詞又不能幫助我辨別文本與文本的區別。可能TF-IDF可以一定程度上解決高頻詞的重要度問題。但是既然沒用,為什麼不去掉。

操作思路就是:生成停用詞表,然後去掉數據里所有的停用詞。

1. 生成停用詞表

可以自己對數據集做詞頻統計選出高頻詞,也可以網上下載,方便一點就直接使用nltk提供的停用詞。

from nltk.corpus import stopwords

# 需要提前調用 nltk.download(『stopwords』) 下載
my_stopwords = set(stopwords.words(english))

但無論如何生成這個停用詞表,都最好加一步篩選過程。比如「what」被nltk當做了停用詞,但假如你的任務恰好就是問答系統,那是不是「what」里包含了很多重要的信息呢。

my_stopwords .remove(what)

2. 去掉停用詞

從序列里去掉特定的單詞,是一個簡單的編程問題,但仍然有一些細節可以注意。

(1)步驟1中生成的停用詞表變數使用set類型,就是因為這裡要判斷 in 的操作。set的in操作平均時間複雜度是O(1),list的in操作平均時間複雜度是O(n)。(2)列表生成式是一種更加Pythonic的寫法,對代碼規範有點輕微強迫症是一種自然而然的好習慣。

# words 是已經分詞好的一句話
words = [ w for w in words if w not in my_stopwords ]

那麼反思一下停用詞這件事,既然是由詞的頻率進行判斷的。那和詞頻無關的表示方式,是否還有必要去掉停用詞呢?比如skip gram是由一個神經網路訓練出來的詞向量。

kaggle上一個專家分享了他的經驗,推薦去看一下全文。

引用自:How to: Preprocessing when using embeddings

Dont use standard preprocessing steps like stemming or stopword removal when you have pre-trained embeddings

他的理由很簡單:

You loose valuable information, which would help your NN to figure things out.

在看到這篇文章前,我是任何任務無腦去停用詞的。但在這之後,會每次任務,分別對「去停用詞」版本和「不去停用詞」版本作比較。不過一般基於預處理詞向量的詞表示方式的任務,確實是不去停用詞版本會更好。

所以這也印證了我文章開頭提到的第二個原因。我們有的預處理步驟,是因為我們處理任務的方法處理不了某些信息,而並不是這些信息真的毫無作用。比如我們用統計的思路去表達詞信息,確實無法處理高頻詞的作用。

文本標準化 —— Text Normalization

需要做文本的標準化,主要是由於英語中同一個單詞可能有不同的形態。

名詞以apple為例,有apple,apples的形態。 動詞以take為例,有take,toke,token的形態。

文本標準化通常的做法有兩種,Stemming和Lemmatization。

1. Stemming —— 詞幹分析

Stemming是一種基於規則的標準化方式。是早期語言學家總結了英語中的單詞變形方式,如詞尾增加後綴等方式。然後根據這些變形規則嘗試去反向還原單詞詞幹。

from nltk.stem import LancasterStemmer
lancaster=LancasterStemmer()
lancaster.stem(prestudy)
# 輸出:prestudi
# study:study studies:studi

根據上圖的例子,我們可以看到Stemming的幾個問題:

  • 還原出來的詞幹,未必是一個單詞。例子中prestudy已經是單詞原型了,但是還原成了prestudi。假如使用了預訓練的詞向量,比如prestudy在GloVe中是存在的,但是prestudi卻是沒有的,這樣就有點弄巧成拙的感覺。
  • 有的單詞是一樣的,但是規則卻不能將他們還原成一樣。
  • 有的單詞是不一樣的,但是規則會把這兩個單詞還原成一個。比如fli和flying變換過都是fli。

2. Lemmatization —— 詞元分析

詞元分析是基於詞典進行的標準化,所以好處就是還原出來的單詞都是現實存在的單詞,但對於詞典中沒有的單詞,就無法還原了。

from nltk.stem import WordNetLemmatizer
wordnet_lemmatizer = WordNetLemmatizer()
wordnet_lemmatizer.lemmatize(studies)
# 輸出是study

如同在stop word章節中的討論一樣,將單詞還原,其實是在丟棄很多的詞義。特別是有的情況下,單詞不同時態其實是當做一個新單詞來使用。比如說現在進行時當做形容詞。

所以,我們依然不能盲目的使用文本標準化來處理自己的數據集。特別是有以下情況,要特別關注。
  • 使用了預訓練的向量,例如:GloVe,Skip gram, CBOW等。
  • 需要做詞性標註

但一切都沒有絕對,還是要根據自己具體情況,從性能、內存、效果多個維度去評估。


清洗特殊符號

其實如果把符號當做詞的話,可能最大的停用詞就是標點符號了。同時一些特殊符號,類似「▓」,可以當做是低頻詞。那就和之前停用詞的考慮是類似的。

清洗特殊符號代碼實現上有兩種方式。

  • 用正則表達式去除所有非字母非數字字元。

special_character_removal = re.compile(r[^a-zd ], re.IGNORECASE)
sentence = special_character_removal.sub(, sentence )

  • 建立特殊字元表,定向去除符號

puncts = [,, ., ", :, ), (, -, !, ?, |, ;, "", $, &, /, [, ], >, %, =, #, *,
+, \, ?, ~, @, £,
·, _, {, }, ?, ^, ?, `, <, →, °, €, ?, ?, ?, ←, ×, §, ″, ′, ?, █,
?, à, …,
「, , 」, –, ●, a, ?, ?, ¢, 2, ?, ?, ?, ↑, ±, ?, ?, ═, |, ║, ―, ¥,
▓, —, ?, ─,
?, :, ?, ⊕, ▼, ?, ?, , 』, ?, ¨, ▄, ?, ☆, é, ˉ, ?, ¤, ▲, è, ?, ?,
?, ?, 『, ∞,
?, ), ↓, 、, │, (, ?, ,, ?, ╩, ╚, 3, ?, ╦, ╣, ╔, ╗, ?, ?, ?, ?, 1,
≤, ?, √, ??, ?]

def clean_text(x):
x = str(x)
for punct in puncts:
x = x.replace(punct, f {punct} )
return x

有一點需要特殊注意,去掉符號就意味著有可能去掉了單引號 ""這個字元。那我們在討論分詞的時候考慮過的"s"的形式就會變成「s」。所以假如我們需要去掉單引號,那一定要先進行類似"aint": "is not」這樣的轉化。

同時,我們可以看到即使是▓這樣奇怪的字元,在GloVe中也是有對應的向量的,那我們要不要去掉呢?我覺得一切以實驗評估為準!


清洗數字

其實很多數字可以當做高頻詞,很多數字可以當做低頻詞來對待。比如「19924354.2」這樣的數字可能全部語料就會出現1次,但是「1」這樣的數字,可能會頻繁的出現。

對待數字,我們可以有幾種方式。

  • 直接去掉根據具體任務,比如情感分析,可能數字對我們的幫助確實不大,那就直接使用正則表達式移除就可以了。

replace_numbers = re.compile(rd+, re.IGNORECASE)
sentence = replace_numbers.sub(, "1 cat have 2 ears")
# 輸出 cat have ears

  • 所有數字轉化為n可能數字的概念是要有,但是對具體是多少不感興趣。

replace_numbers = re.compile(rd+, re.IGNORECASE)
sentence = replace_numbers.sub(n, "1 cat have 2 ears")
# 輸出n cat have n ears

  • GloVe中對數字進行了一個很有趣的處理方式,0~9的數字是有對應向量的。對於10以上的數位數進行了轉化。比如「10」轉化為「##」,「1000」轉化為「####」。如果我們使用GloVe詞向量,也可以這樣處理。

def clean_numbers(x):
x = re.sub([0-9]{5,}, #####, x)
x = re.sub([0-9]{4}, ####, x)
x = re.sub([0-9]{3}, ###, x)
x = re.sub([0-9]{2}, ##, x)
return x
clean_numbers("1,20,300,4000,50000,600000")
#輸出:1,##,###,####,#####,#####

總結

可以看到幾乎所有常用的預處理方法都不是固定的,我曾經考慮過,寫一個函數,把所有預處理流程都囊括進去,這樣每來一個項目,我就拿過來直接調一下這個函數。

但經過了認知的深入,發現預處理需要做大量的實驗來評估。可能不同任務需要保留或是增加的信息不同,甚至可能同一個任務模型不同,也需要不同的處理流程。

另外,可能廣義的說,文本表示是可以算作文本預處理里的一部分。但由於篇幅問題,我們下次再專門開一篇文章去整理討論。

最後,歡迎交流和糾錯。

推薦閱讀:

相关文章