herose  
     
     
 

  多语言标记及语音合成系统的设计与C# 3.0实现
                                -快速区分近百种语言
                                          -带有多语言语音合成的RSS阅读器

肖涵 蒲旭

摘要:本文讲解了一个多语言自动标记系统的C# 3.0实现。其使用Unicode 编码特性及带有回退算法的NGram模型对未知语言进行判别,并采用合并算法标记出相同语言的句子。在分析了优化策略后,给出了一个自学习和简化算法,使得标记结果的正确率提升。为了展示多语言识别的应用,文章还用LINQ实现了一个RSS阅读器,根据已经标记的语言,调用相应的TTS进行多语言语音合成。阅读本文章,您不仅能对自然语言处理有初步的认识,还能看到.NET Framework 3.0/3.5的新技术 LINQ, Speech Synthesis在应用层面上简化开发的。
关键词:统计语言模型, N元语法, 语言分类, LINQ, 语音合成, .NET框架3.0/3.5, C# 3.0

一、背景
    当我在Google上搜索文献的时候,经常会得到一些其他国语言的网页。好奇的我迫切想知道这些文章的大概内容——当然,我想到了Google Translate。但是,该选择哪种语言翻译到我的母语呢?最后能有一个语言区分器帮助我完成这个语言辨识的任务。Lispeln正是源于这个思想,实现了一个多语言文本的分句自动标记。并为读者展示了一个多语言标记的应用场景:实现了支持多语言的语音合成的RSS阅读器。我们可以随意订阅各个语言的Blog,新闻,剩下的事情就交给Lispeln来完成——您所需要的,只是靠在沙发上倾听这些奇闻异事。
下面我们从Lispeln的内核开始,一步一步了解这个系统的架构及运作,开始我们的学习。

二、多语言标记系统的设计与实现
    在了解Lispeln多语言标记内核之前,我们有必要先了解一下有关Unicode的和统计语言模型预备知识,这对我们后面的正确设计将有所帮助。
1. 预备知识
(1) Unicode编码中的基本多文种平面
    基本多文种平面,BMP(Basic Multilingual Plane),或称第零平面(Plane 0),是Unicode中的一个编码区段,其囊括了当今世界上所使用的全部字符。按照Unicode规范,这些字符被编码在不同的区块(Block)。编码范围从U+0000至U+FFFF。Unicode.org提供了这些区块的描述,和对其中一些字符的说明。虽然从编码块看,不同的区块之间字符的确可能属于不同的语言。但仅仅从这一点我们不能完全确定一种语言,因为同一区块中的字符可能在不同语言中被使用。例如编码在0000-007F(Basic Latin)的拉丁字母,可能在英德法等多种语言中使用(实际上我们将看到拉丁字母在多达20多种语言中使用),而编码在3000-303F (CJK Symbols and Punctuation) 的CJK 符号和标点则可能在中日韩三种语言中使用。
               表格 1 Unicode.org上提供的Block描述格式(部分)
 

0000..007F

 Basic Latin

0080..00FF

 Latin-1 Supplement

0100..017F

 Latin Extended-A

0180..024F

 Latin Extended-B

0250..02AF

 IPA Extensions

02B0..02FF

 Spacing Modifier Letters

0300..036F

 Combining Diacritical Marks

0370..03FF

 Greek and Coptic

0400..04FF

 Cyrillic

0500..052F

 Cyrillic Supplement

0530..058F

 Armenian

    实际上,Unicode.org上还提供了一组对我们更有用的Scripts信息。Scripts中实际上比Blocks更为细致的映射了每个字符码所对应的描述,这一点我们可以对照表格一和表格2可以清楚的看出。在后文中,我们将使用Scripts而不是Blocks信息进行初步的语言判定,这很大程度是因为Unicode.org没有提供Blocks到Language映射关系。
            表格 2 Unicode.org上提供的Scripts信息(部分)

0000..001F   

 Common # Cc  [32] <control-0000>..<control-001F>

20

 Common # Zs       SPACE

0021..0023   

 Common # Po   [3] EXCLAMATION MARK..NUMBER SIGN

24

 Common # Sc       DOLLAR SIGN

0025..0027   

 Common # Po   [3] PERCENT SIGN..APOSTROPHE

28

 Common # Ps       LEFT PARENTHESIS

29

 Common # Pe       RIGHT PARENTHESIS

002A         

 Common # Po       ASTERISK

002B         

 Common # Sm       PLUS SIGN

002C         

 Common # Po       COMMA

002D         

 Common # Pd       HYPHEN-MINUS

    更为幸运的是Script Language将Scripts又映射到使用该字符的语言。
                 表格 3 Unicode.org上提供的使用Arabic字符对应的语言

Script

Code

MS

Language

Code

ML

P

Arabic

Arab

 

Arabic

ar

 

 

Azerbaijani

az

Baluchi

bal

Eastern Cham

cjm

Dogri

doi

Persian

fa

Hausa

ha

Kashmiri

ks

Kurdish

ku

Kirghiz

ky

    从上面的简短介绍,可以看到Scripts与Languages是多对多的映射关系,这说明仅仅从单一字符的Unicode编码找出对应语言是不可实现的。但我们还是从Unicode中得到了一笔宝贵的资料——我们可以根据字符得到它相应的语言(集合)了。为了解除这种不确定性,下面我们就要引入统计语言模型。

(2) 统计语言模型(Static Language Model)
     在自然语言处理领域,统计语言模型(SLM)应用之广泛,影响之大是众所周知的。无论是ASR发音模型的问题,OCR文本或普通文本的错词矫正问题,还是词类标注(POS),歧义消除,甚至到高级应用如机器翻译,SLM都发挥了极大的作用。在后面介绍多语言识别的方法是,SLM更是不可缺少的。所以现在我们有必要对其原理及工作方式有个简单的了解。
    SLM的数学原理最早来自于Markov(1913),也就是我们现在所熟知及使用的“Markov链”。先看下面一个简单的“句子”,请猜出***所代表的词语。
                         I eat a big red ***
    在这个上下文中,我们很容易感觉到red后面出现的词很可能是apple,而不会是car, computer。这就是说,我们猜测下一个单词的出现时,不是孤立的去想这个词,而往往需要参考前面“历史”。按照这种思维方式,如果我们只给定第一个单词I,并将上面的猜测进行下去,那么对于整个句子”I eat a big red apple”被我们猜到的概率就可以用如下的数学语言来描述:

不难看出,为了计算apple出现的概率,我们需要考虑它的所有“历史”。这样的计算实在太复杂了,所以一般来说,我们往往不用某个单词的所有“历史”来逼近它出现的概率,而使用前面k个词做为它的历史再做计算。这就是著名的N元语法模型(NGram Model)。比如当N=2时(bigram model),我们选取做为apple出现概率的逼近。N元语法的数学描述为:
            


                             图表 1 I eat a big apple的二元语法模型描述

那么,又该如何计算呢?
N元语法模型的先验概率计算是要依赖于语料库(corpora)的,也就是说,我们需要于一个语料库(当然,这个语料库可以不包含I eat a big red apple这样的句子),先计算出其中 的值,当实际应用时,只需找出这些概率值并相乘,就可以得到这个句子出现的概率。目前比较权威的语料库有华尔街日报语料库 (Wall Street Journal即WSJ0语料库)(LDC,1993)等等。当然,我们也可以自己搜集制作语料库。在Lispeln项目中,共收集了17个不同语言的语料库,它们的题材大都是新闻报道。实际上,语料库的设计需要很多的技巧和精力,如果语料库太偏向于某一个方面(如体育新闻),训练出的N元模型的概率可能会很“窄”(比如,在科技类文章中覆盖度会很低)。反之,若一个语料库无所不包,则训练出的N元概率模型就无法反映出某个领域的特定。有关语料库的选取和测试方法,可以参看Cohen(1995)和Dietterich(1998),另外,Shannon(1951)提出了N元语法模型的测试方法,在这里不再赘述。
可见,N元语法的优点是可以使用丰富的词汇知识,缺点是对训练语料库的依赖性太强。虽然一些批评人士(Chomsky (1957), Miller and Chomsky(1963))指出SLM及N元语法不能作为人类语法知识的完美认知模型。因为我们看到,N元语法实际上只是很浅层的统计了语言表面单词的概率,对语义等更深层次的探究似乎是少了些。但是面对人类如此浩瀚的语言知识,尤其是面对多语言的自然语言处理,当我们很难快速构建每个语言的语言模型时,SLM无疑是一个强有力的工具。

