从大学毕业到工作的开始几年,一直觉得大学期间学的线性代数,离散数学,概率论简直是浪费时间。
那时候实际做的代码,大部分都是数据进销存。数据输入到数据库介质中的转换,CS,BS架构都写过一些。总觉得现实生活中的逻辑,基本就是柴米油盐那么点东西,根本不需要复杂的算法。最多用点排序算是最给面子了。
真正接触算法的魅力,是在写游戏的时候。那时候写寻路算法,第一次听说A*,不喜欢用别人写好的,于是自己实现了一遍,还真别说,第一次写的很吃力。这些用到的算法,基本都是大学的知识(那时候几乎全还给老师了),才忽然意识到,这些知识原来可以这么用啊。于是开始对之前学的离散数学有了点好感。
后来工作有机会写了一个App Stone的爬虫,那时候Ipone2刚刚开始卖。抓取数据,与之前的数据形成对比,显示软件更新频率,价格曲线图,搜集降价推荐。新出软件,发现在接近50万个软件中,做比较是一件很困难的事情,每天的数据要一一比对,甚至之前几天的历史价格进行比对。以及根据评论量生成对应的数据曲线。才发现自己的智商不够用了。(后来才知道,这个叫做数据挖掘)。当时没有什么趁手的工具,几乎自己实现了一遍,虽然有些地方写的非常笨拙,但是真正的开始注重算法在数据筛选中的运用。
可是到了那时候,我还是没有入算法的门。
直到我开始尝试些一个分词工具,那时候工作需要,需要写一个分词筛选器,自己一直用C++,那时候,没有好的C分词工具,github还没有开始流行,网上的资料也不多。当时Java有Lucene,被Java藐视了,于是一怒之下,给自己立了一个小目标,一个短小精悍的C++分词器,并不是重复造轮子,而是,就是想实现一个更好使的,赌的就是一口气。
当时用了Tire树实现了一个分词器,600多行代码,就是之前的那个帖子所说的那个。
但是还是不能在商业中运用,为什么?新词层出不穷,我真的是无力一点点的去增加词库新词。如果这样的话,别的工作别做了。有没有办法自动识别新词呢?
当时google还在,自己的E文很差,当时有几篇很好的论文被我忽略了,比较可惜。可惜老天爷还是照顾我的,最终被我找到了李开复的一篇关于自然语言的论文。
这里,我知道了,其实中文的词汇库,在没有字典的情况下,也是可以把一个句子切分出来的。
这就是马尔科夫算法。(我一直觉得,这个算法足以和图灵比肩),后来发现,确实这个算法的论文作者和图灵是一个级别的人物。
那么,什么是马尔科夫算法?你可以去知乎,去百度知识搜索。
但是我觉得那些描述,可能把一个简单的事情讲复杂了。实际上它非常的简单好理解。
简单到就在我们的生活中,我们几乎时时刻刻在用,只是你不知道,这个过程实际可以翻译成一个马尔科夫过程。
不相信?那么我举个例子好了。
今天我在看新闻,"由于国际油价上涨,今天晚上24点后,汽油涨4毛钱。"
经常开车的你,在心里问候某些人的时候,肯定第一时间想到了,"不行,要在涨价前去加满油。不过,可能加油站会很多车,要多花点时间排队。"
这就是一个典型的马尔科夫过程。
油价上涨和加油站的车辆多少看上去是完全不同的两个事件,但是在现实生活中确是有着隐形的关联。(其实机器学习并不那么高深,所谓的机器大脑,目前阶段说穿了就一句话,"用概率学解释当前的事件是否可以映射成为另一个事件。)
比如,油价上涨是一个概率事件,那么,与之相对,车辆去排队加油也是一个概率事件。单独的来看,这之间没什么必然联系。然而,这里存在着一个隐形的链条,那就是,我们每次听说油价上涨的时候,路过加油站都能看到车排队。这就是一个隐形的关系。只不过,我们的大脑一瞬间完成了这个关联并得出结论,导致我们本身忽略了思考这个过程是怎么得来的。
再举一个例子。
最近雾霾是一个大问题,害的人人都在戴口罩。你可能会想,又雾霾,肯定高速都封闭了,火车票买不到(请怪12306),汽车票估计也买不到。
这又是一个马尔科夫过程。
雾霾,火车票和汽车票的订票没有直接关系。可以看成独立的事件,但是之间有一层隐形的关系链条。
只不过我们的大脑很厉害,在想这些问题的时候几乎是秒过,而我们常常忽略了其中的过程,为什么会如此。
你可能会问,这些和分词有什么关系?
当然有,分词实际上,就是把以一个个的文字(雾霾,汽油涨价),与词(汽车票难定,加油站排队)映射起来的过程。
好了,我们来看看,如果在没有词库的基础上,我们是怎么做到分词的。
首先,凭空想象肯定还是不行的,我们还是需要一些基础的字典(请注意,这里是字典,不是词典)。
字典里包括什么呢?
我们知道,一个句对应中的文字的位置,可以大致分为4种。
B 字在句的首部(Begin)
M 字在句的中部(Middle)
E 字在句的尾部(End)
S 字不在句中被包含(Single)
其实,还可以继续细化。状态不一定只有4种。可以是6种,比如B B1 M E1 E2 S等等。
我们可以把所有的字和词都总结一下,看看每个字在全部已知语句中的含量。
比如,我让计算机去读一本《红楼梦》,把红楼梦中的所有句子全部拆出来。并全部开始作为语句训练的籽料。
那么,我们会得出类似以下结果,比如:"见"字
(B)见:-7.475730(作为句子的Begin,它出现的概率是7.475730)
(E)见:-6.240821(作为句子的End,它的概率是6.240821)
(M)见:-7.354118(作为句子的Middle,它的概率是7.354118)
(S)见:-6.230461(作为单独成句的概率,它的概率是6.230461)
以一篇文章全部出现文字的概率为100000,那么这些多少就能说明一个字在全部句子中的分量。
如果我让计算机去学习更多的著作,那么理论上,就越接近一个合理的值。
目前,字典的训练,大多以人民日报的文章为准,因为那里的错别字最少,词汇使用最准确。
仔细想想,通过我们的不断的训练,我们得到了一个接近实际使用率的字典概率,这里包含了每个文字的BEMS概率。这是不是一个模糊化的过程?(计算机概率论的核心,就是把一个复杂的事务变的简单,比如无限的句子,抽取每个状态的概率。这个状态是有限的,所以,实际上,我们在做的就是把复杂的问题去掉不关心的枝节,只保留最关心的状态,并记录这个状态可能出现的概率。这就是状态表)
其实这个表隐藏了一个关系,一个稍纵即逝的关系(计算机不像人脑,它是死的,需要你指定这个关系)
(B)见:-7.475730(作为句子的Begin,它出现的概率是7.475730)
(E)见:-6.240821(作为句子的End,它的概率是6.240821)
(M)见:-7.354118(作为句子的Middle,它的概率是7.354118)
(S)见:-6.230461(作为单独成句的概率,它的概率是6.230461)
红字就是隐藏的关系,代表这个概率所对应的模糊化的状态值。
我们还需要什么呢?
字典还需要,所有字(不区分任何文字),在句子中作为头和位的总概率。
对应这样一个矩阵表
B E M S
B -3.14e+100 -0.510825623765990 -0.916290731874155 -3.14e+100
E -0.5897149736854513 -3.14e+100 -3.14e+100 -0.8085250474669937
M -3.14e+100 -0.33344856811948514 -1.2603623820268226 -3.14e+100
S -0.7211965654669841 -3.14e+100 -3.14e+100 -0.6658631448798212
任何一个字,在前面是一个B的时候,后面不可能是B和S,只可能是M和E。以此类推。得到一个状态和状态之间的映射概率关系。
有这个还不行,
还需要最后一个初始关系,也就是说,你阅读过的所有句子,作为第一个字,对应BEMS的概率。
-0.26268660809250016 -3.14e+100 -3.14e+100 -1.4652633398537678
我们可以看到,在这里E和M是-3.14e+100(无穷小,也就是概率为0,句子中的第一个字肯定不可能是中间字和结尾字)
好了,有了这个关系,基础的原料我们就有了。
让我们来造词吧。(C++一共100来行代码实现马尔科夫过程)
void CHmmDict::Viterbi(const char* pData, int nLen, vector<_rune>& objRuneList, vector& objResList)
{
//首先把句子拆成一个个字
Sentance_To_Rune(pData, nLen, objRuneList);
printf("[CHmmDict::Viterbi]objRuneList Count=%d.\n", objRuneList.size());
int nRowCount = (int)objRuneList.size();
int nColCount = (int)RUNE_POS_ALL;
int nMatrixCount = nRowCount * nColCount;
//马尔科夫模型
//组建显式矩阵(字权重矩阵)
vector objWeightMatrix(nMatrixCount);
//组建隐式矩阵(字状态矩阵)
vector objStatusMatrix(nMatrixCount);
//显式矩阵第一行,第一个字初始状态的概率,对应BEMS
//第一个字的概率为 初始概率 + 字的初始概率
for (int nCol = 0; nCol < nColCount; nCol++)
{
if(nCol == RUNE_POS_B)
{
objWeightMatrix[0 + nCol * nRowCount] = m_dbStart[nCol] + Get_Rune_Prob(objRuneList[0], m_hashMapB);
objStatusMatrix[0 + nCol * nRowCount] = -1;
}
else if(nCol == RUNE_POS_E)
{
objWeightMatrix[0 + nCol * nRowCount] = m_dbStart[nCol] + Get_Rune_Prob(objRuneList[0], m_hashMapE);
objStatusMatrix[0 + nCol * nRowCount] = -1;
}
else if(nCol == RUNE_POS_M)
{
objWeightMatrix[0 + nCol * nRowCount] = m_dbStart[nCol] + Get_Rune_Prob(objRuneList[0], m_hashMapM);
objStatusMatrix[0 + nCol * nRowCount] = -1;
}
else
{
objWeightMatrix[0 + nCol * nRowCount] = m_dbStart[nCol] + Get_Rune_Prob(objRuneList[0], m_hashMapS);
objStatusMatrix[0 + nCol * nRowCount] = -1;
}
}
//填充矩阵的其余部分(按字的顺序)
for(int nRow = 1; nRow < nRowCount; nRow++)
{
for (int nCol = 0; nCol < nColCount; nCol++)
{
int nCurrPos = nRow + nCol*nRowCount;
objWeightMatrix[nCurrPos] = MIN_DOUBLE;
objStatusMatrix[nCurrPos] = RUNE_POS_E;
double dbCurrProb = MIN_DOUBLE;
if(nCol == RUNE_POS_B)
{
dbCurrProb = Get_Rune_Prob(objRuneList[nRow], m_hashMapB);
}
else if(nCol == RUNE_POS_E)
{
dbCurrProb = Get_Rune_Prob(objRuneList[nRow], m_hashMapE);
}
else if(nCol == RUNE_POS_M)
{
dbCurrProb = Get_Rune_Prob(objRuneList[nRow], m_hashMapM);
}
else
{
dbCurrProb = Get_Rune_Prob(objRuneList[nRow], m_hashMapS);
}
//寻找和上一个字的对应关系,取概率最高的关系作为当前概率(算法核心)
for (int nPreCol = 0; nPreCol < nColCount; nPreCol++)
{
int nOldPos = nRow - 1 + nPreCol * nRowCount;
double dbTemp = objWeightMatrix[nOldPos] + m_dbTransProb[nCol + nPreCol*nColCount] + dbCurrProb;
if (dbTemp > objWeightMatrix[nCurrPos])
{
objWeightMatrix[nCurrPos] = dbTemp;
objStatusMatrix[nCurrPos] = nPreCol;
}
}
}
}
//打印隐式矩阵
/*
printf("=============(Status Matrix)=================\n");
for(int nRow = 0; nRow < nRowCount; nRow++)
{
for (int nCol = 0; nCol < nColCount; nCol++)
{
int nCurrPos = nRow + nCol*nRowCount;
printf("%d ", objStatusMatrix[nCurrPos]);
}
printf("\n");
}
printf("=============(Status Matrix)=================\n");
*/
//获得最后末尾的S和E,因为末尾的字只可能是这两个状态之一
double dbEndE = objWeightMatrix[nRowCount - 1 + RUNE_POS_E*nRowCount];
double dbEndS = objWeightMatrix[nRowCount - 1 + RUNE_POS_S*nRowCount];
short sStat = RUNE_POS_E;
if(dbEndE < dbEndS)
{
sStat = RUNE_POS_S;
}
objResList.resize(nRowCount);
for(int i = nRowCount - 1; i >= 0; i--)
{
objResList[i] = sStat;
sStat = objStatusMatrix[i + sStat*nRowCount];
}
}
这里的核心思想就是
我先把一个语句拆成若干个字(因为计算机中,中文并不是占据1个字节,在GBK下是2个字节,在utf8下是3个字节)
然后根据每个字,生成如下的矩阵(比如句子是"哪里见过你朋友")
B E M S
哪
里
见
过
你
朋
友
在这里,每个字,都根据和上一个字的BEMS关系,和上一个字的概率叠加,得出最可能存在的概率表,比如上一个是B的概率最大,后面肯定是M或者是E,不可能是S。
同时,得到一个隐式的矩阵
=============(Status Matrix)=================
-1 -1 -1 -1
3 0 0 3
1 2 2 1
3 0 0 3
3 0 0 3
3 0 0 3
3 0 0 3
=============(Status Matrix)=================
这个矩阵是对应上面的概率矩阵,最高概率关系对应的BEMS状态。
通过这个矩阵,我们用维斯比算法求最短路径,即可得到句子的分词。
切分结果是:
哪里见过你朋友
哪里/见/过/你/朋友/
看,在没有词库的基础上,我们用马尔科夫过程,一样可以得到词组。(实际上是可能成为词组的最高概率),实际上,这些代码和Tire的词典可以结合使用,Tire无法分词的句子用HMM即可继续分词,生成的新词,可以进入Tire词库,如此循环。就形成了一个可以自动识别新词并添加新词的词库,不需要人工干预的动态分词AI。
其实很多分词是这么做的,百度最开始也是这么做的,不到1000行代码。
只不过,为了更精确,百度会每天用一些语料去训练字典,并把训练好的字典替换到正式环境,因为读的文章越多,切词就越准确,根本不怕有什么新词出现。
其实马尔科夫模型是AI的基础,80%的AI底层都和这个算法先关,为什么用分词去说呢?因为这个最能直接表达马尔科夫过程。实际上,不只是分词,阿里巴巴那些技术专家说的"用户画像","消费习惯关联","产品联想"的AI,实际上也都是以这个为基础的,并不是什么神秘的东西,其实一通百通。
想玩?还是我的github,去拿下来自己玩个够吧。
freeeyes/fenci
最近AlphaGo 60连胜(限定快棋,30秒必须落子),打败了包括柯洁聂卫平等在内的一票围棋高手,所有人都觉得不可思议。很多人觉得人工智能已经超越了人类。但是实际上,它还是有弱点的。几千年的围棋,定式在一定程度上影响了现代的大师们。定式本身是一种概率运算体系。而在围棋上,所谓AI的大局观,只是在一个巨大的Hash散列(361个棋子)上求平均概率最大的事件。而围棋大师还不适应这种思维方式而已(定式是在局部求的最优解)。我觉得,只要人们学会了在前50个子能够找到对付Hash AI的方法去思考模式,后期计算机AI就没有什么机会了,主要AlphaGo赢在前50个子的思维模式。
那么下一贴讲什么呢?我们来看看更刺激的,用户画像是怎么做的吧。
也就是,把一个用户行为归类为几个行为方向。(聚类),光这样还不行,我们必须要解决"像"这个问题,也就是说,我们要把一批看似毫不相干的行为,归类为一个大类。这部分,也用到了马尔科夫模型。很有趣哦。