命名實體識別(NER):BiLSTM-CRF原理介紹+Pytorch_Tutorial程式碼解析

命名實體識別任務(NER)定義

命名實體識別屬於自然語言處理中的序列標註任務,是指從文本中識別出特定命名指向的詞,比如人名、地名和組織機構名等。具體而言,輸入自然語言序列 ,給出對應標籤序列 ,見下圖。

序列標註里標記法有很多,包括BIO、BIOSE、IOB、BILOU、BMEWO、BMEWO+等,最常見的是 BIOBIOES 這兩種。不同標註方法會對模型效果有些許影響,例如有些時候用BIOES會比BIO有些許優勢。

在BIO和BIOSE中,Beginning 表示某個實體詞的開始,Inside表示某個實體詞的中間,Outside表示非實體詞,End表示某個實體詞的結尾,Single表示這個實體詞僅包含當前這一個字。IOB與BIO字母對應的含義相同,其不同點是IOB中,標籤B僅用於兩個連續的同類型命名實體的邊界區分。BILOU 等價於 BIOES,Last等同於End,Unit等同於Single。BMEWO 等價於 BIOES,Middle等同於Inside,Whole等同於Single。BMEWO+是在命名實體邊界外的標註,即『O plus『。

圖1

命名實體識別的常用方法是BiLSTM-CRF和BERT-CRF,可以完美的匹配該任務。

BiLSTM-CRF模型

下文,我們使用BIO標註進行解析,同時加入START和END來使轉移矩陣更加健壯,其中,START表示句子的開始,END表示句子的結束。這樣,標註標籤共有5個:[B, I, O, START, END]

BiLSTM-CRF模型主體由雙向長短時記憶網路(Bi-LSTM)和條件隨機場(CRF)組成,模型輸入是字元特徵,輸出是每個字元對應的預測標籤。

模型輸入

對於輸入的自然語言序列,可通過特徵工程的方法定義序列字元特徵,如詞性特徵、前後詞等,將其輸入模型。但現在多數情況下,可以直接選擇句中每個字元的字嵌入或詞嵌入向量,可以是事先訓練好的或是隨機初始化。對於中文,我個人傾向於將字元向量和其所屬的詞向量進行拼接,詞嵌入使用預訓練好的,字嵌入隨機初始化。

LSTM

BiLSTM接收每個字元的embedding,並預測每個字元的對5個標註標籤的概率。

LSTM是一種特殊的循環神經網路,可以解決RNN的長期依賴問題,其關鍵就是細胞狀態,見下圖中貫穿單元結構上方的水平線。細胞狀態在整個鏈上運行,只有一些少量的線性交互,從而保存長距離的資訊流。具體而言,LSTM一共有三個門來維持和調整細胞狀態,包括遺忘門,輸入門,輸出門。

遺忘門接收h_{t-1}x_t,通過公式1輸出一個在 0 到 1 之間的數值f_t,該數值會作用於上一個細胞狀態C_{t-1},1 表示「完全保留」,0 表示「完全忘記」;輸入門接收h_{t-1}x_t,通過公式2輸出一個在 0 到 1 之間的數值,已控制當前候選狀態\hat{C_t}有多少資訊需要保留,至於候選狀態\hat{C_t},則通過公式3由tanh 層創建一個新的候選值向量,然後根據上一個細胞狀態C_{t-1}和遺忘值f_t、新的細胞狀態C_{t}和輸入值i_t,由公式4更新細胞狀態;輸出門接收h_{t-1}x_t,通過公式5輸出一個在 0 到 1 之間的數值o_t,最後公式6決定了當前狀態C_t有多少資訊需要輸出。

題外話:LSTM的理解

一般來說,循環神經網路是MLP增加了一個時間維度,那麼該怎樣理解這個時間維度呢?可以參考以下兩張圖可視化LSTM。

torch.nn.LSTM()中,有以下幾個重要的參數:input_size,在這裡就是每個字元嵌入的維度;hidden_size,經過一個LSTM單元後輸入h的維度;num_layers,即上圖中depth的深度,若干個LSTMcell的堆疊;bidirectional,默認False,在實驗中將其設為True。

LSTM輸入包括input(h_0, c_0)兩部分,其中input大小為 (seq_len, batch, input_size),h_0c_0大小均為(num_layers * num_directions, batch, hidden_size)。輸出包括output(h_n, c_n)兩部分,其中output大小為(seq_len, batch, num_directions * hidden_size),h_nc_n大小均為(num_layers * num_directions, batch, hidden_size)。

