本文是對CRF基本原理的一個簡明的介紹。固然,「簡明」是相對而言中,要想真的弄清楚CRF,免不了要說起一些公式,若是隻關心調用的讀者,能夠直接移到文末。git
按照以前的思路,咱們依舊來對比一下普通的逐幀softmax和CRF的異同。github
CRF主要用於序列標註問題,能夠簡單理解爲是給序列中的每一幀都進行分類,既然是分類,很天然想到將這個序列用CNN或者RNN進行編碼後,接一個全鏈接層用softmax激活,以下圖所示算法
然而,當咱們設計標籤時,好比用s、b、m、e的4個標籤來作字標註法的分詞,目標輸出序列自己會帶有一些上下文關聯,好比s後面就不能接m和e,等等。逐標籤softmax並無考慮這種輸出層面的上下文關聯,因此它意味着把這些關聯放到了編碼層面,但願模型能本身學到這些內容,但有時候會「強模型所難」。網絡
而CRF則更直接一點,它將輸出層面的關聯分離了出來,這使得模型在學習上更爲「從容」:框架
固然,若是僅僅是引入輸出的關聯,還不只僅是CRF的所有,CRF的真正精巧的地方,是它以路徑爲單位,考慮的是路徑的機率。typecho
假如一個輸入有n幀,每一幀的標籤有k種可能性,那麼理論上就有k^n中不一樣的輸出。咱們能夠將它用以下的網絡圖進行簡單的可視化。在下圖中,每一個點表明一個標籤的可能性,點之間的連線表示標籤之間的關聯,而每一種標註結果,都對應着圖上的一條完整的路徑。學習
而在序列標註任務中,咱們的正確答案是通常是惟一的。好比「今每天氣不錯」,若是對應的分詞結果是「今天/天氣/不/錯」,那麼目標輸出序列就是bebess,除此以外別的路徑都不符合要求。換言之,在序列標註任務中,咱們的研究的基本單位應該是路徑,咱們要作的事情,是從k^n條路徑選出正確的一條,那就意味着,若是將它視爲一個分類問題,那麼將是k^n類中選一類的分類問題!
這就是逐幀softmax和CRF的根本不一樣了:前者將序列標註當作是n個k分類問題,後者將序列標註當作是1個k^n分類問題。
具體來說,在CRF的序列標註問題中,咱們要計算的是條件機率
爲了獲得這個機率的估計,CRF作了兩個假設:
假設一 該分佈是指數族分佈。
這個假設意味着存在函數f(y1,…,yn;x)f(y1,…,yn;x),使得
其中Z(x)是歸一化因子,由於這個是條件分佈,因此歸一化因子跟x有關。這個f函數能夠視爲一個打分函數,打分函數取指數並歸一化後就獲得機率分佈。
假設二 輸出之間的關聯僅發生在相鄰位置,而且關聯是指數加性的。
這個假設意味着f(y1,…,yn;x)f(y1,…,yn;x)能夠更進一步簡化爲
這也就是說,如今咱們只須要對每個標籤和每個相鄰標籤對分別打分,而後將全部打分結果求和獲得總分。
儘管已經作了大量簡化,但通常來講,(3)式所表示的機率模型仍是過於複雜,難以求解。因而考慮到當前深度學習模型中,RNN或者層疊CNN等模型已經可以比較充分捕捉各個y與輸入x的聯繫,所以,咱們不妨考慮函數g跟x無關,那麼
這時候g實際上就是一個有限的、待訓練的參數矩陣而已,而單標籤的打分函數h(yi;x)咱們能夠經過RNN或者CNN來建模。所以,該模型是能夠創建的,其中機率分佈變爲
這就是線性鏈CRF的概念。
爲了訓練CRF模型,咱們用最大似然方法,也就是用
做爲損失函數,能夠算出它等於
其中第一項是原來機率式的分子的對數,它目標的序列的打分,雖然它看上去挺迂迴的,可是並不難計算。真正的難度在於分母的對數logZ(x)這一項。
歸一化因子,在物理上也叫配分函數,在這裏它須要咱們對全部可能的路徑的打分進行指數求和,而咱們前面已經說到,這樣的路徑數是指數量級的(k^n),所以直接來算幾乎是不可能的。
事實上,歸一化因子難算,幾乎是全部機率圖模型的公共難題。幸運的是,在CRF模型中,因爲咱們只考慮了臨近標籤的聯繫(馬爾可夫假設),所以咱們能夠遞歸地算出歸一化因子,這使得原來是指數級的計算量下降爲線性級別。具體來講,咱們將計算到時刻t的歸一化因子記爲Zt,並將它分爲k個部分
其中Z(1)t,…,Z(k)分別是截止到當前時刻t中、以標籤1,…,k爲終點的全部路徑的得分指數和。那麼,咱們能夠遞歸地計算
它能夠簡單寫爲矩陣形式
其中Zt=[Z(1)t,…,Z(k)t];而G是對矩陣g各個元素取指數後的矩陣(前面已經說過,最簡單的狀況下,g只是一個矩陣,表明某個標籤到另外一個標籤的分數),即Gij=exp(gij);而H(yt+1|x)是編碼模型h(yt+1|x)(RNN、CNN等)對位置t+1的各個標籤的打分的指數,即H(yt+1|x)=exp(h(yt+1|x)),也是一個向量。式(10)中,ZtG這一步是矩陣乘法,獲得一個向量,而⊗是兩個向量的逐位對應相乘。
若是不熟悉的讀者,可能一會兒比較難接受(10)式。讀者能夠把n=1,n=2,n=3時的歸一化因子寫出來,試着找它們的遞歸關係,慢慢地就能夠理解(10)式了。
寫出損失函數−logP(y1,…,yn|x)後,就能夠完成模型的訓練了,由於目前的深度學習框架都已經帶有自動求導的功能,只要咱們能寫出可導的loss,就能夠幫咱們完成優化過程了。
那麼剩下的最後一步,就是模型訓練完成後,如何根據輸入找出最優路徑來。跟前面同樣,這也是一個從k^n條路徑中選最優的問題,而一樣地,由於馬爾可夫假設的存在,它能夠轉化爲一個動態規劃問題,用viterbi算法解決,計算量正比於n。
動態規劃在本博客已經出現了屢次了,它的遞歸思想就是:一條最優路徑切成兩段,那麼每一段都是一條(局部)最優路徑。在本博客右端的搜索框鍵入「動態規劃」,就能夠獲得不少相關介紹了,因此再也不重複了~
通過調試,基於Keras框架下,筆者獲得了一個線性鏈CRF的簡明實現,這也許是最簡短的CRF實現了。這裏分享最終的實現並介紹實現要點。
前面咱們已經說明了,實現CRF的困難之處是−logP(y1,…,yn|x)−logP(y1,…,yn|x)的計算,而本質困難是歸一化因子部分Z(x)Z(x)的計算,得益於馬爾科夫假設,咱們獲得了遞歸的(9)(9)式或(10)(10)式,它們應該已是通常狀況下計算Z(x)Z(x)的計算了。
那麼怎麼在深度學習框架中實現這種遞歸計算呢?要注意,從計算圖的視角看,這是經過遞歸的方法定義一個圖,並且這個圖的長度還不固定。這對於pytorch這樣的動態圖框架應該是不爲難的,可是對於tensorflow或者基於tensorflow的Keras就很難操做了(它們是靜態圖框架)。
不過,並不是沒有可能,咱們能夠用封裝好的rnn函數來計算!咱們知道,rnn本質上就是在遞歸計算
新版本的tensorflow和Keras都已經容許咱們自定義rnn細胞,這就意味着函數f能夠自行定義,然後端自動幫咱們完成遞歸計算。因而咱們只須要設計一個rnn,使得咱們要計算的Z對應於rnn的隱藏向量!
這就是CRF實現中最精緻的部分了。
至於剩下的,是一些細節性的,包括:
一、爲了防止溢出,咱們一般要取對數,但因爲歸一化因子是指數求和,因此其實是這樣的格式,它的計算技巧是:
tensorflow和Keras中都已經封裝好了對應的logsumexp函數了,直接調用便可;
二、對於分子(也就是目標序列的得分)的計算技巧,在代碼中已經作了註釋,主要是經過用「目標序列」點乘「預測序列」來實現取出目標得分;
三、關於變長輸入的padding部分如何進行mask?我以爲在這方面Keras作得並非很好。爲了簡單實現這種mask,個人作法是引入多一個標籤,好比原來是s、b、m、e四個標籤作分詞,而後引入第五個標籤,好比x,將padding部分的標籤都設爲x,而後能夠直接在CRF損失計算時忽略第五個標籤的存在,具體實現請看代碼。
純Keras實現的CRF層,歡迎使用~
# -*- coding:utf-8 -*- from keras.layers import Layer import keras.backend as K class CRF(Layer): """純Keras實現CRF層 CRF層本質上是一個帶訓練參數的loss計算層,所以CRF層只用來訓練模型, 而預測則須要另外創建模型。 """ def __init__(self, ignore_last_label=False, **kwargs): """ignore_last_label:定義要不要忽略最後一個標籤,起到mask的效果 """ self.ignore_last_label = 1 if ignore_last_label else 0 super(CRF, self).__init__(**kwargs) def build(self, input_shape): self.num_labels = input_shape[-1] - self.ignore_last_label self.trans = self.add_weight(name='crf_trans', shape=(self.num_labels, self.num_labels), initializer='glorot_uniform', trainable=True) def log_norm_step(self, inputs, states): """遞歸計算歸一化因子 要點:一、遞歸計算;二、用logsumexp避免溢出。 技巧:經過expand_dims來對齊張量。 """ states = K.expand_dims(states[0], 2) # (batch_size, output_dim, 1) trans = K.expand_dims(self.trans, 0) # (1, output_dim, output_dim) output = K.logsumexp(states+trans, 1) # (batch_size, output_dim) return output+inputs, [output+inputs] def path_score(self, inputs, labels): """計算目標路徑的相對機率(尚未歸一化) 要點:逐標籤得分,加上轉移機率得分。 技巧:用「預測」點乘「目標」的方法抽取出目標路徑的得分。 """ point_score = K.sum(K.sum(inputs*labels, 2), 1, keepdims=True) # 逐標籤得分 labels1 = K.expand_dims(labels[:, :-1], 3) labels2 = K.expand_dims(labels[:, 1:], 2) labels = labels1 * labels2 # 兩個錯位labels,負責從轉移矩陣中抽取目標轉移得分 trans = K.expand_dims(K.expand_dims(self.trans, 0), 0) trans_score = K.sum(K.sum(trans*labels, [2,3]), 1, keepdims=True) return point_score+trans_score # 兩部分得分之和 def call(self, inputs): # CRF自己不改變輸出,它只是一個loss return inputs def loss(self, y_true, y_pred): # 目標y_pred須要是one hot形式 mask = 1-y_true[:,1:,-1] if self.ignore_last_label else None y_true,y_pred = y_true[:,:,:self.num_labels],y_pred[:,:,:self.num_labels] init_states = [y_pred[:,0]] # 初始狀態 log_norm,_,_ = K.rnn(self.log_norm_step, y_pred[:,1:], init_states, mask=mask) # 計算Z向量(對數) log_norm = K.logsumexp(log_norm, 1, keepdims=True) # 計算Z(對數) path_score = self.path_score(y_pred, y_true) # 計算分子(對數) return log_norm - path_score # 即log(分子/分母) def accuracy(self, y_true, y_pred): # 訓練過程當中顯示逐幀準確率的函數,排除了mask的影響 mask = 1-y_true[:,:,-1] if self.ignore_last_label else None y_true,y_pred = y_true[:,:,:self.num_labels],y_pred[:,:,:self.num_labels] isequal = K.equal(K.argmax(y_true, 2), K.argmax(y_pred, 2)) isequal = K.cast(isequal, 'float32') if mask == None: return K.mean(isequal) else: return K.sum(isequal*mask) / K.sum(mask)
除去註釋和accuracy的代碼,真正的CRF的代碼量也就30行左右,能夠說跟哪一個框架比較都稱得上是簡明的CRF實現了吧~
用純Keras實現一些複雜的模型,是一件很有意思的事情。目前僅在tensorflow後端測試經過,理論上兼容theano、cntk後端,但可能要自行微調。
個人Github中還附帶了一個使用CNN+CRF實現的中文分詞的例子,用的是Bakeoff 2005語料,例子是一個完整的分詞實現,包括viterbi算法、分詞輸出等。
Github地址:https://github.com/bojone/crf/