在英语中,一个单词常常是另一个单词的“变种”,如:happy=>happiness,这里happy叫做happiness的词干(stem)。在信息检索系统中,我们常常做的一件事,就是在Term规范化过程中,提取词干(stemming),即除去英文单词分词变换形式的结尾。
应用最为广泛的、中等复杂程度的、基于后缀剥离的词干提取算法是波特词干算法,也叫波特词干器(Porter Stemmer)。详见官方网站。比较热门的检索系统包括Lucene、Whoosh等中的词干过滤器就是采用的波特词干算法。
词干提取算法无法达到100%的准确程度,因为语言单词本身的变化存在着许多例外的情况,无法概括到一般的规则中。使用词干提取算法能够帮助提高IR的性能。
波特词干算法的官方网站上,有各个语言的实现版本(其实都是C标准的各个翻译形式)。各位要应用到实际生产中可以直接下载对应的版本。本文将会分析Java语言的源码。在今后的文章中,再介绍使用Python特性优化过的版本。(Python原版几乎就是C语言版本的翻译,这也就意味着不能充分利用Python的语言特性。)
在实际处理中,需要分六步走。首先,我们先定义一个Stemmer类。
class Stemmer { private char[] b; private int i, /* b中的元素位置(偏移量) */ i_end, /* 要抽取词干单词的结束位置 */ j, k; private static final int INC = 50; /* 随着b的大小增加数组要增长的长度(防止溢出) */ public Stemmer() { b = new char[INC]; i = 0; i_end = 0; } }
这里,b是一个数组,用来存待词干提取的单词(以char的形式)。这里的变量k会随着词干抽取而变化。
接着,我们要添加单词来进行处理:
/** * 增加一个字符到要存放待处理的单词的数组。添加完字符时, * 可以调用stem(void)方法来进行抽取词干的工作。 */ public void add(char ch) { if (i == b.length) { char[] new_b = new char[i+INC]; for (int c = 0; c < i; c++) new_b[c] = b[c]; b = new_b; } b[i++] = ch; } /** 增加wLen长度的字符数组到存放待处理的单词的数组b。 */ public void add(char[] w, int wLen) { if (i+wLen >= b.length) { char[] new_b = new char[i+wLen+INC]; for (int c = 0; c < i; c++) new_b[c] = b[c]; b = new_b; } for (int c = 0; c < wLen; c++) b[i++] = w[c]; }
大家可能会觉得这么处理字符串太麻烦了吧,要明白,整个代码是从C移植过来的。
接下来,是一系列工具函数。首先先介绍一下它们:
简单贴出来这些工具函数的代码。
// cons(i) 为真 <=> b[i] 是一个辅音 private final boolean cons(int i) { switch (b[i]) { case 'a': case 'e': case 'i': case 'o': case 'u': return false; //aeiou case 'y': return (i==0) ? true : !cons(i-1); //y开头,为辅;否则看i-1位,如果i-1位为辅,y为元,反之亦然。 default: return true; } } // m() 用来计算在0和j之间辅音序列的个数。 见上面的说明。 */ private final int m() { int n = 0; //辅音序列的个数,初始化 int i = 0; //偏移量 while(true) { if (i > j) return n; //如果超出最大偏移量,直接返回n if (! cons(i)) break; //如果是元音,中断 i++; //辅音移一位,直到元音的位置 } i++; //移完辅音,从元音的第一个字符开始 while(true)//循环计算vc的个数 { while(true) //循环判断v { if (i > j) return n; if (cons(i)) break; //出现辅音则终止循环 i++; } i++; n++; while(true) //循环判断c { if (i > j) return n; if (! cons(i)) break; i++; } i++; } } // vowelinstem() 为真 <=> 0,...j 包含一个元音 private final boolean vowelinstem() { int i; for (i = 0; i <= j; i++) if (! cons(i)) return true; return false; } // doublec(j) 为真 <=> j,(j-1) 包含两个一样的辅音 private final boolean doublec(int j) { if (j < 1) return false; if (b[j] != b[j-1]) return false; return cons(j); } /* cvc(i) is 为真 <=> i-2,i-1,i 有形式: 辅音 - 元音 - 辅音 并且第二个c不是 w,x 或者 y. 这个用来处理以e结尾的短单词。 e.g. cav(e), lov(e), hop(e), crim(e), 但不是 snow, box, tray. */ private final boolean cvc(int i) { if (i < 2 || !cons(i) || cons(i-1) || !cons(i-2)) return false; { int ch = b[i]; if (ch == 'w' || ch == 'x' || ch == 'y') return false; } return true; } private final boolean ends(String s) { int l = s.length(); int o = k-l+1; if (o < 0) return false; for (int i = 0; i < l; i++) if (b[o+i] != s.charAt(i)) return false; j = k-l; return true; } // setto(s) 设置 (j+1),...k 到s字符串上的字符, 并且调整k值 private final void setto(String s) { int l = s.length(); int o = j+1; for (int i = 0; i < l; i++) b[o+i] = s.charAt(i); k = j+l; } private final void r(String s) { if (m() > 0) setto(s); }
接下来,就是分六步来进行处理的过程。
第一步,处理复数,以及ed和ing结束的单词。
/* step1() 处理复数,ed或者ing结束的单词。比如: caresses -> caress ponies -> poni ties -> ti caress -> caress cats -> cat feed -> feed agreed -> agree disabled -> disable matting -> mat mating -> mate meeting -> meet milling -> mill messing -> mess meetings -> meet */ private final void step1() { if (b[k] == 's') { if (ends("sses")) k -= 2; //以“sses结尾” else if (ends("ies")) setto("i"); //以ies结尾,置为i else if (b[k-1] != 's') k--; //两个s结尾不处理 } if (ends("eed")) { if (m() > 0) k--; } //以“eed”结尾,当m>0时,左移一位 else if ((ends("ed") || ends("ing")) && vowelinstem()) { k = j; if (ends("at")) setto("ate"); else if (ends("bl")) setto("ble"); else if (ends("iz")) setto("ize"); else if (doublec(k))//如果有两个相同辅音 { k--; { int ch = b[k]; if (ch == 'l' || ch == 's' || ch == 'z') k++; } } else if (m() == 1 && cvc(k)) setto("e"); } }
private final void step2() { if (ends("y") && vowelinstem()) b[k] = 'i'; }
/* step3() 将双后缀的单词映射为单后缀。 所以 -ization ( = -ize 加上 -ation) 被映射到 -ize 等等。 注意在去除后缀之前必须确保 m() > 0. */ private final void step3() { if (k == 0) return; switch (b[k-1]) { case 'a': if (ends("ational")) { r("ate"); break; } if (ends("tional")) { r("tion"); break; } break; case 'c': if (ends("enci")) { r("ence"); break; } if (ends("anci")) { r("ance"); break; } break; case 'e': if (ends("izer")) { r("ize"); break; } break; case 'l': if (ends("bli")) { r("ble"); break; } if (ends("alli")) { r("al"); break; } if (ends("entli")) { r("ent"); break; } if (ends("eli")) { r("e"); break; } if (ends("ousli")) { r("ous"); break; } break; case 'o': if (ends("ization")) { r("ize"); break; } if (ends("ation")) { r("ate"); break; } if (ends("ator")) { r("ate"); break; } break; case 's': if (ends("alism")) { r("al"); break; } if (ends("iveness")) { r("ive"); break; } if (ends("fulness")) { r("ful"); break; } if (ends("ousness")) { r("ous"); break; } break; case 't': if (ends("aliti")) { r("al"); break; } if (ends("iviti")) { r("ive"); break; } if (ends("biliti")) { r("ble"); break; } break; case 'g': if (ends("logi")) { r("log"); break; } } }
private final void step4() { switch (b[k]) { case 'e': if (ends("icate")) { r("ic"); break; } if (ends("ative")) { r(""); break; } if (ends("alize")) { r("al"); break; } break; case 'i': if (ends("iciti")) { r("ic"); break; } break; case 'l': if (ends("ical")) { r("ic"); break; } if (ends("ful")) { r(""); break; } break; case 's': if (ends("ness")) { r(""); break; } break; } }
private final void step5() { if (k == 0) return; switch (b[k-1]) { case 'a': if (ends("al")) break; return; case 'c': if (ends("ance")) break; if (ends("ence")) break; return; case 'e': if (ends("er")) break; return; case 'i': if (ends("ic")) break; return; case 'l': if (ends("able")) break; if (ends("ible")) break; return; case 'n': if (ends("ant")) break; if (ends("ement")) break; if (ends("ment")) break; /* element etc. not stripped before the m */ if (ends("ent")) break; return; case 'o': if (ends("ion") && j >= 0 && (b[j] == 's' || b[j] == 't')) break; /* j >= 0 fixes Bug 2 */ if (ends("ou")) break; return; /* takes care of -ous */ case 's': if (ends("ism")) break; return; case 't': if (ends("ate")) break; if (ends("iti")) break; return; case 'u': if (ends("ous")) break; return; case 'v': if (ends("ive")) break; return; case 'z': if (ends("ize")) break; return; default: return; } if (m() > 1) k = j; }
private final void step6() { j = k; if (b[k] == 'e') { int a = m(); if (a > 1 || a == 1 && !cvc(k-1)) k--; } if (b[k] == 'l' && doublec(k) && m() > 1) k--; }
/** 通过调用add()方法来讲单词放入词干器数组b中 * 可以通过下面的方法得到结果: * getResultLength()/getResultBuffer() or toString(). */ public void stem() { k = i - 1; if (k > 1) { step1(); step2(); step3(); step4(); step5(); step6(); } i_end = k+1; i = 0; }
最后要提醒的就是,传入的单词必须是小写。关于Porter Stemmer的实现,就看到这里。如果是Java代码这么写,无可厚非(实际上也不是很美观)。对于Python来说,如果写成这样,实在是让人难以接受。以后的文章,将会实现符合Python习惯的写法。
需要测试数据这里是样本文件。而相应的输出文件在这里。更多内容请参考官方网站。
另外,波特词干算法有第二个版本,它的处理结果要比文中所介绍的算法准确度高,但是,相应地也就更复杂,消耗的时间也就更多。本文就不作解释,详细参考官方网站The Porter2 stemming algorithm。