1. 程式人生 > >chapter2:正則表示式、文字標準化和編輯距離

chapter2:正則表示式、文字標準化和編輯距離

Speech and Language Processing: An introduction to Natural Language Processing, Computational Linguistic, and Speech Recognition. Chapter 2

前言: 早期的自然語言處理工具ELIZA採用的方法是pattern matching. 而對於文字模式(text pattern)的描述,有一個很重要的工具:正則表示式(regular expression).

對文字處理任務的統稱,就是文字標準化(text normalization)。其中有:

  • tokenizing: 分詞?
  • lemmatization: 詞形還原。 確定表面不一樣的詞是否是同一個詞根,針對時態比較複雜的語言非常必要。
  • stemming:詞幹提取。 針對字首字尾,一種簡單的lemmatization
  • sentence segmantation: 句子分割. 用時態或者標點符號

最後提到編輯距離(edit distance):一種演算法,在自然語言處理和語音識別中都很常用。

正則表示式 regular expression

  1. 用斜線(slashes)來分隔正則表示式,斜線不是正則表示式的一部分。
    正則表示式的區分大小寫的,可以用方括號(square braces)來解決這個問題。

  1. 對所有的數字(digits)可以用/[1234567890]/,但對於所有的字母這樣就不太方便了,可用連字元(dash)(-)來表示一個範圍(range).

  1. 脫字元(caret)(^)

用脫字元表示否定,或者僅僅表示它自身。放在方括號的第一個位置時才有效。

  1. 用問號?表示前一個字元是可選的。

問號除了表示可選外,還有貪婪和非貪婪的區別。/.* ?/ 和 /.* /

  1. 用 * 表示前一個字元的零個或多個;用 + 表示前一個字元的一個或多個
    舉個栗子:

    baa!(至少兩個a)

    baaa!

    baaaaaa!

用 /baaa*/ 或 /baa+/ 可匹配上面這種形式。

單位數的價格可用 /[0-9]/,一個整數(字串)的正則表示式:/[0-9][0-9]* / 或者 /[0-9]+/

  1. 萬用字元(wildcard) /./

/./表示匹配任何字元,那麼和星號一起使用,就可以表示任何字串了。/.* /

  1. 錨號(anchor)是一種把正則表示式錨在字串中的特定位置,最普通的錨號是脫字元^和美元符$.

    • 脫字元 ^ 與行的開始相匹配。/^ The/ 表示單詞只出現在一行的開始,這樣脫字元就有三種用法了。
    • 美元符 $ 表示一行的結尾./^ The dog\\.\$/表示這一行只有The dog. 其中點號前面必須加反斜槓,因為我們要讓它表示點號,而不是萬用字元。
  2. 還有兩個錨號: \b表示詞界, \B表示非詞界

非數字、下劃線或字母,可以看做詞界。

Disjunction,Grouping, and Precedence 析取,組合和優先關係

  • 析取算符(Disjunction operator)(|),正則表示式 /cat|dog/ 表示字串是dog或者cat,對於字尾guppy和guppies,可以寫作 /gupp(y|ies)/

  • 圓括號算符“()”.我們知道 * 只能表示前一個字元的重複,但如果要重複一個字串呢,那就得用括號了。比如 /(column [0-9]+_*)*/ 就表示column後面跟一個數字和任意數目空格的重複~

  • 運算子的優先順序

正式因為 * 的優先順序高於序列,所以 /the* / 表示與theeeee匹配,而不是與thethe匹配。

還有正則表示式的匹配是貪心的(greedy).比如 /[a-z]* / 可以匹配零個或多個字母,所以結果是儘可能長的符號串。

怎麼讓它不貪心呢(non-greedy)? 可以這麼寫 /[a-z]* ?/ 或 /[a-z]+?/會匹配儘可能少的符號串。

一個簡單的栗子

要用正則表示式找到the

/the/

並不能找到the位於句子開頭的情況The

/[tT]he/

當the嵌入在其他單詞之間時theology,也是不對的

/\b[tT]he\b/

加入詞界後也不包括the_或者the25了,但如果我們也想找到這種情況中的the呢?那就說明,在the兩側不能出現字母。

/[\^a-zA-Z][tT]he[\^a-zA-Z]/

這樣仍然有問題,這意味著前面必須有個非字母符。所以應該這樣:

