Porter Algorithm ---------词干提取算法

在英语中,一个单词常常是另一个单词的“变种”,如:happy=>happiness,这里happy叫做happiness的词干(stem)。在信息检索系统中,我们常常做的一件事,就是在Term规范化过程中,提取词干(stemming),即除去英文单词分词变换形式的结尾。

应用最为广泛的、中等复杂程度的、基于后缀剥离的词干提取算法是波特词干算法,也叫波特词干器(Porter Stemmer)。详见官方网站。比较热门的检索系统包括Lucene、Whoosh等中的词干过滤器就是采用的波特词干算法。


词干提取算法无法达到100%的准确程度,因为语言单词本身的变化存在着许多例外的情况,无法概括到一般的规则中。使用词干提取算法能够帮助提高IR的性能。

波特词干算法的官方网站上,有各个语言的实现版本(其实都是C标准的各个翻译形式)。各位要应用到实际生产中可以直接下载对应的版本。本文将会分析Java语言的源码。在今后的文章中,再介绍使用Python特性优化过的版本。(Python原版几乎就是C语言版本的翻译,这也就意味着不能充分利用Python的语言特性。)

在实际处理中,需要分六步走。首先,我们先定义一个Stemmer类。


[java] view plain copy
 print?
  1. class Stemmer  
  2. {  private char[] b;  
  3.    private int i,     /* b中的元素位置(偏移量) */  
  4.                i_end, /* 要抽取词干单词的结束位置 */  
  5.                j, k;  
  6.    private static final int INC = 50;  
  7.                      /* 随着b的大小增加数组要增长的长度(防止溢出) */  
  8.    public Stemmer()  
  9.    {  b = new char[INC];  
  10.       i = 0;  
  11.       i_end = 0;  
  12.    }  
  13. }  

这里,b是一个数组,用来存待词干提取的单词(以char的形式)。这里的变量k会随着词干抽取而变化。

接着,我们要添加单词来进行处理:

[java] view plain copy
 print?
  1. /** 
  2. * 增加一个字符到要存放待处理的单词的数组。添加完字符时, 
  3. * 可以调用stem(void)方法来进行抽取词干的工作。 
  4. */  
  5. public void add(char ch)  
  6. {  if (i == b.length)  
  7.    {  char[] new_b = new char[i+INC];  
  8.       for (int c = 0; c < i; c++) new_b[c] = b[c];  
  9.       b = new_b;  
  10.    }  
  11.    b[i++] = ch;  
  12. }  
  13.    
  14. /** 增加wLen长度的字符数组到存放待处理的单词的数组b。 
  15. */  
  16. public void add(char[] w, int wLen)  
  17. {  if (i+wLen >= b.length)  
  18.    {  char[] new_b = new char[i+wLen+INC];  
  19.       for (int c = 0; c < i; c++) new_b[c] = b[c];  
  20.       b = new_b;  
  21.    }  
  22.    for (int c = 0; c < wLen; c++) b[i++] = w[c];  
  23. }  

大家可能会觉得这么处理字符串太麻烦了吧,要明白,整个代码是从C移植过来的。

接下来,是一系列工具函数。首先先介绍一下它们:

  • cons(i):参数i:int型;返回值bool型。当i为辅音时,返回真;否则为假。
  • m():返回值:int型。表示单词b介于0和j之间辅音序列的个度。现假设c代表辅音序列,而v代表元音序列。<..>表示任意存在。于是有如下定义;
    •          结果为 0
    • vc       结果为 1
    • vcvc    结果为 2
    • vcvcvc 结果为 3
    • ....
  • vowelinstem():返回值:bool型。从名字就可以看得出来,表示单词b介于0到i之间是否存在元音。
  • doublec(j):参数j:int型;返回值bool型。这个函数用来表示在j和j-1位置上的两个字符是否是相同的辅音。
  • cvc(i):参数i:int型;返回值bool型。对于i,i-1,i-2位置上的字符,它们是“辅音-元音-辅音”的形式,并且对于第二个辅音,它不能为w、x、y中的一个。这个函数用来处理以e结尾的短单词。比如说cav(e),lov(e),hop(e),crim(e)。但是像snow,box,tray就辅符合条件。
  • ends(s):参数:String;返回值:bool型。顾名思义,判断b是否以s结尾。
  • setto(s):参数:String;void类型。把b在(j+1)...k位置上的字符设为s,同时,调整k的大小。
  • r(s):参数:String;void类型。在m()>0的情况下,调用setto(s)。

