从HMM到CRF到LSTM+CRF
在实习期间应用了LSTM+CRF模型,在理论方面存在不足,尤其是HMM和CRF的基础知识掌握不牢固。为了记录这些概念之间的关联,并探讨它们的实际应用价值,特撰写一篇非纯粹理论性的文章。本文的重点依然是LSTM+CRF模型的应用与实现,在介绍该模型时会详细阐述其依赖的概念和相关技术细节。具体来说,在介绍马尔可夫模型的基本原理与应用方法的基础上,深入探讨隐马尔科夫模型(HMM)的概念与工作原理。基于概率的前向算法将用于状态序列的概率计算;而维特比算法则用于确定最优状态路径的方法。接着转向条件随机场(CRF)的基本原理及其在序列建模中的作用;矩阵表示法将描述条件随机场的状态转移概率分布;同时也会详细讲解其前向算法的具体实现过程。最后聚焦于基于长短期记忆网络的条件随机场(LSTM+CRF)这一特定结构,在阐述其技术优势的同时也会对源码解析的具体步骤进行详细说明
本文长而杂,阅读须谨慎。
0. Markov Model
从前学过隐马尔科夫模型的概念,并了解到它包含转移矩阵A以及观测矩阵B等关键组成部分。然而仅限于皮毛的知识水平使得这一知识点难以深入理解。于是决定对其进行系统复习。
当我们面对隐马尔科夫模型(HMM)时,在深入探讨其细节之前,请先放下其中的状态或观测概念。随后,在这一基础上逐步引出马尔可夫模型的基本概念。
考虑一个由随机变量构成的序列X=(X_1, X_2, ..., X_T),其取值均来自指定的状态集合S=\{s_1, s_2, ..., s_{|S|}\}。通过将这些状态值赋给随机变量序列中的每个元素,则形成了长度为T的状态序列描述。举个例子说明这个概念:假设S=\{sun, cloud, rain\}表示天气状况,则当观察到的时间长度为T=3时,可能的一个状态序列为\{X_1=sun, X_2=cloud, X_3=rain\}。
上面的状态序列,在马尔科夫模型中做了两个假设:
- Limited Horizon:
即下一个状态的输出概率只与上一个状态有关
- Time Invariant
状态S_{t+1}与S_t的输出概率之间的关系不受时间t的影响,并不会因时间不同而变化。
在马尔科夫模型中,在计算各态之间的转移几率时,则必须先设定好转移概率矩阵A;而当系统处于初始时刻时,则需明确初始态的概率分布π。
下面以一个例子说明马尔科夫模型:
(1) 记状态序列为
(2) 初始状态概率为
(3) 转移矩阵为
对应的转移状态图为