我們知道,CNN在空間上共享參數,而RNN是在時間上共享參數。也就是在每一層depth中只有一個LSTMcell,即上面第二張圖中每一層只有一個LSTM單元。

在BiLSTM-CRF中,一般使用一層的雙向LSTM是足夠的。因此,BiLSTM對輸入embeddings的特徵提取過程如下圖。

本部分一開始就說過,BiLSTM接收每個字元的embedding,並預測每個字元的對5個標註標籤的概率。但是,我們也知道上圖得到的拼接向量維度大小為num_directions * hidden_size。為將輸入表示為字元對應各個類別的分數,需要在BiLSTM層加入一個全連接層,通過softmax將向量映射為一個5維的分布概率。

到了這一步,似乎我們通過BiLSTM已經找到每個單詞對應的最大標籤類別,但實際上,直接選擇該步驟最大概率的標籤類別得到的結果並不理想。原因在於,儘管LSTM能夠通過雙向的設置學習到觀測序列之間的依賴,但softmax層的輸出是相互獨立的,輸出相互之間並沒有影響,只是在每一步挑選一個最大概率值的label輸出,這樣的模型無法學習到輸出的標註之間的轉移依賴關係(標籤的概率轉移矩陣)以及序列標註的約束條件,如句子的開頭應該是「B」或「O」,而不是「I」等。為此,引入CRF層學習序列標註的約束條件,通過轉移特徵考慮輸出label之間的順序性,確保預測結果的有效性。

CRF

CRF層將BiLSTM的Emission_score作為輸入,輸出符合標註轉移約束條件的、最大可能的預測標註序列。

在開始之前,我們先了解一下線性鏈條件隨機場。見李航老師的《統計學習方法》第11章。

赫然發現,NER問題就是條件隨機場,即給定自然語言序列X,最大概率的標註序列Y用來表示NER標註結果。至於P(y|x),由公式(11.10)和(11.11)求得。齊活!

上圖中標出,公式(11.10)中有三個值得注意的部分:t_ks_lZ(x),理解這三個部分是理解BiLSTM-CRF模型中CRF的關鍵,以下面這張圖進行說明。

在我們的例子中,輸入xc_0,c_1,c_2,c_3,c_4,理想輸出yB,I,O,O,B,上圖中紅色線路。

  • Z(x),稱規範化因子或配分函數。在公式(11.10)中,「Z(x)是規範化因子,求和是在所有可能的輸出序列上進行的」。對應到我們的圖中,其實就是圖中所有可能的路徑組合,由於輸入序列長度為5,標註類型也為5,因此上圖中共有$5^5條不同路徑。每條路徑根據exp(*)計算該路徑的得分,加和得到Z(x)$。
  • s_t是節點上的狀態特徵,取決於當前節點;t_k是邊上的轉移特徵,取決於當前和前一個節點。根據它們的定義,可以很自然的將它們與BiLSTM-CRF中的Emission Score和Transition Score匹配:Emission Score是由BiLSTM生成的、對當前字元標註的概率分布;Transition Score是加入CRF約束條件的、字元標註之間的概率轉移矩陣。從這個意義上講,BiLSTM-CRF其實就是一個CRF模型,只不過用BiLSTM得到狀態特徵值s_t,用反向傳播演算法更新轉移特徵值t_k

在模型訓練過程中,模型損失函數定義如下:

P(\bar{y}|x)=\frac{exp(\operatorname{score}(x,\bar{y}))}{\sum_y{exp(\operatorname{score}(x,y))}}

\operatorname{score}(x,y)=\sum_{i=1}^{n} P_{i, y_{i}}+\sum_{i=0}^{n} A_{y_{i-1}, y_{i}}

其中,P_{i, y_{i}}A_{y_{i-1}, y_{i}}分別表示標註序列yy_i的Emission Score和Transition Score,通過查找上圖中的」BiLSTM的Emission Score「和」序列標註轉移矩陣「可以得到每個字元位置的得分,整個序列相加得到\operatorname{score}(x,y)

模型訓練過程中最大化對數似然函數:

\log P\left(\bar{y}\mid x\right)=\operatorname{score}\left(x, \bar{y}\right)-\log \left(\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right)\right)

真實路徑得分

在我們的例子中,真實路徑\bar{y}=(B,I,O,O,B)\operatorname{score}(x,\bar{y})=\sum\operatorname{EmissionScores}+\sum\operatorname{TransitionScores},其中:

\sum\operatorname{EmissionScores}=P_{0,START}+P_{1, B}+P_{2, I}+P_{3,O}+P_{4, O}+P_{5,B}+P_{6,END}

\sum\operatorname{TransitionScores}=A_{START,B}+A_{B,I}+A_{I,O}+A_{O,O}+A_{O,B}+A_{B,END}

