原文地址:http://blog.csdn.net/dancefire/article/details/1606602
一、简介
这次分析的是ICTCLAS中的
//
Generate Word according the segmentation route
bool
CSegment::GenerateWord(
int
**
nSegRoute,
int
nIndex)
本来这个函数没有必要详细分析,但是我注意到中科院论文中并没有描述这个函数、而Sinboy和吕震宇也基本上跳过这个函数不讲了,所以这个函数还没有有人详细的分析过呢。在这里,我具体的分析一下这个函数,另外,也提出一些问题供打算重写ICTCLAS的朋友来考虑。
二、功能介绍
这个函数虽然叫做GenerateWord,但是事实上并不仅仅是生成词,准确说,它大部分的工作不是为了将已经计算好的结果以词的形式显示出来,而是处理各种格式的数字。
数字实际上属于Named Entity的一种,属于未登录词识别的一部分。按理说应该像ICTCLAS处理人名、地名的方法一样,利用隐马,利用概率来处理。不过可惜的是,作者在对于数字的处理上,没有能够使用概率的优势来排除歧义,而采取了另一种方式,规则,来进行数字的合并和切分。在稍后,我们会讲述ICTCLAS在数字处理上出现的一些问题。我们先看看它所处理的规则:
合并所有的数字 针对 [数字][-—][数字] 的形式把减号从中分离出来。 合并:[数字]([月日时分秒]|月份) 合并:[数字][年] 处理时间格式:.*[点]$ 处理数字格式:[∶·././]$
GenerateWord()对满足上述条件的字符串进行拆分和合并的操作,这就是GenerateWord()的主要工作。
三、代码分析
//
Generate Word according the segmentation route
bool
CSegment::GenerateWord(
int
**
nSegRoute,
int
nIndex)
{ unsigned int i = 0 ,k = 0 ; int j,nStartVertex,nEndVertex,nPOS; char sAtom[WORD_MAXLENGTH],sNumCandidate[ 100 ],sCurWord[ 100 ]; ELEMENT_TYPE fValue; // nSegRoute的是原子位置的数组 // 循环,i初始为0,判断(i)和(i+1)有效,并判断nSegRoute[nIndex][i] < nSegRoute[nIndex][i+1] // 这里为什么要加一个小于来判断?必然前面的小于后面的。 while (nSegRoute[nIndex][i] !=- 1 && nSegRoute[nIndex][i + 1 ] !=- 1 && nSegRoute[nIndex][i] < nSegRoute[nIndex][i + 1 ]) { nStartVertex = nSegRoute[nIndex][i]; j = nStartVertex; // Set the start vertex nEndVertex = nSegRoute[nIndex][i + 1 ]; // Set the end vertex nPOS = 0 ; // 取得该分段(粗分的词)的词性(nPOS)和词频(fValue) m_graphSeg.m_segGraph.GetElement(nStartVertex,nEndVertex, & fValue, & nPOS); // 将该分段对应的词保存进sAtom sAtom[ 0 ] = 0 ; while (j < nEndVertex) { // Generate the word according the segmentation route strcat(sAtom,m_graphSeg.m_sAtom[j]); j ++ ; } // 将sAtom的值赋给sNumCandidate m_pWordSeg[nIndex][k].sWord[ 0 ] = 0 ; // Init the result ending strcpy(sNumCandidate,sAtom); // 如果sNumCandidate全是数字的话,进行特殊处理。 // 这里判断了sNumCandidate是不是全是半角数字或者全角数字。 // *需要注意的是*,IsAllChineseNum() 还有部分日期数字的判断功能,不仅仅是全角判断 while (sAtom[ 0 ] != 0 && (IsAllNum((unsigned char * )sNumCandidate) || IsAllChineseNum(sNumCandidate))) { // Merge all seperate continue num into one number // sAtom[0]!=0: add in 2002-5-9 // k? 在遥远的前方,在那函数入口的地方,被初始为0 // 将sNumCandidate对应的词放到结果集m_pWordSeg[nIndex][k].sWord中 strcpy(m_pWordSeg[nIndex][k].sWord,sNumCandidate); // Save them in the result segmentation // 开始看下一个分段,将下一段的文字放到sAtom中 i ++ ; // Skip to next atom now sAtom[ 0 ] = 0 ; // *注意* 这里是[i+1],而不是i,也就是说sAtom里面是下一个词啦。 while (j < nSegRoute[nIndex][i + 1 ]) { // Generate the word according the segmentation route strcat(sAtom,m_graphSeg.m_sAtom[j]); j ++ ; } // 将sAtom追加到sNumCandidate中。 // 下一个循环的时候依旧再看一下这个sNumCandidate是否是数字。 // 如果是数字,就替换原有的m_pWordSeg[nIndex][k].sWord strcat(sNumCandidate,sAtom); } // 点评:如果仅仅是为了合并所有的数字,这个循环臃肿了。 // 可以直接循环判断出数字所在的范围,然后一次性的追加即可。 // 合并数字这件事情应该在原子处理的时候进行处理,唯一需要注意的是含有数字的日期的合并放在原子上可能并不合适。 // 至于合并含有除了[0-90-9零-九]以外的字符,比如分之,大写数字,[几数第上成] *,应该放在这里,但不能是原子切分那里。 // unsigned int nLen = strlen(m_pWordSeg[nIndex][k].sWord); if ( (nLen == 4 && CC_Find( " 第上成±—+∶·./ " ,m_pWordSeg[nIndex][k].sWord)) || (nLen== 1 && strchr( " +-./ " ,m_pWordSeg[nIndex][k].sWord[ 0 ])) ) { // 这里的判断很不解。 // 第一条是判断前缀是否是数字的前缀,但是为什么长度是4?如果长度是4的话,永远都无法满足啊?恐怕这里的长度应该是写2的。 // 第二条是判断如果只有一个字符而且还是数字的前缀。 // Only one word strcpy(sCurWord,m_pWordSeg[nIndex][k].sWord); // Record current word // i--?为啥让i退一步呢? // 什么情况能进这个条件判断语句块呢?我们在上面的循环得到了字符的前缀,可是却发现后面的字符不是数字。 // i--,是说既然后面不是数字,那么我们在前面while loop里面的那个i++就盲目了。我们需要退回到这个"第上成"这个字接着考虑其他case。 i -- ; } else if (m_pWordSeg[nIndex][k].sWord[ 0 ] == 0 ) // Have never entering the while loop { // 因为当前词不是数字,没能够进入前面的循环。 // 将当前词放入结果,并且记录当前词。 strcpy(m_pWordSeg[nIndex][k].sWord,sAtom); // Save them in the result segmentation strcpy(sCurWord,sAtom); // Record current word } else { // 到这里就说明进入了前面的while loop,而且不仅仅是一个前缀而已,是个真的数字。 // 真的么?看下面两个if判断的意思,还是可能出现不是数字的,因此还需要i--退一步考虑。 // It is a num if ( // 看保存在记录里的词是不是"--","—"或者仅仅是一个"-" strcmp( " -- " ,m_pWordSeg[nIndex][k].sWord) == 0 || strcmp(" — " ,m_pWordSeg[nIndex][k].sWord) == 0 || m_pWordSeg[nIndex][k].sWord[0 ] == ' - ' && m_pWordSeg[nIndex][k].sWord[ 1 ] == 0 )// The delimiter "--" { // 设置词性为 'w' : 标点符号。显然也不是数字了,所以i--,退回一个词。 nPOS = 30464 ; // 'w'*256;Set the POS with 'w' i -- ; // Not num, back to previous word } else { // Adding time suffix char sInitChar[ 3 ]; unsigned int nCharIndex = 0 ; // Get first char // 取第一个字符。这里是通过判断char是否小于零,从而判断是不是汉字,需不需要追加一个字符的。 sInitChar[nCharIndex] = m_pWordSeg[nIndex][k].sWord[nCharIndex]; if (sInitChar[nCharIndex] < 0 ) { nCharIndex += 1 ; sInitChar[nCharIndex] = m_pWordSeg[nIndex][k].sWord[nCharIndex]; } nCharIndex+= 1 ; sInitChar[nCharIndex] = ' ' ; // 这个长长的判断是干嘛的呢?我改写为缩进格式,更利于逻辑上的理解。 // 其实就是为了把原来的[数字], [-][数字],的分词调整为:[数字]、[-]、[数字] // 我们看看具体的实现。先进行条件判断: if ( // 1、只考虑第二个词及其以后的词,因为这里需要考虑前一个词的词性 k > 0 && // 2、前一个词的词性是0x6D00('m')数字或0x7400 ( abs(m_pWordSeg[nIndex][k - 1 ].nHandle) == 27904 || abs(m_pWordSeg[nIndex][k- 1 ].nHandle) == 29696 ) && // 3、第一个字符是减号 ( strcmp(sInitChar, " — " ) == 0 || sInitChar[0 ] == ' - ' ) // 4、除了第一个字符还有别的字符。呵呵,其实只有负号的已经在前面被过滤了,按理说这里不该再担心这个问题了。 && ( strlen(m_pWordSeg[nIndex][k].sWord)> nCharIndex) ) { // 这个条件判断到底是什么条件?下面这个注释注释的好,无非就是针对: // [数字][-—][数字] // 的形式把减号从中分离出来。汗一下…… // 3-4月 // 27904='m'*256 // Split the sInitChar from the original word strcpy(m_pWordSeg[nIndex][k + 1 ].sWord,m_pWordSeg[nIndex][k].sWord + nCharIndex); m_pWordSeg[nIndex][k + 1 ].dValue = m_pWordSeg[nIndex][k].dValue; m_pWordSeg[nIndex][k + 1 ].nHandle = 27904 ; m_pWordSeg[nIndex][k].sWord[nCharIndex] = 0 ; m_pWordSeg[nIndex][k].dValue = 0 ; m_pWordSeg[nIndex][k].nHandle = 30464 ; // 'w'*256; // 将分离出的减号加入优化后的图。 m_graphOptimum.SetElement( nStartVertex, nStartVertex + 1 , m_pWordSeg[nIndex][k].dValue, m_pWordSeg[nIndex][k].nHandle, m_pWordSeg[nIndex][k].sWord ); nStartVertex += 1 ; k += 1 ; } // 取得第k个词的长度。如果进了上述循环,那么nLen长度是第二个[数字]的长度。 nLen = strlen(m_pWordSeg[nIndex][k].sWord); // 如果sAtom是 [月日时分秒]或者 "月份" 的话。 // 等等~,sAtom和m_pWordSeg[nIndex][k].sWord难道还不一样么? // 从前面的代码看,sAtom最多也就比sWord多一个减号啊? // 我们再回去看第一个循环的时候就会注意到,在那里,sAtom被赋予了[i+1]的字符串 // 也就是说sAtom实际上已经是下一个词了。 // 那么重新解释一下下面判断的意思就是: // [数字]([月日时分秒]|月份) if ( ( strlen(sAtom) == 2 && CC_Find(" 月日时分秒 " ,sAtom) ) || strcmp(sAtom," 月份 " ) == 0 ) { // 如果是如下模式: // [数字]([月日时分秒]|月份) // 将他们视为同一个词,加入到m_pWordSeg里, // 并且将sCurWord设置为"未##时",词性为't' // 2001年 // ^--- 啊?兄弟弄错了吧?下一个条件才是年呢。这个是月啊。:) strcat(m_pWordSeg[nIndex][k].sWord,sAtom); strcpy(sCurWord, " 未##时 " ); nPOS =- 29696 ; // 't'*256; // Set the POS with 'm' } else if (strcmp(sAtom, " 年 " ) == 0 ) { // 同上表示的话,应该是这个模式: // [数字][年] // 通过IsYearTime确认[数字]是合法的数字 // 如果满足就将年追加其后,将当前词改为特征词"未##时",并且词性改为't' if (IsYearTime(m_pWordSeg[nIndex][k].sWord)) // strncmp(sAtom,"年",2)==0&& { // 1998年, strcat(m_pWordSeg[nIndex][k].sWord,sAtom); strcpy(sCurWord, " 未##时 " ); nPOS =- 29696 ; // Set the POS with 't' } else { // 如果不满足,那么这些数字就仅仅是数字 // 将当前词改为特征词"未##数",词性设为'm'--数字。 // 并且因为不是时间,所以就得i--退一步。 strcpy(sCurWord, " 未##数 " ); nPOS =- 27904 ; // Set the POS with 'm' i -- ; // Can not be a time word } } else { // 又不是月份,又不是年,那现在看看是不是时分秒。 // 早晨/t 五点/t // .*[点]$ // 看看是不是以"点"结尾的 if (strcmp(m_pWordSeg[nIndex][k].sWord + strlen(m_pWordSeg[nIndex][k].sWord) - 2 , " 点 " ) == 0 ) { // 如果是的话就改sCurWord为特征词"未##时",词性为't' strcpy(sCurWord, " 未##时 " ); nPOS =- 29696 ; // Set the POS with 't' } else { // 如果不是以[∶·././]结尾的,那就改sCurWord为特征词"未##数",词性为'm' if ( ! CC_Find( " ∶·./ " ,m_pWordSeg[nIndex][k].sWord + nLen - 2 ) && m_pWordSeg[nIndex][k].sWord[nLen- 1 ] != ' . ' && m_pWordSeg[nIndex][k].sWord[nLen- 1 ] != ' / ' ) { strcpy(sCurWord, " 未##数 " ); nPOS =- 27904 ; // 'm'*256;Set the POS with 'm' } else if ( nLen > strlen(sInitChar)) { // 这两个逻辑合在一起真是够不合适的。 // 上面这个else if是说,以[∶·././]结尾,并且总长度大于一个字符。 // 除掉尾追的[∶·././]。 // 呵呵,如果2个/呢? // Get rid of . example 1. if ( m_pWordSeg[nIndex][k].sWord[nLen - 1 ] == ' . ' || m_pWordSeg[nIndex][k].sWord[nLen- 1 ] == ' / ' ) m_pWordSeg[nIndex][k].sWord[nLen- 1 ] = 0 ; else m_pWordSeg[nIndex][k].sWord[nLen- 2 ] = 0 ; // 将sCurWord设为特征词"未##数",词性设为'm' strcpy(sCurWord, " 未##数 " ); nPOS =- 27904 ; // 'm'*256;Set the POS with 'm' // i--?是啊,呵呵,你除去了最后一个字符,自然退一步,让程序接着那个最后的字符处理了。 i -- ; } // else呢? // 其实上面的那个else if就是else里的if。呵呵。那么也就是说,如果不除去最后一个字符怎么做? } i-- ; // Not num, back to previous word // 总结一下i--,只要没有把sAtom合并进来,i就要--。 // 呵呵,似乎把逻辑换一下可能更合适。满足条件的话i++,把后面的和进来,不满足就什么都不做。 } } // 数字处理完后,不管是什么情况,词频设为0 // 结束位置为下一个起始位置 fValue = 0 ; nEndVertex = nSegRoute[nIndex][i + 1 ]; // Ending POS changed to latter } // 经过了长长的优化后,包括拆开和合并后,我们终于可以保存结果了。 m_pWordSeg[nIndex][k].nHandle = nPOS; // Get the POS of current word m_pWordSeg[nIndex][k].dValue = fValue; // (int)(MAX_FREQUENCE*exp(-fValue)); // Return the frequency of current word m_graphOptimum.SetElement(nStartVertex,nEndVertex,fValue,nPOS,sCurWord); // Generate optimum segmentation graph according the segmentation result i ++ ; // Skip to next atom k ++ ; // Accept next word } m_pWordSeg[nIndex][k].sWord[0 ] = 0 ; m_pWordSeg[nIndex][k].nHandle =- 1 ; // Set ending return true ; }
其中涉及了一个叫做IsYearTime()的函数,来判断是否是为年的时间:
//
判断数字是否是年号
//
入口:sNum,是一串数字。可能是全角、半角数字。调用方要确保都是数字。
//
判断下面6种模式:(但是最后一种实际上是失效的)
//
1. [0-9]{4} 或者 [5-9][0-9] 四字或者2字年份
//
2. [数字]{6,} or [56789]{2}
//
3. [零○一二三四五六七八九壹贰叁肆伍陆柒捌玖]{2}
//
4. 四个数字的,并且含有2个[千仟零○]的。
//
5. [千仟]
//
6. [甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]
bool
CSegment::IsYearTime(
char
*
sNum)
{ // Judge whether the sNum is a num genearating year unsigned int nLen = strlen(sNum); char sTemp[ 3 ]; strncpy(sTemp,sNum, 2 ); sTemp[ 2 ] = 0 ; // [0-9]{4} 或者 [5-9][0-9] // 这个逻辑有问题啊,也就是4位数字或者大于50的两位数字。 // 那么这句话分词就该出错了: // “这59年比49年好多了。” // 被分成了: // 59年/t 比/p 49/m 年/q 好/a 多/a 了/y 。/w // 其中"59年"被识别为时间了,而"49年"就错了变成了一个数字了。 if ( IsAllSingleByte((unsigned char * )sNum) && ( nLen == 4 || nLen == 2 && sNum[ 0 ] > ' 4 ' ) )// 1992年, 90年 return true ; // 如果全为数字,并且长度大于等于6,或者长度等于4并且都是"56789"里面的数字。 // [数字]{6,} or [56789]{2} // 这是要干吗?后一个条件好说,是说两位数的年份,数字要求>=50年。前一个呢? if ( IsAllNum((unsigned char * )sNum) && ( nLen >= 6 || nLen == 4 && CC_Find( " 56789 " ,sTemp) ) ) return true ; // [零○一二三四五六七八九壹贰叁肆伍陆柒捌玖]{2} if ( GetCharCount( " 零○一二三四五六七八九壹贰叁肆伍陆柒捌玖 " ,sNum) == ( int )nLen / 2 && nLen>= 3 // 3?? 前一个条件已经限定了是偶数了,怎么会出来个3?应该是4吧? ) return true ; // 四个数字的,并且含有2个[千仟零○]的。 if (nLen == 8 && GetCharCount( " 千仟零○ " ,sNum) == 2 ) // 二仟零二年 return true ; // [千仟] if (nLen == 2 && GetCharCount( " 千仟 " ,sNum) == 1 ) return true ; // 下面这个比较特殊,逻辑上是: // [甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥] // 但仅仅是这里的逻辑,因为根本不会有含有上述文字的词进入这里,因为在IsAllNumber哪里就被排除了。 // 开始以为既然如此,那一定识别不了这些年份,结果惊奇的发现虽然词性标注没能成功的标为时间, // 但是竟然识别了所有的天干地支。仔细研究后发现,作者竟然把所有的天干地支都放到词库里面了。 // 词性为数字,词频为0。从这里,至少找到了一部分词频为0的词。 if ( nLen == 4 && GetCharCount( " 甲乙丙丁戊己庚辛壬癸 " ,sNum) == 1 && GetCharCount( " 子丑寅卯辰巳午未申酉戌亥 " ,sNum + 2 ) == 1 ) return true ; return false ; }
在IsYearTime的分析中已经看到了一些问题,50年以前的年就是别不出来了,那么如果说"49年建国",这句话就会分错误了。
在上面的处理中,调用了一个叫做IsAllChineseNum()的函数,需要说明的是,这个函数在ICTCLAS的原子切分里也用到了,用以判断一个字符串是不是中文数字,其正则表达式为:
[几数第上成]([零○一二两三四五六七八九十廿百千万亿壹贰叁肆伍陆柒捌玖拾佰仟∶·./点]|分之)
具体的函数分析如下:
//
以正则表达式表示:
//
[几数第上成]([零○一二两三四五六七八九十廿百千万亿壹贰叁肆伍陆柒捌玖拾佰仟∶·./点]|分之)
bool
IsAllChineseNum(
char
*
sWord)
{ // 百分之五点六的人早上八点十八分起床 unsigned int k; char tchar[ 3 ]; char ChineseNum[] = " 零○一二两三四五六七八九十廿百千万亿壹贰叁肆伍陆柒捌玖拾佰仟∶·./点 " ; // char sPrefix[] = " 几数第上成 " ; for (k = 0 ; k < strlen(sWord); k += 2 ) { strncpy(tchar,sWord + k, 2 ) ; tchar[ 2 ] = ' ' ; if (strncmp(sWord + k, " 分之 " , 4 ) == 0 ) // 百分之五 { k += 2 ; continue ; } if ( ! CC_Find(ChineseNum, tchar) &&! (k == 0 && CC_Find(sPrefix, tchar))) return false ; } return true ; }
这个函数包含了在原子切分的时候无法处理的高层的功能。
那么问题在哪呢?在这部判断的时候不会考虑上下文,而且也尚未成词,因此我们可以用满足规则的任何方式来构造满足这个规则却不是数字的例子。
1、
原句:他们竟没有算上陆兵。 ICTCLAS分词:他们
/
r 竟
/
d 没有
/
d 算
/
v 上陆
/
m 兵
/
n 。
/
w
"上陆" 被判断为m ,即数字,单独成词。这显然不应该。原因是"上"是前缀,"陆"被视为数字。
2、
原句:他从地板上拾起钱包,也没数拾起来就走。 ICTCLAS分词:他 / r 从 / p 地板 / n 上拾 / m 起 / q 钱 / n 包 / v , / w 也 / d 没 / v 数拾 / m 起来 / v 就 / d 走 / v 。 / w
"上拾","数拾" ,都是和上面一样的原因。
3、
原句:成!就这么干了。 ICTCLAS分词:成
/
m !
/
w 就
/
p 这么
/
r 干
/
v 了
/
y 。
/
w
这里"成" 是表示肯定的意思,表示可以,认同。显然也不是数词,词性标注错误。
4、
原句:他结巴地说: " 我......要茶...茶......几。 " ICTCLAS分词:他/ r 结巴 / a 地 / u 说 / v : / w " /w 我/r .../w .../w 要/v 茶/n .../w 茶/n .../w .../w 几/m 。/w " / w
由于标点符号分割,导致最后一个"几" 本来是名词的一部分,结果被视为动词了。当然这个不完全算是ICTCLAS的问题,这个甚至都不属于分词领域。
对于数字的原子判断,恐怕只有纯数字和符号才适合做原子,只要有汉字在内,就应该只将其做为一条边来考虑,在后面判断中来判断是否应该组合为日期等等。 四、一些想法和建议 ICTCLAS在数字处理上使用纯规则判断,特别是中文数字,很容易和上下文产生歧义,对于这种歧义的处理,概率已经被证明是相当迅速有效的,
我们应该充分利用概率排除歧义的优势。这也是ICTCLAS在汉字分词排除歧义上的优势所在。
其实数字排除歧义和普通汉字排除歧义没有本质差别。
对于普通汉字的排除歧义,我们列举了所有可能的词,添上概率,让最短路径算法寻找最可能的解。
对于数字排歧义,我们完全可以使用同样的办法,通过规则列举所有可能的解,填入概率,让最短路径算法去寻找最可能的解。
在原有的ICTCLAS里面没有能够列举所有数字可能的边,而是人工的强制组合。这种强制组合实际上有些没有道理,而且很局限,你只能对你知道的那几个case组合,但是很可能的结果就是导致你没有想到的某种组合突破了你的规则,导致了错误的结果。然后就只能再调整规则。
再仔细看这种过程,这种反复根据规则的调整的场景不是很熟悉么?就是在利用统计进行排除歧义之前,基于规则的做法啊。既然统计引入后大大的提高了排除歧义的能力,我们何不同样利用规则给出多种选择,让统计算法做决定呢?
规则是死的,只有满足、不满足,是或者非。而统计是灵活的,是更可能,或者不太可能。利用死的规则提出所有可能的解(词),再利用活的统计来全局的判定到底该采用谁。
至于添加边这种行为所应该处的代码位置,我们可以将其放在产生一元图后,建立起一系列的规则过滤,对一元图进行调整。其中一个规则过滤就是增加与数字有关的、根据上下文构成可能的词,添加到一元图中去。其他的一些规则,我们也可以加在这个位置。这类规则不再成为判定了,而是成为提供词库所不能提供的更多种组词的选择了,这么利用规则,可能更加灵活。 参考: http://www.cnblogs.com/zhenyulu/articles/673650.html