1. 程式人生 > >Keras實現CNN文字分類

Keras實現CNN文字分類

本文以CAIL司法挑戰賽的資料為例,敘述利用Keras框架進行文字分類的一般流程及基本的深度學習模型。
步驟 1:文字的預處理,分詞->去除停用詞->統計選擇top n的詞做為特徵詞
步驟 2:為每個特徵詞生成ID
步驟 3:將文字轉化成ID序列,並將左側補齊
步驟 4:訓練集shuffle
步驟 5:Embedding Layer 將詞轉化為詞向量
步驟 6:新增模型
步驟 7:訓練模型
步驟 8:得到準確率
(如果使用TFIDF而非詞向量進行文件表示,則直接分詞去停後生成TFIDF矩陣後輸入模型)

二、文字預處理

2.1 資料集說明

本文的資料集來自CAIL2018挑戰賽,資料集是來自“中國裁判文書網”公開的刑事法律文書,其中每份資料由法律文書中的案情描述和事實部分組成,同時也包括每個案件所涉及的法條、被告人被判的罪名和刑期長短等要素。
資料集共包括268萬刑法法律文書,共涉及202條罪名,183條法條,刑期長短包括0-25年、無期、死刑。
資料利用json格式儲存,每一行為一條資料,每條資料均為一個字典。

  • fact: 事實描述
  • meta: 標註資訊,標註資訊中包括:
    • criminals: 被告(資料中均只含一個被告)
    • punish_of_money: 罰款(單位:元)
    • accusation: 罪名
    • relevant_articles: 相關法條
    • term_of_imprisonment: 刑期
      刑期格式(單位:月)
      • death_penalty: 是否死刑
      • life_imprisonment: 是否無期
      • imprisonment: 有期徒刑刑期

比賽有三個任務,
任務一(罪名預測):根據刑事法律文書中的案情描述和事實部分,預測被告人被判的罪名;
任務二(法條推薦):根據刑事法律文書中的案情描述和事實部分,預測本案涉及的相關法條;
任務三(刑期預測):根據刑事法律文書中的案情描述和事實部分,預測被告人的刑期長短。

2.2 讀取資料集

將json中的文字和標籤讀取到list中,每個list的元素為一條文字/標籤。

def read_train_data(path):
    print('reading train data...')
    fin = open(path, 'r', encoding='utf8')

    alltext = []

    accu_label = []
    law_label = []
    time_label = []

    line = fin.readline()
    while line:
        d = json.loads(line)
        alltext.append(d['fact'])
        accu_label.append(get_label(d, 'accu'))
        law_label.append(get_label(d, 'law'))
        time_label.append(get_label(d, 'time'))
        line = fin.readline()
    fin.close()

    return alltext, accu_label, law_label, time_label
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

然後對文字進行分詞,因為後續要表示成詞向量,是否去停意義不大,所以沒有去停。分詞後可以將分詞後的文字每一條為一行存為txt,以免以後每次執行程式都要重新分詞。

def cut_text(alltext):
    print('cut text...')
    count = 0
    cut = thulac.thulac(seg_only=True)
    train_text = []
    for text in alltext:
        count += 1
        if count % 2000 == 0:
            print(count)
        train_text.append(cut.cut(text, text=True)) #分詞結果以空格間隔,每個fact一個字串
    print(len(train_text))

    print(train_text)
    fileObject = codecs.open("./cuttext_all_large.txt", "w", "utf-8")  #必須指定utf-8否則word2vec報錯
    for ip in train_text:
        fileObject.write(ip)
        fileObject.write('\n')
    fileObject.close()
    print('cut text over')
    return train_text
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

用分詞後的文字文件訓練word2vec詞向量模型並儲存,這裡使用了預設的size=100,即每個詞由100維向量表示。

def word2vec_train():
    print("start generate word2vec model...")
    sentences = word2vec.Text8Corpus("cuttext_all_large.txt")
    model = word2vec.Word2Vec(sentences)         #預設size=100 ,100維
    model.save('./predictor/model/word2vec')
    print('finished and saved!')
    return model
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

