Lucene里面的分詞器里面有一個PorterStemFilter類,里就用到了著名的詞干提取算法。所謂Stemming,就是詞干,在英語中單詞有多種變形。比如單復數加s,進行時加ing等等。在分詞的時候,如果能夠把這些變形單詞的詞根找出了,對搜索結果是很有幫助的。Stemming算法有很多了,三大主流算法是Porter stemming algorithm、Lovins stemming algorithm、Lancaster (Paice/Husk) stemming algorithm,還有一些改進的或其它的算法。這個PorterStemFilter里面調用的一個PorterStemmer就是Porter Stemming algorithm的一個實現。 其主頁為http://tartarus.org/~martin/PorterStemmer/,也可查看其論文http://tartarus.org/~martin/PorterStemmer/def.txt。通過以下網頁可以進行簡單的測試:Porter's Stemming Algorithm Online[http://facweb.cs.depaul.edu/mobasher/classes/csc575/porter.html]。
網上找了好久,才找到一個對此算法解釋的文章,它用的是Java版的代碼,這里我改成用.net版的。主要是把里面的函數作了一下注釋,個人沒做什么分析,本身是想的,結果看着就頭痛。下面的東西都是來自這篇博文波特詞干算法,我只是把這里的代碼改成了.net的。
接下來,是一系列工具函數。首先先介紹一下它們:
- cons(i):參數i:int型;返回值bool型。當i為輔音時,返回真;否則為假。
/// <summary>
/// cons(i) 為真 <=> b[i] 是一個輔音
/// </summary>
private bool cons(int i)
{
switch (b[i])
{
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
return false;
case 'y':
return (i == k0) ? true : !cons(i - 1);//y開頭,為輔;否則看i-1位,如果i-1位為輔,y為元,反之亦然。
default:
return true;
}
}
- m():返回值:int型。表示單詞b介於0和j之間輔音序列的個度。現假設c代表輔音序列,而v代表元音序列。<..>表示任意存在。於是有如下定義;
- <c><v> 結果為 0
- <c>vc<v> 結果為 1
- <c>vcvc<v> 結果為 2
- <c>vcvcvc<v> 結果為 3
- ....
/// <summary>
/// m() 用來計算在0和j之間輔音序列的個數
/// </summary>
/// <returns></returns>
private int m()
{
int n = 0;//輔音序列的個數,初始化
int i = k0;//偏移量
while (true)
{
if (i > j)//如果超出最大偏移量,直接返回n
return 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():返回值:bool型。從名字就可以看得出來,表示單詞b介於0到i之間是否存在元音。
/// <summary>
/// vowelinstem() 為真 <=> 0,...j 包含一個元音
/// </summary>
/// <returns>[To be supplied.]</returns>
private bool vowelinstem()
{
int i;
for (i = k0; i <= j; i++)
if (!cons(i))
return true;
return false;
}
- doublec(j):參數j:int型;返回值bool型。這個函數用來表示在j和j-1位置上的兩個字符是否是相同的輔音。
/// <summary>
/// doublec(j) 為真 <=> j,(j-1) 包含兩個一樣的輔音
/// </summary>
/// <param name="j"></param>
/// <returns></returns>
private bool doublec(int j)
{
if (j < k0 + 1)
return false;
if (b[j] != b[j - 1])
return false;
return cons(j);
}
- 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就輔符合條件。
/* 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 bool cvc(int i)
{
if (i < k0 + 2 || !cons(i) || cons(i - 1) || !cons(i - 2))
return false;
else
{
int ch = b[i];
if (ch == 'w' || ch == 'x' || ch == 'y') return false;
}
return true;
}
- ends(s):參數:String;返回值:bool型。顧名思義,判斷b是否以s結尾。
private bool ends(string s)
{
int l = s.Length;
int o = k - l + 1;
if (o < k0)
return false;
for (int i = 0; i < l; i++)
if (b[o + i] != s[i])
return false;
j = k - l;
return true;
}
- setto(s):參數:String;void類型。把b在(j+1)...k位置上的字符設為s,同時,調整k的大小。
// setto(s) 設置 (j+1),...k 到s字符串上的字符, 並且調整k值
void setto(string s)
{
int l = s.Length;
int o = j + 1;
for (int i = 0; i < l; i++)
b[o + i] = s[i];
k = j + l;
dirty = true;
}
- r(s):參數:String;void類型。在m()>0的情況下,調用setto(s)。
void r(string s) { if (m() > 0) setto(s); }
接下來,就是分六步來進行處理的過程。
第一步,處理復數,以及ed和ing結束的單詞。
private 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"))//以“eed”結尾,當m>0時,左移一位
{
if (m() > 0)
k--;
}
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))//如果有兩個相同輔音
{
int ch = b[k--];
if (ch == 'l' || ch == 's' || ch == 'z')
k++;
}
else if (m() == 1 && cvc(k))
setto("e");
}
}
第二步,如果單詞中包含元音,並且以y結尾,將y改為i。代碼很簡單:
//如果單詞中包含元音,並且以y結尾,將y改為i
private void step2()
{
if (ends("y") && vowelinstem())
{
b[k] = 'i';
dirty = true;
}
}
第三步,將雙后綴的單詞映射為單后綴。
/* step3() 將雙后綴的單詞映射為單后綴。
* 所以 -ization ( = -ize 加上 -ation) 被映射到 -ize 等等。
* 注意在去除后綴之前必須確保 m() > 0. */
private void step3()
{
if (k == k0) return; /* For Bug 1 */
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; }
break;
}
}
第四步,處理-ic-,-full,-ness等等后綴。和步驟3有着類似的處理。
/* step4() deals with -ic-, -full, -ness etc. similar strategy to step3. */
//處理-ic-,-full,-ness等等后綴。和步驟3有着類似的處理。
private 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;
}
}
第五步,在<c>vcvc<v>情形下,去除-ant,-ence等后綴。
//step5() takes off -ant, -ence etc., in context <c>vcvc<v>.
//在<c>vcvc<v>情形下,去除-ant,-ence等后綴。
private void step5()
{
if (k == k0) return; /* for Bug 1 */
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;
}
第六步,也就是最后一步,在m()>1的情況下,移除末尾的“e”。
// step6() removes a final -e if m() > 1.
//也就是最后一步,在m()>1的情況下,移除末尾的“e”。
private 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--;
}
在了解了步驟之后,我們寫一個stem()方法,來完成得到詞干的工作。
public bool stem(int i0)
{
k = i - 1;
k0 = i0;
if (k > k0 + 1)
{
step1(); step2(); step3(); step4(); step5(); step6();
}
// Also, a word is considered dirty if we lopped off letters
// Thanks to Ifigenia Vairelles for pointing this out.
if (i != k + 1)
dirty = true;
i = k + 1;
return dirty;
}
最后要提醒的就是,傳入的單詞必須是小寫。關於Porter Stemmer的實現就是這些.
需要測試數據這里是樣本文件。而相應的輸出文件在這里。更多內容請參考官方網站。
另外,波特詞干算法有第二個版本,它的處理結果要比文中所介紹的算法准確度高,但是,相應地也就更復雜,消耗的時間也就更多。本文就不作解釋,詳細參考官方網站The Porter2 stemming algorithm。
這里有一個關於此算法的應用:WordCloud - A Squarified Treemap of Word Frequency
以上的解釋轉自前面所說的博客,你可以在本文最后的參考資料中找到鏈接.
這是整個PorterStemmer類的代碼:
public class PorterStemmer { private char[] b; private int i, /* offset into b */ j, k, k0; private bool dirty = false; private static int INC = 50; /* unit of size whereby b is increased */ private static int EXTRA = 1; /// /// Initializes a new instance of the PorterStemmer class. /// public PorterStemmer() { b = new char[INC]; i = 0; } /// /// reset() resets the stemmer so it can stem another word. If you invoke /// the stemmer by calling add(char) and then stem(), you must call reset() /// before starting another word. /// public void reset() { i = 0; dirty = false; } /// /// Add a character to the word being stemmed. When you are finished /// adding characters, you can call stem(void) to process the word. /// public void add(char ch) { if (b.Length <= i + EXTRA) { char[] new_b = new char[b.Length + INC]; for (int c = 0; c < b.Length; c++) new_b[c] = b[c]; b = new_b; } b[i++] = ch; } /// /// After a word has been stemmed, it can be retrieved by toString(), /// or a reference to the internal buffer can be retrieved by getResultBuffer /// and getResultLength (which is generally more efficient.) /// public string toString() { return new string(b, 0, i); } /// /// Returns the length of the word resulting from the stemming process. /// public int getResultLength() { return i; } /// /// Returns a reference to a character buffer containing the results of /// the stemming process. You also need to consult getResultLength() /// to determine the length of the result. /// public char[] getResultBuffer() { return b; } /// /// cons(i) is true <=> b[i] is a consonant. /// cons(i) 為真 <=> b[i] 是一個輔音 /// /// The input parameter. /// True or False. private bool cons(int i) { switch (b[i]) { case 'a': case 'e': case 'i': case 'o': case 'u': return false; case 'y': return (i == k0) ? true : !cons(i - 1);//y開頭,為輔;否則看i-1位,如果i-1位為輔,y為元,反之亦然。 default: return true; } } /// /// m() 用來計算在0和j之間輔音序列的個數 /// m() measures the number of consonant sequences between k0 and j. if c is /// a consonant sequence and v a vowel sequence, and <..> indicates arbitrary /// presence, /// /// gives 0 /// vc gives 1 /// vcvc gives 2 /// vcvcvc gives 3 /// .... /// /// private int m() { int n = 0;//輔音序列的個數,初始化 int i = k0;//偏移量 while (true) { if (i > j)//如果超出最大偏移量,直接返回n return 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() is true <=> k0,...j contains a vowel /// vowelinstem() 為真 <=> 0,...j 包含一個元音 /// /// [To be supplied.] private bool vowelinstem() { int i; for (i = k0; i <= j; i++) if (!cons(i)) return true; return false; } /// /// doublec(j) is true <=> j,(j-1) contain a double consonant. /// doublec(j) 為真 <=> j,(j-1) 包含兩個一樣的輔音 /// /// /// private bool doublec(int j) { if (j < k0 + 1) return false; if (b[j] != b[j - 1]) return false; return cons(j); } /* cvc(i) is true <=> i-2,i-1,i has the form consonant - vowel - consonant and also if the second c is not w,x or y. this is used when trying to restore an e at the end of a short word. e.g. cav(e), lov(e), hop(e), crim(e), but snow, box, tray. */ /* 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 bool cvc(int i) { if (i < k0 + 2 || !cons(i) || cons(i - 1) || !cons(i - 2)) return false; else { int ch = b[i]; if (ch == 'w' || ch == 'x' || ch == 'y') return false; } return true; } private bool ends(string s) { int l = s.Length; int o = k - l + 1; if (o < k0) return false; for (int i = 0; i < l; i++) if (b[o + i] != s[i]) return false; j = k - l; return true; } /* setto(s) sets (j+1),...k to the characters in the string s, readjusting k. */ // setto(s) 設置 (j+1),...k 到s字符串上的字符, 並且調整k值 void setto(string s) { int l = s.Length; int o = j + 1; for (int i = 0; i < l; i++) b[o + i] = s[i]; k = j + l; dirty = true; } /* r(s) is used further down. */ void r(string s) { if (m() > 0) setto(s); } /* step1() gets rid of plurals and -ed or -ing. e.g. 處理復數,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 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"))//以“eed”結尾,當m>0時,左移一位 { if (m() > 0) k--; } 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))//如果有兩個相同輔音 { int ch = b[k--]; if (ch == 'l' || ch == 's' || ch == 'z') k++; } else if (m() == 1 && cvc(k)) setto("e"); } } /* step2() turns terminal y to i when there is another vowel in the stem. */ //如果單詞中包含元音,並且以y結尾,將y改為i private void step2() { if (ends("y") && vowelinstem()) { b[k] = 'i'; dirty = true; } } /* step3() maps double suffices to single ones. so -ization ( = -ize plus -ation) maps to -ize etc. note that the string before the suffix must give m() > 0. */ /* step3() 將雙后綴的單詞映射為單后綴。 * 所以 -ization ( = -ize 加上 -ation) 被映射到 -ize 等等。 * 注意在去除后綴之前必須確保 m() > 0. */ private void step3() { if (k == k0) return; /* For Bug 1 */ 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; } break; } } /* step4() deals with -ic-, -full, -ness etc. similar strategy to step3. */ //處理-ic-,-full,-ness等等后綴。和步驟3有着類似的處理。 private 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; } } /* step5() takes off -ant, -ence etc., in context vcvc. */ //在vcvc情形下,去除-ant,-ence等后綴。 private void step5() { if (k == k0) return; /* for Bug 1 */ 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; } // step6() removes a final -e if m() > 1. //也就是最后一步,在m()>1的情況下,移除末尾的“e”。 private 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--; } /// /// Stem a word provided as a string. Returns the result as a string. /// public string stem(string s) { if (stem(s.ToCharArray(), s.Length)) return toString(); else return s; } /// Stem a word contained in a char[]. Returns true if the stemming process /// resulted in a word different from the input. You can retrieve the /// result with getResultLength()/getResultBuffer() or toString(). /// public bool stem(char[] word) { return stem(word, word.Length); } /// Stem a word contained in a portion of a char[] array. Returns /// true if the stemming process resulted in a word different from /// the input. You can retrieve the result with /// getResultLength()/getResultBuffer() or toString(). /// public bool stem(char[] wordBuffer, int offset, int wordLen) { reset(); if (b.Length < wordLen) { char[] new_b = new char[wordLen + EXTRA]; b = new_b; } for (int j = 0; j < wordLen; j++) b[j] = wordBuffer[offset + j]; i = wordLen; return stem(0); } /// Stem a word contained in a leading portion of a char[] array. /// Returns true if the stemming process resulted in a word different /// from the input. You can retrieve the result with /// getResultLength()/getResultBuffer() or toString(). /// public bool stem(char[] word, int wordLen) { return stem(word, 0, wordLen); } /// Stem the word placed into the Stemmer buffer through calls to add(). /// Returns true if the stemming process resulted in a word different /// from the input. You can retrieve the result with /// getResultLength()/getResultBuffer() or toString(). /// public bool stem() { return stem(0); } /// /// [To be supplied.] /// /// [To be supplied.] /// [To be supplied.] public bool stem(int i0) { k = i - 1; k0 = i0; if (k > k0 + 1) { step1(); step2(); step3(); step4(); step5(); step6(); } // Also, a word is considered dirty if we lopped off letters // Thanks to Ifigenia Vairelles for pointing this out. if (i != k + 1) dirty = true; i = k + 1; return dirty; } /// Test program for demonstrating the Stemmer. It reads a file and /// stems each word, writing the result to standard out. /// Usage: Stemmer file-name /// public static void Main(string[] args) { PorterStemmer s = new PorterStemmer(); for (int i = 0; i < args.Length; i++) { try { FileStream fs = new FileStream(args[i], FileMode.Open); byte[] buffer = new byte[1024]; int bufferLen, offset, ch; bufferLen = fs.Read(buffer, 0, buffer.Length); offset = 0; s.reset(); while (true) { if (offset < bufferLen) ch = buffer[offset++]; else { bufferLen = fs.Read(buffer, 0, buffer.Length); offset = 0; if (bufferLen < 0) ch = -1; else ch = buffer[offset++]; } if (Char.IsLetter((char)ch)) { s.add(Char.ToLower((char)ch)); } else { s.stem(); Console.Write(s.toString()); s.reset(); if (ch < 0) break; else { Console.Write((char)ch); } } } fs.Close(); } catch (IOException e) { Console.WriteLine("error reading " + args[i], e); } } } }
參考資料:
2.波特詞干算法
3.Lucene源碼及自帶的注釋