前言:

本文章將透過

pytorch

作為主要工具實現不同的模型(包括

HMM

CRF

Bi-LSTM

Bi-LSTM+CRF

)來解決中文命名實體識別問題,文章不會涉及過多的數學推導,但會從直觀上簡單解釋模型的原理,主要的內容會集中在程式碼部分。

本文的目錄結構如下:

NLP實戰-中文命名實體識別

概覽

任務描述

首先,我們明確一下命名實體識別的概念:

命名實體識別

(英語:Named Entity Recognition),簡稱

NER

,是指識別文字中具有特定意義的實體,主要包括人名、地名、機構名、專有名詞等,以及時間、數量、貨幣、比例數值等文字。

舉個例子,假如有這麼一句話:

ACM宣佈,深度學習的三位創造者Yoshua Bengio, Yann LeCun, 以及Geoffrey Hinton獲得了2019年的圖靈獎。

那麼NER的任務就是從這句話中提取出

機構名:ACM

人名:Yoshua Bengio, Yann LeCun,Geoffrey Hinton

時間:2019年

專有名詞:圖靈獎

目前在NER上表現較好的模型都是基於

深度學習

或者是

統計學習

的方法的,這些方法共同的特點都是需要大量的資料來進行學習,所以接下來我先介紹一下本專案使用的資料集的格式,好讓讀者在閱讀模型的程式碼之前對資料的形式有個直觀的認識。

資料集

資料集用的是論文ACL 2018Chinese NER using Lattice LSTM中從新浪財經收集的簡歷資料,資料的格式如下,它的每一行由一個字及其對應的標註組成,標註集採用BIOES(B表示實體開頭,E表示實體結尾,I表示在實體內部,O表示非實體),句子之間用一個空行隔開。

美 B-LOC

國 E-LOC

的 O

華 B-PER

萊 I-PER

士 E-PER

我 O

跟 O

他 O

談 O

笑 O

風 O

生 O

執行結果

下面是四種不同的模型以及這Ensemble這四個模型預測結果的準確率(取最好):

HMM : 91。22%

CRF: 95。43%

BiLSTM: 95。44%

BiLSTM+CRF: 95。75%

Ensemble: 95。89%

最後一行Ensemble是將這四個模型的預測結果結合起來,使用“投票表決”的方法得出最後的預測結果。

下面本文將詳細接受每種模型的實現:

統計學習的方法

隱馬爾可夫模型(Hidden Markov Model,HMM)

隱馬爾可夫模型描述由一個隱藏的馬爾科夫鏈隨機生成不可觀測的狀態隨機序列,再由各個狀態生成一個觀測而產生觀測隨機序列的過程(李航 統計學習方法)。隱馬爾可夫模型由初始狀態分佈,狀態轉移機率矩陣以及觀測機率矩陣所確定。

上面的定義太過學術看不懂沒關係,我們只需要知道,NER本質上可以看成是一種序列標註問題(預測每個字的BIOES標記),在使用HMM解決NER這種序列標註問題的時候,我們所能觀測到的是字組成的序列(觀測序列),觀測不到的是每個字對應的標註(狀態序列)。

對應的,HMM的

三個要素

可以解釋為,

初始狀態分佈

就是每一個標註作為句子第一個字的標註的機率,

狀態轉移機率矩陣

就是由某一個標註轉移到下一個標註的機率(設狀態轉移矩陣為

M

,那麼若前一個詞的標註為

tag_i

,則下一個詞的標註為

tag_j

的機率為

M_{ij}

),

觀測機率矩陣

就是指在某個標註下,生成某個詞的機率。根據HMM的三個要素,我們可以定義如下的HMM模型:

class

HMM

object

):

def

__init__

self

N

M

):

“”“Args:

N: 狀態數,這裡對應存在的標註的種類

M: 觀測數,這裡對應有多少不同的字

”“”

self

N

=

N

self

M

=

M

# 狀態轉移機率矩陣 A[i][j]表示從i狀態轉移到j狀態的機率

self

A

=

torch

zeros

N

N

# 觀測機率矩陣, B[i][j]表示i狀態下生成j觀測的機率

self

B

=

torch

zeros

N

M

