乐哈哈旅游视频网:

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)除非经过测试,否则不要为了一点效率提升而损失代码的可读性。

 

posted on 2007-03-09 14:26  riky  阅读(2181)  评论(0编辑  收藏  举报

乐哈哈旅游视频网: