chapter2:正则表达式、文本标准化和编辑距离

  • 各种正则表达式,析取,组合和优先关系
  • 文本标准化:各种预处理方法
  • 编辑距离:动态规划

前言: 早期的自然语言处理工具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. 其中点号前面必须加反斜杠,因为我们要让它表示点号,而不是通配符。
  1. 还有两个锚号: \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个。Token包括重复词。

其中 Tokens N 和 types |V| 有这样的关系: \[|V|=kN^{\beta}\]

\(\beta\) 取决于语料库的大小(size)和类型(genre).当语料库至少有上图中的大小时, \(\beta\) 的值的大小为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)进行匹配.

伪代码:

代码参考:http://www.cnblogs.com/by-dream/p/6429615.html

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
#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: 词缀

关于词形还原的工具:blog,词形还原工具对比

  • 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

那么伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 创建矩阵[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 词界和非词界
  1. 析取,组合和优先关系 主要是析取算符|和圆括号()的用法,以及运算符优先级

  2. 替换和寄存器 s/regexp1/regexp2/ \1

  3. 基于正则表达式的分词和文本标准化

  4. 用于词干提取stemming的简单粗暴的算法 Porter algorithm

  5. 用于描述字符串相似度的算法,最小编辑距离

leetcode上有个编辑距离的题目:https://leetcode.com/problems/edit-distance/description/

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
#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];
}
};