# 初始狀態機率 Pi[i]表示初始時刻為狀態i的機率

self

Pi

=

torch

zeros

N

有了模型定義,接下來的問題就是訓練模型了。HMM模型的訓練過程對應隱馬爾可夫模型的學習問題(李航 統計學習方法),實際上就是根據訓練資料根據

最大似然

的方法估計模型的三個要素,即上文提到的初始狀態分佈、狀態轉移機率矩陣以及觀測機率矩陣。舉個例子幫助理解,在估計初始狀態分佈的時候,假如某個標記在資料集中作為句子第一個字的標記的次數為k,句子的總數為N,那麼該標記作為句子第一個字的機率可以近似估計為

k/N

,很簡單對吧,使用這種方法,我們近似估計HMM的三個要素,程式碼如下(出現過的函式將用省略號代替):

class

HMM

object

):

def

__init__

self

N

M

):

。。。。

def

train

self

word_lists

tag_lists

word2id

tag2id

):

“”“HMM的訓練,即根據訓練語料對模型引數進行估計,

因為我們有觀測序列以及其對應的狀態序列,所以我們

可以使用極大似然估計的方法來估計隱馬爾可夫模型的引數

引數:

word_lists: 列表,其中每個元素由字組成的列表,如 [‘擔’,‘任’,‘科’,‘員’]

tag_lists: 列表,其中每個元素是由對應的標註組成的列表,如 [‘O’,‘O’,‘B-TITLE’, ‘E-TITLE’]

word2id: 將字對映為ID

tag2id: 字典,將標註對映為ID

”“”

assert

len

tag_lists

==

len

word_lists

# 估計轉移機率矩陣

for

tag_list

in

tag_lists

seq_len

=

len

tag_list

for

i

in

range

seq_len

-

1

):

current_tagid

=

tag2id

tag_list

i

]]

next_tagid

=

tag2id

tag_list

i

+

1

]]

self

A

current_tagid

][

next_tagid

+=

1

# 一個重要的問題:如果某元素沒有出現過,該位置為0,這在後續的計算中是不允許的

# 解決方法:我們將等於0的機率加上很小的數

self

A

self

A

==

0。

=

1e-10

self

A

=

self

A

/

self

A

sum

dim

=

1

keepdim

=

True

# 估計觀測機率矩陣

for

tag_list

word_list

in

zip

tag_lists

word_lists

):

assert

len

tag_list

==

len

word_list

for

tag

word

in

zip

tag_list

word_list

):

tag_id

=

tag2id

tag

word_id

=

word2id

word

self

B

tag_id

][

word_id

+=

1

self

B

self

B

==

0。

=

1e-10

self

B

=

self

B

/

self

B

sum

dim

=

1

keepdim

=

True

# 估計初始狀態機率

for

tag_list

in

tag_lists

init_tagid

=

tag2id

tag_list

0

]]

self

Pi

init_tagid

+=

1

self

Pi

self

Pi

==

0。

=

1e-10

self

Pi

=

self

Pi

/

self

Pi

sum

()

模型訓練完畢之後,要利用訓練好的模型進行解碼,就是對給定的模型未見過的句子,求句子中的每個字對應的標註,針對這個解碼問題,我們使用的是維特比(viterbi)演算法。關於該演算法的數學推導,可以查閱一下

李航統計學習方法10。4。2

,或者是

Speech and Language Processing8。4。5

,這兩本參考書都講的十分詳細並且容易理解,建議閱讀過相關部分再來看看程式碼加深理解。

它實現的細節如下:

class

HMM

object

):

。。。

def

decoding

self

word_list

word2id

tag2id

):

“”“

使用維特比演算法對給定觀測序列求狀態序列, 這裡就是對字組成的序列,求其對應的標註。

維特比演算法實際是用動態規劃解隱馬爾可夫模型預測問題,即用動態規劃求機率最大路徑(最優路徑)

這時一條路徑對應著一個狀態序列

”“”

# 問題:整條鏈很長的情況下,十分多的小機率相乘,最後可能造成下溢

# 解決辦法:採用對數機率,這樣源空間中的很小機率,就被對映到對數空間的大的負數

# 同時相乘操作也變成簡單的相加操作

A

=

torch