则状态序列为1011的概率为:
通过上述实例可以看出, 马尔科夫模型仅适用于处理简单的状态转移问题. 也就是说, 在这种情况下, 我了解了一个完整的状态序列的同时也了解了各状态下之间的转移规律, 因此能够推断出整个系统各状态的概率分布情况. 然而, 在实际情况中我们通常无法直接观察到这一整个的状态序列, 办法又是什么呢?
例如,在隐马尔可夫模型中有一个经典的案例称为Ice Cream Climatology问题。在这个问题中, 我希望预测某段时间内天气是热浪还是冷浪, 但由于种种原因, 我无法直接观察这段时间内的真实天气情况, 只能通过每天摄入的数量级来间接判断天气状况的变化趋势。因此, 在这种情况下, 气候本身就是一个隐式的序列模式, 而真实观察到的是每天吃冰激凌的数量序列这一数据特征。
另一个实例即是NLP领域中的词性标注(POS Tagging)问题。具体而言,在我的研究中涉及两个关键元素:其一是完整的句子序列;其二是每个句子中各词语对应的词性构成的标记序列。然而,在实际应用中通常只获得完整的句子序列;由于在预测阶段无法得知各词语的具体词性信息;因此词性标注的结果就被视为一个隐含存在的标记序列。
在实际应用中, 如果一个问题同时涉及观测序列和隐含的状态序列, 则必须使用隐马尔科夫模型来处理这种情况
1. Hidden Markov Model
隐马尔科夫模型是一种用于描述隐藏状态序列及其对应观测序列之间相互依存关系的数据分析工具。其核心构建包括两个基本序列:状态序列Q={q₁,q₂,...,qₙ}和观测序列O={o₁,o₂,...,oₙ};其中每一个状态变量qᵢ都会对应生成一个观测变量oᵢ。基于隐马尔科夫模型理论,默认假设输出变量与其对应的隐藏状态变量之间存在条件独立性
具体来说,在t时刻的观测值仅受限于该时刻的状态变量,并不受其他任何时间点状态的影响。我的符号系统参考了《统计学习方法》一书中所使用的符号体系。
隐马尔可夫模型同样也需要初始向量以及状态转移概率矩阵A来描述状态之间的转换关系。因为我们引入了更多的观测序列O和相应的观测值o_t(其中t=1,\dots,T),因此在传统的隐马尔科夫模型基础上我们特别定义了一个观测概率矩阵B来表示某个状态q_t产生某个观测值o_t的概率分布情况。其中\lambda=(A,B,\pi)代表模型的所有参数。
在隐马尔科夫模型中,有3个基本问题,这里我主要描述其中的两个:
(1) 概率计算问题 :给定模型和观测序列,求观测O出现的概率P(O|\lambda)
(2) 预测任务:基于给定的模型以及观测序列,寻找使得条件概率P(I\mid O)达到最大值的隐含状态序列I = \{i_1, i_2, ..., i_T\}
1.1 前向算法
计算观测序列发生的概率的一种方法是计算所有可能的状态序列S生成该观测序列的概率。然而直接计算所有可能的状态序列S的数量显然是不现实的。尽管这一思路虽然可行但存在更高的效率计算方法。为了便于后续推导我们定义前向概率为
在时间步t时的部分观测序列为o_1, o_2,..., o_t且处于状态q_i的概率具体而言即为:在t-1时刻的所有可能状态经过状态转移到达t时刻的状态i_t = q_i并伴随观测o_t产生的概率值。同样地对于t+1时刻的状态i_{t+1}=q_j也存在这样的概率关系即t时刻的所有状态经过转移生成q_j并在观测o_{t+1}下出现的概率值。如图所示

在计算下一时刻的概率时,在线段上某一点处所累积的概率值可被复用其概率值可被复用。这表明这种算法的特点是能够在单次迭代中完成所有后续时间步的概率推算其优势在于每次只需调用上一步骤的结果而不必重复运算。基于教材中的示例10.2我对该方法进行了简要实现
import time
import numpy as np
A = np.array([[0.5, 0.2, 0.3],
[0.3, 0.5, 0.2],
[0.2, 0.3, 0.5]])
B = np.array([[0.5, 0.5],
[0.4, 0.6],
[0.7, 0.3]])
PI = np.array([0.2, 0.4, 0.4])
def forward(obs):
alpha_t = PI * B[:, obs[0]]
for t in range(1, len(A)):
# 计算t-1时刻所有节点到t时刻y中某一个节点的概率
transition_score = np.dot(alpha_t, A)
emission_score = B[: ,obs[t]]
alpha_t = transition_score * emission_score
po = np.sum(alpha_t)
print("P(O|lambda): {}".format(po))
return po
if __name__ == '__main__':
obs = [0, 1, 0] # red white red
forward(obs)
1.2 预测算法
对于该任务而言,在已知的一组连续词语中(即已知句子序列),该系统应推断出该组词语最有可能具有的词性标记序列(即该词语最有可能具有的词性标记组合)。基于此推断所设计的预测算法旨在推断出一个词性标记序列(即该词语最有可能具有的词性标记组合),使得这一特定标记组合出现的概率最大(即该特定标记组合的概率值达到最大)。
假设在时间步长t时观测值是X_t = x_t,在当前时间步长的状态是r。在时间步长t-1时的状态s取值包括i、j、k,在从状态s到状态r的过程中会产生相应的概率分布P(r | s)
假设状态s=i, j, k的概率为c_i, c_j, c_k,那么上述概率用图示即为:

从t-1时刻的状态到t时刻的状态的路径有三条,并且这三条都可能是最优状态转移路径。这些状态转移将导致传递出3条可能的最佳状态序列。假设在t+1时刻存在三种状态,则在该时点上将出现3 \times 3 = 3^2种可能的最佳状态序列组合。这些状态序列的数量将以指数方式增长。
在维特比算法中就不同了。对于存在三条可能的路径情况,则需要计算这三条路径对应的状态序列的概率,并保留概率最高的那条路径如图所示

这样每个状态只会传递单一序列下去,而不仅仅是多个,从而导致序列数量呈指数增长.
为什么只要保留到当前节点的最优那条路径就可以了呢?再如图:

假设从节点 S 到 E 的最佳路线是通过依次经过 A₂、B₂、C₂到达 E,并且这条路线上必定会经过 C₂点。
那么对于从 S 到 C₂ 的所有可能子路经中,在该主路上的所有路经必定是最优的选择。
然而如果如果存在另一条从 S 到 A₁再到 B₁最后到 C₂ 并比当前主路更为缩短的话,
将新的较短路线取代现有的长路线后,
原先所假设中的全局最短路经 S-A₂-B₂-C₂-E 将无法成立,
这与我们最初的设定产生矛盾。
这一证明表明,在寻找全局最短路经时必须确保每一步都选择局部最短的可能性。
所以每个节点只需要保留到自己的局部最优路经即可。
参照《统计学习方法》例10.3,我也写了个简单实现:
import time
import numpy as np
A = np.array([[0.5, 0.2, 0.3],
[0.3, 0.5, 0.2],
[0.2, 0.3, 0.5]])
B = np.array([[0.5, 0.5],
[0.4, 0.6],
[0.7, 0.3]])
PI = np.array([0.2, 0.4, 0.4])
def viterbi(obs):
state = PI * B[:, obs[0]]
# 记录到达当前state(t时刻)且具有最优路径概率的state_index(t-1时刻)
sequence_indices = np.zeros((len(obs), len(A)), dtype=np.int32)
# viterbi前向计算
for t in range(1, len(obs)):
state = state.reshape(-1, 1)
# 计算每个状态最优路径的概率
transition_score = (state * A).max(axis=0)
sequence_indices[t] = (state * A).argmax(axis=0)
state = transition_score * B[:, obs[t]]
# 反向查找最优状态序列
final_sequence = [state.argmax()]
for t in range(len(obs)-1, 0, -1):
state_index = sequence_indices[t][final_sequence[-1]]
final_sequence.append(state_index)
print("final_sequence: {}".format(final_sequence[::-1]))
return final_sequence[::-1]
if __name__ == '__main__':
obs = [0, 1, 0] # red white red
viterbi(obs)
2. Conditional Random Field
由于条件随机场的理论较为复杂深入, 单篇教程难以全面涵盖所有要点, 并且其中涉及较多专业术语, 可能会让读者难以完全掌握。因此, 我们这里将作为初步介绍, 前面几段可能会显得枯燥乏味, 但却是值得讲解的重点部分。关于定义及符号体系, 我们仍沿用《统计学习方法》的相关设定, 在此过程中会重新审视并适当调整书中可能存在表述不够严谨的地方。此外, 在后续介绍长短期记忆网络与条件随机场结合应用时, 这一节的相关内容也会得到进一步阐述。
2.1 CRF
设随机变量X与Y之间存在某种关系,在给定条件下其概率分布为P(Y|X);如果这些随机变量构成一个马尔科夫随机场,则该条件概率分布被称为条件随机场。
2.2 Linear-Chain CRF
(1) 定义
在词性标注等问题中
设X=(X_1,X_2,...,X_n)和Y=(Y_1,Y_2,...,Y_n)均为基于线性链结构表示的随机变量序列,则在给定其中一个随机变量序列的情况下(例如给定观测序列),另一个随机变量序列的概率分布可以用条件概率模型来描述;这种概率模型实际上构成了一个条件随机场(CRF),即满足马尔科夫性质:对于任意两个相邻的状态i和j以及观测k
则称 为线性链条件随机场。
(2) 模型表示
线性链条件随机场对建模,表示为:
其中:
$t_k与s_l代表特征函数。\lambda_k与\mu_l作为对应的权重参数。\text{Z}(x)的求和运算在所有可能的输出序列上完成。
(3) 简化形式
如果将上述K_1个转移特征函数和K_2个状态特征函数写在一起有:
其中:K=K_1 + K_2。
w_k将和整合在一起,定义为:
f_k将和整合在一起,定义为:
对f_k(y_{i-1},y_i, x,i)在各时序位置i求和,有:
(4) 矩阵表示
到此为止就是重点所在了。鉴于LSTM与CRF结合而成的模型采用了矩阵形式进行求解,在这种情况下,在这种表示方法下能够实现高效计算的同时还具有良好的泛化能力。通过对比分析我们发现,在这种表示方法下能够实现对数据特征的有效提取,并且其与HMM的表现逐渐趋同
首先,在序列前后引入起点和终点标记:y_0=start,y_{n+1}=stop。同时定义一个m \times m的矩阵M_i(x)。其中,m为标记y的取值个数,i=1, 2, ..., n+1,那么有:
经过上面三个公式表示,有:
在观看了这一堆公式之后提出了两个疑问:1. 这个矩阵具体是怎样的形态?2. 为何Z_w(x)能够以多个矩阵相乘的形式来呈现?
对于第1个问题,在假设标记可能取值范围为\{1, 2, 3\}的基础上,并引入求和符号后,则构成一个五阶矩阵。按照《统计学习方法》中CRF模型的表述方式,则应表示如下:
对于第二个问题中的求和运算而言,在所有可能的输出序列上进行计算时会得到一个结果。这里涉及到一系列矩阵的操作:将这些矩阵分别代入M_i(y_{i-1}, y_i | x)后进行相乘操作。需要注意的是,在这种情况下:
- 每个矩阵的乘积结果相当于将各矩阵对应的指数相加
- 前面那些矩阵的乘积则记录了从时刻1到t的所有可能标记序列及其组合情况
通过提取指定的行号start和列号stop可以从原始矩阵中获取所需的数据块。在实际应用中,则可以选择去除位于上三角区域中的零元素。其中M_1(x)表示行为向量,并将这n+1个矩阵依次相乘所得的结果就是一个标量值;而M_{n+1}(x)则代表列向量,并将这n+1个矩阵依次相乘所得的结果就是一个标量值;上述过程可以通过图形化的方式进行展示