EmissionScores來自BiLSTM層的輸出,至於P_{0,START}P_{6,END},則設為0;TransitionScores來自於CRF層;將真實路徑中這兩類分數加和,即可得到真實路徑得分,上圖中紅色路線。

所有路徑得分

對於所有路徑的總分\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right),一種直接的想法是列舉出所有可能的路徑,然後查找每條路徑的Emission Score和Transition Score,計算出每條路徑的得分,之後加和。

這種方法顯然效率低下,在我們的例子中,僅有5個字元和5個標註序列,就已經有了約5^5種路徑組合,在實際工作中,我們顯然會有更長的序列和更多的標註標籤,提高計算效率是必要的。於是,我們以分數累積的方法計算所有路徑得分,即先計算出到達c_0的所有路徑的總得分,然後,計算c_0{->}c_1的所有路徑的得分,依次類推,直到計算出c_0->c_1\dots->c_n的所有路徑的得分,這就是我們需要的結果。通過下圖可以看出這兩種計算方式的不同。

上面的圖是直觀的、計算所有路徑得分加和的示意圖,這種方法思想上是深度優先的;下面的圖是改進的、分數累計求和的示意圖,這種方法思想上是廣度優先的。

在第一種方法中,圖示以[(S,S,S,S,S),(S,S,S,S,B),(S,S,S,S,I),(S,S,S,S,O),(S,S,S,S,E)]五條可能的標註路徑為例,每一條路徑得分由該路徑上節點得分(EmissionScore)和邊得分(TransitionScore)相加得到,可以發現,如果計算分別計算每一條路徑的話,存在大量的冗餘,如對於上述五條路徑,前四個標註是相同的,理論上只需分別計算最後一項不同標註即可。

分數累積的方法更為激進。要知道,在計算Z(x)時我們並不是要排列組合出所有可能路徑,只是要一個All值,即圖中所有邊和節點的總值。因此,我們可以先計算出到達某一字元的路徑得分之和(s_S^i,s_B^i,s_I^i,s_O^i,s_E^i),然後依次計算下一字元的路徑得分之和(s_S^{i+1},s_B^{i+1},s_I^{i+1},s_O^{i+1},s_E^{i+1}),這類似於動態規劃的思想。如在上圖示例中,我們在得知到達y_1的路徑得分之和(s_S^1,s_B^1,s_I^1,s_O^1,s_E^1)後,根據s_{tag’}^2=\sum_{tags}{s_{tag}^1+t_{(tag,tag’)}}可分別計算出y_2的路徑得分之和(s_S^2,s_B^2,s_I^2,s_O^2,s_E^2),圖中紅/綠/藍/黃/紫分別根據y_1各標註的得分計算到達y_2(S,B,I,O,E)各標註的得分之和。依次類推,計算出整個圖中所有路徑得分之和。

在圖中可以直觀看到上述兩種方法是等價的,其數學上的等價性見下:

\log \left(\sum e^{\log \left(\Sigma e^{x}\right)+y}\right)=\log \left(\sum \sum e^{x+y}\right)

證明過程:

\begin{aligned}
\log \left(\sum e^{\log \left(\Sigma e^{x}\right)+y}\right) &=\log (\sum e^{\log (\Sigma e^{x})+\log e^y}) \\
&=\log (\sum e^{\log (\Sigma e^{x})*e^y})\\
&=\log (\sum e^{\log (\Sigma e^{x+y})})\\ &=\log \left(\sum \sum e^{x+y}\right) \end{aligned}

第二種方法有效減少了計算冗餘。第一種方法的計算複雜度為O(nm^n),其中,m為標註標籤類別數,第二種方法的計算複雜度為O(nm^2)。當然,第二種方法還可以以下圖這種方式計算,下文Pytorch Tutorial中的實現_forward_alg()就是如此,但本質上就是一回事。

建議推薦參照Bi-LSTM-CRF演算法詳解-1中的推導過程進行理解或自行推導。

最終BiLSTM-CRF模型如下:

Pytorch Tutorial NER程式碼解析