log

self

A

B

=

torch

log

self

B

Pi

=

torch

log

self

Pi

# 初始化 維位元矩陣viterbi 它的維度為[狀態數, 序列長度]

# 其中viterbi[i, j]表示標註序列的第j個標註為i的所有單個序列(i_1, i_2, 。。i_j)出現的機率最大值

seq_len

=

len

word_list

viterbi

=

torch

zeros

self

N

seq_len

# backpointer是跟viterbi一樣大小的矩陣

# backpointer[i, j]儲存的是 標註序列的第j個標註為i時,第j-1個標註的id

# 等解碼的時候,我們用backpointer進行回溯,以求出最優路徑

backpointer

=

torch

zeros

self

N

seq_len

long

()

# self。Pi[i] 表示第一個字的標記為i的機率

# Bt[word_id]表示字為word_id的時候,對應各個標記的機率

# self。A。t()[tag_id]表示各個狀態轉移到tag_id對應的機率

# 所以第一步為

start_wordid

=

word2id

get

word_list

0

],

None

Bt

=

B

t

()

if

start_wordid

is

None

# 如果字不再字典裡,則假設狀態的機率分佈是均勻的

bt

=

torch

log

torch

ones

self

N

/

self

N

else

bt

=

Bt

start_wordid

viterbi

[:,

0

=

Pi

+

bt

backpointer

[:,

0

=

-

1

# 遞推公式:

# viterbi[tag_id, step] = max(viterbi[:, step-1]* self。A。t()[tag_id] * Bt[word])

# 其中word是step時刻對應的字

# 由上述遞推公式求後續各步

for

step

in

range

1

seq_len

):

wordid

=

word2id

get

word_list

step

],

None

# 處理字不在字典中的情況

# bt是在t時刻字為wordid時,狀態的機率分佈

if

wordid

is

None

# 如果字不再字典裡,則假設狀態的機率分佈是均勻的

bt

=

torch

log

torch

ones

self

N

/

self

N

else

bt

=

Bt

wordid

# 否則從觀測機率矩陣中取bt

for

tag_id

in

range

len

tag2id

)):

max_prob

max_id

=

torch

max

viterbi

[:,

step

-

1

+

A

[:,

tag_id

],

dim

=

0

viterbi

tag_id

step

=

max_prob

+

bt

tag_id

backpointer

tag_id

step

=

max_id

# 終止, t=seq_len 即 viterbi[:, seq_len]中的最大機率,就是最優路徑的機率

best_path_prob

best_path_pointer

=

torch

max

viterbi

[:,

seq_len

-

1

],

dim

=

0

# 回溯,求最優路徑

best_path_pointer

=

best_path_pointer

item

()

best_path

=

best_path_pointer

for

back_step

in

range

seq_len

-

1

0

-

1

):

best_path_pointer

=

backpointer

best_path_pointer

back_step

best_path_pointer

=

best_path_pointer

item

()

best_path

append

best_path_pointer

# 將tag_id組成的序列轉化為tag

assert

len

best_path

==

len

word_list

id2tag

=

dict

((

id_

tag

for

tag

id_

in

tag2id

items

())

tag_list

=

id2tag

id_

for

id_

in

reversed

best_path

)]

return

tag_list

以上就是HMM的實現了,全部程式碼可見文末。

條件隨機場(Conditional Random Field, CRF)

上面講的HMM模型中存在兩個假設,一是輸出觀察值之間嚴格獨立,二是狀態轉移過程中當前狀態只與前一狀態有關。也就是說,在命名實體識別的場景下,HMM認為觀測到的句子中的每個字都是相互獨立的,而且當前時刻的標註只與前一時刻的標註相關。但實際上,命名實體識別往往需要更多的特徵,比如詞性,詞的上下文等等,同時當前時刻的標註應該與前一時刻以及後一時刻的標註都相關聯。由於這兩個假設的存在,顯然HMM模型在解決命名實體識別的問題上是存在缺陷的。

而條件隨機場就沒有這種問題,它透過引入自定義的特徵函式,不僅可以表達觀測之間的依賴,還可表示當前觀測與前後多個狀態之間的複雜依賴,可以有效克服HMM模型面臨的問題。