/(^|[\^a-zA-Z])[tT]he[\^a-zA-Z]/

更多算符

  1. 通用字符集的別名(aliases)

  2. 用於計數符的算符

  3. 需要加反斜槓的特殊算符

正則表示式中的替換(substitution)、儲存器(capture group)和ELIZA

s/regexp1/regexp2/ 表示用第二個正則表示式替換第一個的內容

  • s/colour/color/

  • s/([0-9]+)/<\1>/ 其中 \1表示參照第一個模式中的內容,也就是括號的內容,然後加上<>後對它進行替換。實際上就是找到這樣的,加上<>

  • /the (.* )er they were, the \1er they will be/

可以匹配 The bigger they were, the bigger they will be 但不能匹配 bigger they were, the faster they will be.

括號中用於儲存的模式叫做 capture group,而用於儲存的數字儲存器叫做 暫存器(register).

這樣一來圓括號就有了兩種含義了,可以用來優先順序的運算子,也可以用來capture group. 所以必須加以區別,用 ?: 來表示 non-capturing group. (?: pattern )

舉個栗子:

  • /(?:some|a few) (people|cats) like some \1/

可以用來匹配 some cats like some people,而不能匹配 some people like some a few. 因為\1 表示的是(people|cats)這個括號中的內容。

ELIZA:

這可真是“人工”智慧啊。。。hahha

Lookahead assertions

最後,有時候我們需要預測未來look ahead:在文字中向前看,看看有些模式是否匹配,但不會推進匹配遊標(match cursor),以便我們可以處理模式。 不推進匹配遊標是什麼意思?

lookahead assertions 使用(?=pattern)和(?!pattern).
The operator (?= pattern) is true if pattern occurs, but is zero-width.

負向預測:

/(ˆ?!Volcano)[A-Za-z]+/ 表示

這個不太理解,到regex.com上試了下:

Words and Corpora

在我們對word進行處理時,我們需要確定怎麼樣才算一個word.

語料庫:

  • written texts from different genres (newspaper, fiction, non-fiction,
    academic, etc.), Brown University in 1963–64 (Kučera and Francis,1967).

  • telephone conversations between strangers,(Godfrey et al., 1992).

disfluencies, fragment, filled pauses

舉個栗子:

  • I do uh main- mainly business data processing

對於語句中出現的不流利的地方 (disfluencies). main- 稱為片段 (fragment), 像uh和um這樣的稱為 fillers or filled pauses

我們在處理文字的時候是否需要保留這些不流利的地方呢,這取決於我們的應用。

Disfluencies like uh or um are actually helpful in speech recognition in predicting the upcoming word, because they may signal that the speaker is restarting the clause or idea, and so for speech recognition they are treated as regular words. Because people use different disfluencies they can also be a cue to speaker identification.

capitalized tokens or uncapitalized tokens

they 和 They 是否需要當做同一個單詞處理。 我們知道在 part-of-speech or named-entity tagging 中首字母大寫是很有用的特徵,這需要保留下來。

lemma and wordform

一句話中的WORD可以用兩種不同的標準來區分。一種是Lemma,一種是wordform。 wordform就是詞的形狀,而lemma則是詞意。比如 am is are ,都是一個lemma,但是3個wordform。在阿拉伯語中,需要將lemmatization,可能因為他們同一個詞意,能用的詞太多了吧,我記得看哪個視訊的時候說過駱駝,有四十多種。。。對於英語的話,wordform就夠了。

word type and word token

倘若以wordform的形式來界定一個詞,那麼一句話中WORD的數目還可以用兩種不同的標準來區分。Type是相同的詞都算一個,Token是每個詞出現幾次都算。所以 “no no no …. it is not possible” 這樣的一句話,Type 有5個,Token 有7個。

其中 Tokens N 和 types |V| 有這樣的關係:

|V|=kNβ

β 取決於語料庫的大小(size)和型別(genre).當語料庫至少有上圖中的大小時, β 的值的大小為0.67到0.75之間。

Roughly then we can say that the vocabulary size for a text goes up significantly faster than the square root of its length in words.

另外一種是以lemmas來界定一個詞,而不是wordform.

文字標準化 Text Normalization

在進行自然語言處理之前,都需要對文字進行標準化處理。
- Segmenting/tokenizing words from running text 分詞
- Normalizing word formats 單詞格式歸一化
- Segmenting sentences in running text. 句子分割