图中的矩阵与HMM中的转移矩阵非常相似。
(5) 概率计算
我对前向算法进行阐述。可利用前向算法计算得出任意时刻的非规范化概率。
对每个位置i=0, 1, ..., n+1,定义前向向量\alpha_i(x):
假设标记有3种可能的取值,加上start和stop,\alpha_0(x)为5维列向量,\alpha^T_0(x)= [1, 0, 0, 0, 0]。
递推公式为:
又可表示为 公式(1) :
a_i(y_i|x)被定义为位置标记为y_i且其前部分标记序列对应的非归一化概率。简单来说,在某一特定状态下的累积可能性即为此值。当用于计算n时刻的状态累积可能性\alpha_n(x)时,则存在归一化因子Z_w(x)=\alpha^T_n(x)\cdot\mathbf{1}。该算法中的前向传播过程与之前所述求解方法具有相似性。
参考上图所示可以看出该示意图表示为多层神经网络结构其中每个隐藏层都包含一组神经元其输出作为下一层神经元的输入且每层之间的连接关系由权重矩阵M_{i+1}(x)定义。
3. LSTM+CRF
在深度学习处理命名实体识别任务时通常采用LSTM与Softmax结合的结构。其主要缺陷在于忽略了预测序列中Label之间的相互关系。例如,在词性标注中,动词后面不可能再接动词这一常识性知识被忽略了。而CRF则能够考虑相邻Label之间的关系。该论文则提出了基于LSTM与CRF结合的网络架构来解决命名实体识别问题,并如图所示展示了模型结构。然而,在该论文中并未详细阐述如何将LSTM输出结果应用于CRF模型以提高标签间的相关性。为了进一步优化模型性能,我参考了TensorFlow中的crf层实现以更好地整合两者的输出结果。

