基於雙向BiLstm神經網絡的中文分詞詳解及源碼

原文鏈接:https://www.cnblogs.com/vipyoumay/p/8608754.html?tdsourcetag=s_pctim_aiomsg

基於雙向BiLstm神經網絡的中文分詞詳解及源碼

在自然語言處理中(NLP,Natural Language ProcessingNLP,Natural Language Processing),分詞是一個較爲簡單也基礎的基本技術。常用的分詞方法包括這兩種:基於字典的機械分詞基於統計序列標註的分詞。對於基於字典的機械分詞本文不再贅述,可看字典分詞方法。在本文中主要講解基於深度學習的分詞方法及原理,包括一下幾個步驟:1標註序列2雙向LSTM網絡預測標籤3Viterbi算法求解最優路徑

1 標註序列

中文分詞的第一步便是標註字,字標註是通過給句子中每個字打上標籤的思路來進行分詞,比如之前提到過的,通過4標籤來進行標註(single,單字成詞;begin,多字詞的開頭;middle,三字以上詞語的中間部分;end,多字詞的結尾。均只取第一個字母。),這樣,“爲人民服務”就可以標註爲“sbebe”了。4標註不是唯一的標註方式,類似地還有6標註,理論上來說,標註越多會越精細,理論上來說效果也越好,但標註太多也可能存在樣本不足的問題,一般常用的就是4標註和6標註。前面已經提到過,字標註是通過給句子中每個字打上標籤的思路來進行分詞,比如之前提到過的,通過4標籤來進行標註(single,單字成詞;begin,多字詞的開頭;middle,三字以上詞語的中間部分;end,多字詞的結尾。均只取第一個字母。),這樣,“爲人民服務”就可以標註爲“sbebe”了。4標註不是唯一的標註方式,類似地還有6標註,理論上來說,標註越多會越精細,理論上來說效果也越好,但標註太多也可能存在樣本不足的問題,一般常用的就是4標註和6標註。標註實例如下:

人/b  們/e  常/s  說/s  生/b  活/e  是/s  一/s  部/s  教/b  科/m  書/e 

2 訓練網絡

這裏所指的網絡主要是指神經網絡,再細化一點就是雙向LSTM(長短時記憶網絡),雙向LSTM是LSTM的改進版,LSTM是RNN的改進版。因此,首先需要理解RNN。

RNN的意思是,爲了預測最後的結果,我先用第一個詞預測,當然,只用第一個預測的預測結果肯定不精確,我把這個結果作爲特徵,跟第二詞一起,來預測結果;接着,我用這個新的預測結果結合第三詞,來作新的預測;然後重複這個過程;直到最後一個詞。這樣,如果輸入有n個詞,那麼我們事實上對結果作了n次預測,給出了n個預測序列。整個過程中,模型共享一組參數。因此,RNN降低了模型的參數數目,防止了過擬合,同時,它生來就是爲處理序列問題而設計的,因此,特別適合處理序列問題。循環神經網絡原理見下圖:

2018-03-20-11-47-27

LSTM對RNN做了改進,使得能夠捕捉更長距離的信息。但是不管是LSTM還是RNN,都有一個問題,它是從左往右推進的,因此後面的詞會比前面的詞更重要,但是對於分詞這個任務來說是不妥的,因爲句子各個字應該是平權的。因此出現了雙向LSTM,它從左到右做一次LSTM,然後從右到左做一次LSTM,然後把兩次結果組合起來。

在分詞中,LSTM可以根據輸入序列輸出一個序列,這個序列考慮了上下文的聯繫,因此,可以給每個輸出序列接一個softmax分類器,來預測每個標籤的概率。基於這個序列到序列的思路,我們就可以直接預測句子的標籤。假設每次輸入y1

-yn由下圖所示每個輸入所對應的標籤爲x1-xn。再抽象一點用$ x_{ij} 表示狀態

x_i$的第j個可能值。

2018-03-20-11-48-06

最終輸出結果串聯起來形成如下圖所示的網絡

2018-03-20-11-49-50

圖中從第一個可能標籤到最後一個可能標籤的任何一條路徑都可能產生一個新的序列,每條路徑可能性不一樣,我們需要做的是找出可能的路徑。由於路徑非常多,因此採用窮舉是非常耗時的,因此引入Viterbi算法。

