在英语中,一个单词常常是另一个单词的“变种”,如:happy=>happiness,这里happy叫做happiness的词干(stem)。在信息检索系统中,我们常常做的一件事,就是在Term规范化过程中,提取词干(stemming),即除去英文单词分词变换形式的结尾。
应用最为广泛的、中等复杂程度的、基于后缀剥离的词干提取算法是波特词干算法,也叫波特词干器(Porter Stemmer)。详见官方网站。比较热门的检索系统包括Lucene、Whoosh等中的词干过滤器就是采用的波特词干算法。
简单说一下历史:
马丁.波特博士(Dr. Martin Porter)于1979年,在英国剑桥大学,计算机实验室,发明了波特词干算法。
波特词干算法当时是作为一个大型IR项目的一部分被提出的。它的原始论文为:
C.J. van Rijsbergen, S.E. Robertson and M.F. Porter, 1980. New models in probabilistic information retrieval. London: British Library. (British Library Research and Development Report, no. 5587).
最初的波特词干提取算法是使用BCPL语言编写的。作者在其网站上公布了各种语言的实现版本,其中C语言的版本是作者编写的最权威的版本。
波特词干器适用于涉及到提取词干的IR研究工作,其实验结果是可重复的,言外之意是说,波特词干器的输出结果是确定性的,不是随机的。(还有基于随机的高级词干提取算法,虽然会更准确,但同时也更加复杂)。
词干提取算法无法达到100%的准确程度,因为语言单词本身的变化存在着许多例外的情况,无法概括到一般的规则中。使用词干提取算法能够帮助提高IR的性能。
波特词干算法的官方网站上,有各个语言的实现版本(其实都是C标准的各个翻译形式)。各位要应用到实际生产中可以直接下载对应的版本。本文将会分析Java语言的源码。在今后的文章中,再介绍使用Python特性优化过的版本。(Python原版几乎就是C语言版本的翻译,这也就意味着不能充分利用Python的语言特性。)
在实际处理中,需要分六步走。首先,我们先定义一个Stemmer类。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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会随着词干抽取而变化。
接着,我们要添加单词来进行处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/**
* 增加一个字符到要存放待处理的单词的数组。添加完字符时,
* 可以调用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移植过来的。
接下来,是一系列工具函数。首先先介绍一下它们:
简单贴出来这些工具函数的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
|
// 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结束的单词。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
/* 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"
);
}
}
|
第二步,如果单词中包含元音,并且以y结尾,将y改为i。代码很简单:
1
|
private
final
void
step2() {
if
(ends(
"y"
) && vowelinstem()) b[k] =
'i'
; }
|
第三步,将双后缀的单词映射为单后缀。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
/* 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
; }
} }
|
第四步,处理-ic-,-full,-ness等等后缀。和步骤3有着类似的处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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
;
} }
|
第五步,在
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
private
final
void
step5()
{
if
(k ==
0
)
return
;
switch
(b[k-
1
])
{
case
'a'
:
if
(ends(
"al"
))
break
;
return
;
case
'c'
|