接上一篇的中文分词,这一篇将发射概率矩阵,状态转移矩阵和Viterbi算法都用上了,然后使用代码实现最简单的中文分词功能.相比较于上一篇的遍历所有可能组合,这一篇使用了Viterbi算法排除掉了不符合要求的组合,减少了不必要的消耗.
前期准备
- 最简单明了地介绍Viterbi算法 如何通俗地讲解 viterbi 算法?
功能说明
如果一句话里面的所有字,都能分类到 BMES 里面(可重复),那么就可对它进行分词。
例:{陈佩斯吃西瓜}
分类
词头 B: 陈佩斯吃西瓜 比如 陈述,佩服,斯文,吃饭,西方,瓜壳
词中 M: 佩 比如 陈佩斯,其他字不行,我说的>_<。
词尾 E:佩斯吃西瓜 比如 钦佩,陈佩斯,好吃,东西,木瓜
单字 S:吃
挑选代码执行结果里面概率最大的组合
BMESBE 即 词头词中词尾 单字成词 词头词尾
对照 {陈佩斯吃西瓜} 进行分词
结果 {陈佩斯 吃 西瓜}
- 这一篇使用了Viterbi算法实现最大概率路径查找,避免了遍历所有的组合.
- 原理就是从左到右,对于每个字都取最大概率的隐藏状态.
- 比如,对于第一个字 {陈},它对应于4种隐藏状态 {B, M, E, S},然后概率分别为{0.5, 0, 0, 0.5},因为作为句子的开头,只能是{B, S}中的一个.这个概率也是初始状态概率.
- 对于第二个字 {佩}, 它也有4种隐藏状态 {B, M, E, S},和第一个字可能的状态组合起来就是{BB, BM, BE, BS, SB, SM, SE, SS}这几种,排除掉不可能的情况,剩下 {BM, BE, SB, SS}这三个.
- {BM} 的概率为选择一个 B 骰子,并骰一次,正好是 {陈} 字;再选择M 骰子,并骰一次,正好是 {佩} 字的概率,计算过程为 0.5 * 1/6 * 0.6 * 1.
- 0.5来自于代码里面的 initStatus[0], 1/6 来自于 emitProbMatrix[0][0], 0.6 来自于 transProbMatrix2[0][1], 1 来自于 emitProbMatrix[1][1].
Viterbi算法计算的最大概率路径
代码实现
/**
* 使用Viterbi算法,减少循环次数.
*/
@Test
public void testHMM2() {
// 1.隐藏状态集合,包括词头,词中、词尾、单字成词。
String[] status = new String[]{"B", "M", "E", "S"};
// 2.观察状态集合,即我们需要分词的句子。
String str = "陈佩斯吃西瓜";
// 3.发射概率矩阵,也称混淆矩阵, 包含了给定隐马尔科夫模型的某一个特殊的隐藏状态,观察到的某个观察状态的概率,通过语料训练获得。
// 4*6 矩阵说明: 行号依次是 4 种隐藏状态{BMES},列号依次是 6 种观察状态{陈佩斯吃西瓜},且每列之和为 1。
// 第一行第一列值为 1/6,它的意思是对于 B 状态即词头而言,有{陈佩斯吃西瓜}这 6 种观察状态,{陈}占其中 1/6。
// 第二行第二列值为 1,它的意思是对于 M 状态即词头而言,有{佩}这 1 种观察状态,{佩}占其中 1/1,其他都为0。
double[][] emitProbMatrix = new double[][] {
{(double)1/6,(double)1/6,(double)1/6,(double)1/6,(double)1/6,(double)1/6},
{0,1,0,0,0,0},
{0,0.2,0.2,0.2,0.2,0.2},
{0,0,0,1,0,0}
};
// 4.初始状态概率,一句话中第一个字符对应的状态概率
double initStatus[] = new double[] {0.5, 0, 0, 0.5};
// 状态转移矩阵,状态集合间的相互转换概率,4*4矩阵,通过语料训练获得。
double transProbMatrix2[][] = new double[][] {
{0, 0.6, 0.4, 0},
{0, 0.4, 0.6, 0},
{0.4, 0, 0, 0.6},
{0.6, 0, 0, 0.4}
};
// 5.模拟分析过程.
// 使用Viterbi算法计算最大概率
// 第二个字是B的可能路径 BB, MB, EB, SB
double sb = 0.6;
// 第二个字是M的可能路径 BM, MM, EM, SM
double bm = 0.6;
// 第二个字是E的可能路径 BE, ME, EE, SE
double me = 0.6;;
// 第二个字是S的可能路径 BS, MS, ES, SS
double es = 0.6;
// 第三个字是B的可能路径 sbb, bmb, meb, esb
double esb = 0.6 * 0.6;
// 第三个字是M的可能路径 sbm, bmm, mem, esm
double sbm = 0.6 * 0.6;
//double bmm = 0.6 * 0.4;
// 第三个字是E的可能路径 sbe, bme, mee, ese
//double sbe = 0.6 * 0.4;
double bme = 0.6 * 0.6;
// 第三个字是S的可能路径 sbs, bms, mes, ess
double mes = 0.6 * 0.6;
//double ess = 0.6 * 0.4;
// 第四个字是B的可能路径 esbb, sbmb, bmeb, mesb
//double bmeb = 0.6 * 0.6 * 0.4;
double mesb = 0.6 * 0.6 * 0.6;
// 第四个字是M的可能路径 esbm, sbmm, bmem, mesm
double esbm = 0.6 * 0.6 * 0.6;
//double sbmm = 0.6 * 0.6 * 0.4;
// 第四个字是E的可能路径 esbe, sbme, bmee, mese
//double esbe = 0.6 * 0.6 * 0.4;
double sbme = 0.6 * 0.6 * 0.6;
// 第四个字是S的可能路径 esbs, sbms, bmes, mess
double bmes = 0.6 * 0.6 * 0.6;
//double mess = 0.6 * 0.6 * 0.4;
// 第四个字是B的可能路径 mesbb, esbmb, sbmeb, bmesb
//double sbmeb = 0.6 * 0.6 * 0.6 * 0.4;
double bmesb = 0.6 * 0.6 * 0.6 * 0.6;
// 6.代码实现
// 存储所有可能的路径,有四种
List<Route> list = new ArrayList<Route>();
// 一共六个字
for (int i = 0; i < 6; i++) {
// 第一个字,需要进行初始化
if (list.size() == 0) {
// 根据Viterbi算法,假设不存在两条完全一样的路径,那么一共有四条可能路径.
for (int j = 0; j < initStatus.length; j++) {
Route route = new Route();
route.setPath(j + "");
route.setValue(initStatus[j] * emitProbMatrix[j][i]);
list.add(route);
}
} else {
// 一共有多少种可能路径
for (int j = 0; j < list.size(); j++) {
Route route = list.get(j);
String path = route.getPath();
// 获取上一个隐藏状态
int lastStatus = Integer.parseInt(path) % 10;
// 用于记录状态转移最大的概率
double maxPosibility = 0;
// 用于记录最有可能转移的状态
int hiddenStatus = 0;
// 一共四种隐藏状态
for (int k=0; k<4; k++) {
// 根据状态转移矩阵和混淆矩阵挑选最有可能的转移情况
if (transProbMatrix2[lastStatus][k] * emitProbMatrix[k][i] > maxPosibility) {
maxPosibility = transProbMatrix2[lastStatus][k] * emitProbMatrix[k][i];
hiddenStatus = k;
}
}
// 将最可能的隐藏状态添加到路径当中.
route.setPath(path + hiddenStatus);
// 将最可能的路径的概率保存下来,用于后面比较
route.setValue(route.getValue() * maxPosibility);
}
}
}
System.out.println("===================================================");
for (int j = 0; j < list.size(); j++) {
Route route = list.get(j);
if (route.getValue() == 0) {
continue;
}
String path = route.getPath();
char[] chars = path.toCharArray();
String allStatus = status[Integer.parseInt(chars[0] + "")] + status[Integer.parseInt(chars[1] + "")] +
status[Integer.parseInt(chars[2] + "")] +
status[Integer.parseInt(chars[3] + "")] +
status[Integer.parseInt(chars[4] + "")] +
status[Integer.parseInt(chars[5] + "")];
System.out.println(allStatus + "\t" + route.getValue());
}
}
/**
* 路径类
*/
public class Route {
// 包含Viterbi算法完整的路径
private String path;
// 当前路径对应的概率
private double value;
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public double getValue() {
return value;
}
public void setValue(double value) {
this.value = value;
}
}
总结
- 对于6个字的句子,上一篇需要循环 4^6=4096 次,这篇只需要循环 64 + 64=48 次,减少的次数还是很可观的.
- 对于句子分词的代码实现主要还是计算各种组合的最大概率.
- 而对这个概率影响最大的因素一个是隐藏状态相互之间的转换概率,例 B 可以 转 M, E, 但不能转 B, S,这样就造成了不同的转换概率,最后的结果就是形成了 transProbMatrix2 这个矩阵.
- 另一个因素就是发射概率矩阵,也称混淆矩阵, 说人话就是有一个骰子 B, 它有六个面, 分别是 {陈, 佩, 斯, 吃, 西, 瓜}, 然后对于这六个字骰中的概率都是 1/6.
- 对于另一个骰子 E, 它有五个面,分别是 {佩, 斯, 吃, 西, 瓜}, 骰中的概率都是 1/5.
- 根据这些骰子(隐藏状态)骰出的结果,可以建立一个隐藏状态对应可观察状态的概率矩阵,也就是代码中的发射概率矩阵 emitProbMatrix.
- 其实初始状态矩阵也是一个因素,这三个矩阵的数值是通过语料训练获得的,就是找一大堆的句子,去统计每个字对应的隐藏状态的概率.
- 代码有个问题,就是这边是默认不同路径的概率不会相同,所以可以用四条路径来囊括所有的最大概率路径组合,事实上可能有两条路径它的概率就是一样的,这样的话,路径组合还不止 48 种.