Unix tools for crude tokenization and normalization

介紹了一個Linux命令 tr 可用來統計詞頻

但這個統計非常簡單粗暴,去掉了所有的標點符號和數字

Word Tokenization and Normalization

介紹了標點符號在很多地方的用途:

  • Ph.D,m.p.h… 時間(09/04/18)..等等
  • email, urls
  • clitic contractions by apostrophes. 用’號表示的縮寫 what’re,we’re

根據應用不同,tokenize也會不同,比如New York通常也會標記為一個詞。在 name entity detection 中Tokenization會很有用。

tokenize standard: Penn Treebank tokenization standard 由Linguistic Data Consortium(LDC)釋出。

case folding: everything is mapped to lower case. 在語音識別和資訊檢索中會比較常用。

但是在sentiment anal-
ysis and other text classification tasks, information extraction, and machine transla-
tion 中大小寫是很有用的,因此通常不會使用case folding.

下一章中的有限狀態自動機 finite state automata 就是用基於正則表示式判別演算法編譯而成的。

中文詞分割:maximum matching/MaxMatch 最大匹配演算法

一種貪心演算法,需要一個字典(dictionary/wordlist)進行匹配.

虛擬碼:

#include <iostream>
#include <string>
using namespace std;

//巨集,計算陣列個數
#define GET_ARRAY_LEN(array, len){len=sizeof(array)/sizeof(array[0]);}

string dict[] = {"計算","計算語言學","課程","有","意思"};

//是否為詞表中的詞或詞表中的字首
bool inDict(string str)
{
    bool res = false;
    int i;
    int len = 0;

    GET_ARRAY_LEN(dict, len);

    for (i=0; i<len; i++){
        if (str == dict[i].substr(0, str.length()))
        {
            res = true;
        }

    }
    return res;
}

int main()
{
    string sentence = "計算語言學課程有意思";
    string word = "-";
    int wordlen = word.length(); // 1

    int i;
    string s1 = "";

    for (i=0; (unsigned)i<sentence.length(); i += wordlen)
    {
        string tmp = s1 + sentence.substr(i, wordlen); //每次增加一個詞

        if (inDict(tmp))
        {
            s1 = s1 + sentence.substr(i, wordlen);
        }
        else  // 如果不在詞表中,先打印出之前的結果,然後從下一個詞開始
        {
            cout << "分詞結果:" << s1 << endl;
            s1 = sentence.substr(i, wordlen);
        }
    }
    cout << "分詞結果:" << s1 << endl;
}

如果詞表足夠大的話,就可以對更多的句子進行分詞了。

我們用一個指標來量化分詞器的準確率,稱為 word error rate.

怎麼計算word error rate:通過計算最小編輯距離

We compare our output segmentation with a perfect hand-segmented (‘gold’) sentence, seeing how many words differ. The word error rate is then the normalized minimum edit distance in words between our output and the gold: the number of word insertions插入, deletions刪除, and substitutions替換 divided by the length of the gold sentence in words.

作者還提到最準確的中文分詞演算法是通過監督學習訓練的統計 sequence models, 在chapter 10中會講到。

Lemmatization and Stemming 詞形還原和詞幹提取

Lemmatization: 詞形還原,am, is,are有共同的詞元(Lemma):be

舉例說明:

He is reading detective stories. –> He
be read detective story.

那麼lemmatization是怎麼實現的呢?

The most sophisticated methods for lemmatization
involve complete morphological parsing(形態解析) of the word.
morphological parsing會在chapter3中講到。

Morphology is the study of the way words are built up from smaller meaning-bearing units called morphemes(語素).

語素包括兩類:

  • stems:詞幹
  • affixes: 詞綴
  • Python: NLTK
  • Python: Pattern
  • Python: TextBlob
  • Tree Tagger

The Porter Stemmer

通常我們用finite-state transducers 來處理 morphological parser,但我們有時候也會使用簡單粗暴的去掉詞綴的方法 stemming. 這裡作者就介紹了一種這樣的演算法 Poster algorithm.

演算法的原理主要是基於一些規則 cascade.

Sentence Segmentation 句子分割

主要是用標點符號啦~
比較unambiguous的標點符號有:Question marks and exclamation points