2. 内核初步框架
    有了上面的准备知识,读者可能对多语言标记的算法已经有了一个大概的轮廓,现在再来简述一下我们的任务和方案。
任务:将多语言的目标文本标记为以下形式
<LanguageName1>
<Content>
...
</Content>
</LanguageName1><LanguageName2>
<Content>
...
</Content>
</LanguageName2>
    方案:首先做一种类似数据库中“连接”的操作,将字符->Scripts的表与Scripts->Languages的表连接成为一个Scripts->Languages的表。再设定每个语言都有一个NGram模型与之一一对应,即Pair<Language,NGram>。对于相应文本,我们使用不同语言的NGram模型得到概率乘积,取概率最大值判定为该文本所使用的语言。下面给出Lispeln所采用的算法的轮廓:我们的文章将沿着这条线循序渐进。实际上,还有很多未知的问题我们将在文中加以解决。
0. 对语料库进行格式化,以便统计转移概率,构建N元语法模型
1. 利用Unicode Scripts对目标文本中字符进行初次判定,得到其Script描述
2. 按照Scripts与Languages的多对多的映射关系,将原目标文本逐句切分
3. 对每个切分出的子句进行不同语言的NGram调度,并根据概率乘积结果,判定相应的语言,并标记。
4. 合并各个已作标记的子句,达到目标文本的语言标记。


                   图表 2 语言标记算法轮廓

    现在,有了上面清晰的思路,我们可以开始着手写语言标记内核了。

3. 根据Unicode获取字符的Script描述
    正如上节中所描述的算法一样,我们先从字符的Unicode编码入手。Unicode.org中所给出的Block和Scripts的说明文件中,都给出了每个字符区域的描述。

             图表 6 Unicode.org提供的Blocks和Scripts的描述方法示意图
    仔细观察表格2,可以发现其描述格式为:Start Code..End Code; Scripts,分成若干大块,在每一块中以升序排列,但并不连续,即:前一块的End并不一定是下一块的Start。实际上,对照表3,Scripts的Language信息我们可以看到,#后面的信息对我们并不重要。因此只要将Scripts.txt的部分读入,并根据这种格式构造ScriptElement类,并把他们所有都插入到一个List<ScriptElement>中。每当得到一个char字符时,我们通过在Scripts类中的GetDescription中利用折半查找法查找其Unicode编码所属的范围,从而确定该字符的Script描述。那么我们怎么才能判定一个字符是属于某个范围呢?这就需要我们为Range类写IComparable接口。
public int CompareTo(object obj) {
if (obj is char){
char c = obj;
if (c > cell) return -1;
else if (c < floor) return 1;}
else if (obj is Range){
Range r = (Range)obj;
if (floor > r.cell) return 1;
else if (cell < r.floor) return -1;}
return 0;
}
    这段代码非常简单,但细心的读者可能会发现这里还实现了Range与Range之间,即不同编码范围之间的排序,这是为什么呢?因为当对List<ScriptElement>进行折半查找时,首先得要求List中的Script是有序排列的。而Unicode.org中给出的文件并没有完全的按照一定顺序排列。其次,在查找对比的时候,也需要对不同的ScriptElement的“大小”进行比较,而反映ScriptElement大小的量值就是Range。因此我们必须自己完成Range与Range之间的大小比较。ScriptElement的IComparable接口如下:
public int CompareTo(object obj) {
if (obj is ScriptElement) {
ScriptElement se = (ScriptElement)obj;
return this.BlockRange.CompareTo(se.BlockRange);}
return 0;
}
    这样,当我们载入全部的Script信息后,只需对ScriptsList执行一下Sort()方法,系统会自动调用IComparable实现比较大小,这样就可以用Char对其中的ScriptElement进行搜索了。而整个折半查找也变得清晰简单。
public string GetDescription(char target){
if (cacheScript.description != null && cacheScript.BlockRange.CompareTo(target) == 0)
return cacheScript.description; //如果和上次范围一致则直接返回Script描述
int low = 0;
int high = scriptsList.Count;
while (low <= high){
int mid = (low + high) / 2;
if (scriptsList[mid].BlockRange.CompareTo(target) == 0){
cacheScript = scriptsList[mid];
return cacheScript.description; //返回找到的字符描述
}
else{
if (scriptsList[mid].BlockRange.CompareTo(target) > 0) high = mid - 1;
else low = mid + 1;
}
}
return null; //查找失败
}
    注意BlockRange.CompareTo(target) == 0是我们找到字符所在的区域描述的条件。
读者可能会发现,这里引入了一个cacheblock用于记录前一个字符所属的ScriptElement。实际上这是一个小技巧,比如考虑到对于一个实际的英语句子和德语句子:

    可以看到,英语中除第一个大写字母I来自于:0041..005A; Latin # L& [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z。其他所有的字符均来自一个区域:0061..007A; Latin # L& [26] LATIN SMALL LETTER A..LATIN SMALL LETTER Z。而在德语中,变元音ü来自于01C4..0293; Latin # L& [208] LATIN CAPITAL LETTER DZ WITH CARON..LATIN SMALL LETTER EZH WITH CUR。当然这三个例子中的所有字符的Script简略描述都是Latin。可以看出,无论什么语言,连续的字符必定大多来自于同一个区域,因此,我们不必要在每次收到一个字符后就立即搜索,而只需在当前字符和上一个字符不在同一区间内时,才进行搜索,这点类似于缓存的功能,提高了查找的效率。

4. 根据Script准备NGram队列
    由于根据标表3,我们已知每个Script对应的若干语言。现在我们构造ScriptMember类,目的是将在相同的Script区间的字符归入同一类中,并将这些字符对应的这些语言的NGram模型排入队列,当一句话扫描结束后,等待下一步分别调用各个NGram模型进行概率检验。例如上面一段英语和德语的例子:当检测完两句话后,只会产生一个ScriptMember,其中包含了这两句话的所有字符。它的ScriptName就是Latin,而NGramNeeded值就是根据Unicode.org提供的Script Language信息查到的Latin所对应的所有语言。一个简单的NGram“入队”算法如下GetProbalLanguages,它将一段文本按Script切分成若干子句,并为每个子句准备好对应的NGram模型队列,等待下一步调用。与前面所举的简单例子不同,实际应用时,目标文本可能是大量混杂的,因此这里返回的是List<ScriptMember>。
public List<ScriptMember> GetProbalLanguages(string tarString, Dictionary<string, string[]> scriptRules){                                       图表 3 ScriptMember类示意图
string lastScriptDsp = "";//记录上一个Script的信息
List<ScriptMember> bmList = new List<ScriptMember>();
foreach (char tmp in tarString){
string tmpdsp = GetDescription(tmp); //获取字符的的Script信息
if (tmpdsp == "Common")
AddNewSymbolToLastBlock(ref bmList, tmp, null); //如果该字符是标点符号则加入到上一个Block的Members中
else{
if (scriptRules.ContainsKey(tmpdsp)) {//如果Script_to_Language中的映射表中包含了这一Script描述
if (lastScriptDsp!=tmpdsp) {//这是一个新的Script,需要构造一个新的ScriptMember存放相应的字符、Script信息、NGram调度队列 lastScriptDsp = tmpdsp;
ScriptMember newbm = new ScriptMember(lastBlockRule);
newbm.Members.Add(tmp);
bmList.Add(newbm);
}
else
//当前字符和前一字符来自于同一个Script区域,因此,直接将该字符添加到上一个ScriptMember.Members中
AddNewSymbolToLastBlock(ref bmList, tmp, lastBlockRule);
}
}
}
}
return bmList;
}
    在完成这部分Unicode的相关工作后,回过来看一下我们已经得到了什么。根据上面的思想和代码。我们可以将一段多语言的文本首先按照Unicode编码规范中的Script描述分成若干个小的子句,这些子句中使用着不同的Script,因此它们很可能也使用着不同的语言(注意这里的“很可能”,读者可以想一想为什么不说是“一定”呢?)。并且,我们已经根据Script到Languages的映射为每个子句准备好需要调度的NGram队列了。现在仅需要针对这些字符描述进行更加精确的语言判断了。是不是离曙光不远了呢?然而在讲解NGram模型之前,我们还有一个重要的工作没做,那就是语料库的格式化。