下面是條件隨機場的數學形式(如果覺得不好理解。也可以直接跳到程式碼部分):

為了建立一個條件隨機場,我們首先要定義一個

特徵函式集

,該函式集內的每個特徵函式都以標註序列作為輸入,提取的特徵作為輸出。假設該函式集為:

NLP實戰-中文命名實體識別

其中

x=(x_1, ..., x_m)

表示觀測序列,

s = (s_1, ...., s_m)

表示狀態序列。然後,條件隨機場使用對數線性模型來計算給定觀測序列下狀態序列的條件機率:

NLP實戰-中文命名實體識別

其中

s^{

是是所有可能的狀態序列,

w

是條件隨機場模型的引數,可以把它看成是每個特徵函式的權重。CRF模型的訓練其實就是對引數

w

的估計。假設我們有

n

個已經標註好的資料

{(x^i, s^i)}_{i=1}^n

則其對數似然函式的正則化形式如下:

NLP實戰-中文命名實體識別

那麼,最優引數

w^*

就是:

NLP實戰-中文命名實體識別

模型訓練結束之後,對給定的觀測序列

x

,它對應的最優狀態序列應該是:

NLP實戰-中文命名實體識別

解碼的時候與HMM類似,也可以採用維特比演算法。

下面是程式碼實現:

from

sklearn_crfsuite

import

CRF

# CRF的具體實現太過複雜,這裡我們藉助一個外部的庫

def

word2features

sent

i

):

“”“抽取單個字的特徵”“”

word

=

sent

i

prev_word

=

if

i

==

0

else

sent

i

-

1

next_word

=

if

i

==

len

sent

-

1

else

sent

i

+

1

# 因為每個詞相鄰的詞會影響這個詞的標記

# 所以我們使用:

# 前一個詞,當前詞,後一個詞,

# 前一個詞+當前詞, 當前詞+後一個詞

# 作為特徵

features

=

{

‘w’

word

‘w-1’

prev_word

‘w+1’

next_word

‘w-1:w’

prev_word

+

word

‘w:w+1’

word

+

next_word

‘bias’

1

}

return

features

def

sent2features

sent

):

“”“抽取序列特徵”“”

return

word2features

sent

i

for

i

in

range

len

sent

))]

class

CRFModel

object

):

def

__init__

self

algorithm

=

‘lbfgs’

c1

=

0。1

c2

=

0。1

max_iterations

=

100

all_possible_transitions

=

False

):

self

model

=

CRF

algorithm

=

algorithm

c1

=

c1

c2

=

c2

max_iterations

=

max_iterations

all_possible_transitions

=

all_possible_transitions

def

train

self

sentences

tag_lists

):

“”“訓練模型”“”

features

=

sent2features

s

for

s

in

sentences

self

model

fit

features

tag_lists

def

test

self

sentences

):

“”“解碼,對給定句子預測其標註”“”

features

=

sent2features

s

for

s

in

sentences

pred_tag_lists

=

self

model

predict

features

return

pred_tag_lists

深度學習的方法

Bi-LSTM

除了以上兩種基於機率圖模型的方法,LSTM也常常被用來解決序列標註問題。和HMM、CRF不同的是,LSTM是依靠神經網路超強的非線性擬合能力,在訓練時將樣本透過高維空間中的複雜非線性變換,學習到從樣本到標註的函式,之後使用這個函式為指定的樣本預測每個token的標註。下方就是使用雙向LSTM(雙向能夠更好的捕捉序列之間的依賴關係)進行序列標註的示意圖:

NLP實戰-中文命名實體識別

LSTM比起CRF模型最大的好處就是

簡單粗暴

,不需要做繁雜的特徵工程,直接訓練即可,同時比起HMM,LSTM的準確率也比較高。

下面是基於雙向LSTM的序列標註模型的實現:

import

torch

import

torch。nn

as

nn

from

torch。nn。utils。rnn

import

pad_packed_sequence

pack_padded_sequence

class

BiLSTM

nn

Module

):

def

__init__

self

vocab_size

emb_size

hidden_size

out_size

):

“”“初始化引數:

vocab_size:字典的大小

emb_size:詞向量的維數

hidden_size:隱向量的維數