而Periods就比較ambiguous了。

具體的句子分割演算法垢面chapter會講到

Minimum Edit DIstance 最小編輯距離

用來表示兩個句子之間的相似性。

  • deletion 刪除: cost 1
  • insertion 插入: cost 1
  • substitution 替換: cost 2

The Minimum Edit Distance Algorithm

一種動態規劃的演算法。

dynamic programming,Bellman, R. (1957). Dynamic Programming. Princeton University Press. that apply a table-driven method to solve problems by combining solutions to sub-problems.

  • source string X[1…i…n]
  • target string Y[1…j…m]

用D(i,j)來定義X中前i個字元到Y中前j個字元的編輯距離,那麼X到Y的編輯距離就是D(n,m)

計算D[i,j],也就是遞推有三種方式:

定義cost:

初始情況:
- D(i,0) = i,也就是 source substring of length i but an empty target string
- D(o,j) = j,也就是 With a target
substring of length j but an empty source

那麼虛擬碼:

# 建立矩陣[n+1,m+1]
D = np.zeros(n+1, m+1)

# 1. Initialization:
D[0,0] = 0
for each row i for i to n:
  D[i,0] = D[i-1] + 1
for each column j from 1 to m:
  D[0,j] = D[0,i-1] + 1

# 2. Recurrence:
for each row i  from 1 to n:
  for each column j from 1 to m:
    D[i,j] = min(D[i-1,j]+1, D[i-1,j]+1, D[i−1, j−1]+2)

# 3. Termination:
return D[n,m]

我們知道了最小編輯距離是多少,但是我們還想知道最小編輯距離對應的兩個字串對齊方式 alignment.據說alignment在語音識別和機器翻譯中很有用~ 最小編輯距離和viterbi演算法、前向演算法很相似。

  • 最小編輯距離:遞推一步有三種選擇方式,然後取最小值。每一步中三種方式的權重weight也是有意義的。
  • Viterbi演算法:遞推一步有N個路徑,然後取max,可以看做最小編輯距離的拓展,權重在這裡就是概率。
  • 前向演算法:遞推每一步有N個路徑,然後取sum.

其中最小編輯距離和Viterbi演算法有 backtrace.

同樣的,在前向遞推的過程中填表:

填表的過程就是從D(0,0)開始,每進入一個 boldfaced cell(除了第0行和第0列)都有三種選擇,然後選擇最小的。

計算 alignment path,分為兩步驟:
- 在演算法計算的過程中,儲存後指標backpointer
- backtrace:從最後一行最後一列的cell開始,沿著指標,每一步都是最小的。

總結:

  1. 介紹了各種正則表示式

    • 用 - 表示range
    • 脫字元 ^ 的三種用法:自身,方括號中的否定,與行開頭匹配
    • 問號 ? 表示前一個字元是可選的
      • 表示前一個字元零個或多個, + 表示前一個字元一個或多個
    • . 表示萬用字元,匹配任意一個字元,/.* /匹配任意長度字元,且貪心的
    • 錨號 ^ 和 $ 匹配行開頭和結尾
    • 錨號 \b和\B 詞界和非詞界
  2. 析取,組合和優先關係
    主要是析取算符|和圓括號()的用法,以及運算子優先順序

  3. 替換和暫存器 s/regexp1/regexp2/ \1

  4. 基於正則表示式的分詞和文字標準化

  5. 用於詞幹提取stemming的簡單粗暴的演算法 Porter algorithm

  6. 用於描述字串相似度的演算法,最小編輯距離

#include <iostream>
#include <string>
using namespace std;


class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.length();
        int m = word2.length();
        int a[n+1][m+1];

        a[0][0] = 0;
        for (int i=1; i<=n; i++){
            a[i][0] = a[i-1][0] + 1;
        }

        for (int j=1; j<=m; j++){
            a[0][j] = a[0][j-1] + 1;
        }

        for (int i=1; i<=n; i++){
            for (int j=1; j<=m; j++){
                if (word1[i-1] != word2[j-1]){
                    int tmp = min(a[i-1][j-1] + 1, a[i-1][j] + 1);
                    a[i][j] = min(tmp, a[i][j-1] + 1);                   
                }
                else {
                    a[i][j] = a[i-1][j-1];
                }

            }
        }

        return a[n][m];
    }
};