3.1 定义
首先,先看下论文中的公式定义:
给定输入序列X=(x_1, x_2, ..., x_n)经过LSTM层处理后生成输出矩阵P \in R^{n \times k}其中实体标签的数量由参数k决定而矩阵中的元素P_{i,j}表示第i个词被预测为第j类实体时所获得的概率
对于一条标记序列y = (y_1, y_2, ..., y_n),定义:
其中该转移矩阵是什么。在添加了start标记和end标记之后这将导致一个k+2阶的方阵出现。其中A_{i,j}表示从第i个标记到第j个标记的分数。对计算得分s(X, y)应用Softmax函数进行规范化处理后所得的概率预测结果即为此处所求。
其中,分母表示对所有可能的标记序列的分数求和。
在训练时,会最大化log(p(y \mid X)):
3.2 模型实现
在tensorflow的crf.py实现过程中涉及到了一些关键模块需要重点关注
# 1 计算公式(4), log(p(y| X))
crf_log_likelihood(inputs, tag_indices, sequence_lengths)
# 1.1 计算公式(4)中的第一项s(X, y), 即公式(2)
crf_sequence_score(inputs, tag_indices, sequence_lengths, transition_params)
# 1.1.1 计算公式(2)中的第一项
crf_unary_score(tag_indices, sequence_lengths, inputs)
# 1.1.2 计算公式(2)中的第二项
crf_binary_score(tag_indicese, sequence_lengths, transition_params)
# 1.2 计算公式(4)中的第二项log(sum)
crf_log_norm(inputs, sequence_lengths, transition_params)
# 1.3 用于预测,输出具有最大p(y| X)条件概率的标记序列y
viterbi_decode(score, transition_params)
#1 crf_log_likelihood :注释内容为1号CRF对数似然函数,默认情况下初始化参数并返回结果值;接收输入数据为LSTM模型的输出结果;根据预设参数设置不同的损失计算方式;通过内部机制完成数据处理并返回最终计算结果
注
def crf_log_likelihood(inputs, tag_indices, sequence_lengths, transition_params):
num_tags = inputs.get_shape()[2].value
if transition_params is None:
transition_params = vs.get_variable("transitions", [num_tags, num_tags])
sequence_scores = crf_sequence_score(inputs, tag_indices, sequence_lengths,
transition_params)
log_norm = crf_log_norm(inputs, sequence_lengths, transition_params)
# Normalize the scores to get the log-likelihood per example.
log_likelihood = sequence_scores - log_norm
return log_likelihood, transition_params
#1.1 crf_sequence_score :计算公式(4)中的第一项,即。
其中一项与y_i, y_{i+1}相关;另一项与y_i, y_{i+1}相关;两者之和即为unary scores与binary scores之总和。
具有转移特性和状态特性的概念。
在TensorFlow中的实现中不涉及特征函数的概念;而是直接计算unary scores与binary scores。
其中,LSTM输出表示每个词在不同标记上的分数,并不受前后时刻的影响。
因此,LSTM输出会被用于计算unary scores;其在概念上类似于CRF中的状态 feature 函数。
而计算binary scores(即转移分数)时则使用预定义的transition_params;这与其说是CRF中的转移 feature 函数并无区别。
def crf_sequence_score(inputs, tag_indices, sequence_lengths,
transition_params):
def _single_seq_fn():
# ...
def _multi_seq_fn():
# Compute the scores of the given tag sequence.
unary_scores = crf_unary_score(tag_indices, sequence_lengths, inputs)
binary_scores = crf_binary_score(tag_indices, sequence_lengths,
transition_params)
sequence_scores = unary_scores + binary_scores
return sequence_scores
return utils.smart_cond(
pred=math_ops.equal(inputs.shape[1].value or array_ops.shape(inputs)[1],1),
true_fn=_single_seq_fn,
false_fn=_multi_seq_fn)
#1.1.1 crf_unary_score :计算公式(2)中的一元分数。
这个函数接收一个输入inputs,即LSTM对应的输出。比如LSTM的输出为:
我们假设当前句子长度为3个词,并且最长的句子包含4个词,在本例中添加了1个填充项。每一行代表一个单词的位置,每一列对应一个特定标记y的分数。请注意:这个句子的真实标记序列tag\_indices = [1, 2, 2, 0]被定义之后,在计算过程中会用于确定每个单词所属的真实标签及其对应的分数值。具体来说,在计算过程中会将所有单词与其真实标签对应的分数相加得到unary score值。在这个案例中,请注意unary score等于每个单词分别对应的标签分数之和:第一个单词对应于tag=1的分数5分;第二个单词对应于tag=2的分数3分;第三个单词同样对应于tag=2并获得4分;最后一个填充项则被赋予了0分以避免其对最终结果产生影响的作用值。为了提高计算效率,在之前的计算中使用了二维矩阵的方式进行处理,在另一版本的设计方案中将二维矩阵转换成了扁平化的1D数组来进行运算,并通过mask操作移除了填充项的影响区域以确保结果的一致性与准确性
def crf_unary_score(tag_indices, sequence_lengths, inputs):
batch_size = array_ops.shape(inputs)[0]
max_seq_len = array_ops.shape(inputs)[1]
num_tags = array_ops.shape(inputs)[2]
flattened_inputs = array_ops.reshape(inputs, [-1])
offsets = array_ops.expand_dims(
math_ops.range(batch_size) * max_seq_len * num_tags, 1)
offsets += array_ops.expand_dims(math_ops.range(max_seq_len) * num_tags, 0)
# Use int32 or int64 based on tag_indices' dtype.
if tag_indices.dtype == dtypes.int64:
offsets = math_ops.to_int64(offsets)
flattened_tag_indices = array_ops.reshape(offsets + tag_indices, [-1])
unary_scores = array_ops.reshape(
array_ops.gather(flattened_inputs, flattened_tag_indices),
[batch_size, max_seq_len])
masks = array_ops.sequence_mask(sequence_lengths,
maxlen=array_ops.shape(tag_indices)[1],
dtype=dtypes.float32)
unary_scores = math_ops.reduce_sum(unary_scores * masks, 1)
return unary_scores
#1.1.2 crf_binary_score :计算公式(2)中的二元分数。
在计算过程中涉及二元分数的情况下,则意味着该转移矩阵transition_params会被调用。其中转移矩阵transition_params起到关键作用,并且它被定义在第1部分所述的函数中。如果没有预先提供该参数,则模型内部会自动初始化这一参数作为可训练变量参与模型优化过程。
未在实现过程中引入start标记和stop标记,并不会影响最终结果。与之前的情况类似,在计算binary score时遵循了同样的逻辑:即1->2的score值加上2->2位置上的score值等于1+3=4。同样,在二维矩阵运算中完成这一操作:源码通过将二维矩阵展平为一维数组来逐一获取每个位置上的score值并进行汇总以获得最终总分。
def crf_binary_score(tag_indices, sequence_lengths, transition_params):
# Get shape information.
num_tags = transition_params.get_shape()[0]
num_transitions = array_ops.shape(tag_indices)[1] - 1
# Truncate by one on each side of the sequence to get the start and end
# indices of each transition.
start_tag_indices = array_ops.slice(tag_indices, [0, 0],
[-1, num_transitions])
end_tag_indices = array_ops.slice(tag_indices, [0, 1], [-1, num_transitions])
# Encode the indices in a flattened representation.
flattened_transition_indices = start_tag_indices * num_tags + end_tag_indices
flattened_transition_params = array_ops.reshape(transition_params, [-1])
# Get the binary scores based on the flattened representation.
binary_scores = array_ops.gather(flattened_transition_params,
flattened_transition_indices)
masks = array_ops.sequence_mask(sequence_lengths,
maxlen=array_ops.shape(tag_indices)[1],
dtype=dtypes.float32)
truncated_masks = array_ops.slice(masks, [0, 1], [-1, -1])
binary_scores = math_ops.reduce_sum(binary_scores * truncated_masks, 1)
return binary_scores
#1.2 crf_log_norm :在公式(4)中重点研究的是CrfForwardRnnCell类中的第二项log(sum),其核心在于通过前向传播实现对log(sum)的求解过程。
def crf_log_norm(inputs, sequence_lengths, transition_params):
"""Computes the normalization for a CRF.
Args:
inputs: A [batch_size, max_seq_len, num_tags] tensor of unary potentials
to use as input to the CRF layer.
sequence_lengths: A [batch_size] vector of true sequence lengths.
transition_params: A [num_tags, num_tags] transition matrix.
Returns:
log_norm: A [batch_size] vector of normalizers for a CRF.
"""
# Split up the first and rest of the inputs in preparation for the forward
# algorithm.
first_input = array_ops.slice(inputs, [0, 0, 0], [-1, 1, -1])
first_input = array_ops.squeeze(first_input, [1])
# If max_seq_len is 1, we skip the algorithm and simply reduce_logsumexp over
# the "initial state" (the unary potentials).
def _single_seq_fn():
return math_ops.reduce_logsumexp(first_input, [1])
def _multi_seq_fn():
"""Forward computation of alpha values."""
rest_of_input = array_ops.slice(inputs, [0, 1, 0], [-1, -1, -1])
# Compute the alpha values in the forward algorithm in order to get the
# partition function.
forward_cell = CrfForwardRnnCell(transition_params)
_, alphas = rnn.dynamic_rnn(
cell=forward_cell,
inputs=rest_of_input,
sequence_length=sequence_lengths - 1,
initial_state=first_input,
dtype=dtypes.float32)
log_norm = math_ops.reduce_logsumexp(alphas, [1])
return log_norm
max_seq_len = array_ops.shape(inputs)[1]
return control_flow_ops.cond(pred=math_ops.equal(max_seq_len, 1),
true_fn=_single_seq_fn,
false_fn=_multi_seq_fn)
该系统实现了在(5)节所述的概率计算框架下的前向计算过程,在代码实现层面具体体现在__call__方法中:首先调用reduce_logsumexp函数处理求和操作(corresponding to the log-sum-exp computation),随后对求和结果执行指数运算(applying the exponential function)以恢复原始尺度;为了确保整个前向传播过程始终处于对数空间内(specifically conducted within the log space),在上述步骤之后再对结果取一次对数(taking logarithm again),从而保证了系统的数值稳定性与精度要求。这一设计思路可参见论文《 Neural Architectures for Named Entity Recognition》中的crf层详解中的详细描述。
class CrfForwardRnnCell(rnn_cell.RNNCell):
def __init__(self, transition_params):
self._transition_params = array_ops.expand_dims(transition_params, 0)
self._num_tags = transition_params.get_shape()[0].value
def __call__(self, inputs, state, scope=None):
state = array_ops.expand_dims(state, 2)
# This addition op broadcasts self._transitions_params along the zeroth
# dimension and state along the second dimension. This performs the
# multiplication of previous alpha values and the current binary potentials
# in log space.
transition_scores = state + self._transition_params
new_alphas = inputs + math_ops.reduce_logsumexp(transition_scores, [1])
# Both the state and the output of this RNN cell contain the alphas values.
# The output value is currently unused and simply satisfies the RNN API.
# This could be useful in the future if we need to compute marginal
# probabilities, which would require the accumulated alpha values at every
# time step.
return new_alphas, new_alphas
由于这个__call__方法的计算看起来不那么直观,我写了一版稍微直观一点的:
def crf_log_norm(inputs, sequence_lengths, transition_params):
first_input = array_ops.slice(inputs, [0, 0, 0], [-1, 1, -1])
first_input = array_ops.squeeze(first_input, [1])
def _multi_seq_fn():
rest_of_input = array_ops.slice(inputs, [0, 1, 0], [-1, -1, -1])
forward_cell = CrfForwardRnnCell(transition_params)
_, alphas = rnn.dynamic_rnn(
cell=forward_cell,
inputs=rest_of_input,
sequence_length=sequence_lengths - 1,
initial_state=math_ops.exp(first_input), # alpha_0
dtype=dtypes.float32)
log_norm = math_ops.log(math_ops.reduce_sum(alphas, [1])) # 最后取log
return log_norm
return _multi_seq_fn()
class CrfForwardRnnCell(rnn_cell.RNNCell):
def __init__(self, transition_params):
self._transition_params = transition_params
self._num_tags = transition_params.get_shape()[0].value
# 前向算法的计算
def __call__(self, inputs, state, scope=None):
# 计算矩阵M_{i+1}(x)
mi = math_ops.exp(inputs + self._transition_params)
# 计算alpha_{i+1}(x) = alpha_i(x) * M_{i+1}(x)
new_alphas = math_ops.matmul(state, mi)
return new_alphas, new_alphas
其实实现起来的方式多种多样。若有关于源码中为何采用 reduce_logsumexp 进行计算的问题,请随时告知。
#1.3 viterbi_decode :用于推断,在给定观测序列X的情况下推断具有最大条件概率的目标序列;其思路类似于前文所述的HMM中的viterbi算法实现过程,在此处不做详细阐述。
def viterbi_decode(score, transition_params):
trellis = np.zeros_like(score)
backpointers = np.zeros_like(score, dtype=np.int32)
trellis[0] = score[0]
for t in range(1, score.shape[0]):
v = np.expand_dims(trellis[t - 1], 1) + transition_params
trellis[t] = score[t] + np.max(v, 0)
backpointers[t] = np.argmax(v, 0)
viterbi = [np.argmax(trellis[-1])]
for bp in reversed(backpointers[1:]):
viterbi.append(bp[viterbi[-1]])
viterbi.reverse()
viterbi_score = np.max(trellis[-1])
return viterbi, viterbi_score
4. 总结
至此,我们大致了解了LSTM+CRF是如何实现的。自LSTM+CRF结构提出后,在进行 Named Entity Recognition (NER) 问题时基本都是基于原有架构进行少量调整。当时在检索相关文献时也未发现有更优方案可用,可能归因于 CRF 层的强大功能。
此外,tensorflow确实将一些常用功能进行了封装,并且即使我们不了解其内部原理,在使用时也只需调用这些功能就非常方便。这听起来就像是在做一个简单的程序而不是真正解决了一个复杂的问题。
So we’II still have to go to deeper levels if we’re trying something new…