简单贴出来这些工具函数的代码。

[java] view plain copy
 print?
  1. // cons(i) 为真 <=> b[i] 是一个辅音  
  2. private final boolean cons(int i)  
  3. {  switch (b[i])  
  4.    {  case 'a'case 'e'case 'i'case 'o'case 'u'return false//aeiou  
  5.       case 'y'return (i==0) ? true : !cons(i-1);  
  6.                 //y开头,为辅;否则看i-1位,如果i-1位为辅,y为元,反之亦然。  
  7.       defaultreturn true;  
  8.    }  
  9. }  
  10.    
  11. // m() 用来计算在0和j之间辅音序列的个数。 见上面的说明。 */  
  12. private final int m()  
  13. {  int n = 0//辅音序列的个数,初始化  
  14.    int i = 0//偏移量  
  15.    while(true)  
  16.    {  if (i > j) return n; //如果超出最大偏移量,直接返回n  
  17.       if (! cons(i)) break//如果是元音,中断  
  18.       i++; //辅音移一位,直到元音的位置  
  19.    }  
  20.    i++; //移完辅音,从元音的第一个字符开始  
  21.    while(true)//循环计算vc的个数  
  22.    {  while(true//循环判断v  
  23.       {  if (i > j) return n;  
  24.          if (cons(i)) break//出现辅音则终止循环  
  25.             i++;  
  26.       }  
  27.       i++;  
  28.       n++;  
  29.       while(true//循环判断c  
  30.       {  if (i > j) return n;  
  31.          if (! cons(i)) break;  
  32.          i++;  
  33.       }  
  34.       i++;  
  35.     }  
  36. }  
  37.    
  38. // vowelinstem() 为真 <=> 0,...j 包含一个元音  
  39. private final boolean vowelinstem()  
  40. {  int i; for (i = 0; i <= j; i++) if (! cons(i)) return true;  
  41.    return false;  
  42. }  
  43.    
  44. // doublec(j) 为真 <=> j,(j-1) 包含两个一样的辅音  
  45. private final boolean doublec(int j)  
  46. {  if (j < 1return false;  
  47.    if (b[j] != b[j-1]) return false;  
  48.    return cons(j);  
  49. }  
  50.    
  51. /* cvc(i) is 为真 <=> i-2,i-1,i 有形式: 辅音 - 元音 - 辅音 
  52.    并且第二个c不是 w,x 或者 y. 这个用来处理以e结尾的短单词。 e.g. 
  53.   
  54.    cav(e), lov(e), hop(e), crim(e), 但不是 
  55.    snow, box, tray. 
  56.   
  57. */  
  58. private final boolean cvc(int i)  
  59. {  if (i < 2 || !cons(i) || cons(i-1) || !cons(i-2)) return false;  
  60.    {  int ch = b[i];  
  61.          if (ch == 'w' || ch == 'x' || ch == 'y'return false;  
  62.    }  
  63.       return true;  
  64. }  
  65.    
  66. private final boolean ends(String s)  
  67. {  int l = s.length();  
  68.    int o = k-l+1;  
  69.    if (o < 0return false;  
  70.    for (int i = 0; i < l; i++) if (b[o+i] != s.charAt(i)) return false;  
  71.    j = k-l;  
  72.    return true;  
  73. }  
  74.    
  75. // setto(s) 设置 (j+1),...k 到s字符串上的字符, 并且调整k值  
  76. private final void setto(String s)  
  77. {  int l = s.length();  
  78.    int o = j+1;  
  79.    for (int i = 0; i < l; i++) b[o+i] = s.charAt(i);  
  80.    k = j+l;  
  81. }  
  82.    
  83. private final void r(String s) { if (m() > 0) setto(s); }  

接下来,就是分六步来进行处理的过程。

第一步,处理复数,以及ed和ing结束的单词。

[java] view plain copy
 print?
  1. /* step1() 处理复数,ed或者ing结束的单词。比如: 
  2.   
  3.       caresses  ->  caress 
  4.       ponies    ->  poni 
  5.       ties      ->  ti 
  6.       caress    ->  caress 
  7.       cats      ->  cat 
  8.   
  9.       feed      ->  feed 
  10.       agreed    ->  agree 
  11.       disabled  ->  disable 
  12.   
  13.       matting   ->  mat 
  14.       mating    ->  mate 
  15.       meeting   ->  meet 
  16.       milling   ->  mill 
  17.       messing   ->  mess 
  18.   
  19.       meetings  ->  meet 
  20. */  
  21.    
  22. private final void step1()  
  23. {  if (b[k] == 's')  
  24.    {  if (ends("sses")) k -= 2//以“sses结尾”  
  25.       else if (ends("ies")) setto("i"); //以ies结尾,置为i  
  26.       else if (b[k-1] != 's') k--; //两个s结尾不处理  
  27.    }  
  28.    if (ends("eed")) { if (m() > 0) k--; } //以“eed”结尾,当m>0时,左移一位  
  29.    else if ((ends("ed") || ends("ing")) && vowelinstem())  
  30.    {  k = j;  
  31.       if (ends("at")) setto("ate"); else  
  32.       if (ends("bl")) setto("ble"); else  
  33.       if (ends("iz")) setto("ize"); else  
  34.       if (doublec(k))//如果有两个相同辅音  
  35.       {  k--;  
  36.          {  int ch = b[k];  
  37.             if (ch == 'l' || ch == 's' || ch == 'z') k++;  
  38.          }  
  39.       }  
  40.       else if (m() == 1 && cvc(k)) setto("e");  
  41.   }  
  42. }  

第二步,如果单词中包含元音,并且以y结尾,将y改为i。代码很简单:

[java] view plain copy
 print?
  1. private final void step2() { if (ends("y") && vowelinstem()) b[k] = 'i'; }  

第三步,将双后缀的单词映射为单后缀。

[java] view plain copy
 print?
  1. /* step3() 将双后缀的单词映射为单后缀。 所以 -ization ( = -ize 加上 
  2.    -ation) 被映射到 -ize 等等。 注意在去除后缀之前必须确保 
  3.    m() > 0. */  
  4. private final void step3() { if (k == 0return;  switch (b[k-1])  
  5. {  
  6.     case 'a'if (ends("ational")) { r("ate"); break; }  
  7.               if (ends("tional")) { r("tion"); break; }  
  8.               break;  
  9.     case 'c'if (ends("enci")) { r("ence"); break; }  
  10.               if (ends("anci")) { r("ance"); break; }  
  11.               break;  
  12.     case 'e'if (ends("izer")) { r("ize"); break; }  
  13.               break;  
  14.     case 'l'if (ends("bli")) { r("ble"); break; }  
  15.               if (ends("alli")) { r("al"); break; }  
  16.               if (ends("entli")) { r("ent"); break; }  
  17.               if (ends("eli")) { r("e"); break; }  
  18.               if (ends("ousli")) { r("ous"); break; }  
  19.               break;  
  20.     case 'o'if (ends("ization")) { r("ize"); break; }  
  21.               if (ends("ation")) { r("ate"); break; }  
  22.               if (ends("ator")) { r("ate"); break; }  
  23.               break;  
  24.     case 's'if (ends("alism")) { r("al"); break; }  
  25.               if (ends("iveness")) { r("ive"); break; }  
  26.               if (ends("fulness")) { r("ful"); break; }  
  27.               if (ends("ousness")) { r("ous"); break; }  
  28.               break;  
  29.     case 't'if (ends("aliti")) { r("al"); break; }  
  30.               if (ends("iviti")) { r("ive"); break; }  
  31.               if (ends("biliti")) { r("ble"); break; }  
  32.               break;  
  33.     case 'g'if (ends("logi")) { r("log"); break; }  
  34. } }  

第四步,处理-ic-,-full,-ness等等后缀。和步骤3有着类似的处理。

[java] view plain copy
 print?
  1. private final void step4() { switch (b[k])  
  2. {  
  3.     case 'e'if (ends("icate")) { r("ic"); break; }  
  4.               if (ends("ative")) { r(""); break; }  
  5.               if (ends("alize")) { r("al"); break; }  
  6.               break;  
  7.     case 'i'if (ends("iciti")) { r("ic"); break; }  
  8.               break;  
  9.     case 'l'if (ends("ical")) { r("ic"); break; }  
  10.               if (ends("ful")) { r(""); break; }  
  11.               break;  
  12.     case 's'if (ends("ness")) { r(""); break; }  
  13.               break;  
  14. } }  

第五步,在vcvc情形下,去除-ant,-ence等后缀。

[java] view plain copy
 print?
  1. private final void step5()  
  2. {   if (k == 0return;  switch (b[k-1])  
  3.     {  case 'a'if (ends("al")) breakreturn;  
  4.        case 'c'if (ends("ance")) break;  
  5.                  if (ends("ence")) breakreturn;  
  6.        case 'e'if (ends("er")) breakreturn;  
  7.        case 'i'if (ends("ic")) breakreturn;  
  8.        case 'l'if (ends("able")) break;  
  9.                  if (ends("ible")) breakreturn;  
  10.        case 'n'if (ends("ant")) break;  
  11.                  if (ends("ement")) break;  
  12.                  if (ends("ment")) break;  
  13.                  /* element etc. not stripped before the m */  
  14.                  if (ends("ent")) breakreturn;  
  15.        case 'o'if (ends("ion") && j >= 0 && (b[j] == 's' || b[j] == 't')) break;  
  16.                                  /* j >= 0 fixes Bug 2 */  
  17.                  if (ends("ou")) breakreturn;  
  18.                  /* takes care of -ous */  
  19.        case 's'if (ends("ism")) breakreturn;  
  20.        case 't'if (ends("ate")) break;  
  21.                  if (ends("iti")) breakreturn;  
  22.        case 'u'if (ends("ous")) breakreturn;  
  23.        case 'v'if (ends("ive")) breakreturn;  
  24.        case 'z'if (ends("ize")) breakreturn;  
  25.        defaultreturn;  
  26.     }  
  27.     if (m() > 1) k = j;  
  28. }  

第六步,也就是最后一步,在m()>1的情况下,移除末尾的“e”。

[java] view plain copy
 print?
  1. private final void step6()  
  2. {  j = k;  
  3.    if (b[k] == 'e')  
  4.    {  int a = m();  
  5.       if (a > 1 || a == 1 && !cvc(k-1)) k--;  
  6.    }  
  7.    if (b[k] == 'l' && doublec(k) && m() > 1) k--;  
  8. }  

在了解了步骤之后,我们写一个stem()方法,来完成得到词干的工作。

[java] view plain copy
 print?
  1. /** 通过调用add()方法来讲单词放入词干器数组b中 
  2.   * 可以通过下面的方法得到结果: 
  3.   * getResultLength()/getResultBuffer() or toString(). 
  4.   */  
  5. public void stem()  
  6. {  k = i - 1;  
  7.    if (k > 1) { step1(); step2(); step3(); step4(); step5(); step6(); }  
  8.    i_end = k+1; i = 0;  
  9. }  

最后要提醒的就是,传入的单词必须是小写。关于Porter Stemmer的实现,就看到这里。如果是Java代码这么写,无可厚非(实际上也不是很美观)。对于Python来说,如果写成这样,实在是让人难以接受。以后的文章,将会实现符合Python习惯的写法。

需要测试数据这里是样本文件。而相应的输出文件在这里。更多内容请参考官方网站。

另外,波特词干算法有第二个版本,它的处理结果要比文中所介绍的算法准确度高,但是,相应地也就更复杂,消耗的时间也就更多。本文就不作解释,详细参考官方网站The Porter2 stemming algorithm。

你可能感兴趣的:(自然语言处理)