ICTCLAS解析

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,然後將對應的元素取出來)。

DynamicArray.cpp
ELEMENT_TYPE CDynamicArray::GetElement(int nRow, int nCol, PARRAY_CHAIN pStart, 
  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爲邏輯真值,那麼鏈表就如上面圖二所示,如果爲假,則如圖三所示。

除了這個外,看到上面長長的條件表達式,你一定會嚇壞了吧!更讓人嚇壞的是調用這段程序的代碼:

對GetElement方法的調用

//來自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方法的調用可以簡化成:

對方法調用進行剝離和簡化
//來自Segment.cpp中BiGraphGenerate方法  
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的那個結點的原因了。最終得到的平滑值圖可以表述成圖六:

圖六 進行初次分詞後生成的二叉圖表的二維圖表表示形式

到此爲止才明白代碼作者的真正用意:

將該調用放到上下文中再次查看
//========= 來自Segment.cpp中BiGraphGenerate方法 =========== 
......  
//取得和當前結點列值(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方法的實現) ,爲了讓問題暴露得更清晰一些,我簡化了代碼中一些不相關的內容。

來自NShortPath.cpp中的ShortPath方法
int CNShortPath::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)的循環。

程序執行結果如圖二:

(圖二)

如果真是這樣的話,上面的代碼似乎可以簡化成:

簡化後的程序
int CNShortPath::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)  
    {  
      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中的代碼,只要我們稍加修改,就可以讓這天書般的代碼改善不少。經過調整後的代碼如下:

重新改造後的代碼
int CNShortPath::ShortPath()  
{  
  ......  
  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)除非經過測試,否則不要爲了一點效率提升而損失代碼的可讀性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章