5. 语料库的构建
    NGram使用必须依赖于语料库。语料库中的文本归整化,就是为方便后面的条件概率的计算。我们看到NGram实际上需要考察没两个相邻单词之间的概率关系。因此需要将语料库中的每个词(甚至包括标点),依次排列到一个string[]中。这样我们就可以方便的通过下标的方法,即Words[i],Words[i-1]来访问它们并进行概率统计了。
首先请读者考虑以下这些问题:
1) 语料库的大小如何定义?
    这里,我们用“型”(type)来表示语料库中不同单词的数目,用“例”(token)表示总的单词数目,并分别用两个string组TypeArray和TokenArray存储整理后的语料库。
2) 当前语料库中是否根据空格来切分每个词?
    将语料库中的文本只有切分成该语言中的最小有实意单位,我们才可以进行概率统计和进一步操作。那么什么是最小实意单位呢?在英文、德文中,一个单词就是一个最小实意单位;而在中文中,一个字就可以构成最小实意单位。我们知道,英德法等西欧文字中,大多数都是以空格来作为词与词之间的分割的。而对于中文、日文等,则完全没有这种分词方式,词和词之间是不存在空格的。对于这两类语言的处理是不太一样的,因此,我们把它们转化为String[]时的处理方法也不一样。对于前者,我们可以使用string.Split(‘ ’)方法,按空格分隔得到“例”;而对于后者,我们把每个char.ToString()做为得到的“例”。
3) (b)中的方式是否完美无缺呢?
    虽然在西欧语言中,以空格可以分割每个词,但是标点和单词之间一般不存在空格(例如he said, ”I’m hungry.”),于是单靠空格切分单词的方法并不完美。为了规整这样的句子,这里将每个标点也作为一个“例”,也就是说,标点与单词的地位是相同的,在用N元模型计算句子出现的概率时,我们还需要考虑类似的条件概率。实际上,将标点算作“例”是有意义的,例如中文里“说”后面出现冒号的概率就远比出现感叹号的概率大,英文中said后面很少是叹号。



           图表 4 中英文语料库到String组的转换
    综上考虑,下面的方法可以构造一个规整化的Corpus类,这里使用两个string栈,来辅助完成string[]构造过程。并最后以调用Stack.Copyto()方法完成string[]组的构造。
public Corpus(string filepath, bool hasSp, string langName,Punctuation punctlist){
string text;
Name = langName; //语料库所使用的语言
FilePath = filepath; //语料库所在的物理位置
HasSpace = hasSp; //该语料库中的语言是否以空格切分单词
StreamReader crpstm = new StreamReader(filepath);
text = crpstm.ReadToEnd();
crpstm.Close();
Stack<string> wStack = new Stack<string>();
Stack<string> bStack = new Stack<string>();
wordStack = wStack;
//开始进行语料库整理
if (hasSp){//如果是以空格分隔的,如英法德文
string[] text1 = text.Split(spaceChar); //先使用空格切分
for (int i = text1.Length - 1; i >= 0; i--){
if (text1[i].Trim() != ""){ //进行标点的处理
if (text1[i].Length > 1){
if (text1[i].Length > 2 && punctlist.IsContained(text1[i][text1[i].Length - 1]) && punctlist.IsContained(text1[i][0])){
//若该单词的格式为“符号+单词+符号”                                            图表 5 语料库的类图
wordStack.Push(text1[i][text1[i].Length - 1].ToString());
wordStack.Push(text1[i].Substring(1, text1[i].Length - 2));
wordStack.Push(text1[i][0].ToString());
}
else if (punctlist.IsContained(text1[i][text1[i].Length - 1])){
//若该单词的格式为“单词+符号”
wordStack.Push(text1[i][text1[i].Length - 1].ToString());
wordStack.Push(text1[i].Substring(0, text1[i].Length - 1));
}
else if (punctlist.IsContained(text1[i][0])){
//若该单词的格式为“符号+单词”
wordStack.Push(text1[i].Substring(1, text1[i].Length - 1));
wordStack.Push(text1[i][0].ToString());
}
else//若该单词的格式为“单词”(无符号)
wordStack.Push(text1[i]);
}
else
wordStack.Push(text1[i]);
}
}
}
else{
//不是以空格区分,比如中日文,处理非常简单,只是将每个Char压栈即可
for (int i = text.Length - 1; i >= 0; i--){
if (spaceChar.ContainsSpace(text[i]))
continue;
else
wordStack.Push(text[i].ToString());
}
}
Token = wordStack.Count; //统计“例”的数量
string[] wArray = new string[wordStack.Count];
wordStack.CopyTo(wArray, 0); //完成string组构造
TokenArray = wArray;
//统计“型”的数量
foreach (string str in wordStack){
if (!bStack.Contains(str))
bStack.Push(str);
}
Type = bStack.Count;
string[] tArray = new string[bStack.Count];
bStack.CopyTo(tArray, 0); //完成string组构造
TypeArray = tArray;
bStack.Clear();
}
    上述代码中所用到的Punctuation类是按照Scripts.txt格式载入的标点描述集,Unicode中常用的标点在script.txt中都有标注。
经过格式化后的语料库,利用下标就可以很方便的进行词频统计和概率计算了。下面我们就来看看,如何计算概率,需要计算那些概率以及如何实现NGram模型。
6. NGram模型及回退算法
    从前文对NGram模型的介绍,可以看出,需要事先从语料库中计算出每个单词在它前一个单词后出现的条件概率。我们可以使用如下公式计算:

    其中C表示单词出现的次数。代表单词的长度为N的“历史”。例如当N=2时,要计算单词后出现的概率,就必须统计语料库中出现的次数,和出现的次数。于是我们想到,应该建立一张元素为Pair<word,count>的表,将每次按下标递增访问到的单词序列,存放到这张表中。当然,当然如果表中已经存在了该项,则count++。这样就可以将整个语料库的单词序列出现次数进行统计。
    考虑到NGram模型的概率表达形式及其在查询速度上的高要求,这里使用Dictionary<string,Dictionary<string,double>>的数据结构来存储。外层的Pair<string,Dictionary>的Key表示作为“历史”的单词序列(其序列长度由N值来确定),内层的Pair<string,double>的Key表示当前单词,于是其value表示的就是“当前单词在“历史”序列后出现的概率”。这里需强调一下,在没有归一化之前,我们先“借用”这个value值来统计出现的次数。

          图表 6 NGram模型所采取的数据存取结构
    使用Dictionary嵌套这样的存储结构的好处是显而易见的:首先当语料库很大时,外层和内层的Dictionary容量都不会不会过大,这样就保证了其查询速度。其次,在编写代码时,很好的符合条件概率的表达形式。例如我们要查询的值,只需使用NgramDictionary[eat][food]就可以得到,这样的编程风格可读性很高。
    显然,当N增大时,需要的统计量也就增加。为了提高统计效率,这里使用多线程技术。每个线程负责统计固定长度的单词序列出现的次数,当N=2时,我们需要开启两个线程分别统计长度为1和长度为2的单词序列的出现次数。