2.3 使用Tokenizer將法律文書轉換成數字特徵

從txt中讀取分好詞的文字,轉換成詞袋序列。同樣,tokenizer物件生成過程較慢,也可以通過pickle儲存下來,以便下次訓練或者測試時使用,具體tokenizer的用法及作用可以參見前文:Keras入門簡介
最後得到一個文字矩陣sequences,每一行為一個用詞編號序列表示的文字,有多少個文字就有多少列。

    train_data = []
    with open('./cuttext_all_large.txt') as f:
        train_data = f.read().splitlines()
    print(len(train_data))

    # 轉換成詞袋序列
    maxlen = 1500
    # 詞袋模型的最大特徵束
    max_features = 20000

    # 設定分詞最大個數 即詞袋的單詞個數
    # with open('./predictor/model/tokenizer.pickle', 'rb') as f:
    #   tokenizer = pickle.load(f)
    tokenizer = Tokenizer(num_words=max_features, lower=True)  # 建立一個max_features個詞的字典
    tokenizer.fit_on_texts(train_data)  # 使用一系列文件來生成token詞典,引數為list類,每個元素為一個文件。可以將輸入的文字中的每個詞編號,編號是根據詞頻的,詞頻越大,編號越小。
    global word_index
    word_index = tokenizer.word_index      # 長度為508242
    # with open('./predictor/model/tokenizer_large.pickle', 'wb') as handle:
    #   pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)
    # print("tokenizer has been saved.")
    # self.tokenizer.fit_on_texts(train_data)  # 使用一系列文件來生成token詞典,引數為list類,每個元素為一個文件。可以將輸入的文字中的每個詞編號,編號是根據詞頻的,詞頻越大,編號越小。

    sequences = tokenizer.texts_to_sequences(
        train_data)  # 對每個詞編碼之後,每個文字中的每個詞就可以用對應的編碼表示,即每條文字已經轉變成一個向量了 將多個文件轉換為word下標的向量形式,shape為[len(texts),len(text)] -- (文件數,每條文件的長度)
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

2.4 讓每句數字影評長度相同

x = sequence.pad_sequences(sequences, maxlen)  # 將每條文字的長度設定一個固定值。
  
  • 1

2.5 使用Embedding層將每個詞編碼轉換為詞向量

呼叫Keras的Embedding層,該層只能作為模型的第一層,將每個詞編碼轉換為詞向量。
下面是最簡單的形式,詞向量隨機初始化。max_features即每條文字取多少個單詞表示,embedding_dims即每個單詞由多少維向量表示。表示完後即得到一個三維向量。shape為(max_features)x(embedding_dims)x len(texts)(文件數)

Embedding(max_features, embedding_dims)
  
  • 1

如果用預訓練的word2vec詞向量進行初始化,則需要先把訓練好的模型轉化為矩陣的形式,

    model = gensim.models.Word2Vec.load('./predictor/model/word2vec')
    word2idx = {"_PAD": 0}  # 初始化 `[word : token]` 字典,後期 tokenize 語料庫就是用該詞典。
    vocab_list = [(k, model.wv[k]) for k, v in model.wv.vocab.items()]
    # 儲存所有 word2vec 中所有向量的陣列,留意其中多一位,詞向量全為 0, 用於 padding
    embeddings_matrix = np.zeros((len(model.wv.vocab.items()) + 1, model.vector_size))
    print('Found %s word vectors.' % len(model.wv.vocab.items()))
    for i in range(len(vocab_list)):
        word = vocab_list[i][0]
        word2idx[word] = i + 1
        embeddings_matrix[i + 1] = vocab_list[i][1]
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

再令矩陣為Embedding的weight:

Embedding(len(embeddings_matrix),       #表示文字資料中詞彙的取值可能數,從語料庫之中保留多少個單詞。 因為Keras需要預留一個全零層, 所以+1
    embedding_dims,       # 嵌入單詞的向量空間的大小。它為每個單詞定義了這個層的輸出向量的大小
    weights=[embeddings_matrix], #構建一個[num_words, EMBEDDING_DIM]的矩陣,然後遍歷word_index,將word在W2V模型之中對應vector複製過來。換個方式說:embedding_matrix 是原始W2V的子集,排列順序按照Tokenizer在fit之後的詞順序。作為權重餵給Embedding Layer
    input_length=maxlen,     # 輸入序列的長度,也就是一次輸入帶有的詞彙個數
    trainable=False        # 我們設定 trainable = False,代表詞向量不作為引數進行更新
                        )
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

三、CNN模型搭建

CNN除了處理影象資料之外,還適用於文字分類。CNN模型首次使用在文字分類,是Yoon Kim發表的“Convolutional Neural Networks for Sentence Classification”論文中。
CNN的基本結構包括兩層,其一為特徵提取層,每個神經元的輸入與前一層的區域性接受域相連,並提取該區域性的特徵。一旦該區域性特徵被提取後,它與其它特徵間的位置關係也隨之確定下來;其二是特徵對映層,網路的每個計算層由多個特徵對映組成,每個特徵對映是一個平面,平面上所有神經元的權值相等。特徵對映結構採用影響函式核小的sigmoid函式作為卷積網路的啟用函式,使得特徵對映具有位移不變性。此外,由於一個對映面上的神經元共享權值,因而減少了網路自由引數的個數。卷積神經網路中的每一個卷積層都緊跟著一個用來求區域性平均與二次提取的計算層,這種特有的兩次特徵提取結構減小了特徵解析度。
本節主要使用一維卷積核的CNN進行文字分類(二維卷積主要用於影象處理),keras使用序貫模型。

3.1 基礎版CNN

def baseline_model(y,max_features,embedding_dims,filters):
    kernel_size = 3

    model = Sequential()
    model.add(Embedding(max_features, embedding_dims))        # 使用Embedding層將每個詞編碼轉換為詞向量
    model.add(Conv1D(filters,
                     kernel_size,
                     padding='valid',
                     activation='relu',
                     strides=1))
    # 池化
    model.add(GlobalMaxPooling1D())

    model.add(Dense(y.shape[1], activation='softmax')) #第一個引數units: 全連線層輸出的維度,即下一層神經元的個數。
    model.add(Dropout(0.2))
    model.compile(loss='categorical_crossentropy',
                  optimizer='adam',
                  metrics=['accuracy'])

    model.summary()

    return model
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

3.2 簡單版textCNN

這是省略掉多通道和微調的簡單版textCNN,用了四個卷積核:

def test_cnn(y,maxlen,max_features,embedding_dims,filters = 250):
    #Inputs
    seq = Input(shape=[maxlen],name='x_seq')

    #Embedding layers
    emb = Embedding(max_features,embedding_dims)(seq)

    # conv layers
    convs = []
    filter_sizes = [2,3,4,5]
    for fsz in filter_sizes:
        conv1 = Conv1D(filters,kernel_size=fsz,activation='tanh')(emb)
        pool1 = MaxPooling1D(maxlen-fsz+1)(conv1)
        pool1 = Flatten()(pool1)
        convs.append(pool1)
    merge = concatenate(convs,axis=1)

    out = Dropout(0.5)(merge)
    output = Dense(32,activation='relu')(out)

    output = Dense(units=y.shape[1],activation='sigmoid')(output)

    model = Model([seq],output)
    model.compile(loss='categorical_crossentropy',optimizer='adam',metrics=['accuracy'])
    return model
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

3.3 使用了word2vec詞向量的CNN:

def cnn_w2v(y,max_features,embedding_dims,filters,maxlen):
    # CNN引數
    kernel_size = 3

    model = gensim.models.Word2Vec.load('./predictor/model/word2vec')
    word2idx = {"_PAD": 0}  # 初始化 `[word : token]` 字典,後期 tokenize 語料庫就是用該詞典。
    vocab_list = [(k, model.wv[k]) for k, v in model.wv.vocab.items()]
    # 儲存所有 word2vec 中所有向量的陣列,留意其中多一位,詞向量全為 0, 用於 padding
    embeddings_matrix = np.zeros((len(model.wv.vocab.items()) + 1, model.vector_size))
    print('Found %s word vectors.' % len(model.wv.vocab.items()))
    for i in range(len(vocab_list)):
        word = vocab_list[i][0]
        word2idx[word] = i + 1
        embeddings_matrix[i + 1] = vocab_list[i][1]

    model = Sequential()
    # 使用Embedding層將每個詞編碼轉換為詞向量
    model.add(Embedding(len(embeddings_matrix),       #表示文字資料中詞彙的取值可能數,從語料庫之中保留多少個單詞。 因為Keras需要預留一個全零層, 所以+1
                                embedding_dims,       # 嵌入單詞的向量空間的大小。它為每個單詞定義了這個層的輸出向量的大小
                                weights=[embeddings_matrix], #構建一個[num_words, EMBEDDING_DIM]的矩陣,然後遍歷word_index,將word在W2V模型之中對應vector複製過來。換個方式說:embedding_matrix 是原始W2V的子集,排列順序按照Tokenizer在fit之後的詞順序。作為權重餵給Embedding Layer
                                input_length=maxlen,     # 輸入序列的長度,也就是一次輸入帶有的詞彙個數
                                trainable=False        # 我們設定 trainable = False,代表詞向量不作為引數進行更新
                        ))
    model.add(Conv1D(filters,
                     kernel_size,
                     padding='valid',
                     activation='relu',
                     strides=1))
    # 池化
    model.add(GlobalMaxPooling1D())

    model.add(Dense(y.shape[1], activation='softmax')) #第一個引數units: 全連線層輸出的維度,即下一層神經元的個數。
    model.add(Dropout(0.2))
    model.compile(loss='categorical_crossentropy',
                  optimizer='adam',
                  metrics=['accuracy'])

    model.summary()

    return model
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

四、模型訓練與測試

因為是多分類問題,這部分主要是訓練前對標籤的one-hot處理和對訓練資料的打亂。
訓練時使用了early stopping。
最後儲存模型。

def runcnn(x,label, label_name):
    y = np_utils.to_categorical(label) #多分類時,此方法將1,2,3,4,....這樣的分類轉化成one-hot 向量的形式,最終使用softmax做為輸出
    print(x.shape,y.shape)
    indices = np.arange(len(x))
    lenofdata = len(x)
    np.random.shuffle(indices)
    x_train = x[indices][:int(lenofdata*0.8)]
    y_train = y[indices][:int(lenofdata*0.8)]
    x_test = x[indices][int(lenofdata*0.8):]
    y_test = y[indices][int(lenofdata*0.8):]

    model = baseline_model(y)
    keras.callbacks.EarlyStopping(
        monitor='val_loss',
        patience=0,
        verbose=0,
        mode='auto')
    print("training model")
    history = model.fit(x_train,y_train,validation_split=0.2,batch_size=64,epochs=10,verbose=2,shuffle=True)
    accy=history.history['acc']
    np_accy=np.array(accy)
    np.savetxt('save.txt',np_accy)

    print("pridicting...")
    scores = model.evaluate(x_test,y_test)
    print('test_loss:%f,accuracy: %f'%(scores[0],scores[1]))

    print("saving %s_textcnnmodel" % label_name)
    model.save('./predictor/model/%s_cnn_large.h5' % label_name)
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

五、常見問題

5.1 如何利用Keras處理超過機器記憶體的資料集?

可以使用model.train_on_batch(X,y)model.test_on_batch(X,y)。或編寫一個每次產生一個batch樣本的生成器函式,並呼叫model.fit_generator(data_generator, samples_per_epoch, nb_epoch)進行訓練。

5.2 如何儲存Keras模型?

