中文分词之HMM模型详解
关于HMM模型的介绍,网上的资料已经烂大街,但是大部分都是在背书背公式,本文在此针对HMM模型在中文分词中的应用,讲讲实现原理。
尽可能的撇开公式,撇开推导。结合实际开源代码作为例子,争取做到雅俗共赏,童叟无欺。
没有公式,就没有伤害。
模型介绍
第一次听说HMM模型是从李开复的博文论文中听说的:
李开复1988年的博士论文发表了第一个基于隐马尔科夫模型(HMM)的语音识别系统Sphinx,被《商业周刊》评为1988年美国最重要的科技发明。
出处点我
乍一听似乎很玄妙,但是其实很简单。下面是相关参数介绍,也是第一眼觉得很抽象,但是慢慢看下去随着具体含义的解释就渐渐清晰。
HMM(Hidden Markov Model): 隐式马尔科夫模型。
HMM模型可以应用在很多领域,所以它的模型参数描述一般都比较抽象,以下篇幅针对HMM的模型参数介绍直接使用它在中文分词中的实际含义来讲:
HMM的典型介绍就是这个模型是一个五元组:
- StatusSet: 状态值集合
- ObservedSet: 观察值集合
- TransProbMatrix: 转移概率矩阵
- EmitProbMatrix: 发射概率矩阵
- InitStatus: 初始状态分布
HMM模型可以用来解决三种问题:
- 参数(StatusSet,TransProbMatrix,EmitRobMatrix,InitStatus)已知的情况下,求解观察值序列。(Forward-backward算法)
- 参数(ObservedSet,TransProbMatrix,EmitRobMatrix,InitStatus)已知的情况下,求解状态值序列。(viterbi算法)
- 参数(ObservedSet)已知的情况下,求解(TransProbMatrix,EmitRobMatrix,InitStatus)。(Baum-Welch算法)
其中,第三种问题最玄乎也最不常用,第二种问题最常用,【中文分词】,【语音识别】, 【新词发现】, 【词性标注】 都有它的一席之地。所以本文主要介绍第二种问题,即【viterbi算法求解状态值序列】的方法。
五元组参数在中文分词中的具体含义
接下来我们讲实的,不讲虚的,针对中文分词应用,直接给五元组参数赋予具体含义:
StatusSet & ObservedSet
状态值集合为(B, M, E, S): {B:begin, M:middle, E:end, S:single}。分别代表每个状态代表的是该字在词语中的位置,B代表该字是词语中的起始字,M代表是词语中的中间字,E代表是词语中的结束字,S则代表是单字成词。
观察值集合为就是所有汉字(东南西北你我他...),甚至包括标点符号所组成的集合。
状态值也就是我们要求的值,在HMM模型中文分词中,我们的输入是一个句子(也就是观察值序列),输出是这个句子中每个字的状态值。 比如:
小明硕士毕业于中国科学院计算所
输出的状态序列为
BEBEBMEBEBMEBES
根据这个状态序列我们可以进行切词:
BE/BE/BME/BE/BME/BE/S
所以切词结果如下:
小明/硕士/毕业于/中国/科学院/计算/所
同时我们可以注意到:
B后面只可能接(M or E),不可能接(B or E)。而M后面也只可能接(M or E),不可能接(B, S)。
没错,就是这么简单,现在输入输出都明确了,下文讲讲输入和输出之间的具体过程,里面究竟发生了什么不可告人的秘密,请看下文:
上文只介绍了五元组中的两元【StatusSet, ObservedSet】,下文介绍剩下的三元【InitStatus, TransProbMatrix, EmitProbMatrix】。
这五元的关系是通过一个叫Viterbi
的算法串接起来, ObservedSet
序列值是Viterbi
的输入,
而StatusSet
序列值是Viterbi
的输出,
输入和输出之间Viterbi
算法还需要借助三个模型参数, 分别是InitStatus, TransProbMatrix, EmitProbMatrix, 接下来一一讲解:
InitStatus
初始状态概率分布是最好理解的,可以示例如下:
#B
-0.26268660809250016
#E
-3.14e+100
#M
-3.14e+100
#S
-1.4652633398537678
示例数值是对概率值取对数之后的结果(可以让概率相乘的计算变成对数相加),其中-3.14e+100
作为负无穷,也就是对应的概率值是0。下同。
也就是句子的第一个字属于{B,E,M,S}
这四种状态的概率,如上可以看出,E和M的概率都是0,这和实际相符合,开头的第一个字只可能是词语的首字(B),或者是单字成词(S)。
TransProbMatrix
转移概率是马尔科夫链很重要的一个知识点,大学里面学过概率论的人都知道,马尔科夫链最大的特点就是当前T=i
时刻的状态Status(i)
,只和T=i
时刻之前的n个状态有关。也就是:
{Status(i-1), Status(i-2), Status(i-3), ... Status(i - n)}
更进一步的说,HMM模型有三个基本假设(具体哪三个请看文末备注)作为模型的前提,其中有个【有限历史性假设】,也就是马尔科夫链的n=1
。即Status(i)只和Status(i-1)相关,这个假设能大大简化问题。
回过头看TransProbMatrix
,其实就是一个4x4
(4就是状态值集合的大小)的二维矩阵,示例如下:
矩阵的横坐标和纵坐标顺序是BEMS x BEMS
。(数值是概率求对数后的值,别忘了。)
-3.14e+100 -0.510825623765990 -0.916290731874155 -3.14e+100
-0.5897149736854513 -3.14e+100 -3.14e+100 -0.8085250474669937
-3.14e+100 -0.33344856811948514 -1.2603623820268226 -3.14e+100
-0.7211965654669841 -3.14e+100 -3.14e+100 -0.6658631448798212
比如TransProbMatrix[0][0]
代表的含义就是从状态B转移到状态B的概率,由TransProbMatrix[0][0]
= -3.14e+100
可知,这个转移概率是0,这符合常理。由状态各自的含义可知,状态B的下一个状态只可能是ME,不可能是BS,所以不可能的转移对应的概率都是0,也就是对数值负无穷,在此记为-3.14e+100
。
由上TransProbMatrix
矩阵可知,对于各个状态可能转移的下一状态,且转移概率对应如下:
#B
#E:-0.510825623765990,M:-0.916290731874155
#E
#B:-0.5897149736854513,S:-0.8085250474669937
#M
#E:-0.33344856811948514,M:-1.2603623820268226
#S
#B:-0.7211965654669841,S:-0.6658631448798212
EmitProbMatrix
这里的发射概率(EmitProb)其实也是一个条件概率而已,根据HMM模型三个基本假设(哪三个请看文末备注)里的【观察值独立性假设】,观察值只取决于当前状态值,也就是:
P(Observed[i], Status[j]) = P(Status[j]) * P(Observed[i]|Status[j])
其中P(Observed[i]|Status[j])
这个值就是从EmitProbMatrix
中获取。
EmitProbMatrix
示例如下:
#B
耀:-10.460283,涉:-8.766406,谈:-8.039065,伊:-7.682602,洞:-8.668696,...
#E
耀:-9.266706,涉:-9.096474,谈:-8.435707,伊:-10.223786,洞:-8.366213,...
#M
耀:-8.47651,涉:-10.560093,谈:-8.345223,伊:-8.021847,洞:-9.547990,....
#S
蘄:-10.005820,涉:-10.523076,唎:-15.269250,禑:-17.215160,洞:-8.369527...
虽然EmitProbMatrix
也称为矩阵,这个矩阵太稀疏了,实际工程中一般是将上面四行发射转移概率存储为4个Map,详见代码HMMSegment。
到此,已经介绍完HMM模型的五元参数,假设现在手头上已经有这些参数的具体概率值,并且已经加载进来,(也就是有该模型的字典了,详见HMMDict里面的hmm_model.utf8
),那么我们只剩下Viterbi
这个算法函数,这个模型就算可以开始使用了。所以接下来讲讲Viterbi
算法。
HMM中文分词之Viterbi算法
输入样例:
小明硕士毕业于中国科学院计算所
Viterbi算法计算过程如下:
定义变量
二维数组 weight[4][15],4是状态数(0:B,1:E,2:M,3:S),15是输入句子的字数。比如 weight[0][2] 代表 状态B的条件下,出现'硕'这个字的可能性。
二维数组 path[4][15],4是状态数(0:B,1:E,2:M,3:S),15是输入句子的字数。比如 path[0][2] 代表 weight[0][2]取到最大时,前一个字的状态,比如 path[0][2] = 1, 则代表 weight[0][2]取到最大时,前一个字(也就是明
)的状态是E。记录前一个字的状态是为了使用viterbi算法计算完整个
weight[4][15] 之后,能对输入句子从右向左地回溯回来,找出对应的状态序列。
使用InitStatus对weight二维数组进行初始化
已知InitStatus如下:
#B
-0.26268660809250016
#E
-3.14e+100
#M
-3.14e+100
#S
-1.4652633398537678
且由EmitProbMatrix可以得出
Status(B) -> Observed(小) : -5.79545
Status(E) -> Observed(小) : -7.36797
Status(M) -> Observed(小) : -5.09518
Status(S) -> Observed(小) : -6.2475
所以可以初始化 weight[i][0] 的值如下:
weight[0][0] = -0.26268660809250016 + -5.79545 = -6.05814
weight[1][0] = -3.14e+100 + -7.36797 = -3.14e+100
weight[2][0] = -3.14e+100 + -5.09518 = -3.14e+100
weight[3][0] = -1.4652633398537678 + -6.2475 = -7.71276
注意上式计算的时候是相加而不是相乘,因为之前取过对数的原因。
遍历句子计算整个weight二维数组
//遍历句子,下标i从1开始是因为刚才初始化的时候已经对0初始化结束了
for(size_t i = 1; i < 15; i++)
{
// 遍历可能的状态
for(size_t j = 0; j < 4; j++)
{
weight[j][i] = MIN_DOUBLE;
path[j][i] = -1;
//遍历前一个字可能的状态
for(size_t k = 0; k < 4; k++)
{
double tmp = weight[k][i-1] + _transProb[k][j] + _emitProb[j][sentence[i]];
if(tmp > weight[j][i]) // 找出最大的weight[j][i]值
{
weight[j][i] = tmp;
path[j][i] = k;
}
}
}
}
如此遍历下来,weight[4][15]
和 path[4][15]
就都计算完毕。
确定边界条件和路径回溯
边界条件如下:
对于每个句子,最后一个字的状态只可能是 E 或者 S,不可能是 M 或者 B。
所以在本文的例子中我们只需要比较 weight[1(E)][14]
和 weight[3(S)][14]
的大小即可。
在本例中:
weight[1][14] = -102.492;
weight[3][14] = -101.632;
所以 S > E,也就是对于路径回溯的起点是 path[3][14]
。
回溯的路径是:
SEBEMBEBEMBEBEB
倒序一下就是:
BE/BE/BME/BE/BME/BE/S
所以切词结果就是:
小明/硕士/毕业于/中国/科学院/计算/所
到此,一个HMM模型中文分词算法过程就阐述完毕了。
也就是给定我们一个模型,我们对模型进行载入完毕之后,只要运行一遍Viterbi
算法,就可以找出每个字对应的状态,根据状态也就可以对句子进行分词。
模型的训练问题
以上讲的前提是基于模型来进行切词,也就是假设我们手头上的HMM模型已经是被训练好了的(也就是InitStatus, TransProbMatrix, EmitProbMatrix这三个模型的关键参数都是已知的),没有涉及到这三个参数是如何得到的。 这三个参数其实也是基于已分词完毕的语料进行统计计算,计算出相应的频率和条件概率就可以算出这三个参数。具体在此就不讲了。
备注
HMM模型的三个基本假设如下:
- 有限历史性假设:
P(Status[i]|Status[i-1],Status[i-2],... Status[1]) = P(Status[i]|Status[i-1])
- 齐次性假设(状态和当前时刻无关):
P(Status[i]|Status[i-1]) = P(Status[j]|Status[j-1])
- 观察值独立性假设(观察值只取决于当前状态值):
P(Observed[i]|Status[i],Status[i-1],...,Status[1]) = P(Observed[i]|Status[i])
源码参考
#ifndef CPPJIBEA_HMMSEGMENT_H #define CPPJIBEA_HMMSEGMENT_H #include <iostream> #include <fstream> #include <memory.h> #include <cassert> #include "HMMModel.hpp" #include "SegmentBase.hpp" namespace cppjieba { class HMMSegment: public SegmentBase { public: HMMSegment(const string& filePath) { model_ = new HMMModel(filePath); } HMMSegment(const HMMModel* model) : model_(model), isNeedDestroy_(false) { } ~HMMSegment() { if (isNeedDestroy_) { delete model_; } } void Cut(const string& sentence, vector<string>& words) const { PreFilter pre_filter(symbols_, sentence); PreFilter::Range range; vector<Unicode> uwords; uwords.reserve(sentence.size()); while (pre_filter.HasNext()) { range = pre_filter.Next(); Cut(range.begin, range.end, uwords); } TransCode::Encode(uwords, words); } void Cut(Unicode::const_iterator begin, Unicode::const_iterator end, vector<Unicode>& res) const { Unicode::const_iterator left = begin; Unicode::const_iterator right = begin; while (right != end) { if (*right < 0x80) { if (left != right) { InternalCut(left, right, res); } left = right; do { right = SequentialLetterRule(left, end); if (right != left) { break; } right = NumbersRule(left, end); if (right != left) { break; } right ++; } while (false); res.push_back(Unicode(left, right)); left = right; } else { right++; } } if (left != right) { InternalCut(left, right, res); } } private: // sequential letters rule Unicode::const_iterator SequentialLetterRule(Unicode::const_iterator begin, Unicode::const_iterator end) const { Rune x = *begin; if (('a' <= x && x <= 'z') || ('A' <= x && x <= 'Z')) { begin ++; } else { return begin; } while (begin != end) { x = *begin; if (('a' <= x && x <= 'z') || ('A' <= x && x <= 'Z') || ('0' <= x && x <= '9')) { begin ++; } else { break; } } return begin; } // Unicode::const_iterator NumbersRule(Unicode::const_iterator begin, Unicode::const_iterator end) const { Rune x = *begin; if ('0' <= x && x <= '9') { begin ++; } else { return begin; } while (begin != end) { x = *begin; if ( ('0' <= x && x <= '9') || x == '.') { begin++; } else { break; } } return begin; } void InternalCut(Unicode::const_iterator begin, Unicode::const_iterator end, vector<Unicode>& res) const { vector<size_t> status; Viterbi(begin, end, status); Unicode::const_iterator left = begin; Unicode::const_iterator right; for (size_t i = 0; i < status.size(); i++) { if (status[i] % 2) { //if (HMMModel::E == status[i] || HMMModel::S == status[i]) right = begin + i + 1; res.push_back(Unicode(left, right)); left = right; } } } void Viterbi(Unicode::const_iterator begin, Unicode::const_iterator end, vector<size_t>& status) const { size_t Y = HMMModel::STATUS_SUM; size_t X = end - begin; size_t XYSize = X * Y; size_t now, old, stat; double tmp, endE, endS; vector<int> path(XYSize); vector<double> weight(XYSize); //start for (size_t y = 0; y < Y; y++) { weight[0 + y * X] = model_->startProb[y] + model_->GetEmitProb(model_->emitProbVec[y], *begin, MIN_DOUBLE); path[0 + y * X] = -1; } double emitProb; for (size_t x = 1; x < X; x++) { for (size_t y = 0; y < Y; y++) { now = x + y*X; weight[now] = MIN_DOUBLE; path[now] = HMMModel::E; // warning emitProb = model_->GetEmitProb(model_->emitProbVec[y], *(begin+x), MIN_DOUBLE); for (size_t preY = 0; preY < Y; preY++) { old = x - 1 + preY * X; tmp = weight[old] + model_->transProb[preY][y] + emitProb; if (tmp > weight[now]) { weight[now] = tmp; path[now] = preY; } } } } endE = weight[X-1+HMMModel::E*X]; endS = weight[X-1+HMMModel::S*X]; stat = 0; if (endE >= endS) { stat = HMMModel::E; } else { stat = HMMModel::S; } status.resize(X); for (int x = X -1 ; x >= 0; x--) { status[x] = stat; stat = path[x + stat*X]; } } const HMMModel* model_; bool isNeedDestroy_; }; // class HMMSegment } // namespace cppjieba #endif