Java 自然语言处理(三)
原文:
annas-archive.org/md5/32c19ca17f77d5b3cedfd88d491a9f8e译者:飞龙
第六章. 字符串比较与聚类
本章将涵盖以下几种方案:
-
距离和接近度 – 简单编辑距离
-
加权编辑距离
-
Jaccard 距离
-
Tf-Idf 距离
-
使用编辑距离和语言模型进行拼写纠正
-
大小写恢复修正器
-
自动短语完成
-
使用编辑距离的单链和完全链接聚类
-
潜在狄利克雷分配(LDA)用于多主题聚类
介绍
本章从使用标准的语言中立技术来比较字符串开始。然后,我们将使用这些技术构建一些常用的应用程序。我们还将探讨基于字符串之间距离的聚类技术。
对于字符串,我们使用标准定义,即字符串是字符的序列。所以,显然,这些技术适用于单词、短语、句子、段落等,你在前几章中已经学会了如何提取这些内容。
距离和接近度 – 简单编辑距离
字符串比较是指用于衡量两个字符串相似度的技术。我们将使用距离和接近度来指定任意两个字符串的相似性。两个字符串的相似性越高,它们之间的距离就越小,因此,一个字符串与自身的距离为 0。相反的度量是接近度,意味着两个字符串越相似,它们的接近度就越大。
我们将首先看看简单编辑距离。简单编辑距离通过衡量将一个字符串转换为另一个字符串所需的编辑次数来计算距离。Levenshtein 在 1965 年提出的一种常见距离度量允许删除、插入和替换作为基本操作。加入字符交换后就称为 Damerau-Levenshtein 距离。例如,foo和boo之间的距离为 1,因为我们是在将f替换为b。
注意
有关距离度量的更多信息,请参考维基百科上的距离文章。
让我们看一些可编辑操作的更多示例:
-
删除:
Bart和Bar -
插入:
Bar和Bart -
替换:
Bar和Car -
字符交换:
Bart和Brat
如何做到...
现在,我们将运行一个关于编辑距离的简单示例:
-
使用命令行或你的 IDE 运行
SimpleEditDistance类:java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.SimpleEditDistance -
在命令提示符下,系统将提示你输入两个字符串:
Enter the first string: ab Enter the second string: ba Allowing Transposition Distance between: ab and ba is 1.0 No Transposition Distance between: ab and ba is 2.0 -
你将看到允许字符交换和不允许字符交换情况下两个字符串之间的距离。
-
多做一些示例来感受它是如何工作的——先手动尝试,然后验证你是否得到了最优解。
它是如何工作的...
这是一段非常简单的代码,所做的只是创建两个EditDistance类的实例:一个允许字符交换,另一个不允许字符交换:
public static void main(String[] args) throws IOException {
EditDistance dmAllowTrans = new EditDistance(true);
EditDistance dmNoTrans = new EditDistance(false);
剩余的代码将设置输入/输出路由,应用编辑距离并输出结果:
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
System.out.println("Enter the first string:");
String text1 = reader.readLine();
System.out.println("Enter the second string:");
String text2 = reader.readLine();
double allowTransDist = dmAllowTrans.distance(text1, text2);
double noTransDist = dmNoTrans.distance(text1, text2);
System.out.println("Allowing Transposition Distance " +" between: " + text1 + " and " + text2 + " is " + allowTransDist);
System.out.println("No Transposition Distance between: " + text1 + " and " + text2 + " is " + noTransDist);
}
}
如果我们想要的是接近度而不是距离,我们只需使用proximity方法,而不是distance方法。
在简单的EditDistance中,所有可编辑的操作都有固定的成本 1.0,也就是说,每个可编辑的操作(删除、替换、插入,以及如果允许的话,交换)都被计为成本 1.0。因此,在我们计算ab和ba之间的距离时,有一个删除操作和一个插入操作,两个操作的成本都是 1.0。因此,如果不允许交换,ab和ba之间的距离为 2.0;如果允许交换,则为 1.0。请注意,通常将一个字符串编辑成另一个字符串的方法不止一种。
注意
虽然EditDistance使用起来非常简单,但实现起来却并不容易。关于这个类,Javadoc 是这么说的:
实现说明:该类使用动态规划实现编辑距离,时间复杂度为 O(n * m),其中 n 和 m 是正在比较的两个序列的长度。通过使用三个格子片段的滑动窗口,而不是一次性分配整个格子所需的空间,仅为三个整数数组的空间,长度为两个字符序列中较短的那个。
在接下来的章节中,我们将看到如何为每种编辑操作分配不同的成本。
另见
-
更多详情请参阅 LingPipe Javadoc 中的
EditDistance:alias-i.com/lingpipe/docs/api/com/aliasi/spell/EditDistance.html -
更多关于距离的详情,请参阅 Javadoc:
alias-i.com/lingpipe/docs/api/com/aliasi/util/Distance.html -
更多关于接近度的详情,请参阅 Javadoc:
alias-i.com/lingpipe/docs/api/com/aliasi/util/Proximity.html
加权编辑距离
加权编辑距离本质上是一个简单的编辑距离,只不过编辑操作允许为每种操作分配不同的成本。我们在前面的示例中识别出的编辑操作包括替换、插入、删除和交换。此外,还可以为完全匹配分配成本,以提高匹配的权重——当需要进行编辑时,这可能会用于字符串变异生成器。编辑权重通常以对数概率的形式进行缩放,这样你就可以为编辑操作分配可能性。权重越大,表示该编辑操作越有可能发生。由于概率值介于 0 和 1 之间,因此对数概率或权重将在负无穷大到零之间。更多内容请参阅WeightedEditDistance类的 Javadoc:alias-i.com/lingpipe/docs/api/com/aliasi/spell/WeightedEditDistance.html
在对数尺度上,加权编辑距离可以通过将匹配权重设置为 0,将替换、删除和插入的权重设置为-1,且将置换权重设置为-1 或负无穷(如果我们想关闭置换操作),以此方式将简单编辑距离的结果与前一个示例中的结果完全一样。
我们将在其他示例中查看加权编辑距离在拼写检查和中文分词中的应用。
在本节中,我们将使用FixedWeightEditDistance实例,并创建扩展了WeightedEditDistance抽象类的CustomWeightEditDistance类。FixedWeightEditDistance类通过为每个编辑操作初始化权重来创建。CustomWeightEditDistance类扩展了WeightedEditDistance,并为每个编辑操作的权重制定了规则。删除字母数字字符的权重是-1,对于所有其他字符,即标点符号和空格,则为 0。我们将插入权重设置为与删除权重相同。
如何操作...
让我们在前面的例子基础上扩展,并看一个同时运行简单编辑距离和加权编辑距离的版本:
-
在你的 IDE 中运行
SimpleWeightedEditDistance类,或者在命令行中输入:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.SimpleWeightedEditDistance -
在命令行中,你将被提示输入两个字符串:输入此处显示的示例,或者选择你自己的:如何操作...
-
如你所见,这里显示了另外两种距离度量:固定权重编辑距离和自定义权重编辑距离。
-
尝试其他示例,包括标点符号和空格。
它是如何工作的...
我们将实例化一个FixedWeightEditDistance类,并设置一些权重,这些权重是任意选择的:
double matchWeight = 0;
double deleteWeight = -2;
double insertWeight = -2;
double substituteWeight = -2;
double transposeWeight = Double.NEGATIVE_INFINITY;
WeightedEditDistance wed = new FixedWeightEditDistance(matchWeight,deleteWeight,insertWeight,substituteWeight,transposeWeight);
System.out.println("Fixed Weight Edit Distance: "+ wed.toString());
在这个例子中,我们将删除、替换和插入的权重设置为相等。这与标准的编辑距离非常相似,唯一的区别是我们将编辑操作的权重从 1 修改为 2。将置换权重设置为负无穷有效地完全关闭了置换操作。显然,删除、替换和插入的权重不必相等。
我们还将创建一个CustomWeightEditDistance类,它将标点符号和空格视为匹配项,也就是说,插入和删除操作的成本为零(对于字母或数字,成本仍为-1)。对于替换操作,如果字符仅在大小写上有所不同,则成本为零;对于所有其他情况,成本为-1。我们还将通过将其成本设置为负无穷来关闭置换操作。这将导致Abc+与abc-匹配:
public static class CustomWeightedEditDistance extends WeightedEditDistance{
@Override
public double deleteWeight(char arg0) {
return (Character.isDigit(arg0)||Character.isLetter(arg0)) ? -1 : 0;
}
@Override
public double insertWeight(char arg0) {
return deleteWeight(arg0);
}
@Override
public double matchWeight(char arg0) {
return 0;
}
@Override
public double substituteWeight(char cDeleted, char cInserted) {
return Character.toLowerCase(cDeleted) == Character.toLowerCase(cInserted) ? 0 :-1;
}
@Override
public double transposeWeight(char arg0, char arg1) {
return Double.NEGATIVE_INFINITY;
}
}
这种自定义加权编辑距离特别适用于比较字符串,其中可能会遇到细微的格式更改,比如基因/蛋白质名称从Serpin A3变成serpina3,但它们指的却是同一样东西。
另见
-
有一个 T&T(Tsuruoka 和 Tsujii)编辑距离规范用于比较蛋白质名称,参见
alias-i.com/lingpipe/docs/api/com/aliasi/dict/ApproxDictionaryChunker.html#TT_DISTANCE -
有关
WeightedEditDistance类的更多细节,可以在 Javadoc 页面找到,网址为:alias-i.com/lingpipe/docs/api/com/aliasi/spell/WeightedEditDistance.html
Jaccard 距离
Jaccard 距离是一种非常流行且高效的字符串比较方法。Jaccard 距离在标记级别进行操作,通过首先对两个字符串进行标记化,然后将共同标记的数量除以总的标记数量来比较两个字符串。在第一章《简单分类器》中的使用 Jaccard 距离消除近似重复项示例中,我们应用该距离来消除近似重复的推文。本篇会更详细地介绍,并展示如何计算它。
距离为 0 是完美匹配,也就是说,两个字符串共享所有的词项,而距离为 1 是完美不匹配,也就是说,两个字符串没有共同的词项。请记住,接近度和距离是相互逆的,因此接近度的范围也是从 1 到 0。接近度为 1 是完美匹配,接近度为 0 是完美不匹配:
proximity = count(common tokens)/count(total tokens)
distance = 1 – proximity
标记由 TokenizerFactory 生成,在构造时传入。例如,让我们使用 IndoEuropeanTokenizerFactory,并看一个具体示例。如果 string1 是 fruit flies like a banana,string2 是 time flies like an arrow,那么 string1 的标记集为 {'fruit', 'flies', 'like', 'a', 'banana'},string2 的标记集为 {'time', 'flies', 'like', 'an', 'arrow'}。这两个标记集之间的共同词项(或交集)是 {'flies', 'like'},这些词项的并集是 {'fruit', 'flies', 'like', 'a', 'banana', 'time', 'an', 'arrow'}。现在,我们可以通过将共同词项的数量除以词项的总数量来计算 Jaccard 接近度,即 2/8,结果为 0.25。因此,距离是 0.75(1 - 0.25)。显然,通过修改类初始化时使用的标记器,Jaccard 距离是非常可调的。例如,可以使用一个大小写标准化的标记器,使得 Abc 和 abc 被认为是等效的。同样,使用词干提取标记器时,runs 和 run 将被认为是等效的。我们将在下一个距离度量——Tf-Idf 距离中看到类似的功能。
如何操作...
下面是如何运行 JaccardDistance 示例:
-
在 Eclipse 中,运行
JaccardDistanceSample类,或者在命令行中输入:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.JaccardDistanceSample -
与之前的示例一样,您将被要求输入两个字符串。我们将使用的第一个字符串是
Mimsey Were the Borogroves,这是一个非常优秀的科幻短篇小说标题,第二个字符串All mimsy were the borogoves,是来自Jabberwocky的实际诗句,启发了这个标题:Enter the first string: Mimsey Were the Borogroves Enter the second string: All mimsy were the borogoves, IndoEuropean Tokenizer Text1 Tokens: {'Mimsey''Were''the'} Text2 Tokens: {'All''mimsy''were''the''borogoves'} IndoEuropean Jaccard Distance is 0.8888888888888888 Character Tokenizer Text1 Tokens: {'M''i''m''s''e''y''W''e''r''e''t''h''e''B''o''r''o''g''r''o''v''e'} Text2 Tokens: {'A''l''l''m''i''m''s''y''w''e''r''e''t''h''e''b''o''r''o''g''o''v''e''s'} Character Jaccard Distance between is 0.42105263157894735 EnglishStopWord Tokenizer Text1 Tokens: {'Mimsey''Were'} Text2 Tokens: {'All''mimsy''borogoves'} English Stopword Jaccard Distance between is 1.0 -
输出包含使用三种不同分词器生成的标记和距离。
IndoEuropean和EnglishStopWord分词器非常相似,显示这两行文本相距较远。记住,两个字符串越接近,它们之间的距离就越小。然而,字符分词器显示,这些行在以字符为比较基础的情况下距离较近。分词器在计算字符串间距离时可能会产生很大的差异。
它是如何工作的……
代码很简单,我们只会讲解JaccardDistance对象的创建。我们将从三个分词器工厂开始:
TokenizerFactory indoEuropeanTf = IndoEuropeanTokenizerFactory.INSTANCE;
TokenizerFactory characterTf = CharacterTokenizerFactory.INSTANCE;
TokenizerFactory englishStopWordTf = new EnglishStopTokenizerFactory(indoEuropeanTf);
请注意,englishStopWordTf使用基础分词器工厂构建自己。如果有任何疑问,参阅第二章,查找和处理词语。
接下来,构建 Jaccard 距离类,并将分词器工厂作为参数:
JaccardDistance jaccardIndoEuropean = new JaccardDistance(indoEuropeanTf);
JaccardDistance jaccardCharacter = new JaccardDistance(characterTf);
JaccardDistance jaccardEnglishStopWord = new JaccardDistance(englishStopWordTf);
其余的代码只是我们标准的输入/输出循环和一些打印语句。就是这样!接下来是更复杂的字符串距离度量。
Tf-Idf 距离
一个非常有用的字符串间距离度量是由TfIdfDistance类提供的。它实际上与流行的开源搜索引擎 Lucene/SOLR/Elastic Search 中的距离度量密切相关,其中被比较的字符串是查询与索引中文档的比对。Tf-Idf 代表核心公式,即词频(TF)乘以逆文档频率(IDF),用于查询与文档中共享的词。关于这种方法的一个非常酷的地方是,常见词(例如,the)在文档中出现频繁,因此其权重被下调,而稀有词则在距离比较中得到上调。这有助于将距离集中在文档集中真正具有区分性的词上。
TfIdfDistance不仅对类似搜索引擎的应用非常有用,它对于聚类和任何需要计算文档相似度的问题也非常有用,而无需监督训练数据。它有一个理想的属性;分数被标准化为 0 到 1 之间的分数,并且对于固定的文档d1和不同长度的文档d2,不会使分配的分数过大。在我们的经验中,如果你想评估一对文档的匹配质量,不同文档对的分数是相当稳健的。
注意
请注意,有一系列不同的距离被称为 Tf-Idf 距离。此类中的距离定义为对称的,不像典型的用于信息检索目的的 Tf-Idf 距离。
Javadoc 中有很多值得一看的信息。然而,针对这些食谱,你需要知道的是,Tf-Idf 距离在逐字查找相似文档时非常有用。
如何做……
为了让事情稍微有点趣味,我们将使用我们的TfIdfDistance类来构建一个非常简单的推文搜索引擎。我们将执行以下步骤:
-
如果你还没有做过,运行第一章中的
TwitterSearch类,简单分类器,并获取一些推文进行操作,或者使用我们提供的数据。我们将使用通过运行Disney World查询找到的推文,它们已经在data目录中。 -
在命令行中输入以下内容——这使用我们的默认设置:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter6.TfIdfSearch Reading search index from data/disney.csv Getting IDF data from data/connecticut_yankee_king_arthur.txt enter a query: -
输入一个可能有匹配单词的查询:
I want to go to disney world 0.86 : I want to go to Disneyworld 0.86 : I want to go to disneyworld 0.75 : I just want to go to DisneyWorld... 0.75 : I just want to go to Disneyworld ??? 0.65 : Cause I wanna go to Disneyworld. 0.56 : I wanna go to disneyworld with Demi 0.50 : I wanna go back to disneyworld 0.50 : I so want to go to Disneyland I've never been. I've been to Disneyworld in Florida. 0.47 : I want to go to #DisneyWorld again... It's so magical!! 0.45 : I want to go to DisneyWorld.. Never been there :( #jadedchildhood -
就是这样。尝试不同的查询,玩弄一下得分。然后,看看源代码。
它是如何工作的……
这段代码是构建搜索引擎的一种非常简单的方法,而不是一种好方法。然而,它是探索字符串距离概念在搜索上下文中如何工作的一个不错的方式。本书后续将基于相同的距离度量进行聚类。可以从src/com/lingpipe/cookbook/chapter6/TfIdfSearch.java中的main()类开始:
public static void main(String[] args) throws IOException {
String searchableDocs = args.length > 0 ? args[0] : "data/disneyWorld.csv";
System.out.println("Reading search index from " + searchableDocs);
String idfFile = args.length > 1 ? args[1] : "data/connecticut_yankee_king_arthur.txt";
System.out.println("Getting IDF data from " + idfFile);
该程序可以接受命令行传入的.csv格式的搜索数据文件和用作训练数据源的文本文件。接下来,我们将设置一个标记器工厂和TfIdfDistance。如果你不熟悉标记器工厂,可以参考第二章中的修改标记器工厂食谱,以获取解释:
TokenizerFactory tokFact = IndoEuropeanTokenizerFactory.INSTANCE;
TfIdfDistance tfIdfDist = new TfIdfDistance(tokFact);
然后,我们将通过按“.”分割训练文本来获取将作为 IDF 组件的数据,这种方式大致上是句子检测——我们本可以像在第五章的句子检测食谱中那样进行正式的句子检测,但我们选择尽可能简单地展示这个例子:
String training = Files.readFromFile(new File(idfFile), Strings.UTF8);
for (String line: training.split("\\.")) {
tfIdfDist.handle(line);
}
在for循环中,有handle(),它通过语料库中的标记分布训练该类,句子即为文档。通常情况下,文档的概念要么小于(句子、段落和单词),要么大于通常所称的文档。在这种情况下,文档频率将是该标记所在的句子数。
接下来,我们加载我们要搜索的文档:
List<String[]> docsToSearch = Util.readCsvRemoveHeader(new File(searchableDocs));
控制台设置为读取查询:
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
System.out.println("enter a query: ");
String query = reader.readLine();
接下来,每个文档将使用TfIdfDistance与查询进行评分,并放入ObjectToDoubleMap中,该映射用于跟踪相似度:
ObjectToDoubleMap<String> scoredMatches = new ObjectToDoubleMap<String>();
for (String [] line : docsToSearch) {
scoredMatches.put(line[Util.TEXT_OFFSET], tfIdfDist.proximity(line[Util.TEXT_OFFSET], query));
}
最后,scoredMatches按相似度顺序被检索,并打印出前 10 个示例:
List<String> rankedDocs = scoredMatches.keysOrderedByValueList();
for (int i = 0; i < 10; ++i) {
System.out.printf("%.2f : ", scoredMatches.get(rankedDocs.get(i)));
System.out.println(rankedDocs.get(i));
}
}
尽管这种方法非常低效,因为每次查询都遍历所有训练数据,进行显式的TfIdfDistance比较并存储结果,但它对于玩转小数据集和比较度量指标来说并不是一种坏方法。
还有更多内容...
有一些值得强调的TfIdfDistance的细节。
有监督和无监督训练的区别
当我们训练TfIdfDistance时,在训练的使用上有一些重要的区别,这些区别与本书其他部分的使用不同。这里进行的训练是无监督的,这意味着没有人类或其他外部来源标记数据的预期结果。本书中大多数训练使用的是人类标注或监督数据。
在测试数据上训练是可以的
由于这是无监督数据,因此没有要求训练数据必须与评估或生产数据不同。
使用编辑距离和语言模型进行拼写纠正
拼写纠正接收用户输入的文本并提供纠正后的形式。我们大多数人都熟悉通过智能手机或像 Microsoft Word 这样的编辑器进行的自动拼写纠正。网络上显然有很多有趣的例子,展示了拼写纠正失败的情况。在这个例子中,我们将构建自己的拼写纠正引擎,并看看如何调整它。
LingPipe 的拼写纠正基于噪声信道模型,该模型模拟了用户的错误和预期用户输入(基于数据)。预期用户输入通过字符语言模型进行建模,而错误(或噪声)则通过加权编辑距离建模。拼写纠正是通过CompiledSpellChecker类来完成的。该类实现了噪声信道模型,并根据实际收到的消息,提供最可能的消息估计。我们可以通过以下公式来表达这一点:
didYouMean(received) = ArgMaxintended P(intended | received)
= ArgMaxintended P(intended,received) / P(received)
= ArgMaxintended P(intended,received)
= ArgMaxintended P(intended) * P(received | intended)
换句话说,我们首先通过创建一个 n-gram 字符语言模型来构建预期消息的模型。语言模型存储了已见短语的统计数据,本质上,它存储了 n-gram 出现的次数。这给我们带来了P(intended)。例如,P(intended)表示字符序列the的可能性。接下来,我们将创建信道模型,这是一个加权编辑距离,它给出了输入错误的概率,即用户输入的错误与预期文本之间的差距。再例如,用户本来打算输入the,但错误地输入了teh,这种错误的概率是多少。我们将使用加权编辑距离来建模这种可能性,其中权重按对数概率进行缩放。请参考本章前面的加权编辑距离配方。
创建一个编译后的拼写检查器的常见方法是通过TrainSpellChecker实例。编译拼写检查训练类并将其读取回来后的结果就是一个编译过的拼写检查器。TrainSpellChecker通过编译过程创建了基本的模型、加权编辑距离和标记集。然后,我们需要在CompiledSpellChecker对象上设置各种参数。
可以选择性地指定一个分词工厂来训练对标记敏感的拼写检查器。通过分词,输入会进一步规范化,在所有未由空格分隔的标记之间插入单个空格。标记会在编译时输出,并在编译后的拼写检查器中读取回来。标记集的输出可能会被修剪,以删除任何低于给定计数阈值的标记。因为在没有标记的情况下我们只有字符,所以阈值在没有标记的情况下没有意义。此外,已知标记集可用于在拼写校正时限制替代拼写的建议,仅包括观察到的标记集中的标记。
这种拼写检查方法相较于纯粹基于字典的解决方案有几个优点:
-
这个上下文得到了有效建模。如果下一个词是
dealership,则Frod可以被纠正为Ford;如果下一个词是Baggins(《魔戒》三部曲中的角色),则可以纠正为Frodo。 -
拼写检查可以对领域敏感。这个方法相较于基于字典的拼写检查还有一个大优点,那就是修正是基于训练语料库中的数据进行的。因此,在法律领域,
trt将被纠正为tort,在烹饪领域,它将被纠正为tart,在生物信息学领域,它将被纠正为TRt。
如何操作...
让我们来看一下运行拼写检查的步骤:
-
在你的 IDE 中,运行
SpellCheck类,或者在命令行中输入以下命令—注意我们通过–Xmx1g标志分配了 1GB 的堆内存:java -Xmx1g -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter6.SpellCheck -
请耐心等待;拼写检查器需要一到两分钟的时间来训练。
-
现在,让我们输入一些拼写错误的单词,例如
beleive:Enter word, . to quit: >beleive Query Text: beleive Best Alternative: believe Nbest: 0: believe Score:-13.97322991490364 Nbest: 1: believed Score:-17.326215342327487 Nbest: 2: believes Score:-20.8595682233572 Nbest: 3: because Score:-21.468056442099623 -
如你所见,我们获得了最接近输入文本的最佳替代方案,以及一些其他替代方案。它们按最有可能是最佳替代方案的可能性排序。
-
现在,我们可以尝试不同的输入,看看这个拼写检查器的表现如何。输入多个单词,看看它的效果:
The rain in Spani falls mainly on the plain. Query Text: The rain in Spani falls mainly on the plain. Best Alternative: the rain in spain falls mainly on the plain . Nbest: 0: the rain in spain falls mainly on the plain . Score:-96.30435947472415 Nbest: 1: the rain in spain falls mainly on the plan . Score:-100.55447634639404 Nbest: 2: the rain in spain falls mainly on the place . Score:-101.32592701496742 Nbest: 3: the rain in spain falls mainly on the plain , Score:-101.81294112237359 -
此外,尝试输入一些专有名词,看看它们是如何被评估的。
它是如何工作的...
现在,让我们来看一下是什么让这一切运作起来。我们将从设置TrainSpellChecker开始,它需要一个NGramProcessLM实例、TokenizerFactory和一个EditDistance对象,用于设置编辑操作的权重,例如删除、插入、替换等:
public static void main(String[] args) throws IOException, ClassNotFoundException {
double matchWeight = -0.0;
double deleteWeight = -4.0;
double insertWeight = -2.5;
double substituteWeight = -2.5;
double transposeWeight = -1.0;
FixedWeightEditDistance fixedEdit = new FixedWeightEditDistance(matchWeight,deleteWeight,insertWeight,substituteWeight,transposeWeight);
int NGRAM_LENGTH = 6;
NGramProcessLM lm = new NGramProcessLM(NGRAM_LENGTH);
TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
tokenizerFactory = new com.aliasi.tokenizer.LowerCaseTokenizerFactory(tokenizerFactory);
NGramProcessLM 需要知道在建模数据时要采样的字符数量。此示例中已经为加权编辑距离提供了合理的值,但可以根据特定数据集的变化进行调整:
TrainSpellChecker sc = new TrainSpellChecker(lm,fixedEdit,tokenizerFactory);
TrainSpellChecker 现在可以构建,接下来我们将从古腾堡计划中加载 150,000 行书籍。在搜索引擎的上下文中,这些数据将是你的索引中的数据:
File inFile = new File("data/project_gutenberg_books.txt");
String bigEnglish = Files.readFromFile(inFile,Strings.UTF8);
sc.handle(bigEnglish);
接下来,我们将从字典中添加条目,以帮助处理罕见单词:
File dict = new File("data/websters_words.txt");
String webster = Files.readFromFile(dict, Strings.UTF8);
sc.handle(webster);
接下来,我们将编译 TrainSpellChecker,以便我们可以实例化 CompiledSpellChecker。通常,compileTo() 操作的输出会写入磁盘,并从磁盘读取并实例化 CompiledSpellChecker,但这里使用的是内存中的选项:
CompiledSpellChecker csc = (CompiledSpellChecker) AbstractExternalizable.compile(sc);
请注意,还有一种方法可以将数据反序列化为 TrainSpellChecker,以便以后可能添加更多数据。CompiledSpellChecker 不接受进一步的训练实例。
CompiledSpellChecker 接受许多微调方法,这些方法在训练期间不相关,但在使用时是相关的。例如,它可以接受一组不进行编辑的字符串;在这种情况下,单个值是 lingpipe:
Set<String> dontEdit = new HashSet<String>();
dontEdit.add("lingpipe");
csc.setDoNotEditTokens(dontEdit);
如果输入中出现这些标记,它们将不会被考虑进行编辑。这会对运行时间产生巨大影响。这个集合越大,解码器的运行速度就越快。如果执行速度很重要,请将不编辑标记的集合配置得尽可能大。通常,这通过从已编译的拼写检查器中获取对象并保存出现频率较高的标记来实现。
在训练期间,使用了分词器工厂将数据标准化为由单个空格分隔的标记。它不会在编译步骤中序列化,因此,如果需要在不编辑标记中保持标记敏感性,则必须提供:
csc.setTokenizerFactory(tokenizerFactory);
int nBest = 3;
csc.setNBest(64);
nBest 参数设置了在修改输入时将考虑的假设数量。尽管输出中的 nBest 大小设置为 3,但建议在从左到右探索最佳编辑的过程中允许更大的假设空间。此外,类还有方法来控制允许的编辑以及如何评分。有关更多信息,请参阅教程和 Javadoc。
最后,我们将进行一个控制台 I/O 循环以生成拼写变化:
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String query = "";
while (true) {
System.out.println("Enter word, . to quit:");
query = reader.readLine();
if (query.equals(".")){
break;
}
String bestAlternative = csc.didYouMean(query);
System.out.println("Best Alternative: " + bestAlternative);
int i = 0;
Iterator<ScoredObject<String>> iterator = csc.didYouMeanNBest(query);
while (i < nBest) {
ScoredObject<String> so = iterator.next();
System.out.println("Nbest: " + i + ": " + so.getObject() + " Score:" + so.score());
i++;
}
}
提示
我们在这个模型中包含了一个字典,我们将像处理其他数据一样将字典条目输入到训练器中。
通过多次训练字典中的每个单词,可能会使字典得到增强。根据字典的数量,它可能会主导或被源训练数据所主导。
另请参阅
-
拼写修正教程更完整,涵盖了在
alias-i.com/lingpipe/demos/tutorial/querySpellChecker/read-me.html进行的评估 -
CompiledSpellChecker的 Javadoc 可以在alias-i.com/lingpipe/docs/api/com/aliasi/spell/CompiledSpellChecker.html找到 -
更多关于拼写检查器如何工作的内容,请参见教材《Speech and Language Processing》,Jurafsky、Dan 和 James H. Martin 编著,2000,Prentice-Hall。
大小写恢复校正器
大小写恢复拼写校正器,也叫做真大小写校正器,只恢复大小写,不更改其他任何内容,也就是说,它不会纠正拼写错误。当处理转录、自动语音识别输出、聊天记录等低质量文本时,这非常有用,因为这些文本通常包含各种大小写问题。我们通常希望增强这些文本,以构建更好的基于规则或机器学习的系统。例如,新闻和视频转录(如字幕)通常存在错误,这使得使用这些数据训练命名实体识别(NER)变得更加困难。大小写恢复可以作为不同数据源之间的标准化工具,确保所有数据的一致性。
如何操作……
-
在你的 IDE 中运行
CaseRestore类,或者在命令行中输入以下内容:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.CaseRestore -
现在,让我们输入一些错误大小写或单一大小写的文本:
Enter input, . to quit: george washington was the first president of the u.s.a Best Alternative: George Washington was the first President of the U.S.A Enter input, . to quit: ITS RUDE TO SHOUT ON THE WEB Best Alternative: its rude to shout on the Web -
如你所见,大小写错误已经被纠正。如果我们使用更现代的文本,例如当前的报纸数据或类似内容,这将直接应用于广播新闻转录或字幕的大小写标准化。
它是如何工作的……
该类的工作方式类似于拼写校正,我们有一个由语言模型指定的模型和一个由编辑距离度量指定的通道模型。然而,距离度量只允许大小写更改,也就是说,大小写变体是零成本的,所有其他编辑成本都被设置为 Double.NEGATIVE_INFINITY:
我们将重点讨论与前一个方法不同的部分,而不是重复所有源代码。我们将使用来自古腾堡计划的英文文本训练拼写检查器,并使用 CompiledSpellChecker 类中的 CASE_RESTORING 编辑距离:
int NGRAM_LENGTH = 5;
NGramProcessLM lm = new NGramProcessLM(NGRAM_LENGTH);
TrainSpellChecker sc = new TrainSpellChecker(lm,CompiledSpellChecker.CASE_RESTORING);
再次通过调用 bestAlternative 方法,我们将获得最好的大小写恢复文本估计:
String bestAlternative = csc.didYouMean(query);
就是这样。大小写恢复变得简单。
另见
- Lucian Vlad Lita 等人于 2003 年的论文,
www.cs.cmu.edu/~llita/papers/lita.truecasing-acl2003.pdf,是关于真大小写恢复的一个很好的参考资料。
自动短语补全
自动短语补全与拼写校正不同,它是在用户输入的文本中,从一组固定短语中找到最可能的补全。
显然,自动短语补全在网络上无处不在,例如,在google.com上。例如,如果我输入 anaz 作为查询,谷歌会弹出以下建议:
请注意,应用程序在完成补全的同时也在进行拼写检查。例如,即使查询到目前为止是anaz,但顶部的建议是amazon。这并不令人惊讶,因为以anaz开头的短语的结果数量可能非常少。
接下来,注意到它并不是进行单词建议,而是短语建议。比如一些结果,如amazon prime是由两个单词组成的。
自动补全和拼写检查之间的一个重要区别是,自动补全通常是基于一个固定的短语集,必须匹配开头才能完成。这意味着,如果我输入查询I want to find anaz,就不会有任何推荐补全。网页搜索的短语来源通常是来自查询日志的高频查询。
在 LingPipe 中,我们使用AutoCompleter类,它维护一个包含计数的短语字典,并通过加权编辑距离和短语似然性基于前缀匹配提供建议的补全。
自动补全器为给定的前缀找到得分最高的短语。短语与前缀的得分是短语得分和该前缀与短语任何前缀匹配的最大得分之和。短语的得分就是其最大似然概率估计,即其计数的对数除以所有计数的总和。
谷歌和其他搜索引擎很可能将它们的查询计数作为最佳得分短语的数据。由于我们这里没有查询日志,因此我们将使用美国人口超过 100,000 的城市的美国人口普查数据。短语是城市名称,计数是它们的人口。
如何操作...
-
在你的 IDE 中,运行
AutoComplete类,或者在命令行中输入以下命令:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.AutoComplete -
输入一些美国城市名称并查看输出。例如,输入
new将产生以下输出:Enter word, . to quit: new |new| -13.39 New York,New York -17.89 New Orleans,Louisiana -18.30 Newark,New Jersey -18.92 Newport News,Virginia -19.39 New Haven,Connecticut If we misspell 'new' and type 'mew' instead, Enter word, . to quit: mew |mew | -13.39 New York,New York -17.89 New Orleans,Louisiana -19.39 New Haven,Connecticut -
输入我们初始列表中不存在的城市名称将不会返回任何输出:
Enter word, . to quit: Alta,Wyoming |Alta,Wyoming|
它是如何工作的...
配置自动补全器与配置拼写检查非常相似,不同之处在于,我们不是训练一个语言模型,而是提供一个固定的短语和计数列表、一个编辑距离度量以及一些配置参数。代码的初始部分只是读取一个文件,并设置一个短语到计数的映射:
File wordsFile = new File("data/city_populations_2012.csv");
String[] lines = FileLineReader.readLineArray(wordsFile,"ISO-8859-1");
ObjectToCounterMap<String> cityPopMap = new ObjectToCounterMap<String>();
int lineCount = 0;
for (String line : lines) {
if(lineCount++ <1) continue;
int i = line.lastIndexOf(',');
if (i < 0) continue;
String phrase = line.substring(0,i);
String countString = line.substring(i+1);
Integer count = Integer.valueOf(countString);
cityPopMap.set(phrase,count);
}
下一步是配置编辑距离。此操作将衡量目标短语的前缀与查询前缀的相似度。该类使用固定权重的编辑距离,但一般来说,可以使用任何编辑距离:
double matchWeight = 0.0;
double insertWeight = -10.0;
double substituteWeight = -10.0;
double deleteWeight = -10.0;
double transposeWeight = Double.NEGATIVE_INFINITY;
FixedWeightEditDistance editDistance = new FixedWeightEditDistance(matchWeight,deleteWeight,insertWeight,substituteWeight,transposeWeight);
有一些参数可以调整自动补全:编辑距离和搜索参数。编辑距离的调整方式与拼写检查完全相同。返回结果的最大数量更多是应用程序的决定,而不是调整的决策。话虽如此,较小的结果集计算速度更快。最大队列大小表示在被修剪之前,自动补全器内部假设集可以变得多大。在仍能有效执行的情况下,将maxQueueSize设置为尽可能小,以提高速度:
int maxResults = 5;
int maxQueueSize = 10000;
double minScore = -25.0;
AutoCompleter completer = new AutoCompleter(cityPopMap, editDistance,maxResults, maxQueueSize, minScore);
另见
- 查看
AutoCompleter类的 Javadoc 文档:alias-i.com/lingpipe/docs/api/com/aliasi/spell/AutoCompleter.html
使用编辑距离的单链和完全链聚类
聚类是通过相似性将一组对象分组的过程,也就是说,使用某种距离度量。聚类的核心思想是,聚类内的对象彼此接近,而不同聚类的对象彼此较远。我们可以大致将聚类技术分为层次(或凝聚)和分治两种技术。层次技术从假设每个对象都是自己的聚类开始,然后合并聚类,直到满足停止准则。
例如,一个停止准则可以是每个聚类之间的固定距离。分治技术则恰好相反,首先将所有对象聚集到一个聚类中,然后进行拆分,直到满足停止准则,例如聚类的数量。
我们将在接下来的几个实例中回顾层次聚类技术。LingPipe 中我们将提供的两种聚类实现是单链聚类和完全链聚类;所得的聚类形成输入集的所谓划分。若一组集合是另一个集合的划分,则该集合的每个元素恰好属于划分中的一个集合。从数学角度来说,构成划分的集合是成对不相交的,并且它们的并集是原始集合。
聚类器接收一组对象作为输入,并返回一组对象的集合作为输出。也就是说,在代码中,Clusterer<String>有一个cluster方法,作用于Set<String>并返回Set<Set<String>>。
层次聚类器扩展了Clusterer接口,同样作用于一组对象,但返回的是Dendrogram(树状图),而不是一组对象的集合。树状图是一个二叉树,表示正在聚类的元素,其中每个分支附有距离值,表示两个子分支之间的距离。对于aa、aaa、aaaaa、bbb、bbbb这些字符串,基于单链的树状图并采用EditDistance作为度量看起来是这样的:
3.0
2.0
1.0
aaa
aa
aaaaa
1.0
bbbb
bbb
上述树状图基于单链聚类,单链聚类将任何两个元素之间的最小距离作为相似性的度量。因此,当{'aa','aaa'}与{'aaaa'}合并时,得分为 2.0,通过将两个a添加到aaa中。完全链接聚类则采用任何两个元素之间的最大距离,这将是 3.0,通过将三个a添加到aa中。单链聚类倾向于形成高度分离的聚类,而完全链接聚类则倾向于形成更紧密的聚类。
从树状图中提取聚类有两种方法。最简单的方法是设置一个距离上限,并保持所有在此上限或以下形成的聚类。另一种构建聚类的方法是继续切割最大距离的聚类,直到获得指定数量的聚类。
在这个示例中,我们将研究使用EditDistance作为距离度量的单链和完全链接聚类。我们将尝试通过EditDistance对城市名称进行聚类,最大距离为 4。
如何操作…
-
在您的 IDE 中运行
HierarchicalClustering类,或者在命令行中输入以下内容:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.HierarchicalClustering -
输出是对同一基础集合
Strings的各种聚类方法。在这个示例中,我们将交替展示源和输出。首先,我们将创建我们的字符串集合:public static void main(String[] args) throws UnsupportedEncodingException, IOException { Set<String> inputSet = new HashSet<String>(); String [] input = { "aa", "aaa", "aaaaa", "bbb", "bbbb" }; inputSet.addAll(Arrays.asList(input)); -
接下来,我们将设置一个使用
EditDistance的单链实例,并为前面的集合创建树状图并打印出来:boolean allowTranspositions = false; Distance<CharSequence> editDistance = new EditDistance(allowTranspositions); AbstractHierarchicalClusterer<String> slClusterer = new SingleLinkClusterer<String>(editDistance); Dendrogram<String> slDendrogram = slClusterer.hierarchicalCluster(inputSet); System.out.println("\nSingle Link Dendrogram"); System.out.println(slDendrogram.prettyPrint()); -
输出将如下所示:
Single Link Dendrogram 3.0 2.0 1.0 aaa aa aaaaa 1.0 bbbb bbb -
接下来,我们将创建并打印出相同集合的完全链接处理结果:
AbstractHierarchicalClusterer<String> clClusterer = new CompleteLinkClusterer<String>(editDistance); Dendrogram<String> clDendrogram = clClusterer.hierarchicalCluster(inputSet); System.out.println("\nComplete Link Dendrogram"); System.out.println(clDendrogram.prettyPrint()); -
这将产生相同的树状图,但具有不同的分数:
Complete Link Dendrogram 5.0 3.0 1.0 aaa aa aaaaa 1.0 bbbb bbb -
接下来,我们将生成控制单链情况聚类数量的聚类:
System.out.println("\nSingle Link Clusterings with k Clusters"); for (int k = 1; k < 6; ++k ) { Set<Set<String>> slKClustering = slDendrogram.partitionK(k); System.out.println(k + " " + slKClustering); } -
这将产生如下结果——对于完全链接来说,给定输入集合时,它们将是相同的:
Single Link Clusterings with k Clusters 1 [[bbbb, aaa, aa, aaaaa, bbb]] 2 [[aaa, aa, aaaaa], [bbbb, bbb]] 3 [[aaaaa], [bbbb, bbb], [aaa, aa]] 4 [[bbbb, bbb], [aa], [aaa], [aaaaa]] 5 [[bbbb], [aa], [aaa], [aaaaa], [bbb]] -
以下代码片段是没有最大距离的完全链接聚类:
Set<Set<String>> slClustering = slClusterer.cluster(inputSet); System.out.println("\nComplete Link Clustering No " + "Max Distance"); System.out.println(slClustering + "\n"); -
输出将是:
Complete Link Clustering No Max Distance [[bbbb, aaa, aa, aaaaa, bbb]] -
接下来,我们将控制最大距离:
for(int k = 1; k < 6; ++k ){ clClusterer.setMaxDistance(k); System.out.println("Complete Link Clustering at " + "Max Distance= " + k); Set<Set<String>> slClusteringMd = clClusterer.cluster(inputSet); System.out.println(slClusteringMd); } -
以下是通过最大距离限制的聚类效果,适用于完全链接的情况。请注意,这里的单链输入将在 3 的距离下将所有元素放在同一聚类中:
Complete Link Clustering at Max Distance= 1 [[bbbb, bbb], [aaa, aa], [aaaaa]] Complete Link Clustering at Max Distance= 2 [[bbbb, bbb], [aaa, aa], [aaaaa]] Complete Link Clustering at Max Distance= 3 [[bbbb, bbb], [aaa, aa, aaaaa]] Complete Link Clustering at Max Distance= 4 [[bbbb, bbb], [aaa, aa, aaaaa]] Complete Link Clustering at Max Distance= 5 [[bbbb, aaa, aa, aaaaa, bbb]] -
就是这样!我们已经演练了 LingPipe 聚类 API 的很大一部分。
还有更多内容…
聚类对用于比较聚类的Distance非常敏感。查阅 Javadoc 以获取 10 个实现类的可能变种。TfIdfDistance在聚类语言数据时非常有用。
K-means(++)聚类是一种基于特征提取的聚类方法。Javadoc 是这样描述它的:
K-means 聚类 可以视为一种迭代方法,旨在最小化项目与其聚类中心之间的平均平方距离……
另请参见…
- 要查看详细的教程,包括评估的具体细节,请访问
alias-i.com/lingpipe/demos/tutorial/cluster/read-me.html
潜在狄利克雷分配 (LDA) 用于多主题聚类
潜在狄利克雷分配 (LDA) 是一种基于文档中存在的标记或单词的文档聚类统计技术。像分类这样的聚类通常假设类别是互斥的。LDA 的一个特点是,它允许文档同时属于多个主题,而不仅仅是一个类别。这更好地反映了一个推文可以涉及迪士尼和沃利世界等多个主题的事实。
LDA 的另一个有趣之处,就像许多聚类技术一样,是它是无监督的,这意味着不需要监督式训练数据!最接近训练数据的是必须提前指定主题的数量。
LDA 可以是探索你不知道的未知数据集的一个很好的方式。它也可能很难调整,但通常它会做出一些有趣的结果。让我们让系统运作起来。
对于每个文档,LDA 根据该文档中的单词分配一个属于某个主题的概率。我们将从转换为标记序列的文档开始。LDA 使用标记的计数,并不关心单词出现的上下文或顺序。LDA 在每个文档上操作的模型被称为“词袋模型”,意味着顺序并不重要。
LDA 模型由固定数量的主题组成,每个主题都被建模为一个单词分布。LDA 下的文档被建模为主题分布。对单词的主题分布和文档的主题分布都存在狄利克雷先验。如果你想了解更多幕后发生的事情,可以查看 Javadoc、参考教程和研究文献。
准备工作
我们将继续使用来自推文的.csv数据。请参考第一章,简单分类器,了解如何获取推文,或使用书中的示例数据。该配方使用data/gravity_tweets.csv。
这个教程紧密跟随了alias-i.com/lingpipe/demos/tutorial/cluster/read-me.html中的教程,该教程比我们在这个配方中所做的更为详细。LDA 部分位于教程的最后。
如何做到的…
本节将对src/com/lingpipe/cookbook/chapter6/Lda.java进行源代码审查,并参考src/com/lingpipe/cookbook/chapter6/LdaReportingHandler.java辅助类,在使用其部分内容时进行讨论:
-
main()方法的顶部从标准的csv reader获取数据:File corpusFile = new File(args[0]); List<String[]> tweets = Util.readCsvRemoveHeader(corpusFile); -
接下来是一堆我们将逐行处理的配置。
minTokenCount会过滤掉在算法中出现次数少于五次的所有标记。随着数据集的增大,这个数字可能会增大。对于 1100 条推文,我们假设至少五次提及有助于减少 Twitter 数据的噪声:int minTokenCount = 5; -
numTopics参数可能是最关键的配置值,因为它告诉算法要找多少个主题。更改这个数字会产生非常不同的主题。你可以尝试调整它。选择 10 表示这 1100 条推文大致涉及 10 个主题。但这显然是错误的,也许 100 会更接近实际情况。也有可能这 1100 条推文有超过 1100 个主题,因为一条推文可以出现在多个主题中。可以多尝试一下:short numTopics = 10; -
根据 Javadoc,
documentTopicPrior的经验法则是将其设置为 5 除以主题数量(如果主题非常少,则可以设置更小的值;0.1 通常是使用的最大值):double documentTopicPrior = 0.1; -
topicWordPrior的一个通用实用值如下:double topicWordPrior = 0.01; -
burninEpochs参数设置在采样之前运行多少个周期。将其设置为大于 0 会产生一些理想的效果,避免样本之间的相关性。sampleLag控制在烧入阶段完成后,采样的频率,numSamples控制采样的数量。目前将进行 2000 次采样。如果burninEpochs为 1000,那么将会进行 3000 次采样,样本间隔为 1(每次都采样)。如果sampleLag为 2,那么将会有 5000 次迭代(1000 次烧入,2000 次每 2 个周期采样,总共 4000 个周期)。更多细节请参见 Javadoc 和教程:int burninEpochs = 0; int sampleLag = 1; int numSamples = 2000; -
最后,
randomSeed初始化了GibbsSampler中的随机过程:long randomSeed = 6474835; -
SymbolTable被构造,它将存储字符串到整数的映射,以便进行高效处理:SymbolTable symbolTable = new MapSymbolTable(); -
接下来是我们的标准分词器:
TokenzierFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE; -
接下来,打印 LDA 的配置:
System.out.println("Input file=" + corpusFile); System.out.println("Minimum token count=" + minTokenCount); System.out.println("Number of topics=" + numTopics); System.out.println("Topic prior in docs=" + documenttopicPrior); System.out.println("Word prior in topics=" + wordPrior); System.out.println("Burnin epochs=" + burninEpochs); System.out.println("Sample lag=" + sampleLag); System.out.println("Number of samples=" + numSamples); -
然后,我们将创建一个文档和标记的矩阵,这些矩阵将作为输入传递给 LDA,并报告有多少标记:
int[][] docTokens = LatentDirichletAllocation.tokenizeDocuments(IdaTexts,tokFactory,symbolTable, minTokenCount); System.out.println("Number of unique words above count" + " threshold=" + symbolTable.numSymbols()); -
紧接着进行一个合理性检查,报告总的标记数量:
int numTokens = 0; for (int[] tokens : docTokens){ numTokens += tokens.length; } System.out.println("Tokenized. #Tokens After Pruning=" + numTokens); -
为了获取有关周期/样本的进度报告,创建了一个处理程序来传递所需的消息。它将
symbolTable作为参数,以便能够在报告中重新创建标记:LdaReportingHandler handler = new LdaReportingHandler(symbolTable); -
搜索在
LdaReportingHandler中访问的方法如下:public void handle(LatentDirichletAllocation.GibbsSample sample) { System.out.printf("Epoch=%3d elapsed time=%s\n", sample.epoch(), Strings.msToString(System.currentTimeMillis() - mStartTime)); if ((sample.epoch() % 10) == 0) { double corpusLog2Prob = sample.corpusLog2Probability(); System.out.println(" log2 p(corpus|phi,theta)=" + corpusLog2Prob + " token cross" + entropy rate=" + (-corpusLog2Prob/sample.numTokens())); } } -
在完成所有设置之后,我们将开始运行 LDA:
LatentDirichletAllocation.GibbsSample sample = LatentDirichletAllocation.gibbsSampler(docTokens, numTopics,documentTopicPrior,wordPrior,burninEpochs,sampleLag,numSamples,new Random(randomSeed),handler); -
等一下,还有更多内容!不过,我们快完成了。只需要一个最终报告:
int maxWordsPerTopic = 20; int maxTopicsPerDoc = 10; boolean reportTokens = true; handler.reportTopics(sample,maxWordsPerTopic,maxTopicsPerDoc,reportTokens); -
最后,我们将开始运行这段代码。输入以下命令:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter6.LDA -
看一下结果输出的样本,确认配置和搜索周期的早期报告:
Input file=data/gravity_tweets.csv Minimum token count=1 Number of topics=10 Topic prior in docs=0.1 Word prior in topics=0.01 Burnin epochs=0 Sample lag=1 Number of samples=2000 Number of unique words above count threshold=1652 Tokenized. #Tokens After Pruning=10101 Epoch= 0 elapsed time=:00 log2 p(corpus|phi,theta)=-76895.71967475882 token cross-entropy rate=7.612683860484983 Epoch= 1 elapsed time=:00 -
完成时,我们将获得一个关于发现的主题的报告。第一个主题从按计数排序的单词列表开始。请注意,该主题没有标题。可以通过扫描具有高计数和高 Z 分数的单词来获取
meaning主题。在这种情况下,有一个 Z 分数为 4.0 的单词movie,a得到了 6.0,向下查看列表,我们看到good的得分为 5.6。Z 分数反映了该单词与具有较高分数的主题的非独立性,这意味着该单词与主题的关联更紧密。查看LdaReportingHandler的源代码以获取确切的定义。TOPIC 0 (total count=1033) WORD COUNT Z -------------------------------------------------- movie 109 4.0 Gravity 73 1.9 a 72 6.0 is 57 4.9 ! 52 3.2 was 45 6.0 . 42 -0.4 ? 41 5.8 good 39 5.6 -
前述输出相当糟糕,而其他主题看起来也不怎么样。下一个主题显示出了潜力,但由于标记化而出现了一些明显的问题:
TOPIC 1 (total count=1334) WORD COUNT Z -------------------------------------------------- / 144 2.2 . 117 2.5 # 91 3.5 @ 73 4.2 : 72 1.0 ! 50 2.7 co 49 1.3 t 47 0.8 http 47 1.2 -
戴上我们系统调谐者的帽子,我们将调整分词器为
new RegExTokenizerFactory("[^\\s]+")分词器,这真正清理了聚类,将聚类增加到 25 个,并应用Util.filterJaccard(tweets, tokFactory, .5)来去除重复项(从 1100 到 301)。这些步骤并非一次执行,但这是一个配方,因此我们展示了一些实验结果。由于没有评估测试集,所以这是一个逐步调整的过程,看看输出是否更好等等。聚类在这样一个开放性问题上评估和调整是非常困难的。输出看起来好了一些。 -
在浏览主题时,我们发现仍然有许多低价值的词汇扰乱了主题,但
Topic 18看起来有些有希望,其中best和ever的 Z 分数很高:OPIC 18 (total count=115) WORD COUNT Z -------------------------------------------------- movie 24 1.0 the 24 1.3 of 15 1.7 best 10 3.0 ever 9 2.8 one 9 2.8 I've 8 2.7 seen 7 1.8 most 4 1.4 it's 3 0.9 had 1 0.2 can 1 0.2 -
进一步查看输出,我们会看到一些在
Topic 18上得分很高的文档:DOC 34 TOPIC COUNT PROB ---------------------- 18 3 0.270 4 2 0.183 3 1 0.096 6 1 0.096 8 1 0.096 19 1 0.096 Gravity(4) is(6) the(8) best(18) movie(19) I've(18) seen(18) in(3) a(4) DOC 50 TOPIC COUNT PROB ---------------------- 18 6 0.394 17 4 0.265 5 2 0.135 7 1 0.071 The(17) movie(18) Gravity(7) has(17) to(17) be(5) one(18) of(18) the(18) best(18) of(18) all(17) time(5) -
对于
best movie ever主题,这两者看起来都是合理的。然而,请注意其他主题/文档分配相当糟糕。
诚实地说,我们不能完全宣称在这个数据集上取得了胜利,但我们已经阐明了 LDA 的工作原理及其配置。LDA 在商业上并不是巨大的成功,但它为国家卫生研究院和其他客户提供了有趣的概念级别实现。LDA 是一个调谐者的天堂,有很多方法可以对生成的聚类进行调整。查看教程和 Javadoc,并向我们发送您的成功案例。
第七章:在概念/人物之间寻找共指
本章将涵盖以下内容:
-
与文档中的命名实体共指
-
向共指中添加代词
-
跨文档共指
-
John Smith 问题
介绍
共指是人类语言中的一种基本机制,它使得两句话可以指代同一个事物。对人类交流而言,它非常重要——其功能与编程语言中的变量名非常相似,但在细节上,作用范围的定义规则与代码块截然不同。共指在商业上不那么重要——也许本章将帮助改变这一点。这里有一个例子:
Alice walked into the garden. She was surprised.
共指存在于Alice和She之间;这些短语指代的是同一个事物。当我们开始探讨一个文档中的 Alice 是否与另一个文档中的 Alice 相同时,情况变得非常有趣。
共指,就像词义消歧一样,是下一代的工业能力。共指的挑战促使美国国税局(IRS)坚持要求一个能够明确识别个人的社会保障号码,而不依赖于其名字。许多讨论的技术都是为了帮助跟踪文本数据中的个人和组织,尽管成功程度不一。
与文档中的命名实体共指
如第五章中所见,文本中的跨度 – Chunking,LingPipe 可以使用多种技术来识别与人、地方、事物、基因等相关的专有名词。然而,分块并未完全解决问题,因为它无法帮助在两个命名实体相同的情况下找到一个实体。能够判断 John Smith 和 Mr. Smith、John 甚至完全重复的 John Smith 是同一个实体是非常有用的——它甚至在我们还是一个初创国防承包商时就成为了我们公司成立的基础。我们的创新贡献是生成按实体索引的句子,这种方法证明是总结某个实体所讨论内容的极佳方式,尤其是当这种映射跨越不同语言时——我们称之为基于实体的摘要化。
注意
基于实体的摘要化的想法源自巴尔温在宾夕法尼亚大学研究生研讨会上的一次讲座。时任系主任的米奇·马库斯认为,展示所有提到某个实体的句子——包括代词——将是对该实体的极佳总结。从某种意义上说,这就是 LingPipe 诞生的原因。这一想法促使巴尔温领导了一个 UPenn DARPA 项目,并最终创立了 Alias-i。经验教训——与每个人交流你的想法和研究。
本教程将带你了解计算共指的基础知识。
准备工作
拿到一些叙述性文本,我们将使用一个简单的示例,大家知道它是有效的——共指系统通常需要针对特定领域进行大量调整。你可以自由选择其他文本,但它必须是英文的。
如何做...
如常,我们将通过命令行运行代码,然后深入分析代码的实际功能。我们开始吧。
-
我们将从一个简单的文本开始,以说明共指。文件位于
data/simpleCoref.txt,它包含:John Smith went to Washington. Mr. Smith is a business man. -
去命令行和 Java 解释器那里,复制以下内容:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.NamedEntityCoreference -
这会得到以下结果:
Reading in file :data/simpleCoref.txt Sentence Text=John Smith went to Washington. mention text=John Smith type=PERSON id=0 mention text=Washington type=LOCATION id=1 Sentence Text=Mr. Smith is a business man. mention text=Mr. Smith type=PERSON id=0 -
找到了三个命名实体。注意,输出中有一个
ID字段。John Smith和Mr. Smith实体具有相同的 ID,id=0。这意味着这些短语被认为是共指的。剩余的实体Washington具有不同的 ID,id=1,并且与 John Smith / Mr. Smith 不共指。 -
创建你自己的文本文件,将其作为参数传递到命令行,看看会计算出什么。
它是如何工作的...
LingPipe 中的共指代码是建立在句子检测和命名实体识别之上的启发式系统。总体流程如下:
-
对文本进行分词。
-
检测文档中的句子,对于每个句子,按从左到右的顺序检测句子中的命名实体,并对每个命名实体执行以下任务:
-
创建一个提到。提到是命名实体的单一实例。
-
提到可以被添加到现有的提到链中,或者可以启动它们自己的提到链。
-
尝试将提到的实体解析为已创建的提到链。如果找到唯一匹配,则将该提到添加到提到链中;否则,创建一个新的提到链。
-
代码位于src/com/lingpipe/cookbook/chapter7/NamedEntityCoreference.java。main()方法首先设置这个配方的各个部分,从分词器工厂、句子分块器,到最后的命名实体分块器:
public static void main(String[] args)
throws ClassNotFoundException, IOException {
String inputDoc = args.length > 0 ? args[0]
: "data/simpleCoref.txt";
System.out.println("Reading in file :"
+ inputDoc);
TokenizerFactory mTokenizerFactory
= IndoEuropeanTokenizerFactory.INSTANCE;
SentenceModel sentenceModel
= new IndoEuropeanSentenceModel();
Chunker sentenceChunker
= new SentenceChunker(mTokenizerFactory,sentenceModel);
File modelFile
= new File("models/ne-en-news-"
+ "muc6.AbstractCharLmRescoringChunker");
Chunker namedEntChunker
= (Chunker) AbstractExternalizable.readObject(modelFile);
现在,我们已经设置了基本的配方基础设施。接下来是一个共指专用类:
MentionFactory mf = new EnglishMentionFactory();
MentionFactory类从短语和类型创建提到——当前的源被命名为entities。接下来,共指类会以MentionFactory作为参数创建:
WithinDocCoref coref = new WithinDocCoref(mf);
WithinDocCoref类封装了计算共指的所有机制。从第五章,查找文本中的跨度 – 分块,你应该熟悉获取文档文本、检测句子,并遍历应用命名实体分块器到每个句子的代码:
File doc = new File(inputDoc);
String text = Files.readFromFile(doc,Strings.UTF8);
Chunking sentenceChunking
= sentenceChunker.chunk(text);
Iterator sentenceIt
= sentenceChunking.chunkSet().iterator();
for (int sentenceNum = 0; sentenceIt.hasNext(); ++sentenceNum) {
Chunk sentenceChunk = (Chunk) sentenceIt.next();
String sentenceText
= text.substring(sentenceChunk.start(),
sentenceChunk.end());
System.out.println("Sentence Text=" + sentenceText);
Chunking neChunking = namedEntChunker.chunk(sentenceText);
在当前句子的上下文中,句子中的命名实体会按从左到右的顺序进行迭代,就像它们被阅读的顺序一样。我们知道这一点是因为ChunkingImpl类按照它们被添加的顺序返回块,而我们的HMMChunker是以从左到右的顺序添加它们的:
Chunking neChunking = namedEntChunker.chunk(sentenceText);
for (Chunk neChunk : neChunking.chunkSet()) {
以下代码从分块中获取信息——类型和短语,但不包括偏移信息,并创建一个提到:
String mentionText
= sentenceText.substring(neChunk.start(),
neChunk.end());
String mentionType = neChunk.type();
Mention mention = mf.create(mentionText,mentionType);
下一行与提到的内容进行共指,并返回它所在的句子的 ID:
int mentionId = coref.resolveMention(mention,sentenceNum);
System.out.println(" mention text=" + mentionText
+ " type=" + mentionType
+ " id=" + mentionId);
如果提及已解析为现有实体,它将具有该 ID,正如我们在 Mr. Smith 例子中看到的那样。否则,它将获得一个独立的 ID,并且可以作为后续提及的先行词。
这涵盖了在文档内运行共指关系的机制。接下来的配方将介绍如何修改这个类。下一个配方将添加代词并提供引用。
向共指关系中添加代词
前面的配方处理了命名实体之间的共指关系。这个配方将把代词添加到其中。
如何操作……
这个配方将使用交互式版本帮助你探索共指算法的特性。该系统非常依赖命名实体检测的质量,因此请使用 HMM 可能正确识别的例子。它是在 90 年代的华尔街日报文章上进行训练的。
-
启动你的控制台并键入以下命令:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.Coreference -
在结果命令提示符中,键入以下内容:
Enter text followed by new line >John Smith went to Washington. He was a senator. Sentence Text=John Smith went to Washington. mention text=John Smith type=PERSON id=0 mention text=Washington type=LOCATION id=1 Sentence Text= He was a senator. mention text=He type=MALE_PRONOUN id=0 -
He和John Smith之间的共享 ID 表示它们之间的共指关系。接下来会有更多的例子和注释。请注意,每个输入被视为具有独立 ID 空间的不同文档。 -
如果代词没有解析为命名实体,它们会得到
-1的索引,如下所示:>He went to Washington. Sentence Text= He went to Washington. mention text=He type=MALE_PRONOUN id=-1 mention text=Washington type=LOCATION id=0 -
以下情况也会导致
id为-1,因为在先前的上下文中没有唯一的一个人,而是两个人。这被称为失败的唯一性预设:>Jay Smith and Jim Jones went to Washington. He was a senator. Sentence Text=Jay Smith and Jim Jones went to Washington. mention text=Jay Smith type=PERSON id=0 mention text=Jim Jones type=PERSON id=1 mention text=Washington type=LOCATION id=2 Sentence Text= He was a senator. mention text=He type=MALE_PRONOUN id=-1 -
以下代码显示了
John Smith也可以解析为女性代词。这是因为没有关于哪些名字表示哪种性别的数据。可以添加这类数据,但通常上下文会消除歧义。John也可能是一个女性名字。关键在于,代词会消除性别歧义,后续的男性代词将无法匹配:Frank Smith went to Washington. She was a senator. Sentence Text=Frank Smith went to Washington. mention text=Frank Smith type=PERSON id=0 mention text=Washington type=LOCATION id=1 Sentence Text=She was a senator. mention text=She type=FEMALE_PRONOUN id=0 -
性别分配将阻止错误性别的引用。以下代码中的
He代词被解析为 ID-1,因为唯一的人被解析为女性代词:John Smith went to Washington. She was a senator. He is now a lobbyist. Sentence Text=John Smith went to Washington. mention text=John Smith type=PERSON id=0 mention text=Washington type=LOCATION id=1 Sentence Text=She was a senator. mention text=She type=FEMALE_PRONOUN id=0 Sentence Text=He is now a lobbyist. mention text=He type=MALE_PRONOUN id=-1 -
共指关系也可以发生在句子内部:
>Jane Smith knows her future. Sentence Text=Jane Smith knows her future. mention text=Jane Smith type=PERSON id=0 mention text=her type=FEMALE_PRONOUN id=0 -
提及的顺序(按最新提及排序)在解析提及时很重要。在以下代码中,
He被解析为James,而不是John:John is in this sentence. Another sentence about nothing. James is in this sentence. He is here. Sentence Text=John is in this sentence. mention text=John type=PERSON id=0 Sentence Text=Another sentence about nothing. Sentence Text=James is in this sentence. mention text=James type=PERSON id=1 Sentence Text=He is here. mention text=He type=MALE_PRONOUN id=1 -
命名实体提及也会产生相同的效果。
Mr. Smith实体解析为最后一次提及:John Smith is in this sentence. Random sentence. James Smith is in this sentence. Mr. Smith is mention again here. Sentence Text=John Smith is in this sentence. mention text=John Smith type=PERSON id=0 Sentence Text=Random sentence. mention text=Random type=ORGANIZATION id=1 Sentence Text=James Smith is in this sentence. mention text=James Smith type=PERSON id=2 Sentence Text=Mr. Smith is mention again here. mention text=Mr. Smith type=PERSON id=2 -
如果插入太多句子,
John和James之间的区别将消失:John Smith is in this sentence. Random sentence. James Smith is in this sentence. Random sentence. Random sentence. Mr. Smith is here. Sentence Text=John Smith is in this sentence. mention text=John Smith type=PERSON id=0 Sentence Text=Random sentence. mention text=Random type=ORGANIZATION id=1 Sentence Text=James Smith is in this sentence. mention text=James Smith type=PERSON id=2 Sentence Text=Random sentence. mention text=Random type=ORGANIZATION id=1 Sentence Text=Random sentence. mention text=Random type=ORGANIZATION id=1 Sentence Text=Mr. Smith is here. mention text=Mr. Smith type=PERSON id=3
前面的例子旨在展示文档内共指关系系统的特性。
它是如何工作的……
添加代词的代码变化非常直接。此配方的代码位于src/com/lingpipe/cookbook/chapter7/Coreference.java。该配方假设你理解了前一个配方,因此这里只涵盖了代词提及的添加:
Chunking mentionChunking
= neChunker.chunk(sentenceText);
Set<Chunk> chunkSet = new TreeSet<Chunk> (Chunk.TEXT_ORDER_COMPARATOR);
chunkSet.addAll(mentionChunking.chunkSet());
我们从多个来源添加了Mention对象,因此元素的顺序不再有保证。相应地,我们创建了TreeSet和适当的比较器,并将所有来自neChunker的分块添加到其中。
接下来,我们将添加男性和女性代词:
addRegexMatchingChunks(MALE_EN_PRONOUNS,"MALE_PRONOUN",
sentenceText,chunkSet);
addRegexMatchingChunks(FEMALE_EN_PRONOUNS,"FEMALE_PRONOUN",
sentenceText,chunkSet);
MALE_EN_PRONOUNS常量是一个正则表达式,Pattern:
static Pattern MALE_EN_PRONOUNS = Pattern.compile("\\b(He|he|Him|him)\\b");
以下代码行展示了addRegExMatchingChunks子程序。它根据正则表达式匹配添加片段,并移除重叠的、已有的 HMM 派生片段:
static void addRegexMatchingChunks(Pattern pattern, String type, String text, Set<Chunk> chunkSet) {
java.util.regex.Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
Chunk regexChunk
= ChunkFactory.createChunk(matcher.start(),
matcher.end(),
type);
for (Chunk chunk : chunkSet) {
if (ChunkingImpl.overlap(chunk,regexChunk)) {
chunkSet.remove(chunk);
}
}
chunkSet.add(regexChunk);
}
复杂之处在于,MALE_PRONOUN和FEMALE_PRONOUN代词的类型将用于与PERSON实体匹配,结果是解析过程会设置被解析实体的性别。
除此之外,代码应与我们标准的 I/O 循环非常相似,该循环在命令提示符中运行交互。
另见
系统背后的算法基于 Baldwin 的博士论文。该系统名为 CogNIAC,工作始于 90 年代中期,并非当前最先进的共指消解系统。更现代的方法很可能会使用机器学习框架,利用 Baldwin 方法生成的特征和其他许多特征来开发一个性能更好的系统。有关该系统的论文可见于www.aclweb.org/anthology/W/W97/W97-1306.pdf。
跨文档共指
跨文档共指(XDoc)将单个文档的id空间扩展到更广泛的宇宙。这一宇宙通常包括其他处理过的文档和已知实体的数据库。虽然注解本身非常简单,只需将文档范围内的 ID 替换为宇宙范围内的 ID 即可,但计算 XDoc 可能相当复杂。
本教程将告诉我们如何使用在多年部署此类系统过程中开发的轻量级 XDoc 实现。我们将为那些可能希望扩展/修改代码的人提供代码概述,但内容较为复杂,教程也相当密集。
输入采用 XML 格式,其中每个文件可以包含多个文档:
<doc id="1">
<title/>
<content>
Breck Baldwin and Krishna Dayanidhi wrote a book about LingPipe.
</content>
</doc>
<doc id="2">
<title/>
<content>
Krishna Dayanidhi is a developer. Breck Baldwin is too.
</content>
</doc>
<doc id="3">
<title/>
<content>
K-dog likes to cook as does Breckles.
</content>
</doc>
目标是生成注解,其中 Breck Baldwin 的提及在各个文档中共享与 Krishna 相同的 ID。注意,在最后一篇文档中,二者都是以昵称被提及的。
XDoc 的一个常见扩展是将已知实体的数据库(DB)与文本中提到的这些实体进行链接。这弥合了结构化数据库和非结构化数据(文本)之间的鸿沟,许多人认为这是商业智能/客户声音/企业知识管理中的下一个重要发展方向。我们曾构建过将基因/蛋白质数据库与 MEDLINE 摘要、以及人物关注名单与自由文本等链接的系统。数据库还为人工编辑提供了一种自然的方式来控制 XDoc 的行为。
如何实现...
本食谱的所有代码都位于 com.lingpipe.cookbook.chapter7.tracker 包中。
-
访问您的 IDE 并运行
RunTracker,或者在命令行中输入以下命令:java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.tracker.RunTracker -
屏幕将会滚动,显示文档分析的过程,但我们将转到指定的输出文件并进行查看。用您喜欢的文本编辑器打开
cookbook/data/xDoc/output/docs1.xml。您将看到一个格式不佳的示例输出版本,除非您的编辑器能够自动格式化 XML(例如,Firefox 浏览器能较好地呈现 XML)。输出应如下所示:<docs> <doc id="1"> <title/> <content> <s index="0"> <entity id="1000000001" type="OTHER">Breck Baldwin</entity> and <entity id="1000000002" type="OTHER">Krishna Dayanidhi</entity> wrote a book about <entity id="1000000003" type="OTHER">LingPipe.</entity> </s> </content> </doc> <doc id="2"> <title/> <content><s index="0"> <entity id="1000000002" type="OTHER">Krishna Dayanidhi</entity> is a developer. </s> <s index="1"><entity id="1000000001" type="OTHER">Breck Baldwin</entity> is too. </s> </content> </doc> <doc id="3"><title/><content><s index="0">K-dog likes to cook as does <entity id="1000000004" start="28" type="OTHER">Breckles</entity>.</s></content></doc> </docs> -
Krishna在前两份文档中被共享 ID1000000002识别,但昵称K-dog完全没有被识别。Breck在所有三份文档中都被识别,但由于第三次提到的 IDBreckles与前两次提到的不同,系统认为它们不是同一个实体。 -
接下来,我们将使用字典形式的数据库来提高当作者通过昵称提及时的识别度。
data/xDoc/author-dictionary.xml中有一个字典,内容如下:<dictionary> <entity canonical="Breck Baldwin" id="1" speculativeAliases="0" type="MALE"> <alias xdc="1">Breck Baldwin</alias> <alias xdc="1">Breckles</alias> <alias xdc="0">Breck</alias> </entity> <entity canonical="Krishna Dayanidhi" id="2" speculativeAliases="0" type="MALE"> <alias xdc="1">Krishna Dayanidhi</alias> <alias xdc="1">K-Dog</alias> <alias xdc="0">Krishna</alias> </entity> -
上述字典包含了两位作者的昵称,以及他们的名字。带有
xdc=1值的别名将用于跨文档链接实体。xdc=0值只会在单个文档内应用。所有别名将通过字典查找来识别命名实体。 -
运行以下命令,指定实体字典或相应的 IDE 等效项:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.tracker.RunTracker data/xDoc/author-dictionary.xml -
xDoc/output/docs1.xml中的输出与上次运行的结果有很大不同。首先,注意现在的 ID 与字典文件中指定的相同:Breck的 ID 为1,Krishna的 ID 为2。这是结构化数据库(如字典的性质)与非结构化文本之间的联系。其次,注意到我们的昵称已经被正确识别并分配到正确的 ID。第三,注意到类型现在是MALE,而不是OTHER:<docs> <doc id="1"> <title/> <content> <s index="0"> <entity id="1" type="MALE">Breck Baldwin</entity> and <entity id="2" type="MALE">Krishna Dayanidhi</entity> wrote a book about <entity id="1000000001" type="OTHER">LingPipe.</entity> </s> </content> </doc> <doc id="2"> <title/> <content> <s index="0"> <entity id="2" start="0" type="MALE">K-dog</entity> likes to cook as does <entity id="1" start="28" type="MALE">Breckles</entity>. </s> </content> </doc> </docs>
这是对如何运行 XDoc 的简要介绍。在接下来的部分,我们将看到它是如何工作的。
它是如何工作的…
在这道食谱之前,我们一直尝试保持代码简单、直观并且易于理解,而不深入探讨大量源代码。这道食谱更为复杂。支撑这个食谱的代码无法完全放入预定的空间中进行解释。此处的阐述假设您会自行探究整个类,并参考本书中的其他食谱进行说明。我们提供这道食谱是因为 XDoc 核心参考是一个非常有趣的问题,我们现有的基础设施可能帮助其他人探索这一现象。欢迎来到游泳池的深水区。
批处理生命周期
整个过程由 RunTracker.java 类控制。main() 方法的总体流程如下:
-
读取已知实体的数据库,这些实体将通过
Dictionary进行命名实体识别,并且存在从别名到字典条目的已知映射。别名包含关于是否应该通过xdc=1或xdc=0标志用于跨文档匹配实体的说明。 -
设置
EntityUniverse,它是文本中找到的实体及已知实体字典的全局 ID 数据结构。 -
设置文档内核心指代所需的内容——例如分词器、句子检测器和命名实体检测器。还会用到一些 POS 标注器和词汇计数器,处理得稍微复杂一些。
-
有一个布尔值控制是否会添加推测性实体。如果该布尔值为
true,则表示我们会将从未见过的实体添加到跨文档实体的宇宙中。将该值设置为true时,可靠地计算是更具挑战性的任务。 -
所有提到的配置都用于创建
Tracker对象。 -
然后,
main()方法读取待处理的文档,将其交给Tracker对象进行处理,并将处理结果写入磁盘。Tracker.processDocuments()方法的主要步骤如下:-
获取一组 XML 格式的文档,并获取单个文档。
-
对于每个文档,应用
processDocument()方法,该方法使用字典进行文档内核心指代分析,帮助查找实体以及命名实体检测器,并返回MentionChain[]。然后,将每个提及的链条与实体宇宙进行对比,以更新文档级 ID 为实体宇宙 ID。最后一步是将文档写入磁盘,带上实体宇宙 ID。
-
以上就是我们要说的关于RunTracker的内容,里面没有任何你在本书中无法处理的内容。在接下来的章节中,我们将讨论RunTracker使用的各个组成部分。
设置实体宇宙
实体宇宙EntityUniverse.java是文档/数据库集合中提到的全局实体的内存表示。实体宇宙还包含指向这些实体的各种索引,支持在单个文档上计算 XDoc。
字典将已知实体填充到EntityUniverse文件中,随后处理的文档会对这些实体敏感。XDoc 算法尝试在创建新实体之前与现有实体合并,因此字典中的实体会强烈吸引这些实体的提及。
每个实体由唯一的长整型 ID、一组分为四个单独列表的别名和一个类型(如人、地点等)组成。还会说明该实体是否在用户定义的字典中,以及是否允许添加推测性提及到该实体。toString()方法将实体列出为:
id=556 type=ORGANIZATION userDefined=true allowSpec=false user XDC=[Nokia Corp., Nokia] user non-XDC=[] spec XDC=[] spec non-XDC
=[]
全局数据结构如下:
private long mLastId = FIRST_SYSTEM_ID;
实体需要唯一的 ID,我们约定FIRST_SYSTEM_ID的值是一个大整数,比如1,000,000。这样可以为用户提供一个空间(ID < 1,000,000),以便他们在不会与系统已发现的实体发生冲突的情况下添加新实体。
我们将为整个跟踪器实例化一个分词器:
private final TokenizerFactory mTokenizerFactory;
存在一个全局映射,将唯一的实体 ID 映射到实体:
private final Map<Long,Entity> mIdToEntity
= new HashMap<Long,Entity>();
另一个重要的数据结构是一个将别名(短语)映射到拥有该别名的实体的映射——mXdcPhraseToEntitySet。只有那些可以作为跨文档共指候选的短语才会被添加到这里。从字典中,xdc=1的别名会被添加进来:
private final ObjectToSet<String,Entity> mXdcPhraseToEntitySet
= new ObjectToSet<String,Entity>();
对于推测性找到的别名,如果该别名至少包含两个标记且尚未关联到其他实体,则将其添加到此集合中。这反映了一种启发式方法,力求不拆分实体。这一逻辑相当复杂,超出了本教程的范畴。你可以参考EntityUniverse.createEntitySpeculative和EntityUniverse.addPhraseToEntity中的代码。
为什么有些别名在寻找候选实体时不被使用?考虑到George对EntityUniverse中的实体区分帮助不大,而George H.W. Bush则提供了更多的信息用于区分。
ProcessDocuments()和 ProcessDocument()
有趣的部分开始出现在Tracker.processDocuments()方法中,该方法调用每个文档的 XML 解析,然后逐步调用processDocument()方法。前者的代码比较简单,因此我们将跳到更具任务特定性的工作部分,即调用processDocument()方法时的逻辑:
public synchronized OutputDocument processDocument(
InputDocument document) {
WithinDocCoref coref
= new WithinDocCoref(mMentionFactory);
String title = document.title();
String content = document.content();
List<String> sentenceTextList = new ArrayList<String>();
List<Mention[]> sentenceMentionList
= new ArrayList<Mention[]>();
List<int[]> mentionStartList = new ArrayList<int[]>();
List<int[]> mentionEndList = new ArrayList<int[]>();
int firstContentSentenceIndex
= processBlock(title,0,
sentenceTextList,
sentenceMentionList,
mentionStartList,mentionEndList,
coref);
processBlock(content,firstContentSentenceIndex,
sentenceTextList,
sentenceMentionList,
mentionStartList,mentionEndList,
coref);
MentionChain[] chains = coref.mentionChains();
我们使用了一种文档格式,可以将标题与正文区分开来。如果标题格式与正文格式有所不同,这种做法是一个好主意,就像新闻稿中通常所做的那样。chains变量将包含来自标题和正文的链条,其中可能存在相互指代的情况。mentionStartList和mentionEndList数组将在方法的后续步骤中使得重新对齐文档范围内的 ID 与实体宇宙范围内的 ID 成为可能:
Entity[] entities = mXDocCoref.xdocCoref(chains);
计算 XDoc
XDoc 代码是通过多小时手动调试算法的结果,旨在处理新闻风格的数据。它已经在 20,000 文档范围的数据集上运行,并且设计上非常积极地支持词典条目。该代码还试图避免短路,即当显然不同的实体被合并在一起时会发生的错误。如果你错误地将芭芭拉·布什和乔治·布什视为同义词,那么结果将会非常尴尬,用户将看到这些错误。
另一种错误是全局存储中有两个实体,而实际上一个就足够了。这类似于超人/克拉克·肯特问题,同样也适用于多次提及同一个名字的情况。
我们将从顶层代码开始:
public Entity[] xdocCoref(MentionChain[] chains) { Entity[]
entities = new Entity[chains.length];
Map<MentionChain,Entity> chainToEntity
= new HashMap<MentionChain,Entity>();
ObjectToSet<Entity,MentionChain> entityToChainSet
= new ObjectToSet<Entity,MentionChain>();
for (MentionChain chain : chains)
resolveMentionChain((TTMentionChain) chain,
chainToEntity, entityToChainSet);
for (int i = 0; i < chains.length; ++i) {
TTMentionChain chain = (TTMentionChain) chains[i];
Entity entity = chainToEntity.get(chain);
if (entity != null) {
if (Tracker.DEBUG) {
System.out.println("XDOC: resolved to" + entity);
Set chainSetForEntity = entityToChainSet.get(entity);
if (chainSetForEntity.size() > 1)
System.out.println("XDOC: multiple chains resolved to same entity " + entity.id());
}
entities[i] = entity;
if (entity.addSpeculativeAliases())
addMentionChainToEntity(chain,entity);
} else {
Entity newEntity = promote(chain);
entities[i] = newEntity;
}
}
return entities;
}
一个文档包含一个提及链列表,每个提及链要么被添加到现有实体中,要么被提升为一个新实体。提及链必须包含一个非代词的提及,这在文档内的共指层面上进行处理。
在处理每个提及链时,会更新三种数据结构:
-
Entity[]实体由xdocCoref方法返回,以支持文档的内联注解。 -
Map<MentionChain,Entity> chainToEntity将提及链映射到实体。 -
ObjectToSet<Entity,MentionChain> entityToChainSet是chainToEntity的反向映射。可能同一文档中的多个链条映射到同一实体,因此这个数据结构要考虑到这种可能性。该版本的代码允许这种情况发生——实际上,XDoc 正在以副作用的方式设置文档内的共指解析。
很简单,如果找到了实体,addMentionChainToEntity() 方法会将提及链中的任何新信息添加到该实体中。新信息可能包括新别名和类型变化(例如,通过消歧代词引用将一个人从无性别转变为男性或女性)。如果没有找到实体,那么提及链会被送到 promote(),它会在实体宇宙中创建一个新实体。我们将从 promote() 开始。
promote() 方法
实体宇宙是一个极简的数据结构,仅记录短语、类型和 ID。TTMentionChain 类是特定文档中提及的更复杂的表示形式:
private Entity promote(TTMentionChain chain) {
Entity entity
= mEntityUniverse.createEntitySpeculative(
chain.normalPhrases(),
chain.entityType());
if (Tracker.DEBUG)
System.out.println("XDOC: promoted " + entity);
return entity;
}
对 mEntityUniverse.createEntitySpeculative 的调用只需要链条的短语(在这种情况下,已归一化为小写且所有空格序列被转换为单个空格的短语)以及实体的类型。不会记录提及链来自的文档、计数或其他潜在有用的信息。这样做是为了尽量减小内存表示。如果需要查找实体被提及的所有句子或文档(这是一个常见任务),那么实体 ID 到文档的映射必须存储在其他地方。XDoc 执行后生成的文档 XML 表示是解决这些需求的一个自然起点。
createEntitySpeculative() 方法
创建一个推测性找到的新实体只需要确定哪些别名是将提及链连接起来的好候选。适合跨文档共指的那些别名进入 xdcPhrases 集合,其他的进入 nonXdc 短语集合:
public Entity createEntitySpeculative(Set<String> phrases,
String entityType) {
Set<String> nonXdcPhrases = new HashSet<String>();
Set<String> xdcPhrases = new HashSet<String>();
for (String phrase : phrases) {
if (isXdcPhrase(phrase,hasMultiWordPhrases))
xdcPhrases.add(phrase);
else
nonXdcPhrases.add(phrase);
}
while (mIdToEntity.containsKey(++mLastId)) ; // move up to next untaken ID
Entity entity = new Entity(mLastId,entityType,
null,null,xdcPhrases,nonXdcPhrases);
add(entity);
return entity;
}
boolean 方法 XdcPhrase() 在 XDoc 过程中扮演着关键角色。当前的方法支持一种非常保守的对什么是好 XDoc 短语的定义。直觉上,在新闻领域,诸如 he、Bob 和 John Smith 这样的短语并不好,无法有效地指示正在讨论的独特个体。好的短语可能是 Breckenridge Baldwin,因为那可能是一个独特的名字。有很多复杂的理论解释这里发生了什么,参见刚性指示符(en.wikipedia.org/wiki/Rigid_designator)。接下来的几行代码几乎抹去了 2,000 年的哲学思想:
public boolean isXdcPhrase(String phrase,
boolean hasMultiWordPhrase) {
if (mXdcPhraseToEntitySet.containsKey(phrase)) {
return false;
}
if (phrase.indexOf(' ') == -1 && hasMultiWordPhrase) {
return false;
}
if (PronounChunker.isPronominal(phrase)) {
return false;
}
return true;
}
这种方法试图识别 XDoc 中的不良短语,而不是好的短语。推理如下:
-
短语已经与一个实体相关联:这强制假设世界上只有一个 John Smith。这对于情报收集应用程序非常有效,因为分析师在分辨
John Smith案例时几乎没有困难。你可以参考本章末尾的 The John Smith problem 配方了解更多内容。 -
短语只有一个单词,并且与提及链或实体相关联的有多个单词短语:这假设较长的单词对 XDoc 更有利。请注意,实体创建的不同顺序可能导致单个单词短语在具有多单词别名的实体上,
xdc为true。 -
短语是代词:这是一个相对安全的假设,除非我们处在宗教文本中,其中句中间大写的
He或Him表示指向上帝。
一旦知道了 xdc 和 nonXdc 短语的集合,实体就会被创建。请参阅 Entity.java 的源代码,了解实体是如何创建的。
然后,实体被创建,add 方法更新 EntityUniverse 文件中从 xdc 短语到实体 ID 的映射:
public void add(Entity e) {
if (e.id() > mLastId)
mLastId = e.id();
mIdToEntity.put(new Long(e.id()),e);
for (String phrase : e.xdcPhrases()) {
mXdcPhraseToEntitySet.addMember(phrase,e);
}
}
EntityUniverse 文件的全局 mXdcPhraseToEntitySet 变量是找到候选实体的关键,正如在 xdcEntitiesToPhrase() 中使用的那样。
XDocCoref.addMentionChainToEntity() 实体
返回到 XDocCoref.xdocCoref() 方法,我们已经介绍了如何通过 XDocCoref.promote() 创建一个新实体。接下来要讨论的选项是当提及链被解析为现有实体时会发生什么,即 XDocCoref.addMentionChainToEntity()。为了添加推测性提及,实体必须允许通过 Entity.allowSpeculativeAliases() 方法提供的推测性找到的提及。这是用户定义的字典实体的一个特性,已在用户定义实体中讨论过。如果允许推测性实体,则提及链会被添加到实体中,并且会根据它们是否为 xdc 短语来敏感处理:
private void addMentionChainToEntity(TTMentionChain chain,
Entity entity) {
for (String phrase : chain.normalPhrases()) {
mEntityUniverse.addPhraseToEntity(normalPhrase,
entity);
}
}
添加提及链到实体的唯一变化就是增加了一个新的短语。这些附加的短语会像在提及链的提升过程中那样被分类为是否为xdc。
到目前为止,我们已经了解了文档中的提及链是如何被提升为猜测实体,或者如何与EntityUniverse中的现有实体合并的。接下来,我们将探讨在XDocCoref.resolveMentionChain()中解析是如何进行的。
XDocCoref.resolveMentionChain()实体
XDocCoref.resolveMentionChain()方法组装了一个可能与被解析的提及链匹配的实体集合,并通过调用XDocCoref.resolveCandidates()尝试找到唯一的实体:
private void resolveMentionChain(TTMentionChain chain, Map<MentionChain,Entity> chainToEntity, ObjectToSet<Entity,MentionChain> entityToChainSet) {
if (Tracker.DEBUG)
System.out.println("XDOC: resolving mention chain "
+ chain);
int maxLengthAliasOnMentionChain = 0;
int maxLengthAliasResolvedToEntityFromMentionChain = -1;
Set<String> tokens = new HashSet<String>();
Set<Entity> candidateEntities = new HashSet<Entity>();
for (String phrase : chain.normalPhrases()) {
String[] phraseTokens = mEntityUniverse.normalTokens(phrase);
String normalPhrase
= mEntityUniverse.concatenateNormalTokens(phraseTokens);
for (int i = 0; i < phraseTokens.length; ++i) {
tokens.add(phraseTokens[i]);
}
int length = phraseTokens.length;
if (length > maxLengthAliasOnMentionChain) {
maxLengthAliasOnMentionChain = length;
}
Set<Entity> matchingEntities
= mEntityUniverse.xdcEntitiesWithPhrase(phrase);
for (Entity entity : matchingEntities) {
if (null != TTMatchers.unifyEntityTypes(
chain.entityType(),
entity.type())) {
if (maxLengthAliasResolvedToEntityFromMentionChain < length)
maxLengthAliasResolvedToEntityFromMentionChain = length;
candidateEntities.add(entity);
}
}
}
resolveCandidates(chain,
tokens,
candidateEntities,
maxLengthAliasResolvedToEntityFromMentionChain == maxLengthAliasOnMentionChain,
chainToEntity,
entityToChainSet);}
该代码通过调用EntityUniverse.xdcEntitiesWithPhrase()从实体宇宙中查找实体集合。所有提及链的别名都会被尝试,而不考虑它们是否是有效的 XDoc 别名。在将实体添加到candidateEntities之前,返回的类型必须与TTMatchers.unifyEntityTypes所确定的提及链类型一致。这样,华盛顿(地点)就不会被解析为华盛顿(人名)。在此过程中,会做一些记录工作,以确定提及链上最长的别名是否与某个实体匹配。
resolveCandidates()方法
resolveCandidates()方法捕捉了一个关键假设,这一假设适用于文档内和 XDoc 共指的情况——这种不歧义的引用是唯一的解析基础。在文档内的案例中,一个类似的问题是“Bob 和 Joe 一起工作。他掉进了脱粒机。”这里的“他”指的是谁?单一指代词有唯一先行词的语言预期被称为唯一性假设。一个 XDoc 的例子如下:
-
Doc1:约翰·史密斯是《风中奇缘》中的一个角色
-
Doc2:约翰·史密斯是董事长或总经理
-
Doc3:约翰·史密斯受人尊敬
Doc3中的约翰·史密斯与哪个约翰·史密斯相匹配?也许,两者都不是。这个软件中的算法要求在匹配标准下应该有一个唯一的实体得以保留。如果有多个或没有,系统就会创建一个新的实体。其实现方式如下:
private void resolveCandidates(TTMentionChain chain,
Set<String> tokens,
Set<Entity> candidateEntities,
boolean resolvedAtMaxLength,
Map<MentionChain,Entity> chainToEntity,
ObjectToSet<Entity,MentionChain> entityToChainSet) {
filterCandidates(chain,tokens,candidateEntities,resolvedAtMaxLength);
if (candidateEntities.size() == 0)
return;
if (candidateEntities.size() == 1) {
Entity entity = Collections.<Entity>getFirst(candidateEntities);
chainToEntity.put(chain,entity);
entityToChainSet.addMember(entity,chain);
return;
}
// BLOWN Uniqueness Presupposition; candidateEntities.size() > 1
if (Tracker.DEBUG)
System.out.println("Blown UP; candidateEntities.size()=" + candidateEntities.size());
}
filterCandidates方法会删除因各种语义原因无法通过的所有候选实体。只有当实体宇宙中的一个实体有唯一的匹配时,才会发生共指。这里并没有区分候选实体过多(多个)或过少(零)的情况。在一个更高级的系统中,如果实体过多,可以尝试通过context进一步消除歧义。
这是 XDoc 代码的核心。其余的代码使用xdocCoref方法返回的实体宇宙相关索引对文档进行标注,这部分我们刚刚已经讲解过:
Entity[] entities = mXDocCoref.xdocCoref(chains);
以下的for循环遍历了提到的链,这些链与xdocCoref返回的Entities[]对齐。对于每一个提到的链,提到的内容会被映射到它的跨文档实体:
Map<Mention,Entity> mentionToEntityMap
= new HashMap<Mention,Entity>();
for (int i = 0; i < chains.length; ++i){
for (Mention mention : chains[i].mentions()) {
mentionToEntityMap.put(mention,entities[i]);
}
}
接下来,代码将设置一系列映射,创建反映实体宇宙 ID 的块:
String[] sentenceTexts
= sentenceTextList
.<String>toArray(new String[sentenceTextList.size()])
Mention[][] sentenceMentions
= sentenceMentionList
.<Mention[]>toArray(new Mention[sentenceMentionList.size()][]);
int[][] mentionStarts
= mentionStartList
.<int[]>toArray(new int[mentionStartList.size()][]);
int[][] mentionEnds
= mentionEndList
.<int[]>toArray(new int[mentionEndList.size()][]);
实际的块创建在下一步进行:
Chunking[] chunkings = new Chunking[sentenceTexts.length];
for (int i = 0; i < chunkings.length; ++i) {
ChunkingImpl chunking = new ChunkingImpl(sentenceTexts[i]);
chunkings[i] = chunking;
for (int j = 0; j < sentenceMentions[i].length; ++j) {
Mention mention = sentenceMentions[i][j];
Entity entity = mentionToEntityMap.get(mention);
if (entity == null) {
Chunk chunk = ChunkFactory.createChunk(mentionStarts[i][j],
mentionEnds[i][j],
mention.entityType()
+ ":-1");
//chunking.add(chunk); //uncomment to get unresolved ents as -1 indexed.
} else {
Chunk chunk = ChunkFactory.createChunk(mentionStarts[i][j],
mentionEnds[i][j],
entity.type()
+ ":" + entity.id());
chunking.add(chunk);
}
}
}
然后,块被用来创建文档的相关部分,并返回OutputDocument:
// needless allocation here and last, but simple
Chunking[] titleChunkings = new Chunking[firstContentSentenceIndex];
for (int i = 0; i < titleChunkings.length; ++i)
titleChunkings[i] = chunkings[i];
Chunking[] bodyChunkings = new Chunking[chunkings.length - firstContentSentenceIndex];
for (int i = 0; i < bodyChunkings.length; ++i)
bodyChunkings[i] = chunkings[firstContentSentenceIndex+i];
String id = document.id();
OutputDocument result = new OutputDocument(id,titleChunkings,bodyChunkings);
return result;
}
这是我们为 XDoc 共指提供的起点。希望我们已经解释了更多晦涩方法背后的意图。祝你好运!
约翰·史密斯问题
不同的人、地点和概念可能有相同的书面表示,但却是不同的。世界上有多个“约翰·史密斯”、“巴黎”和“银行”的实例,适当的跨文档共指系统应该能够处理这些情况。对于“银行”这样的概念(例如:河岸和金融银行),术语是词义消歧。本示例将展示巴尔温(Baldwin)和阿米特·巴加(Amit Bagga)当年为人物消歧开发的一个方法。
准备工作
这个示例的代码紧跟alias-i.com/lingpipe/demos/tutorial/cluster/read-me.html的聚类教程,但进行了修改,以更贴合最初的 Bagga-Baldwin 工作。代码量不小,但没有非常复杂的部分。源代码在src/com/lingpipe/cookbook/chapter7/JohnSmith.java。
该类首先使用了标准的 NLP 工具包,包括分词、句子检测和命名实体检测。如果这个工具堆栈不熟悉,请参阅前面的示例:
public static void main(String[] args)
throws ClassNotFoundException, IOException {
TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
SentenceModel sentenceModel
= new IndoEuropeanSentenceModel();
SENTENCE_CHUNKER
= new SentenceChunker(tokenizerFactory,sentenceModel);
File modelFile
= new File("models/ne-en-news-muc6.AbstractCharLmRescoringChunker");
NAMED_ENTITY_CHUNKER
= (Chunker) AbstractExternalizable.readObject(modelFile);
接下来,我们将重新访问TfIdfDistance。不过,任务要求我们将类封装成处理Documents而非CharSequences,因为我们希望保留文件名,并能够操作用于后续计算的文本:
TfIdfDocumentDistance tfIdfDist = new TfIdfDocumentDistance(tokenizerFactory);
降级到引用的类,我们有以下代码:
public class TfIdfDocumentDistance implements Distance<Document> {
TfIdfDistance mTfIdfDistance;
public TfIdfDocumentDistance (TokenizerFactory tokenizerFactory) {
mTfIdfDistance = new TfIdfDistance(tokenizerFactory);
}
public void train(CharSequence text) {
mTfIdfDistance.handle(text);
}
@Override
public double distance(Document doc1, Document doc2) {
return mTfIdfDistance.distance(doc1.mCoreferentText,
doc2.mCoreferentText);
}
}
train方法与TfIdfDistance.handle()方法接口,并提供了一个distance(Document doc1, Document doc2)方法的实现,驱动下面讨论的聚类代码。train方法的作用仅仅是提取相关文本,并将其交给TfIdfDistance类来计算相关值。
引用类Document是JohnSmith中的一个内部类,非常简单。它获取包含匹配.*John Smith.*模式的实体的句子,并将其放入mCoreferentText变量中:
static class Document {
final File mFile;
final CharSequence mText;
final CharSequence mCoreferentText;
Document(File file) throws IOException {
mFile = file; // includes name
mText = Files.readFromFile(file,Strings.UTF8);
Set<String> coreferentSents
= getCoreferentSents(".*John " + "Smith.*",mText.toString());
StringBuilder sb = new StringBuilder();
for (String sentence : coreferentSents) {
sb.append(sentence);
}
mCoreferentText = sb.toString();
}
public String toString() {
return mFile.getParentFile().getName() + "/"
+ mFile.getName();
}
}
深入到代码中,我们现在将访问getCoreferentSents()方法:
static final Set<String> getCoreferentSents(String targetPhrase, String text) {
Chunking sentenceChunking
= SENTENCE_CHUNKER.chunk(text);
Iterator<Chunk> sentenceIt
= sentenceChunking.chunkSet().iterator();
int targetId = -2;
MentionFactory mentionFactory = new EnglishMentionFactory();
WithinDocCoref coref = new WithinDocCoref(mentionFactory);
Set<String> matchingSentenceAccumulator
= new HashSet<String>();
for (int sentenceNum = 0; sentenceIt.hasNext(); ++sentenceNum) {
Chunk sentenceChunk = sentenceIt.next();
String sentenceText
= text.substring(sentenceChunk.start(),
sentenceChunk.end());
Chunking neChunking
= NAMED_ENTITY_CHUNKER.chunk(sentenceText);
Set<Chunk> chunkSet
= new TreeSet<Chunk>(Chunk.TEXT_ORDER_COMPARATOR);
chunkSet.addAll(neChunking.chunkSet()); Coreference.addRegexMatchingChunks(
Pattern.compile("\\bJohn Smith\\b"),
"PERSON",sentenceText,chunkSet);
Iterator<Chunk> neChunkIt = chunkSet.iterator();
while (neChunkIt.hasNext()) {
Chunk neChunk = neChunkIt.next();
String mentionText
= sentenceText.substring(neChunk.start(),
neChunk.end());
String mentionType = neChunk.type();
Mention mention
= mentionFactory.create(mentionText,mentionType);
int mentionId
= coref.resolveMention(mention,sentenceNum);
if (targetId == -2 && mentionText.matches(targetPhrase)) {
targetId = mentionId;
}
if (mentionId == targetId) { matchingSentenceAccumulator.add(sentenceText);
System.out.println("Adding " + sentenceText);
System.out.println(" mention text=" + mentionText
+ " type=" + mentionType
+ " id=" + mentionId);
}
}
}
if (targetId == -2) {
System.out.println("!!!Missed target doc " + text);
}
return matchingSentenceAccumulator;
}
查看跨文档共指的配方,了解前面方法的大部分运动部分。我们将挑出一些值得注意的部分。某种意义上,我们通过使用正则表达式分块器来找到任何包含John Smith子字符串的字符串,并将其作为PERSON实体添加进来,算是作弊。像大多数类型的作弊一样,如果你的人生目标仅仅是追踪John Smith,这种方法相当有效。实际上,我们做的作弊是使用字典匹配来找到Osama bin Laden等高价值情报目标的所有变种。最终,在 MiTAP 项目中,我们找到了超过 40 个版本的他的名字,遍历公开的新闻来源。
此外,在处理每个句子时,我们会检查所有提及的内容是否匹配John Smith的模式,如果匹配,则收集包含该 ID 的句子。这意味着,任何提到John Smith的句子,包括用代词指代的句子,如果共指工作正常,Mr. Smith的情况也会被包括在内。注意,我们需要看到John Smith的匹配才能开始收集上下文信息,所以我们会错过句子He awoke. John Smith was a giant cockroach的第一个句子。同时,如果第二个John Smith出现并带有不同的 ID,它将被忽略——这种情况是可能发生的。
最后,注意有一些错误检查,如果找不到John Smith,系统会向System.out报告错误。
如果我们在设置好TfIdfDocumentDistance后又回到main()方法中的普通 I/O 处理,我们将会有:
File dir = new File(args[0]);
Set<Set<Document>> referencePartition
= new HashSet<Set<Document>>();
for (File catDir : dir.listFiles()) {
System.out.println("Category from file=" + catDir);
Set<Document> docsForCat = new HashSet<Document>();
referencePartition.add(docsForCat);
for (File file : catDir.listFiles()) {
Document doc = new Document(file);
tfIdfDist.train(doc.mText);
docsForCat.add(doc);
}
}
我们没有讨论这个问题,但关于哪个文档引用了哪个Mr. Smith的真实注解编码在数据的目录结构中。johnSmith顶级目录中的每个子目录都被视为真实聚类。所以,referencePartition包含了真实数据。我们本可以将其包装为一个分类问题,每个子目录对应正确的分类。我们将这个作为练习留给你,要求将其嵌入到交叉验证语料库中,并用逻辑回归解决。
接下来,我们将通过将之前的类别展平为一个Documents的集合来构建测试集。我们本可以在前一步完成这个操作,但混合任务往往会产生错误,而且多出的for循环对执行速度几乎没有影响:
Set<Document> docSet = new HashSet<Document>();
for (Set<Document> cluster : referencePartition) {
docSet.addAll(cluster);
}
接下来,我们将启动聚类算法。我们将执行CompleteLink和SingleLink,由TfIdfDocumentDistance驱动,后者负责整个过程:
HierarchicalClusterer<Document> clClusterer
= new CompleteLinkClusterer<Document>(tfIdfDist);
Dendrogram<Document> completeLinkDendrogram
= clClusterer.hierarchicalCluster(docSet);
HierarchicalClusterer<Document> slClusterer
= new SingleLinkClusterer<Document>(tfIdfDist);
Dendrogram<Document> singleLinkDendrogram
= slClusterer.hierarchicalCluster(docSet);
聚类算法的细节在第五章中进行了介绍,文本中的跨度查找 – 分块。现在,我们将根据聚类数从1到输入数量的变化来报告性能。一个特别的地方是,Cross类别使用SingleLinkClusterer作为参考,而CompleteLinkClusterer作为响应:
System.out.println();
System.out.println(" -------------------------------------------"
+ "-------------");
System.out.println("| K | Complete | Single | "
+ " Cross |");
System.out.println("| | P R F | P R F | P"
+ " R F |");
System.out.println(" -------------------------------------------"
+"-------------");
for (int k = 1; k <= docSet.size(); ++k) {
Set<Set<Document>> clResponsePartition
= completeLinkDendrogram.partitionK(k);
Set<Set<Document>> slResponsePartition
= singleLinkDendrogram.partitionK(k);
ClusterScore<Document> scoreCL
= new ClusterScore<Document>(referencePartition,
clResponsePartition) PrecisionRecallEvaluation clPrEval
= scoreCL.equivalenceEvaluation();
ClusterScore<Document> scoreSL
= new ClusterScore<Document>(referencePartition,
slResponsePartition);
PrecisionRecallEvaluation slPrEval
= scoreSL.equivalenceEvaluation();
ClusterScore<Document> scoreX
= new ClusterScore<Document>(clResponsePartition
slResponsePartition);
PrecisionRecallEvaluation xPrEval
= scoreX.equivalenceEvaluation();
System.out.printf("| %3d | %3.2f %3.2f %3.2f | %3.2f %3.2f %3.2f"
+ " | %3.2f %3.2f %3.2f |\n",
k,
clPrEval.precision(),
clPrEval.recall(),
clPrEval.fMeasure(),
slPrEval.precision(),
slPrEval.recall(),
slPrEval.fMeasure(),
xPrEval.precision(),
xPrEval.recall(),
xPrEval.fMeasure());
}
System.out.println(" --------------------------------------------"
+ "------------");
}
这就是我们为准备这个配方所需做的一切。这是一个罕见的现象要计算,这是一个玩具实现,但关键概念应该是显而易见的。
如何做...
我们只需运行这段代码,稍微调整一下:
-
到终端并输入:
java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.JohnSmith -
结果将是一堆信息,指示正在提取用于聚类的句子——记住真相注释是由文件所在目录确定的。第一个聚类是
0:Category from file=data/johnSmith/0 -
代码报告包含对
John Smith的引用的句子:Adding I thought John Smith marries Pocahontas.'' mention text=John Smith type=PERSON id=5 Adding He's bullets , she's arrows.'' mention text=He type=MALE_PRONOUN id=5 -
对
John Smith的代词引用是包含第二句的基础。 -
系统输出继续进行,最后,我们将获得与真相进行单链接聚类和与真相进行完全链接的结果。
K列指示算法允许多少个聚类,并报告了精确度、召回率和 F-度量。第一行在这种情况下是只允许一个聚类,将允许百分之百的召回率和 23%的精确度,无论是完全链接还是单链接。查看得分,我们可以看到完全链接在0.60时报告了最佳的 F-度量——事实上,有 35 个聚类。单链接方法在0.78时将 F-度量最大化到 68 个聚类,并在不同数量的聚类上显示出更大的鲁棒性。交叉案例显示单链接和完全链接在直接比较中有很大的不同。请注意,为了可读性,一些K值已被消除:-------------------------------------------------------- | K | Complete | Single | | P R F | P R F -------------------------------------------------------- | 1 | 0.23 1.00 0.38 | 0.23 1.00 0.38 | 2 | 0.28 0.64 0.39 | 0.24 1.00 0.38 | 3 | 0.29 0.64 0.40 | 0.24 1.00 0.39 | 4 | 0.30 0.64 0.41 | 0.24 1.00 0.39 | 5 | 0.44 0.63 0.52 | 0.24 0.99 0.39 | 6 | 0.45 0.63 0.52 | 0.25 0.99 0.39 | 7 | 0.45 0.63 0.52 | 0.25 0.99 0.40 | 8 | 0.49 0.62 0.55 | 0.25 0.99 0.40 | 9 | 0.55 0.61 0.58 | 0.25 0.99 0.40 | 10 | 0.55 0.61 0.58 | 0.25 0.99 0.41 | 11 | 0.59 0.61 0.60 | 0.27 0.99 0.42 | 12 | 0.59 0.61 0.60 | 0.27 0.98 0.42 | 13 | 0.56 0.41 0.48 | 0.27 0.98 0.43 | 14 | 0.71 0.41 0.52 | 0.27 0.98 0.43 | 15 | 0.71 0.41 0.52 | 0.28 0.98 0.43 | 16 | 0.68 0.34 0.46 | 0.28 0.98 0.44 | 17 | 0.68 0.34 0.46 | 0.28 0.98 0.44 | 18 | 0.69 0.34 0.46 | 0.29 0.98 0.44 | 19 | 0.67 0.32 0.43 | 0.29 0.98 0.45 | 20 | 0.69 0.29 0.41 | 0.29 0.98 0.45 | 30 | 0.84 0.22 0.35 | 0.33 0.96 0.49 | 40 | 0.88 0.18 0.30 | 0.61 0.88 0.72 | 50 | 0.89 0.16 0.28 | 0.64 0.86 0.73 | 60 | 0.91 0.14 0.24 | 0.66 0.77 0.71 | 61 | 0.91 0.14 0.24 | 0.66 0.75 0.70 | 62 | 0.93 0.14 0.24 | 0.87 0.75 0.81 | 63 | 0.94 0.13 0.23 | 0.87 0.69 0.77 | 64 | 0.94 0.13 0.23 | 0.87 0.69 0.77 | 65 | 0.94 0.13 0.23 | 0.87 0.68 0.77 | 66 | 0.94 0.13 0.23 | 0.87 0.66 0.75 | 67 | 0.95 0.13 0.23 | 0.87 0.66 0.75 | 68 | 0.95 0.13 0.22 | 0.95 0.66 0.78 | 69 | 0.94 0.11 0.20 | 0.95 0.66 0.78 | 70 | 0.94 0.11 0.20 | 0.95 0.65 0.77 | 80 | 0.98 0.11 0.19 | 0.97 0.43 0.59 | 90 | 0.99 0.10 0.17 | 0.97 0.30 0.46 | 100 | 0.99 0.08 0.16 | 0.96 0.20 0.34 | 110 | 0.99 0.07 0.14 | 1.00 0.11 0.19 | 120 | 1.00 0.07 0.12 | 1.00 0.08 0.14 | 130 | 1.00 0.06 0.11 | 1.00 0.06 0.12 | 140 | 1.00 0.05 0.09 | 1.00 0.05 0.10 | 150 | 1.00 0.04 0.08 | 1.00 0.04 0.08 | 160 | 1.00 0.04 0.07 | 1.00 0.04 0.07 | 170 | 1.00 0.03 0.07 | 1.00 0.03 0.07 | 180 | 1.00 0.03 0.06 | 1.00 0.03 0.06 | 190 | 1.00 0.02 0.05 | 1.00 0.02 0.05 | 197 | 1.00 0.02 0.04 | 1.00 0.02 0.04 -------------------------------------------------------- -
下面的输出限制了聚类的方式不是通过聚类大小,而是通过最大距离阈值。输出是对单链接聚类的,增加了
.05距离,并且评估是 B-cubed 度量。输出是距离、精确度、召回率以及生成聚类的大小。在.80和.9的表现非常好,但要小心在事后设置生产阈值。在生产环境中,我们将希望在设置阈值之前看到更多数据:B-cubed eval Dist: 0.00 P: 1.00 R: 0.77 size:189 Dist: 0.05 P: 1.00 R: 0.80 size:171 Dist: 0.10 P: 1.00 R: 0.80 size:164 Dist: 0.15 P: 1.00 R: 0.81 size:157 Dist: 0.20 P: 1.00 R: 0.81 size:153 Dist: 0.25 P: 1.00 R: 0.82 size:148 Dist: 0.30 P: 1.00 R: 0.82 size:144 Dist: 0.35 P: 1.00 R: 0.83 size:142 Dist: 0.40 P: 1.00 R: 0.83 size:141 Dist: 0.45 P: 1.00 R: 0.83 size:141 Dist: 0.50 P: 1.00 R: 0.83 size:138 Dist: 0.55 P: 1.00 R: 0.83 size:136 Dist: 0.60 P: 1.00 R: 0.84 size:128 Dist: 0.65 P: 1.00 R: 0.84 size:119 Dist: 0.70 P: 1.00 R: 0.86 size:108 Dist: 0.75 P: 0.99 R: 0.88 size: 90 Dist: 0.80 P: 0.99 R: 0.94 size: 60 Dist: 0.85 P: 0.95 R: 0.97 size: 26 Dist: 0.90 P: 0.91 R: 0.99 size: 8 Dist: 0.95 P: 0.23 R: 1.00 size: 1 Dist: 1.00 P: 0.23 R: 1.00 size: 1 -
B-cubed(Bagga、Bierman 和 Baldwin)评估被设计为严重惩罚将大量文档关联在一起的情况。它假设将关于乔治·W·布什和乔治·H·W·布什这样的大型聚类合并在一起是更大的问题,而不是误将提到数据集中的一次性提到的机械师乔治·布什的情况。其他评分指标将同样认为这两种错误同样糟糕。这是文献中用于此现象的标准评分指标。
另请参阅
在研究文献中,关于这个具体问题有相当多的工作。我们并不是第一个考虑这个问题的人,但我们提出了主流的评估指标,并发布了一个语料库供其他团队与我们以及彼此进行比较。我们的贡献是基于实体的跨文档共指消解,使用向量空间模型,由 Bagga 和 Baldwin 提出,收录于ACL '98 第 36 届计算语言学会年会和第 17 届国际计算语言学会议论文集。自那时以来已经取得了许多进展——Google Scholar 上已有超过 400 次引用,如果这个问题对你来说很重要,它们值得一看。