ICTCLAS解析
ICTCLAS分詞系統是由中科院計算所的張華平、劉羣所開發的一套獲得廣泛好評的分詞系統,該版的Free版開放了源代碼,爲初學者提供了寶貴的學習材料。我們可以在“http://sewm.pku.edu.cn/QA/”找到FreeICTCLASLinux.tar的C++代碼。
可是目前該版本的ICTCLAS並沒有提供完善的文檔,所以閱讀起來有一定的難度,所幸網上可以找到一些對ICTCLAS進行代碼分析的文章,對理解分詞系統的內部運行機制提供了很大的幫助。這些文章包括:
1)http://blog.csdn.net/group/ictclas4j/;《ICTCLAS分詞系統研究(一)~(六)》作者:sinboy。
2)http://qxred.yculblog.com/post.1204714.html;《ICTCLAS 中科院分詞系統 代碼 註釋 中文分詞 詞性標註》作者:風暴紅QxRed 。
按照上面這些文章的思路去讀ICTCLAS的代碼,可以比較容易的理順思路。然而在我閱讀代碼的過程中,越來越對ICTCLAS天書般的代碼感到厭煩。我不得不佩服中科院計算所的人思維縝密,頭腦清晰,能寫出滴水不漏而又讓那些“頭腦簡單”的人百思不得其解的代碼。將一件本來很簡單的事情做得無比複雜...
ICTCLAS中有一個名爲CDynamicArray的類,存放在DynamicArray.cpp與DynamicArray.h兩個文件中,這個DynamicArray是幹什麼用的?經過一番研究後終於明白是一個經過排序的鏈表。爲了表達的更明白些,我們不妨看下面這張圖:
(圖一)
上面這張圖是一個按照index值進行了排序的鏈表,當插入新結點時必須確保index值的有序性。DynamicArray類完成的功能基本上與上面這個鏈表差不多,只是排序規則不是index,而是row和col兩個數據,如下圖:
(圖二)
大家可以看到,這個有序鏈表的排序規則是先按row排序,row相同的按照col排序。當然排序規則是可以改變的,如果先按col排,再按row排,則上面的鏈表必須表述成:
(圖三)
在瞭解了這些內容的基礎上,不妨讓我們看看ICTCLAS中DynamicArray.cpp中的代碼實現(這裏我們只看GetElement方法的實現,其基本功能爲給出row與col,然後將對應的元素取出來)。
PARRAY_CHAIN *pRet)
{
PARRAY_CHAIN pCur = pStart;
if (pStart == 0)
pCur = m_pHead;
if (pRet != 0)
*pRet = NULL;
if (nRow > (int)m_nRow || nCol > (int)m_nCol)
//Judge if the row and col is overflow
return INFINITE_VALUE;
if (m_bRowFirst)
{
while (pCur != NULL && (nRow != - 1 && (int)pCur->row < nRow || (nCol !=
- 1 && (int)pCur->row == nRow && (int)pCur->col < nCol)))
{
if (pRet != 0)
*pRet = pCur;
pCur = pCur->next;
}
}
else
{
while (pCur != NULL && (nCol != - 1 && (int)pCur->col < nCol || ((int)pCur
->col == nCol && nRow != - 1 && (int)pCur->row < nRow)))
{
if (pRet != 0)
*pRet = pCur;
pCur = pCur->next;
}
}
if (pCur != NULL && ((int)pCur->row == nRow || nRow == - 1) && ((int)pCur
->col == nCol || nCol == - 1))
//Find the same position
{
//Find it and return the value
if (pRet != 0)
*pRet = pCur;
return pCur->value;
}
return INFINITE_VALUE;
}
這裏我先要說明的是程序中的m_bRowFirst變量,它表示是先按row大小排列還是先按col大小排列。如果m_bRowFirst爲邏輯真值,那麼鏈表就如上面圖二所示,如果爲假,則如圖三所示。
除了這個外,看到上面長長的條件表達式,你一定會嚇壞了吧!更讓人嚇壞的是調用這段程序的代碼:
//來自NShortPath.cpp中ShortPath方法
eWeight = m_apCost->GetElement( -1, nCurNode, 0, &pEdgeList);
//來自Segment.cpp中BiGraphGenerate方法
aWord.GetElement(pCur->col, -1, pCur, &pNextWords);//Get next words which begin with pCur->col
- 先分析第一個調用
第一個調用給GetElement方法的nRow傳遞了-1,他想幹什麼呢?
假設這時候變量m_bRowFirst爲true,並且傳遞過去的nCol!=-1,那麼while (pCur != NULL && (nRow != - 1 && (int)pCur->row < nRow || (nCol != -1 && (int)pCur->row == nRow && (int)pCur->col < nCol))) 等價於while (pCur != NULL && ( (int)pCur->row == -1 && (int)pCur->col < nCol))) ,注意紅色部分在程序運行時永遠爲false(因爲根本就不存在row爲-1的結點),因此,上面的表達式等價於while(false)!這對於該段程序沒有任何意義!
因此我們可以得到這樣一個結論:如果GetElement方法的nRow參數取-1,當且僅當m_bRowFirst爲false時纔有意義。這時候,代碼中第二個while得到執行,讓我們分析一下:
while (pCur != NULL && (nCol != - 1 && (int)pCur->col < nCol || ((int)pCur->col == nCol && nRow != - 1 && (int)pCur->row < nRow))) 在nRow爲-1時等價於while (pCur != NULL && ((int)pCur->col < nCol ) ,這就容易解釋的多了:在如圖三所示的鏈表中查找col=nCol 的第一個結點。
My God!
- 再分析第二個調用
上面的第二個調用就更讓人摸不着頭腦了:將pCur->col傳遞給GetElement的nRow參數,並將-1傳遞給nCol參數,這想幹什麼呢?要想分析清楚這個問題,沒有個把鐘頭恐怕不行(再次佩服這些中科院的牛人們)。
按照“分析第一個調用”中的結論可知,如果GetElement方法的nCol參數取-1,當且僅當m_bRowFirst爲true時纔有意義。因此鏈表排序一定是先按照行排(如圖二),此時對DynamicArray的GetElement方法的調用可以簡化成:
aWord.GetElement(pCur->col, -1, pCur, &pNextWords);
//======================================================================
ELEMENT_TYPE CDynamicArray::GetElement(int nRow, int nCol, PARRAY_CHAIN pStart, PARRAY_CHAIN *pRet)
// 經過調用後,上面的形參對應的值分別是:nRow:pStart->col, nCol:-1, pStart, &pNextWords
// 注意,爲了和下面代碼中的pCur以示區分,這裏用了pStart這個變量名。
{
......
while (pCur != NULL && ((int)pCur->row < pStart->col))
{
if (pRet != 0)
*pRet = pCur;
pCur = pCur->next;
}
if (pCur != NULL && ((int)pCur->row == pStart->col)
//Find the same position
{
//Find it and return the value
if (pRet != 0)
*pRet = pCur;
return pCur->value;
}
return INFINITE_VALUE;
}
此時的意義就比較明顯了,其實就是找pCur->row == pStart->col的那個結點。
可有人會問,幹嗎把row和col扯到一起呢?這又是一個非常複雜的問題。具體內容可以參考sinboy的《ICTCLAS分詞系統研究(四)--初次切分》一文。這裏簡單解釋如下:
如圖四,這是row優先排列的一個鏈表:
圖四 進行初步分詞後的鏈表結構(TagArrayChain)實例
用二維表來表示圖四中的鏈表結構如下圖五所示:
圖五 TagArrayChain實例的二維表表示形式
然後找出相鄰兩個詞的平滑值。例如“他@說”、“的@確”、“的@確實”、“的確@實”、“的確@實在”等。如果仔細觀察的話,可以注意到以下特點:例如“的確”這個詞,它的col = 5,需要和它計算平滑值的有兩個,分別是“實”和“實在”,你會發現這兩個詞的row = 5。同樣道理,“確”的col = 5,它也需要和“實”與“實在”(row = 5)分別計算平滑值。
其實,這就是爲什麼上面分析的找pCur->row == pStart->col的那個結點的原因了。最終得到的平滑值圖可以表述成圖六:
圖六 進行初次分詞後生成的二叉圖表的二維圖表表示形式
到此爲止才明白代碼作者的真正用意:
......
//取得和當前結點列值(col)相同的下個結點
aWord.GetElement(pCur->col, -1, pCur, &pNextWords);
while(pNextWords&&pNextWords->row==pCur->col)//Next words
{
//前後兩個詞用@分隔符連接起來
strcpy(sTwoWords,pCur->sWord);
strcat(sTwoWords,WORD_SEGMENTER);
strcat(sTwoWords,pNextWords->sWord);
......
}
- 小結
想不到短短一個GetElement方法中竟然綜合考慮了1)row優先排序的鏈表;2)col優先排序的鏈表;3)當nRow爲-1時的行爲(只有m_bRowFirst爲false時才能這麼做,代碼中沒有指,所以非常容易出錯!);4)當nCol爲-1時的行爲;5)當nRow與nCol都不爲-1時的行爲。
這也難怪我們會看到諸如while (pCur != NULL && (nRow != - 1 && (int)pCur->row < nRow || (nCol != -1 && (int)pCur->row == nRow && (int)pCur->col < nCol))) 這樣的邏輯表達式了!我們也不得不佩服代碼書寫者複雜的邏輯思維能力(離散數學的謂詞邏輯一定學得超級好)和給代碼閱讀者製造障礙的能力!類似代碼在ICTCLAS中比比皆是,看來我只能恨自己腦筋太簡單了!
《天書般的ICTCLAS分詞系統代碼(一)》 說了說ICTCLAS分詞系統有些代碼讓人無所適從,需要好一番努力才能弄明白究竟是怎麼回事。儘管有很多人支持應當寫簡單、清晰的代碼,但也有人持不同意見。主要集中在(1)如果效率高,代碼複雜點也行; (2)只要註釋寫得好就行;(3)軟件關鍵在思路(這我同意),就好像買了一臺電腦,不管包裝箱內的電腦本身怎麼,一羣人偏在死扣那個外面透明膠帶帖歪了(這我堅決不同意,因爲只有好思路出不來好電腦,好電腦還要性能穩定,即插即用的好硬件;另外天書般的代碼不僅僅是透明膠帶
貼歪的問題,他甚至可能意味着電腦中的絕緣膠帶失效了...)。
這兩天在抓緊學習ICTCLAS分詞系統的思路的同時,也在消化學習它的代碼實現,然而我看到的代碼已經不僅僅是爲了效率犧牲代碼清晰度的問題了,我看到的是連作者都不知道自己真正想要做什麼了,儘管程序的執行結果是正確的!
爲了說明這種情況的嚴重性,我們需要從CQueue.cpp這個文件着手。我對CQueue這個類頗有些微辭,明明是個Queue,裏面確用的是Push、Pop方法(讓人感覺是個Stack而不是Queue),而且Pop方法純粹是個大雜燴,不過這些都不是原則性問題,畢竟每個人有每個人寫代碼的習慣。CQueue完成的工作是製造一個排序隊列(按照eWeight從小到大排序),如圖一:
(圖一)
在瞭解了這些內容的基礎上,讓我們看看ICTCLAS中NShortPath.cpp中的代碼實現(這裏我們只看ShortPath方法的實現) ,爲了讓問題暴露得更清晰一些,我簡化了代碼中一些不相關的內容。
{
......
for (; nCurNode < m_nVertex; nCurNode++)
{
CQueue queWork;
//此處省略的代碼主要負責將一些結點按照eWeight從
//小到大的順序放入隊列queWork
......
//初始化權重
for (i = 0; i < m_nValueKind; i++)
m_pWeight[nCurNode - 1][i] = INFINITE_VALUE;
i = 0;
while (i < m_nValueKind && queWork.Pop(&nPreNode, &nIndex, &eWeight) != -1)
{
//Set the current node weight and parent
if (m_pWeight[nCurNode - 1][i] == INFINITE_VALUE)
m_pWeight[nCurNode - 1][i] = eWeight;
else if (m_pWeight[nCurNode - 1][i] < eWeight)
//Next queue
{
i++; //Go next queue and record next weight
if (i == m_nValueKind)
//Get the last position
break;
m_pWeight[nCurNode - 1][i] = eWeight;
}
m_pParent[nCurNode - 1][i].Push(nPreNode, nIndex);
}
}
......
}
上面的代碼作者想幹什麼?讓我們來分析一番:
變量queWork中存放的是一個按照eWeight從小到大排列的隊列, 我們不妨假設裏面有4個元素,其eWeight值分別是5、6、7、8。另外我們假設變量m_nValueKind的值爲2,即查找最短的兩條路徑(注意:這種說法不完全正確,後面會解釋爲什麼)。在此假設基礎上,我們看看程序是如何運行的:
1)將所有m_pWeight[nCurNode - 1][i]初始化爲INFINITE_VALUE。
2)在第一輪循環中,我們從queWork中取出第一個元素,其eWeight爲5,注意表達式“if (m_pWeight[nCurNode - 1][i] == INFINITE_VALUE) ”沒有任何作用,因爲我們在第一步將所有m_pWeight[nCurNode - 1][i] 均初始化成了INFINITE_VALUE,所以第一輪循環該條件一定爲true。
3)在第二輪循環中,我們從queWork中取出第二個元素,其eWeight爲6,此時表達式“else if (m_pWeight[nCurNode - 1][i] < eWeight) ”似乎就沒有什麼作用了,因爲queWork是經過排序的,第二個元素的eWeight不會小於第一個eWeight,對於我們這個例子來說, 該表達式一定爲true,於是就讓 i++。
4)緊接着你會發現程序重新進入了步驟2)的循環。
程序執行結果如圖二:
(圖二)
如果真是這樣的話,上面的代碼似乎可以簡化成:
{
......
for (; nCurNode < m_nVertex; nCurNode++)
{
CQueue queWork;
//此處省略的代碼主要負責將一些結點按照eWeight從
//小到大的順序放入隊列queWork
......
//初始化權重
for (i = 0; i < m_nValueKind; i++)
m_pWeight[nCurNode - 1][i] = INFINITE_VALUE;
i = 0;
while (i < m_nValueKind && queWork.Pop(&nPreNode, &nIndex, &eWeight) != -1)
{
m_pWeight[nCurNode - 1][i] = eWeight;
m_pParent[nCurNode - 1][i].Push(nPreNode, nIndex);
i++;
}
}
......
}
對於上面這個案例,簡化後的程序與ICTCLAS中的程序執行結果完全相同。可作者寫出如此複雜的代碼應當是有理由的,難道我們對代碼的分析有什麼問題嗎?
是的!作者將一個最爲重要的內容作爲隱含條件放入了代碼之中,我們只能通過 if 條件以及 else if 條件中的內容推斷出這個隱含條件究竟是什麼,而這個隱含的條件恰恰應當是這段代碼中最關鍵的內容。如果沒能將最關鍵的內容展現在代碼當中,而是需要讀者去推斷的話,我只能說連作者自己都不清楚究竟什麼是最關鍵的東西,僅僅是讓程序執行沒有錯誤而已。
那麼究竟隱藏了什麼關鍵的內容呢?那就是“m_pWeight[nCurNode - 1][i] = eWeight”這個條件。在ShortPath方法代碼中,作者用了 if 條件、 else if 條件,但都沒有提及等於eWeight時程序的執行行爲,他將這個留給了讀者去推敲,看出來這個隱含條件就看出來了,看不出來就只能怪你自己笨了。
我們更換一組數據來看看:假設queWork裏面有4個元素,其eWeight值分別是5、6、6、7,還假設變量m_nValueKind的值爲2,那麼ICTCLAS中ShortPath程序執行結果是什麼呢?讀者可以根據代碼自己推敲一下,然後再看看下面的結果,與你預期的一樣不一樣。如圖三。
(圖三)
這裏m_Parent[nCurNode - 1][2]是一個CQueue,裏面存入了eWeight爲6的兩個結點。這也是爲什麼我前文說,NShortPath中 N 如果取2,並不意味着只有兩條路徑。
如果那位有耐心看到這裏,對ICTCLAS中的NShortPath.cpp代碼有什麼感覺呢?其實要想寫出一個比較清晰的代碼並不複雜,只要你真正瞭解究竟什麼是最重要的東西,對於NShortPath.cpp中的代碼,只要我們稍加修改,就可以讓這天書般的代碼改善不少。經過調整後的代碼如下:
{
......
for (; nCurNode < m_nVertex; nCurNode++)
{
CQueue queWork;
//此處省略的代碼主要負責將一些結點按照eWeight從
//小到大的順序放入隊列queWork
......
//初始化權重
for (i = 0; i < m_nValueKind; i++)
m_pWeight[nCurNode - 1][i] = INFINITE_VALUE;
if(queWork.Pop(&nPreNode, &nIndex, &eWeight) != -1)
{
for(i=0; i < m_nValueKind ; i++)
{
m_pWeight[nCurNode - 1][i] = eWeight;
do
{
m_pParent[nCurNode - 1][i].Push(nPreNode, nIndex);
if(queWork.Pop(&nPreNode, &nIndex, &new_eWeight) == -1)
goto finish;
}while(new_eWeight == eWeight)
eWeight = new_eWeight;
}
}
}
finish:
......
}
經過改造的代碼使用了一個do...while循環,並利用了goto命令簡化代碼結構,我想這樣的代碼讀起來應當清晰多了吧。
- 小結
(1)軟件關鍵在思路,只有真正瞭解思路的人才能寫出清晰的代碼。如果代碼不清晰,說明思路根本不清晰。
(2)註釋寫得好不如代碼結構清晰。
(3)除非經過測試,否則不要爲了一點效率提升而損失代碼的可讀性。