總的來講,一個完整的文本分類器主要由兩個階段,或者說兩個部分組成:一是將文本向量化,將一個字符串轉化成向量形式;二是傳統的分類器,包括線性分類器,SVM, 神經網絡分類器等等。
之前看的THUCTC的技術棧是使用 tf-idf 來進行文本向量化,使用卡方校驗(chi-square)來降低向量維度,使用liblinear(采用線性核的svm) 來進行分類。而這里所述的文本分類器,使用lsi (latent semantic analysis, 隱性語義分析) 來進行向量化, 不需要降維, 因為可以直接指定維度, 然后使用線性核svm進行分類。lsi的部分主要使用gensim來進行, 分類主要由sklearn來完成。具體實現可見使用gensim和sklearn搭建一個文本分類器(二):代碼和注釋這邊主要敘述流程
1. 文檔向量化
這部分的內容主要由gensim來完成。gensim庫的一些基本用法在我之前的文章中已經有過介紹點這里這里就不再詳述, 直接按照流程來寫了。采用lsi進行向量化的流程主要有下面幾步:
將各文檔分詞,從字符串轉化為單詞列表
統計各文檔單詞,生成詞典(dictionary)
利用詞典將文檔轉化成詞頻表示的向量,即指向量中的各值對應于詞典中對應位置單詞在該文檔中出現次數
再進行進一步處理,將詞頻表示的向量轉化成tf-idf表示的向量
由tf-idf表示的向量轉化成lsi表示的向量
接下來按照上述流程來分別闡述
1.1 文檔分詞及預處理
分詞有很多種方法,也有很多現成的庫,這里僅介紹結巴的簡單用法
import jieba
content ="""面對當前挑戰,我們應該落實2030年可持續發展議程,促進包容性發展"""
content = list(jieba.cut(content, cut_all=False))
print(content)
>>>['面對','當前','挑戰',',','我們','應該','落實','2030','年','可','持續','發展','議程',',','促進','包容性','發展']
注意上面的cut_all選項,如果cut_all=False, 則會列出最優的分割選項; 如果cut_all=True, 則會列出所有可能出現的詞
content =list(jieba.cut(content, cut_all=True))
print(content)
>>>['面對','當前','挑戰','','','我們','應該','落實','2030','年','可','持續','發展','議程','','','促進','包容','包容性','容性','發展']
應該觀察到,在分詞后的直接結果中,有大量的無效項,例如空格,逗號等等。因此,一般在分詞以后,還要進行預處理。例如去掉停用詞(stop words, 指的是沒什么意義的詞,例如空格,逗號,句號,啊,呀, 等等), 去掉出現出現頻率過低和過高的詞等等。
我這一部分的程序是
def convert_doc_to_wordlist(str_doc,cut_all):
# 分詞的主要方法
sent_list = str_doc.split('\n')
sent_list = map(rm_char, sent_list) # 去掉一些字符,例如\u3000
word_2dlist = [rm_tokens(jieba.cut(part,cut_all=cut_all)) for part in sent_list] # 分詞
word_list = sum(word_2dlist,[])
return word_list
def rm_char(text):
text = re.sub('\u3000','',text)
return text
def get_stop_words(path='/home/multiangle/coding/python/PyNLP/static/stop_words.txt'):
# stop_words中,每行放一個停用詞,以\n分隔
file = open(path,'rb').read().decode('utf8').split('\n')
return set(file)
def rm_tokens(words): # 去掉一些停用次和數字
words_list = list(words)
stop_words = get_stop_words()
for i in range(words_list.__len__())[::-1]:
if words_list[i] in stop_words: # 去除停用詞
words_list.pop(i)
elif words_list[i].isdigit():
words_list.pop(i)
return words_list
主程序是convert_doc_to_wordlist方法,拿到要分詞的文本以后,首先去掉一些字符,例如\u3000等等。然后進行分詞,再去掉其中的停用詞和數字。 最后得到的單詞,其順序是打亂的,即單詞間的相關信息已經丟失
1.2 統計單詞,生成詞典
一般來講, 生成詞典應該在將所有文檔都分完詞以后統一進行,不過對于規模特別大的數據,可以采用邊分詞邊統計的方法。將文本分批讀取分詞,然后用之前生成的詞典加入新內容的統計結果,如下面所示
from gensim import corpora,models
import jieba
import re
from pprint import pprint
import os
files = ["但是現在教育局非要治理這么一個情況",
"然而又不搞明白為什么這些詞會出現"]
dictionary = corpora.Dictionary()
for file in files:
file = convert_doc_to_wordlist(file, cut_all=True)
dictionary.add_documents([file])
pprint(sorted(list(dictionary.items()),key=lambda x:x[0]))
>>>[(0, '教育'),
>>> (1, '治理'),
>>> (2, '教育局'),
>>> (3, '情況'),
>>> (4, '非要'),
>>> (5, '搞'),
>>> (6, '明白'),
>>> (7, '詞')]
對于已經存在的詞典,可以使用dictionary.add_documents來往其中增加新的內容。當生成詞典以后,會發現詞典中的詞太多了,達到了幾十萬的數量級, 因此需要去掉出現次數過少的單詞,因為這些代詞沒什么代表性。
small_freq_ids = [tokenid for tokenid, docfreqindictionary.dfs.items() if docfreq <5]
dictionary.filter_tokens(small_freq_ids)
dictionary.compactify()
1.3 將文檔轉化成按詞頻表示的向量
繼續沿著之前的思路走,接下來要用dictionary把文檔從詞語列表轉化成用詞頻表示的向量,也就是one-hot表示的向量。所謂one-hot,就是向量中的一維對應于詞典中的一項。如果以詞頻表示,則向量中該維的值即為詞典中該單詞在文檔中出現的頻率。其實這個轉化很簡單,使用dictionray.doc2bow方法即可。
count = 0
bow? = []
for file in files:
count += 1
if count%100 == 0 :
print('{c} at {t}'.format(c=count, t=time.strftime('%Y-%m-%d %H:%M:%S',time.localtime())))
word_list = convert_doc_to_wordlist(file, cut_all=False)
word_bow = dictionary.doc2bow(word_list)
bow.append(word_bow)
pprint(bow)
>>>[[(1, 1), (2, 1), (4, 1)], [(5, 1), (6, 1)]]
1.4 轉化成tf-idf和lsi向量
之所以把這兩部分放到一起,并不是因為這兩者的計算方式或者說原理有多相似(實際上兩者完全不同),而是說在gensim中計算這兩者的調用方法比較類似,都需要調用gensim.models庫。
tfidf_model = models.TfidfModel(corpus=corpus,? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? dictionary=dictionary)corpus_tfidf = [tfidf_model[doc]fordocincorpus]lsi_model = models.LsiModel(corpus = corpus_tfidf,? ? ? ? ? ? ? ? ? ? ? ? ? ? id2word = dictionary,? ? ? ? ? ? ? ? ? ? ? ? ? ? num_topics=50)corpus_lsi = [lsi_model[doc]fordocincorpus]
1
2
3
4
5
6
7
可以看到gensim的方法還是比較簡潔的。
由于之前閱讀THUCTC源碼的時候下載了THUCTCNews文檔集,大概1G多點,已經幫你分好類,放在各個文件夾下面了。為了便于分析,各個環節的中間結果(詞頻向量,tfidf向量等)也都會存放到本地。為了便于以后標注,各個類的中間結果也是按類別存儲的。
在將文本向量化以后,就可以采用傳統的分類方法了, 例如線性分類法,線性核的svm,rbf核的svm,神經網絡分類等方法。我在這個分類器中嘗試了前3種,都可以由sklearn庫來完成
一個很尷尬的問題是,gensim中的corpus數據格式,sklearn是無法識別的。即gensim中對向量的表示形式與sklearn要求的不符。
在gensim中,向量是稀疏表示的。例如[(0,5),(6,3)] 意思就是說,該向量的第0個元素值為5,第6個元素值為3,其他為0.但是這種表示方式sklearn是無法識別的。sklearn的輸入一般是與numpy或者scipy配套的。如果是密集矩陣,就需要輸入numpy.array格式的; 如果是稀疏矩陣,則需要輸入scipy.sparse.csr_matrix.由于后者可以轉化成前者,而且gensim中向量本身就是稀疏表示,所以這邊只講如何將gensim中的corpus格式轉化成csr_matrix.
去scipy的官網去找相關文檔,可以看到csr_matrix的構造有如下幾種方法。
第一種是由現有的密集矩陣來構建稀疏矩陣,第二種不是很清楚,第三種構建一個空矩陣。第四種和第五種符合我們的要求。其中第四種最為直觀,構建三個數組,分別存儲每個元素的行,列和數值即可。
官網給出的示例代碼如下,還是比較直觀的。
row = np.array([0,0,1,2,2,2])col = np.array([0,2,2,0,1,2])data = np.array([1,2,3,4,5,6])print(csr_matrix((data, (row, col)), shape=(3,3)).toarray())>>>array([[1, 0, 2],
[0, 0, 3],
[4, 5, 6]])
1
2
3
4
5
6
7
依樣畫葫蘆,gensim轉化到csr_matrix的程序可以寫成
data= []rows= []cols= []line_count=0forlineinlsi_corpus_total:? # lsi_corpus_total 是之前由gensim生成的lsi向量? ? for eleminline:? ? ? ? rows.append(line_count)? ? ? ? cols.append(elem[0])data.append(elem[1])line_count +=1lsi_sparse_matrix= csr_matrix((data,(rows,cols))) # 稀疏向量lsi_matrix= lsi_sparse_matrix.toarray()? # 密集向量
1
2
3
4
5
6
7
8
9
10
11
12
在將所有數據集都轉化成sklearn可用的格式以后,還要將其分成訓練集和檢驗集,比例大概在8:2.下面的代碼就是關于訓練集和檢驗集的生成的
data= []rows= []cols= []line_count=0forlineinlsi_corpus_total:? ? for eleminline:? ? ? ? rows.append(line_count)? ? ? ? cols.append(elem[0])data.append(elem[1])line_count +=1lsi_matrix= csr_matrix((data,(rows,cols))).toarray()rarray=np.random.random(size=line_count)train_set= []train_tag= []test_set= []test_tag= []foriinrange(line_count):ifrarray[i]<0.8:? ? ? ? train_set.append(lsi_matrix[i,:])? ? ? ? train_tag.append(tag_list[i])else:? ? ? ? test_set.append(lsi_matrix[i,:])? ? ? ? test_tag.append(tag_list[i])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sklearn中,可以使用sklearn.discriminant_analysis.LinearDiscriminantAnalysis來進行線性分類。
import numpy as npfrom sklearn.discriminant_analysis import LinearDiscriminantAnalysislda = LinearDiscriminantAnalysis(solver="svd", store_covariance=True)X = np.array([[-1, -1], [-2, -1], [1, 1], [2, 1]])Y = np.array([1,1,2,2])lda_res = lda.fit(X, Y)print(lda_res.predict([[-0.8, -1]]))
1
2
3
4
5
6
7
8
在上面的例子中,X代表了訓練集。上面的X是一個4*2的矩陣,代表訓練集中含有4各樣本,每個樣本的維度是2維。而Y代表的是訓練集中各樣本所期望的分類結果。回到文本分類的任務,易知上面代碼的X對應于train_set, 而Y對應于train_tag
lda = LinearDiscriminantAnalysis(solver="svd", store_covariance=True)lda_res = lda.fit(train_set, train_tag)train_pred? = lda_res.predict(train_set)# 訓練集的預測結果test_pred = lda_res.predict(test_set)# 檢驗集的預測結果
1
2
3
4
lda_res即是得到的lda模型。 train_pred, test_pred 分別是訓練集和檢驗集根據得到的lda模型獲得的預測結果。
實驗批次向量化方法向量長度分類方法訓練集錯誤率檢驗集錯誤率
1LSI50線性判別16.78%17.18%
2LSI100線性判別14.10%14.25%
3LSI200線性判別11.74%11.73%
4LSI400線性判別10.50%10.93%
總的來說,使用SVM與上面LDA的使用方法比較類似。使用sklearn.svm類可以完成。不過與lda相比,svm可以接受稀疏矩陣作為輸入,這是個好消息。
# clf = svm.SVC()? # 使用RBF核clf = svm.LinearSVC()# 使用線性核clf_res = clf.fit(train_set,train_tag)train_pred? = clf_res.predict(train_set)test_pred? = clf_res.predict(test_set)
1
2
3
4
5
可以使用RBF核,也可以使用線性核。不過要注意,RBF核在數據集不太充足的情況下有很好的結果,但是當數據量很大是就不太明顯,而且運行速度非常非常非常的慢! 所以我推薦使用線性核,運算速度快,而且效果比線性判別稍好一些
實驗批次向量化方法向量長度分類方法訓練集錯誤率檢驗集錯誤率
5LSI50svm_linear12.31%12.52%
6LSI100svm_linear10.13%10.20%
7LSI200svm_linear8.75%8.98%
8LSI400svm_linear7.70%7.89%