1. 程式人生 > >使用gensim和sklearn搭建一個文字分類器(一):流程概述

使用gensim和sklearn搭建一個文字分類器(一):流程概述

總的來講,一個完整的文字分類器主要由兩個階段,或者說兩個部分組成:一是將文字向量化,將一個字串轉化成向量形式;二是傳統的分類器,包括線性分類器,SVM, 神經網路分類器等等。

之前看的THUCTC的技術棧是使用 tf-idf 來進行文字向量化,使用卡方校驗(chi-square)來降低向量維度,使用liblinear(採用線性核的svm) 來進行分類。而這裡所述的文字分類器,使用lsi (latent semantic analysis, 隱性語義分析) 來進行向量化, 不需要降維, 因為可以直接指定維度, 然後使用線性核svm進行分類。lsi的部分主要使用gensim來進行, 分類主要由sklearn來完成。具體實現可見

使用gensim和sklearn搭建一個文字分類器(二):程式碼和註釋 這邊主要敘述流程

1. 文件向量化

這部分的內容主要由gensim來完成。gensim庫的一些基本用法在我之前的文章中已經有過介紹 點這裡 這裡就不再詳述, 直接按照流程來寫了。採用lsi進行向量化的流程主要有下面幾步:

  1. 將各文件分詞,從字串轉化為單詞列表
  2. 統計各文件單詞,生成詞典(dictionary)
  3. 利用詞典將文件轉化成詞頻表示的向量,即指向量中的各值對應於詞典中對應位置單詞在該文件中出現次數
  4. 再進行進一步處理,將詞頻表示的向量轉化成tf-idf表示的向量
  5. 由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, docfreq in dictionary.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] for doc in corpus]
lsi_model = models.LsiModel(corpus = corpus_tfidf, 
                            id2word = dictionary, 
                            num_topics=50)
corpus_lsi = [lsi_model[doc] for doc in corpus]

可以看到gensim的方法還是比較簡潔的。

1.5 實踐中的一些問題

由於之前閱讀THUCTC原始碼的時候下載了THUCTCNews文件集,大概1G多點,已經幫你分好類,放在各個資料夾下面了。為了便於分析,各個環節的中間結果(詞頻向量,tfidf向量等)也都會存放到本地。為了便於以後標註,各個類的中間結果也是按類別儲存的。

2. 分類問題

在將文字向量化以後,就可以採用傳統的分類方法了, 例如線性分類法,線性核的svm,rbf核的svm,神經網路分類等方法。我在這個分類器中嘗試了前3種,都可以由sklearn庫來完成

2.1 從gensim到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]])

依樣畫葫蘆,gensim轉化到csr_matrix的程式可以寫成

data = []
rows = []
cols = []
line_count = 0
for line in lsi_corpus_total:  # lsi_corpus_total 是之前由gensim生成的lsi向量
    for elem in line:
        rows.append(line_count)
        cols.append(elem[0])
        data.append(elem[1])
    line_count += 1
lsi_sparse_matrix = csr_matrix((data,(rows,cols))) # 稀疏向量
lsi_matrix = lsi_sparse_matrix.toarray()  # 密集向量

在將所有資料集都轉化成sklearn可用的格式以後,還要將其分成訓練集和檢驗集,比例大概在8:2.下面的程式碼就是關於訓練集和檢驗集的生成的

data = []
rows = []
cols = []
line_count = 0
for line in lsi_corpus_total:
    for elem in line:
        rows.append(line_count)
        cols.append(elem[0])
        data.append(elem[1])
    line_count += 1
lsi_matrix = csr_matrix((data,(rows,cols))).toarray()
rarray=np.random.random(size=line_count)
train_set = []
train_tag = []
test_set = []
test_tag = []
for i in range(line_count):
    if rarray[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])

2.2 線性判別分析

sklearn中,可以使用sklearn.discriminant_analysis.LinearDiscriminantAnalysis來進行線性分類。

import numpy as np
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis

lda = 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]]))

在上面的例子中,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)       # 檢驗集的預測結果

lda_res即是得到的lda模型。 train_pred, test_pred 分別是訓練集和檢驗集根據得到的lda模型獲得的預測結果。

實驗批次 向量化方法 向量長度 分類方法 訓練集錯誤率 檢驗集錯誤率
1 LSI 50 線性判別 16.78% 17.18%
2 LSI 100 線性判別 14.10% 14.25%
3 LSI 200 線性判別 11.74% 11.73%
4 LSI 400 線性判別 10.50% 10.93%

2.3 SVM分類

總的來說,使用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)

可以使用RBF核,也可以使用線性核。不過要注意,RBF核在資料集不太充足的情況下有很好的結果,但是當資料量很大是就不太明顯,而且執行速度非常非常非常的慢! 所以我推薦使用線性核,運算速度快,而且效果比線性判別稍好一些

實驗批次 向量化方法 向量長度 分類方法 訓練集錯誤率 檢驗集錯誤率
5 LSI 50 svm_linear 12.31% 12.52%
6 LSI 100 svm_linear 10.13% 10.20%
7 LSI 200 svm_linear 8.75% 8.98%
8 LSI 400 svm_linear 7.70% 7.89%