本文按照调用顺序抽丝剥茧地分析了CRF++的代码,详细注释了主要函数,并指出了代码与理论公式的对应关系。内容包括拟牛顿法的目标函数、梯度、L2正则化、L-BFGS优化、概率图构建、前向后向算法、维特比算法等。背景知识请参考《条件随机场》。
先从训练开始说起吧
该函数解析命令行之后调用:
该函数会调用:
该函数先读取特征模板和训练文件
这个open方法并没有构建训练实例,而是简单地解析特征模板和统计标注集:
回到learn方法中来,做完了这些诸如IO和参数解析之后,learn方法会根据算法参数的不同而调用不同的训练算法。取最常用的说明如下:
创建多个CRFEncoderThread,平均地将句子分给每个线程。每个线程的工作其实只是计算梯度:
梯度计算时,先构建网格:
由于CRF是概率图模型,所以有一些图的特有概念,如顶点和边:
buildLattice方法调用rebuildFeatures对每个时刻的每个状态分别构造边和顶点:
这也就是大家经常看到的类似如下的图:
然后计算每个节点和每条边的代价(也就是特征函数乘以相应的权值,简称代价):
其中fvector是当前命中特征函数的起始id集合,对于每个起始id,都有连续标签个数种y值;n->y是当前时刻的标签,由于每个特征函数都必须同时接受x和y才能决定输出1或0,所以要把两者加起来才能确定最终特征函数的id。用此id就能在alpha向量中取到最终的权值,将权值累加起来,乘以一个倍率(也就是所谓的代价参数cost_factor),得到最终的代价cost。
对于边来说,也是类似的,只不过对每个起始id,都有连续标签个数平方种y值组合。
这部分对应
网格建完了,就可以在这个图上面跑前向后向算法了:
该方法依次计算前后向概率:
计算前向概率的具体实现是:
其中cost是我们刚刚计算的当前节点的M_i(x),而alpha则是当前节点的前向概率。lpath是入边,如代码和图片所示,一个顶点可能有多个入边。
对应:
后向概率同理略过。
前后向概率都有了之后,计算规范化因子:
对应着
关于函数logsumexp的意义,请参考《计算指数函数的和的对数》。
于是完成整个前后向概率的计算。
所谓的节点期望值指的是节点对应的特征函数关于条件分布
的数学期望。
calcExpectation具体实现是:
第一个for对应下式的求和
概率求和意味着得到期望。
第二个for对应边的期望值。
对应下式的求和
这样就得到了条件分布的数学期望:
–expected表示模型期望(条件分布)减去观测期望,得到目标函数的梯度:
有人可能要问了,expected的确存的是条件分布的期望,但观测期望还没计算呢,把条件分布的期望减一是干什么?
这是因为对观测数据(训练数据)来讲,它一定是对的,也就是在y!=answer_[i]的时候概率为0,在y=answer_[i]的时候概率为1,乘以特征函数的输出1,就等于1,这就是观测期望。
紧接着gradient函数还顺便调了一下TaggerImpl::viterbi:
void TaggerImpl::viterbi(){ for (size_t i = 0; i < x_.size(); ++i) { for (size_t j = 0; j < ysize_; ++j) { double bestc = -1e37; Node *best = 0; const std::vector<Path *> &lpath = node_[i][j]->lpath; for (const_Path_iterator it = lpath.begin(); it != lpath.end(); ++it) { double cost = (*it)->lnode->bestCost + (*it)->cost + node_[i][j]->cost; if (cost > bestc) { bestc = cost; best = (*it)->lnode; } } node_[i][j]->prev = best; node_[i][j]->bestCost = best ? bestc : node_[i][j]->cost; } } double bestc = -1e37; Node *best = 0; size_t s = x_.size() - 1; for (size_t j = 0; j < ysize_; ++j) { if (bestc < node_[s][j]->bestCost) { best = node_[s][j]; bestc = node_[s][j]->bestCost; } } for (Node *n = best; n; n = n->prev) { result_[n->x] = n->y; } cost_ = -node_[x_.size() - 1][result_[x_.size() - 1]]->bestCost;}
其中prev构成一个前驱数组,在动态规划结束后通过prev回溯最优路径的标签y,存放于result数组中。
跑viterbi算法的目的是为了评估当前模型的准确度,以辅助决定是否终止训练。
为了防止过拟合,CRF++采用了L1或L2正则化:
if (orthant){ // L1 for (size_t k = 0; k < feature_index->size(); ++k) { thread[0].obj += std::abs(alpha[k] / C); if (alpha[k] != 0.0) { ++num_nonzero; } }}else{ num_nonzero = feature_index->size(); for (size_t k = 0; k < feature_index->size(); ++k) { thread[0].obj += (alpha[k] * alpha[k] / (2.0 * C)); thread[0].expected[k] += alpha[k] / C; }}
以L2正则为例,L2正则在目标函数上加了一个正则项:
+
其中,
是一个常数,在CRF++中其平方被称作cost-factor,
控制着惩罚因子的强度。可见要最小化目标函数,正则化项
也必须尽量小才行。模型参数的平方和小,其复杂度就低,于是就不容易过拟合。关于L1、L2正则化推荐看Andrew Ng的ML公开课。
目标函数加了一项
之后,梯度顺理成章地也应加上
的导数:
+
这也就是代码中为什么要自加这两项的原因了:
thread[0].obj += (alpha[k] * alpha[k] / (2.0 * C)); thread[0].expected[k] += alpha[k] / C;
梯度和损失函数有了,之后就是通用的凸函数LBFGS优化了。CRF++直接将这些参数送入一个LBFGS模块中:
if (lbfgs.optimize(feature_index->size(), &alpha[0], thread[0].obj, &thread[0].expected[0], orthant, C) <= 0){ return false;}
据说这个模块是用一个叫f2c的工具从FORTRAN代码转成的C代码,可读性并不好,也就不再深入了。
// lbfgs.c was ported from the FORTRAN code of lbfgs.m to C// using f2c converter//// http://www.ece.northwestern.edu/~nocedal/lbfgs.html
有兴趣的话看看《数值优化:理解L-BFGS算法》即可。
预测就简单多了,主要对应下列方法:
bool TaggerImpl::parse(){ CHECK_FALSE(feature_index_->buildFeatures(this)) << feature_index_->what(); if (x_.empty()) { return true; } buildLattice(); if (nbest_ || vlevel_ >= 1) { forwardbackward(); } viterbi(); if (nbest_) { initNbest(); } return true;}
主要的方法也就是建立网格和维特比这两个,由于前面训练的时候已经分析过,这里就不再赘述了。