網上多數Pytorch NER解析來自官方示例,見ADVANCED: MAKING DYNAMIC DECISIONS AND THE BI-LSTM CRF,以下程式碼添加有個人備註解析。以下幾點需要注意:

  • 前文提到,模型目標是最大化對數似然函數\log P\left(\bar{y}\mid x\right)=\operatorname{score}\left(x, \bar{y}\right)-\log \left(\sum_{y} \exp \left(\operatorname{score}\left(x, y\right)\right)\right),但在深度學習中,我們一般通過最小化損失函數優化模型參數。因此,取負得到損失函數Loss=\log (\sum_{y} \exp (\operatorname{score}(x, y)))-\operatorname{score}(x, \bar{y})
  • 在NER問題中,訓練過程與測試過程是不同的。在訓練中,我們需要計算所有路徑的得分以正確更新轉移矩陣,但在測試過程中,我們以然得到合理的標籤概率轉移矩陣,因此只需要結合Emission Score選擇一條概率最大的路徑。該過程為解碼過程,採用Vertibe演算法,參見如何通俗地講解 viterbi 演算法?
  • Pytorch Tutorial僅提供最基本的程式碼幫助理解BiLSTM-CRF,具體的到實踐,該程式碼還有很大的優化空間。如批次訓練、GPU訓練、維特比解碼和配分函數計算優化等。
import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.optim as optim

torch.manual_seed(1)

def prepare_sequence(seq, to_ix):
    idxs = [to_ix[w] for w in seq]
    return torch.tensor(idxs, dtype=torch.long)

def argmax(vec):
	# 得到最大的值的索引
	_, idx = torch.max(vec, 1)
	return idx.item()

def log_sum_exp(vec):
	max_score = vec[0, argmax(vec)]  # max_score的維度為1
	max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])  # 維度為1*5
	return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))
	#等同於torch.log(torch.sum(torch.exp(vec))),防止e的指數導致電腦上溢

class BiLSTM_CRF(nn.Module):
	def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
		super(BiLSTM_CRF, self).__init__()
		self.embedding_dim = embedding_dim
		self.hidden_dim = hidden_dim
		self.vocab_size = vocab_size
		self.tag_to_ix = tag_to_ix
		self.tagset_size = len(tag_to_ix)

		self.word_embeds = nn.Embedding(vocab_size, embedding_dim)
		self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2, num_layers=1, bidirectional=True)
		self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
		# 轉移矩陣,transitions[i][j]表示從label_j轉移到label_i的概率,雖然是隨機生成的但是後面會迭代更新
		self.transitions = nn.Parameter(torch.randn(self.tagset_size, self.tagset_size))

		self.transitions.data[tag_to_ix[START_TAG], :] = -10000  # 從任何標籤轉移到START_TAG不可能
		self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000  # 從STOP_TAG轉移到任何標籤不可能

		self.hidden = self.init_hidden() # 隨機初始化LSTM的輸入(h_0, c_0)

	def init_hidden(self):
		return (torch.randn(2, 1, self.hidden_dim // 2),
				torch.randn(2, 1, self.hidden_dim // 2))

	def _forward_alg(self, feats):
		'''
		輸入:發射矩陣(emission score),實際上就是LSTM的輸出——sentence的每個word經BiLSTM後,對應於每個label的得分
		輸出:所有可能路徑得分之和/歸一化因子/配分函數/Z(x)
		'''
		init_alphas = torch.full((1, self.tagset_size), -10000.)
		init_alphas[0][self.tag_to_ix[START_TAG]] = 0.

		# 包裝到一個變數裡面以便自動反向傳播
		forward_var = init_alphas
		for feat in feats: # w_i
			alphas_t = []
			for next_tag in range(self.tagset_size): # tag_j
				# t時刻tag_i emission score(1個)的廣播。需要將其與t-1時刻的5個previous_tags轉移到該tag_i的transition scors相加
				emit_score = feat[next_tag].view(1, -1).expand(1, self.tagset_size) # 1*5
				# t-1時刻的5個previous_tags到該tag_i的transition scors
				trans_score = self.transitions[next_tag].view(1, -1)  # 維度是1*5

				next_tag_var = forward_var + trans_score + emit_score
				# 求和,實現w_(t-1)到w_t的推進
				alphas_t.append(log_sum_exp(next_tag_var).view(1))
			forward_var = torch.cat(alphas_t).view(1, -1) # 1*5

		# 最後將最後一個單詞的forward var與轉移 stop tag的概率相加
		terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
		alpha = log_sum_exp(terminal_var)
		return alpha

	def _get_lstm_features(self, sentence):
		'''
		輸入:id化的自然語言序列
		輸出:序列中每個字元的Emission Score
		'''
		self.hidden = self.init_hidden() # (h_0, c_0)
		embeds = self.word_embeds(sentence).view(len(sentence), 1, -1)
		lstm_out, self.hidden = self.lstm(embeds, self.hidden)
		lstm_out = lstm_out.view(len(sentence), self.hidden_dim)
		lstm_feats = self.hidden2tag(lstm_out) # len(s)*5
		return lstm_feats

	def _score_sentence(self, feats, tags):
		'''
		輸入:feats——emission scores;tags——真實序列標註,以此確定轉移矩陣中選擇哪條路徑
		輸出:真實路徑得分
		'''
		score = torch.zeros(1)
		# 將START_TAG的標籤3拼接到tag序列最前面
		tags = torch.cat([torch.tensor([self.tag_to_ix[START_TAG]], dtype=torch.long), tags])
		for i, feat in enumerate(feats):
			score = score + \
					self.transitions[tags[i + 1], tags[i]] + feat[tags[i + 1]]
		score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
		return score

	def _viterbi_decode(self, feats):
		# 預測序列的得分,維特比解碼,輸出得分與路徑值
		backpointers = []

		init_vvars = torch.full((1, self.tagset_size), -10000.)
		init_vvars[0][self.tag_to_ix[START_TAG]] = 0

		forward_var = init_vvars
		for feat in feats:
			bptrs_t = []
			viterbivars_t = []

			for next_tag in range(self.tagset_size):
				next_tag_var = forward_var + self.transitions[next_tag]  # forward_var保存的是之前的最優路徑的值
				best_tag_id = argmax(next_tag_var)  # 返回最大值對應的那個tag
				bptrs_t.append(best_tag_id)
				viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))

			forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1)
			backpointers.append(bptrs_t)  # bptrs_t有5個元素

		# 其他標籤到STOP_TAG的轉移概率
		terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
		best_tag_id = argmax(terminal_var)
		path_score = terminal_var[0][best_tag_id]

		best_path = [best_tag_id]
		for bptrs_t in reversed(backpointers):
			best_tag_id = bptrs_t[best_tag_id]
			best_path.append(best_tag_id)
		
		# 無需返回最開始的START位
		start = best_path.pop()
		assert start == self.tag_to_ix[START_TAG]
		best_path.reverse()  # 把從後向前的路徑正過來
		return path_score, best_path

	def neg_log_likelihood(self, sentence, tags):  # 損失函數
		feats = self._get_lstm_features(sentence)  # len(s)*5
		forward_score = self._forward_alg(feats)  # 規範化因子/配分函數
		gold_score = self._score_sentence(feats, tags) # 正確路徑得分
		return forward_score - gold_score  # Loss(已取反)

	def forward(self, sentence):
		'''
		解碼過程,維特比解碼選擇最大概率的標註路徑
		'''
		lstm_feats = self._get_lstm_features(sentence)

		score, tag_seq = self._viterbi_decode(lstm_feats)
		return score, tag_seq