3 Viterbi算法求解最優路徑

維特比算法是一個特殊但應用最廣的動態規劃算法,利用動態規劃,可以解決任何一個圖中的最短路徑問題。而維特比算法是針對一個特殊的圖——籬笆網絡的有向圖(Lattice )的最短路徑問題而提出的。

而維特比算法的精髓就是,既然知道到第i列所有節點Xi{j=123…}的最短路徑,那麼到第i+1列節點的最短路徑就等於到第i列j個節點的最短路徑+第i列j個節點到第i+1列各個節點的距離的最小值,關於維特比算法的詳細可以點擊

4 keras代碼講解

使用Keras構建bilstm網絡,在keras中已經預置了網絡模型,只需要調用相應的函數就可以了。需要注意的是,對於每一句話會轉換爲詞向量(Embedding)如下圖所示:

2018-03-20-11-49-20

embedded = Embedding(len(chars) + 1, word_size, input_length=maxlen, mask_zero=True)(sequence)並將不足的補零。

創建網絡

from keras.layers import Dense, Embedding, LSTM, TimeDistributed, Input, Bidirectional
from keras.models import Model


def create_model(maxlen, chars, word_size):
    """

    :param maxlen:
    :param chars:
    :param word_size:
    :return:
    """
    sequence = Input(shape=(maxlen,), dtype='int32')
    embedded = Embedding(len(chars) + 1, word_size, input_length=maxlen, mask_zero=True)(sequence)
    blstm = Bidirectional(LSTM(64, return_sequences=True), merge_mode='sum')(embedded)
    output = TimeDistributed(Dense(5, activation='softmax'))(blstm)
    model = Model(input=sequence, output=output)
    return model

訓練數據

# -*- coding:utf-8 -*-

import re
import numpy as np
import pandas as pd

# 設計模型
word_size = 128
maxlen = 32

with open('data/msr_train.txt', 'rb') as inp:
    texts = inp.read().decode('gbk')
s = texts.split('\r\n')  # 根據換行切分


def clean(s):  # 整理一下數據,有些不規範的地方
    if u'“/s' not in s:
        return s.replace(u' ”/s', '')
    elif u'”/s' not in s:
        return s.replace(u'“/s ', '')
    elif u'‘/s' not in s:
        return s.replace(u' ’/s', '')
    elif u'’/s' not in s:
        return s.replace(u'‘/s ', '')
    else:
        return s


s = u''.join(map(clean, s))
s = re.split(u'[,。!?、]/[bems]', s)

data = []  # 生成訓練樣本
label = []


def get_xy(s):
    s = re.findall('(.)/(.)', s)
    if s:
        s = np.array(s)
        return list(s[:, 0]), list(s[:, 1])


for i in s:
    x = get_xy(i)
    if x:
        data.append(x[0])
        label.append(x[1])

d = pd.DataFrame(index=range(len(data)))
d['data'] = data
d['label'] = label
d = d[d['data'].apply(len) <= maxlen]
d.index = range(len(d))
tag = pd.Series({'s': 0, 'b': 1, 'm': 2, 'e': 3, 'x': 4})

chars = []  # 統計所有字,跟每個字編號
for i in data:
    chars.extend(i)

chars = pd.Series(chars).value_counts()
chars[:] = range(1, len(chars) + 1)

# 保存數據
import pickle

with open('model/chars.pkl', 'wb') as outp:
    pickle.dump(chars, outp)
print('** Finished saving the data.')

# 生成適合模型輸入的格式
from keras.utils import np_utils

d['x'] = d['data'].apply(lambda x: np.array(list(chars[x]) + [0] * (maxlen - len(x))))


def trans_one(x):
    _ = map(lambda y: np_utils.to_categorical(y, 5), tag[x].reshape((-1, 1)))
    _ = list(_)
    _.extend([np.array([[0, 0, 0, 0, 1]])] * (maxlen - len(x)))
    return np.array(_)


d['y'] = d['label'].apply(trans_one)

import lstm_model

model = lstm_model.create_model(maxlen, chars, word_size)
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