out_size:標註的種類

”“”

super

BiLSTM

self

__init__

()

self

embedding

=

nn

Embedding

vocab_size

emb_size

self

bilstm

=

nn

LSTM

emb_size

hidden_size

batch_first

=

True

bidirectional

=

True

self

lin

=

nn

Linear

2

*

hidden_size

out_size

def

forward

self

sents_tensor

lengths

):

emb

=

self

embedding

sents_tensor

# [B, L, emb_size]

packed

=

pack_padded_sequence

emb

lengths

batch_first

=

True

rnn_out

_

=

self

bilstm

packed

# rnn_out:[B, L, hidden_size*2]

rnn_out

_

=

pad_packed_sequence

rnn_out

batch_first

=

True

scores

=

self

lin

rnn_out

# [B, L, out_size]

return

scores

def

test

self

sents_tensor

lengths

_

):

“”“解碼”“”

logits

=

self

forward

sents_tensor

lengths

# [B, L, out_size]

_

batch_tagids

=

torch

max

logits

dim

=

2

return

batch_tagid

Bi-LSTM+CRF

簡單的LSTM的優點是能夠透過

雙向

的設定學習到觀測序列(輸入的字)之間的依賴,在訓練過程中,LSTM能夠根據目標(比如識別實體)自動提取觀測序列的特徵,但是缺點是無法學習到狀態序列(輸出的標註)之間的關係,要知道,在命名實體識別任務中,標註之間是有一定的關係的,比如B類標註(表示某實體的開頭)後面不會再接一個B類標註,所以LSTM在解決NER這類序列標註任務時,雖然可以省去很繁雜的特徵工程,但是也存在無法學習到標註上下文的缺點。

相反,CRF的優點就是能對隱含狀態建模,學習狀態序列的特點,但它的缺點是需要手動提取序列特徵。所以一般的做法是,在LSTM後面再加一層CRF,以獲得兩者的優點。

下面是給Bi-LSTM加一層CRF的程式碼實現:

class

BiLSTM_CRF

nn

Module

):

def

__init__

self

vocab_size

emb_size

hidden_size

out_size

):

“”“初始化引數:

vocab_size:字典的大小

emb_size:詞向量的維數

hidden_size:隱向量的維數

out_size:標註的種類

”“”

super

BiLSTM_CRF

self

__init__

()

# 這裡的BiLSTM就是LSTM模型部分所定義的BiLSTM模型

self

bilstm

=

BiLSTM

vocab_size

emb_size

hidden_size

out_size

# CRF實際上就是多學習一個轉移矩陣 [out_size, out_size] 初始化為均勻分佈

self

transition

=

nn

Parameter

torch

ones

out_size

out_size

*

1

/

out_size

# self。transition。data。zero_()

def

forward

self

sents_tensor

lengths

):

# [B, L, out_size]

emission

=

self

bilstm

sents_tensor

lengths

# 計算CRF scores, 這個scores大小為[B, L, out_size, out_size]

# 也就是每個字對應對應一個 [out_size, out_size]的矩陣

# 這個矩陣第i行第j列的元素的含義是:上一時刻tag為i,這一時刻tag為j的分數

batch_size

max_len

out_size

=

emission

size

()

crf_scores

=

emission

unsqueeze

2

expand

-

1

-

1

out_size

-

1

+

self

transition

unsqueeze

0

return

crf_scores

def

test

self

test_sents_tensor

lengths

tag2id

):

“”“使用維特比演算法進行解碼”“”

start_id

=

tag2id

end_id

=

tag2id

pad

=

tag2id

tagset_size

=

len

tag2id

crf_scores

=

self

forward

test_sents_tensor

lengths

device

=

crf_scores

device

# B:batch_size, L:max_len, T:target set size

B

L

T

_

=

crf_scores

size

()

# viterbi[i, j, k]表示第i個句子,第j個字對應第k個標記的最大分數

viterbi

=

torch

zeros

B

L

T

to

device

# backpointer[i, j, k]表示第i個句子,第j個字對應第k個標記時前一個標記的id,用於回溯

backpointer

=

torch

zeros

B

L

T

long

()

*

end_id

to

device

lengths

=

torch

LongTensor