START_TAG = "<START>"
STOP_TAG = "<STOP>"
EMBEDDING_DIM = 5 # 由於標籤一共有B\I\O\START\STOP 5個,所以embedding_dim為5
HIDDEN_DIM = 4 # 這其實是BiLSTM的隱藏層的特徵數量,因為是雙向所以是2倍,單向為2

training_data = [(
	"the wall street journal reported today that apple corporation made money".split(),
	"B I I I O O O B I O O".split()
), (
	"georgia tech is a university in georgia".split(),
	"B I O O O O B".split()
)]

word_to_ix = {}
for sentence, tags in training_data:
	for word in sentence:
		if word not in word_to_ix:
			word_to_ix[word] = len(word_to_ix)

tag_to_ix = {"B": 0, "I": 1, "O": 2, START_TAG: 3, STOP_TAG: 4}

model = BiLSTM_CRF(len(word_to_ix), tag_to_ix, EMBEDDING_DIM, HIDDEN_DIM)
optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)

for epoch in range(300):
	for sentence, tags in training_data:
		model.zero_grad()
		
        # 輸入
		sentence_in = prepare_sequence(sentence, word_to_ix)
		targets = torch.tensor([tag_to_ix[t] for t in tags], dtype=torch.long)
		
        # 獲取loss
		loss = model.neg_log_likelihood(sentence_in, targets)
		
        # 反向傳播
		loss.backward()
		optimizer.step()

with torch.no_grad():
	precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
	print(model(precheck_sent))

#TODO

  • NER實戰
  • BERT-CRF

參考

流水的NLP鐵打的NER:命名實體識別實踐與探索

序列標註方法BIO、BIOSE、IOB、BILOU、BMEWO、BMEWO+的異同

BiLSTM-CRF模型理解

Understanding LSTM Networks

pytorch中LSTM的細節分析理解

Bi-LSTM-CRF演算法詳解-1

ADVANCED: MAKING DYNAMIC DECISIONS AND THE BI-LSTM CRF

如何通俗地講解 viterbi 演算法?

《統計學習方法(第二版)》