batch_size = 1024
history = model.fit(np.array(list(d['x'])), np.array(list(d['y'])).reshape((-1, maxlen, 5)), batch_size=batch_size,
                    nb_epoch=20, verbose=2)
model.save('model/model.h5')

2018-03-20-13-21-202018-03-20-13-21-20

1080顯卡訓練每次需要耗時44s,訓練20個epoch後精度達到95%

測試

import pickle
import lstm_model
import pandas as pd

with open('model/chars.pkl', 'rb') as inp:
    chars = pickle.load(inp)
word_size = 128
maxlen = 32

model = lstm_model.create_model(maxlen, chars, word_size)
model.load_weights('model/model.h5', by_name=True)

import re
import numpy as np

# 轉移概率,單純用了等概率
zy = {'be': 0.5,
      'bm': 0.5,
      'eb': 0.5,
      'es': 0.5,
      'me': 0.5,
      'mm': 0.5,
      'sb': 0.5,
      'ss': 0.5
      }

zy = {i: np.log(zy[i]) for i in zy.keys()}


def viterbi(nodes):
    paths = {'b': nodes[0]['b'], 's': nodes[0]['s']}  # 第一層,只有兩個節點
    for layer in range(1, len(nodes)):  # 後面的每一層
        paths_ = paths.copy()  # 先保存上一層的路徑
        # node_now 爲本層節點, node_last 爲上層節點
        paths = {}  # 清空 path
        for node_now in nodes[layer].keys():
            # 對於本層的每個節點,找出最短路徑
            sub_paths = {}
            # 上一層的每個節點到本層節點的連接
            for path_last in paths_.keys():
                if path_last[-1] + node_now in zy.keys():  # 若轉移概率不爲 0
                    sub_paths[path_last + node_now] = paths_[path_last] + nodes[layer][node_now] + zy[
                        path_last[-1] + node_now]
            # 最短路徑,即概率最大的那個
            sr_subpaths = pd.Series(sub_paths)
            sr_subpaths = sr_subpaths.sort_values()  # 升序排序
            node_subpath = sr_subpaths.index[-1]  # 最短路徑
            node_value = sr_subpaths[-1]  # 最短路徑對應的值
            # 把 node_now 的最短路徑添加到 paths 中
            paths[node_subpath] = node_value
    # 所有層求完後,找出最後一層中各個節點的路徑最短的路徑
    sr_paths = pd.Series(paths)
    sr_paths = sr_paths.sort_values()  # 按照升序排序
    return sr_paths.index[-1]  # 返回最短路徑(概率值最大的路徑)


def simple_cut(s):
    if s:
        r = model.predict(np.array([list(chars[list(s)].fillna(0).astype(int)) + [0] * (maxlen - len(s))]),
                          verbose=False)[
                0][:len(s)]
        r = np.log(r)
        print(r)
        nodes = [dict(zip(['s', 'b', 'm', 'e'], i[:4])) for i in r]
        t = viterbi(nodes)
        words = []
        for i in range(len(s)):
            if t[i] in ['s', 'b']:
                words.append(s[i])
            else:
                words[-1] += s[i]
        return words
    else:
        return []


not_cuts = re.compile(u'([\da-zA-Z ]+)|[。,、?!\.\?,!]')


def cut_word(s):
    result = []
    j = 0
    for i in not_cuts.finditer(s):
        result.extend(simple_cut(s[j:i.start()]))
        result.append(s[i.start():i.end()])
        j = i.end()
    result.extend(simple_cut(s[j:]))
    return result


print(cut_word('深度學習主要是特徵學習'))

結果:

['深度', '學習', '主要', '是', '特徵', '學習']

最後

本例子使用 Bi-directional LSTM 來完成了序列標註的問題。本例中展示的是一個分詞任務,但是還有其他的序列標註問題都是可以通過這樣一個架構來實現的,比如 POS(詞性標註)、NER(命名實體識別)等。在本例中,單從分類準確率來看的話差不多到 95% 了,還是可以的。可是最後的分詞效果還不是非常好,但也勉強能達到實用的水平。

源代碼地址

https://github.com/stephen-v/zh-segmentation-keras

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章