List<Thread> l = new List<Thread>();//所使用的线程列表,在最后是要清空
List<CalPack> lcp = new List<CalPack>();
for (int i = nValue - 1; i < nValue; i++){//需要统计的目标序列的长度必定不大于N,这里我们看到i是从nValue-1开始的,也就是说对于N元语法,我们只统计长度为N-1和N的单词序列
Dictionary<string, SDict> thisDct = new Dictionary<string, SDict>();
CalPack tmpPak = new CalPack(); //CalPack是为了传入线程而封装的一个朴素类,其中包含需要进行统计的文本(string[]),和目标序列的长度(int)
if (i != nValue - 1){
probORG.Add(thisDct); //probORG 是List<Dictionary<string, SDict>>类型的
}
tmpPak.CurNValue = i;
tmpPak.WordArray = tarCorpus.TokenArray;
Thread t = new Thread(new ParameterizedThreadStart(SumCount)); //为线程指定所要执行的SumCount统计方法,注意这里的只是统计序列出现次数,而归一化处理在后面进行
l.Add(t);
lcp.Add(tmpPak);
t.Start(tmpPak); //传入工作参数
}
//…Some Codes here foreach Thread in l,Drop!线程关闭
//…Some Codes here 将统计量归一化得到概率
    这里需要注意几个问题:首先,对于统计长度为N - 1的序列(即“历史”)出现的次数,并不需要Dictionary<string,Dictionary<string,double>>,而只需要Dictionary<string,double>。其次,对于长度为N的单词序列,由于统计后要用对我们刚才借用的Value进行归一化,因此需要对整个Dictionary进行更新,而Dictionary的迭代器是不允许在迭代时对Dictionary做修改的。这里再用到一个小技巧:只需将double封装为一个朴素类,比如
private class CDouble
{public double value;}
    并将上面的Dictionary的value改变为CDouble类型,即使用Dictionary<string,Dictionary<string,CDouble>>,在迭代器中按照Dictionary[key].value方式访问和修改就可以了。
现在我们回过头来看看公式:

    也就是说,我们要计算某个句子在某种语言中出现的概率时,就利用这个语言的N元语法中相应先验概率相乘。对于多语言判定问题,就是对每个语言的NGram模型重复上述计算,取得所有结果中最大的那个语言。例如考虑下列短语在中文和日文中出现的概率(N=2):

          图表  SEQ 图表 \* ARABIC 7 中日文对于“著名小说”的概率转移示意图

    显然,在中文中出现“著名小说”短语的概率要比日文中的大。那么我们就可以判定该语言是中文。还应注意的是,对于语言判定问题这里基本遵循了一个前提假设:即同一句子(以标点分割)中不可能出现两种及以上的语言。
    细心的读者可能已经意识到这种判定方法有缺陷。是的,这里存在两个比较严重的问题。首先,概率相乘的越多,所得的概率乘积就越小。如果计算现实中一个长句子,很容易出现数值下溢的危险。因此习惯上我们将每个概率值取负对数( ), 将概率相乘转化为对数相加。这样就可以避免数值下溢的危险。在C#中,我们可以使用Math.Log10()来进行队数运算,特别的,当概率为0时,得到的负对数是正无穷大,可以使用Double.IsPositiveInfinity()来进行判断。
    第二个问题是:如果语料库中没有当前词的条件概率,该怎么办呢?这样的情况是很可能发生的,比如一个关于计算机领域的语料库,不存在“春节”这样的词是很正常的,即 。那么一个包含“春节”的句子,连乘后得到的概率总是0,其对数概率总为无穷大,这时就无法进行语言判定。N>2时这种情况更加普遍。
    为了解决这个问题,我们使用Witten and Bell(1991)提出的打折法和Katz(1987)提出的回退技术。打折技术又称平滑技术,为了弥补NGram模型总是会低估那些在语料库中不是彼此临近出现的符号串的概率,它将那些非零的概率值削减,并分摊到“零概率的二元语法”上。在实现打折技术时,有时将语料库的全部“型”所构成的集合与自身做笛卡尔积,从而构造出实际中并不相邻“零概率的二元语法”。
    回退技术主要是针对高阶的NGram模型。在回退模型中,如果存在阶数较高的N元语法,则依靠这些语法计数,如果不存在时,则通过低阶的语法计数来近似。例如当N=3时,如果不存在特定三元语法的概率值,则使用 来估计这个概率,而如果也没有的概率值,则使用来估计。
    将打折法和回退技术结合起来,我们可以得到一个递归表达式:

    其中, , 就是当前N元语法的概率值,在实际应用时,我们就是用来计算。但我们要注意,它可能在语料库实际存在,也可能是通过低阶的语法近似而来,实际上,根据上述回退表达式,只要符号在语料库中存在,我们就可以得到以结束的任意的N阶的语法概率。是根据语料库中实际存在的单词序列而算出的,但是与前面我们所讲的计算方法不同,我们不再用做为归一化因子,而使用下面的公式计算

    其中V是整个语料库的“型”数。以这种方法计算出概率,比用下面所算出的最大似然估计MLE要小一些

    这是很好理解的,形象的说,我们需要将“存在”的语法概率值削减一部分,分配给那些“不存在”的语法概率值。而加权因子就是保证这样分摊后的总概率之和为1。 的计算方法如下:

    由此可以看出,采用回退和打折后的计算量增大了,因为我们每要使用一个“不存在”的N元语法概率时,都要对出现在N元语法中的每个N-1元语法的概率重新计算。需注意的是,这些概率值是不必事先计算好的,也就是说,当我们需要哪个N元语法时,再计算。下面给出带有回退和打折的概率计算方法。
private double GetProbabilityUsingBackoff(string target){
string his, pres;
double alpha = 0, sigma1 = 0, sigma2 = 0;
int index = GetLength(target); //获得当前序列的长度
his = GetThisHistory(target, false); //当前序列的“历史”,长度为N-1
pres = GetLastWord(target); //当前序列的最后一个符号
index--;
string nexthis = GetNextHistory(his, false); //当前序列长度为N-2的“历史”,即回退到N-1阶的“历史”
if (probORG[index - 1].ContainsKey(his)){ //probORG 是List<Dictionary<string, SDict>>类型的
foreach (var tmpPair in probORG[index - 1][his]){
//对于所有存在“历史”的二元语法计算概率,其中sigma1,sigma2就是上式中的分母和分子的求和值
sigma1 += tmpPair.Value.value;
sigma2 += probORG[index - 2][nexthis][tmpPair.Key].value;
}
alpha = (1 - sigma1) / (1 - sigma2); //计算alpha值
if (alpha > 1) alpha = 1; //由于精度问题,alpha的值有时会略大于1,这里对其进行调整
}
else
alpha = 1;
double pb;
if (probORG[index - 2].ContainsKey(nexthis) && probORG[index - 2][nexthis].ContainsKey(pres))
pb = probORG[index - 2][nexthis][pres].value; //如果N-1阶语法是存在的,即可以直接使用
else
//若不存在则进行迭代计算
pb = GetProbabilityUsingBackoff(nexthis + " " + pres);
p = alpha * pb;
return p;
}
}
    至此,我们已经完全构建好了NGram内核部分。现在就可以使用如下的代码段获得某个符号序列的带有回退概率。其方法是:当NGram字典中存在符号序列的概率时,直接读取概率值;否则,我们采用回退算法去近似这个序列的概率值。
bool inORG = false;
if (DicORG.ContainsKey(hisStr)){
if (DicORG[hisStr].ContainsKey(nextStr)){
//语料库中存在这样的符号序列,直接取已经算好的概率值
p += -Math.Log10(DicORG[hisStr][nextStr].value); //将概率连乘变成对数概率相加
inORG = true;
}
}
if (!inORG){
if (enablebackoff){
//若语料库中不存在这样的符号序列,且开启了回退功能,则迭代使用回退算法
double thisp = -Math.Log10(GetProbabilityUsingBackoff(hisStr + " " + nextStr));
if (!double.IsPositiveInfinity(thisp))
p += thisp;
else
p = double.PositiveInfinity; //概率为0,对数概率为正无穷大,与前面所算的对数概率值相加,仍为正无穷大
}
else
p = double.PositiveInfinity;
}
    虽然我们使用回退和打折法优化了NGram模型对语料库的依赖性,但是语料库中不存在“型”的问题仍然不可避免的,Kučera(1992)曾对莎士比亚(Shakespeare)的全部著作进行过计算,其中包含的“例”虽然达884,674个,但“型”也只有29,066个。Brown et al.(1992)积累了一个58,300万个“例”的英语语料库,包含293,181个“型”。相比之下,Lispeln使用的都是超小规模的语料库,17种语言的语料库总计不到800Kb。因此不可避免的会出现零概率语法。为了改善这个缺陷,我们在此定义一个“覆盖度”的概念,即以当前带有回退和打折的NGram模型能覆盖住给定句子的比例。仍然考虑下图“著名小说”:

        
    对于一个句子的若干符号序列的多次概率计算,仅当得到非0概率时覆盖度加1,最后用计算次数达到归一化。图中上半部分为中文的转移概率,下半部分为日文的转移概率,没有画出的部分,如中文的“名”到“小”的转移概率没有,说明语料库中不存在“名小”这样的“型”。可见,对于这个短语,中文的覆盖率为2/3而日文的覆盖率为1/3。覆盖率2/3>1/3,故判定语言为中文。
    因此,对于多语言的识别的最后一步(见第一章第2节所述的第6步),我们需要重写返回结果,使其不仅仅根据对数概率的大小来判定,还要考虑覆盖度。这里唯一需要注意的就是对数概率值越大,实际概率值越小。
public class ProbabilityResult:IComparable{
//返回的最终计算结果
public double result; //对数概率值
public double cov; //覆盖度
public string langName; //语言名称
public int CompareTo(object obj){
if (obj is ProbabilityResult){
ProbabilityResult pr = (ProbabilityResult)obj;
if (Double.IsPositiveInfinity(result)){ //若结果为正无穷大说明概率不可比,比较覆盖度
if (!Double.IsPositiveInfinity(pr.result))
return -1;
else
return (cov.CompareTo(pr.cov));
}                                                                      图表 8 重写返回结果的IComparable接口
else //否则比较对数概率,而不比较覆盖度,注意对数概率越大,原概率越小
return (-result.CompareTo(pr.result));
}
return 1;
}
7. 测试结果
    最后,我们来看看Lispeln在开启回退模型时的性能。Lispeln所用的的17个微型语料库总计800Kb,采用N=2时的二元语法构建NGram词典。测试文本共6种语言,分别是:法语,英语,德语,汉语,日语,韩语。每种语言不只1个句子,并且同一种语言可以在不相邻句子间的多次穿插出现。例如: 。值得注意的是,无论在什么时候,测试文本都不应与语料库中的文本有重叠。
    下面将这段测试文本的大体结构给出:
    Puis un jour, un …([法语]省略30字)初期の勧進は主とし…([日语]省略160字)L'homme aurait voulu offrir un …([法语]省略34字)한국에 두 발로 걷고…([韩语]省略140字).有一天,一对夫妇来到了…([汉语]省略140字) With the country still facing ([英语]省略51字)Ich hoffe, dass alles gut ist! ([德语]省略40字) The country's top leaders([英语]省略45字)
读者容易看到,这样特殊的测试文本,已经足以检测我们上文提到的所有技术。
下面我们打开回退算法,使用相测试文本进行标记,并考察它的XML输出结果,并省略具体的标记内容:
<?xml version="1.0" encoding="utf-8" ?>
- <Lispeln>
- <Summary>
<Languages>8</Languages>
<TimeCost>549ms</TimeCost>
<Fast-tagging>Enabled</Fast-tagging>
<Self-learning>Disabled</Self-learning>
<Italian>0.77%</Italian>
<French>24.47%</French>
<Japanese>11.38%</Japanese>
<Korean>11.62%</Korean>
<Chinese>5.96%</Chinese>
<Unknown>1.77%</Unknown>
<English>27.18%</English>
<German>16.75%</German>
</Summary>
    可以看出共区分出8种语言。值得注意的是,这里标记为“Unknown”句子,我们并不是不知道它的任何语言信息,因为我们至少从Script中得到了一些信息,只是利用NGram模型后没有判定出最终语种结果。
    发生误判的情况是:

- <Italian>
- <Contents>
<Content>Puis un jour,</Content>
</Contents>
</Italian>
- <Japanese>
- <Contents>
<Content>有一天,</Content>
</Contents>
</Japanese>

- <Japanese>
- <Contents>
<Content>年轻的女子生下一个婴儿。</Content>
</Contents>
</Japanese>

    即有1个法语短句误判成意大利语,2个中文短句误判成日语。
    没有判断出的语言概率为1.77%,而在没有打开回退算法时,未知率为20.35%。可以看到,很多句子都已经能够判定,未知率大大下降,但也造成了误判率的小幅度升高。这样的结果是容易理解的,当采用回退算法后,语料库中的每个“型”都得到了充分利用,因此未知率下降;但当目标序列的长度较小时,这样的“充分利用”会导致相似语言的误判(例如上文的中文和日文)。但对于这样超小型的语料库来说,其结果令人较为满意。
8. 优化方法
(1)合并Script策略
    现在我们重新审视一下我们的算法,是不是有需要改进的地方呢?首先我们还是来看一下Script和Language的对应关系。下面举这样一个例子,首先根据Unicode.org提供的数据:
    按照我们第4节所描述的分句方法,如果当前文本是一段日文。例如:

    由于Han和Hiragana这两个不同的Script在这里不停交错,将产生数个ScriptMember,我们各举其中一些,展示一下他们的构造:

 

ScriptMember1

ScriptMember2

ScriptMember3

ScriptMember3

ScriptName

Han

Hiragana

Han

Hiragana

Members

らは

各地

NGramNeeded

Zhuang, Chinese, Japanese

Japanese

Zhuang, Chinese, Japanese

Japanese

    可以看到,原来的文本被分的支离破碎,如果继续下一步NGram的概率判断,很可能所有的ScriptMember都无法确定语言。这个算法就失败了。如果抛开日文的特殊性不谈,实际上还有很多语言有这样的特性。因此,第4节中的合并算法需要有一点点改进,即增加一个RecheckMember,当两个Script对应的可选语言集有交集时,则选择他们的并集作为不拆分文本的NGram队列。也就是说,增加一个RechekMember,其Members值为存在Script->Languages交集的所有字符,其NGramNeeded为Script->Languages的并集。

 

RecheckMember

ScriptName

/

Members

彼らは各地を遍歴しながら説法を行い、人々から銭や米の寄付を受けた。彼らは必要経費のみをそこから受け取り、残りを事業達成のための寄付に充てた。

NGramNeeded

Zhuang, Chinese, Japanese

    这样再调用NGram模型计算概率,就可以成功判断出日文了。而对于交集、并集的运算,C# 3.0中已经可以支持,因此实现起来并非难事。
(2)自学习算法
    上文已经提到,Lispeln使用的是超小型的语料库。实际上每个语料库并不能做为整个语言的集合,即便是带有回退的NGram模型,也无法保证对测试文本的每组转移概率都有值。因此提出了使用覆盖度去弥补这一缺陷。读者可以想一想,当对一个句子进行语言判定后,如果它的概率为0,覆盖度不为1,则说明什么呢?

    覆盖度不为1,说明根据语料库生成的NGram辞典中不包含这个条件概率的“型”。如果把这句话再加入到语料库中,更新部分NGram模型,下个句子判定时,利用更新后的模型,就可以提高正确率。这就是自学习的方法。当对一篇围绕固定话题的文章进行多语言标记时,这种学习算法对正确率的提升是相当大的。另外,我们还可以将学习过程与判定过程循环起来,直到判定结果呈收敛时算法结束。
自学习算法的原理简单,效果明显,但对计算量要求也较大。因为每次更新NGram模型都需要重新计算部分概率。这明显加重了计算负担。
(3)概率简化
    前文中曾经因为NGram模型的概率词典太小而使用了覆盖度做为语言判定的一个重要标准。实际上,我们可以抛开NGram的转移概率的连乘计算,而直接使用覆盖度做为判定语言的唯一标准。实际上,由于覆盖度的取值是离散点{0,1},而转移概率为连续值[0,1],覆盖度也可看作转移概率的退化值。当我们只适用覆盖度来判定时,原来的查表,计算,甚至查表,递归,查表,计算的复杂过程简化成了查表,就可以了。也就是说,所有我们需要的知识对一句话中的相邻两字符依次用NGram.ContainsKey[words[i-1]]和words[i-1].ContainsKey[i]来判断就可以了。这无疑大大减少了计算量。配合刚才提到的自学习算法,整个语言标记算法的效率和正确率会大大提升。
下面,我们将概率简化和自学习算法的思想纳入Lispeln中,并给出测试。
第一段文本来自Friends (老友记),约为8000字。它前半部分包含了人物之间的场景对话,例如:

    后面用一小部分篇幅用中文讲解对话中的语法和短语,例如:

    由于我们的中英文语料库来都自于社会政治新闻,并且人物之间的对话可以被看作是围绕同一话题而进行的,因此这是一个很好的用于检测自学习算法的文本:
    下面测试结果对比:

<Lispeln>
<Summary>
<Languages>6</Languages>
<TimeCost>986ms</TimeCost>
<Fast-tagging>Enabled</Fast-tagging>
<Self-learning>Disabled</Self-learning>
<Unknown>16.79%</Unknown>
<English>69.58%</English>
<Swedish>0.93%</Swedish>
<Chinese>4.97%</Chinese>
<Japanese>1.60%</Japanese>
<Italian>0.16%</Italian>
</Summary>

- <Summary>
<Languages>6</Languages>
<TimeCost>742ms</TimeCost>
<Fast-tagging>Enabled</Fast-tagging>
<Self-learning>Enabled</Self-learning>
<Unknown>15.16%</Unknown>
<English>71.37%</English>
<Swedish>0.67%</Swedish>
<Chinese>5.84%</Chinese>
<Japanese>0.82%</Japanese>
<Italian>0.16%</Italian>
</Summary>

    读者容易看出,在打开自学习算法后,中英文的所占比例均有所提高,但幅度不大,这是因为被标分为Unknown的句子不能被学习,而这部分句子又有一定比例(15%左右)。即便这样,总体趋势是不错的:目标语言的所占比例增大,未知率和错误语言的所占比例减小。
    至此,我们就介绍完Lispeln的多语言标记内核了。相信读者已经对自然语言处理中的一些技术和知识有了一个了解。下面我们将一个带有语音合成的RSS阅读器架设在这个多语言内核上,实现更加激动人心的功能——多语言语音合成。

三、多语言标记的应用
1.应用场景
    可能会一些读者会质疑多语种标记的应用价值,实际上其应用场景还是比较多的。首先,多语种识别可以作为语音识别和更深层次的自然语言处理的前期工作。对于语音识别来说,尤其是针对非特定说话人的语音识别、翻译中,多语言识别显得更为重要。但这里会涉及到一些较为复杂的语音特征提取、多语种发音模型的匹配问题,已经超出了本文的探讨范围,有兴趣的读者可以参看D. Caseiro(1998)的两篇论文。而本文所讨论的这种基于文本的多语言标记系统,在LIFI: Language Identification From Images中有很好的应用。读者可以以即将临近的北京奥运会为场景设想一下:当面对其它国家大量运动员的材料时,我们如何快速确定他们的语言,以分配到适当的部门处理?这时,如果我们有一个Unicode识别的手持扫描仪,只需要对所出示的任何材料(身份证、书籍)做一小段简短的扫描,通过多语种识别器就能快速的确定。这里涉及到了我们所熟知的OCR技术,而OCR目前也已经较为成熟,将OCR和多语种识别结合起来,就是我们上文所提到的LIFI。下文中我们将给出多语种识别的另一个应用——多语言语音合成。有了前文的基础,相信读者对此已经有了明确的思路。之所以把这一技术应用于RSS上,是因为在一个用户订阅的多个RSS源中很容易穿插出现多种语言。当然,一些初级外语教材(因为包含了中文注释)也是很好的应用对象。下面就让我们从LINQ开始,一步一步构建RSS阅读器。
2.RSS阅读器的实现
    在这一部分中,我们将为读者展示使用.NET Framework 3.5中的两项新技术——LINQ to XML和Speech.Synthesis。LINQ是如何帮助我们快速构建RSS阅读器的呢?下文将会通过一些简短的实例,让读者够对LINQ的使用方法及其工作原理有个初步的了解。最后我们将用.NET强大且方便易用Speech.Synthesis合成语音。在我们正式开始之前,有必要了解一下LINQ的背景。
    众所周知的,RSS Feed离不开XML。而LINQ to XML为开发人员提供了以LINQ框架为支持的内存中XML的编程接口,使开发人员能够方便地从XML数据源中获得数据。
  在使用LINQ to XML之前,我们必须先引入System.Linq命名空间和System.Xml.Linq命名空间:
  
  System.Linq命名空间提供了用于支持使用LINQ进行查询的类和接口,而System.Xml.Linq命名空间包含了用于支持LINQ to XML的类。

1. RSS阅读器内核概述
  RssOrderer类是RSS阅读器内核中最主要的类,提供了对RSS源进行增加、删除、更新、排序输出的一系列方法。下面我们简单介绍一下类的成员,为我们后面的LINQ to XML排序做些准备。
  RssOrderer类的构造函数要求传入一个xml文件,用于保存用户的RSS列表。下面是这个xml文件的实例


  在Rsses结点下存放了若干个RSS结点,表示用户喜好的RSS源。每一个Rss结点下有两个子结点——Address和FileName。Address表示RSS源的URL,FileName表示此RSS源被下载到硬盘后的文件名。此文件名是通过GenerateFileName方法利用取字符串的哈希值生成的。当然也可以使用其它方法生成文件名,例如,Path类的GetRandomFileName方法。
  下面分别介绍RssOrderer类中各公有方法和事件的作用。
  public void Add(string uri):添加一个RSS源。
  public void Remove(string uri):删除一个RSS源。                          图表 10 RSSOrderer类示意图
  public void UpdateAsyn():异步更新RSS源。完成更新后引发UpdateCompleted事件。
  public void Save():保存RSS源列表。通过Add方法或Remove方法更改RSS源列表后需调用Save方法才可将更改保存到磁盘。
  public event UpdateEventHandler UpdateCompleted:更新RSS源完成后引发。(UpdateEventHandler定义为public delegate void UpdateEventHandler(object sender,EventArgs e))。
  public void GenerateOrderedFile(string fileName):生成排序的RSS内容列表。这个方法的用途是为Lispeln提供ListView控件的绑定源,经ListView控件绑定后,通过SpeechSynthesizer读出,关于XML绑定的概念,我们将在下一章WPF界面设计中给出。生成的xml文件示例如下:
 
我们看到,得到的RSS源文件中是所有Feed中各item结点的内容按时间顺序降序排列的结果,那么这是如何做到的呢?实际上使用LINQ to XML很容易实现这个方法。

2. 用LINQ操作RSS
  在.NET Framework 2.0时代,我们在内存中建立XML树形结构是通过XmlDocument的CreateElement方法以及XmlElement的AppendChild方法进行的。但是,如果在代码中写入大量的此类语句,就很难从代码上看出各XML结点之间的层次关系。
  在.NET Framework 3.5中,加入了新的命名空间,即System.Xml.Linq。这个命名空间提供给我们一套新的操作XML的类,Lispeln中的RSS一系列管理操作都是使用LINQ完成的,或许您至今仍不很明白LINQ的用途,但是读完本节,您就会发现LINQ是一种非常人性及高效的技术。
  首先我们从建立一个XML开始,步入我们的LINQ之旅。
(1) 在内存中建立XML树形结构
  在Add方法中,有如下的语句块:

  _root是XElement类型的私有成员变量,表示存放RSS列表的根,即Rsses结点。从上面的代码中,我们可以明显地看出,在_root结点下,有子结点Rss;结点Rss下,又存在Address和FileName两个结点。如果我们用传统的方法重写这段代码,会写出类似下面的代码:

  在分辩XML文档的层次结构时,这段代码显然不如使用.NET Framework 3.5书写的代码清晰,而且,代码写起来也更加地复杂。
  另外,在查看MSDN文档时,细心的读者可能会发现,并没有XElement的任意一个构造函数的第一个参数是string类型,但是在我们的例子中,却使用了第一个参数为string类型的构造函数。您也许会感到奇怪,但是,当我们看到下面的定义后,您心中的疑惑就可以解除了:
  public static implicit operator XName (string expandedName)
  是的,在XName类中,提供了对于string类型的隐式转换!由于Xelement提供了public XElement (XName name,Object content)这一构造函数,我们便可以直接将string传入,系统会在执行时自动将它转换为XName类型。

(2) 从文件读XML及保存XML至文件
  System.Xml.Linq命名空间为我们提供了类似System.Xml命名空间的保存、读取xml文件的方法:
  XElement类的静态方法Load用于读取xml至内存。

  XElement类的成员方法Save用于保存xml。

(3) 从XElement对象查询数据
  在RssOrderer类中,有一个名为Remove的公有方法:

  其作用是删除一个RSS源。通过传入的uri与各Address结点的内容比较,从Rsses结点(即_root对应的结点)中删除包含Address结点内容与uri一致的Rss结点。
  在这个方法里,用到了一个很激动人心的技术——LINQ查询表达式(LINQ Query Expressions)。通过查询表达式,开发人员可以通过书写很简单的代码来完成很复杂的查询数据源的操作。
  我们通过“from rss in _root.Elements() where (string)rss.Element("Address") == uri select rss”选出了包含Address结点内容与uri一致的Rss结点。其语法很像SQL,所以对于熟悉SQL的开发者而言,就显得更容易上手了。
  下面让我们分析一下这个查询表达式,它包括三个子句,即from、where、select。
  一个查询表达式必须以from开始。通过from子句,我们为系统提供了如下的信息:待查询的数据源和一个表示源中每一个元素的局部变量。在我们的例子中,数据源是_root.Elements(),即Rsses结点下的所有Rss结点;局部变量为rss。
  where子句用于指定了返回元素的条件,即从数据源选择出什么样的元素。在这个例子中,我们的选择条件就是rss下的Address元素中的内容等于uri。
  select子句表示查询被执行时返回的集合中的对象是什么。例如,此处返回的是rss局部变量本身。

(4) orderby子句
  通过orderby子句,我们可以使返回的内容按升序或降序排列。例如,在GenerateOrderedFile方法中,我们需要将RSS的item按降序排列:
  result = new XElement("OrderedRsses",
        from rss in result.Elements()
orderby DateTime.Parse((string)rss.Element("pubDate")) descending
select rss);
  我们在查询表达式中加入orderby关键字,在后面跟以什么标准进行排序。在这里,我们将pubDate结点内的字符串转换为DateTime对象作为排序依据,并用descending关键字标识以降序排列。

3. LINQ技术内幕初探
  在看到这么多LINQ强大的功能之后,读者可能已经对LINQ有了一定了解。好奇的读者们可能会想,LINQ查询表达式在内部是如何实现查询功能的?现在我们将为您揭开这层神秘的面纱,简单地阐述一下在查询表达式后面发生的事情。
  我们从一段代码说起。
  在Add方法中,有这样一个语句,用于提取已存在的所有用户RSS源的地址:

  实际上,编译器在编译时,会生成类似下述的代码(实际上,编译器还会在编译时做一些其它优化):

  我们可以看到,实际上,查询表达式的内部实现,是通过许多.NET Framework 3.5之中的扩展方法实现的。在C# 3.0标准中,允许用户在不派生新类的条件下为已有的类添加方法,这类方法被称作扩展方法(Extension Method)。这里,我们并不讨论扩展方法是如何通过代码实现的,我们的重点在于,这些扩展方法是如何帮助我们完成查询工作的。
  首先,我们在_root.Elements()上执行Where<XElement>方法。Where<XElement>方法接受委托Func<XElement, bool>的对象。Func<T,TResult>是一个泛型委托,其委托对象用于封装接受T类型,返回TResult类型的方法。在此处,它是接受XElement类型,返回bool类型的方法。在执行Where<XElement>方法时,程序会对每个元素执行Func<XElement,bool>对象所封装的方法,并根据返回布尔值确定该元素是否应该存在于其返回的IEnumerable<XElement>对象中。在这个例子中,即判断Address结点下的值是否与uri相等。
  在执行Where<XElement>后,包含所有Address结点下值等于uri的XElement对象(Rss结点)的IEnumerable<XElement>对象就形成了。
  但是,此时我们需要的并不是Rss结点,而是所有其下Address结点的字符串对象。所以,在Where<XElement>方法的返回值上,执行了Select<XElement,string>方法。这个方法用于根据传入的Func<T,TResult>泛型委托,对已存在的IEnumerable<XElement>对象中的每一个元素,利于Func进行处理,形成新的IEnumerable<string>对象。此例中,返回了每一个XElement对象下Address结点的字符串值。
  这就是在一句LINQ查询表达式背后发生的复杂的故事。对于LINQ这个新的体系,还有许多值得我们学习和探索的地方。

4. 语音合成的实现——System.Speech.Synthesis
  最后(我相信您看到此,一定长舒一口气),我们将要实现一个很激动人心的功能。那就是利用我们前文所讲的全部技术,去实现一个多语言语音阅读器。当然,有了上面的基础,实现这个最终目标已经不遥远了。帮助我们迈完这最后一步的是.NET Framwork 3.0下的System.Speech.Synthesis。这个命名空间提供了语言合成的相关功能。
  在我们的GUI类中,添加此命名空间:

  在这个命名空间中,有一个语音合成器——SpeechSynthesizer:

  由于我们需要阅读多种语言,需首先将用户电脑支持阅读的语言提取出来,以便在阅读时判断用户电脑可否阅读某种特定语言:

  languages是GUI类中的私有成员变量,是一个InstalledVoice类型的集合。每一个InstalledVoice对象表示一种语言的相关信息。
在用户点击“Read”时,我们需要将GUI左边TextBox中的信息读出来。
  首先,我们调用GetRssLanguage方法获得此文本的语言,需要注意的是,这里假设每个Feed Item中只包含一种语言,而多个Feed Item可以包含不同的语言,但是实际上,一个Feed Item中是可能包含多种语言的,例如个人面向双语的Blog。其实,Lispeln的多语言区分内核完全能够自动的区分开来这些语言并作上标记。有兴趣的读者,不妨可以一试。

  我们将languages里的每一种语言与language对比,找到第一个支持language的语音:

  如果vi在执行foreach后仍为null,则表示用户电脑不支持此种语言,我们给出用户提示。否则,我们需用此种语言读出plainText中的内容:(此处省略了一些细节)

  第一个语句选择通过foreach找到的语言,第二个语句用于异步阅读文字。当阅读完毕后,会引发SpeakCompleted事件。我们可以在SpeakCompleted事件中处理阅读完毕后的工作。例如,在Lispeln中,在阅读完一个RSS item后,程序会自动阅读下一个RSS item。而当语言发生变换时,Lispeln将自动切换语音合成引擎,实现多语言的朗读。

2.2 界面设计及功能扩展
1. XML与界面的绑定
    Lispeln的使用了.NET 3.0的WPF(Windows Presentation Fundation),为用户提供了一个非常友好的交互性界面。其中XAML界面与XML的数据绑定是一种非常实用的技术,(其示意图如下)他将XML的内容与界面上各个控件的属性进行绑定,这样极大的方便了开发完结后对界面的定制与修改。


                    图图表 11 XML数据绑定

    Lispeln中很多界面元素都是使用XML绑定的:例如,在Lispeln中的欢迎界面的三个功能按钮,实际上是将一个简单的XML绑定到Listbox实现的。RSS阅读面板,则是将上节中所排序好的XML文件与界面绑定,而整个界面的C#代码不足5行。
由于篇幅所限,我们仅以欢迎界面绑定为例,在这里,我们仅仅指出绑定的一个注意事项。一些初学者在将XML文件绑定到控件上时,经常会直接在下面的这个界面上通过单击“Browse”来选定一个XML文件。

图表 12 Lispeln 阅读器界面