官方文件推薦使用model.save(filepath),將Keras模型和權重儲存在一個HDF5檔案中,該檔案將包含:
模型的結構,以便重構該模型
模型的權重
訓練配置(損失函式,優化器等)
優化器的狀態,以便於從上次訓練中斷的地方開始
使用keras.models.load_model(filepath)來重新例項化你的模型,如果檔案中儲存了訓練配置的話,該函式還會同時完成模型的編譯.

5.3 如何將Tokenizer物件儲存到檔案以進行評分?

很多比賽提交模型後用測試集進行評分,如果不儲存Tokenizer物件,則需要在對每一個句子評分的時候都重新載入整個語料庫並生成Tokenizer物件。在網上找到的儲存方法是使用pickle或joblib,使用pickle儲存的程式碼如下:

import pickle

# saving
with open('tokenizer.pickle', 'wb') as handle:
    pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)

# loading
with open('tokenizer.pickle', 'rb') as handle:
    tokenizer = pickle.load(handle)
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

5.4 如何在多張GPU卡上使用Keras?

官方建議有多張GPU卡可用時,使用TnesorFlow後端。有兩種方法可以在多張GPU上執行一個模型:資料並行/裝置並行
大多數情況下,你需要的很可能是“資料並行”資料並行
資料並行將目標模型在多個裝置上各複製一份,並使用每個裝置上的複製品處理整個資料集的不同部分資料。Keras在keras.utils.multi_gpu_model中提供有內建函式,該函式可以產生任意模型的資料並行版本,最高支援在8片GPU上並行。 請參考utils中的multi_gpu_model文件。
裝置並行
裝置並行是在不同裝置上運行同一個模型的不同部分,當模型含有多個並行結構,例如含有兩個分支時,這種方式很適合。

5.5 如何在執行程式時設定使用的GPU?

首先可以用nvidia-smi命令在伺服器上檢視GPU使用情況,如果要在python程式碼中設定使用的GPU(如使用pycharm進行除錯時),可以使用下面的程式碼

import os
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
  
  • 1
  • 2

5.6 多分類問題應怎樣設定?

如出現下列錯誤,

ValueError: Error when checking target: expected dense_2 to have shape (None, 1) but got array with shape (123673, 202)

可能是多分類label設定的問題。
多分類問題的類別設定與單分類問題不同之處在於以下幾點:

  • 首先需要將類別通過y = np_utils.to_categorical(accu_label) 設定成one-hot的形式;
  • 然後最後一層輸出的unit個數應設定為最後的分類個數,啟用函式應選softmax而不應是sigmoid,即Dense(y.shape[1], activation='softmax')
  • 最後compile函式裡的loss引數,也要設定為loss="categorical_crossentropy"

遇到的其他問題

問題1

File “/usr/local/lib/python3.5/dist-packages/keras/preprocessing/text.py”, line 267, in texts_to_sequences for vect in self.texts_to_sequences_generator(texts): File “/usr/local/lib/python3.5/dist-packages/keras/preprocessing/text.py”,
line 302, in texts_to_sequences_generator elif self.oov_token is not None: AttributeError: ‘Tokenizer’ object has no attribute ‘oov_token’

檢視keras2.1.1版本的原始碼發現texts_to_sequences_generator中沒有oov_token,手動設定tokenizer.oov_token = None來解決這個問題。
Pickle並不是序列化物件的可靠方法,因為它假定您匯入的底層Python程式碼/模組沒有改變。通常,不要使用與pickle時使用的庫版本不同的pickle物件。這不是Keras問題,而是一個通用的Python/Pickle問題。在這種情況下,有一個簡單的修復(設定屬性),但是在很多情況下不會。
參考:https://stackoverflow.com/questions/49861842/attributeerror-tokenizer-object-has-no-attribute-oov-token-in-keras

問題2

softmax() got an unexpected keyword argument ‘axis’

將keras升級到2.1.6之後TensorFlow和keras的版本不一致