Java-和-Lingpipe-自然语言处理秘籍-全-

Java 和 Lingpipe 自然语言处理秘籍(全)

原文:zh.annas-archive.org/md5/c0680a0c1f30c92db32811ad302566b4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到这本书,当你跨过新咨询工作的门槛或承担新的自然语言处理(NLP)问题时,你会在身边想要拥有的书。这本书最初是Baldwin在面临重复但棘手的系统构建NLP问题时不断参考的LingPipe食谱的私人仓库。我们是一家开源公司,但代码从未值得分享。现在它们被分享了。

实话实说,LingPipe API是一个令人畏惧且晦涩难懂的建筑,就像任何丰富而复杂的Java API一样。再加上让NLP系统运行所需的“黑魔法”品质,我们就有了满足对理论最小化、实际操作最大化、并在20年的业务实践中穿插最佳实践的需求的完美条件。

这本书是关于完成任务;去他的理论!拿起这本书,构建下一代自然语言处理(NLP)系统,并告诉我们你做了什么。

LingPipe是构建NLP系统的最佳工具;这本书是使用它的方法。

本书涵盖内容

第1章简单分类器,解释了大量的NLP问题实际上是分类问题。这一章涵盖了基于字符序列的非常简单但强大的分类器,然后引入了交叉验证等评估技术,以及精确度、召回率和总是抵抗BS的混淆矩阵等指标。你可以自己训练,并从Twitter下载数据。章节以一个简单的情感分析示例结束。

第2章寻找和操作单词,正如其名一样无聊,但也有一些亮点。最后一道食谱将向你展示如何对没有空格的中文/日语/越南语进行分词,以帮助定义单词。我们将向你展示如何包装Lucene分词器,这些分词器涵盖了所有种类的有趣语言,如阿拉伯语。本书后面的几乎所有内容都依赖于分词。

第3章高级分类器,介绍了现代NLP系统的明星——逻辑回归分类器。20年的辛勤经验隐藏在这一章中。我们将讨论构建分类器的生命周期以及如何创建训练数据,如何通过主动学习在创建训练数据时作弊,以及如何调整和使分类器运行更快。

第4章, 标记单词和标记,解释说语言是关于单词的。这一章重点介绍将类别应用于标记的方法,这反过来又推动了LingPipe的许多高端用途,例如实体检测(文本中的人/地点/组织)、词性标注以及更多。它从标签云开始,标签云被描述为“互联网的莫霍克”,并以条件随机场(CRF)的基础配方结束,这可以为实体检测任务提供最先进的性能。在中间,我们将讨论置信度标记的单词,这可能是更复杂系统的一个重要维度。

第5章, 文本中寻找跨度 – 分块,表明文本不仅仅是单词。它是单词的集合,通常在跨度中。这一章将从单词标注发展到跨度标注,这引入了诸如寻找句子、命名实体和基本NP和VP等能力。通过讨论特征提取和调整,我们解决了CRF的全部功能。讨论了词典方法,因为它们是组合分块的方式。

第6章, 字符串比较和聚类,专注于比较文本,而不依赖于训练好的分类器。技术范围从极其实用的拼写检查到充满希望但往往令人沮丧的潜在狄利克雷分配(LDA)聚类方法。不那么武断的技术,如单链接和完全链接聚类,为我们带来了重大的商业成功。不要忽视这一章。

第7章, 概念/人物指代消解,奠定了基础,但遗憾的是,你不会得到最终的秘方,只有我们迄今为止的最佳努力。这是工业和学术自然语言处理(NLP)努力中的一个前沿领域,具有巨大的潜力。正是潜力,我们包括我们的努力,以帮助铺平道路,以便看到这项技术的应用。

你需要这本书的什么

你需要一些NLP问题和扎实的Java基础,一台计算机,以及一个开发者敏锐的方法。

这本书是为谁而写的

如果你遇到NLP问题,或者你想在评论NLP问题上进行自我教育,这本书适合你。通过一些创意,你可以训练自己成为一名坚实的NLP开发者,这种生物非常罕见,它们出现的频率几乎和独角兽一样,结果是在硅谷或纽约市等热门技术领域拥有更多有趣的职业前景。

惯例

在这本书中,你会发现许多不同风格的文章,以区分不同类型的信息。以下是一些这些风格及其含义的示例。

Java是一种相当糟糕的语言,不适合放入行宽限制为66个字符的食谱书中。主要的惯例是代码很丑陋,我们为此道歉。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter昵称如下所示:"一旦字符串从控制台读取,就会调用classifier.classify(input),它返回Classification。"

代码块设置如下:

public static List<String[]> filterJaccard(List<String[]> texts, TokenizerFactory tokFactory, double cutoff) {
  JaccardDistance jaccardD = new JaccardDistance(tokFactory);

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

public static void consoleInputBestCategory(
BaseClassifier<CharSequence> classifier) throws IOException {
  BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
  while (true) {
    System.out.println("\nType a string to be classified. " + " Empty string to quit.");
    String data = reader.readLine();
    if (data.equals("")) {
      return;
    }
    Classification classification = classifier.classify(data);
 System.out.println("Best Category: " + classification.bestCategory());
  }
}

任何命令行输入或输出都如下所示:

tar –xvzf lingpipeCookbook.tgz

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"点击创建一个新应用程序。"

注意

警告或重要说明如下所示。

提示

技巧和窍门如下所示。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。

要向我们发送一般反馈,请简单地将电子邮件发送到<[feedback@packtpub.com](mailto:feedback@packtpub.com)>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

将仇恨/喜爱/中立的电子邮件发送到<[cookbook@lingpipe.com](mailto:cookbook@lingpipe.com)>。我们确实在乎,我们不会免费帮你做作业或为你的初创公司原型化,但我们会和你交谈。

客户支持

现在您已经是Packt书籍的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

我们确实提供咨询服务,甚至还有一个免费(pro-bono)项目和初创支持项目。自然语言处理(NLP)很难,这本书包含了我们的大部分知识,但我们可能还能提供更多帮助。

下载示例代码

您可以从您在http://www.packtpub.com的账户中下载您购买的所有Packt书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

书籍的所有源代码均可在http://alias-i.com/book.html找到。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问http://www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过从http://www.packtpub.com/support选择您的标题来查看任何现有的勘误。

海盗行为

在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <[copyright@packtpub.com](mailto:copyright@packtpub.com)> 与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <[questions@packtpub.com](mailto:questions@packtpub.com)> 联系我们,我们将尽力解决。

访问 http://lingpipe.com 并前往我们的论坛,这是获取问题解答和查看您是否已经有了解决方案的最佳地方。

第一章:第1章. 简单分类器

在本章中,我们将涵盖以下食谱:

  • 反序列化和运行分类器

  • 从分类器中获取置信度估计

  • 从Twitter API获取数据

  • 将分类器应用于.csv文件

  • 分类器的评估——混淆矩阵

  • 训练你自己的语言模型分类器

  • 如何使用交叉验证进行训练和评估

  • 查看错误类别——误报

  • 理解精确度和召回率

  • 如何序列化LingPipe对象——分类器示例

  • 使用Jaccard距离消除近似重复项

  • 如何进行情感分类——简单版本

简介

本章在LingPipe工具包的竞赛背景下介绍它,然后直接深入到文本分类器。文本分类器将类别分配给文本,例如,它们将语言分配给一个句子或告诉我们一条推文是正面、负面还是中性。本章涵盖了如何使用、评估和创建基于语言模型的文本分类器。这些是LingPipe API中最简单的基于机器学习的分类器。它们之所以简单,是因为它们仅操作于字符——稍后,分类器将具有单词/标记的概念,甚至更多。然而,不要被误导,字符语言模型非常适合语言识别,它们也是世界上一些最早的商业情感系统的基石。

本章还涵盖了关键的评价基础设施——结果证明,我们做的几乎所有事情在某种解释层面上都可以被视为分类器。因此,不要忽视交叉验证的力量、精确度/召回率的定义以及F度量。

最好的部分是,你将学习如何程序化地访问Twitter数据来训练和评估你自己的分类器。有关从/向磁盘读取和写入LingPipe对象的机制有一些枯燥的内容,但除此之外,这是一个有趣的章节。本章的目标是让你快速上手,掌握自然语言处理(NLP)领域中机器学习技术的基本维护和培养。

LingPipe是一个面向NLP应用的Java工具包。本书将向您展示如何以问题/解决方案的格式使用LingPipe解决常见的NLP问题,这允许开发者快速部署常见任务的解决方案。

LingPipe及其安装

LingPipe 1.0于2003年作为双授权的开源NLP Java库发布。在撰写本书时,我们在Google Scholar上的引用接近2000次,并且有数千个商业安装,从大学到政府机构到财富500强公司。

当前许可协议是AGPL (http://www.gnu.org/licenses/agpl-3.0.html) 或我们提供的商业许可,它提供更多传统功能,如赔偿、代码不共享以及支持。

与LingPipe类似的项目

几乎所有自然语言处理(NLP)项目都有糟糕的缩写,所以我们将公开我们的缩写。LingPipelinguistic pipeline的简称,这个名字是Bob Carpenter存放初始代码的cvs目录的名称。

LingPipe在NLP领域有很多竞争对手。以下是一些更受欢迎的、专注于Java的:

  • NLTK:这是NLP处理领域占主导地位的Python库。

  • OpenNLP:这是一个由一群聪明人构建的Apache项目。

  • JavaNLP:这是对斯坦福NLP工具的重新品牌,同样是由一群聪明人构建的。

  • ClearTK:这是一个博尔德大学的工具包,封装了许多流行的机器学习框架。

  • DkPro:德国达姆施塔特技术大学生产的这个基于UIMA的项目,以一种有用的方式封装了许多常见组件。UIMA是NLP的一个通用框架。

  • GATE:GATE实际上更像是一个框架而不是竞争对手。事实上,LingPipe组件是它们标准分布的一部分。它有一个很好的图形化“连接组件”功能。

  • 基于学习的JavaLBJ):LBJ是一种基于Java的特殊用途编程语言,它面向机器学习和NLP。它是在伊利诺伊大学厄巴纳-香槟分校的认知计算组开发的。

  • Mallet:这个名字是MAchine Learning for LanguagE Toolkit的简称。显然,合理的缩写生成现在是稀缺的。这也是由聪明人构建的。

这里有一些纯机器学习框架,它们具有更广泛的吸引力,但并不一定针对NLP任务:

  • Vowpal Wabbit:它非常专注于逻辑回归、潜在狄利克雷分配等可扩展性。由聪明人推动。

  • Factorie:它来自美国马萨诸塞大学阿默斯特分校,是Mallet的替代品。最初它主要关注图形模型,但现在也支持NLP任务。

  • 支持向量机SVM):SVM light和libsvm是非常流行的SVM实现。LingPipe中没有SVM实现,因为逻辑回归也能做到这一点。

那么,为什么使用LingPipe?

提到前面提到的如此出色的免费竞争,合理地询问为什么选择LingPipe是有道理的。有几个原因:

  • 文档:LingPipe中的类级别文档非常详尽。如果工作基于学术研究,那么这项工作会被引用。算法被列出,底层数学被解释,解释精确。文档缺乏的是“如何完成任务”的视角;然而,这一点在本书中有所涉及。

  • 企业/服务器优化:LingPipe从一开始就是为了服务器应用程序而设计的,而不是为了命令行使用(尽管我们将在整本书中广泛使用命令行)。

  • 用Java方言编写: LingPipe是一个遵循标准Java类设计原则(Addison-Wesley出版的Joshua Bloch的《Effective Java》)的本地Java API,例如在构造时的一致性检查、不可变性、类型安全、向后兼容的序列化以及线程安全。

  • 错误处理: 在长时间运行的过程中,通过异常和可配置的消息流对错误处理给予了相当的关注。

  • 支持: LingPipe有付费员工,他们的工作是回答您的问题并确保LingPipe正在完成其工作。通常,罕见的错误会在24小时内得到修复。他们非常快速地回答问题,并且非常愿意帮助人们。

  • 咨询: 您可以雇佣LingPipe的专家为您构建系统。通常,他们会作为副产品教开发者如何构建NLP系统。

  • 一致性: LingPipe API是由Bob Carpenter一个人设计的,他对一致性有着近乎狂热的追求。虽然它并不完美,但您会发现其中存在一种规律性和设计上的关注,这在学术努力中可能会缺失。研究生来来去去,因此对大学工具包的贡献可能会有很大的差异。

  • 开源: 尽管有许多商业供应商,但他们的软件是一个黑盒。LingPipe的开源特性提供了透明度和信心,确保代码正在执行我们所要求的功能。当文档出现问题时,能够访问代码以更好地理解它是一种巨大的安慰。

下载书籍代码和数据

您需要从http://alias-i.com/book.html下载此食谱的源代码,以及支持模型和数据。使用以下命令解包和解压缩:

tar –xvzf lingpipeCookbook.tgz

小贴士

下载示例代码

您可以从http://www.packtpub.com上您购买的所有Packt书籍的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

或者,您的操作系统可能提供其他提取存档的方法。所有食谱都假设您在生成的食谱目录中运行命令。

下载LingPipe

下载LingPipe不是严格必要的,但您可能希望能够查看源代码并拥有Javadoc的本地副本。

LingPipe的下载和安装说明可以在http://alias-i.com/lingpipe/web/install.html找到。

本章的示例使用命令行调用,但假设读者具备足够的开发技能,可以将示例映射到他们首选的IDE/ant或其他环境。

反序列化和运行分类器

本食谱做了两件事:介绍一个非常简单且有效的语言ID分类器,并演示如何反序列化LingPipe类。如果你是从后面的章节来到这里,试图理解反序列化,我鼓励你无论如何运行示例程序。这需要5分钟,你可能会学到一些有用的东西。

我们的语言ID分类器基于字符语言模型。每个语言模型都会给出文本在该语言下生成的概率。与文本最熟悉的模型是最佳匹配。这个模型已经构建好了,但稍后在本章中,你将学习如何创建自己的模型。

如何操作...

执行以下步骤以反序列化和运行分类器:

  1. 前往书的cookbook目录并运行OSX、Unix和Linux的命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.RunClassifierFromDisk
    
    

    对于Windows调用(引用类路径并使用;代替:):

    java -cp "lingpipe-cookbook.1.0.jar;lib\lingpipe-4.1.0.jar" com.lingpipe.cookbook.chapter1.RunClassifierFromDisk
    
    

    我们将在本书中使用Unix风格的命令行。

  2. 程序报告正在加载模型和默认设置,并提示输入一个句子进行分类:

    Loading: models/3LangId.LMClassifier
    Type a string to be classified. Empty string to quit.
    The rain in Spain falls mainly on the plain.
    english
    Type a string to be classified. Empty string to quit.
    la lluvia en España cae principalmente en el llano.
    spanish
    Type a string to be classified. Empty string to quit.
    スペインの雨は主に平野に落ちる。
    japanese
    
    
  3. 该分类器是在英语、西班牙语和日语上训练的。我们已输入了每种语言的示例——要获取一些日语,请访问http://ja.wikipedia.org/wiki/。这些是它所知道的唯一语言,但它会对任何文本进行猜测。所以,让我们尝试一些阿拉伯语:

    Type a string to be classified. Empty string to quit.
    المطر في اسبانيا يقع أساسا على سهل.
    japanese
    
    
  4. 它认为它是日语,因为这种语言的字符比英语或西班牙语多。这反过来又导致该模型期望更多的未知字符。所有的阿拉伯文字符都是未知的。

  5. 如果你在一个Windows终端中工作,可能会遇到输入UTF-8字符的困难。

它是如何工作的...

jar文件中的代码位于cookbook/src/com/lingpipe/cookbook/chapter1/RunClassifierFromDisk.java。正在发生的事情是,一个用于语言识别的预构建模型被反序列化并可供使用。它已经在英语、日语和西班牙语上进行了训练。训练数据来自每种语言的维基百科页面。你可以在data/3LangId.csv中看到数据。本食谱的重点是向你展示如何反序列化分类器并运行它——训练在本章的“训练你自己的语言模型分类器”食谱中处理。RunClassifierFromDisk.java类的整个代码从包开始;然后它导入RunClassifierFromDisk类的开始和main()的开始:

package com.lingpipe.cookbook.chapter1;
import java.io.File;
import java.io.IOException;

import com.aliasi.classify.BaseClassifier;
import com.aliasi.util.AbstractExternalizable;
import com.lingpipe.cookbook.Util;
public class RunClassifierFromDisk {
  public static void main(String[] args) throws
  IOException, ClassNotFoundException {

上述代码是一个非常标准的Java代码,我们在此不进行解释。接下来是大多数食谱中的一个特性,它为命令行中不包含的文件提供一个默认值。这允许你使用自己的数据(如果你有),否则它将从分发中的文件运行。在这种情况下,如果没有命令行参数,将提供一个默认分类器:

String classifierPath = args.length > 0 ? args[0] :  "models/3LangId.LMClassifier";
System.out.println("Loading: " + classifierPath);

接下来,我们将看到如何从磁盘反序列化一个分类器或另一个LingPipe对象:

File serializedClassifier = new File(classifierPath);
@SuppressWarnings("unchecked")
BaseClassifier<String> classifier
  = (BaseClassifier<String>)
  AbstractExternalizable.readObject(serializedClassifier);
AbstractExternalizable.readObject method.

这个类在 LingPipe 中被用于执行类的编译,原因有两个。首先,它允许编译后的对象设置最终变量,这支持 LingPipe 对不可变性的广泛使用。其次,它避免了暴露外部化和反序列化所需的 I/O 方法所带来的混乱,特别是无参数构造函数。这个类被用作一个私有内部类的超类,该内部类执行实际的编译。这个私有内部类实现了所需的 no-arg 构造函数,并存储了 readResolve() 所需的对象。

注意

我们使用 Externalizable 而不是 Serializable 的原因是避免在更改任何方法签名或成员变量时破坏向后兼容性。Externalizable 扩展了 Serializable 并允许控制对象是如何被读取或写入的。有关更多信息,请参阅 Josh Bloch 的书 Effective Java, 2nd Edition 中的优秀章节。

BaseClassifier<E> 是 LingPipe 的基础分类器接口,其中 E 是在 LingPipe 中被分类的对象的类型。查看 Javadoc 以了解实现该接口的分类器的范围——共有 10 个。将序列化到 BaseClassifier<E> 中隐藏了很多复杂性,我们将在本章的 如何序列化 LingPipe 对象 – 分类器示例 菜谱中稍后探讨。

最后一行调用了一个实用方法,我们将在本书中经常使用:

Util.consoleInputBestCategory(classifier);

此方法处理与命令行的交互。代码位于 src/com/lingpipe/cookbook/Util.java

public static void consoleInputBestCategory(
BaseClassifier<CharSequence> classifier) throws IOException {
  BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
  while (true) {
    System.out.println("\nType a string to be classified. " + " Empty string to quit.");
    String data = reader.readLine();
    if (data.equals("")) {
      return;
    }
    Classification classification = classifier.classify(data);
    System.out.println("Best Category: " + classification.bestCategory());
  }
}

一旦从控制台读取字符串,就会调用 classifier.classify(input),它返回 Classification。然后,这会提供一个 String 标签并打印出来。就这样!你已经运行了一个分类器。

从分类器获取置信度估计

如果分类器提供了更多关于它们对分类的置信度的信息,那么它们通常更有用——这通常是一个分数或概率。我们经常设置分类器的阈值,以帮助满足安装的性能要求。例如,如果分类器永远不犯错误至关重要,那么我们可以在做出决定之前要求分类非常自信。

LingPipe 分类器基于它们提供的估计类型存在一个层次结构。其核心是一系列接口——不要惊慌;实际上它相当简单。你现在不需要理解它,但我们确实需要将其记录下来以供将来参考:

  • BaseClassifier<E>:这只是你的基本分类器,用于类型为 E 的对象。它有一个 classify() 方法,该方法返回一个分类,该分类又有一个 bestCategory() 方法和 toString() 方法,这些方法具有一定的信息用途。

  • RankedClassifier<E> extends BaseClassifier<E>classify()方法返回RankedClassification,它扩展了Classification并添加了category(int rank)方法,该方法说明了第1到n个分类是什么。还有一个size()方法,表示有多少个分类。

  • ScoredClassifier<E> extends RankedClassifier<E>:返回的ScoredClassification添加了一个score(int rank)方法。

  • ConditionalClassifier<E> extends RankedClassifier<E>:由这个产生的ConditionalClassification具有这样一个属性,即所有类别的分数之和必须通过conditionalProbability(int rank)方法和conditionalProbability(String category)方法访问,总和为1。还有更多;你可以阅读这个的Javadoc。当事情变得复杂时,这种分类将成为本书的工作马,我们想知道推文是英语、日语还是西班牙语的置信度。这些估计之和必须为1。

  • JointClassifier<E> extends ConditionalClassifier<E>:这提供了输入和类别在所有可能输入空间中的JointClassification,并且所有这样的估计之和为1。这是一个稀疏空间,因此值是基于对数来避免下溢错误的。我们在生产中很少直接使用这个估计。

很明显,对所提出的分类栈进行了大量的思考。这是因为大量的工业级自然语言处理问题最终都是由分类系统处理的。

结果表明,我们最简单的分类器——在某种任意意义上的简单——产生了最丰富的估计,即联合分类。让我们深入探讨。

准备工作

在之前的配方中,我们轻率地反序列化为BaseClassifier<String>,这隐藏了所有正在发生的事情的细节。实际上,情况比模糊的抽象类所暗示的要复杂得多。请注意,加载到磁盘上的文件被命名为3LangId.LMClassifier。按照惯例,我们用将要反序列化的对象类型来命名序列化模型,在这种情况下,是LMClassifier,它扩展了BaseClassifier。对于分类器的最具体类型是:

LMClassifier<CompiledNGramBoundaryLM, MultivariateDistribution> classifier = (LMClassifier <CompiledNGramBoundaryLM, MultivariateDistribution>) AbstractExternalizable.readObject(new File(args[0]));

LMClassifier<CompiledNGramBoundaryLM, MultivariateDistribution>转换为类型指定了分布类型为MultivariateDistributioncom.aliasi.stats.MultivariateDistribution的Javadoc非常明确且有助于描述这是什么。

注意

MultivariateDistribution实现了一个离散分布,该分布覆盖了有限个结果,这些结果从零开始连续编号。

Javadoc对MultivariateDistribution进行了大量的详细说明,但基本上意味着我们可以有一个n路概率分配,这些概率之和为1。

接下来的类是为 CompiledNGramBoundaryLM 定制的,它是 LMClassifier 的“记忆”。实际上,每种语言都有自己的。这意味着英语将有一个与西班牙语等不同的语言模型。在这个分类器的这部分可能使用了八种不同的语言模型——请参阅 LanguageModel 接口的 Javadoc。每个 语言模型LM)具有以下属性:

  • LM 将提供生成提供的文本的概率。它对之前未见过的数据具有鲁棒性,即它不会崩溃或给出零概率。在我们的示例中,阿拉伯语只是未知字符的序列。

  • 对于边界语言模型(LM),任何长度的可能字符序列概率之和为 1。处理 LM 将概率加到相同长度的所有序列上。查看 Javadoc 了解这部分数学是如何实现的。

  • 每个语言模型对其类别之外的数据一无所知。

  • 分类器跟踪类别的边缘概率,并将此因素纳入类别的结果中。边缘概率是指我们倾向于在迪士尼推文中看到三分之二的英语,六分之一的西班牙语,六分之一的日语。这些信息与 LM 估计相结合。

  • LM 是 LanguageModel.Dynamic 的编译版本,我们将在稍后的关于训练的食谱中介绍。

LMClassifier 所构建的将这些组件包装成一个分类器。

幸运的是,接口通过更美观的反序列化来拯救了这一天:

JointClassifier<String> classifier = (JointClassifier<String>) AbstractExternalizable.readObject(new File(classifierPath));

界面很好地隐藏了实现的细节,这正是我们在示例程序中要采用的。

如何做到这一点...

这个食谱是我们第一次开始剥去分类器能做什么,但首先,让我们先玩玩它:

  1. 让你的魔法外壳精灵召唤一个带有 Java 解释器的命令提示符并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter1.RunClassifierJoint 
    
    
  2. 我们将输入与之前相同的数据:

    Type a string to be classified. Empty string to quit.
    The rain in Spain falls mainly on the plain.
    Rank Categ Score   P(Category|Input) log2 P(Category,Input)
    0=english -3.60092 0.9999999999         -165.64233893156052
    1=spanish -4.50479 3.04549412621E-13    -207.2207276413206
    2=japanese -14.369 7.6855682344E-150    -660.989401136873
    
    

如描述所述,JointClassification 在以 Classification 为根的层次结构中传递所有分类度量。以下所示分类的每一级都添加到其前面的分类器中:

  • Classification 提供了最佳类别作为排名 0 的类别。

  • RankedClassification 为所有可能的类别添加一个排序,较低排名对应于该类别的更大可能性。rank 列反映了这种排序。

  • ScoredClassification 为排序输出添加一个数值分数。请注意,分数可能或可能无法很好地与其他正在分类的字符串进行比较,具体取决于分类器的类型。这是标记为 Score 的列。要了解分数的基础,请参阅相关的 Javadoc。

  • ConditionalClassification通过将其作为输入条件下的类别概率来进一步细化分数。所有类别的概率之和为1。这是标记为P(Category|Input)的列,这是传统上写给定输入的类别概率的方式。

  • JointClassification添加了输入和类别的log2(以2为底的对数)概率——这是联合概率。所有类别和输入的概率之和为1,这实际上是一个非常大的空间,任何一对类别和字符串的概率都分配得非常低。这就是为什么使用log2值来防止数值下溢。这是标记为log 2 P(Category, Input)的列,它被翻译为类别和输入的对数**2概率*。

查看关于实现这些度量标准和分类器的com.aliasi.classify包的Javadoc以获取更多信息。

它是如何工作的…

代码位于src/com/lingpipe/cookbook/chapter1/RunClassifierJoint.java中,并反序列化为JointClassifier<CharSequence>

public static void main(String[] args) throws IOException, ClassNotFoundException {
  String classifierPath  = args.length > 0 ? args[0] : "models/3LangId.LMClassifier";
  @SuppressWarnings("unchecked")
    JointClassifier<CharSequence> classifier = (JointClassifier<CharSequence>) AbstractExternalizable.readObject(new File(classifierPath));
  Util.consoleInputPrintClassification(classifier);
}

它调用Util.consoleInputPrintClassification(classifier),这与Util.consoleInputBestCategory(classifier)最小不同之处在于它使用分类的toString()方法来打印。代码如下:

public static void consoleInputPrintClassification(BaseClassifier<CharSequence> classifier) throws IOException {
  BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
  while (true) {
    System.out.println("\nType a string to be classified." + Empty string to quit.");
    String data = reader.readLine();
    if (data.equals("")) {
      return;
    }
    Classification classification = classifier.classify(data);
    System.out.println(classification);
  }
}

我们得到了比预期的更丰富的输出,因为类型是Classification,但toString()方法将被应用于运行时类型JointClassification

相关内容

从Twitter API获取数据

我们使用流行的twitter4j包来调用Twitter搜索API,并搜索推文并将它们保存到磁盘。自版本1.1起,Twitter API需要身份验证,在我们开始之前,我们需要获取身份验证令牌并将它们保存到twitter4j.properties文件中。

准备中

如果你没有Twitter账户,请访问twitter.com/signup并创建一个账户。你还需要访问dev.twitter.com并登录以启用开发者账户。一旦你有了Twitter登录,我们就可以开始创建Twitter OAuth凭据了。请准备好这个过程可能与我们所展示的不同。无论如何,我们将在data目录中提供示例结果。现在让我们创建Twitter OAuth凭据:

  1. 登录到dev.twitter.com

  2. 在顶部栏图标旁边找到那个小下拉菜单。

  3. 选择我的应用

  4. 点击创建一个新应用

  5. 填写表格并点击创建Twitter应用

  6. 下一页包含OAuth设置。

  7. 点击创建我的访问令牌链接。

  8. 您需要复制消费者密钥消费者密钥密钥

  9. 您还需要复制访问令牌访问令牌密钥

  10. 这些值应放入twitter4j.properties文件中的适当位置。属性如下:

    debug=false
    oauth.consumerKey=ehUOExampleEwQLQpPQ
    oauth.consumerSecret=aTHUGTBgExampleaW3yLvwdJYlhWY74
    oauth.accessToken=1934528880-fiMQBJCBExamplegK6otBG3XXazLv
    oauth.accessTokenSecret=y0XExampleGEHdhCQGcn46F8Vx2E
    

如何操作...

现在,我们准备好使用以下步骤访问Twitter并获取一些搜索数据:

  1. 进入本章目录并运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/twitter4j-core-4.0.1.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.TwitterSearch
    
    
  2. 代码显示输出文件(在这种情况下,默认值)。提供路径作为参数将写入此文件。然后,在提示符中输入您的查询:

    Writing output to data/twitterSearch.csv
    Enter Twitter Query:disney
    
    
  3. 代码随后查询Twitter,并报告每找到100条推文(输出被截断):

    Tweets Accumulated: 100
    Tweets Accumulated: 200
    …
    Tweets Accumulated: 1500
    writing to disk 1500 tweets at data/twitterSearch.csv 
    
    

该程序使用搜索查询,搜索Twitter中的术语,并将输出(限制为1500条推文)写入您在命令行中指定的.csv文件名或使用默认值。

它是如何工作的...

代码使用twitter4j库实例化TwitterFactory,并使用用户输入的查询搜索Twitter。src/com/lingpipe/cookbook/chapter1/TwitterSearch.javamain()的开始部分如下:

String outFilePath = args.length > 0 ? args[0] : "data/twitterSearch.csv";
File outFile = new File(outFilePath);
System.out.println("Writing output to " + outFile);
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
System.out.print("Enter Twitter Query:");
String queryString = reader.readLine();

上述代码获取输出文件,如果没有提供则使用默认值,并从命令行获取查询。

以下代码根据twitter4j开发者的愿景设置查询。有关此过程的更多信息,请阅读他们的Javadoc。然而,这应该是相当直接的。为了使我们的结果集更加独特,您会注意到,当我们创建查询字符串时,我们将使用-filter:retweets选项过滤掉重复推文。这仅是部分有效;请参阅本章后面的使用Jaccard距离消除近似重复配方以获得更完整的解决方案:

Twitter twitter = new TwitterFactory().getInstance();
Query query = new Query(queryString + " -filter:retweets"); query.setLang("en");//English
query.setCount(TWEETS_PER_PAGE);
query.setResultType(Query.RECENT);

我们将得到以下结果:

List<String[]> csvRows = new ArrayList<String[]>();
while(csvRows.size() < MAX_TWEETS) {
  QueryResult result = twitter.search(query);
  List<Status> resultTweets = result.getTweets();
  for (Status tweetStatus : resultTweets) {
    String row[] = new String[Util.ROW_LENGTH];
    row[Util.TEXT_OFFSET] = tweetStatus.getText();
    csvRows.add(row);
  }
  System.out.println("Tweets Accumulated: " + csvRows.size());
  if ((query = result.nextQuery()) == null) {
    break;
  }
}
query to handle paging through the search results—it returns null when no more pages are available. The current Twitter API allows a maximum of 100 results per page, so in order to get 1500 results, we need to rerun the search until there are no more results, or until we get 1500 tweets. The next step involves a bit of reporting and writing:
System.out.println("writing to disk " + csvRows.size() + " tweets at " + outFilePath);
Util.writeCsvAddHeader(csvRows, outFile);

然后使用Util.writeCsvAddHeader方法将推文列表写入.csv文件:

public static void writeCsvAddHeader(List<String[]> data, File file) throws IOException {
  CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(new FileOutputStream(file),Strings.UTF8));
  csvWriter.writeNext(ANNOTATION_HEADER_ROW);
  csvWriter.writeAll(data);
  csvWriter.close();
}

我们将使用这个.csv文件在下一节中运行语言ID测试。

参见

关于使用Twitter API和twitter4j的更多详细信息,请访问他们的文档页面:

将分类器应用到.csv文件

现在,我们可以测试我们从Twitter下载的数据上的语言ID分类器。这个配方将向您展示如何在.csv文件上运行分类器,并为下一配方中的评估步骤做好准备。

如何操作...

将分类器应用到.csv文件上非常简单!只需执行以下步骤:

  1. 获取命令提示符并运行:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/twitter4j-core-4.0.1.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter1.ReadClassifierRunOnCsv
    
    
  2. 这将使用data/disney.csv分布的默认CSV文件,遍历CSV文件的每一行,并对其应用来自models/ 3LangId.LMClassifier的语言ID分类器:

    InputText: When all else fails #Disney
    Best Classified Language: english
    InputText: ES INSUPERABLE DISNEY !! QUIERO VOLVER:(
    Best Classified Language: Spanish
    
    
  3. 您也可以指定输入作为第一个参数,分类器作为第二个参数。

它是如何工作的…

我们将反序列化一个来自之前配方中描述的外部化模型的分类器。然后,我们将遍历.csv文件的每一行,并调用分类器的classify方法。main()中的代码如下:

String inputPath = args.length > 0 ? args[0] : "data/disney.csv";
String classifierPath = args.length > 1 ? args[1] : "models/3LangId.LMClassifier";
@SuppressWarnings("unchecked") BaseClassifier<CharSequence> classifier = (BaseClassifier<CharSequence>) AbstractExternalizable.readObject(new File(classifierPath));
List<String[]> lines = Util.readCsvRemoveHeader(new File(inputPath));
for(String [] line: lines) {
  String text = line[Util.TEXT_OFFSET];
  Classification classified = classifier.classify(text);
  System.out.println("InputText: " + text);
  System.out.println("Best Classified Language: " + classified.bestCategory());
}

之前的代码基于之前的配方,没有特别新的内容。以下所示的Util.readCsvRemoveHeader只是跳过了.csv文件的第一行,然后从磁盘读取并返回具有非空值和非空字符串的TEXT_OFFSET位置的行:

public static List<String[]> readCsvRemoveHeader(File file) throws IOException {
  FileInputStream fileIn = new FileInputStream(file);
  InputStreamReader inputStreamReader = new InputStreamReader(fileIn,Strings.UTF8);
  CSVReader csvReader = new CSVReader(inputStreamReader);
  csvReader.readNext();  //skip headers
  List<String[]> rows = new ArrayList<String[]>();
  String[] row;
  while ((row = csvReader.readNext()) != null) {
    if (row[TEXT_OFFSET] == null || row[TEXT_OFFSET].equals("")) {
      continue;
    }
    rows.add(row);
  }
  csvReader.close();
  return rows;
}

分类器的评估 – 混淆矩阵

评估在构建坚实的NLP系统中至关重要。它允许开发人员和管理人员将业务需求映射到系统性能,反过来,这有助于将系统改进传达给利益相关者。“嗯,嗯,系统似乎做得更好”并不像“召回率提高了20%,并且特异性在50%更多训练数据的情况下保持良好”那样有分量。

此配方提供了创建真实或黄金标准数据的步骤,并告诉我们如何使用这些数据来评估我们预编译分类器的性能。它既简单又强大。

准备中

你可能已经注意到了CSV编写器输出中的标题以及可疑标记的列TRUTH。现在,我们可以使用它了。加载我们之前提供的推文或将你的数据转换为我们的.csv格式。获取新数据的简单方法是对Twitter运行一个多语言友好的查询,例如Disney,这是我们默认提供的数据。

打开CSV文件,为至少10个英语的e和非英语的n标注你认为推文所使用的语言。在分发中有一个data/disney_e_n.csv文件;如果你不想处理标注数据,可以使用这个文件。如果你对某个推文不确定,请随意忽略它。未标注的数据将被忽略。请看下面的截图:

准备中

包含对英语'e'和非英语'n'的人类标注的电子表格截图。它被称为真实数据或黄金标准数据,因为它正确地代表了现象。

通常,这类数据被称为黄金标准数据,因为它代表了真相。在“黄金标准”中的“黄金”字面意思非常明显。备份并存储它时,要考虑到其持久性——它很可能是你硬盘上最有价值的字节集合,因为它以任何数量生产都很昂贵,并且是对正在进行的事情最清晰的阐述。实现方式会来来去去;评估数据将永远存在。来自《约翰·史密斯问题》食谱的约翰·史密斯语料库,在第7章找到概念/人物之间的指代关系,是该特定问题的标准评估语料库,并且作为始于1997年的研究系列的比较基准而存在。原始实现已经被人遗忘。

如何操作...

执行以下步骤以评估分类器:

  1. 在命令提示符中输入以下内容;这将运行默认分类器在默认黄金标准数据中的文本。然后,它将比较分类器的最佳类别与TRUTH列中标注的内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.RunConfusionMatrix
    
    
  2. 此类将生成混淆矩阵:

    reference\response
     \e,n,
     e 11,0,
     n 1,9,
    
    

混淆矩阵的命名非常恰当,因为它最初几乎会让人困惑,但毫无疑问,它是分类器输出的最佳表示,因为它很难用它来隐藏糟糕的分类器性能。换句话说,它是一个出色的BS检测器。它明确地展示了分类器正确识别的内容、错误识别的内容以及它认为正确的答案。

每行的总和代表根据真相/参考/黄金标准已知属于该类别的项目。对于英语(e),有11条推文。每一列代表系统认为属于同一标记类别的内容。对于英语(e),系统认为有11条推文是英语,没有非英语(n)。对于非英语类别(n),在真相中有10个案例,其中分类器认为1个是英语(错误地)和9个是非英语(正确地)。完美系统性能将在非对角线上的所有单元格中为零,从左上角到底右角。

实际上被称为混淆矩阵的原因是,它相对容易看出分类器混淆的类别。例如,英国英语和美国英语可能会非常容易混淆。此外,混淆矩阵可以很好地扩展到多个类别,这一点将在后面看到。访问Javadoc以获取对混淆矩阵的更详细解释——掌握它是值得的。

它是如何工作的...

接下来,将加载语言模型和.csv数据。与Util.CsvRemoveHeader解释略有不同,因为它只接受TRUTH列中有值的行——如果这还不清楚,请参阅src/com/lingpipe/cookbook/Util.java

@SuppressWarnings("unchecked")
BaseClassifier<CharSequence> classifier = (BaseClassifier<CharSequence>) AbstractExternalizable.readObject(new File(classifierPath));

List<String[]> rows = Util.readAnnotatedCsvRemoveHeader(new File(inputPath));

接下来,将找到类别:

String[] categories = Util.getCategories(rows);

该方法将累积来自 TRUTH 列的所有类别标签。代码很简单,如下所示:

public static String[] getCategories(List<String[]> data) {
  Set<String> categories = new HashSet<String>();
  for (String[] csvData : data) {
    if (!csvData[ANNOTATION_OFFSET].equals("")) {
      categories.add(csvData[ANNOTATION_OFFSET]);
    }
  }
  return categories.toArray(new String[0]);
}

当我们运行任意数据,其中标签在编译时未知时,此代码将很有用。

然后,我们将设置 BaseClassfierEvaluator。这需要评估分类器。还将设置类别和一个 boolean 值,该值控制是否在分类器中存储输入以进行构建:

boolean storeInputs = false;
BaseClassifierEvaluator<CharSequence> evaluator = new BaseClassifierEvaluator<CharSequence>(classifier, categories, storeInputs);

注意,分类器可以是空的,并且可以在稍后指定;类别必须与注释和分类器产生的类别完全匹配。我们不会麻烦配置评估器来存储输入,因为我们在这个配方中不会使用这个功能。有关输入存储和访问的示例,请参阅查看错误类别 - 假阳性配方。

接下来,我们将进行实际的评估。循环将遍历 .csv 文件中的每一行信息,构建一个 Classified<CharSequence>,并将其传递给评估器的 handle() 方法:

for (String[] row : rows) {
  String truth = row[Util.ANNOTATION_OFFSET];
  String text = row[Util.TEXT_OFFSET];
  Classification classification = new Classification(truth);
  Classified<CharSequence> classified = new Classified<CharSequence>(text,classification);
  evaluator.handle(classified);
}

第四行将创建一个分类对象,其值来自真实注释——在本例中是 en。这与 BaseClassifier<E>bestCategory() 方法返回的类型相同。没有为真实注释设置特殊类型。下一行添加了分类所应用的文本,我们得到一个 Classified<CharSequence> 对象。

循环的最后一行将应用处理方法到创建的分类对象上。评估器假定提供给其处理方法的 数据是真实注释,它通过提取正在分类的数据,将分类器应用于这些数据,获取结果 firstBest() 分类,并最终记录分类是否与刚刚用真实数据构造的分类相匹配。这发生在 .csv 文件的每一行上。

在循环外部,我们将使用 Util.createConfusionMatrix() 打印出混淆矩阵:

System.out.println(Util.confusionMatrixToString(evaluator.confusionMatrix()));

检查此代码留给了读者。就是这样;我们已经评估了我们的分类器并打印出了混淆矩阵。

还有更多...

评估器有一个完整的 toString() 方法,它提供了关于您的分类器表现如何的大量信息。输出中的这些方面将在后面的配方中介绍。Javadoc 非常详尽,值得一读。

训练自己的语言模型分类器

当分类器被定制时,NLP 的世界真正地打开了。这个配方提供了如何通过收集分类器学习示例来定制分类器的详细信息——这被称为训练数据。它也被称为黄金标准数据、真实数据或基准数据。我们有一些来自前面的配方,我们将使用它们。

准备工作

我们将为英语和其他语言创建一个定制的语言 ID 分类器。创建训练数据涉及获取文本数据,然后为分类器的类别进行标注——在这种情况下,标注是语言。训练数据可以来自各种来源。一些可能性包括:

  • 标准数据,如前述评估配方中创建的数据。

  • 已经为你关心的类别进行标注的数据。例如,维基百科有针对特定语言的版本,这使得训练语言 ID 分类器变得容易。这就是我们创建 3LangId.LMClassifier 模型的方式。

  • 要有创意——哪里是帮助分类器正确引导的数据?

语言 ID 不需要太多数据就能很好地工作,因此每种语言 20 条推文就可以可靠地区分不同的语言。训练数据量将由评估驱动——更多的数据通常可以提高性能。

示例假设大约有 10 条英文推文和 10 条非英文推文由人们标注并放入 data/disney_e_n.csv

如何做到这一点...

为了训练你自己的语言模型分类器,执行以下步骤:

  1. 启动一个终端并输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.TrainAndRunLMClassifier
    
    
  2. 然后,在命令提示符中输入一些英文,比如,库尔特·冯内古特的引言,以查看生成的 JointClassification。请参阅 从分类器获取置信度估计 的配方,以了解以下输出的解释:

    Type a string to be classified. Empty string to quit.
    So it goes.
    Rank Categ Score  P(Category|Input)  log2 P(Category,Input)
    0=e -4.24592987919 0.9999933712053  -55.19708842949149
    1=n -5.56922173547 6.62884502334E-6 -72.39988256112824
    
    
  3. 输入一些非英文,例如,博尔赫斯的《分叉之路》的西班牙语标题:

    Type a string to be classified. Empty string to quit.
    El Jardín de senderos que se bifurcan 
    Rank Categ Score  P(Category|Input)  log2 P(Category,Input)
    0=n -5.6612148689 0.999989087229795 -226.44859475801326
    1=e -6.0733050528 1.091277041753E-5 -242.93220211249715
    
    

它是如何工作的...

程序位于 src/com/lingpipe/cookbook/chapter1/TrainAndRunLMClassifier.javamain() 方法的内文开始如下:

String dataPath = args.length > 0 ? args[0] : "data/disney_e_n.csv";
List<String[]> annotatedData = Util.readAnnotatedCsvRemoveHeader(new File(dataPath));
String[] categories = Util.getCategories(annotatedData);

上述代码获取 .csv 文件的内容,然后提取标注的类别列表;这些类别将是标注列中的所有非空字符串。

下面的 DynamicLMClassifier 是通过一个静态方法创建的,该方法需要类别数组以及 int 类型的语言模型顺序。当顺序为 3 时,语言模型将在所有 1 到 3 个字符序列的文本训练数据上训练。因此,“I luv Disney”将产生“ I”,“ I ”,“ I l”,“ l”,“ lu”,“ u”,“ uv”,“ luv”等训练实例。createNGramBoundary 方法将一个特殊标记添加到每个文本序列的开始和结束处;如果序列的开始或结束对分类有信息性,这个标记可能会有所帮助。大多数文本数据对开始/结束都很敏感,因此我们将选择这个模型:

int maxCharNGram = 3;
DynamicLMClassifier<NGramBoundaryLM> classifier = DynamicLMClassifier.createNGramBoundary(categories,maxCharNGram);

以下代码遍历训练数据的行,并创建 Classified<CharSequence>,就像在 分类器的评估——混淆矩阵 配方中展示的评估方式一样。然而,它不是将 Classified 对象传递给评估处理程序,而是用于训练分类器。

for (String[] row: annotatedData) {
  String truth = row[Util.ANNOTATION_OFFSET];
  String text = row[Util.TEXT_OFFSET];
  Classification classification 
    = new Classification(truth);
  Classified<CharSequence> classified = new Classified<CharSequence>(text,classification);
  classifier.handle(classified);
}

不需要进一步的操作,分类器已经准备好供控制台使用:

Util.consoleInputPrintClassification(classifier);

更多...

对于基于DynamicLM的分类器,训练和使用分类器可以交错进行。这通常不适用于其他分类器,如LogisticRegression,因为它们使用所有数据来编译一个可以进行分类的模型。

存在另一种训练分类器的方法,它让你能更多地控制训练过程。以下是这个方法的代码片段:

Classification classification = new Classification(truth);
Classified<CharSequence> classified = new Classified<CharSequence>(text,classification);
classifier.handle(classified);

或者,我们可以用以下方法达到相同的效果:

int count = 1;
classifier.train(truth,text,count);

train()方法允许在训练过程中有额外的控制,因为它允许显式设置计数。当我们探索LingPipe分类器时,我们经常会看到一种允许进行一些额外控制的替代训练方法,这超出了handle()方法提供的控制范围。

基于字符语言模型的分类器在处理具有独特字符序列的任务中表现非常好。语言识别是这种任务的理想候选者,但它也可以用于情感分析、主题分配和问答等任务。

参见

LingPipe分类器的Javadoc对驱动该技术的底层数学进行了相当广泛的描述。

如何使用交叉验证进行训练和评估

早期的食谱已经展示了如何使用真实数据进行分类器的评估和训练,但关于同时进行这两者呢?这个伟大的想法被称为交叉验证,它的工作方式如下:

  1. 将数据分成n个不同的集合或折——标准的n是10。

  2. 对于从1到ni

    • 在排除折i的情况下,使用n - 1个折进行训练

    • i折上进行评估

  3. 报告所有折i的评估结果。

这就是大多数机器学习系统如何调整以获得性能。工作流程如下:

  1. 查看交叉验证的性能。

  2. 查看由评估指标确定的错误。

  3. 查看实际错误——是的,数据——以了解如何改进系统。

  4. 进行一些更改

  5. 再次评估它。

交叉验证是对比不同问题解决方法、尝试不同的分类器、推动归一化方法、探索特征增强等的一种优秀方法。通常,在交叉验证中表现出更高性能的系统配置也会在新数据上表现出更高的性能。交叉验证不做的,尤其是在后面讨论的主动学习策略中,是可靠地预测新数据的性能。在发布生产系统之前,始终在新的数据上应用分类器作为最后的合理性检查。你已经收到警告了。

与在所有可能的训练数据上训练的分类器相比,交叉验证施加了负偏差,因为每个折都是一个稍微弱一些的分类器,它在10个折上只有90%的数据。

“清洗、泡沫、重复”是构建最先进的NLP系统的咒语。

准备中

注意这种方法与其他经典计算机工程方法的不同,后者侧重于针对由单元测试驱动的功能规范进行开发。这个过程更多的是根据评估指标来精炼和调整代码,以使其工作得更好。

如何实现…

要运行代码,请执行以下步骤:

  1. 打开命令提示符并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.RunXValidate
    
    
  2. 结果将是:

    Training data is: data/disney_e_n.csv
    Training on fold 0
    Testing on fold 0
    Training on fold 1
    Testing on fold 1
    Training on fold 2
    Testing on fold 2
    Training on fold 3
    Testing on fold 3
    reference\response
        \e,n,
        e 10,1,
        n 6,4,
    

    上述输出将在下一节中更有意义。

它是如何工作的…

本配方介绍了一个用于管理交叉验证的XValidatingObjectCorpus对象。它在训练分类器时被大量使用。其他所有内容都应该与之前的配方熟悉。main()方法从以下内容开始:

String inputPath = args.length > 0 ? args[0] : "data/disney_e_n.csv";
System.out.println("Training data is: " + inputPath);
List<String[]> truthData = Util.readAnnotatedCsvRemoveHeader(new File(inputPath));

上述代码从默认文件或用户输入的文件中获取数据。接下来的两行介绍了XValidatingObjectCorpus——本配方的明星:

int numFolds = 4;
XValidatingObjectCorpus<Classified<CharSequence>> corpus = Util.loadXValCorpus(truthData, numFolds);

numFolds变量控制刚刚加载的数据如何分区——在这种情况下,它将分为四个分区。现在,我们将查看Util.loadXValCorpus(truthData, numfolds)子程序:

public static XValidatingObjectCorpus<Classified<CharSequence>> loadXValCorpus(List<String[]> rows, int numFolds) throws IOException {
  XValidatingObjectCorpus<Classified<CharSequence>> corpus = new XValidatingObjectCorpus<Classified<CharSequence>>(numFolds);
  for (String[] row : rows) {
    Classification classification = new Classification(row[ANNOTATION_OFFSET]);
    Classified<CharSequence> classified = new Classified<CharSequence>(row[TEXT_OFFSET],classification);
    corpus.handle(classified);
  }
  return corpus;
}

构造的XValidatingObjectCorpus<E>将包含所有以Objects E形式存在的真实数据。在这种情况下,我们正在用本章之前配方中用于训练和评估的相同对象填充语料库—Classified<CharSequence>。这将很有用,因为我们将使用这些对象来训练和测试我们的分类器。numFolds参数指定要创建多少个数据分区。它可以在以后更改。

以下for循环应该很熟悉,因为它应该遍历所有已注释的数据,在应用corpus.handle()方法(该方法将其添加到语料库中)之前创建Classified<CharSequence>对象。最后,我们将返回语料库。如果您有任何问题,查看XValidatingObjectCorpus<E>的Javadoc可能会有所帮助。

返回到main()函数的主体,我们将对语料库进行排列以混合数据,获取类别,并在之前配方中提供分类器的地方设置BaseClassifierEvaluator<CharSequence>为null值:

corpus.permuteCorpus(new Random(123413));
String[] categories = Util.getCategories(truthData);
boolean storeInputs = false;
BaseClassifierEvaluator<CharSequence> evaluator = new BaseClassifierEvaluator<CharSequence>(null, categories, storeInputs);

现在,我们已准备好进行交叉验证:

int maxCharNGram = 3;
for (int i = 0; i < numFolds; ++i) {
  corpus.setFold(i);
  DynamicLMClassifier<NGramBoundaryLM> classifier = DynamicLMClassifier.createNGramBoundary(categories, maxCharNGram);
  System.out.println("Training on fold " + i);
  corpus.visitTrain(classifier);
  evaluator.setClassifier(classifier);
  System.out.println("Testing on fold " + i);
  corpus.visitTest(evaluator);
}

for循环的每次迭代中,我们将设置正在使用的折数,这反过来将选择训练和测试分区。然后,我们将构建DynamicLMClassifier并通过向corpus.visitTrain(classifier)提供分类器来对其进行训练。接下来,我们将评估器的分类器设置为刚刚训练的那个。评估器被传递到corpus.visitTest(evaluator)方法中,其中包含的分类器应用于它未训练过的测试数据。有四个折,在任何给定迭代中,25%的数据将是测试数据,75%的数据将是训练数据。数据将在测试分区中恰好一次,在训练中三次。除非数据中有重复,否则训练和测试分区永远不会包含相同的数据。

一旦循环完成所有迭代,我们将打印一个在 分类器的评估——混淆矩阵 配方中讨论的混淆矩阵:

System.out.println(
  Util.confusionMatrixToString(evaluator.confusionMatrix()));

还有更多...

这个配方引入了许多移动部件,即交叉验证和支撑它的语料库对象。ObjectHandler<E> 接口也被大量使用;这对于不熟悉该模式的开发者来说可能会令人困惑。它用于训练和测试分类器。它还可以用于打印语料库的内容。将 for 循环的内容更改为 visitTrain 并使用 Util.corpusPrinter

System.out.println("Training on fold " + i);
corpus.visitTrain(Util.corpusPrinter());
corpus.visitTrain(classifier);
evaluator.setClassifier(classifier);
System.out.println("Testing on fold " + i);
corpus.visitTest(Util.corpusPrinter());

现在,你将得到一个看起来像这样的输出:

Training on fold 0
Malis?mos los nuevos dibujitos de disney, nickelodeon, cartoon, etc, no me gustannn:n
@meeelp mas que venha um filhinho mais fofo que o pr?prio pai, com covinha e amando a Disney kkkkkkkkkkkkkkkkk:n
@HedyHAMIDI au quartier pas a Disney moi:n
I fully love the Disney Channel I do not care ?:e

文本后面跟着 : 和类别。打印训练/测试折是检查语料库是否正确填充的良好合理性检查。这也是了解 ObjectHandler<E> 接口工作方式的一个很好的视角——这里,源代码来自 com/lingpipe/cookbook/Util.java

public static ObjectHandler<Classified<CharSequence>> corpusPrinter () {
  return new ObjectHandler<Classified<CharSequence>>() {
    @Override
    public void handle(Classified<CharSequence> e) {
      System.out.println(e.toString());
    }
  };
}

返回的类别没有多少内容。有一个单一的 handle() 方法,它只是打印 Classified<CharSequence>toString() 方法。在这个配方中,分类器会调用文本和分类的 train() 方法,评估器将文本传递给分类器,并将结果与真实情况进行比较。

另一个值得进行的实验是报告每个折的性能而不是所有折的性能。对于小型数据集,你将看到非常大的性能变化。另一个值得进行的实验是将语料库随机排列10次,并观察来自不同数据划分的性能变化。

另一个问题是如何选择用于评估的数据。对于文本处理应用来说,重要的是不要在测试数据和训练数据之间泄露信息。如果每一天都是一个折而不是所有10天的10%的切片,那么10天的数据交叉验证将更加现实。原因是某一天的数据可能会相关,如果允许某一天同时出现在训练和测试中,这种相关性将在训练和测试中产生关于该天的信息。在评估最终性能时,如果可能,始终选择训练数据纪元之后的数据,以更好地模拟生产环境,在那里未来是未知的。

查看错误类别——误报

我们可以通过检查错误并对系统进行修改来达到最佳可能的分类器性能。开发人员和机器学习人员中存在一个非常不好的习惯,那就是不查看错误,尤其是在系统成熟之后。为了明确起见,在项目结束时,负责调整分类器的开发者应该非常熟悉被分类的领域,如果不是专家,因为他们在调整系统时已经查看了很多数据。如果开发者无法合理地模拟你正在调整的分类器,那么你查看的数据就不够。

这个食谱以最基本的形式查看系统出错的地方,即假阳性,这些是从训练数据中分配给某个类别的示例,但正确的类别是其他类别。

如何做到这一点...

按以下步骤执行以使用假阳性查看错误类别:

  1. 这个食谱通过访问更多评估类提供的内容扩展了之前的如何使用交叉验证进行训练和评估食谱。获取命令提示符并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.ReportFalsePositivesOverXValidation
    
    
  2. 这将导致:

    Training data is: data/disney_e_n.csv
    reference\response
     \e,n,
     e 10,1,
     n 6,4,
    False Positives for e
    Malisímos los nuevos dibujitos de disney, nickelodeon, cartoon, etc, no me gustannn : n
    @meeelp mas que venha um filhinho mais fofo que o próprio pai, com covinha e amando a Disney kkkkkkkkkkkkkkkkk : n
    @HedyHAMIDI au quartier pas a Disney moi : n
    @greenath_ t'as de la chance d'aller a Disney putain j'y ai jamais été moi. : n
    Prefiro gastar uma baba de dinheiro pra ir pra cancun doq pra Disney por exemplo : n
    ES INSUPERABLE DISNEY !! QUIERO VOLVER:( : n
    False Positives for n
    request now "let's get tricky" by @bellathorne and @ROSHON on @radiodisney!!! just call 1-877-870-5678 or at http://t.co/cbne5yRKhQ!! <3 : e
    
    
  3. 输出从混淆矩阵开始。然后,我们将看到来自混淆矩阵左下角单元格的实际六个假阳性实例,该单元格标记了分类器猜测的类别。然后,我们将看到n的假阳性,这是一个单独的示例。真实类别后面附加了:,这对于具有两个以上类别的分类器很有帮助。

它是如何工作的…

这个食谱基于之前的食谱,但其源代码位于com/lingpipe/cookbook/chapter1/ReportFalsePositivesOverXValidation.java。有两个不同之处。首先,对于评估器,storeInputs被设置为true

boolean storeInputs = true;
BaseClassifierEvaluator<CharSequence> evaluator = new BaseClassifierEvaluator<CharSequence>(null, categories, storeInputs);

其次,添加了一个Util方法来打印假阳性:

for (String category : categories) {
  Util.printFalsePositives(category, evaluator, corpus);
}

前面的代码通过识别一个关注的类别——e或英文推文——并从分类器评估器中提取所有假阳性来实现。对于这个类别,假阳性是真实上非英文的推文,但分类器认为它们是英文的。引用的Util方法如下:

public static <E> void printFalsePositives(String category, BaseClassifierEvaluator<E> evaluator, Corpus<ObjectHandler<Classified<E>>> corpus) throws IOException {
  final Map<E,Classification> truthMap = new HashMap<E,Classification>();
  corpus.visitCorpus(new ObjectHandler<Classified<E>>() {
    @Override
    public void handle(Classified<E> data) {
      truthMap.put(data.getObject(),data.getClassification());
    }
  });

前面的代码取包含所有真实数据的语料库,并填充Map<E,Classification>以允许根据输入查找真实注释。如果相同的输入存在于两个类别中,那么这个方法将不会很稳健,但会记录最后看到的示例:

List<Classified<E>> falsePositives = evaluator.falsePositives(category);
System.out.println("False Positives for " + category);
for (Classified<E> classified : falsePositives) {
  E data = classified.getObject();
  Classification truthClassification = truthMap.get(data);
  System.out.println(data + " : " + truthClassification.bestCategory());
  }
}

代码从评估器获取假阳性,然后遍历所有这些,通过前一段代码中构建的truthMap进行查找,并打印出相关信息。evaluator中也有获取假阴性、真阳性和真阴性的方法。

识别错误的能力对于提高性能至关重要。这个建议看起来很明显,但开发者不查看错误的情况非常普遍。他们只会查看系统输出,并对系统是否足够好做出粗略估计;这不会导致性能最优秀的分类器。

下一个食谱将更深入地探讨更多评估指标及其定义。

理解精确率和召回率

前一个食谱中的假阳性是四种可能的错误类别之一。所有类别及其解释如下:

  • 对于给定的类别X:

    • 真阳性:分类器猜测X,而真实类别也是X

    • 假阳性:分类器猜测X,但真实类别是不同于X的类别

    • 真正的负例:分类器猜测的类别与X不同,且真实类别也与X不同

    • 假负例:分类器猜测的类别与X不同,但真实类别是X

在掌握这些定义后,我们可以定义以下额外的常见评估度量标准:

  • 类别X的精度是真正的正例 / (假正例 + 真正例)

    • 退化情况是做出一个非常有信心的一百 percent 精度的猜测。这最小化了假正例,但召回率会非常糟糕。
  • 类别X的召回率或灵敏度是真正的正例 / (假负例 + 真正例)

    • 退化情况是猜测所有数据都属于类别X以实现100 percent 的召回率。这最小化了假负例,但精度会非常糟糕。
  • 类别X的特定性是真正的负例 / (真正的负例 + 假正例)

    • 退化情况是猜测所有数据都不属于类别X。

提供退化情况是为了清楚地说明度量标准关注的是什么。有如f-measure这样的度量标准可以平衡精度和召回率,但即使如此,也没有包含真正的负例,而真正的负例可以提供高度信息。有关评估的更多详细信息,请参阅com.aliasi.classify.PrecisionRecallEvaluation的Javadoc。

  • 在我们的经验中,大多数商业需求映射到三种场景之一:

  • 高精度 / 高召回率:语言ID需要同时具有良好的覆盖率和准确性;否则,会出现很多问题。幸运的是,对于错误代价较高的不同语言(如日语与英语或英语与西班牙语),LM分类器表现相当不错。

  • 高精度 / 可用的召回率:大多数商业用例都有这种形状。例如,如果一个搜索引擎在自动更改查询时如果拼写错误,最好不要犯很多错误。这意味着将“Breck Baldwin”更改为“Brad Baldwin”看起来很糟糕,但如果“Bradd Baldwin”没有被纠正,没有人会真正注意到。

  • 高召回率 / 可用的精度:在寻找特定针的稻草堆中的智能分析会容忍大量的假正例以支持找到目标。这是我们DARPA早期的一个教训。

如何序列化LingPipe对象 – 分类器示例

在部署情况下,训练好的分类器、具有复杂配置的其他Java对象或训练最好通过从磁盘反序列化它们来访问。第一个配方正是这样做的,通过使用AbstractExternalizable从磁盘读取LMClassifier。这个配方展示了如何将语言ID分类器写入磁盘以供以后使用。

序列化DynamicLMClassifier并重新读取它会导致不同的类,这是一个LMClassifier的实例,其表现与刚刚训练的实例相同,但不再接受训练实例,因为计数已转换为对数概率,后缀树中存储了退避平滑弧。结果分类器要快得多。

通常,大多数LingPipe分类器、语言模型和隐马尔可夫模型(HMM)都实现了SerializableCompilable接口。

准备工作

我们将使用与“查看错误类别 - 假阳性”食谱中相同的相同数据。

如何操作...

执行以下步骤以序列化LingPipe对象:

  1. 前往命令提示符并传达:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.TrainAndWriteClassifierToDisk
    
    
  2. 程序将以默认的输入/输出文件值响应:

    Training on data/disney_e_n.csv
    Wrote model to models/my_disney_e_n.LMClassifier
    
    
  3. 通过在调用反序列化和运行分类器食谱时指定要读取的分类器文件来测试模型是否工作:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.LoadClassifierRunOnCommandLine models/my_disney_e_n.LMClassifier
    
    
  4. 常规交互方式如下:

    Type a string to be classified. Empty string to quit.
    The rain in Spain
    Best Category: e 
    
    

它是如何工作的...

src/com/lingpipe/cookbook/chapter1/TrainAndWriteClassifierToDisk.javamain()方法的起始内容涵盖了本章前面食谱中介绍的材料,用于读取.csv文件,设置分类器并对其进行训练。如果代码有任何不清楚的地方,请参考它。

本食谱的新内容发生在我们调用DynamicLMClassifier上的AbtractExternalizable.compileTo()方法时,该方法编译模型并将其写入文件。此方法的使用方式类似于Java的Externalizable接口中的writeExternal方法:

AbstractExternalizable.compileTo(classifier,outFile);

这就是大家需要知道的所有内容,以便将分类器写入磁盘。

还有更多...

存在一种替代的序列化方法,它适用于更多基于File类之外的序列化数据源变体。编写分类器的另一种方法是:

FileOutputStream fos = new FileOutputStream(outFile);
ObjectOutputStream oos = new ObjectOutputStream(fos);
classifier.compileTo(oos);
oos.close();
fos.close();

此外,DynamicLM可以通过使用静态的AbstractExternalizable.compile()方法编译,而不涉及磁盘。它将以以下方式使用:

@SuppressWarnings("unchecked")
LMClassifier<LanguageModel, MultivariateDistribution> compiledLM = (LMClassifier<LanguageModel, MultivariateDistribution>) AbstractExternalizable.compile(classifier);

编译版本要快得多,但不再允许进一步训练实例。

使用Jaccard距离消除近似重复项

经常发生的情况是数据中存在重复或近似重复的数据,这些数据应该被过滤。Twitter数据有很多重复项,即使有搜索API提供的-filter:retweets选项,处理起来也可能相当令人沮丧。快速查看此问题的一种方法是按顺序排列电子表格中的文本,具有常见前缀的推文将相邻:

使用Jaccard距离消除近似重复项

具有相同前缀的重复推文

此排序仅揭示共享前缀;还有许多不共享前缀的更多前缀。此食谱将允许您找到其他重叠来源和阈值,即删除重复项的点。

如何操作...

执行以下步骤以使用Jaccard距离消除近似重复项:

  1. 在命令提示符中输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/opencsv-2.4.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter1.DeduplicateCsvData
    
    
  2. 你将被大量的文本淹没:

    Tweets too close, proximity 1.00
     @britneyspears do you ever miss the Disney days? and iilysm   please follow me. kiss from Turkey #AskBritneyJean ??
     @britneyspears do you ever miss the Disney days? and iilysm please follow me. kiss from Turkey #AskBritneyJean ??? 
    Tweets too close, proximity 0.50
     Sooo, I want to have a Disney Princess movie night....
     I just want to be a Disney Princess
    
    
  3. 两个示例输出被展示出来——第一个是一个几乎完全相同的副本,只有最后的 ? 有所不同。它的邻近度为 1.0;下一个示例的邻近度为 0.50,推文不同,但有很多单词重叠。请注意,第二种情况没有共享前缀。

它是如何工作的...

这个配方跳过了序列的一小部分,使用分词器驱动去重过程。它在这里是因为接下来的配方,用于情感分析,确实需要去重数据才能良好工作。第2章, 寻找和操作单词,详细介绍了分词。

main() 的源代码如下:

String inputPath = args.length > 0 ? args[0] : "data/disney.csv";
String outputPath = args.length > 1 ? args[1] : "data/disneyDeduped.csv";  
List<String[]> data = Util.readCsvRemoveHeader(new File(inputPath));
System.out.println(data.size());
TokenizerFactory:
TokenizerFactory tokenizerFactory = new RegExTokenizerFactory("\\w+");

简而言之,分词器将文本分解成由正则表达式 \w+(前一个代码中的第一个反斜杠转义了第二个反斜杠——这是Java的一个特性)定义的文本序列。它匹配连续的单词字符。字符串 "Hi, you here??" 产生标记 "Hi"、"you" 和 "here"。标点符号被忽略。

接下来,Util.filterJaccard 被调用,截止值为 .5,这大致消除了与一半单词重叠的推文。然后,过滤器数据被写入磁盘:

double cutoff = .5;
List<String[]> dedupedData = Util.filterJaccard(data, tokenizerFactory, cutoff);
System.out.println(dedupedData.size());
Util.writeCsvAddHeader(dedupedData, new File(outputPath));
}

Util.filterJaccard() 方法的源代码如下:

public static List<String[]> filterJaccard(List<String[]> texts, TokenizerFactory tokFactory, double cutoff) {
  JaccardDistance jaccardD = new JaccardDistance(tokFactory);
JaccardDistance class is constructed with a tokenizer factory. The Jaccard distance divides the intersection of tokens from the two strings over the union of tokens from both strings. Look at the Javadoc for more information.

下面的例子中的嵌套 for 循环探索每一行与每一行其他行,直到找到更高的阈值邻近度或直到查看完所有数据。不要用于大型数据集,因为这个算法是 O(n²)。如果没有行超过邻近度,则该行被添加到 filteredTexts

List<String[]> filteredTexts = new ArrayList<String[]>();
for (int i = 0; i < texts.size(); ++i) {
  String targetText = texts.get(i)[TEXT_OFFSET];
  boolean addText = true;
  for (int j = i + 1; j < texts.size(); ++j ) {
    String comparisionText = texts.get(j)[TEXT_OFFSET];
    double proximity = jaccardD.proximity(targetText,comparisionText);
    if (proximity >= cutoff) {
      addText = false;
      System.out.printf(" Tweets too close, proximity %.2f\n", proximity);
      System.out.println("\t" + targetText);
      System.out.println("\t" + comparisionText);
      break;
    }
  }
  if (addText) {
    filteredTexts.add(texts.get(i));
  }
}
return filteredTexts;
}

有许多更有效的方法来过滤文本,但代价是额外的复杂性——一个简单的反向单词查找索引来计算初始覆盖集将大大提高效率——搜索具有 O(n) 到 O(n log(n)) 方法的 shingling 文本查找。

设置阈值可能有点棘手,但查看大量数据应该会使适当的截止值对您的需求相当清晰。

如何进行情感分类——简单版本

情感分析已经成为经典的以业务为导向的分类任务——哪位高管能抗拒知道他们业务中不断被说出的正面和负面事情的能力?情感分类器通过将文本数据分类为正面和负面类别来提供这种能力。这个配方解决了创建简单情感分类器的过程,但更普遍地,它解决了如何为新型类别创建分类器的问题。它也是一个三向分类器,与我们所使用的双向分类器不同。

我们在2004年为BuzzMetrics构建的第一个情感分析系统使用了语言模型分类器。我们现在倾向于使用逻辑回归分类器,因为它们通常表现更好。第3章, 高级分类器,介绍了逻辑回归分类器。

如何做到这一点...

之前的配方集中在语言识别——我们如何将分类器转移到完全不同的情感分析任务上?这会比人们想象的简单得多——唯一需要改变的是训练数据,信不信由你。步骤如下:

  1. 使用Twitter搜索配方下载有关某个主题的推文,该主题有正面/负面的推文。以 disney 为例,但请随意扩展。此配方将适用于提供的CSV文件,data/disneySentiment_annot.csv

  2. 将创建的 data/disneySentiment_annot.csv 文件加载到您选择的电子表格中。已经有了一些标注。

  3. 如同在 分类器的评估 – 混淆矩阵 配方中,为三个类别之一标注 true class 列:

    • p 标注代表正面。例如:“哦,我真的很喜欢迪士尼电影。#hateonit”。

    • n 标注代表负数。例如:“迪士尼真的让我失望了,事情不应该这样发展”。

    • o 标注代表其他。例如:“关于迪士尼小镇的更新。http://t.co/SE39z73vnw”。

    • 对于不是英文、无关、既正面又负面,或者您不确定的推文,请留空。

  4. 继续标注,直到最小的类别至少有10个示例。

  5. 保存标注。

  6. 运行之前的交叉验证配方,提供标注文件的名称:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter1.RunXValidate data/disneyDedupedSentiment.csv
    
    
  7. 系统将随后运行四折交叉验证并打印出混淆矩阵。如果您需要进一步的解释,请查看 如何使用交叉验证进行训练和评估 配方:

    Training on fold 0
    Testing on fold 0
    Training on fold 1
    Testing on fold 1
    Training on fold 2
    Testing on fold 2
    Training on fold 3
    Testing on fold 3
    reference\response
     \p,n,o,
     p 14,0,10,
     n 6,0,4,
     o 7,1,37,
    
    

就这样!分类器完全依赖于训练数据来进行分类。更复杂的技术将比字符n-gram带来更丰富的特征,但最终,训练数据施加的标签是传递给分类器的知识。根据您的观点,底层技术要么神奇,要么令人惊讶地简单。

它是如何工作的...

大多数开发者都会惊讶地发现,语言识别和情感分析之间的唯一区别是用于训练的数据标注。语言模型分类器为每个类别应用一个单独的语言模型,并在估计中记录类别的边缘分布。

还有更多...

如果分类器不被期望在其能力之外工作,它们虽然很笨拙但非常有用。语言识别作为分类问题工作得很好,因为观察事件与所进行的分类紧密相关——语言中的单词和字符。情感分析更困难,因为在这种情况下,观察事件与语言识别完全相同,但与最终分类的关联较弱。例如,“我爱”这个短语是句子是英语的良好预测指标,但并不是那么清晰地预测情感是正面、负面或其他。如果推文是“我爱迪士尼”,那么我们有一个积极的陈述。如果推文是“我爱迪士尼,不”,那么它是负面的。处理情感和其他更复杂现象的复杂性通常以下列方式解决:

  • 创建更多训练数据。即使相对简单的技术,如语言模型分类器,只要有足够的数据,也能表现出色。人类在抱怨或赞扬某事的方式上并不那么有创造力。第3章中提到的“训练一点,学习一点——主动学习”的配方,高级分类器,提供了一种巧妙的方式来做到这一点。

  • 使用更复杂的分类器,这些分类器反过来又使用关于数据更复杂(观察)的特征来完成工作。更多信息请参阅逻辑回归配方。对于否定情况,一个在推文中寻找否定短语的特性可能有所帮助。这可以变得任意复杂。

注意,处理情感问题的更合适方式可能是创建一个用于正面非正面的二分类器,以及一个用于负面非负面的二分类器。这些分类器将拥有各自独立的训练数据,并允许一条推文同时具有正面和负面的情感。

常见问题作为分类问题

分类器是许多工业级自然语言处理问题的基础。这个配方将一些常见问题编码成基于分类的解决方案。只要可能,我们将从我们所构建的真实世界例子中提取信息。你可以把它们看作是迷你配方。

主题检测

问题:从财务文件(10Qs和10Ks)中提取脚注,并确定是否应用了可扩展商业报告语言XBRL)类别,例如“前瞻性财务报表”。结果证明,脚注是所有活动发生的地方。例如,脚注是否指的是已偿还的债务?需要达到90%以上的精确度,并接受可接受的召回率。

解决方案:这个问题与我们处理语言识别和情感分析的方法非常相似。实际的解决方案涉及一个句子识别器,它可以检测脚注——参见第5章文本中的跨度查找——分块——并为每个XBRL类别创建训练数据。我们使用混淆矩阵输出帮助细化系统难以区分的XBRL类别。合并类别是可能的,我们也确实合并了它们。这个系统基于语言模型分类器。如果现在进行,我们会使用逻辑回归。

问答

问题:在大量基于文本的客户支持数据集中识别常见问题(FAQs),并开发自动以90%精确度提供答案的能力。

解决方案:对日志进行聚类分析以找到常见问题(FAQs)——参见第6章字符串比较和聚类。这将导致一个非常大的常见问题(FAQs)集,实际上是非常不常见的问题IAQs);这意味着IAQ的普遍性可能低至1/20000。对于分类器来说,找到正数据相对容易,但创建负数据成本太高,无法在任何平衡分布上实现——对于每一个正面案例,预期将有19999个负面案例。解决方案是假设任何大样本的随机样本将包含非常少的正样本,并仅将其用作负样本。一个改进的方法是在负样本上运行一个训练好的分类器以找到得分高的案例,并标注它们以提取可能找到的正样本。

情感程度

问题:根据负面到正面的程度,在1到10的尺度上对情感进行分类。

解决方案:尽管我们的分类器提供了一个可以映射到1到10分的分数,但这并不是背景计算所做的事情。为了正确映射到程度尺度,一个人将不得不在训练数据中标注区分——这条推文是1分,这条推文是3分,依此类推。然后我们将训练一个10路分类器,理论上第一个最佳类别应该是程度。我们写理论上是因为尽管有定期的客户请求这样做,但我们从未找到愿意支持所需标注的客户。

非排他性类别分类

问题:所需的分类不是互斥的。一条推文可以同时表达正面和负面内容,例如,“喜欢米奇,讨厌普路托”。我们的分类器假设类别是互斥的。

解决方案:我们经常使用多个二元分类器来代替一个n-路或多项式分类器。这些分类器将针对正/非正和负/非负进行训练。然后一条推文可以被标注为np

人物/公司/地点检测

问题:在文本数据中检测提及的人。

解决方案:信不信由你,这个问题可以分解为一个词分类问题。参见第6章字符串比较和聚类

通常将任何新问题视为一个分类问题是有益的,即使底层技术并不使用分类器。这有助于明确底层技术实际上需要做什么。

第二章:查找和使用单词

本章涵盖了以下内容:

  • 分词器工厂简介——在字符流中查找单词

  • 结合分词器——小写分词器

  • 结合分词器——停用词分词器

  • 使用Lucene/Solr分词器

  • 使用LingPipe与Lucene/Solr分词器

  • 使用单元测试评估分词器

  • 修改分词器工厂

  • 为没有空格的语言查找单词

简介

构建NLP系统的一个重要部分是与适当的处理单元合作。本章讨论与处理单词级别相关的抽象层。这被称为分词,其目的是将相邻字符分组为有意义的块,以支持分类、实体识别以及其他NLP任务。

LingPipe提供了一系列的分词需求,这些需求在本书中没有涵盖。请查看分词器的Javadoc,这些分词器可以进行词干提取、Soundex(基于英语单词发音的标记)等。

分词器工厂简介——在字符流中查找单词

LingPipe分词器基于一个通用模式,即一个可以单独使用或作为后续过滤分词器源的基部分词器。过滤分词器操作基部分词器提供的标记/空白。本食谱涵盖了我们的最常用分词器IndoEuropeanTokenizerFactory,它适用于使用印欧风格标点符号和单词分隔符的语言——例如英语、西班牙语和法语。一如既往,Javadoc提供了有用的信息。

注意

IndoEuropeanTokenizerFactory创建具有内置对印欧语言中的字母数字、数字和其他常见结构的支持的分词器。

分词规则大致基于MUC-6中使用的规则,但必然更加精细,因为MUC分词器基于词汇和语义信息,例如一个字符串是否为缩写。

MUC-6指的是1995年发起的政府赞助的承包商之间竞争的会议,该会议起源于1995年。非正式术语是Bake off,指的是1949年开始的Pillsbury Bake-Off,其中一位作者作为MUC-6的后博士后参与者。MUC推动了NLP系统评估的大部分创新。

LingPipe 标记化器是使用 LingPipe TokenizerFactory 接口构建的,该接口提供了一种使用相同接口调用不同类型标记化器的方法。这在创建过滤标记化器时非常有用,过滤标记化器作为标记化器的链构建,并以某种方式修改其输出。TokenizerFactory 实例可以是基本标记化器,它在构建时接受简单参数,或者作为过滤标记化器,它接受其他标记化器工厂对象作为参数。在两种情况下,TokenizerFactory 实例都有一个单一的 tokenize() 方法,该方法接受输入作为字符数组、起始索引和要处理的字符数,并输出一个 Tokenizer 对象。Tokenizer 对象表示对特定字符串片段进行标记化的状态,并提供标记流。虽然 TokenizerFactory 是线程安全且/或可序列化的,但标记化器实例通常既不是线程安全的也不是可序列化的。Tokenizer 对象提供方法来遍历字符串中的标记,并提供标记在底层文本中的位置。

准备工作

如果您还没有这样做,请下载该书的 JAR 文件和源代码。

如何操作...

这一切都很简单。以下是与标记化开始相关的步骤:

  1. 进入 cookbook 目录并调用以下类:

    java -cp "lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar" com.lingpipe.cookbook.chapter2.RunBaseTokenizerFactory
    
    

    这将带我们到一个命令提示符,提示我们输入一些文本:

    type a sentence to see tokens and white spaces
    
    
  2. 如果我们输入一个句子,例如:It's no use growing older if you only learn new ways of misbehaving yourself,我们将得到以下输出:

    It's no use growing older if you only learn new ways of misbehaving yourself. 
    Token:'It'
    WhiteSpace:''
    Token:'''
    WhiteSpace:''
    Token:'s'
    WhiteSpace:' '
    Token:'no'
    WhiteSpace:' '
    Token:'use'
    WhiteSpace:' '
    Token:'growing'
    WhiteSpace:' '
    Token:'older'
    WhiteSpace:' '
    Token:'if'
    WhiteSpace:' '
    Token:'you'
    WhiteSpace:' '
    Token:'only'
    WhiteSpace:' '
    Token:'learn'
    WhiteSpace:' '
    Token:'new'
    WhiteSpace:' '
    Token:'ways'
    WhiteSpace:' '
    Token:'of'
    WhiteSpace:' '
    Token:'misbehaving'
    WhiteSpace:' '
    Token:'yourself'
    WhiteSpace:''
    Token:'.'
    WhiteSpace:' '
    
    
  3. 检查输出并注意标记和空白。文本来自萨基的短篇小说《巴斯特布尔女士的狂奔》。

它是如何工作的...

代码非常简单,可以完整地包含如下:

package com.lingpipe.cookbook.chapter2;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

import com.aliasi.tokenizer.IndoEuropeanTokenizerFactory;
import com.aliasi.tokenizer.Tokenizer;
import com.aliasi.tokenizer.TokenizerFactory;

public class RunBaseTokenizerFactory {

  public static void main(String[] args) throws IOException {
    TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
    BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

    while (true) {
      System.out.println("type a sentence to " + "see the tokens and white spaces");
      String input = reader.readLine();
      Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(), 0, input.length());
      String token = null;
      while ((token = tokenizer.nextToken()) != null) {
        System.out.println("Token:'" + token + "'");
        System.out.println("WhiteSpace:'" + tokenizer.nextWhitespace() + "'");

      }
    }
  }
}

此配方从 main() 方法的第一个语句中创建 TokenizerFactory tokFactory 开始。请注意,使用了单例 IndoEuropeanTokenizerFactory.INSTANCE。该工厂将为给定的字符串生成标记化器,这在 Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(), 0, input.length()) 这一行中很明显。输入的字符串通过 input.toCharArray() 转换为字符数组,作为 tokenizer 方法的第一个参数,并将起始和结束偏移量提供给创建的字符数组。

生成的 tokenizer 为提供的字符数组片段提供标记,空白和标记在 while 循环中打印出来。调用 tokenizer.nextToken() 方法执行以下操作:

  • 该方法返回下一个标记或 null(如果没有下一个标记)。null 结束循环;否则,循环继续。

  • 该方法还增加相应的空白。总是有一个与标记一起的空白,但它可能是空字符串。

IndoEuropeanTokenizerFactory假设字符分解的相当标准的抽象,分解如下:

  • char数组的开始到第一个标记之间的字符被忽略,并且不会作为空白字符报告

  • 上一个标记的末尾字符到char数组末尾之间的字符被报告为下一个空白字符

  • 由于两个相邻的标记,空白可以是空字符串——注意输出中的撇号和相应的空白

这意味着如果输入不以标记开始,则不一定能够重建原始字符串。幸运的是,分词器可以很容易地修改以满足定制需求。我们将在本章后面看到这一点。

更多...

分词可以是任意复杂的。LingPipe分词器旨在覆盖大多数常见用途,但你可能需要创建自己的分词器以实现细粒度控制,例如,将“Victoria's Secret”中的“Victoria's”作为一个标记。如果需要此类自定义,请参考IndoEuropeanTokenizerFactory的源代码,以了解这里是如何进行任意分词的。

结合分词器 – 小写分词器

在前面的配方中,我们提到LingPipe分词器可以是基本的或过滤的。基本分词器,如印欧分词器,不需要太多的参数化,实际上根本不需要。然而,过滤分词器需要一个分词器作为参数。我们使用过滤分词器所做的就是在多个分词器中调用,其中基本分词器通常通过过滤器修改以产生不同的分词器。

LingPipe提供了几个基本分词器,例如IndoEuropeanTokenizerFactoryCharacterTokenizerFactory。完整的列表可以在LingPipe的Javadoc中找到。在本节中,我们将向您展示如何将一个印欧分词器与一个小写分词器结合使用。这是一个相当常见的流程,许多搜索引擎为印欧语言实现此流程。

准备工作

您需要下载书籍的JAR文件,并设置Java和Eclipse,以便可以运行示例。

如何做...

这与前面的配方工作方式相同。执行以下步骤:

  1. 从命令行调用RunLowerCaseTokenizerFactory类:

    java -cp "lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar" com.lingpipe.cookbook.chapter2.RunLowerCaseTokenizerFactory.
    
    
  2. 然后,在命令提示符下,让我们使用以下示例:

    type a sentence below to see the tokens and white spaces are:
    This is an UPPERCASE word and these are numbers 1 2 3 4.5.
    Token:'this'
    WhiteSpace:' '
    Token:'is'
    WhiteSpace:' '
    Token:'an'
    WhiteSpace:' '
    Token:'uppercase'
    WhiteSpace:' '
    Token:'word'
    WhiteSpace:' '
    Token:'and'
    WhiteSpace:' '
    Token:'these'
    WhiteSpace:' '
    Token:'are'
    WhiteSpace:' '
    Token:'numbers'
    WhiteSpace:' '
    Token:'1'
    WhiteSpace:' '
    Token:'2'
    WhiteSpace:' '
    Token:'3'
    WhiteSpace:' '
    Token:'4.5'
    WhiteSpace:''
    Token:'.'
    WhiteSpace:''
    
    

它是如何工作的...

您可以在前面的输出中看到,所有标记都被转换为小写,包括以大写输入的单词UPPERCASE。由于此示例使用印欧分词器作为其基本分词器,您可以看到数字4.5被保留为4.5,而不是被拆分为4和5。

我们组合分词器的方式非常简单:

public static void main(String[] args) throws IOException {

  TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  tokFactory = new LowerCaseTokenizerFactory(tokFactory);
  tokFactory = new WhitespaceNormTokenizerFactory(tokFactory);

  BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

  while (true) {
    System.out.println("type a sentence below to see the tokens and white spaces are:");
    String input = reader.readLine();
    Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(), 0, input.length());
    String token = null;
    while ((token = tokenizer.nextToken()) != null) {
      System.out.println("Token:'" + token + "'");
      System.out.println("WhiteSpace:'" + tokenizer.nextWhitespace() + "'");
    }
  }
}

在这里,我们创建了一个分词器,它返回使用印欧语分词器生成的归一化大小写和空白符标记符。从分词器工厂创建的分词器是一个过滤分词器,它以印欧语基础分词器开始,然后通过LowerCaseTokenizer修改以生成小写分词器。然后,它再次通过WhiteSpaceNormTokenizerFactory修改,以生成小写、空白符归一化的印欧语分词器。

在这里,我们应用了大小写归一化,因为单词的大小写并不重要;例如,搜索引擎通常在它们的索引中存储大小写归一化的单词。现在,我们将使用大小写归一化的标记符在即将到来的关于分类器的示例中。

参见

  • 有关如何构建过滤分词器的更多详细信息,请参阅抽象类ModifiedTokenizerFactory的Javadoc。

结合分词器 - 停用词分词器

类似于我们组合小写和空白符归一化分词器的方式,我们可以使用过滤分词器创建一个过滤掉停用词的分词器。再次以搜索引擎为例,我们可以从我们的输入集中移除常见单词,以便归一化文本。通常被移除的停用词本身传达的信息很少,尽管它们可能在上下文中传达信息。

输入使用设置的任何基础分词器进行分词,然后,通过停用词分词器过滤掉结果标记符,以生成一个在初始化停用词分词器时指定的停用词免于出现的标记符流。

准备工作

你需要下载书籍的JAR文件,并设置Java和Eclipse,以便你可以运行示例。

如何做到这一点...

正如我们之前所做的那样,我们将通过与分词器交互的步骤:

  1. 从命令行调用RunStopTokenizerFactory类:

    java -cp "lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar" com.lingpipe.cookbook.chapter2.RunStopTokenizerFactory
    
    
  2. 然后,在提示中,让我们使用以下示例:

    type a sentence below to see the tokens and white spaces:
    the quick brown fox is jumping
    Token:'quick'
    WhiteSpace:' '
    Token:'brown'
    WhiteSpace:' '
    Token:'fox'
    WhiteSpace:' '
    Token:'jumping'
    WhiteSpace:''
    
    
  3. 注意,我们丢失了相邻信息。在输入中,我们有fox is jumping,但分词结果却是fox后面跟着jumping,因为is被过滤掉了。这可能对需要准确相邻信息的基于分词的过程造成问题。在第4章(part0051_split_000.html#page "Chapter 4. Tagging Words and Tokens")的前景或背景驱动的有趣短语检测配方中,我们将展示一个基于长度的过滤分词器,它可以保留相邻信息。

它是如何工作的...

在这个StopTokenizerFactory过滤器中使用的停用词只是一个非常短的单词列表,isoftheto。显然,如果需要,这个列表可以更长。正如你在前面的输出中看到的那样,单词theis已经被从分词输出中移除。这是通过一个非常简单的步骤完成的:我们在src/com/lingpipe/cookbook/chapter2/RunStopTokenizerFactory.java中实例化StopTokenizerFactory。相关的代码是:

TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
tokFactory = new LowerCaseTokenizerFactory(tokFactory);
Set<String> stopWords = new HashSet<String>();
stopWords.add("the");
stopWords.add("of");
stopWords.add("to");
stopWords.add("is");

tokFactory = new StopTokenizerFactory(tokFactory, stopWords);

由于我们在分词器工厂中使用 LowerCaseTokenizerFactory 作为其中一个过滤器,我们可以忽略只包含小写单词的停用词。如果我们想保留输入标记的大小写并继续移除停用词,我们需要添加大写或混合大小写的版本。

参见

使用 Lucene/Solr 分词器

非常流行的搜索引擎 Lucene 包含许多分析模块,它们提供通用分词器以及从阿拉伯语到泰语的语言特定分词器。截至 Lucene 4,这些不同的分析器大多可以在单独的 JAR 文件中找到。我们将介绍 Lucene 分词器,因为它们可以用作 LingPipe 分词器,您将在下一个配方中看到。

与 LingPipe 分词器类似,Lucene 分词器也可以分为基本分词器和过滤分词器。基本分词器以读取器作为输入,而过滤分词器以其他分词器作为输入。我们将查看一个使用标准 Lucene 分析器和 lowercase-filtered 分词器的示例。Lucene 分析器本质上将字段映射到标记流。因此,如果您有一个现有的 Lucene 索引,您可以使用分析器而不是原始分词器,正如我们将在本章的后续部分展示的那样。

准备工作

您需要下载书籍的 JAR 文件,并设置 Java 和 Eclipse,以便运行示例。示例中使用的某些 Lucene 分析器是 lib 目录的一部分。但是,如果您想尝试其他语言分析器,请从 Apache Lucene 网站下载它们:https://lucene.apache.org

如何操作...

记住,在这个配方中我们没有使用 LingPipe 分词器,而是介绍了 Lucene 分词器类:

  1. 从命令行调用 RunLuceneTokenizer 类:

    java -cp lingpipe-cookbook.1.0.jar:lib/lucene-analyzers-common-4.6.0.jar:lib/lucene-core-4.6.0.jar com.lingpipe.cookbook.chapter2.RunLuceneTokenize
    
    
  2. 然后,在提示中,让我们使用以下示例:

    the quick BROWN fox jumped
    type a sentence below to see the tokens and white spaces:
    The rain in Spain.
    Token:'the' Start: 0 End:3
    Token:'rain' Start: 4 End:8
    Token:'in' Start: 9 End:11
    Token:'spain' Start: 12 End:17
    
    

它是如何工作的...

让我们回顾以下代码,看看 Lucene 分词器在调用上与前面的示例有何不同——来自 src/com/lingpipe/cookbook/chapter2/RunLuceneTokenizer.java 的相关代码部分是:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

while (true) {
BufferedReader from the command line and starts a perpetual while() loop. Next, the prompt is provided, the input is read, and it is used to construct a Reader object:
System.out.println("type a sentence below to see the tokens and white spaces:");
String input = reader.readLine();
Reader stringReader = new StringReader(input);

所有输入都已封装,现在是构建实际分词器的时候了:

TokenStream tokenStream = new StandardTokenizer(Version.LUCENE_46,stringReader);

tokenStream = new LowerCaseFilter(Version.LUCENE_46,tokenStream);

输入文本用于使用 Lucene 的版本控制系统构建 StandardTokenizer,这产生了一个 TokenStream 实例。然后,我们使用 LowerCaseFilter 创建最终的过滤 tokenStream,其中将基本 tokenStream 作为参数。

在 Lucene 中,我们需要从标记流中附加我们感兴趣的属性;这是通过 addAttribute 方法完成的:

CharTermAttribute terms = tokenStream.addAttribute(CharTermAttribute.class);
OffsetAttribute offset = tokenStream.addAttribute(OffsetAttribute.class);
tokenStream.reset();

注意,在 Lucene 4 中,一旦分词器被实例化,在使用分词器之前必须调用 reset() 方法:

while (tokenStream.incrementToken()) {
  String token = terms.toString();
  int start = offset.startOffset();
  int end = offset.endOffset();
  System.out.println("Token:'" + token + "'" + " Start: " + start + " End:" + end);
}

tokenStream 被以下内容包装:

tokenStream.end();
tokenStream.close();

参见

Text Processing with JavaMitzi MorrisColloquial Media Corporation 中,对 Lucene 的一个很好的介绍,其中我们之前解释的内容比在配方中提供的更清晰。

使用 LingPipe 与 Lucene/Solr 分词器

我们可以使用这些 Lucene 分词器与 LingPipe 一起使用;这很有用,因为 Lucene 有如此丰富的分词器集。我们将展示如何通过扩展 Tokenizer 抽象类将 Lucene TokenStream 包装到 LingPipe TokenizerFactory 中。

How to do it...

我们将做一些不同的尝试,并有一个非交互式的配方。执行以下步骤:

  1. 从命令行调用 LuceneAnalyzerTokenizerFactory 类:

    java -cp lingpipe-cookbook.1.0.jar:lib/lucene-analyzers-common-4.6.0.jar:lib/lucene-core-4.6.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter2.LuceneAnalyzerTokenizerFactory
    
    
  2. 类中的 main() 方法指定了输入:

    String text = "Hi how are you? " + "Are the numbers 1 2 3 4.5 all integers?";
    Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_46);
    TokenizerFactory tokFactory = new LuceneAnalyzerTokenizerFactory(analyzer, "DEFAULT");
    Tokenizer tokenizer = tokFactory.tokenizer(text.toCharArray(), 0, text.length());
    
    String token = null;
    while ((token = tokenizer.nextToken()) != null) {
      String ws = tokenizer.nextWhitespace();
      System.out.println("Token:'" + token + "'");
      System.out.println("WhiteSpace:'" + ws + "'");
    }
    
  3. 前面的代码片段创建了一个 Lucene StandardAnalyzer 并使用它来构建一个 LingPipe TokenizerFactory。输出如下——StandardAnalyzer 过滤掉停用词,所以标记 are 被过滤掉:

    Token:'hi'
    WhiteSpace:'default'
    Token:'how'
    WhiteSpace:'default'
    Token:'you'
    WhiteSpace:'default'
    Token:'numbers'
    WhiteSpace:'default'
    
    
  4. 空白字符报告为 default,因为实现没有准确提供空白字符,而是使用默认值。我们将在 How it works… 部分讨论这个限制。

How it works...

让我们看看 LuceneAnalyzerTokenizerFactory 类。这个类通过包装 Lucene 分析器实现了 LingPipe TokenizerFactory 接口。我们将从 src/com/lingpipe/cookbook/chapter2/LuceneAnalyzerTokenizerFactory.java 中的类定义开始:

public class LuceneAnalyzerTokenizerFactory implements TokenizerFactory, Serializable {

  private static final long serialVersionUID = 8376017491713196935L;
  private Analyzer analyzer;
  private String field;
  public LuceneAnalyzerTokenizerFactory(Analyzer analyzer, String field) {
    super();
    this.analyzer = analyzer;
    this.field = field;
  }

构造函数将分析器和字段的名称存储为私有变量。由于这个类实现了 TokenizerFactory 接口,我们需要实现 tokenizer() 方法:

public Tokenizer tokenizer(char[] charSeq , int start, int length) {
  Reader reader = new CharArrayReader(charSeq,start,length);
  TokenStream tokenStream = analyzer.tokenStream(field,reader);
  return new LuceneTokenStreamTokenizer(tokenStream);
}

tokenizer() 方法创建一个新的字符数组读取器,并将其传递给 Lucene 分析器以将其转换为 TokenStream。基于标记流创建了一个 LuceneTokenStreamTokenizer 实例。LuceneTokenStreamTokenizer 是一个嵌套的静态类,它扩展了 LingPipe 的 Tokenizer 类:

static class LuceneTokenStreamTokenizer extends Tokenizer {
  private TokenStream tokenStream;
  private CharTermAttribute termAttribute;
  private OffsetAttribute offsetAttribute;

  private int lastTokenStartPosition = -1;
  private int lastTokenEndPosition = -1;

  public LuceneTokenStreamTokenizer(TokenStream ts) {
    tokenStream = ts;
    termAttribute = tokenStream.addAttribute(
      CharTermAttribute.class);
    offsetAttribute = tokenStream.addAttribute(OffsetAttribute.class);
  }

构造函数存储 TokenStream 并附加术语和偏移量属性。在先前的配方中,我们看到了术语和偏移量属性包含标记字符串,以及输入文本中的标记起始和结束偏移量。在找到任何标记之前,标记偏移量也被初始化为 -1

@Override
public String nextToken() {
  try {
    if (tokenStream.incrementToken()){
      lastTokenStartPosition = offsetAttribute.startOffset();
      lastTokenEndPosition = offsetAttribute.endOffset();
      return termAttribute.toString();
    } else {
      endAndClose();
      return null;
    }
  } catch (IOException e) {
    endAndClose();
    return null;
  }
}

我们将实现 nextToken() 方法,并使用标记流的 incrementToken() 方法从标记流中检索任何标记。我们将使用 OffsetAttribute 设置标记的起始和结束偏移量。如果标记流已结束或 incrementToken() 方法抛出 I/O 异常,我们将结束并关闭 TokenStream

nextWhitespace() 方法有一些限制,因为 offsetAttribute 专注于当前标记,LingPipe 分词器将输入量化为下一个标记和下一个偏移量。在这里找到一个通用的解决方案将非常具有挑战性,因为标记之间可能没有明确定义的空白——想想字符 n-gram。因此,提供了 default 字符串以使其更清晰。该方法如下:

@Override
public String nextWhitespace() {
  return "default";
}

代码还涵盖了如何序列化分词器,但我们将不在菜谱中涵盖这一点。

使用单元测试评估分词器

我们不会像 LingPipe 的其他组件那样,用精确度和召回率等指标来评估印欧语系分词器。相反,我们将通过单元测试来开发它们,因为我们的分词器是启发式构建的,并预期在示例数据上表现完美——如果一个分词器未能分词一个已知案例,那么它是一个错误,而不是性能的降低。为什么是这样?有几个原因:

  • 许多分词器非常“机械”,并且易于单元测试框架的刚性。例如,RegExTokenizerFactory 显然是单元测试的候选,而不是评估工具。

  • 推动大多数分词器的启发式规则非常通用,并且不存在以部署系统为代价过度拟合训练数据的问题。如果你有一个已知的坏案例,你只需去修复分词器并添加一个单元测试即可。

  • 标记和空白被认为是语义中性的,这意味着标记不会根据上下文而改变。但我们的印欧语系分词器并非完全如此,因为它会根据上下文不同对待 .,例如,如果是十进制的一部分或在句子末尾,例如 3.14 是 pi.

    Token:'3.14'
    WhiteSpace:' '
    Token:'is'
    WhiteSpace:' '
    Token:'pi'
    WhiteSpace:''
    Token:'.'
    WhiteSpace:''.
    
    

对于基于统计的分词器,可能需要使用评估指标;这在本章的 为没有空格的语言寻找单词 菜谱中有讨论。参见 第 5 章句子检测评估 菜谱,在文本中寻找跨度 – 分块,以了解适当的基于跨度的评估技术。

如何操作...

我们将跳过运行代码步骤,直接进入源代码来构建一个分词器评估器。源代码位于 src/com/lingpipe/chapter2/TestTokenizerFactory.java。执行以下步骤:

  1. 以下代码设置了一个基于正则表达式的基分词工厂——如果你不清楚正在构建的内容,请查看该类的 Javadoc:

    public static void main(String[] args) {
      String pattern = "[a-zA-Z]+|[0-9]+|\\S";
      TokenizerFactory tokFactory = new RegExTokenizerFactory(pattern);
      String[] tokens = {"Tokenizers","need","unit","tests","."};
      String text = "Tokenizers need unit tests.";
      checkTokens(tokFactory,text,tokens);
      String[] whiteSpaces = {" "," "," ","",""};
      checkTokensAndWhiteSpaces(tokFactory,text,tokens,whiteSpaces);
      System.out.println("All tests passed!");
    }
    
  2. checkTokens 方法接受 TokenizerFactory,一个表示所需分词的 String 数组,以及要分词的 String。它遵循以下步骤:

    static void checkTokens(TokenizerFactory tokFactory, String string, String[] correctTokens) {
      Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(),0,input.length());
      String[] tokens = tokenizer.tokenize();
      if (tokens.length != correctTokens.length) {
        System.out.println("Token list lengths do not match");
        System.exit(-1);
      }
      for (int i = 0; i < tokens.length; ++i) {
        if (!correctTokens[i].equals(tokens[i])) {
          System.out.println("Token mismatch: got |" + tokens[i] + "|");
          System.out.println(" expected |" + correctTokens[i] + "|" );
          System.exit(-1);
        }
      }
    
  3. 该方法对错误非常敏感,因为如果令牌数组长度不同或任何令牌不相等,程序就会退出。JUnit这样的适当单元测试框架将是一个更好的框架,但这超出了本书的范围。您可以查看lingpipe.4.1.0/src/com/aliasi/test中的LingPipe单元测试,了解JUnit的使用方法。

  4. checkTokensAndWhiteSpaces()方法检查空白字符以及令牌。它遵循与checkTokens()相同的基本思想,所以我们不再解释。

修改分词器工厂

在这个菜谱中,我们将描述一个修改令牌流中令牌的分词器。我们将扩展ModifyTokenTokenizerFactory类以返回在英文字母表中旋转了13个位置的文本,也称为rot-13。Rot-13是一个非常简单的替换密码,用字母表中13个位置后的字母替换字母。例如,字母a将被替换为字母n,字母z将被替换为字母m。这是一个互逆密码,这意味着应用相同的密码两次可以恢复原始文本。

如何做到这一点...

我们将从命令行调用Rot13TokenizerFactory类:

java -cp "lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar" com.lingpipe.cookbook.chapter2.Rot13TokenizerFactory

type a sentence below to see the tokens and white spaces:
Move along, nothing to see here.
Token:'zbir'
Token:'nybat'
Token:','
Token:'abguvat'
Token:'gb'
Token:'frr'
Token:'urer'
Token:'.'
Modified Output: zbir nybat, abguvat gb frr urer.
type a sentence below to see the tokens and white spaces:
zbir nybat, abguvat gb frr urer.
Token:'move'
Token:'along'
Token:','
Token:'nothing'
Token:'to'
Token:'see'
Token:'here'
Token:'.'
Modified Output: move along, nothing to see here.

您可以看到,输入文本,原本是混合大小写且为正常英语,已经被转换为其Rot-13等价形式。您可以看到,第二次,我们将经过Rot-13修改后的文本作为输入,并得到了原始文本,除了它全部都是小写字母。

它是如何工作的...

Rot13TokenizerFactory扩展了ModifyTokenTokenizerFactory类。我们将重写modifyToken()方法,该方法一次操作一个令牌,在这种情况下,将令牌转换为它的Rot-13等价形式。还有一个类似的modifyWhiteSpace(String)方法,如果需要,会修改空白字符:

public class Rot13TokenizerFactory extends ModifyTokenTokenizerFactory{

  public Rot13TokenizerFactory(TokenizerFactory f) {
    super(f);
  }

  @Override
  public String modifyToken(String tok) {
    return rot13(tok);
  }

  public static void main(String[] args) throws IOException {

  TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  tokFactory = new LowerCaseTokenizerFactory(tokFactory);
  tokFactory = new Rot13TokenizerFactory(tokFactory);

令牌本身的起始和结束偏移量与底层分词器的相同。在这里,我们将使用一个印欧语系分词器作为我们的基础分词器。首先通过LowerCaseTokenizer过滤一次,然后通过Rot13Tokenizer过滤。

rot13方法如下:

public static String rot13(String input) {
  StringBuilder sb = new StringBuilder();
  for (int i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    if       (c >= 'a' && c <= 'm') c += 13;
    else if  (c >= 'A' && c <= 'M') c += 13;
    else if  (c >= 'n' && c <= 'z') c -= 13;
    else if  (c >= 'N' && c <= 'Z') c -= 13;
    sb.append(c);
  }
  return sb.toString();
}

为没有空格的语言寻找单词

例如,像中文这样的语言没有单词边界。例如,木卫三是围绕木星运转的一颗卫星,公转周期约为7天,来自维基百科的这句话在中文中大致翻译为“甘尼德在木星的卫星周围运行,轨道周期约为七天”,这是由https://translate.google.com上的机器翻译服务完成的。注意空格的缺失。

在这类数据中查找标记需要一种非常不同的方法,该方法基于字符语言模型和我们的拼写检查类。这个配方通过将未标记的文本视为拼写错误的文本来编码查找单词,其中更正插入空格以分隔标记。当然,中文、日语、越南语和其他非单词分隔的语系并没有拼写错误,但我们已经在拼写纠正类中对其进行了编码。

准备工作

我们将使用去空格的英语来近似非单词分隔的语系。这足以理解配方,并且可以很容易地修改为实际所需的语言。获取大约10万个英语单词,并将它们以UTF-8编码的方式存储到磁盘上。固定编码的原因是输入假定是UTF-8——你可以通过更改编码并重新编译配方来更改它。

我们使用了马克·吐温的《亚瑟王宫廷中的康涅狄格州扬基》,从Project Gutenberg下载(http://www.gutenberg.org/)。Project Gutenberg是公共领域文本的绝佳来源,马克·吐温是一位优秀的作家——我们强烈推荐这本书。将你的选定文本放在食谱目录中或使用我们的默认设置。

如何做到这一点...

我们将运行一个程序,稍作玩耍,并使用以下步骤解释它做什么以及它是如何做到的:

  1. 输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter2.TokenizeWithoutWhiteSpaces
    Type an Englese sentence (English without spaces like Chinese):
    TheraininSpainfallsmainlyontheplain
    
    
  2. 下面的输出是:

    The rain in Spain falls mainly on the plain
    
  3. 你可能不会得到完美的输出。马克·吐温从生成它的Java程序中恢复正确空白的能力如何?让我们来看看:

    type an Englese sentence (English without spaces like Chinese)
    NGramProcessLMlm=newNGramProcessLM(nGram);
    NGram Process L Mlm=new NGram Process L M(n Gram);
    
    
  4. 前面的方法并不很好,但我们也不是非常公平;让我们使用LingPipe的连接源作为训练数据:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter2.TokenizeWithoutWhiteSpaces data/cookbookSource.txt
    Compiling Spell Checker
    type an Englese sentence (English without spaces like Chinese)
    NGramProcessLMlm=newNGramProcessLM(nGram);
    NGramProcessLM lm = new NGramProcessLM(nGram);
    
    
  5. 这就是完美的空格插入。

它是如何工作的...

尽管有很多乐趣和游戏,但涉及的代码非常少。酷的地方在于我们正在基于第1章中的字符语言模型,简单分类器。源代码位于src/com/lingpipe/chapter2/TokenizeWithoutWhiteSpaces.java

public static void main (String[] args) throws IOException, ClassNotFoundException {
  int nGram = 5;
  NGramProcessLM lm = new NGramProcessLM(nGram);
  WeightedEditDistance spaceInsertingEditDistance
    = CompiledSpellChecker.TOKENIZING;
  TrainSpellChecker trainer = new TrainSpellChecker(lm, spaceInsertingEditDistance);

main()方法通过创建NgramProcessLM来启动。接下来,我们将访问一个用于编辑距离的类,该类旨在仅向字符流中添加空格。就是这样。Editdistance通常是一个相当粗略的字符串相似度度量,它评估需要对string1进行多少编辑才能使其与string2相同。关于这方面的许多信息可以在Javadoc com.aliasi.spell中找到。例如,com.aliasi.spell.EditDistance对基础知识进行了很好的讨论。

注意

EditDistance类实现了带有或不带有转置的标准编辑距离概念。不带转置的距离被称为Levenshtein距离,带有转置则称为Damerau-Levenstein距离。

使用LingPipe阅读Javadoc;它包含许多有用的信息,但这些信息在这本书中我们没有足够的空间来介绍。

到目前为止,我们已经配置和构建了一个 TrainSpellChecker 类。下一步是自然地对其进行训练:

File trainingFile = new File(args[0]);
String training = Files.readFromFile(trainingFile, Strings.UTF8);
training = training.replaceAll("\\s+", " ");
trainer.handle(training);

我们假设文本文件是 UTF-8 编码,将其全部读取;如果不是,则更正字符编码并重新编译。然后,我们将所有多个空白字符替换为单个空白字符。如果多个空白字符具有意义,这可能不是最好的选择。接下来是训练,就像我们在 第 1 章 中训练语言模型一样,简单分类器

接下来,我们将编译和配置拼写检查器:

System.out.println("Compiling Spell Checker");
CompiledSpellChecker spellChecker = (CompiledSpellChecker)AbstractExternalizable.compile(trainer);

spellChecker.setAllowInsert(true);
spellChecker.setAllowMatch(true);
spellChecker.setAllowDelete(false);
spellChecker.setAllowSubstitute(false);
spellChecker.setAllowTranspose(false);
spellChecker.setNumConsecutiveInsertionsAllowed(1);

下一个有趣的步骤是编译 spellChecker,它将底层语言模型中的所有计数转换为预计算的概率,这要快得多。编译步骤可以写入磁盘,因此可以在不进行训练的情况下稍后使用;然而,请参阅 AbstractExternalizable 的 Javadoc 了解如何进行此操作。接下来的几行配置 CompiledSpellChecker 以仅考虑插入字符的编辑,并检查精确字符串匹配,但禁止删除、替换和转置。最后,只允许一个插入。应该很清楚,我们正在使用 CompiledSpellChecker 的非常有限的功能,但这正是所需要的——插入空格或不插入。

最后是我们的标准 I/O 例程:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.println("type an Englese sentence (English " + "without spaces like Chinese)"));
  String input = reader.readLine();
  String result = spellChecker.didYouMean(input);
  System.out.println(result);
}

CompiledSpellCheckerWeightedEditDistance 类的机制在 Javadoc 或 第 6 章使用编辑距离和语言模型进行拼写校正 菜单中描述得更好,字符串比较和聚类。然而,基本思想是将输入的字符串与刚刚训练的语言模型进行比较,得到一个分数,显示该字符串与模型匹配得有多好。这个字符串将是一个没有空白字符的巨大单词——但请注意,这里没有分词器在工作,因此拼写检查器开始插入空格并重新评估结果的分数。它保留这些序列,其中插入空格会增加序列的分数。

记住,语言模型是在带有空白字符的文本上训练的。拼写检查器试图在可能的地方插入空格,并保留一组“迄今为止最佳”的空白字符插入。最后,它返回最佳得分的编辑序列。

注意,为了完成分词器,需要将适当的 TokenizerFactory 应用到经过空白字符修改的文本上,但这被留作读者的练习。

还有更多...

CompiledSpellChecker 允许输出 n-best 结果;这允许对文本进行多种可能的解析。在像研究搜索引擎这样的高覆盖率/召回率情况下,可能需要允许应用多种分词。此外,可以通过直接扩展 WeightedEditDistance 类来调整编辑成本。

参见

如果不提供非英语资源,那么这份食谱将不会有所帮助。我们构建并评估了一个中文分词器,使用了网络上可用的资源进行科研。我们的关于中文分词教程详细介绍了这一点。您可以在http://alias-i.com/lingpipe/demos/tutorial/chineseTokens/read-me.html找到中文分词教程。

第三章:第3章。高级分类器

在本章中,我们将涵盖以下配方:

  • 一个简单的分类器

  • 基于标记的语言模型分类器

  • 朴素贝叶斯

  • 特征提取器

  • 逻辑回归

  • 多线程交叉验证

  • 逻辑回归中的调参

  • 定制特征提取

  • 结合特征提取器

  • 分类器构建生命周期

  • 语言学调谐

  • 阈值分类器

  • 训练一点,学习一点——主动学习

  • 注释

简介

本章介绍了更复杂的分类器,这些分类器使用不同的学习技术以及关于数据的更丰富观察(特征)。我们还将讨论构建机器学习系统的最佳实践以及数据标注和减少所需训练数据量的方法。

一个简单的分类器

这个配方是一个思想实验,应该有助于阐明机器学习做什么。回想一下第1章训练自己的语言模型分类器的配方,在配方中训练自己的情感分类器。考虑一下对同一问题的保守方法可能是什么——从输入到正确类别的Map<String,String>。这个配方将探讨这可能如何工作以及可能产生的后果。

如何做到这一点...

准备好;这将是非常愚蠢但希望是有信息的。

  1. 在命令行中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.OverfittingClassifier
    
    
  2. 常见的贫血提示出现,伴随着一些用户输入:

    Training
    Type a string to be classified. Empty string to quit.
    When all else fails #Disney
    Category is: e
    
  3. 它正确地识别出语言为e或英语。然而,其他所有事情都将失败。接下来,我们将使用以下代码:

    Type a string to be classified. Empty string to quit.
    When all else fails #Disne
    Category is: n
    

    我们刚刚在#Disney上省略了最后的y,结果我们得到了一个很大的混淆分类器。发生了什么?

它是如何工作的...

这一节实际上应该被称为它不工作的方式,但无论如何让我们深入细节。

为了清楚起见,这个配方不推荐作为任何需要灵活性的分类问题的实际解决方案。然而,它引入了如何与LingPipe的Classification类一起工作的最小示例,以及清楚地说明了过度拟合的极端案例;这反过来又有助于展示机器学习与标准计算机工程的不同之处。

main()方法开始,我们将进入您从第1章中熟悉的常规代码编写:

String dataPath = args.length > 0 ? args[0] : "data/disney_e_n.csv";
List<String[]> annotatedData = Util.readAnnotatedCsvRemoveHeader(new File(dataPath));

OverfittingClassifier classifier = new OverfittingClassifier();
System.out.println("Training");
for (String[] row: annotatedData) {
  String truth = row[Util.ANNOTATION_OFFSET];
  String text = row[Util.TEXT_OFFSET];
  classifier.handle(text,new Classification(truth));
}
Util.consoleInputBestCategory(classifier);

这里没有发生任何新颖的事情——我们只是在训练一个分类器,正如第1章中所示,简单分类器,然后将分类器提供给Util.consoleInputBestCategory()方法。查看类代码可以揭示发生了什么:

public class OverfittingClassifier implements BaseClassifier<CharSequence> {

  Map<String,Classification> mMap 
         = new HashMap<String,Classification>();  

   public void handle(String text, Classification classification) {mMap.put(text, classification);
  }

因此,handle()方法接受textclassification对,并将它们放入HashMap中。分类器不会从数据中学习其他任何事情,因此训练相当于数据的记忆:

@Override
public Classification classify(CharSequence text) {
  if (mMap.containsKey(text)) {
    return mMap.get(text);
  }
  return new Classification("n");
}

classify()方法只是在Map中进行查找,如果存在则返回值,否则,我们将得到类别n作为返回分类。

前一个代码的优点在于,你有一个BaseClassifier实现的简约示例,你可以看到handle()方法是如何向分类器添加数据的。

前一个代码的缺点在于从训练数据到类别的映射过于僵化。如果训练中没有看到确切的例子,那么就假设为n类别。

这是一个过度拟合的极端例子,但它本质上传达了拥有一个过度拟合模型的意义。一个过度拟合的模型过于贴近训练数据,无法很好地推广到新数据。

让我们再深入思考一下前一个用于语言识别的分类器有什么问题——问题是整个句子/推文是错误的处理单元。单词/标记是衡量正在使用哪种语言的更好指标。在后面的食谱中将会体现的一些改进包括:

  • 将文本分解成单词/标记。

  • 而不是简单的匹配/不匹配决策,考虑一个更细致的方法。简单的“哪种语言匹配更多单词”将是一个巨大的改进。

  • 当语言越来越接近时,例如,英国英语与美国英语,可以调用概率。注意可能的区分性单词。

尽管这个食谱可能对于当前任务来说有些滑稽不合适,但考虑尝试一个更加荒谬的例子。它体现了一个计算机科学的核心假设,即输入的世界是离散且有限的。机器学习可以被视为对这样一个世界的回应。

还有更多...

令人奇怪的是,我们在商业系统中往往需要这样的分类器——我们称之为管理分类器;它在数据上预先运行。曾经发生过一位高级副总裁对系统输出的某些示例不满意的情况。这个分类器随后可以用确切的案例进行训练,以便立即修复系统并满足副总裁的需求。

基于标记的语言模型分类器

第1章简单分类器,在不知道标记/单词是什么的情况下进行了分类,每个类别都有一个语言模型——我们使用了字符切片或ngram来对文本建模。第2章查找和使用单词,详细讨论了在文本中查找标记的过程,现在我们可以使用它们来构建分类器。大多数时候,我们使用标记化输入到分类器中,所以这个食谱是概念的重要介绍。

如何实现...

这个食谱将告诉我们如何训练和使用一个分词语言模型分类器,但它将忽略诸如评估、序列化、反序列化等问题。您可以参考第1章中的食谱,简单分类器,以获取示例。这个食谱的代码在com.lingpipe.cookbook.chapter3.TrainAndRunTokenizedLMClassifier

  1. 以下代码的异常与第1章中“训练自己的语言模型分类器”食谱中找到的相同,简单分类器DynamicLMClassifier类提供了一个用于创建分词语言模型分类器的静态方法。需要一些设置。maxTokenNgram变量设置了分类器中使用的最大标记序列大小——较小的数据集通常从低阶(标记数量)ngram中受益。接下来,我们将设置一个tokenizerFactory方法,选择来自第2章的“查找和使用单词”中的工作马分类器。最后,我们将指定分类器使用的类别:

    int maxTokenNGram = 2;
    TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
    String[] categories = Util.getCategories(annotatedData);
    
  2. 接下来,构建分类器:

    DynamicLMClassifier<TokenizedLM> classifier = DynamicLMClassifier.createTokenized(categories,tokenizerFactory,maxTokenNGram);
    
  3. 通过命令行或您的IDE运行代码:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.TrainAndRunTokenizedLMClassifier
    

还有更多...

在应用中,DynamicLMClassifier分类器在商业应用中并没有得到广泛的使用。这个分类器可能是一个很好的选择,用于作者识别分类器(即,判断给定的文本是由作者还是其他人写的)对短语和精确的单词使用非常敏感。Javadoc值得咨询,以更好地了解这个类的作用。

朴素贝叶斯

朴素贝叶斯可能是世界上最著名的分类技术,为了保持你的警惕,我们提供了两个具有许多可配置性的独立实现。朴素贝叶斯分类器最著名的应用之一是用于电子邮件中的垃圾邮件过滤。

使用单词“naïve”的原因是分类器假设单词(特征)彼此独立——这显然是一个简单的假设,但许多有用和不那么有用的技术都是基于这种方法。传统朴素贝叶斯的一些显著特点包括:

  • 字符序列被转换为带有计数的标记袋。不考虑空白字符,并且标记的顺序无关紧要。

  • 朴素贝叶斯分类器需要两个或更多类别,输入文本被分类到这些类别中。这些类别必须是详尽无遗且相互排斥的。这意味着用于训练的文档必须只属于一个类别。

  • 数学非常简单:p(category|tokens) = p(category,tokens)/p(tokens)

  • 该类可以根据各种未知标记模型进行配置。

朴素贝叶斯分类器估计两件事。首先,它估计每个类别的概率,独立于任何标记。这是基于每个类别提供的训练示例的数量来执行的。其次,对于每个类别,它估计在该类别中看到每个标记的概率。朴素贝叶斯非常有用且重要,我们将向您展示它的工作原理以及如何使用公式。我们的例子是根据文本对炎热和寒冷天气进行分类。

首先,我们将计算出给定词序列的类别概率。其次,我们将插入一个示例,然后使用我们构建的分类器进行验证。

准备工作

让我们列出基本公式来计算给定文本输入的类别概率。基于标记的朴素贝叶斯分类器计算联合标记计数和类别概率如下:

p(tokens,cat) = p(tokens|cat) * p(cat)
  1. 条件概率是通过应用贝叶斯定理来反转概率计算得到的:

    p(cat|tokens) = p(tokens,cat) / p(tokens)
                   = p(tokens|cat) * p(cat) / p(tokens)
    
  2. 现在,我们将扩展所有这些术语。如果我们看p(tokens|cat),这就是朴素假设发挥作用的地方。我们假设每个标记是独立的,因此所有标记的概率是每个标记概率的乘积:

    p(tokens|cat) = p(tokens[0]|cat) * p(tokens[1]|cat) * . . . * p(tokens[n]|cat)
    

    标记本身的概率,即p(tokens),是前面方程中的分母。这仅仅是它们在每个类别中的概率之和,并按该类别的概率本身进行加权:

    p(tokens) = p(tokens|cat1) * p(cat1) + p(tokens|cat2) * p(cat2) + . . . + p(tokens|catN) * p(catN)
    

    注意

    当构建朴素贝叶斯分类器时,p(tokens)不需要显式计算。相反,我们可以使用p(tokens|cat) * p(cat)并将标记分配给具有更高乘积的类别。

  3. 现在我们已经列出了方程的每个元素,我们可以看看这些概率是如何计算的。我们可以使用简单的频率来计算这两个概率。

    一个类别的概率是通过计算该类别在训练实例中出现的次数除以训练实例的总数来计算的。正如我们所知,朴素贝叶斯分类器具有穷尽性和互斥性类别,因此每个类别的频率之和必须等于训练实例的总数:

    p(cat) = frequency(cat) / (frequency(cat1) + frequency(cat2) + . . . + frequency(catN))
    

    一个类别中标记的概率是通过计算该标记在类别中出现的次数除以所有其他标记在该类别中出现的次数来计算的:

    p(token|cat) = frequency(token,cat)/(frequency(token1,cat) + frequency(token2,cat) + . . . + frequency(tokenN,cat)
    

    这些概率被计算出来以提供所谓的最大似然估计的模型。不幸的是,这些估计为在训练期间未看到的标记提供了零概率。你可以在计算未看到标记的概率中非常容易地看到这一点。由于它没有被看到,它将有一个频率计数为0,并且我们原始方程中的分子将变为0。

    为了克服这一点,我们将使用一种称为平滑的技术,它分配一个先验,然后计算最大后验估计而不是最大似然估计。一个非常常见的平滑技术称为加性平滑,它只是将先验计数添加到训练数据中的每个计数。添加了两组计数:第一组是添加到所有标记频率计算中的标记计数,第二组是类别计数,它添加到所有类别计数计算中。

    这显然改变了p(cat)p(token|cat)的值。让我们称添加到类别计数中的alpha先验和添加到标记计数中的beta先验为alpha。当我们调用alpha先验时,我们的先前计算将变为:

    p(cat) = frequency(cat) + alpha / [(frequency(cat1) + alpha) + (frequency(cat2)+alpha) + . . . + (frequency(catN) + alpha)]
    

    当我们调用beta先验时,计算将变为:

    p(token|cat) = (frequency(token,cat)+beta) / [(frequency(token1,cat)+beta) + frequency(token2,cat)+beta) + . . . + (frequency(tokenN,cat) + beta)]
    
  4. 现在我们已经建立了方程,让我们看看一个具体的例子。

    我们将构建一个分类器,根据一组短语来分类预报是热天还是冷天:

    hot : super steamy today
    hot : boiling out
    hot : steamy out
    
    cold : freezing out
    cold : icy
    

    这五个训练项中总共有七个标记:

    • super

    • steamy

    • today

    • boiling

    • out

    • freezing

    • icy

    在这些中,所有标记都出现了一次,除了steamyhot类别中出现了两次,以及out在每个类别中都出现了一次。这是我们训练数据。现在,让我们计算输入文本属于hotcold类别的概率。假设我们的输入是单词super。让我们将类别先验alpha设置为1,将标记先验beta也设置为1

  5. 因此,我们将计算p(hot|super)p(cold|super)的概率:

    p(hot|super) = p(super|hot) * p(hot)/ p(super)
    
    p(super|hot) = (freq(super,hot) + beta) / [(freq(super|hot)+beta) + (freq(steamy|hot) + beta) + . . . + (freq(freezing|hot)+beta)
    

    我们将考虑所有标记,包括在hot类别中尚未见过的标记:

    freq(super|hot) + beta = 1 + 1 = 2
    freq(steamy|hot) + beta = 2 + 1 = 3
    freq(today|hot) + beta = 1 + 1 = 2
    freq(boiling|hot) + beta = 1 + 1 = 2
    freq(out|hot) + beta = 1 + 1 = 2
    freq(freezing|hot) + beta = 0 + 1 = 1
    freq(icy|hot) + beta = 0 + 1 = 1
    

    这将给我们一个等于这些输入之和的分母:

    2+3+2+2+2+1+1 = 13
    
  6. 现在,p(super|hot) = 2/13是方程的一部分。我们还需要计算p(hot)p(super)

    p(hot) = (freq(hot) + alpha) / 
                        ((freq(hot) + alpha) + freq(cold)+alpha)) 
    

    对于hot类别,我们有三个文档或案例,而对于cold类别,我们在训练数据中有两个文档。因此,freq(hot) = 3freq(cold) = 2

    p(hot) = (3 + 1) / (3 + 1) + (2 +1) = 4/7
    Similarly p(cold) = (2 + 1) / (3 + 1) + (2 +1) = 3/7
    Please note that p(hot) = 1 – p(cold)
    
    p(super) = p(super|hot) * p(hot) + p(super|cold) + p(cold)
    

    要计算p(super|cold),我们需要重复相同的步骤:

    p(super|cold) = (freq(super,cold) + beta) / [(freq(super|cold)+beta) + (freq(steamy|cold) + beta) + . . . + (freq(freezing|cold)+beta)
    
    freq(super|cold) + beta = 0 + 1 = 1
    freq(steamy|cold) + beta = 0 + 1 = 1
    freq(today|cold) + beta = 0 + 1 = 1
    freq(boiling|cold) + beta = 0 + 1 = 1
    freq(out|cold) + beta = 1 + 1 = 2
    freq(freezing|cold) + beta = 1 + 1 = 2
    freq(icy|cold) + beta = 1 + 1 = 2
    
    p(super|cold) = freq(super|cold)+beta/sum of all terms above
    
                  = 0 + 1 / (1+1+1+1+2+2+2) = 1/10
    

    这给我们带来了标记super的概率:

    P(super) = p(super|hot) * p(hot) + p(super|cold) * p(cold)
             = 2/13 * 4/7 + 1/10 * 3/7
    

    现在我们已经拥有了计算p(hot|super)p(cold|super)的所有部分:

    p(hot|super) = p(super|hot) * p(hot) / p(super)
                 = (2/13 * 4/7) / (2/13 * 4/7 + 1/10 * 3/7)
    
                 = 0.6722
    p(cold|super) = p(super|cold) * p(cold) /p(super)
                 = (1/10 * 3/7) / (2/13 * 4/7 + 1/10 * 3/7)
                 = 0.3277
    
    Obviously, p(hot|super) = 1 – p(cold|super)
    

    如果我们想要对输入流super super重复此操作,可以使用以下计算:

    p(hot|super super) = p(super super|hot) * p(hot) / p(super super)
                 = (2/13 * 2/13 * 4/7) / (2/13 * 2/13 * 4/7 + 1/10 * 1/10 * 3/7)
                 = 0.7593
    p(cold|super super) = p(super super|cold) * p(cold) /p(super super)
                 = (1/10 * 1/10 * 3/7) / (2/13 * 2/13 * 4/7 + 1/10 * 1/10 * 3/7)
                 = 0.2406
    

    记住我们的朴素假设:标记的概率是概率的乘积,因为我们假设它们彼此独立。

让我们通过训练朴素贝叶斯分类器并使用相同的输入来验证我们的计算:

如何做...

让我们在代码中验证一些这些计算:

  1. 在你的IDE中,运行本章代码包中的TrainAndRunNaiveBayesClassifier类,或者使用命令行,输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.TrainAndRunNaiveBayesClassifier
    
    
  2. 在提示中,让我们使用我们的第一个例子,super

    Type a string to be classified
    super
    h 0.67   
    c 0.33   
    
  3. 如我们所见,我们的计算是正确的。对于在训练中不存在的单词hello的情况,我们将回退到由类别先验计数修改的类别的普遍性:

    Type a string to be classified
    hello
    h 0.57   
    c 0.43
    
  4. 再次,对于super super的情况,我们的计算是正确的。

    Type a string to be classified
    super super
    
    
    h 0.76   
    c 0.24    
    
  5. 生成前面输出的源代码在src/com/lingpipe/chapter3/TrainAndRunNaiveBays.java。代码应该是直截了当的,所以我们不会在这个配方中涵盖它。

参考内容

特征提取器

到目前为止,我们一直使用字符和单词来训练我们的模型。我们将介绍一个分类器(逻辑回归),它允许对数据进行其他观察,以告知分类器——例如,一个单词是否实际上是一个日期。特征提取器在CRF标记器和K-means聚类中使用。这个配方将介绍独立于任何使用它们的技术的特征提取器。

如何操作...

这个配方没有太多内容,但即将到来的逻辑回归配方有很多组成部分,这是其中之一。

  1. 启动您的IDE或输入命令行:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter3.SimpleFeatureExtractor
    
    
  2. 在我们的标准I/O循环中输入一个字符串:

    Type a string to see its features
    My first feature extraction!
    
  3. 然后产生特征:

    !=1
    My=1
    extraction=1
    feature=1
    first=1
    
  4. 注意,这里没有顺序信息。它是否保持计数?

    Type a string to see its features
    My my my what a nice feature extractor.
    my=2
    .=1
    My=1
    a=1
    extractor=1
    feature=1
    nice=1
    what=1
    
  5. 特征提取器使用my=2进行计数,并且它不规范化大小写(Mymy不同)。请参考本章后面的配方,了解如何修改特征提取器——它们非常灵活。

它是如何工作的…

LingPipe为创建特征提取器提供了坚实的基础设施。这个配方的代码在src/com/lingipe/chapter3/SimpleFeatureExtractor.java

public static void main(String[] args) throws IOException {
  TokenizerFactory tokFact 
    = IndoEuropeanTokenizerFactory.INSTANCE;
  FeatureExtractor<CharSequence> tokenFeatureExtractor 
    = new TokenFeatureExtractor(tokFact);

上述代码使用TokenizerFactory构建了TokenFeatureExtractor。它是LingPipe提供的13个FeatureExtractor实现之一。

接下来,我们将应用I/O循环并打印出特征,它是Map<String, ? extends Number>String元素是特征名称。在这种情况下,实际的标记是名称。映射的第二个元素是一个扩展Number的值,在这种情况下,是标记在文本中出现的次数。

BufferedReader reader 
  = new BufferedReader(new   InputStreamReader(System.in));
while (true) {
  System.out.println("\nType a string to see its features");
  String text = reader.readLine();
  Map<String, ? extends Number > features 
    = tokenFeatureExtractor.features(text);
  System.out.println(features);
}

特征名称只需要是一个唯一的名称——我们可以在每个特征名称前加上SimpleFeatExt_以跟踪特征来源,这在复杂的特征提取场景中很有帮助。

逻辑回归

逻辑回归可能是大多数工业分类器的主要责任,可能除外的是朴素贝叶斯分类器。它几乎肯定是目前表现最好的分类器之一,尽管代价是训练速度慢,配置和调整相当复杂。

逻辑回归也被称为最大熵、单神经元神经网络分类以及其他名称。到目前为止,本书中的分类器都是基于底层字符或标记,但逻辑回归使用无限制的特征提取,这允许将任意观察到的情境编码到分类器中。

这个方法与http://alias-i.com/lingpipe/demos/tutorial/logistic-regression/read-me.html上的更完整的教程非常相似。

逻辑回归的工作原理

逻辑回归所做的只是对数据中的特征权重向量应用一个系数向量,并进行一些简单的数学运算,从而得到训练过程中遇到的每个类别的概率。复杂之处在于确定系数应该是什么。

以下是我们为21条标注为英语e和非英语n的推文训练的某些特征。由于我们的先验将特征权重推到0.0,因此特征相对较少,一旦权重为0.0,则删除该特征。请注意,一个类别n被设置为0.0,对于n-1类别的所有特征——这是逻辑回归过程的一个属性,一旦将类别特征固定为0.0,就调整所有其他类别特征相对于该值:

FEATURE    e          n
I :   0.37    0.0
! :   0.30    0.0
Disney :   0.15    0.0
" :   0.08    0.0
to :   0.07    0.0
anymore : 0.06    0.0
isn :   0.06    0.0
' :   0.06    0.0
t :   0.04    0.0
for :   0.03    0.0
que :   -0.01    0.0
moi :   -0.01    0.0
_ :   -0.02    0.0
, :   -0.08    0.0
pra :   -0.09    0.0
? :   -0.09    0.0

以字符串“I luv Disney”为例,它将只有两个非零特征:I=0.37Disney=0.15对于e,而n为0。由于没有与luv匹配的特征,它被忽略。推文是英语的概率分解如下:

vectorMultiply(e,[I,Disney]) = exp(.371 + .151) = 1.68

vectorMultiply(n,[I,Disney]) = exp(01 + 01) = 1

我们将通过求和结果并除以总数来将其转换为概率:

p(e|,[I,Disney]) = 1.68/(1.68 +1) = 0.62

p(e|,[I,Disney]) = 1/(1.68 +1) = 0.38

这就是运行逻辑回归模型时数学工作的方式。训练是另一个完全不同的问题。

准备工作

这个方法假设了我们一直在使用的相同框架,从.csv文件中获取训练数据,训练分类器,并在命令行中运行它。

设置训练分类器相对复杂,因为训练中使用的参数和对象数量众多。我们将讨论com.lingpipe.cookbook.chapter3.TrainAndRunLogReg中训练方法的10个参数。

main() 方法从应该熟悉的类和方法开始——如果它们不熟悉,请查看 如何使用交叉验证进行训练和评估介绍到介绍分词器工厂——在字符流中查找单词,这些是从 第1章简单分类器 和 [第2章](part0027_split_000.html#page "第2章。查找和使用单词),查找和使用单词* 中摘录的食谱:

public static void main(String[] args) throws IOException {
  String trainingFile = args.length > 0 ? args[0] 
           : "data/disney_e_n.csv";
  List<String[]> training 
    = Util.readAnnotatedCsvRemoveHeader(new File(trainingFile));

  int numFolds = 0;
  XValidatingObjectCorpus<Classified<CharSequence>> corpus 
    = Util.loadXValCorpus(training,numFolds);

  TokenizerFactory tokenizerFactory 
    = IndoEuropeanTokenizerFactory.INSTANCE;

注意,当我们可以使用更简单的实现,如 ListCorpus 时,我们正在使用 XValidatingObjectCorpus。我们不会利用其交叉验证的任何功能,因为 numFolds 参数设置为 0 将导致训练访问整个语料库。我们试图将新类别的数量保持在最低,而且我们通常在实际工作中总是使用这种实现。

现在,我们将开始为我们的分类器构建配置。FeatureExtractor<E> 接口提供了从数据到特征的映射;这将被用于训练和运行分类器。在这种情况下,我们使用 TokenFeatureExtractor() 方法,该方法基于在构建时提供的分词器找到的标记创建特征。这类似于朴素贝叶斯推理。前面的食谱更详细地介绍了特征提取器正在做什么,如果这还不清楚的话:

FeatureExtractor<CharSequence> featureExtractor
  = new TokenFeatureExtractor(tokenizerFactory);

minFeatureCount 项目通常设置为一个大于1的数字,但在小型训练集中,这是获得任何性能所必需的。过滤特征计数的想法是,逻辑回归倾向于过度拟合低计数的特征,这些特征只是偶然存在于训练数据的一个类别中。随着训练数据的增长,minFeatureCount 值通常通过关注交叉验证性能来调整:

int minFeatureCount = 1;

addInterceptFeature 布尔值控制是否存在一个类别特征,该特征用于建模训练中该类别的普遍性。截距特征的默认名称是 *&^INTERCEPT%$^&**,如果正在使用它,你将在权重向量输出中看到它。按照惯例,对于所有输入,截距特征被设置为 1.0。其想法是,如果一个类别非常普遍或非常罕见,应该有一个特征来捕捉这一事实,而与其他可能分布不干净的其它特征无关。这种方式在朴素贝叶斯中某种意义上建模了类别概率,但逻辑回归算法将决定它作为所有其他特征一样有用:

boolean addInterceptFeature = true;
boolean noninformativeIntercept = true;

这些布尔值控制如果使用截距特征时会发生什么。在下面的代码中,先验通常不应用于截距特征;这是如果此参数为真的结果。将布尔值设置为 false,先验将应用于截距。

接下来是 RegressionPrior 实例,它控制模型如何拟合。您需要知道的是,先验有助于通过将系数推向 0 来防止逻辑回归过度拟合数据。存在一个非信息性先验,它不会这样做,结果是如果有一个只适用于一个类别的特征,它将被缩放到无穷大,因为模型在数值估计中增加系数时拟合得越来越好。在这个上下文中,先验充当了一种方式,以避免对世界的观察过于自信。

RegressionPrior 实例的另一个维度中是特征的预期方差。低方差会更有力地将系数推向零。静态 laplace() 方法返回的先验通常对 NLP 问题很有用。有关这里发生情况的更多信息,请参阅相关的 Javadoc 和食谱开头引用的逻辑回归教程——这里有很多事情发生,但无需深入的理论理解就可以管理。此外,请参阅本章中的“逻辑回归中的参数调整”食谱。

double priorVariance = 2;
RegressionPrior prior 
  = RegressionPrior.laplace(priorVariance,
          noninformativeIntercept);

接下来,我们将控制算法搜索答案的方式。

AnnealingSchedule annealingSchedule
  = AnnealingSchedule.exponential(0.00025,0.999);
double minImprovement = 0.000000001;
int minEpochs = 100;
int maxEpochs = 2000;

通过查阅 Javadoc 可以更好地理解 AnnealingSchedule,但它所做的就是改变在拟合模型时允许系数变化的程度。minImprovement 参数设置模型拟合必须改进的量,以避免终止搜索,因为算法已经收敛。minEpochs 参数设置最小迭代次数,而 maxEpochs 设置搜索没有根据 minImprovement 确定的收敛时上限。

接下来是一段允许进行基本报告/记录的代码。LogLevel.INFO 将报告分类器在尝试收敛过程中的大量信息:

PrintWriter progressWriter = new PrintWriter(System.out,true);
progressWriter.println("Reading data.");
Reporter reporter = Reporters.writer(progressWriter);
reporter.setLevel(LogLevel.INFO);  

这是我们最复杂的课程之一“准备”部分的结束——接下来,我们将训练并运行分类器。

如何做到这一点...

设置训练和运行此类已经做了一些工作。我们将只通过步骤来使其运行;即将到来的食谱将解决其调整和评估:

  1. 注意,还有一个更复杂的 14 参数的 train 方法以及扩展可配置性的一个方法。这是 10 参数版本:

    LogisticRegressionClassifier<CharSequence> classifier
        = LogisticRegressionClassifier.
            <CharSequence>train(corpus,
            featureExtractor,
            minFeatureCount,
            addInterceptFeature,
            prior,
            annealingSchedule,
            minImprovement,
            minEpochs,
            maxEpochs,
            reporter);
    
  2. 根据 LogLevel 常量,train() 方法将根据 LogLevel.NONE 从无到 LogLevel.ALL 的巨大输出产生。

  3. 虽然我们不会使用它,但我们展示了如何将训练好的模型序列化到磁盘。第 1 章,“如何序列化 LingPipe 对象 – 分类器示例”食谱解释了这里发生的情况:

    AbstractExternalizable.compileTo(classifier,
      new File("models/myModel.LogisticRegression"));
    
  4. 训练完成后,我们将使用以下标准分类循环应用:

    Util.consoleInputPrintClassification(classifier);
    
  5. 在您选择的 IDE 中运行前面的代码或使用命令行命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.TrainAndRunLogReg
    
    
  6. 结果是关于训练的大量信息:

    Reading data.
    :00 Feature Extractor class=class com.aliasi.tokenizer.TokenFeatureExtractor
    :00 min feature count=1
    :00 Extracting Training Data
    :00 Cold start
    :00 Regression callback handler=null
    :00 Logistic Regression Estimation
    :00 Monitoring convergence=true
    :00 Number of dimensions=233
    :00 Number of Outcomes=2
    :00 Number of Parameters=233
    :00 Number of Training Instances=21
    :00 Prior=LaplaceRegressionPrior(Variance=2.0, noninformativeIntercept=true)
    :00 Annealing Schedule=Exponential(initialLearningRate=2.5E-4, base=0.999)
    :00 Minimum Epochs=100
    :00 Maximum Epochs=2000
    :00 Minimum Improvement Per Period=1.0E-9
    :00 Has Informative Prior=true
    :00 epoch=    0 lr=0.000250000 ll=   -20.9648 lp= -232.0139 llp=  -252.9787 llp*=  -252.9787
    :00 epoch=    1 lr=0.000249750 ll=   -20.9406 lp= -232.0195 llp=  -252.9602 llp*=  -252.9602
    
  7. epoch 报告会持续进行,直到达到指定的 epoch 数量或搜索收敛。在以下情况下,达到了 epoch 数量:

    :00 epoch= 1998 lr=0.000033868 ll=   -15.4568 lp=  -233.8125 llp=  -249.2693 llp*=  -249.2693
    :00 epoch= 1999 lr=0.000033834 ll=   -15.4565 lp=  -233.8127 llp=  -249.2692 llp*=  -249.2692
    
  8. 现在,我们可以稍微玩一下这个分类器:

    Type a string to be classified. Empty string to quit.
    I luv Disney
    Rank  Category  Score  P(Category|Input)
    0=e 0.626898085027528 0.626898085027528
    1=n 0.373101914972472 0.373101914972472
    
  9. 这看起来很熟悉;这正是菜谱开头的工作示例的结果。

就这样!你已经训练并使用了世界上最具相关性的工业分类器。然而,要充分利用这个巨兽的力量还有很多。

多线程交叉验证

交叉验证(参考第 1 章 如何使用交叉验证进行训练和评估 菜谱,简单分类器),可能会非常慢,这会干扰系统调优。这个菜谱将向你展示一种简单但有效的方法,以访问系统上所有可用的核心,以便更快地处理每个折叠。

如何操作...

这个菜谱在下一个菜谱的上下文中解释了多线程交叉验证,所以不要因为同一个类被重复而感到困惑。

  1. 启动你的 IDE 或在命令行中输入命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.TuneLogRegParams
    
    
  2. 系统随后会响应以下输出(你可能需要滚动到窗口顶部):

    Reading data.
    RUNNING thread Fold 5 (1 of 10)
    RUNNING thread Fold 9 (2 of 10)
    RUNNING thread Fold 3 (3 of 10)
    RUNNING thread Fold 4 (4 of 10)
    RUNNING thread Fold 0 (5 of 10)
    RUNNING thread Fold 2 (6 of 10)
    RUNNING thread Fold 8 (7 of 10)
    RUNNING thread Fold 6 (8 of 10)
    RUNNING thread Fold 7 (9 of 10)
    RUNNING thread Fold 1 (10 of 10)
    reference\response
              \e,n,
             e 11,0,
             n 6,4,
    
  3. 默认的训练数据是 21 条标注为英语 e 和非英语 n 的推文。在前面的输出中,我们看到了作为线程运行的每个折叠的报告和结果混淆矩阵。就是这样!我们刚刚完成了多线程交叉验证。让我们看看它是如何工作的。

它是如何工作的…

所有操作都在 Util.xvalLogRegMultiThread() 方法中发生,我们从 src/com/lingpipe/cookbook/chapter3/TuneLogRegParams.java 调用此方法。TuneLogRegParams 的细节将在下一个菜谱中介绍。这个菜谱将专注于 Util 方法:

int numThreads = 2;
int numFolds = 10;
Util.xvalLogRegMultiThread(corpus,
        featureExtractor,
        minFeatureCount,
        addInterceptFeature,
        prior,
        annealingSchedule,
        minImprovement,
        minEpochs,
        maxEpochs,
        reporter,
        numFolds,
        numThreads,
        categories);

用于配置逻辑回归的所有 10 个参数都是可控制的(你可以参考前面的菜谱进行解释),包括 numFolds,它控制将有多少个折叠,numThreads,它控制可以同时运行多少个线程,以及最后的 categories

如果我们查看 src/com/lingpipe/cookbook/Util.java 中的相关方法,我们会看到:

public static <E> ConditionalClassifierEvaluator<E> xvalLogRegMultiThread(
    final XValidatingObjectCorpus<Classified<E>> corpus,
    final FeatureExtractor<E> featureExtractor,
    final int minFeatureCount, 
    final boolean addInterceptFeature,
    final RegressionPrior prior, 
    final AnnealingSchedule annealingSchedule,
    final double minImprovement, 
    final int minEpochs, final int maxEpochs,
    final Reporter reporter, 
    final int numFolds, 
    final int numThreads, 
    final String[] categories) {
  1. 方法从为逻辑回归配置信息匹配的参数开始,运行交叉验证。由于交叉验证最常用于系统调优,所有相关部分都暴露出来以供修改。一切都是最终的,因为我们使用匿名内部类来创建线程。

  2. 接下来,我们将设置 crossFoldEvaluator,它将收集每个线程的结果:

    corpus.setNumFolds(numFolds);
    corpus.permuteCorpus(new Random(11211));
    final boolean storeInputs = true;
    final ConditionalClassifierEvaluator<E> crossFoldEvaluator
      = new ConditionalClassifierEvaluator<E>(null, categories, storeInputs);
    
  3. 现在,我们将着手为每个折叠创建线程,i

    List<Thread> threads = new ArrayList<Thread>();
    for (int i = 0; i < numFolds; ++i) {
      final XValidatingObjectCorpus<Classified<E>> fold 
        = corpus.itemView();
      fold.setFold(i);
    

    XValidatingObjectCorpus 类通过创建用于读取的线程安全版本的数据集,通过 itemView() 方法设置为多线程访问。此方法返回一个可以设置折叠的数据集,但不能添加数据。

    每个线程都是一个 runnable 对象,其中折叠的训练和评估的实际工作在 run() 方法中处理:

    Runnable runnable 
      = new Runnable() {
        @Override
        public void run() {
        try {
          LogisticRegressionClassifier<E> classifier
            = LogisticRegressionClassifier.<E>train(fold,
                    featureExtractor,
                    minFeatureCount,
                    addInterceptFeature,
                    prior,
                    annealingSchedule,
                    minImprovement,
                    minEpochs,
                    maxEpochs,
                    reporter);
    

    在此代码中,我们首先训练分类器,这反过来又需要一个 try/catch 语句来处理 LogisticRegressionClassifier.train() 方法抛出的 IOException。接下来,我们将创建 withinFoldEvaluator,它将在线程中填充,而不会出现同步问题:

    ConditionalClassifierEvaluator<E> withinFoldEvaluator 
      = new ConditionalClassifierEvaluator<E>(classifier, categories, storeInputs);
    fold.visitTest(withinFoldEvaluator);
    

    确保 storeInputstrue 非常重要,这样可以将折叠结果添加到 crossFoldEvaluator

    addToEvaluator(withinFoldEvaluator,crossFoldEvaluator);
    

    此方法,同样在 Util 中,遍历每个类别的所有真实正例和假负例,并将它们添加到 crossFoldEvaluator。请注意,这是同步的:这意味着一次只有一个线程可以访问该方法,但由于分类已经完成,这不应该成为瓶颈:

    public synchronized static <E> void addToEvaluator(BaseClassifierEvaluator<E> foldEval, ScoredClassifierEvaluator<E> crossFoldEval) {
      for (String category : foldEval.categories()) {
       for (Classified<E> classified : foldEval.truePositives(category)) {
        crossFoldEval.addClassification(category,classified.getClassification(),classified.getObject());
       }
       for (Classified<E> classified : foldEval.falseNegatives(category)) {
        crossFoldEval.addClassification(category,classified.getClassification(),classified.getObject());
       }
      }
     }
    

    该方法将每个类别的真实正例和假负例添加到 crossFoldEval 评估器中。这些基本上是复制操作,计算起来不费时。

  4. 返回到 xvalLogRegMultiThread,我们将处理异常并将完成的 Runnable 添加到我们的 Thread 列表中:

        catch (Exception e) {
          e.printStackTrace();
        }
      }
    };
    threads.add(new Thread(runnable,"Fold " + i));
    
  5. 在设置好所有线程后,我们将调用 runThreads() 并打印出由此产生的混淆矩阵。我们不会深入探讨 runThreads() 的来源,因为它只是简单的 Java 线程管理,而 printConfusionMatrix第 1 章简单分类器 中已经介绍过:

    
      runThreads(threads,numThreads); 
      printConfusionMatrix(crossFoldEvaluator.confusionMatrix());
    }
    

这就是加快多核机器上交叉验证速度的全部内容。在调整系统时,这可以产生很大的影响。

调整逻辑回归中的参数

逻辑回归提供了一系列令人畏惧的参数来调整以获得更好的性能,与之合作有点像黑魔法。我们已经构建了数千个这样的分类器,但我们仍在学习如何做得更好。这个配方将指引你走向一般正确的方向,但这个主题可能值得一本自己的书。

如何做到这一点...

这个配方涉及到对 src/com/lingpipe/chapter3/TuneLogRegParams.java 源代码的广泛修改。我们在这里只运行它的一个配置,大部分解释都在 它是如何工作的… 部分中。

  1. 启动你的 IDE 或在命令行中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.TuneLogRegParams
    
    
  2. 系统随后会响应默认数据 data/disney_e_n.csv 的交叉验证输出混淆矩阵:

    reference\response
              \e,n,
             e 11,0,
             n 6,4,
    
  3. 接下来,我们将报告每个类别的假正例——这将涵盖所有错误:

    False Positives for e
    ES INSUPERABLE DISNEY !! QUIERO VOLVER:( : n
    @greenath_ t'as de la chance d'aller a Disney putain : n 
    jamais été moi. : n
    @HedyHAMIDI au quartier pas a Disney moi: n
    …
    
  4. 此输出之后是特征、它们的系数和计数——记住,我们将看到 n-1 个类别,因为其中一个类别的特征被设置为所有特征的 0.0

    Feature coefficients for category e
    I : 0.36688604
    ! : 0.29588525
    Disney : 0.14954419
    " : 0.07897427
    to : 0.07378086
    …
    Got feature count: 113
    
  5. 最后,我们有我们的标准输入/输出,允许测试示例:

    Type a string to be classified
    I luv disney
    Rank  Category  Score  P(Category|Input)
    0=e 0.5907060507161321 0.5907060507161321
    1=n 0.40929394928386786 0.40929394928386786
    
  6. 这是我们将要工作的基本结构。在接下来的章节中,我们将更深入地探讨参数变化的影响。

它是如何工作的…

本菜谱假设你已经熟悉从两个菜谱之前的逻辑回归训练和配置以及交叉验证,这是之前的菜谱。代码的整体结构以提纲形式呈现,保留了调整参数。将讨论如何修改每个参数将在菜谱的后面部分——下面我们开始从main()方法开始,忽略一些由...指示的代码,并显示用于分词和特征提取的可调整代码:

public static void main(String[] args) throws IOException {
    …
  TokenizerFactory tokenizerFactory 
     = IndoEuropeanTokenizerFactory.INSTANCE;
  FeatureExtractor<CharSequence> featureExtractor
     = new TokenFeatureExtractor(tokenizerFactory);
  int minFeatureCount = 1;
  boolean addInterceptFeature = false;

接下来设置先验概率:

  boolean noninformativeIntercept = true;
  double priorVariance = 2 ;
  RegressionPrior prior 
    = RegressionPrior.laplace(priorVariance,
            noninformativeIntercept);

先验概率对行为系数分配有强烈的影响:

  AnnealingSchedule annealingSchedule
    = AnnealingSchedule.exponential(0.00025,0.999);
  double minImprovement = 0.000000001;
  int minEpochs = 10;
  int maxEpochs = 20;

之前的代码控制着逻辑回归的搜索空间:

Util.xvalLogRegMultiThread(corpus,…);

之前的代码运行交叉验证以查看系统表现如何——注意省略的参数使用...表示。

在以下代码中,我们将折数设置为0,这将使训练方法访问整个语料库:

corpus.setNumFolds(0);
LogisticRegressionClassifier<CharSequence> classifier
  = LogisticRegressionClassifier.<CharSequence>train(corpus,…

然后,对于每个类别,我们将打印出刚刚训练好的分类器的特征及其系数:

int featureCount = 0;
for (String category : categories) {
  ObjectToDoubleMap<String> featureCoeff 
    = classifier.featureValues(category);
  System.out.println("Feature coefficients for category " 
        + category);
  for (String feature : featureCoeff.keysOrderedByValueList()) {
    System.out.print(feature);
    System.out.printf(" :%.8f\n",featureCoeff.getValue(feature));
    ++featureCount;
  }
}
System.out.println("Got feature count: " + featureCount);

最后,我们将有常规的控制台分类器输入/输出:

Util.consoleInputPrintClassification(classifier);    

调整特征提取

输入到逻辑回归中的特征对系统的性能有巨大影响。我们将在后面的菜谱中更详细地介绍特征提取,但在这里我们将使用一种非常实用且有些反直觉的方法,因为它非常容易执行——使用字符n-gram而不是单词/标记。让我们看一个例子:

Type a string to be classified. Empty string to quit.
The rain in Spain
Rank  Category  Score  P(Category|Input)
0=e 0.5 0.5
1=n 0.5 0.5

此输出表明,分类器在e英语和n非英语之间做出决策。通过滚动回查看特征,我们将看到输入中的任何单词都没有匹配项。在英语方面有一些子串匹配。The对于特征词the有子串he。对于语言识别,考虑子串是有意义的,但根据经验,它对于情感和其他问题也可能有很大的帮助。

将分词器修改为两到四个字符的n-gram如下:

int min = 2;
int max = 4;
TokenizerFactory tokenizerFactory 
  = new NGramTokenizerFactory(min,max);

这导致了适当的区分:

Type a string to be classified. Empty string to quit.
The rain in Spain
Rank  Category  Score  P(Category|Input)
0=e 0.5113903651380305 0.5113903651380305
1=n 0.4886096348619695 0.4886096348619695

在交叉验证中的整体性能略有下降。对于非常小的训练集,例如21条推文,这是预料之中的。通常,通过查看错误的样子以及查看假阳性,交叉验证的性能将有助于指导这个过程。

在查看假阳性时,很明显Disney是问题之源,因为特征上的系数表明它是英语的证据。一些假阳性如下:

False Positives for e
@greenath_ t'as de la chance d'aller a Disney putain j'y ai jamais été moi. : n
@HedyHAMIDI au quartier pas a Disney moi : n
Prefiro gastar uma baba de dinheiro pra ir pra cancun doq pra Disney por exemplo : n

以下是为e的特征:

Feature coefficients for category e
I : 0.36688604
! : 0.29588525
Disney : 0.14954419
" : 0.07897427
to : 0.07378086

在没有更多训练数据的情况下,应该删除特征!Disney"以帮助此分类器表现更好,因为这些特征都不是语言特定的,而Ito是,尽管它们不是英语特有的。这可以通过过滤数据或创建适当的分词器工厂来完成,但最好的办法可能是获取更多数据。

当数据量很大,且你不想逻辑回归专注于非常低频的现象,因为它往往会引起过拟合时,minFeature计数变得有用。

addInterceptFeature参数设置为true将添加一个始终触发的特征。这将允许逻辑回归具有对每个类别的示例数量敏感的特征。这并不是类别的边缘似然,因为逻辑回归会像任何其他特征一样调整权重——但是以下先验展示了如何进一步调整:

de : -0.08864114
( : -0.10818647
*&^INTERCEPT%$^&** : -0.17089337

最终,截距是n的最强特征,在这种情况下,整体交叉验证性能有所下降。

先验

先验的作用是限制逻辑回归完美拟合训练数据的倾向。我们使用的那些先验在程度上试图将系数推向零。我们将从nonInformativeIntercept先验开始,它控制截距特征是否受到先验的归一化影响——如果为真,则截距不受先验影响,这在先前的例子中就是这样。将其设置为false使其从-0.17移动到接近零:

*&^INTERCEPT%$^&** : -0.03874782

接下来,我们将调整先验的方差。这为权重设定了一个预期的变化。低方差意味着系数预计不会从零变化很大。在先前的代码中,方差被设置为2。这是将其设置为.01的结果:

Feature coefficients for category e
' : -0.00003809
Feature coefficients for category n

这是从方差为2的104个特征下降到方差为.01的一个特征,因为一旦一个特征下降到0,它就会被移除。

增加方差将使我们的前e个特征从2增加到4

Feature coefficients for category e
I : 0.36688604
! : 0.29588525
Disney : 0.14954419

I : 0.40189501
! : 0.31387376
Disney : 0.18255271

这总共是119个特征。

考虑一个方差为2gaussian先验:

boolean noninformativeIntercept = false;
double priorVariance = 2;
RegressionPrior prior 
  = RegressionPrior.gaussian(priorVariance,
    noninformativeIntercept);

我们将得到以下输出:

I : 0.38866670
! : 0.27367013
Disney : 0.22699340

奇怪的是,我们花很少的时间担心使用哪个先验,但方差在性能中起着重要作用,因为它可以快速减少特征空间。拉普拉斯是NLP应用中普遍接受的先验。

请参阅Javadoc和逻辑回归教程以获取更多信息。

退火计划和时期

随着逻辑回归收敛,退火计划控制着搜索空间的探索和终止方式:

AnnealingSchedule annealingSchedule
    = AnnealingSchedule.exponential(0.00025,0.999);
  double minImprovement = 0.000000001;
  int minEpochs = 10;
  int maxEpochs = 20;

在调整时,如果搜索过程耗时过长,我们将按数量级(.0025,.025,...)增加退火计划的第一参数——通常,我们可以增加训练速度而不会影响交叉验证性能。此外,minImprovement值可以增加,以便收敛结束得更快,这既可以增加训练速度,又可以防止模型过拟合——这被称为提前停止。再次强调,在这种情况下,你的指导方针是查看在做出更改时的交叉验证性能。

实现收敛所需的epoch数可能会相当高,所以如果分类器正在迭代到maxEpochs -1,这意味着需要更多的epoch才能收敛。确保设置reporter.setLevel(LogLevel.INFO);属性或更详细的信息级别以获取收敛报告。这是强制提前停止的另一种方式。

参数调整是一门只能通过实践学习的黑艺术。训练数据的质量和数量可能是分类器性能的主要因素,但调整也可以产生重大影响。

自定义特征提取

逻辑回归允许使用任意特征。特征是关于正在分类的数据可以做出的任何观察。以下是一些例子:

  • 文本中的单词/标记。

  • 我们发现,在代替单词或词干的情况下,字符n-gram工作得非常好。对于小于10,000个训练单词的小数据集,我们将使用2-4个gram。更大的训练数据可能需要更长的gram,但我们从未在超过8-gram字符的情况下获得良好的结果。

  • 来自另一个组件的输出可以是特征,例如,一个词性标注器。

  • 关于文本已知的元数据,例如,推文的地点或创建时间。

  • 从实际值中抽象出的日期和数字的识别。

如何做……

此菜谱的源代码位于src/com/lingpipe/cookbook/chapter3/ContainsNumberFeatureExtractor.java

  1. 特征提取器很容易构建。以下是一个返回带有权重1CONTAINS_NUMBER特征的提取器:

    public class ContainsNumberFeatureExtractor implements FeatureExtractor<CharSequence> {
      @Override
      public Map<String,Counter> features(CharSequence text) {
             ObjectToCounterMap<String> featureMap 
             = new ObjectToCounterMap<String>();
        if (text.toString().matches(".*\\d.*")) {
          featureMap.set("CONTAINS_NUMBER", 1);
        }
        return featureMap;  }
    
  2. 通过添加main()方法,我们可以测试特征提取器:

    public static void main(String[] args) {
      FeatureExtractor<CharSequence> featureExtractor 
             = new ContainsNumberFeatureExtractor();
      System.out.println(featureExtractor.features("I have a number 1"));
    }
    
  3. 现在运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.ContainsNumberFeatureExtractor
    
    
  4. 上述代码产生以下输出:

    CONTAINS_NUMBER=1
    
    

就这样。下一个菜谱将向您展示如何组合特征提取器。

还有更多……

设计特征有点像一门艺术。逻辑回归在面临无关特征时应该很稳健,但用真正愚蠢的特征来压倒它可能会降低性能。

思考你需要哪些特征的一种方式是思考文本或环境中哪些证据有助于你,作为人类,决定正确的分类。尝试在查看文本时忽略你的世界知识。如果世界知识,即法国是一个国家,很重要,那么尝试用 gazetteer 来模拟这种世界知识,以生成CONTAINS_COUNTRY_MENTION

注意,特征是字符串,唯一的概念是精确的字符串匹配。12:01pm特征与12:02pm完全不同,尽管对人类来说,这些字符串非常接近,因为我们理解时间。要获取这两个特征之间的相似度,你必须有像LUNCH_TIME这样的特征,它是使用时间计算出来的。

组合特征提取器

特征提取器可以像 第 2 章 中的分词器一样组合,查找和使用单词

如何操作...

本食谱将向您展示如何将前一个食谱中的特征提取器与一个非常常见的基于字符 n-gram 的特征提取器相结合。

  1. 我们将在 src/com/lingpipe/cookbook/chapter3/CombinedFeatureExtractor.java 中的 main() 方法开始,我们将使用它来运行特征提取器。以下行设置了使用 LingPipe 类 TokenFeatureExtractor 通过分词器生成的特征:

    public static void main(String[] args) {
       int min = 2;
      int max = 4;
      TokenizerFactory tokenizerFactory 
         = new NGramTokenizerFactory(min,max);
      FeatureExtractor<CharSequence> tokenFeatures 
    = new TokenFeatureExtractor(tokenizerFactory);
    
  2. 然后,我们将从前一个食谱中构建特征提取器。

    FeatureExtractor<CharSequence> numberFeatures 
    = new ContainsNumberFeatureExtractor();
    
  3. 接下来,LingPipe 类 AddFeatureExtractor 将两个特征提取器合并为一个第三个:

    FeatureExtractor<CharSequence> joinedFeatureExtractors 
      = new AddFeatureExtractor<CharSequence>(
              tokenFeatures,numberFeatures);
    
  4. 剩余的代码获取特征并打印出来:

    String input = "show me 1!";
    Map<String,? extends Number> features 
       = joinedFeatureExtractors.features(input);
    System.out.println(features);
    
  5. 运行以下命令

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.CombinedFeatureExtractor
    
    
  6. 输出看起来像这样:

    {me =1.0,  m=1.0, me 1=1.0, e =1.0, show=1.0,  me =1.0, ho=1.0, ow =1.0, e 1!=1.0, sho=1.0,  1=1.0, me=1.0, how =1.0, CONTAINS_NUMBER=1.0, w me=1.0,  me=1.0, how=1.0,  1!=1.0, sh=1.0, ow=1.0, e 1=1.0, w m=1.0, ow m=1.0, w =1.0, 1!=1.0}
    
    

还有更多...

Javadoc 引用了广泛的功能提取器和组合器/过滤器,以帮助管理特征提取的任务。这个类的一个稍微令人困惑的方面是,FeatureExtractor 接口位于 com.aliasi.util 包中,而实现类都在 com.aliasi.features

分类器构建生命周期

在顶级构建中,分类器通常按以下方式进行:

  1. 创建训练数据——有关更多信息,请参阅以下食谱。

  2. 使用健全性检查构建训练和评估基础设施。

  3. 建立基线性能。

  4. 选择分类器的优化指标——这是分类器试图完成的事情,并将指导调整。

  5. 通过以下技术优化分类器:

    • 参数调整

    • 阈值

    • 语言学调整

    • 添加训练数据

    • 精炼分类器定义

本食谱将以具体术语展示前四个步骤,本章中还有针对优化步骤的食谱。

准备工作

没有训练数据,分类器不会发生任何作用。查看本章末尾的 Annotation 食谱,以获取创建训练数据的技巧。您还可以使用主动学习框架逐步生成训练语料库(本章后面将介绍),这是本食谱中使用的数据。

接下来,通过从最愚蠢的可能实现开始来降低风险,以确保所解决的问题范围正确,整体架构合理。用简单的代码将假设的输入连接到假设的输出。我们保证,大多数情况下,其中一个或另一个将不是你所想的。

本食谱假设您熟悉 第 1 章 中介绍的评价概念,如交叉验证和混淆矩阵,以及迄今为止涵盖的逻辑回归食谱。

整个源代码位于 src/com/lingpipe/cookbook/chapter3/ClassifierBuilder.java

这个食谱还假设你可以在你首选的开发环境中编译和运行代码。我们做出的所有更改的结果在src/com/lingpipe/cookbook/chapter3/ClassifierBuilderFinal.java中。

注意

在这个食谱中有一个大前提——我们使用一个非常小的数据集来在分类器构建上阐述基本观点。我们试图构建的情感分类器将从10倍更多的数据中受益。

如何做到这一点…

我们从一组去重后的推文开始,这些推文是接下来这个食谱中将要遵循的“训练一点,学习一点——主动学习”食谱的结果。食谱的起点是以下代码:

public static void main(String[] args) throws IOException {
  String trainingFile = args.length > 0 ? args[0] 
    : "data/activeLearningCompleted/"
    + "disneySentimentDedupe.2.csv";
  int numFolds = 10;
  List<String[]> training 
    = Util.readAnnotatedCsvRemoveHeader(new File(trainingFile));
  String[] categories = Util.getCategories(training);
  XValidatingObjectCorpus<Classified<CharSequence>> corpus 
  = Util.loadXValCorpus(training,numFolds);
TokenizerFactory tokenizerFactory 
  = IndoEuropeanTokenizerFactory.INSTANCE;
PrintWriter progressWriter = new PrintWriter(System.out,true);
Reporter reporter = Reporters.writer(progressWriter);
reporter.setLevel(LogLevel.WARN);
boolean storeInputs = true;
ConditionalClassifierEvaluator<CharSequence> evaluator 
    = new ConditionalClassifierEvaluator<CharSequence>(null, categories, storeInputs);
corpus.setNumFolds(0);
LogisticRegressionClassifier<CharSequence> classifier = Util.trainLogReg(corpus, tokenizerFactory, progressWriter);
evaluator.setClassifier(classifier);
System.out.println("!!!Testing on training!!!");
Util.printConfusionMatrix(evaluator.confusionMatrix());
}

理性检查 – 在训练数据上测试

首先要做的事情是让系统运行起来,并在训练数据上测试:

  1. 我们留下了一条打印语句,宣传正在发生的事情:

    System.out.println("!!!Testing on training!!!");
    corpus.visitTrain(evaluator);
    
  2. 运行ClassifierBuilder将产生以下结果:

    !!!Testing on training!!!
    reference\response
              \p,n,o,
             p 67,0,3,
             n 0,30,2,
             o 2,1,106,
    
  3. 前面的混淆矩阵几乎是一个完美的系统输出,这验证了系统基本上是正常工作的。这是你将看到的最好的系统输出;永远不要让管理层看到它,否则他们会认为这种性能水平是可以做到的或者已经做到了。

使用交叉验证和指标建立基线

现在是时候看看真正发生的事情了。

  1. 如果你数据量小,那么将折数设置为10,这样就有90%的数据用于训练。如果你数据量大或者时间紧迫,那么将其设置为2

    static int NUM_FOLDS = 10;
    
  2. 取消注释或删除测试数据上的训练代码:

    //System.out.println("!!!Testing on training!!!");
    //corpus.visitTrain(evaluator);
    
  3. 插入一个交叉验证循环或者只是取消注释我们源代码中的循环:

    corpus.setNumFolds(numFolds);
    for (int i = 0; i < numFolds; ++i) {
     corpus.setFold(i);
      LogisticRegressionClassifier<CharSequence> classifier 
         = Util.trainLogReg(corpus, tokenizerFactory, progressWriter);
      evaluator.setClassifier(classifier);
     corpus.visitTest(evaluator);
    }
    
  4. 重新编译并运行代码将给出以下输出:

    reference\response
              \p,n,o,
             p 45,8,17,
             n 16,13,3,
             o 18,3,88,
    
  5. 分类器的标签意味着p=positiveSentimentn=negativeSentiment,和o=other,这涵盖了其他语言或中性情感。混淆矩阵的第一行表明,系统得到了45个真正的正例,8个它认为是n的假阴性,以及17个它认为是o的假阴性:

    reference\response
          \p,n,o,
        p 45,8,17,
    
  6. 要获取p的假阳性,我们需要查看第一列。我们看到系统认为16n注释是p18o注释是p

    reference\response
              \p,
             p 45
             n 16
             o 18
    

    小贴士

    混淆矩阵是查看/展示分类器结果最诚实和直接的方式。性能指标如精确度、召回率、F度量、准确度都非常难以捉摸,并且经常被错误地使用。在展示结果时,始终准备好混淆矩阵,因为如果我们是听众或者类似的人,我们都会要求看到它。

  7. 对其他类别执行相同的分析,你将有一个系统性能的评估。

选择一个单一指标进行优化

执行以下步骤:

  1. 虽然混淆矩阵建立了分类器的整体性能,但它太复杂,不能用作调整指南。你不想每次调整一个特征时都要消化整个矩阵。你和你的团队必须同意一个单一数字,如果它上升,系统就被认为是更好的。以下指标适用于二元分类器;如果有超过两个类别,那么你将不得不以某种方式将它们相加。我们看到的常见指标包括:

    • F-measure:F-measure是尝试同时奖励减少误负和误正:

      F-measure = 2TP / (2TP + FP + FN)

      这主要是一个学术指标,用来声明一个系统比另一个系统更好。在工业界几乎很少使用。

    • 在90%精确度下的召回率:目标是尽可能提供覆盖范围,同时不超过10%的误报。这是系统不希望频繁出错的时刻;这适用于拼写检查器、问答系统和情感仪表板。

    • 在99.9%召回率下的精确度:这个指标支持“大海捞针”或“针尖上的针堆”这类问题。用户不能容忍错过任何信息,并且愿意忍受大量的误报,只要不错过任何信息。如果误报率较低,系统会更好。用例包括情报分析师和医学研究人员。

  2. 确定这个指标来源于业务/研究需求、技术能力、可用资源和意志力的混合。如果客户想要高召回率和高精确度的系统,我们首先会问每个文档的预算是多少。如果预算足够高,我们会建议雇佣专家来纠正系统输出,这是计算机擅长(全面性)和人类擅长(辨别力)的最佳结合。通常,预算不支持这一点,因此平衡行为开始了,但我们确实以这种方式部署了系统。

  3. 对于这个配方,我们将选择在50%精确度下最大化召回率,对于n(负面),因为我们想确保拦截任何负面情绪,并且可以容忍误报。我们将选择65%的p(正面),因为好消息的可操作性较低,而且谁不喜欢迪士尼呢?我们不在乎o(其他性能)是什么——这个类别存在是出于语言原因,与业务用途无关。这是一个可能的情感仪表板应用指标。这意味着系统将为每个负面情绪类别的两个猜测犯一个错误,以及为正面情绪的20个猜测犯13个错误。

实现评估指标

执行以下步骤以实现评估指标:

  1. 在打印出混淆矩阵后,我们将使用Util.printPrecRecall方法报告所有类别的精确度/召回率:

    Util.printConfusionMatrix(evaluator.confusionMatrix());
    Util.printPrecRecall(evaluator);
    
    
  2. 输出现在将看起来像这样:

    reference\response
              \p,n,o,
             p 45,8,17,
             n 16,13,3,
             o 18,3,88,
    Category p
    Recall: 0.64
    Prec  : 0.57
    Category n
    Recall: 0.41
    Prec  : 0.54
    Category o
    Recall: 0.81
    Prec  : 0.81
    
  3. n的精度超过了我们的目标.5——因为我们希望在.5处最大化召回率,我们可以在达到极限之前犯一些错误。你可以参考阈值分类器配方来了解如何做到这一点。

  4. p的精度为57%,这对于我们的业务目标来说太低了。然而,逻辑回归分类器提供了一种条件概率,这可能允许我们仅通过关注概率来满足精度需求。添加以下代码行将允许我们按条件概率排序查看结果:

    Util.printPRcurve(evaluator);
    
    
  5. 上一行代码首先从评估器中获取一个ScoredPrecisionRecallEvaluation值。从这个对象中获取一个双评分曲线([][]),布尔插值设置为false,因为我们希望曲线保持纯净。你可以查看Javadoc了解具体发生了什么。然后,我们将使用同一类中的打印路由来打印出曲线。输出将看起来像这样:

    reference\response
              \p,n,o,
             p 45,8,17,
             n 16,13,3,
             o 18,3,88,
    Category p
    Recall: 0.64
    Prec  : 0.57
    Category n
    Recall: 0.41
    Prec  : 0.54
    Category o
    Recall: 0.81
    Prec  : 0.81
    PR Curve for Category: p
      PRECI.   RECALL    SCORE
    0.000000 0.000000 0.988542
    0.500000 0.014286 0.979390
    0.666667 0.028571 0.975054
    0.750000 0.042857 0.967286
    0.600000 0.042857 0.953539
    0.666667 0.057143 0.942158
    0.571429 0.057143 0.927563
    0.625000 0.071429 0.922381
    0.555556 0.071429 0.902579
    0.600000 0.085714 0.901597
    0.636364 0.100000 0.895898
    0.666667 0.114286 0.891566
    0.615385 0.114286 0.888831
    0.642857 0.128571 0.884803
    0.666667 0.142857 0.877658
    0.687500 0.157143 0.874135
    0.647059 0.157143 0.874016
    0.611111 0.157143 0.871183
    0.631579 0.171429 0.858999
    0.650000 0.185714 0.849296
    0.619048 0.185714 0.845691
    0.636364 0.200000 0.810079
    0.652174 0.214286 0.807661
    0.666667 0.228571 0.807339
    0.640000 0.228571 0.799474
    0.653846 0.242857 0.753967
    0.666667 0.257143 0.753169
    0.678571 0.271429 0.751815
    0.655172 0.271429 0.747515
    0.633333 0.271429 0.745660
    0.645161 0.285714 0.744455
    0.656250 0.300000 0.738555
    0.636364 0.300000 0.736310
    0.647059 0.314286 0.705090
    0.628571 0.314286 0.694125
    
  6. 输出按分数排序,在第三列,在这种情况下,恰好是条件概率,所以最大值是1,最小值是0。注意,随着正确案例的发现(第二行),召回率会增长,并且它永远不会下降。然而,当出现错误,如第四行时,精度会下降到.6,因为到目前为止有3个案例是正确的。实际上,在找到最后一个值之前,精度实际上已经低于.65——在粗体中,分数为.73

  7. 因此,没有任何调整,我们可以报告,在65%的接受精度极限下,我们可以实现30%的召回率p。这要求我们将分类器在该类别上的阈值设置为.73,这意味着如果我们拒绝小于.73p分数,一些评论是:

    • 我们很幸运。通常情况下,第一次分类器的运行不会立即揭示一个有用的阈值,默认值通常是不够的。

    • 逻辑回归分类器有一个非常好的特性,它们提供;它们还提供用于阈值化的条件概率估计。并非所有分类器都有这个特性——语言模型和朴素贝叶斯分类器倾向于将分数推向0或1,这使得阈值化变得困难。

    • 由于训练数据高度偏差(这是来自后续的训练一点,学习一点——主动学习配方),我们无法信任这个阈值。分类器必须指向新鲜数据来设置阈值。请参考阈值分类器配方了解如何进行。

    • 这个分类器看到的数据非常少,尽管有支持性的评估,但不会是一个好的部署候选。我们更愿意至少有来自不同日期的1,000条推文。

在这个处理阶段,我们要么通过验证在新鲜数据上的性能是否可接受来接受结果,要么通过本章其他配方中涵盖的技术来改进分类器。配方的最后一步是在所有训练数据上训练分类器并将其写入磁盘:

corpus.setNumFolds(0);
LogisticRegressionClassifier<CharSequence> classifier 
  = Util.trainLogReg(corpus, tokenizerFactory, progressWriter);
AbstractExternalizable.compileTo(classifier, 
  new File("models/ClassifierBuilder.LogisticRegression"));

我们将在Thresholding classifiers配方中使用生成的模型。

语言学调优

此配方将通过关注系统犯的错误,通过调整参数和特征进行语言调整来处理围绕调整分类器的问题。我们将继续使用前一个配方中的情感用例,并使用相同的数据。我们将从一个新的类src/com/lingpipe/cookbook/chapter3/LinguisticTuning.java开始。

我们的数据非常少。在现实世界中,我们将坚持需要更多的训练数据——至少需要100个最小的类别,即负类别,并且需要正类别和其他类别的自然分布。

如何操作...

我们将直接运行一些数据——默认是data/activeLearningCompleted/disneySentimentDedupe.2.csv,但您可以在命令行中指定自己的文件。

  1. 在您的命令行或IDE等效环境中运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.LinguisticTuning
    
    
  2. 对于每个折,将打印出分类器的特征。每个类别的输出将如下所示(仅显示每个类别的几个特征):

    Training on fold 0
    ######################Printing features for category p NON_ZERO 
    ?: 0.52
    !: 0.41
    love: 0.37
    can: 0.36
    my: 0.36
    is: 0.34
    in: 0.29
    of: 0.28
    I: 0.28
    old: 0.26
    me: 0.25
    My: 0.25
    ?: 0.25
    wait: 0.24
    ?: 0.23
    an: 0.22
    out: 0.22
    movie: 0.22
    ?: 0.21
    movies: 0.21
    shirt: 0.21
    t: 0.20
    again: 0.20
    Princess: 0.19
    i: 0.19 
    …
    ######################Printing features for category o NON_ZERO 
    :: 0.69
    /: 0.52
    *&^INTERCEPT%$^&**: 0.48
    @: 0.41
    *: 0.36
    (: 0.35
    …
    ######################Printing features for category n ZERO
    
  3. n类别开始,请注意没有特征。这是逻辑回归的一个特性,即一个类别的所有特征都设置为0.0,而剩余的n-1个类别的特征相应地偏移。这无法控制,这有点令人烦恼,因为n或负类别可能会成为语言调优的焦点,考虑到它在示例中的表现非常糟糕。我们不会气馁,将继续前进。

  4. 注意输出旨在便于使用find命令在大量的报告输出中定位特征输出。要查找特征,请在category <feature name>上进行搜索,以查看是否存在非零报告,在category <feature name> NON_ZERO上进行搜索。

  5. 在这些特性中,我们寻找几个要点。首先,显然有一些奇特的特性得到了高分——输出按类别从正到负排序。我们想要寻找的是特征权重中的某些信号——因此“爱”与积极的情感相关是有意义的。查看这类特性可能会非常令人惊讶且反直觉。大写字母I和小写字母i暗示文本应该转换为小写。我们将进行这一更改并看看是否有所帮助。我们当前的性能如下:

    Category p
    Recall: 0.64
    Prec  : 0.57
    
  6. 代码更改是在当前的IndoEuropeanTokenizerFactory类中添加一个LowerCaseTokenizerFactory项:

    TokenizerFactory tokenizerFactory 
      = IndoEuropeanTokenizerFactory.INSTANCE;
    tokenizerFactory = new   LowerCaseTokenizerFactory(tokenizerFactory);
    
  7. 运行代码,我们将提高一些精确度和召回率:

    Category p
    Recall: 0.69
    Prec  : 0.59
    
  8. 特征如下:

    Training on fold 0
    ######################Printing features for category p NON_ZERO 
    ?: 0.53
    my: 0.49
    love: 0.43
    can: 0.41
    !: 0.39
    i: 0.35
    is: 0.31
    of: 0.28
    wait: 0.27
    old: 0.25
    ♥: 0.24
    an: 0.22
    
  9. 下一步是什么?minFeature计数非常低,为1。让我们将其提高到2并看看会发生什么:

    Category p
    Recall: 0.67
    Prec  : 0.58
    
  10. 这导致性能下降几个案例,因此我们将返回到1。然而,经验表明,随着更多数据的发现,最小计数会上升以防止过拟合。

  11. 是时候加入秘密调料了——将分词器更改为NGramTokenizer;它通常比标准分词器效果更好——我们现在正在使用以下代码:

    TokenizerFactory tokenizerFactory 
      = new NGramTokenizerFactory(2,4);
    tokenizerFactory 
    = new LowerCaseTokenizerFactory(tokenizerFactory);
    
  12. 这有效了。我们将挑选一些更多的情况:

    Category p
    Recall: 0.71
    Prec  : 0.64
    
  13. 然而,现在的特征非常难以扫描:

    #########Printing features for category p NON_ZERO 
    ea: 0.20
    !!: 0.20
    ov: 0.17
    n : 0.16
    ne: 0.15
     ?: 0.14
    al: 0.13
    rs: 0.13
    ca: 0.13
    ! : 0.13
    ol: 0.13
    lo: 0.13
     m: 0.13
    re : 0.12
    so: 0.12
    i : 0.12
    f : 0.12
     lov: 0.12 
    
  14. 我们发现,随着时间的推移,字符n-gram是文本分类问题中首选的特征。它们似乎几乎总是有帮助,而且在这里也很有帮助。看看特征,你可以恢复出love仍然在以小块的方式贡献,例如lovovlo

  15. 另一种值得注意的方法是,IndoEuropeanTokenizerFactory生成的某些标记很可能无用,它们只是混淆了问题。使用停用词表,关注更有用的分词,并可能应用像Porter词干提取器这样的词干提取器可能也会有效。这是处理这类问题的传统方法——我们从未在这方面取得过太多成功。

  16. 现在是检查n类别性能的好时机;我们一直在调整模型,应该检查它:

    Category n
    Recall: 0.41
    Prec  : 0.72
    
  17. 输出还报告了pn的误报。我们真的不太关心o,除非它作为其他类别的误报出现:

    False Positives for p
    *<category> is truth category
    
    I was really excited for Disney next week until I just read that it's "New Jersey" week. #noooooooooo
     p 0.8434727204351016
     o 0.08488521562829848
    *n 0.07164206393660003
    
    "Why worry? If you've done the best you can, worrying won't make anything better." ~Walt Disney
     p 0.4791823543407749
    *o 0.3278392260935065
     n 0.19297841956571868
    
  18. 查看误报情况,我们可以建议对特征提取进行修改。识别来自~华特·迪士尼的引语可能有助于分类器使用IS_DISNEY_QUOTE

  19. 此外,查看错误可以指出注释中的错误,有人可能会认为以下内容实际上是正面的:

    Cant sleep so im watching.. Beverley Hills Chihuahua.. Yep thats right, I'm watching a Disney film about talking dogs.. FML!!!
     p 0.6045997587907997
     o 0.3113342571409484
    *n 0.08406598406825164
    

    到目前为止,系统已经进行了某种程度的调整。应将配置保存在某个地方,并考虑下一步。它们包括以下内容:

    • 宣布胜利并部署。在部署之前,务必使用所有训练数据在新型数据上进行测试。阈值分类器食谱将非常有用。

    • 标注更多数据。使用以下食谱中的主动学习框架来帮助识别错误和正确的高置信度案例。这可能会比其他任何方法更有助于性能,特别是对于低计数数据,如我们一直在处理的数据。

    • 查看纪元报告,系统从未自行收敛。将限制提高到10,000并看看这是否有帮助。

    我们调整努力的成果是提高性能,从:

    reference\response
              \p,n,o,
             p 45,8,17,
             n 16,13,3,
             o 18,3,88,
    Category p
    Recall: 0.64
    Prec  : 0.57
    Category n
    Recall: 0.41
    Prec  : 0.54
    Category o
    Recall: 0.81
    Prec  : 0.81
    

    到以下:

    reference\response
              \p,n,o,
             p 50,3,17,
             n 14,13,5,
             o 14,2,93,
    Category p
    Recall: 0.71
    Prec  : 0.64
    Category n
    Recall: 0.41
    Prec  : 0.72
    Category o
    Recall: 0.85
    Prec  : 0.81
    

    这不是在查看一些数据和思考如何帮助分类器完成其工作的情况下,以换取性能提升的坏交易。

阈值分类器

逻辑回归分类器通常使用阈值而不是提供的classifier.bestCategory()方法进行部署。该方法选择具有最高条件概率的类别,在一个三分类器中,这个概率可能略高于三分之一。本食谱将向您展示如何通过显式控制最佳类别的确定方式来调整分类器性能。

本食谱将考虑具有pno标签的三分类情况,并使用本章前面提到的分类器构建生命周期食谱中产生的分类器。产生的交叉验证评估结果如下:

Category p
Recall: 0.64
Prec  : 0.57
Category n
Recall: 0.41
Prec  : 0.54
Category o
Recall: 0.81
Prec  : 0.81

我们将运行新的数据来设置阈值。

如何做到这一点...

我们的业务用例是,在分类器构建生命周期中讨论的原因下,p具有.65的精确度,n具有.5的精确度,同时最大化召回率。在这种情况下,o类别并不重要。p类别似乎精确度太低,为.57,而n类别可以在精确度高于.5的情况下提高召回率。

  1. 我们不能使用交叉验证结果,除非已经注意到了产生适当的注释分布——所使用的主动学习方法往往不会产生这样的分布。即使有良好的分布,由于分类器很可能经过交叉验证进行调优,因此它很可能过度拟合到该数据集,因为调优决策是为了最大化那些不适用于新数据的集合并不是一般性的性能。

  2. 我们需要将训练好的分类器指向新的数据——一般来说,无论通过什么方式都要进行训练,但始终在新鲜数据上设置阈值。我们遵循了第1章中“从Twitter API获取数据”的食谱,简单分类器,并使用disney查询从Twitter下载了新的数据。自从我们最初的搜索以来,已经过去将近一年,因此推文很可能不会重叠。结果得到的1,500条推文被放入data/freshDisney.csv

  3. 确保不要在未备份的数据上运行此代码。I/O相对简单而不是健壮。代码会覆盖输入文件。

  4. 在你的IDE中调用RunClassifier或运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3/RunClassifier
    Data is: data/freshDisney.csv model is: models/ClassifierBuilder.LogisticRegression
    No annotations found, not evaluating
    writing scored output to data/freshDisney.csv
    
    
  5. 在你的最喜欢的电子表格中打开.csv文件。所有推文都应该有一个分数和一个猜测的类别,格式为标准注释格式。

  6. 按照升序或降序对GUESS列进行主要排序,然后按SCORE降序排序。结果应该是每个类别的分数从高到低。这是我们设置自上而下注释的方式。如何做到这一点...

    设置用于自上而下注释的数据排序。所有类别都分组在一起,并建立分数的降序排序。

  7. 对于你关心的类别,在这个例子中是pn,从最高得分标注到最低得分,直到很可能已经达到了精度目标。例如,标注n直到你用完n的猜测,或者你有足够的错误使得精度达到.50。错误是指真实值为op。对于p也做同样的操作,直到你达到.65的精度,或者用完p的数量。在我们的示例中,我们将标注放在了data/freshDisneyAnnotated.csv

  8. 运行以下命令或你IDE中的等效命令(注意,我们提供了输入文件,而没有使用默认设置):

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3/RunClassifier data/freshDisneyAnnotated.csv
    
    
  9. 这个命令将产生以下输出:

    Data is: data/freshDisneyAnnotated.csv model is: models/ClassifierBuilder.LogisticRegression
    reference\response
     \p,n,o,
     p 141,25,0,
     n 39,37,0,
     o 51,28,0,
    Category p
    Recall: 0.85
    Prec  : 0.61
    Category n
    Recall: 0.49
    Prec  : 0.41
    Category o
    Recall: 0.00
    Prec  : NaN
    
    
  10. 首先,这是一个令人惊讶的好系统性能,对于我们的最少训练分类器来说。p非常接近没有阈值时的目标精度.65,而且覆盖率也不错:在1500条推文中找到了141个真实正例。由于我们没有标注所有1500条推文,我们无法真正地说出分类器的召回率是多少,所以这个术语在常见用法中被过度使用。n类别表现不佳,但仍然相当不错。我们的标注没有对o类别进行标注,所以系统列都是零。

  11. 接下来,我们将查看用于阈值指导的精度/召回/得分曲线:

    PR Curve for Category: p
      PRECI.   RECALL    SCORE
    1.000000 0.006024 0.976872
    1.000000 0.012048 0.965248
    1.000000 0.018072 0.958461
    1.000000 0.024096 0.947749
    1.000000 0.030120 0.938152
    1.000000 0.036145 0.930893
    1.000000 0.042169 0.928653
    …
    0.829268 0.204819 0.781308
    0.833333 0.210843 0.777209
    0.837209 0.216867 0.776252
    0.840909 0.222892 0.771287
    0.822222 0.222892 0.766425
    0.804348 0.222892 0.766132
    0.808511 0.228916 0.764918
    0.791667 0.228916 0.761848
    0.795918 0.234940 0.758419
    0.780000 0.234940 0.755753
    0.784314 0.240964 0.755314
    …
    0.649746 0.771084 0.531612
    0.651515 0.777108 0.529871
    0.653266 0.783133 0.529396
    0.650000 0.783133 0.528988
    0.651741 0.789157 0.526603
    0.648515 0.789157 0.526153
    0.650246 0.795181 0.525740
    0.651961 0.801205 0.525636
    0.648780 0.801205 0.524874
    
  12. 为了节省空间,前面的输出中省略了大多数值。我们注意到,分类器达到.65精度的点得分为.525。这意味着,如果我们以.525为阈值,我们可以期望达到65%的精度,但有一些注意事项:

    • 这是一个没有置信度估计的单点样本。有更复杂的方法来确定阈值,但这超出了本食谱的范围。

    • 时间是性能方差的一个大因素。

    • 在实践中,对于开发良好的分类器,性能的10%方差并不罕见。将这一点纳入性能要求中。

  13. 前面的曲线的优点在于,如果我们决定需要更高的精度,那么在.76的阈值下,我们可以提供一个.80精度的分类器,并且几乎覆盖了.65精度分类器的30%。

  14. n的情况有一个看起来像这样的曲线:

    PR Curve for Category: n
      PRECI.   RECALL    SCORE
    1.000000 0.013158 0.981217
    0.500000 0.013158 0.862016
    0.666667 0.026316 0.844607
    0.500000 0.026316 0.796797
    0.600000 0.039474 0.775489
    0.500000 0.039474 0.768295
    …
    0.468750 0.197368 0.571442
    0.454545 0.197368 0.571117
    0.470588 0.210526 0.567976
    0.485714 0.223684 0.563354
    0.500000 0.236842 0.552538
    0.486486 0.236842 0.549950
    0.500000 0.250000 0.549910
    0.487179 0.250000 0.547843
    0.475000 0.250000 0.540650
    0.463415 0.250000 0.529589
    
  15. 看起来阈值.549就能完成任务。接下来的食谱将展示如何设置阈值分类器,现在我们已经有了阈值。

RunClassifier.java背后的代码在本章的上下文中没有提供任何新颖之处,所以留给你去实现。

它是如何工作的…

目标是创建一个分类器,如果某个推文的该类别得分为.525以上,则将其分配为p;如果得分为.549以上,则分配为n;否则,分配为o。错误……管理层看到了p/r曲线,现在坚持认为p必须达到80%的精度,这意味着阈值将是.76

解决方案非常简单。如果 p 的分数低于 .76,则将其重新评分到 0.0。同样,如果 n 的分数低于 .54,则将其重新评分到 0.0。这种效果是,对于所有低于阈值的案例,o 将是最佳类别,因为 .75p 最多是 .25n,这仍然低于 n 的阈值,而 .53n 最多是 .47p,这低于该类别的阈值。如果所有类别都设置了阈值,或者阈值很低,这可能会变得复杂。

回顾一下,我们正在处理一个条件分类器,其中所有类别的分数必须加起来为 1,并打破这个合同,因为我们将对任何低于 .76p 估计进行下推到 0.0。对于 n 也是如此。结果分类器现在将必须是 ScoredClassifier,因为这是 LingPipe API 中我们可以维持的下一个最具体的合同。

这个类的代码位于 src/com/lingpipe/cookbook/chapter3/ThresholdedClassifier。在顶层,我们有类、相关成员变量和构造函数:

public class ThresholdedClassifier<E> implements  ScoredClassifier<E> {

  ConditionalClassifier<E> mNonThresholdedClassifier;

  public ThresholdedClassifier (ConditionalClassifier<E> classifier) {
    mNonThresholdedClassifier = classifier;
  }

接下来,我们将实现 ScoredClassification 所需的唯一方法,这就是魔法发生的地方:

@Override
public ScoredClassification classify(E input) {
  ConditionalClassification classification 
    = mNonThresholdedClassifier.classify(input);
  List<ScoredObject<String>> scores 
      = new ArrayList<ScoredObject<String>>();
  for (int i = 0; i < classification.size(); ++i) {
    String category = classification.category(i);
    Double score = classification.score(i);
     if (category.equals("p") && score < .76d) {
       score = 0.0;
     }
    if (category.equals("n") && score < .549d) {
       score = 0.0;
     }
     ScoredObject<String> scored 
      = new ScoredObject<String>(category,score);
    scores.add(scored);
  }
  ScoredClassification thresholded 
    = ScoredClassification.create(scores);
  return thresholded;
}

关于评分分类的复杂之处在于,即使分数是 0.0,也必须将分数分配给所有类别。从条件分类(所有分数加起来为 1.0)到通用解决方案的映射并不适用,这就是为什么使用先前的临时实现的原因。

此外,还有一个 main() 方法,它会加载 ThresholdedClassifier 相关的片段并应用它们:

java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3/ThresholdedClassifier data/freshDisneyAnnotated.csv 
Data is: data/freshDisneyAnnotated.csv model is: models/ClassifierBuilder.LogisticRegression

reference\response
 \p,n,o,
 p 38,14,114,
 n 5,19,52,
 o 5,5,69,
Category p
Recall: 0.23
Prec  : 0.79
Category n
Recall: 0.25
Prec  : 0.50
Category o
Recall: 0.87
Prec  : 0.29

阈值正在按设计执行;p.79 精度,对于咨询来说足够接近,而 n 则非常准确。考虑到本章的上下文,main() 方法的来源应该是直截了当的。

就这样。我们几乎从不发布非阈值分类器,最佳实践要求在保留数据上设置阈值,最好是训练数据之后的时期。逻辑回归对倾斜的训练数据相当稳健,但清除倾斜数据缺陷的药膏是新颖的数据,这些数据从上到下精确标注到目标。是的,可以使用交叉验证进行阈值设置,但它存在由于调整而过度拟合的缺陷,并且你会搞砸你的分布。以回忆为导向的目标是另一回事。

少量训练,少量学习——主动学习

主动学习是一种快速开发分类器的超级能力。它已经在现实世界的许多项目中挽救了许多项目。这个想法非常简单,可以分解如下:

  1. 组装一个远远超过你手动标注能力的原始数据包。

  2. 仅标注一小部分原始数据。

  3. 在那令人尴尬的小量训练数据上训练分类器。

  4. 在所有数据上运行训练好的分类器。

  5. 将分类器的输出按最佳类别的置信度排序放入一个 .csv 文件中。

  6. 更正另一小部分令人尴尬的数据,从最自信的分类开始。

  7. 评估性能。

  8. 重复此过程,直到性能可接受,或者数据用尽。

  9. 如果成功,请务必在新鲜数据上评估/阈值,因为主动学习过程可能会引入偏差到评估中。

这个过程所做的就是帮助分类器区分它做出更高置信度错误的案例,并对其进行纠正。它还充当一种分类驱动的搜索引擎,其中正训练数据充当查询,其余数据充当被搜索的索引。

传统上,主动学习应用于分类器不确定正确类别的近错案例。在这种情况下,更正将应用于置信度最低的分类。我们提出了高置信度更正方法,因为我们面临着在仅接受高置信度决策的阈值分类器中提高精度的压力。

准备工作

这里发生的事情是,我们正在使用分类器来找到更多看起来像它所知道的数据。对于目标类别在未标注数据中很少见的问题,它可以非常快速地帮助系统识别更多该类别的示例。例如,在一个二分类任务中,目标类别的原始数据中边际概率为1%,这几乎肯定是一条可行的途径。你不能要求标注者随着时间的推移可靠地标记1/100的现象。虽然这是正确的方法,但最终结果可能因为涉及的努力而不会这样做。

就像大多数作弊、捷径和超级能力一样,需要问的问题是付出了什么代价。在精确度和召回率的二元对立中,召回率会受到影响。这是因为这种方法倾向于已知案例的标注。具有非常不同措辞的案例不太可能被发现,因此覆盖率可能会受到影响。

如何做到这一点...

让我们开始主动学习:

  1. 第1章简单分类器收集一些我们的.csv格式的训练数据,或者使用data/activeLearning/disneyDedupe.0.csv中的示例数据。我们的数据建立在第1章简单分类器中的迪士尼推文之上。情感分析是主动学习的良好候选,因为它受益于高质量的训练数据,而创建高质量的训练数据可能很困难。如果你使用自己的数据,请使用Twitter搜索下载器的.csv文件格式。

  2. 运行第1章简单分类器消除近重复项使用Jaccard距离食谱的.csv去重程序,以消除近重复推文。我们已经用示例数据做了这件事。我们从1,500条推文减少到1,343条。

  3. 如果你有自己的数据,根据标准标注在TRUTH列中标注大约25个示例:

    • p代表积极情感

    • n代表消极情感

    • o代表其他,这意味着没有表达情感,或者推文不是英文。

    • 确保获取每个类别的几个示例

    我们的示例数据已经为此步骤进行了标注。如果你使用自己的数据,请确保使用第一个文件(具有0.csv格式)的格式,路径中不包含其他.

    如何做…

    已标注的推文示例。注意,所有类别都有示例。

  4. 运行以下命令。在没有备份文件的情况下,不要在自己的标注数据上执行此操作。我们的I/O例程是为简单而编写的,而不是为了健壮性。我们已经警告过你:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar: com.lingpipe.cookbook.chapter3.ActiveLearner 
    
    
  5. 指向提供的标注数据,这将打印以下内容到控制台,并给出最终建议:

    reference\response
              \p,n,o,
             p 7,0,1,
             n 1,0,3,
             o 2,0,11,
    Category p
    Recall: 0.88
    Prec  : 0.70
    Category n
    Recall: 0.00
    Prec  : NaN
    Category o
    Recall: 0.85
    Prec  : 0.73
    Writing to file: data/activeLearning/disneySentimentDedupe.1.csv
    Done, now go annotate and save with same file name
    
  6. 这个配方将向你展示如何通过以更智能的方式使其更大来改进它。让我们看看我们目前的情况:

    • 数据已经为三个类别进行了一些标注

    • 在1,343条推文中,有25条进行了标注,其中13条是o,鉴于用例,我们并不特别关心这些,但它们仍然很重要,因为它们不是pn

    • 这并不是构建可靠分类器的足够标注数据,但我们可以用它来帮助标注更多数据

    • 最后一行鼓励进行更多标注,并指出要标注的文件名

  7. 每个类别都报告了精确度和召回率,即对训练数据进行交叉验证的结果。还有一个混淆矩阵。到目前为止,我们并不期望有很好的性能,但po做得相当好。n类别做得非常不好。

    接下来,打开电子表格,使用UTF-8编码导入并查看指示的.csv文件。OpenOffice显示以下内容:

    如何做…

    活动学习方法的初始输出

  8. 从左侧向右侧读取,我们将看到SCORE列,它反映了分类器的置信度;最可能的类别,在GUESS列中显示,是正确的。下一列是人工确定的TRUTH类别。最后一列TEXT是正在分类的推文。

  9. 所有1,343条推文都通过两种方式之一进行了分类:

    • 如果推文有标注,即TRUTH列中的条目,那么标注是在推文处于10折交叉验证的测试折叠时进行的。第13行就是这样一种情况。在这种情况下,分类是o,但事实是p,所以对于p来说,这将是一个假阴性。

    • 如果推文没有标注,即TRUTH列中没有条目,那么它就是使用所有可用的训练数据进行分类的。显示的电子表格中的所有其他示例都是这样处理的。它们根本不影响评估。我们将标注这些推文以帮助提高分类器性能。

  10. 接下来,我们将注释高置信度的推文,无论类别如何,如下面的屏幕截图所示:如何做…

    活动学习输出的更正输出。注意o类别的优势。

  11. 注释到第19行,我们会注意到大多数推文都是o,并且主导着整个过程。只有三个p,没有n。我们需要得到一些n注释。

  12. 我们可以通过选择整个工作表(除了标题外)并按列BGUESS排序,来专注于可能的候选n注释。滚动到n猜测,我们应该看到置信度最高的示例。在下面的屏幕截图中,我们注释了所有的n猜测,因为该类别需要数据。我们的注释在data/activeLearningCompleted/disneySentimentDedupe.1.csv中。如果你想完全复制这个配方,你必须将此文件复制到activeLearning目录中。如何做…

    按类别排序的注释,其中n或负类别非常少。

  13. 滚动到p猜测,我们也注释了一大堆。如何做…

    带有更正的正面标签和令人惊讶的负数数量

  14. 我们在p猜测中发现了八个负案例,混合在许多p和一些o注释中。

  15. 我们将保存文件,不更改文件名,并运行我们之前所做的相同程序:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar: com.lingpipe.cookbook.chapter3.ActiveLearner 
    
    
  16. 输出将如下所示:

    First file: data/activeLearning2/disneySentimentDedupe.0.csv
    Reading from file data/activeLearning2/disneySentimentDedupe.1.csv
    reference\response
              \p,n,o,
             p 17,1,20,
             n 9,1,5,
             o 9,1,51,
    Category p
    Recall: 0.45
    Prec  : 0.49
    Category n
    Recall: 0.07
    Prec  : 0.33
    Category o
    Recall: 0.84
    Prec  : 0.67
    Corpus is: 114
    Writing to file: data/activeLearning2/disneySentimentDedupe.2.csv
    Done, now go annotate and save with same file name
    
  17. 这是在注释过程中的一个相当典型的输出。正面p,一个简单的类别,以49%的准确率和45%的召回率缓慢前进。负面的n甚至更糟。尽管如此,我们将在输出文件上再进行一轮注释,重点关注n猜测,以帮助该类别提高性能。我们将保存并重新运行文件:

    First file:  data/activeLearning2/disneySentimentDedupe.0.csv
    Reading from file data/activeLearning2/disneySentimentDedupe.2.csv
    reference\response
              \p,n,o,
             p 45,8,17,
             n 16,13,3,
             o 18,3,88,
    Category p
    Recall: 0.64
    Prec  : 0.57
    Category n
    Recall: 0.41
    Prec  : 0.54
    Category o
    Recall: 0.81
    Prec  : 0.81
    
  18. 这最后一轮注释使我们达到了极限(如果你要完全复制这个配方,请记住将我们的注释从activeLearningCompleted/disneySentimentDedupe.2.csv复制过来)。我们从pn中注释了高置信度示例,增加了近100个示例。n的第一个最佳注释的准确率超过50%,召回率为41%。我们假设将会有一个可调的阈值,以满足我们对p的80%要求,并在211步中宣布胜利,这比总共1,343个注释要少得多。

  19. 就这样。这是一个真实世界的例子,也是我们为这本书尝试的第一个例子,所以数据是未经处理的。这种方法往往有效,尽管不能保证;有些数据甚至对装备精良的计算语言学家最专注的努力也表现出抵抗力。

  20. 一定要将最终的.csv文件保存在安全的地方。丢失所有这些有针对性的注释将是件遗憾的事。

  21. 在发布这个分类器之前,我们希望运行这个分类器,它训练所有注释数据,并在新文本上验证性能并设置阈值。这个注释过程会在数据中引入偏差,这些偏差在现实世界中不会反映出来。特别是,我们对np进行了有偏注释,并添加了o,正如我们所看到的。这并不是实际的分布。

它是如何工作的...

这个配方由于同时评估和创建注释的排名输出而有一些微妙之处。代码从您应该熟悉的构造开始:

public static void main(String[] args) throws IOException {
  String fileName = args.length > 0 ? args[0] 
    : "data/activeLearning/disneySentimentDedupe.0.csv"; 
  System.out.println("First file:  " + fileName);
  String latestFile = getLatestEpochFile(fileName);

getLatestEpochFile方法寻找以csv结尾的最高编号文件,与文件名共享根目录,并返回它。我们决不会用这个程序做任何严肃的事情。这个方法是标准的Java,所以我们将不会涉及它。

一旦我们有了最新的文件,我们将进行一些报告,读取我们的标准.csv注释文件,并加载一个交叉验证语料库。所有这些程序在其他地方有详细说明,位置由Util源中指定的位置给出。最后,我们将得到在.csv注释文件中找到的类别:

List<String[]> data 
  = Util.readCsvRemoveHeader(new File(latestFile));
int numFolds = 10;
XValidatingObjectCorpus<Classified<CharSequence>> corpus 
  = Util.loadXValCorpus(data,numFolds);
String[] categories = Util.getCategoryArray(corpus);

接下来,我们将配置一些标准的逻辑回归训练参数,并创建用于交叉验证评估的评估器。请注意,storeInputs的布尔值为true,这将便于记录结果。《第1章》(part0014_split_000.html#page "Chapter 1. Simple Classifiers")中关于*How to train and evaluate with cross validation*的配方有完整的解释:

PrintWriter progressWriter = new PrintWriter(System.out,true);
boolean storeInputs = true;
ConditionalClassifierEvaluator<CharSequence> evaluator 
  = new ConditionalClassifierEvaluator<CharSequence>(null, categories, storeInputs);
TokenizerFactory tokFactory 
  = IndoEuropeanTokenizerFactory.INSTANCE;

然后,我们将执行标准的交叉验证:

for (int i = 0; i < numFolds; ++i) {
  corpus.setFold(i);
  final LogisticRegressionClassifier<CharSequence> classifier 
    = Util.trainLogReg(corpus,tokFactory, progressWriter);
  evaluator.setClassifier(classifier);
  corpus.visitTest(evaluator);
}

在交叉验证结束时,评估器在visitTest()中存储了所有的分类。接下来,我们将把这些数据转移到累加器中,它创建并存储将要放入输出电子表格的行,并冗余地存储分数;这个分数将用于排序以控制打印出的注释的顺序:

final ObjectToDoubleMap<String[]> accumulator 
  = new ObjectToDoubleMap<String[]>();

然后,我们将遍历每个类别,并为该类别创建一个包含假阴性和真阳性的列表——这些是真实类别与类别标签相同的案例:

for (String category : categories) {
List<Classified<CharSequence>> inCategory
   = evaluator.truePositives(category);    
inCategory.addAll(evaluator.falseNegatives(category));

接下来,所有属于类别的测试用例都用于为累加器创建行:

for (Classified<CharSequence> testCase : inCategory) {
   CharSequence text = testCase.getObject();
  ConditionalClassification classification 
    = (ConditionalClassification)                  testCase.getClassification();
  double score = classification.conditionalProbability(0);
  String[] xFoldRow = new String[Util.TEXT_OFFSET + 1];
  xFoldRow[Util.SCORE] = String.valueOf(score);
  xFoldRow[Util.GUESSED_CLASS] = classification.bestCategory();
  xFoldRow[Util.ANNOTATION_OFFSET] = category;
  xFoldRow[Util.TEXT_OFFSET] = text.toString();
  accumulator.set(xFoldRow,score);
}

接下来,代码将打印出一些标准的评估器输出:

Util.printConfusionMatrix(evaluator.confusionMatrix());
Util.printPrecRecall(evaluator);  

所述的所有步骤仅适用于注释数据。我们现在将转向获取.csv文件中所有未注释数据的最佳类别和分数。

首先,我们将设置交叉验证语料库的折数数量为0,这意味着vistTrain()将访问整个注释语料库——未注释的数据不包含在语料库中。逻辑回归分类器以通常的方式进行训练:

corpus.setNumFolds(0);
final LogisticRegressionClassifier<CharSequence> classifier
  = Util.trainLogReg(corpus,tokFactory,progressWriter);

带着分类器,代码逐行遍历所有的data项。第一步是检查注释。如果值不是空字符串,则数据位于上述语料库中,并用作训练数据,因此循环跳到下一行:

for (String[] csvData : data) {
   if (!csvData[Util.ANNOTATION_OFFSET].equals("")) {
    continue;
   }
   ScoredClassification classification = classifier.classify(csvData[Util.TEXT_OFFSET]);
   csvData[Util.GUESSED_CLASS] = classification.category(0);
   double estimate = classification.score(0);
   csvData[Util.SCORE] = String.valueOf(estimate);
   accumulator.set(csvData,estimate);
  }

如果行未标注,则在适当的位置添加分数和bestCategory()方法,并将行添加到累加器中,带有分数。

代码的其余部分增加文件名索引并输出带有少量报告的累加器数据:

String outfile = incrementFileName(latestFile);
Util.writeCsvAddHeader(accumulator.keysOrderedByValueList(), 
        new File(outfile));    
System.out.println("Corpus size: " + corpus.size());
System.out.println("Writing to file: " + outfile);
System.out.println("Done, now go annotate and save with same" 
          + " file name");

这就是它的工作方式。记住,这种方法可能引入的偏差会使得评估数字无效。始终在新鲜保留数据上运行以获得分类器性能的正确感觉。

标注

我们提供的一项最有价值的服务是教我们的客户如何创建黄金标准数据,也称为训练数据。我们进行的几乎所有以成功为导向的自然语言处理项目都涉及大量的客户驱动标注。自然语言处理的质量完全取决于训练数据的质量。创建训练数据是一个相当直接的过程,但它需要关注细节和大量资源。从预算角度来看,你可以预期在标注上花费的金额与开发团队一样多,甚至更多。

如何做...

我们将以推文上的情感为例,并假设一个商业环境,但即使是学术努力也将有类似的维度。

  1. 获取你期望的系统将执行的前10个示例。对于我们的例子,这意味着获取10条反映系统预期执行范围的推文。

  2. 尽量从你期望的输入/输出范围内挑选。你可以自由挑选强有力的例子,但不要编造例子。人类在创建示例数据方面非常糟糕。真的,不要这么做。

  3. 标注这些推文为预期的类别。

  4. 与标注中的所有利益相关者召开会议。这包括用户体验设计师、商业人士、开发人员和最终用户。这次会议的目标是让所有相关方了解系统实际上将做什么——系统将使用这10个示例并产生类别标签。你会对这一步骤带来的多少清晰度感到惊讶。以下是一些清晰度:

    • 分类器的上游/下游用户将清楚地了解他们期望产生或消费的内容。例如,系统消耗UTF-8编码的英文推文,并产生一个ASCII单字符pnu

    • 对于情感,人们往往希望得到一个严重程度分数,这非常难以获得。你可以预期标注成本至少翻倍。这值得吗?可以提供一个置信度分数,但这只是对类别正确的置信度,而不是情感的严重程度。这次会议将迫使进行讨论。

    • 在这次会议中解释说,每个类别可能至少需要100个示例,如果不是500个,才能做合理的工作。还解释说,切换领域可能需要新的标注。自然语言处理对于你的同事来说非常容易,因此他们往往低估了构建系统所需的努力。

    • 不要忽视包括支付所有这些费用的人。我想如果你这是本科论文,你应该不让你的父母参与。

  5. 记录一个注释标准,解释每个类别的意图。它不需要非常复杂,但必须存在。注释标准应该分发给所有利益相关者。如果在提到的会议上已经有了这样的标准,那么加分;如果是这样,最终可能有所不同,但这没关系。例如:

    • 如果推文的情感明确地是关于迪士尼的积极,那么这条推文就是积极的p。如果一个积极的情感适用于非迪士尼的推文,那么它不是p而是u。一个例子是n推文明确表示对迪士尼的负面意图。其他所有推文都是u

    • 在注释标准中的示例最能有效地传达意图。根据我们的经验,人类用示例比用描述做得更好。

  6. 创建你的未注释数据集。这里的最佳实践是数据来自预期的来源,并且是随机的。这对于数据中具有明显普遍性的类别来说效果很好,比如说10%或更多,但我们已经构建了在问答系统中以1/2,000,000的比率出现的分类器。对于罕见类别,你可以使用搜索引擎来帮助找到该类别的实例——例如,搜索luv以找到积极的推文。或者,你可以使用在几个示例上训练的分类器,在数据上运行它,并查看得分高的积极项——我们已经在之前的食谱中介绍了这一点。

  7. 招募至少两名注释员来注释数据。我们需要至少两名注释员的原因是,这项任务必须证明人类可以重复进行。如果人们无法可靠地完成任务,那么你不能期望计算机能够完成。这就是我们执行代码的地方。在命令行中输入以下命令或在你的IDE中调用你的注释员——这将使用我们的默认文件运行:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter3.InterAnnotatorAgreement
    
    
    data/disney_e_n.csv treated as truth 
    data/disney1_e_n.csv treated as response
    Disagreement: n x e for: When all else fails #Disney
    Disagreement: e x n for: 昨日の幸せな気持ちのまま今日はLANDにいっ
    reference\response
     \e,n,
     e 10,1,
     n 1,9, 
    Category: e Precision: 0.91, Recall: 0.91 
    Category: n Precision: 0.90, Recall: 0.90
    
    
  8. 代码报告分歧并打印出混淆矩阵。精确率和召回率也是有用的指标。

它是如何工作的……

src/com/lingpipe/cookbook/chapter3/InterAnnotatorAgreement.java中的代码几乎没有新数据。有一点细微的区别是,我们使用了BaseClassifierEvaluator来执行评估工作,而不需要指定任何分类器——创建方式如下:

BaseClassifierEvaluator<CharSequence> evaluator 
  = new BaseClassifierEvaluator<CharSequence>(null, 
                categories, storeInputs);

评估者直接填充分类,而不是像书中其他地方所做的那样使用Corpus.visitTest()方法:

evaluator.addClassification(truthCategory, 
          responseClassification, text);

如果食谱需要进一步解释,请参考第1章中的分类器评估——混淆矩阵食谱,简单分类器

还有更多……

注释是一个非常复杂的领域,值得拥有一本自己的书,幸运的是,有一本很好的书,《机器学习自然语言注释》,作者是詹姆斯·普斯特约夫斯基和安布尔·斯塔布斯,由奥莱利媒体出版。为了完成注释工作,可以使用亚马逊的Mechanical Turk服务,以及专注于创建训练数据的公司,如CrowdFlower。然而,外包时要小心,因为分类器非常依赖于数据的质量。

注释者之间的冲突解决是一个具有挑战性的领域。许多错误将归因于注意力分散,但一些错误将作为合法的不同意见领域持续存在。两种简单的解决策略要么是丢弃数据,要么是保留两个注释。

第四章. 标注单词和标记

在本章中,我们将介绍以下食谱:

  • 有趣短语检测

  • 前景或背景驱动的有趣短语检测

  • 隐藏马尔可夫模型 (HMM) – 词性标注

  • N-best 单词标注

  • 基于置信度的标注

  • 训练单词标注

  • 单词标注评估

  • 条件随机场 (CRF) 用于词/标记标注

  • 修改 CRF

简介

单词和标记是本章的重点。最常见的提取技术,如命名实体识别,实际上已经编码在本章介绍的概念中,但这一点将留待 第五章在文本中查找跨度 – 分块 中讨论。我们将从查找有趣的标记集开始,然后转向 HMM,并以 LingPipe 最复杂的组件之一结束。像往常一样,我们将向您展示如何评估标注并训练自己的标注器。

有趣短语检测

想象一下,一个程序可以自动从大量文本数据中找到有趣的片段,其中“有趣”意味着单词或短语出现的频率高于预期。它有一个非常好的特性——不需要训练数据,并且适用于我们有标记的任何语言。您最常见到这种情况是在如下所示的标签云中:

有趣短语检测

前面的图显示了为 lingpipe.com 主页生成的标签云。然而,请注意,标签云被认为是杰弗里·泽尔达曼所说的互联网上的“莫霍克”,因此如果您在网站上部署此类功能,可能会处于不稳定的状态。

如何做...

要从关于迪士尼的小数据集(推文)中提取有趣的短语,请执行以下步骤:

  1. 启动命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter4.InterestingPhrases
    
    
  2. 程序应响应如下:

    Score 42768.0 : Crayola Color 
    Score 42768.0 : Bing Rewards 
    Score 42768.0 : PassPorter Moms 
    Score 42768.0 : PRINCESS BATMAN 
    Score 42768.0 : Vinylmation NIB 
    Score 42768.0 : York City 
    Score 42768.0 : eternal damnation 
    Score 42768.0 : ncipes azules 
    Score 42768.0 : diventare realt 
    Score 42768.0 : possono diventare 
    ….
    Score 42768.0 : Pictures Releases 
    Score 42768.0 : SPACE MOUNTAIN 
    Score 42768.0 : DEVANT MOI 
    Score 42768.0 : QUOI DEVANT 
    Score 42768.0 : Lindsay Lohan 
    Score 42768.0 : EPISODE VII 
    Score 42768.0 : STAR WARS 
    Score 42768.0 : Indiana Jones 
    Score 42768.0 : Steve Jobs 
    Score 42768.0 : Smash Mouth
    
    
  3. 您也可以提供一个 .csv 文件作为参数,以查看不同的数据。

输出往往令人难以置信地无用。令人难以置信地无用意味着一些有用的短语出现了,但伴随着大量您永远不会想在数据有趣总结中的不那么有趣的短语。在有趣的一侧,我们可以看到 Crayola ColorLindsey LohanEpisode VII 等等。在垃圾的一侧,我们可以看到 ncipes azulespictures releases 等等。有许多方法可以解决垃圾输出——显然的第一步将是使用语言 ID 分类器来丢弃非英语。

它是如何工作的...

在这里,我们将从头到尾解释源代码:

package com.lingpipe.cookbook.chapter4;

import java.io.FileReader;
import java.io.IOException;
import java.util.List;
import java.util.SortedSet;
import au.com.bytecode.opencsv.CSVReader;
import com.aliasi.lm.TokenizedLM;
import com.aliasi.tokenizer.IndoEuropeanTokenizerFactory;
import com.aliasi.util.ScoredObject;

public class InterestingPhrases {
  static int TEXT_INDEX = 3;
  public static void main(String[] args) throws IOException {
    String inputCsv = args.length > 0 ? args[0] : "data/disney.csv";

在这里,我们看到路径、导入和 main() 方法。我们提供默认文件名或从命令行读取的三元运算符是最后一行:

List<String[]> lines = Util.readCsv(new File(inputCsv));
int ngramSize = 3;
TokenizedLM languageModel = new TokenizedLM(IndoEuropeanTokenizerFactory.INSTANCE, ngramSize);

在收集输入数据后,第一个有趣的代码构建了一个标记化语言模型,它与第1章中使用的字符语言模型有显著的不同。标记化语言模型在TokenizerFactory创建的标记上操作,ngram参数指定了使用的标记数而不是字符数。TokenizedLM的一个细微之处在于,它还可以使用字符语言模型对它之前未看到的标记进行预测。请参阅前景或背景驱动的有趣短语检测配方以了解实际操作;除非在估计时没有未知标记,否则不要使用前面的构造函数。相关的Javadoc提供了更多详细信息。在以下代码片段中,语言模型被训练:

for (String [] line: lines) {
  languageModel.train(line[TEXT_INDEX]);
}

下一个相关步骤是创建配对:

int phraseLength = 2;
int minCount = 2;
int maxReturned = 100;
SortedSet<ScoredObject<String[]>> collocations = languageModel.collocationSet(phraseLength, minCount, maxReturned);

参数化控制短语在标记中的长度;它还设置短语可以出现的最小次数以及要返回的短语数量。由于我们有一个存储3元组的语言模型,我们可以查看长度为3的短语。接下来,我们将查看结果:

for (ScoredObject<String[]> scoredTokens : collocations) {
  double score = scoredTokens.score();
  StringBuilder sb = new StringBuilder();
  for (String token : scoredTokens.getObject()) {
    sb.append(token + " ");
  }
  System.out.printf("Score %.1f : ", score);
  System.out.println(sb);
}

SortedSet<ScoredObject<String[]>> 配对从高分数到低分数排序。分数背后的直觉是,当标记一起出现次数超过预期时,会给予更高的分数,这取决于它们在训练数据中的单标记频率。换句话说,短语根据它们与基于标记的独立性假设的差异进行评分。有关确切定义,请参阅http://alias-i.com/lingpipe/docs/api/com/aliasi/lm/TokenizedLM.html中的Javadoc——一个有趣的练习是创建自己的评分标准并与LingPipe中使用的评分标准进行比较。

还有更多...

由于此代码接近在网站上使用,因此讨论调整是值得的。调整是查看系统输出并根据系统犯的错误进行更改的过程。我们立即会考虑的一些更改包括:

  • 一个语言ID分类器,可以方便地过滤掉非英文文本

  • 关于如何更好地标记数据的思考

  • 变化的标记长度,包括3元组和单标记在摘要中

  • 使用命名实体识别来突出显示专有名词

前景或背景驱动的有趣短语检测

与之前的配方类似,这个配方也寻找有趣的短语,但它使用另一个语言模型来确定什么是有趣的。亚马逊的统计不可能短语(SIP)就是这样工作的。您可以从他们的网站http://www.amazon.com/gp/search-inside/sipshelp.html获得清晰的了解:

"Amazon.com的统计不可能短语,或"SIPs",是搜索内部!™计划中书籍文本中最独特的短语。为了识别SIPs,我们的计算机扫描搜索内部!计划中所有书籍的文本。如果它们在特定书籍中相对于所有搜索内部!书籍出现次数很多,那么这个短语就是该书籍中的SIP。

SIPs在特定书籍中不一定是不可能的,但相对于搜索内部!中的所有书籍来说是不可能的。

前景模型将是正在处理的书籍,背景模型将是亚马逊搜索内部!™计划中的所有其他书籍。虽然亚马逊可能已经引入了不同的调整,但基本思想是相同的。

准备工作

有几个数据来源值得一看,以获取两个独立语言模型中的有趣短语。关键是你希望背景模型作为预期单词/短语分布的来源,这将有助于突出前景模型中的有趣短语。以下是一些例子:

  • 时间分离的Twitter数据:时间分离的Twitter数据的例子如下:

    • 背景模型:这指的是昨天之前一年的关于迪士尼世界的推文。

    • 前景模型:今天的推文。

    • 有趣短语:今天在Twitter上关于迪士尼世界的新鲜事。

  • 主题分离的Twitter数据:主题分离的Twitter数据的例子如下:

    • 背景模型:关于迪士尼乐园的推文

    • 前景模型:关于迪士尼世界的推文

    • 有趣短语:关于迪士尼世界所说但没有在迪士尼乐园说的内容

  • 非常相似主题的书籍:相似主题书籍的例子如下:

    • 背景模型:一堆早期的科幻小说

    • 前景模型:儒勒·凡尔纳的世界大战

    • 有趣短语:关于“世界大战”的独特短语和概念

如何做到这一点...

在关于迪士尼乐园和迪士尼世界推文的推文中运行前景或背景模型的步骤如下:

  1. 在命令行中输入:

    java -cp  lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter4.InterestingPhrasesForegroundBackground
    
    
  2. 输出将类似于:

    Score 989.621859 : [sleeping, beauty]
    Score 989.621859 : [california, adventure]
    Score 521.568529 : [winter, dreams]
    Score 367.309361 : [disneyland, resort]
    Score 339.429700 : [talking, about]
    Score 256.473825 : [disneyland, during]
    
    
  3. 前景模型由搜索词disneyland的推文组成,背景模型由搜索词disneyworld的推文组成。

  4. 最相关的结果是为了加州迪士尼乐园的独特特征,即城堡的名字、睡美人的城堡,以及建在加州迪士尼乐园停车场上的主题公园。

  5. 下一个双词组是关于Winter Dreams的,它指的是一部电影的预映。

  6. 总体来说,区分两个度假村的推文,输出还不错。

它是如何工作的...

代码位于src/com/lingpipe/cookbook/chapter4/InterestingPhrasesForegroundBackground.java。在加载前景和背景模型的原始.csv数据之后,解释开始:

TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
tokenizerFactory = new LowerCaseTokenizerFactory(tokenizerFactory);
int minLength = 5;
tokenizerFactory = new LengthFilterTokenizerFactoryPreserveToken(tokenizerFactory, minLength);
Chapter 2, *Finding and Working with Words*, but the third one is a customized factory that bears some examination. The intent behind the LengthFilterTokenizerFactoryPreserveToken class is to filter short tokens but at the same time not lose adjacency information. The goal is to take the phrase, "Disney is my favorite resort", and produce tokens (disney, _234, _235, favorite, resort), because we don't want short words in our interesting phrases—they tend to sneak past simple statistical models and mess up the output. Please refer to src/come/lingpipe/cookbook/chapter4/LengthFilterTokenizerFactoryPreserveToken.java for the source of the third tokenizer. Also, refer to Chapter 2, *Finding and Working with Words* for exposition. Next is the background model:
int nGramOrder = 3;
TokenizedLM backgroundLanguageModel = new TokenizedLM(tokenizerFactory, nGramOrder);
for (String [] line: backgroundData) {
  backgroundLanguageModel.train(line[Util.TEXT_OFFSET]);
}

正在构建的是用于判断前景模型中短语新颖性的模型。然后,我们将创建并训练前景模型:

TokenizedLM foregroundLanguageModel = new TokenizedLM(tokenizerFactory,nGramOrder);
for (String [] line: foregroundData) {
  foregroundLanguageModel.train(line[Util.TEXT_OFFSET]);
}

接下来,我们将从前景模型中访问newTermSet()方法。参数和phraseSize确定标记序列的长度;minCount指定要考虑的短语的最小实例数,而maxReturned控制要返回的结果数量:

int phraseSize = 2;
int minCount = 3;
int maxReturned = 100;
SortedSet<ScoredObject<String[]>> suprisinglyNewPhrases
    = foregroundLanguageModel.newTermSet(phraseSize, minCount, maxReturned,backgroundLanguageModel);
for (ScoredObject<String[]> scoredTokens : suprisinglyNewPhrases) {
    double score = scoredTokens.score();
    String[] tokens = scoredTokens.getObject();
    System.out.printf("Score %f : ", score);
    System.out.println(java.util.Arrays.asList(tokens));
}

前面的for循环按从最令人惊讶的短语到最不令人惊讶的短语的顺序打印短语。

这里发生的事情的细节超出了食谱的范围,但Javadoc再次为我们指明了通往启迪的道路。

使用的精确评分方法是z分数,如BinomialDistribution.z(double,int,int)中定义的那样,成功概率由背景模型中的n-gram概率估计定义,成功次数是此模型中n-gram的计数,试验次数是此模型中的总计数。

还有更多...

这个食谱是我们第一次遇到未知标记的地方,如果不正确处理,这些标记可能会具有非常糟糕的特性。很容易看出为什么这是一个基于最大似然的语言模型的问题,这是一个语言模型的华丽名称,它通过乘以每个标记的似然来估计一些未见过的标记。每个似然是标记在训练中出现的次数除以在数据中看到的标记数。例如,考虑以下来自《亚瑟王宫廷中的康涅狄格州扬基》的训练数据:

“本故事中提到的那些不温柔的习俗和历史事件都是历史的,用来说明它们的情节也是历史的。”

这是非常少的训练数据,但对于要说明的观点来说已经足够了。考虑一下我们如何使用我们的语言模型来估计短语“不温柔的岳父”的值。有24个单词包含一次出现的“The”,我们将分配1/24的概率给这个。我们也将1/24的概率分配给“ungentle”。如果我们在这里停止,我们可以说“The ungentle”的可能性是1/24 * 1/24。然而,下一个词是“inlaws”,它不在训练数据中。如果这个标记被分配0/24的值,这将使整个字符串的可能性变为0(1/24 * 1/24 * 0/20)。这意味着每当有一个未见过的标记,其估计很可能是零时,这通常是一个无益的特性。

对此问题的标准响应是用替代值和近似值来代替训练中未见过的数据。有几种解决此问题的方法:

  • 为未知标记提供一个低但非零的估计。这是一个非常常见的做法。

  • 使用包含未知标记的字符语言模型。类中有这方面的规定——请参阅Javadoc。

  • 有许多其他方法和大量的研究文献。好的搜索词是“回退”和“平滑”。

隐藏马尔可夫模型(HMM)- 词性

这个配方引入了LingPipe的第一个核心语言能力;它指的是单词或词性POS)的语法类别。文本中的动词、名词、形容词等等是什么?

如何做到这一点...

让我们直接跳进去,回到那些尴尬的中学英语课或我们等效的年份:

  1. 像往常一样,前往你友好的命令提示符并输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter9.PosTagger 
    
    
  2. 系统将响应一个提示,我们将添加一个豪尔赫·路易斯·博尔赫斯(Jorge Luis Borges)的引言:

    INPUT> Reality is not always probable, or likely.
    
    
  3. 系统将愉快地响应这个引言:

    Reality_nn is_bez not_* always_rb probable_jj ,_, or_cc likely_jj ._. 
    
    

每个标记后面都附加了_和词性标签;nn是名词,rb是副词,等等。完整的标签集和标注器的语料库描述可以在http://en.wikipedia.org/wiki/Brown_Corpus找到。稍作尝试。词性标注器是90年代早期NLP中第一个突破性的机器学习应用之一。你可以期待这个应用的准确率超过90%,尽管由于基础语料库是在1961年收集的,因此在Twitter数据上可能会有所下降。

它是如何工作的...

正如食谱书所应有的,我们不会透露词性标注器构建的基本原理。有Javadoc、网络和科研文献来帮助你理解底层技术——在训练HMM的配方中,对底层HMM有一个简要的讨论。这是关于如何使用API的展示:

public static void main(String[] args) throws ClassNotFoundException, IOException {
  TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  String hmmModelPath = args.length > 0 ? args[0] : "models/pos-en-general-brown.HiddenMarkovModel";
  HiddenMarkovModel hmm = (HiddenMarkovModel) AbstractExternalizable.readObject(new File(hmmModelPath));
  HmmDecoder decoder = new HmmDecoder(hmm);
  BufferedReader bufReader = new BufferedReader(new InputStreamReader(System.in));
  while (true) {
    System.out.print("\n\nINPUT> ");
    System.out.flush();
    String input = bufReader.readLine();
    Tokenizer tokenizer = tokFactory.tokenizer(input.toCharArray(),0,input.length());
    String[] tokens = tokenizer.tokenize();
    List<String> tokenList = Arrays.asList(tokens);
    firstBest(tokenList,decoder);
  }
}

代码首先设置TokenizerFactory,这是有道理的,因为我们需要知道将要获得词性的单词。下一行读取一个之前训练好的词性标注器作为HiddenMarkovModel。我们不会过多深入细节;你只需要知道HMM将根据其前面的标签分配为token n 分配一个词性标签。

这些标签在数据中不是直接观察到的,这使得马尔可夫模型是隐藏的。通常,会查看一个或两个标记之前的标记。HMM中有许多值得理解的事情在进行。

下一行使用HmmDecoder解码器将HMM包装在代码中以标注提供的标记。接下来是我们的标准交互式while循环,所有有趣的代码都在firstBest(tokenList,decoder)方法中。方法如下:

static void firstBest(List<String> tokenList, HmmDecoder decoder) {
  Tagging<String> tagging = decoder.tag(tokenList);
    System.out.println("\nFIRST BEST");
    for (int i = 0; i < tagging.size(); ++i){
      System.out.print(tagging.token(i) + "_" + tagging.tag(i) + " ");
    }
  System.out.println();
}

注意decoder.tag(tokenList)调用,它产生一个Tagging<String>标签。标签没有迭代器或标签/标记对的封装,因此信息是通过增加索引i来访问的。

N-best词性标注

计算机科学的确定性本质在语言学的不确定性中并未得到体现,在乔姆斯基的助手出现之前,合理的博士们可以至少同意或不同意。这个食谱使用在前面食谱中训练的相同HMM,但为每个单词提供可能的标记排名列表。

这可能在哪里有帮助?想象一个搜索引擎,它搜索单词和标记——不一定是词性。搜索引擎可以索引单词和前n-best标记,这将允许匹配到非最佳标记。这可以帮助提高召回率。

如何操作...

N-best分析推动了NLP开发者的复杂度边界。曾经是单例的现在变成了一个排名列表,但这是下一个性能级别的发生地。让我们通过执行以下步骤开始:

  1. 将你的《句法结构》副本面朝下放好,并输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.NbestPosTagger 
    
    
  2. 然后,输入以下内容:

    INPUT> Colorless green ideas sleep furiously.
    
    
  3. 它产生了以下输出:

    N BEST
    #   JointLogProb         Analysis
    0     -91.141  Colorless_jj   green_jj   ideas_nns  sleep_vb   furiously_rb   ._. 
    1     -93.916  Colorless_jj   green_nn   ideas_nns  sleep_vb   furiously_rb   ._. 
    2     -95.494  Colorless_jj   green_jj   ideas_nns  sleep_rb   furiously_rb   ._. 
    3     -96.266  Colorless_jj   green_jj   ideas_nns  sleep_nn   furiously_rb   ._. 
    4     -98.268  Colorless_jj   green_nn   ideas_nns  sleep_rb   furiously_rb   ._.
    
    

输出列表从最有可能到最不可能列出整个标记序列的估计,给定HMM的估计。记住,联合概率是以2为底的对数。要比较联合概率,从-93.9减去-91.1得到2.8的差异。所以,标记器认为选项1比选项0发生的机会低7倍。这种差异的来源在于将green分配为名词而不是形容词。

它的工作原理...

加载模型和命令I/O的代码与之前的食谱相同。不同之处在于获取和显示标记的方法:

static void nBest(List<String> tokenList, HmmDecoder decoder, int maxNBest) {
  System.out.println("\nN BEST");
  System.out.println("#   JointLogProb         Analysis");
  Iterator<ScoredTagging<String>> nBestIt = decoder.tagNBest(tokenList,maxNBest);
  for (int n = 0; nBestIt.hasNext(); ++n) {
    ScoredTagging<String> scoredTagging = nBestIt.next();
    System.out.printf(n + "   %9.3f  ",scoredTagging.score());
    for (int i = 0; i < tokenList.size(); ++i){
      System.out.print(scoredTagging.token(i) + "_" + pad(scoredTagging.tag(i),5));
    }
    System.out.println();
  }

这并没有什么太多的事情,除了在迭代标记时解决格式问题。

基于置信度的标记

另一种看待标记概率的方法;这反映了单词层面的概率分配。代码反映了底层的TagLattice,并提供了关于标记器是否自信的见解。

如何操作...

这个食谱将聚焦于对单个标记的概率估计。执行以下步骤:

  1. 在命令行或IDE等效环境中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.ConfidenceBasedTagger
    
    
  2. 然后,输入以下内容:

    INPUT> Colorless green ideas sleep furiously.
    
    
  3. 它产生了以下输出:

    CONFIDENCE
    #   Token          (Prob:Tag)*
    0   Colorless           0.991:jj       0.006:np$      0.002:np 
    1   green               0.788:jj       0.208:nn       0.002:nns 
    2   ideas               1.000:nns      0.000:rb       0.000:jj 
    3   sleep               0.821:vb       0.101:rb       0.070:nn 
    4   furiously           1.000:rb       0.000:ql       0.000:jjr 
    5   .                   1.000:.        0.000:np       0.000:nn 
    
    

这种数据视图将标记和单词的联合概率分布。我们可以看到,green被标记为nn或单数名词的概率是.208,但正确的分析仍然是.788,带有形容词jj

它的工作原理…

我们仍然使用与《隐藏马尔可夫模型(HMM)- 词性》食谱中相同的旧HMM,但使用它的不同部分。读取模型的代码完全相同,但在报告结果的方式上有重大差异。src/com/lingpipe/cookbook/chapter4/ConfidenceBasedTagger.java中的方法:

static void confidence(List<String> tokenList, HmmDecoder decoder) {
  System.out.println("\nCONFIDENCE");
  System.out.println("#   Token          (Prob:Tag)*");
  TagLattice<String> lattice = decoder.tagMarginal(tokenList);

  for (int tokenIndex = 0; tokenIndex < tokenList.size(); ++tokenIndex) {
    ConditionalClassification tagScores = lattice.tokenClassification(tokenIndex);
    System.out.print(pad(Integer.toString(tokenIndex),4));
    System.out.print(pad(tokenList.get(tokenIndex),15));

    for (int i = 0; i < 3; ++i) {
      double conditionalProb = tagScores.score(i);
      String tag = tagScores.category(i);
      System.out.printf(" %9.3f:" + pad(tag,4),conditionalProb);

    }
    System.out.println();
  }
}

该方法明确展示了标记的底层图,这是HMM的核心。通过改变for循环的终止条件来查看更多或更少的标记。

训练词标记

当你可以创建自己的模型时,词性标注就变得更有趣了。对词性标注语料库进行标注的领域对于一本简单的食谱书来说有点过于广泛——对词性数据的标注非常困难,因为它需要相当的语言学知识才能做好。这个食谱将直接解决基于HMM的句子检测器的机器学习部分。

由于这是一本食谱书,我们将最小化解释HMM是什么。我们一直在使用的标记语言模型在计算当前估计的单词的前一个上下文时,考虑了一些单词/标记的数量。HMM在计算当前标记的标签估计时,会考虑到一些前一个标签的长度。这使得看似不同的邻居,如 ofin,看起来相似,因为它们都是介词。

句子检测 食谱中,从 第5章在文本中查找范围 – 分块,一个有用但不太灵活的句子检测器基于LingPipe中的 HeuristicSentenceModel。我们不会去修改/扩展 HeuristicSentenceModel,而是将使用我们标注的数据构建一个基于机器学习的句子系统。

如何做到这一点...

以下步骤描述了如何在 src/com/lingpipe/cookbook/chapter4/HMMTrainer.java 中运行程序:

  1. 要么创建一个句子标注数据的新的语料库,要么使用以下默认数据,该数据位于 data/connecticut_yankee_EOS.txt。如果你正在自己生成数据,只需用 [''] 编辑一些文本以标记句子边界。我们的例子如下:

    [The ungentle laws and customs touched upon in this tale are
    historical, and the episodes which are used to illustrate them
    are also historical.] [It is not pretended that these laws and
    customs existed in England in the sixth century; no, it is only
    pretended that inasmuch as they existed in the English and other
    civilizations of far later times, it is safe to consider that it is
    no libel upon the sixth century to suppose them to have been in
    practice in that day also.] [One is quite justified in inferring
    that whatever one of these laws or customs was lacking in that
    remote time, its place was competently filled by a worse one.]
    
  2. 前往命令提示符并使用以下命令运行程序:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter4.HmmTrainer
    
    
  3. 它将给出以下输出:

    Training The/BOS ungentle/WORD laws/WORD and/WORD customs/WORD touched/WORD…
    done training, token count: 123
    Enter text followed by new line
    > The cat in the hat. The dog in a bog.
    The/BOS cat/WORD in/WORD the/WORD hat/WORD ./EOS The/BOS dog/WORD in/WORD a/WORD bog/WORD ./EOS
    
    
  4. 输出是一个标记化文本,包含三个标签之一:BOS 表示句子的开始,EOS 表示句子的结束,以及 WORD 表示所有其他标记。

它是如何工作的…

就像许多基于范围的标记一样,span 标注被转换成之前在食谱输出中展示的标记级别标注。因此,首要任务是收集标注文本,设置 TokenizerFactory,然后调用一个解析子例程将其添加到 List<Tagging<String>>

public static void main(String[] args) throws IOException {
  String inputFile = args.length > 0 ? args[0] : "data/connecticut_yankee_EOS.txt";
  char[] text = Files.readCharsFromFile(new File(inputFile), Strings.UTF8);
  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  List<Tagging<String>> taggingList = new ArrayList<Tagging<String>>();
  addTagging(tokenizerFactory,taggingList,text);

解析前述格式的子例程通过首先使用 IndoEuropeanTokenizer 对文本进行标记化来实现,该标记化器具有将 [''] 句子分隔符作为单独标记的有益特性。它不会检查句子分隔符是否格式正确——需要更健壮的解决方案来完成这项工作。棘手的部分在于我们希望忽略结果标记流中的这种标记,但同时又想使用它来确保跟随 [' 的标记是 BOS,而跟随 '] 的标记是 EOS。其他标记只是 WORD。子例程为标签和标记构建了一个并行 Lists<String> 实例,然后用于创建 Tagging<String> 并添加到 taggingList 中。第 2 章(part0027_split_000.html#page "Chapter 2. Finding and Working with Words")Finding and Working with Words 中的标记化配方涵盖了标记化器正在处理的内容。查看以下代码片段:

static void addTagging(TokenizerFactory tokenizerFactory, List<Tagging<String>> taggingList, char[] text) {
  Tokenizer tokenizer = tokenizerFactory.tokenizer(text, 0, text.length);
  List<String> tokens = new ArrayList<String>();
  List<String> tags = new ArrayList<String>();
  boolean bosFound = false;
  for (String token : tokenizer.tokenize()) {
    if (token.equals("[")) {
      bosFound = true;
    }
    else if (token.equals("]")) {
      tags.set(tags.size() - 1,"EOS");
    }
    else {
      tokens.add(token);
      if (bosFound) {
        tags.add("BOS");
        bosFound = false;
      }
      else {
        tags.add("WORD");
      }
    }
  }
  if (tokens.size() > 0) {
    taggingList.add(new Tagging<String>(tokens,tags));
  }
}

前述代码有一个细微之处。训练数据被视为单个标记——这将模拟当我们使用句子检测器对新颖数据进行处理时输入将看起来像什么。如果使用多个文档/章节/段落进行训练,那么我们将为每个文本块调用此子例程。

返回到 main() 方法,我们将设置 ListCorpus 并将标记添加到语料库的训练部分,一次添加一个标记。还有一个 addTest() 方法,但这个配方不涉及评估;如果是评估,我们可能无论如何都会使用 XValidatingCorpus

ListCorpus<Tagging<String>> corpus = new ListCorpus<Tagging<String>> ();
for (Tagging<String> tagging : taggingList) {
  System.out.println("Training " + tagging);
  corpus.addTrain(tagging);
}

接下来,我们将创建 HmmCharLmEstimator,这是我们使用的 HMM。请注意,有一些构造函数允许自定义参数,这些参数会影响性能——请参阅 Javadoc。接下来,估计器将对语料库进行训练,并创建 HmmDecoder,它将实际标记标记,如以下代码片段所示:

HmmCharLmEstimator estimator = new HmmCharLmEstimator();
corpus.visitTrain(estimator);
System.out.println("done training, token count: " + estimator.numTrainingTokens());
HmmDecoder decoder = new HmmDecoder(estimator);
Note that there is no requirement that the training tokenizer be the same as the production tokenizer, but one must be careful to not tokenize in a radically different way; otherwise, the HMM will not be seeing the tokens it was trained with. The back-off model will then be used, which will likely degrade performance. Have a look at the following code snippet:
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  String evalText = reader.readLine();
  Tokenizer tokenizer = tokenizerFactory.tokenizer(evalText.toCharArray(),0,evalText.length());
  List<String> evalTokens = Arrays.asList(tokenizer.tokenize());
  Tagging<String> evalTagging = decoder.tag(evalTokens);
  System.out.println(evalTagging);
}

就这些了!为了真正将其作为一个合适的句子检测器,我们需要将字符偏移量映射回原始文本,但这在第 5 章(part0061_split_000.html#page "Chapter 5. Finding Spans in Text – Chunking")Finding Spans in Text – Chunking 中有所介绍。这足以展示如何使用 HMM。一个更完整的功能方法将确保每个 BOS 都有一个匹配的 EOS,反之亦然。HMM 没有这样的要求。

还有更多...

我们有一个小型且易于使用的词性标注语料库;这使我们能够展示如何训练用于非常不同问题的 HMM 与训练简单分类器([Chapter 1](part0014_split_000.html#page "Chapter 1. Simple Classifiers")Simple Classifiers)中的配方相同。它就像我们的 如何对情感进行分类 – 简单版本 配方,在 Chapter 1 Simple Classifiers;语言 ID 和情感之间的唯一区别是训练数据。我们将从一个硬编码的语料库开始,以保持简单——它位于 src/com/lingpipe/cookbook/chapter4/TinyPosCorus.java

public class TinyPosCorpus extends Corpus<ObjectHandler<Tagging<String>>> {

  public void visitTrain(ObjectHandler<Tagging<String>> handler) {
    for (String[][] wordsTags : WORDS_TAGSS) {
      String[] words = wordsTags[0];
      String[] tags = wordsTags[1];
      Tagging<String> tagging = new Tagging<String>(Arrays.asList(words),Arrays.asList(tags));
      handler.handle(tagging);
    }
  }

  public void visitTest(ObjectHandler<Tagging<String>> handler) {
    /* no op */
  }

  static final String[][][] WORDS_TAGSS = new String[][][] {
    { { "John", "ran", "." },{ "PN", "IV", "EOS" } },
    { { "Mary", "ran", "." },{ "PN", "IV", "EOS" } },
    { { "John", "jumped", "!" },{ "PN", "IV", "EOS" } },
    { { "The", "dog", "jumped", "!" },{ "DET", "N", "IV", "EOS" } },
    { { "The", "dog", "sat", "." },{ "DET", "N", "IV", "EOS" } },
    { { "Mary", "sat", "!" },{ "PN", "IV", "EOS" } },
    { { "Mary", "likes", "John", "." },{ "PN", "TV", "PN", "EOS" } },
    { { "The", "dog", "likes", "Mary", "." }, { "DET", "N", "TV", "PN", "EOS" } },
    { { "John", "likes", "the", "dog", "." }, { "PN", "TV", "DET", "N", "EOS" } },
    { { "The", "dog", "ran", "." },{ "DET", "N", "IV", "EOS", } },
    { { "The", "dog", "ran", "." },{ "DET", "N", "IV", "EOS", } }
  };

语料库手动创建标记以及标记中的静态 WORDS_TAGS 中的标记,并为每个句子创建 Tagging<String>;在这种情况下,Tagging<String> 由两个对齐的 List<String> 实例组成。然后,标记被发送到 Corpus 超类的 handle() 方法。替换这个语料库看起来如下:

/*
List<Tagging<String>> taggingList = new ArrayList<Tagging<String>>();
addTagging(tokenizerFactory,taggingList,text);
ListCorpus<Tagging<String>> corpus = new ListCorpus<Tagging<String>> ();
for (Tagging<String> tagging : taggingList) {
  System.out.println("Training " + tagging);
  corpus.addTrain(tagging);
}
*/

Corpus<ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();
HmmCharLmEstimator estimator = new HmmCharLmEstimator();
corpus.visitTrain(estimator);

我们只是注释掉了在 TinyPosCorpus 中加载带有句子检测和特征的代码,取而代之。由于它不需要添加数据,我们将仅用此训练HMM。为了避免混淆,我们创建了一个单独的类 HmmTrainerPos.java。运行它会产生以下结果:

java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar 
done training, token count: 42
Enter text followed by new line
> The cat in the hat is back.
The/DET cat/N in/TV the/DET hat/N is/TV back/PN ./EOS

唯一的错误是 in 是一个及物动词 TV。由于训练数据非常小,所以错误是可以预料的。就像在第1章中语言ID和情感分类的差异一样,HMM通过改变训练数据来学习一个非常不同的现象。

词性标注评估

词性标注评估推动了下游技术,如命名实体检测的发展,反过来,又推动了高端应用,如指代消解。您会注意到,大部分评估与我们的分类器评估相似,只是每个标记都像其自己的分类器类别一样进行评估。

这个菜谱应该可以帮助您开始评估,但请注意,我们网站上有一个关于词性标注评估的优秀教程http://alias-i.com/lingpipe/demos/tutorial/posTags/read-me.html;这个菜谱更详细地介绍了如何最好地理解标记器的性能。

这个菜谱简短且易于使用,所以你没有不评估你的标记器的借口。

准备工作

以下是我们评估器的类源代码,位于 src/com/lingpipe/cookbook/chapter4/TagEvaluator.java

public class TagEvaluator {
  public static void main(String[] args) throws ClassNotFoundException, IOException {
    HmmDecoder decoder = null;
    boolean storeTokens = true;
    TaggerEvaluator<String> evaluator = new TaggerEvaluator<String>(decoder,storeTokens);
    Corpus<ObjectHandler<Tagging<String>>> smallCorpus = new TinyPosCorpus();
    int numFolds = 10;
    XValidatingObjectCorpus<Tagging<String>> xValCorpus = new XValidatingObjectCorpus<Tagging<String>>(numFolds);
    smallCorpus.visitCorpus(xValCorpus);
    for (int i = 0; i < numFolds; ++i) {
      xValCorpus.setFold(i);
      HmmCharLmEstimator estimator = new HmmCharLmEstimator();
      xValCorpus.visitTrain(estimator);
      System.out.println("done training " + estimator.numTrainingTokens());
      decoder = new HmmDecoder(estimator);
      evaluator.setTagger(decoder);
      xValCorpus.visitTest(evaluator);
    }
    BaseClassifierEvaluator<String> classifierEval = evaluator.tokenEval();
    System.out.println(classifierEval);
  }
}

如何操作...

我们将指出前面代码中的有趣部分:

  1. 首先,我们将使用空 HmmDecoder 和控制标记是否存储的 boolean 来设置 TaggerEvaluatorHmmDecoder 对象将在代码中的交叉验证部分稍后设置:

    HmmDecoder decoder = null;
    boolean storeTokens = true;
    TaggerEvaluator<String> evaluator = new TaggerEvaluator<String>(decoder,storeTokens);
    
  2. 接下来,我们将从上一个菜谱中加载 TinyPosCorpus 并用它来填充 XValididatingObjectCorpus——这是一个非常巧妙的技巧,允许轻松地在语料库类型之间进行转换。请注意,我们选择了10个折——语料库只有11个训练示例,所以我们希望最大化每个折的训练数据量。如果您对这个概念不熟悉,请参阅第1章中的如何使用交叉验证进行训练和评估菜谱,简单分类器。查看以下代码片段:

    Corpus<ObjectHandler<Tagging<String>>> smallCorpus = new TinyPosCorpus();
    int numFolds = 10;
    XValidatingObjectCorpus<Tagging<String>> xValCorpus = new XValidatingObjectCorpus<Tagging<String>>(numFolds);
    smallCorpus.visitCorpus(xValCorpus);
    
  3. 以下代码片段是一个 for() 循环,它遍历折的数量。循环的前半部分处理训练:

    for (int i = 0; i < numFolds; ++i) {
      xValCorpus.setFold(i);
      HmmCharLmEstimator estimator = new HmmCharLmEstimator();
      xValCorpus.visitTrain(estimator);
      System.out.println("done training " + estimator.numTrainingTokens());
    
  4. 循环的其余部分首先为HMM创建解码器,将评估器设置为使用此解码器,然后将配置适当的评估器应用于语料库的测试部分:

    decoder = new HmmDecoder(estimator);
    evaluator.setTagger(decoder);
    xValCorpus.visitTest(evaluator);
    
  5. 最后几行在语料库的所有折都用于训练和测试之后应用。注意,评估器是BaseClassifierEvaluator!它报告每个标签作为类别:

    BaseClassifierEvaluator<String> classifierEval = evaluator.tokenEval();
    System.out.println(classifierEval);
    
  6. 准备迎接评估的洪流。以下只是其中的一小部分,即你应该从第1章简单分类器中熟悉的混淆矩阵:

    Confusion Matrix
    reference \ response
      ,DET,PN,N,IV,TV,EOS
      DET,4,2,0,0,0,0
      PN,0,7,0,1,0,0
      N,0,0,4,1,1,0
      IV,0,0,0,8,0,0
      TV,0,1,0,0,2,0
      EOS,0,0,0,0,0,11
    

就这样。你有一个与第1章简单分类器中的分类器评估紧密相关的评估设置。

还有更多...

对于n-best词标注有评估类,即NBestTaggerEvaluatorMarginalTaggerEvaluator,用于置信度排名。再次提醒,查看关于词性标注的更详细教程,以获得关于评估指标和一些帮助调整HMM的示例软件的全面介绍。

用于词/标记标注的条件随机场(CRF)

条件随机场CRF)是第3章高级分类器逻辑回归公式的扩展,但应用于词标注。在第1章简单分类器的结尾,我们讨论了将问题编码为分类问题的各种方法。CRFs将序列标注问题视为寻找最佳类别,其中每个类别(C)是C*T标签(T)分配给标记之一。

例如,如果我们有标记Therain,并且将d标记为限定词,将n标记为名词,那么CRF分类器的类别集合是:

  • 类别1d d

  • 类别2n d

  • 类别3n n

  • 类别4d d

为了使这个组合噩梦可计算,已经应用了各种优化,但这是基本思路。疯狂,但有效。

此外,CRFs允许在训练中使用随机特征,这与逻辑回归用于分类的方式完全相同。此外,它具有针对HMM风格观察的优化数据结构。它在词性标注方面的应用并不令人兴奋,因为我们的当前HMMs已经非常接近最先进水平。CRFs真正发挥作用的地方是在诸如命名实体识别等用例中,这些用例在第5章在文本中查找跨度 - 分块中有介绍,但我们希望在将分块接口复杂化之前先解决纯CRF实现问题。

http://alias-i.com/lingpipe/demos/tutorial/crf/read-me.html有一个关于CRF的优秀详细教程;这道菜谱非常接近这个教程。你将在那里找到更多信息以及适当的引用。

如何做到这一点…

我们至今所展示的所有技术都是在上一个千年发明的;这是一个来自新千年的技术。执行以下步骤:

  1. 在命令行中,输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.CRFTagger
    
    
  2. 控制台继续显示收敛结果,这些结果应该与第3章中的逻辑回归菜谱中熟悉的结果一样,我们将得到标准的命令提示符:

    Enter text followed by new line
    >The rain in Spain falls mainly on the plain.
    
    
  3. 作为对此的回应,我们将得到一些相当混乱的输出:

    The/DET rain/N in/TV Spain/PN falls/IV mainly/EOS on/DET the/N plain/IV ./EOS
    
    
  4. 这是一个糟糕的输出,但CRF已经在11个句子上进行了训练。所以,我们不要太苛刻——尤其是考虑到这项技术主要在给足训练数据以完成其工作时,在词性标注和跨度标注方面占据主导地位。

它是如何工作的…

就像逻辑回归一样,我们需要执行许多配置相关的任务来使这个类运行起来。这道菜谱将解决代码中CRF特定的方面,并参考第3章中的逻辑回归菜谱,高级分类器中的逻辑回归配置方面。

main()方法的顶部开始,我们将获取我们的语料库,这在前面三道菜谱中已经讨论过:

Corpus<ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();

接下来是特征提取器,它是CRF训练器的实际输入。它之所以是最终的,唯一的原因是有一个匿名内部类将访问它,以展示在下一道菜谱中特征提取是如何工作的:

final ChainCrfFeatureExtractor<String> featureExtractor
  = new SimpleCrfFeatureExtractor();

我们将在菜谱的后面部分讨论这个类的工作方式。

下一个配置块是为底层逻辑回归算法准备的。有关此方面的更多信息,请参阅第3章中的逻辑回归菜谱,高级分类器。请查看以下代码片段:

boolean addIntercept = true;
int minFeatureCount = 1;
boolean cacheFeatures = false;
boolean allowUnseenTransitions = true;
double priorVariance = 4.0;
boolean uninformativeIntercept = true;
RegressionPrior prior = RegressionPrior.gaussian(priorVariance, uninformativeIntercept);
int priorBlockSize = 3;
double initialLearningRate = 0.05;
double learningRateDecay = 0.995;
AnnealingSchedule annealingSchedule = AnnealingSchedule.exponential(initialLearningRate,
  learningRateDecay);
double minImprovement = 0.00001;
int minEpochs = 2;
int maxEpochs = 2000;
Reporter reporter = Reporters.stdOut().setLevel(LogLevel.INFO);

接下来,使用以下内容训练CRF:

System.out.println("\nEstimating");
ChainCrf<String> crf = ChainCrf.estimate(corpus,featureExtractor,addIntercept,minFeatureCount,cacheFeatures,allowUnseenTransitions,prior,priorBlockSize,annealingSchedule,minImprovement,minEpochs,maxEpochs,reporter);

代码的其余部分只是使用了标准的I/O循环。有关tokenizerFactory的工作方式,请参阅第2章查找和使用单词

TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  System.out.flush();
  String text = reader.readLine();
  Tokenizer tokenizer = tokenizerFactory.tokenizer(text.toCharArray(),0,text.length());
  List<String> evalTokens = Arrays.asList(tokenizer.tokenize());
  Tagging<String> evalTagging = crf.tag(evalTokens);
  System.out.println(evalTagging);

SimpleCrfFeatureExtractor

现在,我们将进入特征提取器。提供的实现紧密模仿了标准HMM的特征。在com/lingpipe/cookbook/chapter4/SimpleCrfFeatureExtractor.java中的类开始于:

public class SimpleCrfFeatureExtractor implements ChainCrfFeatureExtractor<String> {
  public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
    return new SimpleChainCrfFeatures(tokens,tags);
  }

ChainCrfFeatureExtractor 接口需要一个 extract() 方法,该方法接受标记和相关标签,这些标签在此情况下被转换为 ChainCrfFeatures<String>。这是通过以下 SimpleChainCrfFeatures 下的内部类处理的;这个内部类扩展了 ChainCrfFeatures 并提供了 nodeFeatures()edgeFeatures() 抽象方法的实现:

static class SimpleChainCrfFeatures extends ChainCrfFeatures<String> {

以下构造函数传递标记和标签到超类,超类将进行账目管理以支持查找 tagstokens

public SimpleChainCrfFeatures(List<String> tokens, List<String> tags) {
  super(tokens,tags);
}

节点特征的计算如下:

public Map<String,Double> nodeFeatures(int n) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.increment("TOK_" + token(n),1.0);
  return features;
}

标记按其在句子中的位置进行索引。位置 n 的单词/标记的节点特征是 ChainCrfFeatures 的基类方法 token(n) 返回的 String 值,带有前缀 TOK_。这里的值是 1.0。特征值可以有效地调整到其他值,这对于更复杂的 CRF 方法很有用,例如使用其他分类器的置信估计。以下是一个示例。

与 HMMs 一样,有一些特征依赖于输入中的其他位置——这些被称为 边缘特征。边缘特征接受两个参数:一个用于为 nk 生成特征的参数,这将应用于句子中的所有其他位置:

public Map<String,Double> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.increment("TAG_" + tag(k),1.0);
  return features;
}

下一个方法将说明如何修改特征提取。

还有更多...

Javadoc 中引用了广泛的研究文献,LingPipe 网站上还有更详尽的教程。

修改 CRFs

CRFs 的强大和吸引力来自于丰富的特征提取——继续使用一个提供反馈的评估工具来探索。这个方法将详细说明如何创建更复杂的特征。

如何操作...

我们将不会训练和运行一个 CRF;相反,我们将打印出特征。用这个特征提取器替换上一个方法中的特征提取器,以查看它们的工作情况。执行以下步骤:

  1. 前往命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter4.ModifiedCrfFeatureExtractor
    
    
  2. 特征提取器类为训练数据中的每个标记输出正在使用的真实标签,以用于学习:

    -------------------
    Tagging:  John/PN
    
    
  3. 这反映了由 src/com/lingpipe/cookbook/chapter4/TinyPosCorpus.java 确定的标记 John 的训练标签。

  4. 节点特征遵循来自我们 Brown 语料库 HMM 标签器的三个最高 POS 标签以及 TOK_John 特征:

    Node Feats:{nps=2.0251355582754984E-4, np=0.9994337160349874, nn=2.994165140854113E-4, TOK_John=1.0}
    
  5. 接下来,显示句子 "John ran" 中其他标记的边缘特征:

    Edge Feats:{TOKEN_SHAPE_LET-CAP=1.0, TAG_PN=1.0}
    Edge Feats:{TAG_IV=1.0, TOKEN_SHAPE_LET-CAP=1.0}
    Edge Feats:{TOKEN_SHAPE_LET-CAP=1.0, TAG_EOS=1.0}
    
  6. 输出的其余部分是句子中剩余标记的特征,然后是 TinyPosCorpus 中剩余句子的特征。

它是如何工作的...

我们的特性提取代码位于 src/com/lingpipe/cookbook/chapter4/ModifiedCrfFeatureExtractor.java。我们将从 main() 方法开始,该方法加载语料库,将内容传递给特征提取器,并打印出来:

public static void main(String[] args) throws IOException, ClassNotFoundException {

  Corpus <ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();
  final ChainCrfFeatureExtractor<String> featureExtractor = new ModifiedCrfFeatureExtractor();

我们将使用之前步骤中的TinyPosCorpus作为我们的语料库,然后,我们将从包含的类中创建一个特征提取器。在匿名内部类中引用变量时需要使用final

对于匿名内部类表示歉意,但这只是访问语料库中存储内容的简单方法,原因有很多,比如复制和打印。在这种情况下,我们只是生成和打印训练数据中找到的特征:

corpus.visitCorpus(new ObjectHandler<Tagging<String>>() {
  @Override
  public void handle(Tagging<String> tagging) {
    ChainCrfFeatures<String> features = featureExtractor.extract(tagging.tokens(), tagging.tags());

语料库包含Tagging对象,它们反过来包含一个List<String>的标记和标记。然后,通过将featureExtractor.extract()方法应用于标记和标记来创建一个ChainCrfFeatures<String>对象,这将涉及大量的计算,如下所示。

接下来,我们将使用标记和预期的标记报告训练数据:

for (int i = 0; i < tagging.size(); ++i) {
  System.out.println("---------");
  System.out.println("Tagging:  " + tagging.token(i) + "/" + tagging.tag(i));

然后,我们将使用以下节点先前标记的节点来告知CRF模型使用的特征:

System.out.println("Node Feats:" + features.nodeFeatures(i));

然后,通过以下迭代相对位置到源节点i来生成边特征:

for (int j = 0; j < tagging.size(); ++j) {
  System.out.println("Edge Feats:" 
        + features.edgeFeatures(i, j));
}

这就是打印特征的步骤。现在,我们将讨论如何构建特征提取器。我们假设你已经熟悉之前的步骤。首先,引入布朗语料库POS标记器的构造器:

HmmDecoder mDecoder;

public ModifiedCrfFeatureExtractor() throws IOException, ClassNotFoundException {
  File hmmFile = new File("models/pos-en-general-" + "brown.HiddenMarkovModel");
  HiddenMarkovModel hmm = (HiddenMarkovModel)AbstractExternalizable.readObject(hmmFile);
  mDecoder = new HmmDecoder(hmm);
}

构造器引入了一些用于特征生成的外部资源,即一个在布朗语料库上训练的POS标记器。为什么要在POS标记器中涉及另一个POS标记器?我们将布朗POS标记器的角色称为“特征标记器”,以区别于我们试图构建的标记器。使用特征标记器的一些原因包括:

  • 我们使用一个非常小的语料库进行训练,一个更健壮的通用POS特征标记器将有助于解决问题。"TinyPosCorpus"对于这种好处来说太小了,但有了更多数据,存在一个将theasome统一起来的特征at将有助于CRF识别some dog'DET' 'N',即使它从未在训练中看到some

  • 我们不得不与不与POS特征标记器对齐的标记集一起工作。CRF可以使用这些观察结果在外国标记集中更好地推理所需的标记。最简单的情况是,来自布朗语料库标记集的at在这个标记集中干净地映射到DET

  • 可以通过运行在训练数据上训练的不同数据或使用不同技术进行标记的多个标记器来提高性能。CRF可以,希望如此,识别出某个标记器优于其他标记器的上下文,并使用这些信息来指导分析。在那些日子里,我们的MUC-6系统有3个POS标记器,它们为最佳输出投票。让CRF来解决这个问题将是一个更优越的方法。

特征提取的核心可以通过extract方法访问:

public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
  return new ModChainCrfFeatures(tokens,tags);
}

ModChainCrfFeatures 被创建为一个内部类,目的是将类的数量保持在最小,并且包含它的类非常轻量级:

class ModChainCrfFeatures extends ChainCrfFeatures<String> {

  TagLattice<String> mBrownTaggingLattice;

  public ModChainCrfFeatures(List<String> tokens, List<String> tags) {
    super(tokens,tags);
    mBrownTaggingLattice = mDecoder.tagMarginal(tokens);
  }

前一个构造函数将标记和标签传递给超类,该超类处理此数据的管理。然后,将“特征标记器”应用于标记,并将结果输出分配给成员变量 mBrownTaggingLattice。代码将逐个访问标记,因此必须现在计算。

特征创建步骤通过两种方法进行:nodeFeaturesedgeFeatures。我们将从对之前食谱中 edgeFeatures 的简单增强开始:

public Map<String,? extends Number> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> features = newObjectToDoubleMap<String>();
  features.set("TAG_" + tag(k), 1.0d);
  String category = IndoEuropeanTokenCategorizer.CATEGORIZER.categorize(token(n));
  features.set("TOKEN_SHAPE_" + category,1.0d);
  return features;
}

代码添加了一个形状为标记的特征,将 1234 通用于 2-DIG 以及许多其他通用的形式。对于 CRF 来说,除非特征提取表明不同,否则 1234 作为两位数的相似性是不存在的。请参阅 Javadoc 以获取完整的分类器输出。

候选边特征

CRF 允许应用随机特征,因此问题是什么特征是有意义的。边特征与节点特征一起使用,因此另一个问题是特征是否应该应用于边或节点。边特征将用于推理当前单词/标记与其周围标记之间的关系。一些可能的边特征包括:

  • 前一个标记的形状(全部大写,以数字开头等)与之前所做的相同。

  • 需要正确排列重音和非重音音节的扬抑格识别。这需要音节重音标记器。

  • 文本中经常包含一种或多种语言——这被称为代码切换。这在推文中很常见。一个合理的边特征将是周围标记的语言;这将更好地模拟下一个单词可能具有与上一个单词相同的语言。

节点特征

节点特征在 CRF 中往往是最活跃的部分,并且可以变得非常丰富。在 第 5 章使用更好的特征的 CRF 命名实体识别 食谱中,文本中的跨度查找 – 分块 是一个例子。我们将在此食谱中添加之前食谱的标记特征:

public Map<String,? extends Number> nodeFeatures(int n) {
  ObjectToDoubleMap<String> features = new ObjectToDoubleMap<String>();
  features.set("TOK_" + token(n), 1);
  ConditionalClassification tagScores = mBrownTaggingLattice.tokenClassification(n);
  for (int i = 0; i < 3; ++ i) {
    double conditionalProb = tagScores.score(i);
    String tag = tagScores.category(i);
    features.increment(tag, conditionalProb);
  }
  return features;
}

然后,就像之前的食谱一样,通过以下方式添加标记特征:

features.set("TOK_" + token(n), 1); 

这导致标记字符串被 TOK_ 预先附加,并计数为 1。请注意,虽然 tag(n) 在训练中可用,但使用此信息没有意义,因为这正是 CRF 试图预测的内容。

接下来,从 POS 特征标记器中提取前三个标签,并添加相关的条件概率。CRF 将能够有效地处理具有不同权重的标签。

还有更多...

在生成新特征时,考虑数据的稀疏性是值得思考的。如果日期对于CRF来说可能是一个重要的特征,那么做标准的计算机科学事情,将日期转换为自1970年1月1日GMT以来的毫秒数可能不是一个好主意。原因如下:

  • 底层分类器不知道这两个值几乎相同

  • 分类器不知道MILLI_前缀是相同的——公共前缀只是为了方便人类

  • 该特征在训练中不太可能多次出现,并且可能会被最小特征计数剪枝

与将日期规范化为毫秒数不同,考虑对训练数据中可能存在多个实例的日期进行抽象,例如has_date特征,它忽略实际日期但记录日期的存在。如果日期很重要,那么计算所有关于日期的重要信息。如果它是星期几,那么映射到星期几。如果时间顺序很重要,那么映射到更粗略的测量,这种测量可能有很多测量值。一般来说,条件随机场(CRFs)及其底层的逻辑回归分类器对无效特征具有鲁棒性,所以请随意发挥创意——添加特征不太可能降低准确性。

第五章:第5章. 在文本中查找跨度 – 块

本章涵盖了以下食谱:

  • 句子检测

  • 句子检测评估

  • 调整句子检测

  • 在字符串中标记嵌入的块——句子块示例

  • 段落检测

  • 简单的名词短语和动词短语

  • 基于正则表达式的命名实体识别块

  • 基于词典的命名实体识别块

  • 在词性标注和块之间进行翻译 – BIO 编码器

  • 基于HMM的命名实体识别

  • 混合命名实体识别源

  • 块的CRFs

  • 使用CRFs和更好特征的命名实体识别

简介

本章将告诉我们如何处理通常覆盖一个或多个单词/标记的文本跨度。LingPipe API将这个文本单元表示为块,并使用相应的块生成器来生成块。以下是一些带有字符偏移的文本:

LingPipe is an API. It is written in Java.
012345678901234567890123456789012345678901
          1         2         3         4           

将前面的文本块成句子将给出以下输出:

Sentence start=0, end=18
Sentence start =20, end=41

为命名实体添加块,为LingPipe和Java添加实体:

Organization start=0, end=7
Organization start=37, end=40

我们可以根据它们与包含它们的句子的偏移量来定义命名实体块;这不会对LingPipe产生影响,但对Java来说会是这样:

Organization start=17, end=20

这是块的基本思想。有很多方法可以创建它们。

句子检测

书面文本中的句子大致对应于口语表达。它们是工业应用中处理单词的标准单元。在几乎所有成熟的NLP应用中,句子检测都是处理流程的一部分,即使在推文中,推文可以有超过140个字符的多个句子。

如何做...

  1. 如往常一样,我们首先会玩一些数据。在控制台中输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.SentenceDetection
    
    
  2. 程序将为您的句子检测实验提供提示。一个新行/回车符终止要分析的文本:

    Enter text followed by new line
    >A sentence. Another sentence.
    SENTENCE 1:
    A sentence.
    SENTENCE 2:
    Another sentence.
    
    
  3. 值得尝试不同的输入。以下是一些探索句子检测器特性的示例。删除句子的首字母大写;这将防止检测第二个句子:

    >A sentence. another sentence.
    SENTENCE 1:
    A sentence. another sentence.
    
    
  4. 检测器不需要句尾的句号——这是可配置的:

    >A sentence. Another sentence without a final period
    SENTENCE 1:A sentence.
    SENTENCE 2:Another sentence without a final period
    
    
  5. 检测器平衡括号,这不会允许句子在括号内断开——这也是可配置的:

    >(A sentence. Another sentence.)
    SENTENCE 1: (A sentence. Another sentence.)
    
    

它是如何工作的...

这个句子检测器是基于启发式或基于规则的句子检测器。统计句子检测也是一个合理的方法。我们将运行整个源代码来运行检测器,稍后我们将讨论修改:

package com.lingpipe.cookbook.chapter5;

import com.aliasi.chunk.Chunk;
import com.aliasi.chunk.Chunker;
import com.aliasi.chunk.Chunking;
import com.aliasi.sentences.IndoEuropeanSentenceModel;
import com.aliasi.sentences.SentenceChunker;
import com.aliasi.sentences.SentenceModel;
import com.aliasi.tokenizer.IndoEuropeanTokenizerFactory;
import com.aliasi.tokenizer.TokenizerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Set;

public class SentenceDetection {

public static void main(String[] args) throws IOException {
  boolean endSent = true;
  boolean parenS = true;
  SentenceModel sentenceModel = new IndoEuropeanSentenceModel(endSent,parenS);

main类的顶部开始工作,布尔endSent参数控制是否假设检测到的句子以句子结束,无论什么情况——这意味着最后一个字符总是句子边界——它不需要是句号或其他典型的句子结束标记。更改它并尝试一个不带句尾句号的句子,结果将是没有检测到句子。

下一个布尔变量parenS的声明在查找句子时优先考虑括号而不是句子生成器。接下来,将设置实际的句子分块器:

TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
Chunker sentenceChunker = new SentenceChunker(tokFactory,sentenceModel);

tokFactory应该对你很熟悉,来自第2章寻找和使用单词。然后可以构建sentenceChunker。以下是为命令行交互的标准I/O代码:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("Enter text followed by new line\n>");
  String text = reader.readLine();

一旦我们有了文本,然后应用句子检测器:

Chunking chunking = sentenceChunker.chunk(text);
Set<Chunk> sentences = chunking.chunkSet();

分块提供了Set<Chunk>参数,它将非合同地提供Chunks的适当顺序;它们将按照ChunkingImpl Javadoc中的方式添加。真正偏执的程序员可能会强制执行正确的排序顺序,我们将在本章后面讨论,当我们必须处理重叠的块时。

接下来,我们将检查是否找到了任何句子,如果没有找到,我们将向控制台报告:

if (sentences.size() < 1) {
  System.out.println("No sentence chunks found.");
  return;
}

以下是在书中首次接触Chunker接口,有一些评论是必要的。Chunker接口生成Chunk对象,这些对象是CharSequence(通常是String)上类型化和评分的连续字符序列。Chunks可以重叠。Chunk对象存储在Chunking中:

String textStored = chunking.charSequence().toString();
for (Chunk sentence : sentences) {
  int start = sentence.start();
  int end = sentence.end();
  System.out.println("SENTENCE :" 
    + textStored.substring(start,end));
  }
}

首先,我们恢复了基于块的基础文本字符串textStored。它与text相同,但我们想展示Chunking类中这种可能有用的方法,该方法可能在分块远离它所使用的CharSequence的递归或其他上下文中出现。

剩余的for循环遍历句子,并使用Stringsubstring()方法将它们打印出来。

还有更多...

在继续介绍如何自己实现句子检测器之前,值得提一下,LingPipe有MedlineSentenceModel,它面向医学研究文献中发现的句子类型。它已经看到了大量数据,应该成为你在这些类型数据上句子检测工作的起点。

嵌套句子

句子,尤其是在文学中,可以包含嵌套的句子。考虑以下例子:

John said "this is a nested sentence" and then shut up.

前面的句子将被正确标记为:

[John said "[this is a nested sentence]" and then shut up.]

这种嵌套与语言学家对嵌套句子的概念不同,后者基于语法角色。考虑以下例子:

[[John ate the gorilla] and [Mary ate the burger]].

这个句子由两个通过and连接的语言学上完整的句子组成。这两个句子的区别在于,前者由标点符号决定,而后者由语法功能决定。这种区别是否重要可以讨论。然而,前者在程序上更容易识别。

然而,我们在工业环境中很少需要建模嵌套句子,但我们在MUC-6系统和研究环境中的各种核心ference解析系统中承担了这项任务。这超出了食谱书的范围,但请注意这个问题。LingPipe没有现成的嵌套句子检测功能。

句子检测评估

就像我们做的许多事情一样,我们希望能够评估我们组件的性能。句子检测也不例外。句子检测是一种跨度注释,与我们之前对分类器和分词的评价不同。由于文本中可能包含不属于任何句子的字符,因此存在句子开始和句子结束的概念。一个不属于句子的字符的例子将来自HTML页面的JavaScript。

以下步骤将指导你创建评估数据并将其传递给评估类。

如何进行...

执行以下步骤以评估句子检测:

  1. 打开一个文本编辑器,复制粘贴一些你想要用来评估句子检测的文学瑰宝,或者你可以使用我们提供的默认文本,如果你没有提供自己的数据,就会使用这个文本。如果你坚持使用纯文本,这会更容易。

  2. 在文本中插入平衡的[]来指示句子的开始和结束。如果文本已经包含[],请选择一个不在文本中的字符作为句子分隔符——花括号或斜杠是一个不错的选择。如果你使用不同的分隔符,你将不得不相应地修改源代码并重新创建JAR文件。代码假设使用单字符文本分隔符。以下是从《银河系漫游指南》中摘取的句子注释文本的例子——请注意,并非每个字符都在句子中;句子之间有一些空白:

    [The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.] [It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.]
    
  3. 打开命令行并运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.EvaluateAnnotatedSentences
    TruePos: 0-83/The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    TruePos: 84-233/It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.:S
    
    
  4. 对于这些数据,代码将显示两个与用[]标注的句子完美匹配的句子,正如TruePos标签所示。

  5. 一个好的练习是稍微修改一下注释以强制出现错误。我们将把第一个句子边界向前移动一个字符:

    T[he Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.] [It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.]
    
    
  6. 保存修改后的注释文件后重新运行会产生以下结果:

    TruePos: 84-233/It says that the effect of a Pan Galactic Gargle Blaster is like having your brains smashed out by a slice of lemon wrapped round a large gold brick.:S
    FalsePos: 0-83/The Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    FalseNeg: 1-83/he Guide says that the best drink in existence is the Pan Galactic Gargle Blaster.:S
    
    

    通过更改真实标注,产生了一个假阴性,因为句子跨度被遗漏了一个字符。此外,句子检测器还创建了一个假阳性,它识别了0-83字符序列。

  7. 试着玩一玩注释和各种类型的数据,以了解评估的工作方式和句子检测器的功能。

它是如何工作的...

类首先消化注释文本并将句子块存储在评估对象中。然后创建句子检测器,就像我们在前面的食谱中做的那样。代码最后将创建的句子检测器应用于文本,并打印结果。

解析注释数据

给定用[]注释句子边界的文本意味着必须恢复句子的正确偏移量,并且必须创建原始未注释的文本,即没有任何[]。跨度解析器可能有点难以编码,以下提供的是为了简单而不是效率或正确的编码技术:

String path = args.length > 0 ? args[0] 
             : "data/hitchHikersGuide.sentDetected";
char[] chars 
  = Files.readCharsFromFile(new File(path), Strings.UTF8);
StringBuilder rawChars = new StringBuilder();
int start = -1;
int end = 0;
Set<Chunk> sentChunks = new HashSet<Chunk>();

之前的代码以适当的字符编码将整个文件读入为一个char[]数组。另外,请注意,对于大文件,流式方法将更节省内存。接下来,设置一个未注释字符的累加器,作为一个StringBuilder对象,使用rawChars变量。所有遇到的既不是[也不是]的字符都将追加到该对象中。剩余的代码设置句子开始和结束的计数器,这些计数器索引到未注释的字符数组中,以及一个用于注释句子段的Set<Chunk>累加器。

下面的for循环逐个字符地遍历注释字符序列:

for (int i=0; i < chars.length; ++i) {
  if (chars[i] == '[') {
    start = rawChars.length();
  }
  else if (chars[i] == ']') {
    end = rawChars.length();

    Chunk chunk = ChunkFactory.createChunk(start,end, SentenceChunker.SENTENCE_CHUNK_TYPE);
    sentChunks.add(chunk);}
  else {
    rawChars.append(chars[i]);
  }
}
String originalText = rawChars.toString();

第一个if (chars[i] == '[')测试注释中的句子开头,并将start变量设置为rawChars的长度。迭代变量i包括由注释添加的长度。相应的else if (chars[i] == ']')语句处理句子结尾的情况。请注意,这个解析器没有错误检查——这是一个非常糟糕的想法,因为如果使用文本编辑器输入,注释错误的可能性非常高。然而,这是为了保持代码尽可能简单。在后面的食谱中,我们将提供一个带有一些最小错误检查的示例。一旦找到句子结尾,就会使用ChunkFactory.createChunk创建一个句子块,带有偏移量和标准LingPipe句子类型SentenceChunker.SENTENCE_CHUNK_TYPE,这对于即将到来的评估类正常工作是必需的。

剩余的else语句适用于所有不是句子边界的字符,它只是将字符添加到rawChars累加器中。当创建String unannotatedText时,可以在for循环外部看到这个累加器的结果。现在,我们已经将句子块正确地索引到一个文本字符串中。接下来,我们将创建一个合适的Chunking对象:

ChunkingImpl sentChunking = new ChunkingImpl(unannotatedText);
for (Chunk chunk : sentChunks) {
  sentChunking.add(chunk);
}

实现ChunkingImpl类的类(Chunking是一个接口)在构造时需要底层的文本,这就是为什么我们没有在先前的循环中直接填充它的原因。LingPipe通常试图使对象构造完整。如果可以在没有底层的CharSequence方法的情况下创建Chunkings,那么当调用charSequence()方法时将返回什么?一个空字符串会误导用户。或者,返回null需要被捕获并处理。最好是强制对象在构造时就有意义。

接下来,我们将看到来自先前食谱的标准句子分块器配置:

boolean eosIsSentBoundary = false;
boolean balanceParens = true;
SentenceModel sentenceModel = new IndoEuropeanSentenceModel(eosIsSentBoundary, balanceParens);
TokenizerFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
SentenceChunker sentenceChunker = new SentenceChunker(tokFactory,sentenceModel);

有趣的部分紧随其后,有一个评估器,它将sentenceChunker作为评估参数:

SentenceEvaluator evaluator = new SentenceEvaluator(sentenceChunker);

接下来,handle(sentChunking)方法将接受我们刚刚解析到的Chunking文本,并在sentChunking提供的CharSequence上运行句子检测器,并设置评估:

evaluator.handle(sentChunking);

然后,我们只需获取评估数据,逐步分析真实句子检测和系统所做之间的差异:

SentenceEvaluation eval = evaluator.evaluation();
ChunkingEvaluation chunkEval = eval.chunkingEvaluation();
for (ChunkAndCharSeq truePos : chunkEval.truePositiveSet()) {
  System.out.println("TruePos: " + truePos);
}
for (ChunkAndCharSeq falsePos : chunkEval.falsePositiveSet()) {
  System.out.println("FalsePos: " + falsePos);
}
for (ChunkAndCharSeq falseNeg : chunkEval.falseNegativeSet()){
  System.out.println("FalseNeg: " + falseNeg);
}

这个配方并不涵盖所有的评估方法——查看Javadoc,但它确实提供了句子检测调整器可能最需要的;这是一个列出句子检测器正确识别的内容(真阳性)、它找到但错误的句子(假阳性)以及它遗漏的句子(假阴性)。请注意,在跨度注释中,真阴性没有太多意义,因为它们将是所有不在真实句子检测中的可能跨度集合。

调整句子检测

大量的数据将抵制IndoEuropeanSentenceModel的诱惑,因此这个配方将提供一个起点来修改句子检测以适应新的句子类型。不幸的是,这是一个非常开放的系统构建领域,因此我们将关注技术而不是句子可能的格式。

如何做到这一点...

这个配方将遵循一个熟悉的模式:创建评估数据,设置评估,然后开始修改。我们开始吧:

  1. 拿出你最喜欢的文本编辑器并标记一些数据——我们将坚持使用[]标记方法。以下是一个违反我们标准IndoEuropeanSentenceModel的例子:

    [All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.]  [A few gifted individuals manage to do both.]
    
    
  2. 我们将把前面的句子放入data/saki.sentDetected.txt并运行它:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.EvaluateAnnotatedSentences data/saki.sentDetected 
    FalsePos: 0-159/All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.  A few gifted individuals manage to do both.:S
    FalseNeg: 0-114/All decent people live beyond their incomes nowadays, and those who aren't respectable live beyond other people's.:S
    FalseNeg: 116-159/A few gifted individuals manage to do both.:S
    
    

还有更多...

单个假阳性对应于找到的一个句子,两个假阴性是我们在这里注释的两个未找到的句子。发生了什么?句子模型遗漏了people's.作为句子结束。如果去掉撇号,句子就能正确检测到——这是怎么回事?

首先,让我们看看在后台运行的代码。IndoEuropeanSentenceModel通过配置来自HeuristicSentenceModel的Javadoc中几个类别的标记来扩展HeuristicSentenceModel

  • 可能的停止点:这些是允许作为句子最后一个标记的标记。这个集合通常包括句子结尾的标点符号,例如句号(.)和双引号(")。

  • 不可能的次末点:这些可能是句子中不是次末(倒数第二个)的标记。这个集合通常由缩写或首字母缩略词组成,例如Mr

  • 不可能的起始点:这些可能是句子中不是第一个的标记。这个集合通常包括应该附加到上一个句子的标点符号,例如结束引号('')。

IndoEuropeanSentenceModel 不可配置,但从 Javadoc 中可以看出,所有单个字符都被视为不可能的末尾音节。单词 people's 被分词为 people's.。单个字符 s. 的前缀音节,因此被阻止。如何解决这个问题?

几种选项呈现在眼前:

  • 假设这种情况不会经常发生,忽略这个错误

  • 通过创建一个自定义句子模型来修复

  • 通过修改分词器来不分离撇号来修复

  • 为接口编写一个完整的句子检测模型

第二种选项,创建一个自定义句子模型,最简单的处理方式是将 IndoEuropeanSentenceModel 的源代码复制到一个新类中并对其进行修改,因为相关数据结构是私有的。这样做是为了简化类的序列化——写入磁盘的配置非常少。在示例类中,有一个 MySentenceModel.java 文件,它与原文件的区别在于包名和导入语句的明显变化:

IMPOSSIBLE_PENULTIMATES.add("R");
//IMPOSSIBLE_PENULTIMATES.add("S"); breaks on "people's."
//IMPOSSIBLE_PENULTIMATES.add("T"); breaks on "didn't."
IMPOSSIBLE_PENULTIMATES.add("U");

上述代码只是注释掉了可能的单字符末尾音节的情况,这些音节是单个单词字符。要看到它的工作效果,请在 EvaluateAnnotatedSentences.java 类中将句子模型更改为 SentenceModel sentenceModel = new MySentenceModel(); 并重新编译和运行它。

如果你认为上述代码是找到以可能缩写结尾的句子与诸如 [Hunter S. Thompson is a famous fellow.] 这样的非句子情况之间合理平衡的合理方法,这将检测 S. 作为句子边界。

扩展 HeuristicSentenceModel 对于许多类型的数据都适用。Mitzi Morris 构建了 MedlineSentenceModel.java,它旨在与 MEDLINE 研究索引中提供的摘要很好地配合工作。

看待上述问题的一种方式是,为了句子检测的目的,不应该将缩写拆分成标记。IndoEuropeanTokenizerFactory 应该调整以将 "people's" 和其他缩写保留在一起。虽然最初似乎第一个解决方案稍微好一些,但它可能会违反 IndoEuropeanSentenceModel 是针对特定分词进行调整的事实,而在没有评估语料库的情况下,这种变化的影响是未知的。

另一种选择是编写一个完全新颖的句子检测类,该类支持 SentenceModel 接口。面对高度新颖的数据集,例如 Twitter 流,我们将考虑使用机器学习驱动的跨度标注技术,如 HMMs 或 CRFs,这些技术在本章的 第 4 章 和本章末尾的 “标记单词和标记” 中有所介绍。

在字符串中标记嵌入的块 - 句子分块示例

之前配方中显示块的方法并不适合需要修改底层字符串的应用程序。例如,情感分析器可能只想突出显示强烈积极的句子,而不标记其他句子,同时仍然显示整个文本。产生标记文本的轻微复杂性在于添加标记会改变底层字符串。这个配方提供了插入块的工作代码,通过反向添加块来实现。

如何做...

虽然这个配方在技术上可能并不复杂,但它有助于将跨度注释添加到文本中,而无需从头编写代码。src/com/lingpipe/coobook/chapter5/WriteSentDetectedChunks类包含了引用的代码:

  1. 句子块是按照第一个句子检测配方创建的。以下代码将块提取为Set<Chunk>,然后按Chunk.LONGEST_MATCH_ORDER_COMPARITOR排序。在Javadoc中,比较器被定义为:

    根据文本位置比较两个块。如果一个块比另一个块开始晚,或者如果它以相同的位置开始但结束得更早,则该块更大。

    此外,还有TEXT_ORDER_COMPARITOR,如下所示:

    String textStored = chunking.charSequence().toString();
    Set<Chunk> chunkSet = chunking.chunkSet();
    System.out.println("size: " + chunkSet.size());
    Chunk[] chunkArray = chunkSet.toArray(new Chunk[0]);
    Arrays.sort(chunkArray,Chunk.LONGEST_MATCH_ORDER_COMPARATOR);
    
  2. 接下来,我们将以相反的顺序遍历块,这样可以消除为StringBuilder对象的改变长度保持偏移变量的需要。偏移变量是常见的错误来源,所以这个配方尽可能地避免了它们,但进行了非标准的反向循环迭代,这可能会更糟:

    StringBuilder output = new StringBuilder(textStored);
    int sentBoundOffset = 0;
    for (int i = chunkArray.length -1; i >= 0; --i) {
      Chunk chunk = chunkArray[i];
      String sentence = textStored.substring(chunk.start(), chunk.end());
      if (sentence.contains("like")) {
        output.insert(chunk.end(),"}");
        output.insert(chunk.start(),"{");
      }
    }
    System.out.println(output.toString());
    
  3. 上述代码通过查找句子中的字符串like来进行非常简单的情感分析,并在true时标记该句子。请注意,此代码无法处理重叠块或嵌套块。它假设一个单一的非重叠块集。以下是一些示例输出:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.WriteSentDetectedChunks
    Enter text followed by new line
    >People like to ski. But sometimes it is terrifying. 
    size: 2
    {People like to ski.} But sometimes it is terrifying. 
    
    
  4. 要打印嵌套块,请查看下面的段落 检测配方。

段落检测

句子集的典型包含结构是段落。它可以在HTML中的<p>等标记语言中显式设置,或者通过两个或更多的新行,这是段落通常的渲染方式。我们处于NLP的这样一个部分,没有硬性规则适用,所以我们对此表示歉意。我们将在本章中处理一些常见示例,并将其留给你自己去推广。

如何做...

我们从未为段落检测设置过评估工具,但它可以通过与句子检测类似的方式进行。这个配方将展示一个简单的段落检测程序,它做了一件非常重要的事情——使用嵌入的句子检测来维护对原始文档的偏移量。如果你需要以对句子或其他文档子跨度(如命名实体)敏感的方式标记文档,这种对细节的关注将对你大有裨益。考虑以下示例:

Sentence 1\. Sentence 2
Sentence 3\. Sentence 4.

它被转换成以下形式:

{[Sentence 1.] [Sentence 2]}

{[Sentence 3.] [Sentence 4.]
}
[]designates sentences, and {} designates paragraphs. We will jump right into the code on this recipe from src/com/lingpipe/cookbook/chapter5/ParagraphSentenceDetection.java:
  1. 示例代码在段落检测技术方面提供很少的内容。这是一个开放性问题,你将不得不运用你的技巧来解决它。我们的段落检测器是一个可怜的 split("\n\n"),在更复杂的方法中,将考虑上下文、字符和其他对我们来说过于特殊而无法涵盖的特征。以下是代码的起始部分,它将整个文档作为字符串读取并将其分割成数组。请注意,paraSeperatorLength 是构成段落分割的基础字符数——如果分割的长度变化,那么这个长度将必须与相应的段落相关联:

    public static void main(String[] args) throws IOException {
      String document = Files.readFromFile(new File(args[0]), Strings.UTF8);
      String[] paragraphs = document.split("\n\n");
      int paraSeparatorLength = 2;
    
  2. 菜单的真正目的是帮助处理将字符偏移量维持到原始文档中的机制,并展示嵌入式处理。这将通过保持两个独立的分块来实现:一个用于段落,一个用于句子:

    ChunkingImpl paraChunking = new ChunkingImpl(document.toCharArray(),0,document.length());
    ChunkingImpl sentChunking = new ChunkingImpl(paraChunking.charSequence());
    
  3. 接下来,句子检测器将以与之前菜谱中相同的方式设置:

    boolean eosIsSentBoundary = true;
    boolean balanceParens = false;
    SentenceModel sentenceModel = new IndoEuropeanSentenceModel(eosIsSentBoundary, balanceParens);
    SentenceChunker sentenceChunker = new SentenceChunker(IndoEuropeanTokenizerFactory.INSTANCE, sentenceModel);
    
  4. 分块遍历段落数组并为每个段落构建一个句子分块。这种方法中较为复杂的部分是,句子分块的偏移量是相对于段落字符串的,而不是整个文档。因此,代码中变量的开始和结束位置将使用文档偏移量更新。块没有调整开始和结束的方法,因此必须创建一个新的块 adjustedSentChunk,带有适当的段落起始偏移量,并将其添加到 sentChunking

    int paraStart = 0;
    for (String paragraph : paragraphs) {
      for (Chunk sentChunk : sentenceChunker.chunk(paragraph).chunkSet()) {
        Chunk adjustedSentChunk = ChunkFactory.createChunk(sentChunk.start() + paraStart,sentChunk.end() + paraStart, "S");
        sentChunking.add(adjustedSentChunk);
      }
    
  5. 循环的其余部分添加段落块,然后更新段落的开始位置,该位置为段落长度加上段落分隔符的长度。这将完成在原始文档字符串中正确偏移的句子和段落的创建:

    paraChunking.add(ChunkFactory.createChunk(paraStart, paraStart + paragraph.length(),"P"));
    paraStart += paragraph.length() + paraSeparatorLength;
    }
    
  6. 程序的其余部分关注于打印带有一些标记的段落和句子。首先,我们将创建一个同时包含句子和段落分块的块:

    String underlyingString = paraChunking.charSequence().toString();
    ChunkingImpl displayChunking = new ChunkingImpl(paraChunking.charSequence());
    displayChunking.addAll(sentChunking.chunkSet());
    displayChunking.addAll(paraChunking.chunkSet());
    
  7. 接下来,displayChunking 将通过恢复 chunkSet,将其转换为块数组并应用静态比较器来排序:

    Set<Chunk> chunkSet = displayChunking.chunkSet();
    Chunk[] chunkArray = chunkSet.toArray(new Chunk[0]);
    Arrays.sort(chunkArray, Chunk.LONGEST_MATCH_ORDER_COMPARATOR);
    
  8. 我们将使用与我们在 在字符串中标记嵌入式块 - 句子分块示例 菜单中使用的相同技巧,即将标记反向插入到字符串中。我们将需要保留一个偏移计数器,因为嵌套句子将扩展完成段落标记的位置。这种方法假设没有块重叠,并且句子始终包含在段落中:

    StringBuilder output = new StringBuilder(underlyingString);
    int sentBoundOffset = 0;
    for (int i = chunkArray.length -1; i >= 0; --i) {
      Chunk chunk = chunkArray[i];
      if (chunk.type().equals("P")) {
        output.insert(chunk.end() + sentBoundOffset,"}");
        output.insert(chunk.start(),"{");
        sentBoundOffset = 0;
      }
      if (chunk.type().equals("S")) {
        output.insert(chunk.end(),"]");
        output.insert(chunk.start(),"[");
        sentBoundOffset += 2;
      }
    }
    System.out.println(output.toString());
    
  9. 菜单就到这里。

简单的名词短语和动词短语

这个菜谱将向您展示如何找到简单的名词短语NP)和动词短语VP)。这里的“简单”意味着短语内没有复杂结构。例如,复杂的 NP “The rain in Spain” 将被拆分为两个简单的 NP 块 “The rain” 和 “Spain”。这些短语也被称为“基础”。

这个食谱不会详细介绍如何计算基线NP/VP,而是如何使用这个类——它可能很有用,如果你想要弄清楚它是如何工作的,可以包含源代码。

如何做…

就像许多食谱一样,我们在这里将提供一个命令行交互式界面:

  1. 打开命令行并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.PhraseChunker
    INPUT> The rain in Spain falls mainly on the plain.
    The/at rain/nn in/in Spain/np falls/vbz mainly/rb on/in the/at plain/jj ./. 
     noun(0,8) The rain
     noun(12,17) Spain
     verb(18,30) falls mainly
     noun(34,43) the plain
    
    

它是如何工作的…

main()方法首先反序列化一个词性标注器,然后创建tokenizerFactory

public static void main(String[] args) throws IOException, ClassNotFoundException {
  File hmmFile = new File("models/pos-en-general-brown.HiddenMarkovModel");
  HiddenMarkovModel posHmm = (HiddenMarkovModel) AbstractExternalizable.readObject(hmmFile);
  HmmDecoder posTagger  = new HmmDecoder(posHmm);
  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;

接下来,构建PhraseChunker,这是一种针对该问题的启发式方法。查看源代码以了解其工作原理——它从左到右扫描输入以查找NP/VP的开始,并尝试增量地添加到短语中:

PhraseChunker chunker = new PhraseChunker(posTagger,tokenizerFactory);

我们的标准控制台I/O代码如下:

BufferedReader bufReader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.print("\n\nINPUT> ");
  String input = bufReader.readLine();

然后,对输入进行分词,进行词性标注,并打印出分词和标签:

Tokenizer tokenizer = tokenizerFactory.tokenizer(input.toCharArray(),0,input.length());
String[] tokens = tokenizer.tokenize();
List<String> tokenList = Arrays.asList(tokens);
Tagging<String> tagging = posTagger.tag(tokenList);
for (int j = 0; j < tokenList.size(); ++j) {
  System.out.print(tokens[j] + "/" + tagging.tag(j) + " ");
}
System.out.println();

然后计算并打印出NP/VP分块:

Chunking chunking = chunker.chunk(input);
CharSequence cs = chunking.charSequence();
for (Chunk chunk : chunking.chunkSet()) {
  String type = chunk.type();
  int start = chunk.start();
  int end = chunk.end();
  CharSequence text = cs.subSequence(start,end);
  System.out.println("  " + type + "(" + start + ","+ end + ") " + text);
  }

http://alias-i.com/lingpipe/demos/tutorial/posTags/read-me.html有一个更全面的教程。

基于正则表达式的NER分块

命名实体识别NER)是找到文本中特定事物提及的过程。考虑一个简单的名字;一个地点命名实体识别器可能会在以下文本中将Ford PrefectGuildford分别识别为名字和地点提及:

Ford Prefect used to live in Guildford before he needed to move.

我们将首先构建基于规则的NER系统,然后逐步过渡到机器学习方法。在这里,我们将探讨如何构建一个可以从文本中提取电子邮件地址的NER系统。

如何做…

  1. 在命令提示符中输入以下命令:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.RegexNer
    
    
  2. 与程序的交互过程如下:

    Enter text, . to quit:
    >Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com.
    input=Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com.
    chunking=Hello,my name is Foo and my email is foo@bar.com or you can also contact me at foo.bar@gmail.com. : [37-48:email@0.0, 79-96:email@0.0]
     chunk=37-48:email@0.0  text=foo@bar.com
     chunk=79-96:email@0.0  text=foo.bar@gmail.com
    
    
  3. 你可以看到,foo@bar.com以及foo.bar@gmail.com都被返回为有效的e-mail类型分块。此外,请注意,句子中的最后一个句点不是第二个电子邮件的一部分。

它是如何工作的…

正则表达式分块器找到与给定正则表达式匹配的分块。本质上,使用java.util.regex.Matcher.find()方法迭代地找到匹配的文本段,然后这些段被转换为Chunk对象。RegExChunker类封装了这些步骤。src/com/lingpipe/cookbook/chapter5/RegExNer.java的代码描述如下:

public static void main(String[] args) throws IOException {
  String emailRegex = "[A-Za-z0-9](([_\\.\\-]?[a-zA-Z0-9]+)*)" + + "@([A-Za-z0-9]+)" + "(([\\.\\-]?[a-zA-Z0-9]+)*)\\.([A-Za-z]{2,})";
  String chunkType = "email";
  double score = 1.0;
  Chunker chunker = new RegExChunker(emailRegex,chunkType,score);

所有有趣的工作都在前面的代码行中完成了。emailRegex是从互联网上获取的——见下文以获取来源,其余部分是设置chunkTypescore

代码的其余部分读取输入并打印出分块:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
  String input = "";
  while (true) {
    System.out.println("Enter text, . to quit:");
    input = reader.readLine();
    if(input.equals(".")){
      break;
    }
    Chunking chunking = chunker.chunk(input);
    System.out.println("input=" + input);
    System.out.println("chunking=" + chunking);
    Set<Chunk> chunkSet = chunking.chunkSet();
    Iterator<Chunk> it = chunkSet.iterator();
    while (it.hasNext()) {
      Chunk chunk = it.next();
      int start = chunk.start();
      int end = chunk.end();
      String text = input.substring(start,end);
      System.out.println("     chunk=" + chunk + " text=" + text);
    }
  }
}

参见

基于词典的NER分块

在许多网站、博客以及当然是在网络论坛上,你可能会看到关键字高亮显示,链接到你可以购买产品的页面。同样,新闻网站也为人物、地点和热门事件提供主题页面,例如http://www.nytimes.com/pages/topics/

这里的许多操作都是完全自动化的,并且使用基于词典的Chunker很容易做到。编译实体及其类型的名称列表非常直接。精确的词典分词器根据标记化词典条目的精确匹配提取分词。

LingPipe中基于词典的分词器实现基于Aho-Corasick算法,该算法在字典中找到所有匹配项,其时间复杂度与匹配项的数量或字典的大小无关。这使得它比使用子字符串搜索或正则表达式的朴素方法要高效得多。

如何做到这一点...

  1. 在你选择的IDE中运行chapter5包中的DictionaryChunker类,或者使用命令行输入以下内容:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.DictionaryChunker
    
    
  2. 由于这个特定的分词示例偏向于《银河系漫游指南》(非常严重),让我们使用一个涉及一些角色的句子:

    Enter text, . to quit:
    Ford and Arthur went up the bridge of the Heart of Gold with Marvin
    CHUNKER overlapping, case sensitive
     phrase=|Ford| start=0 end=4 type=PERSON score=1.0
     phrase=|Arthur| start=9 end=15 type=PERSON score=1.0
     phrase=|Heart| start=42 end=47 type=ORGAN score=1.0
     phrase=|Heart of Gold| start=42 end=55 type=SPACECRAFT score=1.0
     phrase=|Marvin| start=61 end=67 type=ROBOT score=1.0
    
    
  3. 注意,我们从HeartHeart of Gold中得到了重叠的分词。正如我们将看到的,这可以配置为以不同的方式行为。

它是如何工作的...

基于词典的命名实体识别(NER)在针对非结构化文本数据的大量自动链接中发挥着重要作用。我们可以通过以下步骤构建一个:

代码的第一步将创建MapDictionary<String>来存储词典条目:

static final double CHUNK_SCORE = 1.0;

public static void main(String[] args) throws IOException {
  MapDictionary<String> dictionary = new MapDictionary<String>();
  MapDictionary<String> dictionary = new MapDictionary<String>();

接下来,我们将使用DictionaryEntry<String>填充词典,它包括类型信息和用于创建分词的分数:

dictionary.addEntry(new DictionaryEntry<String>("Arthur","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Ford","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Trillian","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Zaphod","PERSON",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Marvin","ROBOT",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("Heart of Gold", "SPACECRAFT",CHUNK_SCORE));
dictionary.addEntry(new DictionaryEntry<String>("HitchhikersGuide", "PRODUCT",CHUNK_SCORE));

DictionaryEntry构造函数中,第一个参数是短语,第二个字符串参数是类型,最后一个双精度参数是分词的分数。词典条目总是区分大小写的。词典中不同实体类型的数量没有限制。分数将简单地作为基于词典的分词器中的分词分数传递。

接下来,我们将构建Chunker

boolean returnAllMatches = true;
boolean caseSensitive = true;
ExactDictionaryChunker dictionaryChunker = new ExactDictionaryChunker(dictionary, IndoEuropeanTokenizerFactory.INSTANCE, returnAllMatches,caseSensitive);

一个精确的词典分词器可能被配置为提取所有匹配的分词,通过returnAllMatches布尔值将结果限制为一致的非重叠分词集。查看Javadoc以了解确切的标准。还有一个caseSensitive布尔值。分词器需要一个分词器,因为它将标记作为符号进行匹配,并且在匹配过程中忽略空白字符。

接下来是我们的标准I/O代码,用于控制台交互:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String text = "";
while (true) {
  System.out.println("Enter text, . to quit:");
  text = reader.readLine();
  if(text.equals(".")){
    break;
  }

剩余的代码创建分词,遍历分词并打印它们:

System.out.println("\nCHUNKER overlapping, case sensitive");
Chunking chunking = dictionaryChunker.chunk(text);
  for (Chunk chunk : chunking.chunkSet()) {
    int start = chunk.start();
    int end = chunk.end();
    String type = chunk.type();
    double score = chunk.score();
    String phrase = text.substring(start,end);
    System.out.println("     phrase=|" + phrase + "|" + " start=" + start + " end=" + end + " type=" + type + " score=" + score);

即使在基于机器学习的系统中,词典分词器也非常有用。通常总有一类实体最适合以这种方式识别。"混合NER来源"配方解决了如何处理多个命名实体来源的问题。

词语标记和块之间的翻译 – BIO 编码器

在第 4 章 “标记词语和标记” 中,我们使用了 HMM 和 CRF 来对词语/标记应用标记。这个配方解决了从使用 开始,内部,和外部BIO) 标记来编码可以跨越多个词语/标记的块分割的标记中创建块的情况。这反过来又是现代命名实体检测系统的基础。

准备工作

标准的 BIO 标记方案将类型为 X 的块中的第一个标记标记为 B-X(开始),而同一块中的所有后续标记标记为 I-X(内部)。所有不在块中的标记标记为 O(外部)。例如,具有字符计数的字符串:

John Jones Mary and Mr. Jones
01234567890123456789012345678
0         1         2         

它可以标记为:

John  B_PERSON
Jones  I_PERSON
Mary  B_PERSON
and  O
Mr    B_PERSON
.    I_PERSON
Jones  I_PERSON

对应的块将是:

0-10 "John Jones" PERSON
11-15 "Mary" PERSON
20-29 "Mr. Jones" PERSON

如何做到这一点…

程序将显示标记和块分割之间的最简单映射以及相反的映射:

  1. 运行以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.BioCodec
    
    
  2. 程序首先打印出将被标记的字符串:

    Tagging for :The rain in Spain.
    The/B_Weather
    rain/I_Weather
    in/O
    Spain/B_Place
    ./O
    
    
  3. 接下来,打印出块分割:

    Chunking from StringTagging
    0-8:Weather@-Infinity
    12-17:Place@-Infinity
    
    
  4. 然后,从刚刚显示的块分割创建标记:

    StringTagging from Chunking
    The/B_Weather
    rain/I_Weather
    in/O
    Spain/B_Place
    ./O
    
    

它是如何工作的…

代码首先手动构建 StringTagging——我们将看到 HMM 和 CRF 以编程方式执行相同的操作,但在这里是明确的。然后打印出创建的 StringTagging

public static void main(String[] args) {
  List<String> tokens = new ArrayList<String>();
  tokens.add("The");
  tokens.add("rain");
  tokens.add("in");
  tokens.add("Spain");
  tokens.add(".");
  List<String> tags = new ArrayList<String>();
  tags.add("B_Weather");
  tags.add("I_Weather");
  tags.add("O");
  tags.add("B_Place");
  tags.add("O");
  CharSequence cs = "The rain in Spain.";
  //012345678901234567
  int[] tokenStarts = {0,4,9,12,17};
  int[] tokenEnds = {3,8,11,17,17};
  StringTagging tagging = new StringTagging(tokens, tags, cs, tokenStarts, tokenEnds);
  System.out.println("Tagging for :" + cs);
  for (int i = 0; i < tagging.size(); ++i) {
    System.out.println(tagging.token(i) + "/" + tagging.tag(i));
  }

接下来,它将构建 BioTagChunkCodec 并将刚刚打印出的标记转换为块分割,然后打印出块分割:

BioTagChunkCodec codec = new BioTagChunkCodec();
Chunking chunking = codec.toChunking(tagging);
System.out.println("Chunking from StringTagging");
for (Chunk chunk : chunking.chunkSet()) {
  System.out.println(chunk);
}

剩余的代码将反转此过程。首先,创建一个不同的 BioTagChunkCodec,带有 boolean enforceConsistency,如果为 true,则检查由提供的分词器创建的标记与块开始和结束的精确对齐。如果没有对齐,我们最终会得到一个可能无法维持的块和标记之间的关系,这取决于用例:

boolean enforceConsistency = true;
BioTagChunkCodec codec2 = new BioTagChunkCodec(IndoEuropeanTokenizerFactory.INSTANCE, enforceConsistency);
StringTagging tagging2 = codec2.toStringTagging(chunking);
System.out.println("StringTagging from Chunking");
for (int i = 0; i < tagging2.size(); ++i) {
  System.out.println(tagging2.token(i) + "/" + tagging2.tag(i));
}

最后的 for 循环简单地打印出 codec2.toStringTagging() 方法返回的标记。

更多内容…

该配方通过标记和块之间映射的最简单示例来工作。BioTagChunkCodec 还接受 TagLattice<String> 对象以生成 n-best 输出,正如接下来的 HMM 和 CRF 块分割器将展示的那样。

基于HMM的命名实体识别

HmmChunker 使用 HMM 对标记化的字符序列进行块分割。实例包含模型和分词器工厂的 HMM 解码器。块分割器要求 HMM 的状态符合块分割的按标记编码。它使用分词器工厂将块分解成标记和标记的序列。请参阅第 4 章 “隐藏马尔可夫模型 (HMM) – 词性” 中的配方,标记词语和标记

我们将查看训练 HmmChunker 并使用它进行 CoNLL2002 西班牙语任务。你可以也应该使用自己的数据,但这个配方假设训练数据将以 CoNLL2002 格式。

训练使用 ObjectHandler 完成,它提供训练实例。

准备工作

由于我们想要训练这个分词器,我们需要使用 计算自然语言学习CoNLL)模式标记一些数据,或者使用公开可用的数据。为了提高速度,我们将选择获取 CoNLL 2002 任务中可用的语料库。

注意

ConNLL 是一个年度会议,它赞助了一个烘焙比赛。在 2002 年,比赛涉及西班牙语和荷兰语的命名实体识别。

数据可以从 http://www.cnts.ua.ac.be/conll2002/ner.tgz 下载。

与我们之前展示的配方类似;让我们看看这些数据看起来像什么:

El       O 
Abogado     B-PER 
General     I-PER 
del     I-PER 
Estado     I-PER 
,       O 
Daryl     B-PER 
Williams     I-PER 
,       O

在这种编码方案中,短语 El Abogado General del EstadoDaryl Williams 被编码为人物,分别用标签 B-PER 和 I-PER 挑选出它们的起始和持续标记。

注意

数据中存在一些格式错误,在解析器能够处理它们之前必须修复。在 data 目录中解压 ner.tgz 后,您将需要转到 data/ner/data,解压以下文件,并按指示修改:

esp.train, line 221619, change I-LOC to B-LOC
esp.testa, line 30882, change I-LOC to B-LOC
esp.testb, line 9291, change I-LOC to B-LOC

如何操作...

  1. 使用命令行,键入以下内容:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.HmmNeChunker
    
    
  2. 如果模型不存在,它将在 CoNLL 训练数据上运行训练。这可能需要一段时间,所以请耐心等待。训练的输出将如下:

    Training HMM Chunker on data from: data/ner/data/esp.train
    Output written to : models/Conll2002_ESP.RescoringChunker
    Enter text, . to quit:
    
    
  3. 一旦出现输入文本的提示,请输入 CoNLL 测试集中的西班牙文文本:

    La empresa también tiene participación en Tele Leste Celular , operadora móvil de los estados de Bahía y Sergipe y que es controlada por la española Iberdrola , y además es socia de Portugal Telecom en Telesp Celular , la operadora móvil de Sao Paulo .
    Rank   Conf      Span         Type     Phrase
    0      1.0000   (105, 112)    LOC      Sergipe
    1      1.0000   (149, 158)    ORG      Iberdrola
    2      1.0000   (202, 216)    ORG      Telesp Celular
    3      1.0000   (182, 198)    ORG      Portugal Telecom
    4      1.0000   (97, 102)     LOC      Bahía
    5      1.0000   (241, 250)    LOC      Sao Paulo
    6      0.9907   (163, 169)    PER      además
    7      0.9736   (11, 18)      ORG      también
    8      0.9736   (39, 60)      ORG      en Tele Leste Celular
    9      0.0264   (42, 60)      ORG      Tele Leste Celular
    
    
  4. 我们将看到一系列实体,它们的置信度分数,原始句子中的范围,实体的类型,以及代表这个实体的短语。

  5. 要找出正确的标签,请查看注释过的 esp.testa 文件,其中包含以下标签用于这个句子:

    Tele B-ORG
    Leste I-ORG
    Celular I-ORG
    Bahía B-LOC
    Sergipe B-LOC
    Iberdrola B-ORG
    Portugal B-ORG
    Telecom I-ORG
    Telesp B-ORG
    Celular I-ORG
    Sao B-LOC
    Paulo I-LOC
    
    
  6. 这可以读作如下:

    Tele Leste Celular      ORG
    Bahía                   LOC
    Sergipe                 LOC
    Iberdrola               ORG
    Portugal Telecom        ORG
    Telesp Celular          ORG
    Sao Paulo               LOC
    
    
  7. 因此,我们得到了所有 1.000 置信度正确的那些,其余的都是错误的。这可以帮助我们在生产中设置一个阈值。

它是如何工作的...

CharLmRescoringChunker 提供了一个基于长距离字符语言模型的分词器,它通过重新评分包含的字符语言模型 HMM 分词器的输出来操作。底层分词器是 CharLmHmmChunker 的一个实例,它使用构造函数中指定的分词器工厂、n-gram 长度、字符数和插值比进行配置。

让我们从 main() 方法开始;在这里,我们将设置分词器,如果它不存在,则对其进行训练,然后允许输入一些文本以获取命名实体:

String modelFilename = "models/Conll2002_ESP.RescoringChunker";
String trainFilename = "data/ner/data/esp.train";

如果你在数据目录中解压 CoNLL 数据(tar –xvzf ner.tgz),训练文件将位于正确的位置。请记住,要更正 esp.train 文件中第 221619 行的注释。如果你使用其他数据,那么请修改并重新编译类。

下一段代码在模型不存在时训练模型,然后加载分词器的序列化版本。如果你对反序列化有疑问,请参阅第 1 章 反序列化和运行分类器 的配方,简单分类器。考虑以下代码片段:

File modelFile = new File(modelFilename);
if(!modelFile.exists()){
  System.out.println("Training HMM Chunker on data from: " + trainFilename);
  trainHMMChunker(modelFilename, trainFilename);
  System.out.println("Output written to : " + modelFilename);
}

@SuppressWarnings("unchecked")
RescoringChunker<CharLmRescoringChunker> chunker = (RescoringChunker<CharLmRescoringChunker>) AbstractExternalizable.readObject(modelFile);

trainHMMChunker() 方法在设置 CharLmRescoringChunker 的配置参数之前,进行了一些 File 记录:

static void trainHMMChunker(String modelFilename, String trainFilename) throws IOException{
  File modelFile = new File(modelFilename);
  File trainFile = new File(trainFilename);

  int numChunkingsRescored = 64;
  int maxNgram = 12;
  int numChars = 256;
  double lmInterpolation = maxNgram; 
  TokenizerFactory factory
    = IndoEuropeanTokenizerFactory.INSTANCE;

CharLmRescoringChunker chunkerEstimator
  = new CharLmRescoringChunker(factory,numChunkingsRescored,
          maxNgram,numChars,
          lmInterpolation);
chunkerEstimator with the setHandler() method, and then, the parser.parse() method does the actual training. The last bit of code serializes the model to disk—see the *How to serialize a LingPipe object – classifier example* recipe in Chapter 1, *Simple Classifiers*, to read about what is going on:
Conll2002ChunkTagParser parser = new Conll2002ChunkTagParser();
parser.setHandler(chunkerEstimator);
parser.parse(trainFile);
AbstractExternalizable.compileTo(chunkerEstimator,modelFile);

现在,让我们看看如何解析 CoNLL 数据。这个类的来源是 src/com/lingpipe/cookbook/chapter5/Conll2002ChunkTagParser

public class Conll2002ChunkTagParser extends StringParser<ObjectHandler<Chunking>>
{

  static final String TOKEN_TAG_LINE_REGEX = "(\\S+)\\s(\\S+\\s)?(O|[B|I]-\\S+)";
  static final int TOKEN_GROUP = 1;
  static final int TAG_GROUP = 3;
  static final String IGNORE_LINE_REGEX = "-DOCSTART(.*)";
  static final String EOS_REGEX = "\\A\\Z";
  static final String BEGIN_TAG_PREFIX = "B-";
  static final String IN_TAG_PREFIX = "I-";
  static final String OUT_TAG = "O";

静态设置 com.aliasi.tag.LineTaggingParser LingPipe 类的配置。CoNLL,像许多可用的数据集一样,使用每行一个标记/标记的格式,这种格式旨在非常容易解析:

private final LineTaggingParser mParser = new LineTaggingParser(TOKEN_TAG_LINE_REGEX, TOKEN_GROUP, TAG_GROUP, IGNORE_LINE_REGEX, EOS_REGEX);

LineTaggingParser 构造函数需要一个正则表达式,该正则表达式通过分组识别标记和标记字符串。此外,还有一个用于忽略行的正则表达式,最后是一个用于句子结束的正则表达式。

接下来,我们设置 TagChunkCodec;这将处理从 BIO 格式的标记标记到正确分块的映射。有关这里发生的事情的更多信息,请参阅之前的菜谱,在词标记和分块之间转换 – BIO codec。其余参数定制标记以匹配 CoNLL 训练数据:

private final TagChunkCodec mCodec = new BioTagChunkCodec(null, false, BEGIN_TAG_PREFIX, IN_TAG_PREFIX, OUT_TAG);

类的其余部分提供了 parseString() 方法,该方法立即发送到 LineTaggingParser 类:

public void parseString(char[] cs, int start, int end) {
  mParser.parseString(cs,start,end);
}

接下来,使用 codec 和提供的处理器正确配置了 ObjectHandler 解析器:

public void setHandler(ObjectHandler<Chunking> handler) {

  ObjectHandler<Tagging<String>> taggingHandler = TagChunkCodecAdapters.chunkingToTagging(mCodec, handler);
  mParser.setHandler(taggingHandler);
}

public TagChunkCodec getTagChunkCodec(){
  return mCodec;
}

这段代码看起来很奇怪,但它所做的只是设置一个解析器来读取输入文件的行并从中提取分块。

最后,让我们回到 main 方法,看看输出循环。我们将设置 MAX_NBEST 分块值为 10,然后对分块器调用 nBestChunkings 方法。这提供了前 10 个分块及其概率分数。基于评估,我们可以选择在特定分数处截断:

char[] cs = text.toCharArray();
Iterator<Chunk> it = chunker.nBestChunks(cs,0,cs.length, MAX_N_BEST_CHUNKS);
System.out.println(text);
System.out.println("Rank          Conf      Span"    + "    Type     Phrase");
DecimalFormat df = new DecimalFormat("0.0000");

for (int n = 0; it.hasNext(); ++n) {

Chunk chunk = it.next();
double conf = chunk.score();
int start = chunk.start();
int end = chunk.end();
String phrase = text.substring(start,end);
System.out.println(n + " "       + "            "   + df.format(conf)     + "       (" + start  + ", " + end  + ")    " + chunk.type()      + "         " + phrase);
}

还有更多…

关于运行完整评估的更多详细信息,请参阅教程中的评估部分,网址为 http://alias-i.com/lingpipe/demos/tutorial/ne/read-me.html

参见

关于 CharLmRescoringChunkerHmmChunker 的更多详细信息,请参阅:

混合 NER 源

现在我们已经看到了如何构建几种不同类型的 NER,我们可以看看如何将它们组合起来。在这个菜谱中,我们将使用正则表达式分块器、基于字典的分块器和基于 HMM 的分块器,并将它们的输出组合起来,查看重叠部分。

我们将像过去几道菜谱中做的那样初始化几个块分割器,然后将相同的文本通过这些块分割器。最简单的情况是每个块分割器返回一个独特的输出。例如,让我们考虑一个句子,如“美国总统奥巴马今晚将在G-8会议上发表演讲”。如果我们有一个人物块分割器和组织块分割器,我们可能只能得到两个独特的块。然而,如果我们添加一个Presidents of USA块分割器,我们将得到三个块:PERSONORGANIZATIONPRESIDENT。这个非常简单的菜谱将展示处理这些情况的一种方法。

如何做到这一点...

  1. 使用命令行或你IDE中的等效工具,输入以下内容:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter5.MultipleNer
    
    
  2. 常见的交互式提示如下:

    Enter text, . to quit:
    President Obama is scheduled to arrive in London this evening. He will address the G-8 summit.
    neChunking: [10-15:PERSON@-Infinity, 42-48:LOCATION@-Infinity, 83-86:ORGANIZATION@-Infinity]
    pChunking: [62-66:MALE_PRONOUN@1.0]
    dChunking: [10-15:PRESIDENT@1.0]
    ----Overlaps Allowed
    
     Combined Chunks:
    [83-86:ORGANIZATION@-Infinity, 10-15:PERSON@-Infinity, 10-15:PRESIDENT@1.0, 42-48:LOCATION@-Infinity, 62-66:MALE_PRONOUN@1.0]
    
    ----Overlaps Not Allowed
    
     Unique Chunks:
    [83-86:ORGANIZATION@-Infinity, 42-48:LOCATION@-Infinity, 62-66:MALE_PRONOUN@1.0]
    
     OverLapped Chunks:
    [10-15:PERSON@-Infinity, 10-15:PRESIDENT@1.0]
    
    
  3. 我们看到来自三个块分割器的输出:neChunking是一个训练有素以返回MUC-6实体的HMM块分割器的输出,pChunking是一个简单的正则表达式,用于识别男性代词,而dChunking是一个识别美国总统的字典块分割器。

  4. 允许重叠的情况下,我们将在合并输出中看到PRESIDENT以及PERSON的块。

  5. 禁止重叠的情况下,它们将被添加到重叠块集合中,并从唯一块集合中移除。

它是如何工作的...

我们初始化了三个块分割器,这些块分割器应该在本章之前的菜谱中对你来说很熟悉:

Chunker pronounChunker = new RegExChunker(" He | he | Him | him", "MALE_PRONOUN",1.0);
File MODEL_FILE = new File("models/ne-en-news.muc6." + "AbstractCharLmRescoringChunker");
Chunker neChunker = (Chunker) AbstractExternalizable.readObject(MODEL_FILE);

MapDictionary<String> dictionary = new MapDictionary<String>();
dictionary.addEntry(
  new DictionaryEntry<String>("Obama","PRESIDENT",CHUNK_SCORE));
dictionary.addEntry(
  new DictionaryEntry<String>("Bush","PRESIDENT",CHUNK_SCORE));
ExactDictionaryChunker dictionaryChunker = new ExactDictionaryChunker(dictionary, IndoEuropeanTokenizerFactory.INSTANCE);

现在,我们将通过所有三个块分割器对输入文本进行块分割,将块组合成一个集合,并将我们的getCombinedChunks方法传递给它:

Set<Chunk> neChunking = neChunker.chunk(text).chunkSet();
Set<Chunk> pChunking = pronounChunker.chunk(text).chunkSet();
Set<Chunk> dChunking = dictionaryChunker.chunk(text).chunkSet();
Set<Chunk> allChunks = new HashSet<Chunk>();
allChunks.addAll(neChunking);
allChunks.addAll(pChunking);
allChunks.addAll(dChunking);
getCombinedChunks(allChunks,true);//allow overlaps
getCombinedChunks(allChunks,false);//no overlaps

这个菜谱的核心在于getCombinedChunks方法。我们只需遍历所有块,并检查每一对是否在起始和结束位置重叠。如果它们重叠且不允许重叠,则将它们添加到一个重叠集中;否则,将它们添加到一个组合集中:

static void getCombinedChunks(Set<Chunk> chunkSet, boolean allowOverlap){
  Set<Chunk> combinedChunks = new HashSet<Chunk>();
  Set<Chunk>overLappedChunks = new HashSet<Chunk>();
  for(Chunk c : chunkSet){
    combinedChunks.add(c);
    for(Chunk x : chunkSet){
      if (c.equals(x)){
        continue;
      }
      if (ChunkingImpl.overlap(c,x)) {
        if (allowOverlap){
          combinedChunks.add(x);
        } else {
          overLappedChunks.add(x);
          combinedChunks.remove(c);
        }
      }
    }
  }
}

这里是添加更多重叠块规则的地方。例如,你可以使其基于分数,如果PRESIDENT块类型比基于HMM的块类型得分更高,你可以选择它。

CRFs用于块分割

CRFs最著名的是在命名实体标注方面提供接近最先进性能。这个菜谱将告诉我们如何构建这样的系统。这个菜谱假设你已经阅读、理解并尝试过第4章中的条件随机字段 – CRF用于单词/标记标注菜谱,它涉及底层技术。与HMMs一样,CRFs将命名实体检测视为一个单词标注问题,有一个解释层提供块分割。与HMMs不同,CRFs使用基于逻辑回归的分类方法,这反过来又允许包括随机特征。此外,这个菜谱紧密遵循(但省略了细节)一个关于CRFs的优秀教程http://alias-i.com/lingpipe/demos/tutorial/crf/read-me.html。Javadoc中也有大量信息。

准备工作

正如我们之前所做的那样,我们将使用一个小型手编语料库作为训练数据。语料库位于 src/com/lingpipe/cookbook/chapter5/TinyEntityCorpus.java。它从以下内容开始:

public class TinyEntityCorpus extends Corpus<ObjectHandler<Chunking>> {

  public void visitTrain(ObjectHandler<Chunking> handler) {
    for (Chunking chunking : CHUNKINGS) handler.handle(chunking);
  }

  public void visitTest(ObjectHandler<Chunking> handler) {
    /* no op */
  }

由于我们只使用这个语料库进行训练,visitTest() 方法不起作用。然而,visitTrain() 方法将处理程序暴露给存储在 CHUNKINGS 常量中的所有分块。这反过来又看起来像以下这样:

static final Chunking[] CHUNKINGS = new Chunking[] {
  chunking(""), chunking("The"), chunking("John ran.", chunk(0,4,"PER")), chunking("Mary ran.", chunk(0,4,"PER")), chunking("The kid ran."), chunking("John likes Mary.", chunk(0,4,"PER"), chunk(11,15,"PER")), chunking("Tim lives in Washington", chunk(0,3,"PER"), chunk(13,23,"LOC")), chunking("Mary Smith is in New York City", chunk(0,10,"PER"), chunk(17,30,"LOC")), chunking("New York City is fun", chunk(0,13,"LOC")), chunking("Chicago is not like Washington", chunk(0,7,"LOC"), chunk(20,30,"LOC"))
};

我们还没有完成。鉴于 Chunking 的创建相当冗长,有一些静态方法可以帮助动态创建所需的对象:

static Chunking chunking(String s, Chunk... chunks) {
  ChunkingImpl chunking = new ChunkingImpl(s);
  for (Chunk chunk : chunks) chunking.add(chunk);
  return chunking;
}

static Chunk chunk(int start, int end, String type) {
  return ChunkFactory.createChunk(start,end,type);
}

这就是所有的设置;接下来,我们将对前面的数据进行训练和运行一个条件随机场(CRF)。

如何做到这一点…

  1. 在命令行中输入 TrainAndRunSimplCrf 类或在您的集成开发环境(IDE)中运行等效命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.TrainAndRunSimpleCrf
    
    
  2. 这会产生大量的屏幕输出,报告 CRF 的健康和进度,这主要是来自驱动整个过程的底层逻辑回归分类器的信息。有趣的是,我们将收到一个邀请来玩新的 CRF:

    Enter text followed by new line
    >John Smith went to New York.
    
    
  3. 分块器报告了第一个最佳输出:

    FIRST BEST
    John Smith went to New York. : [0-10:PER@-Infinity, 19-27:LOC@-Infinity]
    
    
  4. 前面的输出是 CRF 对句子中存在哪些实体类型的第一次最佳分析。它认为 John SmithPER,输出为 the 0-10:PER@-Infinity。我们知道它适用于 John Smith 字符串,是通过在输入文本中从 0 到 10 取子字符串来实现的。忽略 –Infinity,这是为没有得分的分块提供的。第一次最佳分块没有得分。它认为文本中存在的另一个实体是 New York 作为 LOC

  5. 立即,条件概率随之而来:

    10 BEST CONDITIONAL
    Rank log p(tags|tokens)  Tagging
    0    -1.66335590 [0-10:PER@-Infinity, 19-27:LOC@-Infinity]
    1    -2.38671498 [0-10:PER@-Infinity, 19-28:LOC@-Infinity]
    2    -2.77341747 [0-10:PER@-Infinity]
    3    -2.85908677 [0-4:PER@-Infinity, 19-27:LOC@-Infinity]
    4    -3.00398856 [0-10:PER@-Infinity, 19-22:LOC@-Infinity]
    5    -3.23050827 [0-10:PER@-Infinity, 16-27:LOC@-Infinity]
    6    -3.49773765 [0-10:PER@-Infinity, 23-27:PER@-Infinity]
    7    -3.58244582 [0-4:PER@-Infinity, 19-28:LOC@-Infinity]
    8    -3.72315571 [0-10:PER@-Infinity, 19-22:PER@-Infinity]
    9    -3.95386735 [0-10:PER@-Infinity, 16-28:LOC@-Infinity]
    
    
  6. 前面的输出提供了整个短语的 10 个最佳分析,以及它们的条件(自然对数)概率。在这种情况下,我们将看到系统对其分析并不特别自信。例如,第一次最佳分析正确的估计概率为 exp(-1.66)=0.19

  7. 接下来,在输出中,我们可以看到单个分块的概率:

    MARGINAL CHUNK PROBABILITIES
    Rank Chunk Phrase
    0 0-10:PER@-0.49306887565189683 John Smith
    1 19-27:LOC@-1.1957935770408703 New York
    2 0-4:PER@-1.3270942262839682 John
    3 19-22:LOC@-2.484463373596263 New
    4 23-27:PER@-2.6919267821139776 York
    5 16-27:LOC@-2.881057607295971 to New York
    6 11-15:PER@-3.0868632773744222 went
    7 16-18:PER@-3.1583044940140192 to
    8 19-22:PER@-3.2036305275847825 New
    9 23-27:LOC@-3.536294896211011 York
    
    
  8. 与之前的条件输出一样,概率是日志形式,因此我们可以看到 John Smith 分块估计的概率为 exp(-0.49) = 0.61,这是有道理的,因为在训练 CRF 时,它看到了 JohnPER 的开头,Smith 在另一个结尾,但没有直接看到 John Smith

  9. 前面的这种概率分布如果考虑到足够多的资源来考虑广泛的分析和结合证据的方式,以允许选择不太可能的结果,确实可以改善系统。第一次最佳分析往往过于承诺于符合训练数据看起来那样的保守结果。

它是如何工作的…

src/com/lingpipe/cookbook/chapter5/TrainAndRunSimpleCRF.java 中的代码与我们的分类器和隐马尔可夫模型(HMM)配方相似,但有几个不同之处。这些差异如下所述:

public static void main(String[] args) throws IOException {
  Corpus<ObjectHandler<Chunking>> corpus = new TinyEntityCorpus();

  TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
  boolean enforceConsistency = true;
  TagChunkCodec tagChunkCodec = new BioTagChunkCodec(tokenizerFactory, enforceConsistency);

在我们之前玩CRFs时,输入是 Tagging<String> 类型。回顾 TinyEntityCorpus.java,类型是 Chunking 类型。前面的 BioTagChunkCodec 通过提供的 TokenizerFactorybooleanChunking 转换为 Tagging,如果 TokenizerFactoryChunk 的开始和结束不完全一致,则会抛出异常。回顾在单词标记和分块之间转换-BIO编解码器配方以更好地理解此类的作用。

让我们看看以下内容:

John Smith went to New York City. : [0-10:PER@-Infinity, 19-32:LOC@-Infinity]

此编解码器将转换为以下标记:

Tok    Tag
John   B_PER
Smith  I_PER
went  O
to     O
New    B_LOC
York  I_LOC
City  I_LOC
.    O
ChainCrfFeatureExtractor<String> featureExtractor = new SimpleCrfFeatureExtractor();

所有机制都隐藏在一个新的 ChainCrfChunker 类中,并且它的初始化方式类似于逻辑回归,这是其底层技术。有关配置的更多信息,请参阅第3章逻辑回归配方:

int minFeatureCount = 1;
boolean cacheFeatures = true;
boolean addIntercept = true;
double priorVariance = 4.0;
boolean uninformativeIntercept = true;
RegressionPrior prior = RegressionPrior.gaussian(priorVariance, uninformativeIntercept);
int priorBlockSize = 3;
double initialLearningRate = 0.05;
double learningRateDecay = 0.995;
AnnealingSchedule annealingSchedule = AnnealingSchedule.exponential(initialLearningRate, learningRateDecay);
double minImprovement = 0.00001;
int minEpochs = 10;
int maxEpochs = 5000;
Reporter reporter = Reporters.stdOut().setLevel(LogLevel.DEBUG);
System.out.println("\nEstimating");
ChainCrfChunker crfChunker = ChainCrfChunker.estimate(corpus, tagChunkCodec, tokenizerFactory, featureExtractor, addIntercept, minFeatureCount, cacheFeatures, prior, priorBlockSize, annealingSchedule, minImprovement, minEpochs, maxEpochs, reporter);

这里唯一的新事物是 tagChunkCodec 参数,我们刚刚描述了它。

训练完成后,我们将使用以下代码访问分块器以获取最佳结果:

System.out.println("\nFIRST BEST");
Chunking chunking = crfChunker.chunk(evalText);
System.out.println(chunking);

条件分块通过以下方式提供:

int maxNBest = 10;
System.out.println("\n" + maxNBest + " BEST CONDITIONAL");
System.out.println("Rank log p(tags|tokens)  Tagging");
Iterator<ScoredObject<Chunking>> it = crfChunker.nBestConditional(evalTextChars,0, evalTextChars.length,maxNBest);

  for (int rank = 0; rank < maxNBest && it.hasNext(); ++rank) {
    ScoredObject<Chunking> scoredChunking = it.next();
    System.out.println(rank + "    " + scoredChunking.score() + " " + scoredChunking.getObject().chunkSet());
  }

通过以下方式访问单个分块:

System.out.println("\nMARGINAL CHUNK PROBABILITIES");
System.out.println("Rank Chunk Phrase");
int maxNBestChunks = 10;
Iterator<Chunk> nBestIt  = crfChunker.nBestChunks(evalTextChars,0, evalTextChars.length,maxNBestChunks);
for (int n = 0; n < maxNBestChunks && nBestIt.hasNext(); ++n) {
  Chunk chunk = nBestChunkIt.next();
  System.out.println(n + " " + chunk + " " + evalText.substring(chunk.start(),chunk.end()));
}

就这样。您已经可以使用世界上最先进的分块技术之一了。接下来,我们将向您展示如何使其变得更好。

使用CRFs进行NER并具有更好的特征

在这个配方中,我们将向您展示如何为CRFs创建一组真实但并非最先进的特征。这些特征将包括归一化标记、词性标记、词形特征、位置特征以及标记的前缀和后缀。将其替换为CRFs for chunking配方中的 SimpleCrfFeatureExtractor 以使用它。

如何做…

此配方的源代码位于 src/com/lingpipe/cookbook/chapter5/FancyCrfFeatureExtractor.java

  1. 打开您的IDE或命令提示符,并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter5.FancyCrfFeatureExtractor
    
    
  2. 准备好从控制台爆炸般涌现出的功能。用于特征提取的数据是之前配方中的 TinyEntityCorpus。幸运的是,第一部分数据只是句子 John ran. 中 "John" 的节点特征:

    Tagging:  John/PN
    Node Feats:{PREF_NEXT_ra=1.0, PREF_Jo=1.0, POS_np=1.0, TOK_CAT_LET-CAP=1.0, SUFF_NEXT_an=1.0, PREF_Joh=1.0, PREF_NEXT_r=1.0, SUFF_John=1.0, TOK_John=1.0, PREF_NEXT_ran=1.0, BOS=1.0, TOK_NEXT_ran=1.0, SUFF_NEXT_n=1.0, SUFF_NEXT_ran=1.0, SUFF_ohn=1.0, PREF_J=1.0, POS_NEXT_vbd=1.0, SUFF_hn=1.0, SUFF_n=1.0, TOK_CAT_NEXT_ran=1.0, PREF_John=1.0}
    
    
  3. 序列中的下一个词添加了边缘特征——我们不会展示节点特征:

    Edge Feats:{PREV_TAG_TOKEN_CAT_PN_LET-CAP=1.0, PREV_TAG_PN=1.0}
    
    

它是如何工作的…

与其他配方一样,我们不会讨论与之前配方非常相似的部分——这里相关的先前配方是第4章修改CRFs配方,标记单词和标记。这完全一样,只是我们将添加更多功能——也许,来自意想不到的来源。

注意

CRFs教程涵盖了如何序列化/反序列化此类。此实现不包含它。

对象构造类似于第4章修改CRFs配方,标记单词和标记

public FancyCrfFeatureExtractor()
  throws ClassNotFoundException, IOException {
  File posHmmFile = new File("models/pos-en-general" + "brown.HiddenMarkovModel");
  @SuppressWarnings("unchecked") HiddenMarkovModel posHmm = (HiddenMarkovModel)
  AbstractExternalizable.readObject(posHmmFile);

  FastCache<String,double[]> emissionCache = new FastCache<String,double[]>(100000);
  mPosTagger = new HmmDecoder(posHmm,null,emissionCache);
}

构造函数使用缓存设置了一个词性标注器,并将其推入mPosTagger成员变量中。

以下方法做得很少,除了提供一个内部的ChunkerFeatures类:

public ChainCrfFeatures<String> extract(List<String> tokens, List<String> tags) {
  return new ChunkerFeatures(tokens,tags);
}

ChunkerFeatures类是事情变得更有趣的地方:

class ChunkerFeatures extends ChainCrfFeatures<String> {
  private final Tagging<String> mPosTagging;

  public ChunkerFeatures(List<String> tokens, List<String> tags) {
    super(tokens,tags);
    mPosTagging = mPosTagger.tag(tokens);
  }

mPosTagger函数用于在类创建时为提供的标记设置Tagging<String>。这将与tag()token()超类方法对齐,并作为节点特征的词性标签来源。

现在,我们可以继续进行特征提取。我们将从边缘特征开始,因为它们是最简单的:

public Map<String,? extends Number> edgeFeatures(int n, int k) {
  ObjectToDoubleMap<String> feats = new ObjectToDoubleMap<String>();
  feats.set("PREV_TAG_" + tag(k),1.0);
  feats.set("PREV_TAG_TOKEN_CAT_"  + tag(k) + "_" + tokenCat(n-1), 1.0);
  return feats;
}

新特征以PREV_TAG_TOKEN_CAT_为前缀,例如PREV_TAG_TOKEN_CAT_PN_LET-CAP=1.0tokenCat()方法查看前一个标记的词形特征,并将其作为字符串返回。查看IndoEuropeanTokenCategorizer的Javadoc以了解发生了什么。

接下来是节点特征。这里有许多这样的特征;每个将依次介绍:

public Map<String,? extends Number> nodeFeatures(int n) {
  ObjectToDoubleMap<String> feats = new ObjectToDoubleMap<String>();

之前的代码设置了具有适当返回类型的方法。接下来的两行设置了某些状态,以了解特征提取器在字符串中的位置:

boolean bos = n == 0;
boolean eos = (n + 1) >= numTokens();

接下来,我们将计算输入的当前位置、前一个位置和下一个位置的标记类别、标记和词性标签:

String tokenCat = tokenCat(n);
String prevTokenCat = bos ? null : tokenCat(n-1);
String nextTokenCat = eos ? null : tokenCat(n+1);

String token = normedToken(n);
String prevToken = bos ? null : normedToken(n-1);
String nextToken = eos ? null : normedToken(n+1);

String posTag = mPosTagging.tag(n);
String prevPosTag = bos ? null : mPosTagging.tag(n-1);
String nextPosTag = eos ? null : mPosTagging.tag(n+1);

前一个和下一个方法检查我们是否位于句子的开始或结束,并相应地返回null。词性标注是从构造函数中计算出的已保存的词性标注中获取的。

标记方法提供了一些标记的规范化,以将所有数字压缩到同一类型的值。此方法如下:

public String normedToken(int n) {
  return token(n).replaceAll("\\d+","*$0*").replaceAll("\\d","D");
}

这只是将每个数字序列替换为*D...D*。例如,12/3/08被转换为*DD*/*D*/*DD*

然后,我们将为前一个、当前和后续标记设置特征值。首先,一个标志指示它是否是句子或内部节点的开始或结束:

if (bos) {
  feats.set("BOS",1.0);
}
if (eos) {
  feats.set("EOS",1.0);
}
if (!bos && !eos) {
  feats.set("!BOS!EOS",1.0);
}

接下来,我们将包括标记、标记类别及其词性:

feats.set("TOK_" + token, 1.0);
if (!bos) {
  feats.set("TOK_PREV_" + prevToken,1.0);
}
if (!eos) {
  feats.set("TOK_NEXT_" + nextToken,1.0);
}
feats.set("TOK_CAT_" + tokenCat, 1.0);
if (!bos) {
  feats.set("TOK_CAT_PREV_" + prevTokenCat, 1.0);
}
if (!eos) {
  feats.set("TOK_CAT_NEXT_" + nextToken, 1.0);
}
feats.set("POS_" + posTag,1.0);
if (!bos) {
  feats.set("POS_PREV_" + prevPosTag,1.0);
}
if (!eos) {
  feats.set("POS_NEXT_" + nextPosTag,1.0);
}

最后,我们将添加前缀和后缀特征,这些特征为每个后缀和前缀(最长可达预定义长度)添加特征:

for (String suffix : suffixes(token)) {
  feats.set("SUFF_" + suffix,1.0);
}
if (!bos) {
  for (String suffix : suffixes(prevToken)) {
    feats.set("SUFF_PREV_" + suffix,1.0);
    if (!eos) {
      for (String suffix : suffixes(nextToken)) {
        feats.set("SUFF_NEXT_" + suffix,1.0);
      }
      for (String prefix : prefixes(token)) {
        feats.set("PREF_" + prefix,1.0);
      }
      if (!bos) {
        for (String prefix : prefixes(prevToken)) {
          feats.set("PREF_PREV_" + prefix,1.0);
      }
      if (!eos) {
        for (String prefix : prefixes(nextToken)) {
          feats.set("PREF_NEXT_" + prefix,1.0);
        }
      }
      return feats;
    }

之后,我们只需返回生成的特征映射。

prefixsuffix函数简单地使用列表实现:

static int MAX_PREFIX_LENGTH = 4;
  static List<String> prefixes(String s) {
    int numPrefixes = Math.min(MAX_PREFIX_LENGTH,s.length());
    if (numPrefixes == 0) {
      return Collections.emptyList();
    }
    if (numPrefixes == 1) {
      return Collections.singletonList(s);
    }
    List<String> result = new ArrayList<String>(numPrefixes);
    for (int i = 1; i <= Math.min(MAX_PREFIX_LENGTH,s.length()); ++i) {
      result.add(s.substring(0,i));
    }
    return result;
  }

  static int MAX_SUFFIX_LENGTH = 4;
  static List<String> suffixes(String s) {
    int numSuffixes = Math.min(s.length(), MAX_SUFFIX_LENGTH);
    if (numSuffixes <= 0) {
      return Collections.emptyList();
    }
    if (numSuffixes == 1) {
      return Collections.singletonList(s);
    }
    List<String> result = new ArrayList<String>(numSuffixes);
    for (int i = s.length() - numSuffixes; i < s.length(); ++i) {
      result.add(s.substring(i));
    }
    return result;
  }

这是对你的命名实体检测器来说很棒的特征集。

第六章:第6章:字符串比较和聚类

在本章中,我们将介绍以下食谱:

  • 距离和邻近度 – 简单编辑距离

  • 加权编辑距离

  • 杰卡德距离

  • Tf-Idf距离

  • 使用编辑距离和语言模型进行拼写校正

  • 案例恢复校正器

  • 自动短语补全

  • 使用编辑距离进行单链和完全链聚类

  • 潜在狄利克雷分配(LDA)用于多主题聚类

简介

本章首先通过使用标准的中立语言技术比较字符串。然后,我们将使用这些技术构建一些常用的应用。我们还将探讨基于字符串之间距离的聚类技术。

对于一个字符串,我们使用规范定义,即字符串是一系列字符的序列。因此,显然,这些技术适用于单词、短语、句子、段落等,所有这些你都在前面的章节中学过如何提取。

距离和邻近度 – 简单编辑距离

字符串比较是指用于衡量两个字符串之间相似度的技术。我们将使用距离和邻近度来指定任何两个字符串的相似程度。任何两个字符串越相似,它们之间的距离就越小,因此一个字符串到自身的距离是0。一个相反的度量是邻近度,这意味着任何两个字符串越相似,它们的邻近度就越大。

我们首先将研究简单的编辑距离。简单的编辑距离通过需要多少次编辑将一个字符串转换为另一个字符串来衡量距离。1965年由Levenshtien提出的一个常见的距离度量允许删除、插入和替换作为基本操作。加入转置称为Damerau-Levenshtien距离。例如,fooboo 之间的距离是1,因为我们正在查看将 f 替换为 b 的替换操作。

注意

更多关于距离度量的信息,请参考维基百科上的距离文章 http://en.wikipedia.org/wiki/Distance

让我们看看一些可编辑操作的更多示例:

  • 删除BartBar

  • 插入BarBart

  • 替换BarCar

  • 转置BartBrat

如何做到这一点...

现在,我们将通过一个简单的例子来运行编辑距离:

  1. 使用命令行或你的集成开发环境(IDE)运行 SimpleEditDistance 类:

    java –cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.SimpleEditDistance
    
    
  2. 在命令提示符下,你将被提示输入两个字符串:

    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
    
    
  3. 你将看到允许转置和不允许转置的字符串之间的距离。

  4. 通过一些更多示例来了解它是如何工作的——首先手动尝试,然后验证你是否得到了最优解。

它是如何工作的...

这段代码非常简单,它所做的只是创建两个 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。因此,在我们找到abba之间的距离的例子中,有一个删除和一个插入,它们各自的成本都是1.0。因此,如果禁止置换,abba之间的距离是2.0,如果允许置换,则是1.0。请注意,通常,将一个字符串编辑成另一个字符串会有多种方式。

注意

虽然EditDistance使用起来相当简单,但实现起来并不简单。这是Javadoc对这个类所说的话:

实现说明:这个类使用动态规划实现编辑距离,时间复杂度为O(n * m),其中n和m是被比较的序列的长度。使用三个晶格切片的滑动窗口而不是一次性分配整个晶格,所需的空间是两个比较字符序列中较短的长度整数数组的长度。

在接下来的章节中,我们将看到如何为每个编辑操作分配不同的成本。

参见

加权编辑距离

加权编辑距离本质上是一种简单的编辑距离,不同之处在于编辑操作允许与每种编辑操作关联不同的成本。我们在前面的菜谱中确定的编辑操作是替换、插入、删除和置换。此外,还可以与精确匹配相关联一个成本,以增加匹配的权重——这可能在需要编辑时使用,例如字符串变异生成器。编辑权重通常按对数概率缩放,以便你可以分配一个编辑操作的似然性。权重越大,该编辑操作的可能性就越大。由于概率介于0和1之间,对数概率或权重将在负无穷大到零之间。有关更多信息,请参阅WeightedEditDistance类的Javadoc http://alias-i.com/lingpipe/docs/api/com/aliasi/spell/WeightedEditDistance.html

在对数尺度上,加权编辑距离可以通过将匹配权重设置为0,将替换、删除和插入权重设置为-1,将转置权重设置为-1或负无穷大(如果我们想关闭转置操作)来推广,以产生与之前配方中简单编辑距离完全相同的结果。

我们将在其他配方中查看加权编辑距离在拼写检查和中文分词中的应用。

在本节中,我们将使用FixedWeightEditDistance实例并创建一个扩展WeightedEditDistance抽象类的CustomWeightEditDistance类。FixedWeightEditDistance类使用每个编辑操作的权重进行初始化。CustomWeightEditDistance类扩展WeightedEditDistance并为每个编辑操作的权重有规则。删除字母数字字符的权重为-1,而对于所有其他字符,即标点符号和空格,它为0。我们将设置插入权重与删除权重相同。

如何实现...

让我们扩展我们之前的例子,并查看一个同时运行简单编辑距离和我们的加权编辑距离的版本:

  1. 在您的IDE中运行SimpleWeightedEditDistance类,或在命令行中键入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.SimpleWeightedEditDistance
    
    
  2. 在命令行中,您将被提示输入两个字符串:输入这里显示的示例或选择您自己的:如何实现...

  3. 如您所见,这里还展示了两种其他的距离度量:固定权重编辑距离和自定义权重编辑距离。

  4. 尝试其他示例,包括标点符号和空格。

它是如何工作的...

我们将实例化一个具有一些任意选择的权重的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 A3serpina3变化的基因/蛋白质名称,但指的是同一事物。

参考以下内容

Jaccard距离

Jaccard距离是一种非常流行且高效的方法,用于比较字符串。Jaccard距离在令牌级别上操作,首先对两个字符串进行分词,然后通过将共同令牌的数量除以令牌总数来比较这两个字符串。在第1章中“使用Jaccard距离消除近似重复项”的配方中,我们应用了距离来消除近似重复的推文。这个配方将更详细地介绍,并展示它是如何计算的。

0的距离是一个完美匹配,即字符串共享它们所有的术语,而1的距离是一个完美不匹配,即字符串没有共同的术语。记住,相似度和距离是加法逆元,因此相似度也介于1到0之间。相似度为1是完美匹配,相似度为0是完美不匹配:

proximity  = count(common tokens)/count(total tokens)
distance = 1 – proximity

令牌是由TokenizerFactory生成的,该工厂在构建时传入。例如,让我们使用IndoEuropeanTokenizerFactory并查看一个具体示例。如果string1fruit flies like a banana,而string2time 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距离是可以调整的。例如,可以使用一个大小写归一化的分词器,使得Abcabc被视为等效。同样,一个词干提取分词器会将单词runsrun视为等效。我们将在下一个距离度量中看到类似的能力,即Tf-Idf距离。

如何实现...

下面是如何运行JaccardDistance示例:

  1. 在Eclipse中运行JaccardDistanceSample类,或在命令行中输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.JaccardDistanceSample
    
    
  2. 如前所述的食谱,你将被提示输入两个字符串。我们将使用的第一个字符串是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
    
    
  3. 输出包含使用三个不同分词器的标记和距离。IndoEuropeanEnglishStopWord分词器非常接近,显示出这两行相距甚远。记住,两个字符串越接近,它们之间的距离就越小。然而,字符分词器却显示出这些行在字符比较的基础上彼此更接近。分词器在计算字符串之间的距离时可以产生很大的差异。

它是如何工作的...

代码很简单,我们只需覆盖JaccardDistance对象的创建。我们将从三个分词工厂开始:

TokenizerFactory indoEuropeanTf = IndoEuropeanTokenizerFactory.INSTANCE;

TokenizerFactory characterTf = CharacterTokenizerFactory.INSTANCE;

TokenizerFactory englishStopWordTf = new EnglishStopTokenizerFactory(indoEuropeanTf);

注意,englishStopWordTf使用一个基础分词工厂来构建自身。如果有关于这里发生的事情的问题,请参阅第2章, 查找和使用单词

接下来,构建了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距离被定义为对称的,这与为信息检索目的定义的典型Tf-Idf距离不同。

Javadoc中有大量信息值得一看。然而,对于这些配方的目的,你需要知道的是,Tf-Idf距离对于按单词逐个查找相似文档是有用的。

如何实现...

为了保持事情有点趣味性,我们将使用我们的TfIdfDistance类在推文中构建一个非常简单的搜索引擎。我们将执行以下步骤:

  1. 如果你还没有这样做,运行第1章中的TwitterSearch类,简单分类器,获取一些推文来玩耍,或者使用我们提供的数据。我们将使用运行Disney World查询找到的推文,它们已经位于data目录中。

  2. 在命令行中输入以下内容——这使用我们的默认设置:

    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:
    
    
  3. 输入一个具有一些可能单词匹配的查询:

    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
    
    
  4. 就这样。尝试不同的查询并玩转分数。然后,查看源代码。

它是如何工作的...

这段代码是构建搜索引擎的一种非常简单的方式,而不是一种很好的构建方式。然而,它是一种探索搜索上下文中字符串距离概念的好方法。在本书的后面,我们将根据相同的距离度量进行聚类。从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。如果你不熟悉分词器工厂,可以参考第2章中的修改分词器工厂配方,修改分词器工厂,以获取解释:

TokenizerFactory tokFact = IndoEuropeanTokenizerFactory.INSTANCE;
TfIdfDistance tfIdfDist = new TfIdfDistance(tokFact);

然后,我们将通过在训练文本上分割"."来获取将成为IDF组件的数据,这近似于句子检测——我们本可以像在第5章中的文本中的跨度检测配方中那样进行适当的句子检测,但我们选择尽可能简化示例:

String training = Files.readFromFile(new File(idfFile), Strings.UTF8);
for (String line: training.split("\\.")) {
  tfIdfDist.handle(line);
}

for循环内部,有handle(),它使用语料库中标记分布的知识来训练类,句子作为文档。通常情况下,文档的概念要么比通常所说的document小(句子、段落和单词),要么更大。在这种情况下,文档频率将是标记所在的句子数量。

接下来,加载我们正在搜索的文档:

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

如何做到这一点...

让我们看看运行拼写检查所涉及的步骤:

  1. 在您的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 
    
    
  2. 请耐心等待;拼写检查器需要一分钟左右的时间来训练。

  3. 现在,让我们输入一些拼写错误的单词,例如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
    
    
  4. 如您所见,我们得到了输入文本的最佳替代方案以及一些其他替代方案。它们按照成为最佳替代方案的可能性进行排序。

  5. 现在,我们可以尝试不同的输入并看看这个拼写检查器做得如何。尝试在输入中输入多个单词,看看它的表现:

    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
    
    
  6. 此外,尝试输入一些专有名词,看看它们是如何被评估的。

它是如何工作的...

现在,让我们看看是什么让这一切运转。我们将从设置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现在可以构建,接下来,我们将从Project Gutenberg加载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++;
  }
}

提示

我们已经在这个模型中包含了一个字典,我们只需将字典条目像其他任何数据一样输入到训练器中。

可能值得通过训练字典中的每个单词多次来增强字典。根据字典的计数,它可能占主导地位或被源训练数据所支配。

参见

恢复大小写的纠正器

恢复大小写的拼写纠正器,也称为真大小写纠正器,它只恢复大小写,不改变其他任何内容,也就是说,它不会纠正拼写错误。这在处理来自转录、自动语音识别输出、聊天记录等低质量文本时非常有用,这些文本包含各种大小写挑战。我们通常希望增强这些文本以构建更好的基于规则或机器学习系统。例如,新闻和视频转录(如字幕)通常存在错误,这使得使用这些数据来训练命名实体识别变得更加困难。大小写恢复可以用作跨不同数据源的规范化工具,以确保所有数据的一致性。

如何做到这一点...

  1. 在您的 IDE 中运行 CaseRestore 类,或在命令行中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.CaseRestore 
    
    
  2. 现在,让我们输入一些混乱的大小写或单大小写输入:

    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
    
    
  3. 如您所见,混乱的大小写得到了纠正。如果我们使用更现代的文本,如当前报纸数据或类似的内容,这将直接适用于案例归一化的广播新闻转录或字幕。

它是如何工作的...

该类的工作方式与拼写纠正类似,其中我们有一个由语言模型指定的模型和一个由编辑距离度量指定的信道模型。然而,距离度量只允许大小写变化,即大小写变体为零成本,所有其他编辑成本都设置为 Double.NEGATIVE_INFINITY

我们将关注与之前配方不同的地方,而不是详述所有源代码。我们将使用来自 Project Gutenberg 的某些英文文本来训练拼写检查器,并使用 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);

就这些了。大小写恢复变得简单。

参见

自动短语补全

自动短语补全与拼写纠正不同,它会在用户输入的文本中找到一组固定短语中最可能的补全。

显然,自动短语补全在网络上无处不在,例如在 https://google.com。例如,如果我输入 anaz 作为查询,Google 会弹出以下建议:

自动短语补全

注意,应用程序在执行补全的同时也在进行拼写检查。例如,最高建议是amazon,尽管到目前为止的查询是anaz。考虑到以anaz开头的短语报告的结果数量可能非常小,这并不令人惊讶。

接下来,请注意,它不是进行单词建议,而是短语建议。一些结果,如amazon prime,是两个单词。

自动补全和拼写检查之间的重要区别在于,自动补全通常在一系列固定的短语上操作,这些短语必须与补全的开始部分匹配。这意味着,如果我输入查询我想找到anaz,将没有建议的补全。网页搜索的短语来源通常是查询日志中的高频查询。

在LingPipe中,我们使用AutoCompleter类,它维护一个包含计数的短语字典,并根据加权编辑距离和短语可能性提供基于前缀匹配的建议补全。

自动补全器为给定前缀找到最佳评分的短语。短语与前缀的分数是短语分数和前缀与短语任何前缀的最大分数的总和。短语分数只是其最大似然概率估计,即其计数的对数除以所有计数的总和。

谷歌和其他搜索引擎很可能使用它们的查询计数作为最佳评分短语的数据。由于我们没有查询日志,我们将使用关于美国人口超过10万的城市的人口普查数据。短语是城市名称,它们的计数是它们的人口。

如何做...

  1. 在你的IDE中运行AutoComplete类,或者在命令行中输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.AutoComplete 
    
    
  2. 输入一些美国城市名称并查看输出。例如,输入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
    
    
  3. 输入我们初始列表中不存在的城市名称将不会返回任何输出:

    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);

参见

使用编辑距离进行单链接和完全链接聚类

聚类是将一组对象根据它们的相似性进行分组的过程,即使用某种距离度量。聚类背后的想法是,聚类内的对象彼此靠近,但不同聚类中的对象彼此较远。我们可以非常广泛地将聚类技术分为层次(或聚合)技术和分区技术。层次技术首先假设每个对象都是它自己的聚类,然后合并聚类直到满足停止标准。

例如,一个停止标准可以是每个聚类之间的固定距离。分区技术则相反,它首先将所有对象分组到一个聚类中,然后不断分割直到满足停止标准,比如聚类数量。

我们将在接下来的几个菜谱中回顾层次技术。LingPipe 将提供两种聚类实现:单链接聚类和完全链接聚类;生成的聚类形成了一个被称为输入集划分的结果。如果集合中的每个元素恰好是划分中一个集合的成员,那么一组集合就是另一个集合的划分。用数学术语来说,构成划分的集合是成对不相交的,并且它们的并集是原始集合。

聚类器接收一组对象作为输入,并返回一组对象的集合作为输出,即在代码中,Clusterer<String> 有一个 cluster 方法,它作用于 Set<String> 并返回 Set<Set<String>>

层次聚类器扩展了 Clusterer 接口,并且也作用于一组对象,但它返回的是 Dendrogram 而不是一组对象的集合。Dendrogram 是一个二叉树,表示正在聚类的元素,每个分支上附有距离,这表示两个子分支之间的距离。对于 aaaaaaaaaabbbbbbb 这些字符串,基于编辑距离的单链接层次树如下所示:

3.0
 2.0
 1.0
 aaa
 aa
 aaaaa
 1.0
 bbbb
 bbb

上述树状图是基于单链接聚类,它将任意两个元素之间的最小距离作为相似度的度量。因此,当{'aa','aaa'}{'aaaa'}合并时,分数是2.0,通过向aaa添加两个a。完整链接聚类考虑任意两个元素之间的最大距离,这将是一个3.0,通过向aa添加三个a。单链接聚类倾向于创建高度分离的聚类,而完整链接聚类倾向于创建更紧密集中的聚类。

从树状图中提取聚类有两种方法。最简单的方法是设置一个距离界限,并保持所有形成的聚类都小于或等于这个界限。另一种构建聚类的方法是继续切割距离最高的聚类,直到获得指定数量的聚类。

在这个例子中,我们将查看使用编辑距离作为距离度量的单链接和完整链接聚类。我们将尝试通过EditDistance聚类城市名称,最大距离为4。

如何做到这一点…

  1. 在你的集成开发环境(IDE)中运行HierarchicalClustering类,或者在命令行中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.HierarchicalClustering
    
    
  2. 输出是针对相同底层集合的多种聚类方法。在这个菜谱中,我们将交错源代码和输出。首先,我们将创建我们的字符串集合:

    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));
    
  3. 接下来,我们将使用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());
    
  4. 输出将如下所示:

    Single Link Dendrogram
    
    3.0
     2.0
     1.0
     aaa
     aa
     aaaaa
     1.0
     bbbb
     bbb
    
    
  5. 接下来,我们将创建并打印出相同集合的完整链接处理:

    AbstractHierarchicalClusterer<String> clClusterer = new CompleteLinkClusterer<String>(editDistance);
    
    Dendrogram<String> clDendrogram = clClusterer.hierarchicalCluster(inputSet);
    
    System.out.println("\nComplete Link Dendrogram");
    System.out.println(clDendrogram.prettyPrint());
    
  6. 这将生成相同的树状图,但得分不同:

    Complete Link Dendrogram
    
    5.0
     3.0
     1.0
     aaa
     aa
     aaaaa
     1.0
     bbbb
     bbb
    
    
  7. 接下来,我们将生成单链接情况下控制聚类数量的聚类:

    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);
    }
    
  8. 这将生成以下结果——对于完整链接,给定输入集将相同:

    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]]
    
    
  9. 以下代码片段是没有任何最大距离的完整链接聚类:

    Set<Set<String>> slClustering = slClusterer.cluster(inputSet);
    System.out.println("\nComplete Link Clustering No " + "Max Distance");
    System.out.println(slClustering + "\n");
    
  10. 输出将如下所示:

    Complete Link Clustering No Max Distance
    [[bbbb, aaa, aa, aaaaa, bbb]]
    
    
  11. 接下来,我们将控制最大距离:

    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);
    }
    
  12. 以下是在完整链接情况下限制最大距离的聚类效果。请注意,这里的单链接输入将所有元素都放在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]] 
    
    
  13. 就这样!我们已经练习了LingPipe聚类API的大部分功能。

还有更多…

聚类对用于比较聚类的Distance非常敏感。请参阅10个实现类的Javadoc以获取可能的变体。TfIdfDistance在聚类语言数据时非常有用。

K-means(++)聚类是基于特征提取器的聚类。这是Javadoc对它的描述:

K-means聚类可以被视为一种迭代方法,用于最小化项目与其聚类中心之间的平均平方距离…*

参见…

多主题聚类的潜在狄利克雷分配(LDA)

潜在狄利克雷分配LDA)是一种基于文档中出现的标记或单词进行文档聚类的统计技术。聚类,如分类,通常假设类别是互斥的。LDA的巧妙之处在于它允许文档同时属于多个主题,而不仅仅是单个类别。这更好地反映了推特可以关于迪士尼沃尔玛世界等众多主题的事实。

LDA的另一个巧妙之处,就像许多聚类技术一样,是它是无监督的,这意味着不需要监督训练数据!最接近训练数据的是必须事先指定主题的数量。

LDA可以是一种探索数据集的好方法,其中你不知道自己不知道什么。它也可能很难调整,但通常,它会做一些有趣的事情。让我们让系统运行起来。

对于每个文档,LDA根据该文档中的单词为其分配属于某个主题的概率。我们将从已转换为标记序列的文档开始。LDA使用标记的计数,而不关心这些单词出现的上下文或顺序。LDA对每个文档操作的模式被称为“词袋”,以表示顺序并不重要。

LDA模型由固定数量的主题组成,每个主题都被建模为单词上的分布。在LDA下,文档被建模为主题上的分布。主题分布和文档分布上都有一个狄利克雷先验。如果你想了解更多关于幕后发生的事情的信息,请查看Javadoc、参考教程和研究文献。

准备工作

我们将继续使用推文的.csv数据。参考第1章简单分类器,了解如何获取推文,或使用书中的示例数据。食谱使用data/gravity_tweets.csv

这个食谱紧密遵循http://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辅助类,该类将在我们使用其部分内容时进行讨论:

  1. main()方法的顶部从标准csv reader获取数据:

    File corpusFile = new File(args[0]);
     List<String[]> tweets = Util.readCsvRemoveHeader(corpusFile);
    
  2. 接下来,我们将逐行处理一堆配置。minTokenCount 过滤掉算法中看到少于五次的标记。随着数据集变大,这个数字可以更大。对于1100条推文,我们假设至少五次提及有助于减少Twitter数据的整体噪声:

    int minTokenCount = 5;
    
  3. numTopics 参数可能是最重要的配置值,因为它告知算法要找到多少个主题。这个数字的变化可以产生非常不同的主题。你可以尝试一下。选择10意味着1100条推文总体上谈论了10件事情。这显然是错误的;也许100更接近实际情况。1100条推文可能有超过1100个主题,因为一条推文可能属于多个主题。尝试调整它:

    short numTopics = 10;
    
  4. 根据Javadoc,documentTopicPrior 的一个经验规则是将它设置为主题数量的5倍(如果主题很少,则更少;通常最大值为0.1):

    double documentTopicPrior = 0.1;
    
  5. 对于 topicWordPrior,一个通常有用的值如下:

    double topicWordPrior = 0.01;
    
  6. burninEpochs 参数设置在采样之前要运行多少个epoch。将其设置为大于0具有一些期望的特性,即避免样本之间的相关性。sampleLag 控制在烧毁完成后多久取一次样,而 numSamples 控制要取多少个样本。目前,将取2000个样本。如果 burninEpochs 是1000,那么将取3000个样本,采样间隔为1(每次)。如果 sampleLag 是2,那么将有5000次迭代(1000次烧毁,每2个epoch取2000个样本,总共4000个epoch)。请参考Javadoc和教程了解更多关于这里发生的事情:

    int burninEpochs = 0;
    int sampleLag = 1;
    int numSamples = 2000;
    
  7. 最后,randomSeed 初始化 GibbsSampler 中的随机过程:

    long randomSeed = 6474835;
    
  8. SymbolTable 已经构建完成;这将存储字符串到整数的映射,以便于高效处理:

    SymbolTable symbolTable = new MapSymbolTable();
    
  9. 接下来是一个分词器,使用我们标准的分词器:

    TokenzierFactory tokFactory = IndoEuropeanTokenizerFactory.INSTANCE;
    
  10. 然后,打印出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);
    
  11. 然后,我们将创建一个文档和标记的矩阵,这些矩阵将被输入到LDA中,以及一个关于有多少标记存在的报告:

    int[][] docTokens = LatentDirichletAllocation.tokenizeDocuments(IdaTexts,tokFactory,symbolTable, minTokenCount);
    System.out.println("Number of unique words above count" + " threshold=" + symbolTable.numSymbols());
    
  12. 接下来,将进行一个合理性检查,通过报告总标记数:

    int numTokens = 0;
    for (int[] tokens : docTokens){
      numTokens += tokens.length;
    }
    System.out.println("Tokenized.  #Tokens After Pruning=" + numTokens);
    
  13. 为了在epoch/samples上获得进度报告,创建了一个处理程序来传递所需的消息。它接受 symbolTable 作为参数,以便在报告中重新创建标记:

    LdaReportingHandler handler = new LdaReportingHandler(symbolTable);
    
  14. 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()));
      }
    }
    
  15. 在所有这些设置之后,我们将运行LDA:

    LatentDirichletAllocation.GibbsSample sample = LatentDirichletAllocation.gibbsSampler(docTokens, numTopics,documentTopicPrior,wordPrior,burninEpochs,sampleLag,numSamples,new Random(randomSeed),handler);
    
  16. 等等,还有更多!然而,我们几乎完成了。我们只需要一个最终报告:

    int maxWordsPerTopic = 20;
    int maxTopicsPerDoc = 10;
    boolean reportTokens = true;
    handler.reportTopics(sample,maxWordsPerTopic,maxTopicsPerDoc,reportTokens);
    
  17. 最后,我们将运行这段代码。输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar:lib/opencsv-2.4.jar com.lingpipe.cookbook.chapter6.LDA
    
    
  18. 看一下结果的输出样本,以确认配置和搜索epoch的早期报告:

    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
    
    
  19. 在完成epoch后,我们将得到关于找到的主题的报告。第一个主题以按计数排序的词汇列表开始。请注意,该主题没有标题。通过扫描计数高且Z得分高的词汇,我们可以推断出主题“意义”。在这种情况下,有一个单词movie的Z得分为4.0,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
    
  20. 上述输出相当糟糕,其他主题看起来也好不到哪里去。下一个主题没有更多潜力,但一些明显的问题是由于分词引起的:

    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
    
    
  21. 穿上我们系统调优者的帽子,我们将调整分词器为new RegExTokenizerFactory("[^\\s]+")分词器,这实际上清理了聚类,将聚类数量增加到25,并应用Util.filterJaccard(tweets, tokFactory, .5)来移除重复项(从1100减少到301)。这些步骤并不是一次一个完成的,这是一个配方,所以我们展示了实验的结果。没有评估工具,所以这是一个通过做出改变,看看输出是否更好,等等的过程。在这样一个开放性问题中,聚类评估和调优是出了名的困难。输出看起来稍微好一些。

  22. 在浏览主题时,我们了解到其中仍然有很多低价值词汇充斥着主题,但“主题18”看起来有些希望,因为“最佳”和“永远”这两个词的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
    
    
  23. 进一步查看输出结果,我们会看到一些在“主题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)
    
    
  24. 两个选项对于一个“史上最佳电影”主题来说似乎都合理。然而,要警告的是,其他主题/文档作业相当糟糕。

实事求是地说,我们并不能真正宣称在这个数据集上取得了胜利,但我们已经阐述了LDA的工作原理及其配置。LDA并没有取得巨大的商业成功,但它为美国国家卫生研究院和其他客户产生了有趣的概念级实现。LDA是一个调优者的天堂,有很多方式可以玩转生成的聚类。查看教程和Javadoc,并分享你的成功故事。

第七章:第7章. 在概念/人物之间寻找指代关系

在本章中,我们将介绍以下食谱:

  • 与文档的命名实体指代

  • 向指代中添加代词

  • 跨文档指代

  • 约翰·史密斯问题

简介

指代是人类语言中的基本机制,它允许两个句子谈论同一件事。这对人类交流来说意义重大——它在很大程度上与编程语言中变量名的作用相同,只是作用域的定义规则与代码块不同。在商业上,指代的重要性较小——也许这一章能帮助改变这一点。以下是一个例子:

Alice walked into the garden. She was surprised.

Alice和“她”之间存在指代关系;这些短语谈论的是同一件事。当我们开始询问一个文档中的Alice是否与另一个文档中的Alice相同,事情就变得非常有趣。

语义消歧一样,指代消解是下一代工业能力。指代消解的挑战促使美国国税局坚持要求有一个社会安全号码,该号码可以明确地识别个人,而与他们的名字无关。许多讨论的技术都是为了帮助在文本数据中跟踪个人和组织,这些数据具有不同的成功程度。

与文档的命名实体指代

第5章中所述,“在文本中寻找跨度 – 分块”,LingPipe可以使用各种技术来识别与人物、地点、事物、基因等相对应的正确名词。然而,分块并没有完成这项工作,因为它在两个命名实体相同的情况下无法帮助找到实体。能够说约翰·史密斯与史密斯先生、约翰或甚至完全重复的约翰·史密斯是同一实体,这可能非常有用——有用到这种想法成为我们公司作为婴儿防御承包商时的基础。我们的创新贡献是生成按提及的实体索引的句子,这最终证明是总结关于该实体所说内容的极好方法,尤其是如果映射跨越了语言——我们称之为基于实体的摘要

注意

实体基础摘要的想法是在巴德温在宾夕法尼亚大学的一次研究生研讨会上发表演讲后产生的。当时的系主任米奇·马库斯认为,显示提及一个实体的所有句子——包括代词——将是对该实体的极好总结。在某种程度上,这个评论就是为什么LingPipe存在的原因。这导致了巴德温领导宾夕法尼亚大学的DARPA项目,然后创建了Alias-i。学到的教训——与所有人谈论你的想法和研究。

这个食谱将带你了解计算指代的基本知识。

准备工作

找到一些叙事文本;我们将使用一个我们知道可以工作的简单示例——共指系统通常需要对领域进行大量的调整。你可以自由选择其他内容,但它必须用英语编写。

如何做到这一点…

如同往常,我们将带你通过命令行运行代码,然后深入探讨代码的实际功能。我们出发吧。

  1. 我们将从一个简单的文本开始,以说明共指。文件位于data/simpleCoref.txt,它包含:

    John Smith went to Washington. Mr. Smith is a business man.
    
  2. 打开命令行和一个Java解释器,重新生成以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.NamedEntityCoreference
    
    
  3. 这会导致:

    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
    
  4. 找到了三个命名实体。请注意,输出中有一个ID字段。John SmithMr. Smith实体具有相同的ID,id=0。这意味着这些短语被认为是共指的。剩余的实体Washington具有不同的ID,id=1,并且与John Smith / Mr. Smith不共指。

  5. 创建你自己的文本文件,将其作为命令行参数提供,并查看会计算什么。

它是如何工作的…

LingPipe中的共指代码是在句子检测和命名实体识别之上构建的启发式系统。整体流程如下:

  1. 分词文本。

  2. 在文档中检测句子,对于每个句子,在句子中按从左到右的顺序检测命名实体,并对每个命名实体执行以下任务:

    1. 创建一个提及。提及是命名实体的单个实例。

    2. 提及可以添加到现有的提及链中,或者它们可以开始自己的提及链。

    3. 尝试将提及解析到已创建的提及链中。如果找到唯一匹配项,则将提及添加到提及链中;否则,创建一个新的提及链。

代码位于src/com/lingpipe/cookbook/chapter7/NamedEntityCoreference.javamain()方法首先设置本食谱的各个部分,从分词工厂开始,然后是句子块处理器,最后是命名实体块处理器:

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类封装了计算共指的所有机制。从第5章文本中的跨度查找 - 块处理,你应该熟悉获取文档文本、检测句子以及迭代应用命名实体块处理器的句子代码:

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 年代的《华尔街日报》文章上训练的。

  1. 将你的控制台准备好,并输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.Coreference
    
    
  2. 在生成的命令提示符中,输入以下内容:

    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
    
  3. HeJohn Smith 之间的共享 ID 表明两者之间的核心词引用。接下来将有更多示例,并附有注释。请注意,每个输入都被视为一个独立的文档,具有单独的 ID 空间。

  4. 如果代词没有解析到命名实体,它们将得到索引 -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
    
  5. 以下案例也导致 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
    
  6. 以下代码显示 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
    
  7. 性别分配将阻止错误性别引起的引用。以下代码中的 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
    
  8. 核心词引用也可以在句子内部发生:

    >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
    
  9. 在解析提及时,提及的顺序(按最近提及排序)很重要。在以下代码中,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
    
  10. 同样的效果也发生在命名实体提及上。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
    
  11. 如果有太多的间隔句子,JohnJames 之间的区别就会消失:

    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_PRONOUNFEMALE_PRONOUN代词的类型将用于与PERSON实体匹配,其结果是解析集确定了解析到的实体的性别。

除了这些,代码应该非常熟悉我们的标准I/O循环,在命令提示符中运行交互。

参见

系统背后的算法基于Baldwin的博士论文。该系统被称为CogNIAC,这项工作始于20世纪90年代中期,并不是当前最先进的共指系统。更现代的方法可能会使用机器学习框架来利用Baldwin方法和其他许多特征生成特征,并用于开发性能更好的系统。关于该系统的论文可在http://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包中。

  1. 获取对您的IDE的访问权限并运行RunTracker或在命令行中输入以下命令:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.tracker.RunTracker
    
    
  2. 屏幕将滚动显示文档的分析,但我们将转到指定的输出文件并检查它。在您最喜欢的文本编辑器中打开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>
    
  3. Krishna在前两个文档中被识别为具有共享ID,1000000002,但昵称K-dog根本未被识别。Breck在所有三个文档中都被识别,但由于第三次提及时Breckles的ID与前两次提及的ID不同,系统不认为它们是同一实体。

  4. 接下来,我们将使用字典形式的数据库来提高通过昵称提及作者时的识别率。在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>
    
  5. 上述字典包含作者的两个昵称,以及他们的名字。具有xdc=1值的别名将用于跨文档链接实体。xdc=0值仅适用于文档内部。所有别名都将通过字典查找来识别命名实体。

  6. 运行以下命令,指定实体字典或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
    
    
  7. xDoc/output/docs1.xml中的输出与上一次运行的结果非常不同。首先,请注意,现在的ID与我们指定的字典文件中的ID相同:Breck1Krishna2。这是结构化数据库(如字典的性质)与无结构文本之间的链接。其次,请注意,我们的昵称都被正确识别并分配到了正确的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()方法的整体流程如下:

  1. 通过 Dictionary 读取已知实体数据库,这些实体将成为命名实体识别的来源,以及从别名到字典条目的已知映射。别名带有关于是否应通过 xdc=1xdc=0 标志在文档间匹配实体的说明。

  2. 设置 EntityUniverse,这是全局数据结构,包含文本中找到的以及从已知实体字典中提到的实体的 ID。

  3. 设置文档内核心参照所需的内容——例如分词器、句子检测器和命名实体检测器。在 POS 标记器和一些词频统计方面,这会变得有些复杂。

  4. 有一个布尔值控制是否添加推测性实体。如果这个布尔值为 true,则意味着我们将更新我们的跨文档实体宇宙,包括我们之前从未见过的那些。将这个集合设置为 true 是一个更艰巨的任务,需要可靠地计算。

  5. 所有提到的配置都用于创建一个 Tracker 对象。

  6. 然后,main() 方法读取要处理的文档,将它们交给 Tracker 对象进行处理,并将它们写入磁盘。Tracker.processDocuments() 方法的重大步骤如下:

    1. 从 XML 格式的文档集中提取单个文档。

    2. 对于每个文档,应用 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.createEntitySpeculativeEntityUniverse.addPhraseToEntity 来查看代码。

为什么有些别名在寻找候选实体时不被使用?考虑一下,GeorgeEntityUniverse 中几乎没有描述性内容来区分实体,但 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 变量将包含来自文本标题和正文的链,以及它们之间可能存在的同指。mentionStartListmentionEndList 数组将使得在方法后面的某个时候能够将文档作用域的 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> entityToChainSetchainToEntity 的逆映射。可能同一文档中的多个链会被映射到同一个实体,因此这个数据结构对这种可能性很敏感。这个版本的代码允许这种情况发生——实际上,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 短语的观点。直观地说,在新闻稿的领域,像 heBobJohn Smith 这样的短语是关于谈论的独特个体的较差指标。好的短语可能是 Breckenridge Baldwin,因为这很可能是一个独特的名字。关于这里发生的事情有许多复杂的理论,参见刚性指示符 (http://en.wikipedia.org/wiki/Rigid_designator)。接下来的几行代码对2000年的哲学思想进行了粗暴的践踏:

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 的情况。您可以在本章末尾的 The John Smith problem 菜单中了解更多关于此的信息。

  • 短语仅由一个单词组成,并且与提及链或实体相关联的短语是多个单词的:这假设较长的单词更适合 XDoc。请注意,实体创建的不同顺序可能导致具有多词别名的实体上的单词短语 xdctrue

  • 短语是代词:这是一个相当安全的假设,除非我们处于宗教文本中,其中句子中间大写的 HeHim 指的是上帝。

一旦知道了 xdcnonXdc 短语的集合,然后实体就被创建。请参考 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() 中查找 XDoc 的候选实体的关键。

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.resolveCandates() 尝试找到唯一的实体:

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() 在实体宇宙中进行查找来组装一组实体。在将实体添加到 candidateEntities 之前,必须确保返回的类型与由 TTMatchers.unifyEntityTypes 确定的提及链的类型一致。这样,就不会将地点“华盛顿”解析为人物“华盛顿”。为了确定提及链上最长的别名是否匹配了实体,进行了一些记录。

resolveCandidates() 方法

resolveCandidates() 方法捕捉到对于文档内和 XDoc 共指都成立的一个关键假设——这个明确的引用是唯一解决的基础。在文档内的情况下,人类有这种问题的例子是句子,“鲍勃和乔一起工作。他掉进了脱粒机。”谁是指的“他”?一个单数指称词有一个唯一先行词的语言学期望被称为唯一性预设。以下是一个 XDoc 例子:

  • Doc1:约翰·史密斯是《波卡洪塔斯》中的一个角色

  • Doc2:约翰·史密斯是董事长或总经理

  • Doc3:约翰·史密斯受人钦佩

Doc3 中的 John Smith 与哪个 John Smith 相匹配?也许都不是。这个软件中的算法要求应该有一个单一的实体能够通过匹配标准。如果有多个或零个,则创建一个新的实体。实现方式如下:

        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共指提供的起点。希望我们已经解释了更不透明的方法背后的意图。祝你好运!

约翰·史密斯问题

不同的人、地点和概念可能有相同的书写形式但却是不同的。世界上有多个“John Smith”、“Paris”和“bank”的例子,一个合适的跨文档共指系统应该能够处理这种情况。对于像“bank”这样的概念(河岸与金融机构),术语是词义消歧。这个方案将展示Baldwin当年与Amit Bagga一起为人物消歧开发的一种解决问题的方法。

准备工作

这个方案的代码紧密遵循http://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类以获取相关值。

引用类DocumentJohnSmith中的一个内部类,它相当简单。它获取具有匹配.*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的第一句话。此外,如果出现第二个具有不同ID的John Smith,它将被忽略——这种情况可能发生。

最后,请注意,有一些错误检查,如果找不到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);
        }

接下来,我们将准备聚类算法。我们将使用TfIdfDocumentDistance运行程序,进行CompleteLinkSingleLink


        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);

聚类算法的细节在第5章 文本中的跨度查找 – 分块 中有所介绍。现在,我们将根据从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(" --------------------------------------------"
         + "------------");
}

这就是我们为这个食谱做准备所需做的所有事情。这是一个罕见的现象,这是一个玩具实现,但关键概念应该是显而易见的。

如何做...

我们将只运行这个代码,然后稍微修改一下:

  1. 打开终端并输入:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter7.JohnSmith
    
    
  2. 结果将是一堆信息,表明正在提取哪些句子用于聚类——记住,真实标注是由文件所在的目录决定的。第一个聚类是0

    Category from file=data/johnSmith/0
    
  3. 代码报告包含对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
    
  4. John Smith的代词引用是第二句被包含的基础。

  5. 系统输出继续,最后,我们将得到针对真实情况的单链接聚类结果和完全链接聚类结果。K列表示算法允许的聚类数量,并报告了精确度、召回率和F度量。在这种情况下,第一行表示只有一个聚类允许100%的召回率和23%的精确度,无论是完全链接还是单链接。向下查看分数,我们可以看到完全链接报告了最佳的F度量,有11个聚类在0.60——实际上有35个聚类。单链接方法将F度量最大化到68个聚类,达到0.78,并在不同数量的聚类上显示出更大的鲁棒性。交叉案例显示,单链接和完全链接在直接比较中也很不同。请注意,为了可读性,一些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 
     --------------------------------------------------------
    
  6. 以下输出通过最大距离阈值来约束聚类,而不是通过聚类大小。输出是针对单链接聚类的,.05增加距离,评估是B-立方度指标。输出包括距离、精确度、召回率和最终聚类的规模。在.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
    
  7. B-立方度(Bagga, Bierman, 和 Baldwin)评估是为了严重惩罚将大聚类放在一起。它假设将大量关于乔治·W·布什的文档与乔治·H·W·布什放在一起是一个更大的问题,两者都是大聚类,而不是将乔治·布什,那位在数据集中只被提及一次的机械师弄错。其他评分指标将把这两个错误视为同样糟糕。这是文献中用于这种现象的标准评分指标。

参见

在研究文献中,关于这个确切问题的研究相当丰富。我们并不是第一个考虑这个问题的人,但我们提出了主导的评价指标,并且发布了一个语料库,供其他团队与我们以及彼此进行比较。我们的贡献是Bagga和Baldwin在ACL '98第36届计算语言学协会年会和第17届国际计算语言学会议论文集中提出的基于实体的跨文档共指消解使用向量空间模型。自那时以来,已经取得了许多进展——在谷歌学术上有超过400篇关于这个模型的引用;如果这个问题对你很重要,它们值得一看。

posted @ 2025-09-20 21:33  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报