lengths

to

device

# 向前遞推

for

step

in

range

L

):

batch_size_t

=

lengths

>

step

sum

()

item

()

if

step

==

0

# 第一個字它的前一個標記只能是start_id

viterbi

[:

batch_size_t

step

:]

=

crf_scores

[:

batch_size_t

step

start_id

:]

backpointer

[:

batch_size_t

step

:]

=

start_id

else

max_scores

prev_tags

=

torch

max

viterbi

[:

batch_size_t

step

-

1

:]

unsqueeze

2

+

crf_scores

[:

batch_size_t

step

:,

:],

# [B, T, T]

dim

=

1

viterbi

[:

batch_size_t

step

:]

=

max_scores

backpointer

[:

batch_size_t

step

:]

=

prev_tags

# 在回溯的時候我們只需要用到backpointer矩陣

backpointer

=

backpointer

view

B

-

1

# [B, L * T]

tagids

=

[]

# 存放結果

tags_t

=

None

for

step

in

range

L

-

1

0

-

1

):

batch_size_t

=

lengths

>

step

sum

()

item

()

if

step

==

L

-

1

index

=

torch

ones

batch_size_t

long

()

*

step

*

tagset_size

index

=

index

to

device

index

+=

end_id

else

prev_batch_size_t

=

len

tags_t

new_in_batch

=

torch

LongTensor

end_id

*

batch_size_t

-

prev_batch_size_t

))

to

device

offset

=

torch

cat

tags_t

new_in_batch

],

dim

=

0

# 這個offset實際上就是前一時刻的

index

=

torch

ones

batch_size_t

long

()

*

step

*

tagset_size

index

=

index

to

device

index

+=

offset

long

()

tags_t

=

backpointer

[:

batch_size_t

gather

dim

=

1

index

=

index

unsqueeze

1

long

())

tags_t

=

tags_t

squeeze

1

tagids

append

tags_t

tolist

())

# tagids:[L-1](L-1是因為扣去了end_token),大小的liebiao

# 其中列表內的元素是該batch在該時刻的標記

# 下面修正其順序,並將維度轉換為 [B, L]

tagids

=

list

zip_longest

*

reversed

tagids

),

fillvalue

=

pad

))

tagids

=

torch

Tensor

tagids

long

()

# 返回解碼的結果

return

tagids

以上就是這四個模型的具體實現了,模型的效果比較在前面已經給出了。

全部的程式碼地址在

luopeixiang/named_entity_recognition

(這個專案後續會保持更新,同時嘗試使用其他模型來做這個問題,如果覺得有幫助可以點個star哦~)

程式碼中一些需要注意的點

HMM模型中要處理OOV(Out of vocabulary)的問題,就是測試集裡面有些字是不在訓練集裡面的, 這個時候透過觀測機率矩陣是無法查詢到OOV對應的各種狀態的機率的,處理這個問題可以將OOV對應的狀態的機率分佈設為均勻分佈。

HMM的三個引數(即狀態轉移機率矩陣、觀測機率矩陣以及初始狀態機率矩陣)在使用監督學習方法進行估計的過程中,如果有些項從未出現,那麼該項對應的位置就為0,而在使用維特比演算法進行解碼的時候,計算過程需要將這些值相乘,那麼如果其中有為0的項,那麼整條路徑的機率也變成0了。此外,解碼過程中多個小機率相乘很可能出現下溢的情況,為了解決這兩個問題,我們給那些從未出現過的項賦予一個很小的數(如0。00000001),同時在進行解碼的時候將模型的三個引數都對映到對數空間,這樣既可以避免下溢,又可以簡化乘法運算。

CRF中將訓練資料以及測試資料作為模型的輸入之前,都需要先用特徵函式提取特徵!

Bi-LSTM+CRF模型可以參考:Neural Architectures for Named Entity Recognition,可以重點看一下里面的損失函式的定義。程式碼裡面關於損失函式的計算採用的是類似動態規劃的方法,不是很好理解,這裡推薦看一下以下這些部落格:

CRF Layer on the Top of BiLSTM - 5PENG:Bi-LSTM-CRF for Sequence LabelingPytorch Bi-LSTM + CRF 程式碼詳解