      图表 13 Blend中绑定XML   
    人们往往会发现,绑定的XML文件使用的是绝对路径,而不是相对路径,从而导致这个程序不能在其它路径下使用。实际上一种正确的做法是,先将欲绑定的XML文件加入到工程中,并将其“Build Action”属性设为“Content”,将“Copy to Output Directory”设为“Copy if newer”。关于这方面的详细说明,读者可以参见MSDN。

    实际上XML与界面绑定的设计模式不仅在Lispeln中,在一些现代软件设计中是较为普遍的,因为这样极大的方便了后期界面修改,还可以提供多语言支持。由上文我们可以看到,Lispeln正是将XML,LINQ,WPF这三项技术融为一体,才能快速构建出一个人性化,拓展性强的RSS语音阅读器。
2. 跨线程更新窗体内容
    在第一章内核部分我们看到,Lispeln在很多地方使用了多线程技术。多线程在保证程序效率的同时,也为界面设计带来了不便。例如,在一个较长时间的等待过程中(比如Lispeln的语料库载入),我们需要提示用户目前程序所处的状态,然而在另一个线程中是无法对窗体上对象进行任何修改的。在.NET 2.0中,我们可以用this.InvokeRequired配合相关代码实现,而在.NET 3.0中新增了一个SynchronizationContext,我们可以使用如下的方法,为所要开启的线程中传入当前主线程的上下文信息。
LoadCorpusThread = new Thread(LoadAllCorpus);
LoadCorpusThread.Start(SynchronizationContext.Current);
    在所执行的委托方法中写入下代码:
private void LoadAllCorpus(object o){
SynchronizationContext sc = o as SynchronizationContext;
sc.Post(UpdateWindow, parameter);}
    其中UpdateWindow是要执行的更新窗体的方法,parameter是该方法所要求的参数。这样,就可以实现跨线程更新窗体内容了。
3. 增加用户修正
    第一章中曾经花很大篇幅探讨了语料库利用问题,因为其影响到语言判决的正确率。对于Lispeln这样的超小型语料库来说,这个问题更为重要。换个思路,我们不妨将语料库的修改权交给用户,由他们来帮助我们改善判决结果。实际上,Google Translate已经开始使用类似的方法了,如下图所示。


          图表 14 Google Translate中提供的“建议”功能和Lispeln中提供的修正


    当用户对当前句子的语言进行修正后,我们将这个句子写入到该语言的语料库中。与上文中所提到的自学习算法类似,语料库的NGram模型会被重新计算,并考虑到用户在语料库中添加的这个句子。由于人为修正的的正确率几乎为100%,但耗时耗力;而自学习虽然无需人为操作,但学习效率较低。因此,一般将自学习与人工修正混合,以提高判定的正确率。
4. 使用更好的架构
    至此,我们已经将Lispeln从内核到外壳都讲解完了。实际上Lispeln在架构上还是有很多需要改进的地方。正如上一节中我们所谈到的用户修正概念——如果我们将语料库架设到Internet上,将NGram模型的“概率词典”做成数据库。即把Lispeln完全做为一个ASP.NET程序独立于本机而架设在服务器上——那么面对成千上万的使用者,它们每个人的修正都将直接作用于语料库,从而对NGram模型进行修正,这无疑会使判定结果更加准确。幸运的是,Microsoft Blend也可以开发Silverlight界面,使得WPF给用户提供的丰富体验能够完整的保留。实际上,由于Silverlight,ASP.NET和C#关系及其紧密,我们不需要太多的工作就可以实现一种更实用的架构。关于WPF Browser Application与 Window Application的区别可以参看Chappell(2006)。

四、结束语
    通过本文的阅读,相信读者们已经掌握了自然语言处理方面的一些基本原理,对于如何使用.NET 3.0/3.5进行应用层次上的进一步扩展和开发也有了一个新的思路。从理论层的算法优化,到应用层的新技术引进,其目的就在于构造一个更智能,更高效,更易于拓展的系统。而以C#作为开发语言贯穿始终使整个项目保持了连贯性。对比以往的系统设计方法,常常因为开发语言的问题,使理论与应用脱节,无论从开发周期、工作协调方面都面临巨大障碍。其实.NET Framework已经在文本、图像、通信等方面提供了众多的方法与接口,使工程人员能将理论快速转化为实际应用,从而开发出更高级更优秀的项目。


参考文献:
1 Unicode.org, Blocks and Ranges http://www.unicode.org/Public/UNIDATA/Blocks.txt
2 Unicode.org, Scripts http://www.unicode.org/Public/UNIDATA/Scripts.txt
3 Chomsky, N.(1957). Syntactic Structures. Mouton, The Hague.
4 Chomsky, N. and Miller, G.A. (1963). Introduction to formal analysis of natural languages. In Luce, R.D., Bush,R., and Galanter, E.(Eds.), Handbook of Mathematical Psychology
5 Markov, A.A. (1913). Essai d'une recherche statistique sur le texte du roman "Eugene Onegin" illustrant las liaison des epreuve en chain('Example of a statisical investigation of the text of "Eugene Onegin" illustrating the dependence between samples in chain'). Izvistia Imperatorskoi Akademii Nauk(Bulletin de l'Académie impériale des Sciences de St.-Pétersboug), &,153-162.English translation bu Morris Halle,1956.
6 LDC (1993). LDC Catalog: CSR-I (WSJ0) Complete. University of Pennsylvania. www.ldc.upenn.edu/Catalog/LDC93S6A.html
7 Cohen, P.R.(1995). Empirical Methods of Artificial Intelligence. MIT Press, Cambridge, MA.
8 Dietterich T.G.(1998). Approximate statistical tests for comparing supervised classification learning algorithms. Neural Computation, 10(7), 1895-1924.
9 Shannon, C.E. (1951). Prediction and entropy of printed English. Bell System Technical Journal, 30, 50-64.
10 Witten, I.H. and Bell, T.C. (1991). The zero-frequency problem: Estimating the probabilities of novel events in adaptive text compression. IEEE Transactions on Information Theory, 37(4) 1085-1094.
11 Katz, S.M. (1987). Estimation of probabilities from sparse data for the language model component of a speech recognizer. IEEE Transactions on Acoustics, Speech, and Signal Processing, 35(3), 400-401.
12 Kučera, H. and Francis, W.N.(1967). Computational analysis of present-day American English. Brown University Press, Providence, RI.
13 Brown, P.F., Della Pietra, S.A., Della Pietra, V.J., Lai, J.C., and Mercer, R.L.(1992). An estimate of an upper bound for entropy of English. Computational Linguistics, 18(1),31-40
14 D. Caseiro and I. M. Trancoso, "Identification of Spoken European Languages", in Proceedings X European Signal Processing Conference (Eusipco-98), Rhodes, Greece, September 1998.
15 D. Caseiro and I. M. Trancoso, "Spoken Language Identification Using the Speechdat Corpus", in Proceedings of the 1998 International Conference on Spoken Language Processing (ICSLP 98), Sydney, Australia, December 1998.
16 Robert Brown, Talking Windows, Exploring New Speech Recognition And Synthesis APIs In Windows Vista, MSDN Magazine, January 2006
17 Michael Champion, .NET Language-Integrated Query for XML Data, MSDN Library, February 2007
18 Anson Horton, The Evolution Of LINQ And Its Impact On The Design Of C#, MSDN Magazine , June 2007
19 Dustin Metzgar, A WPF-powered 3D graphing library, The Code Project, 20 Nov 2006
20 David Chappell, Introducing Windows Presentation Foundation, Windows Vista Technical Articles,September 2006



(电脑编程技巧与维护杂志社版权所有。未经许可不得转载。)


 

 
 
     
     
 
2008 Copyright.《电脑编程技巧与维护》杂志社 版权所有