Java-自然语言处理-全-

Java 自然语言处理(全)

原文:annas-archive.org/md5/32c19ca17f77d5b3cedfd88d491a9f8e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:关于作者

Breck Baldwin是 Alias-i/LingPipe 的创始人兼总裁。该公司专注于为客户构建系统,为开发者提供教育,并偶尔进行纯研究探索。他自 1996 年起一直在构建大规模的 NLP 系统。他喜欢远足滑雪,并且写过《DIY RC Airplanes from Scratch: The Brooklyn Aerodrome Bible for Hacking the Skies》,McGraw-Hill/TAB Electronics出版。

本书献给 Peter Jackson,他在我创办公司之前聘请我作为 Westlaw 的顾问,并且给了我创办公司的信心。他一直是我的顾问委员会成员,直到他早逝,我非常怀念他。

同为亚里士多德学派的 Bob Carpenter,是 LingPipe API 的架构师和开发者。是他提出了将 LingPipe 开源的想法,这为我们打开了许多大门,也促成了本书的诞生。

Mitzi Morris 多年来一直与我们合作,并在我们艰难的 NIH 工作中发挥了重要作用,她是教程、软件包的作者,并在需要时提供帮助。

Jeff Reynar 是我在研究生时期的室友,我们在那时萌生了参加 MUC-6 比赛的想法,这也成为了公司成立的主要推动力;他现在是我们的顾问委员会成员。

我们的志愿审稿人值得大大赞扬;Doug Donahue 和 Rob Stupay 给了我们很大的帮助。Packt Publishing 的审稿人使得本书更加完善;感谢 Karthik Raghunathan、Altaf Rahman 和 Kshitij Judah 对细节的关注以及提出的优秀问题和建议。

我们的编辑们非常耐心;Ruchita Bhansali 一直在推动章节进展,并提供了优秀的评论,Shiny Poojary 是我们的细致技术编辑,他为此付出了辛劳,让你们不必经历那些痛苦。非常感谢你们两位。

如果没有我的合著者 Krishna,我是做不到的,他全职工作并承担了写作的另一部分。

非常感谢我的妻子 Karen,在整个写书过程中给予我的支持。

****Krishna Dayanidhi大部分职业生涯都专注于自然语言处理技术。他构建了多种系统,从为汽车设计的自然对话界面到在(不同的)财富 500 强公司中的问答系统。他还承认为大型电信公司构建了自动语音系统。他是个狂热的跑步者,也是一个不错的厨师。

我想感谢 Bob Carpenter,他回答了许多问题,并且为本书提供了大量的先前著作,包括教程和 Javadocs,这些都对本书的编写起到了启发和塑造作用。谢谢你,Bob!我还要感谢我的合著者 Breck,是他说服我一起合著这本书,并且在整个写作过程中容忍我的各种怪癖。

我想感谢审阅者 Karthik Raghunathan、Altaf Rahman 和 Kshitij Judah,感谢他们提供的关键反馈,某些反馈甚至改变了整本书的内容。非常感谢我们在 Packt Publishing 的编辑 Ruchita,她的指导、劝说,以及确保本书最终能够问世。最后,感谢 Latha 的支持、鼓励和包容。

第二章:关于审阅者

Karthik Raghunathan 是微软硅谷的科学家,专注于语音与自然语言处理。自 2006 年首次接触该领域以来,他从事了包括语音对话系统、机器翻译、文本规范化、共指消解和基于语音的信息检索等多项工作,发表了多篇论文,出现在 SIGIR、EMNLP 和 AAAI 等知名会议上。他还曾有幸得到语言学和自然语言处理领域一些顶尖学者的指导与合作,如 Christopher Manning 教授、Daniel Jurafsky 教授和 Ron Kaplan 博士。

Karthik 目前在微软的 Bing 语音与语言科学团队工作,构建语音驱动的对话理解系统,服务于微软的多个产品,如 Xbox 游戏机和 Windows Phone 移动操作系统。他运用语音处理、自然语言处理、机器学习和数据挖掘等多种技术,改进自动语音识别和自然语言理解系统。他最近在微软工作的产品包括 Xbox One 的新型改进版 Kinect 传感器和 Windows Phone 8.1 中的 Cortana 数字助手。在微软之前的工作中,Karthik 曾在 Bing 搜索团队从事浅层依存解析和网页查询语义理解的工作,也在微软 Office 团队从事统计拼写检查和语法检查的工作。

在加入微软之前,Karthik 获得了计算机科学硕士学位(专攻人工智能),并在斯坦福大学的自然语言处理研究中获得了优异成绩。尽管他研究生论文的重点是共指消解(他论文中的共指工具作为斯坦福 CoreNLP Java 包的一部分可用),但他还研究了统计机器翻译问题(领导斯坦福在 GALE 3 中英机器翻译大赛中的工作)、短信中的俚语规范化(共同开发了斯坦福 SMS 翻译器)以及机器人中的情境化语音对话系统(参与开发语音包,现在作为开源机器人操作系统(ROS)的一部分提供)。

Karthik 在印度国家技术学院卡利卡特分校的本科工作主要集中在为印度语言构建 NLP 系统。他与海得拉巴 IIIT 合作,研究了泰米尔语、泰卢固语和印地语的受限领域语音对话系统。他还在微软印度研究院实习,参与了一个针对资源匮乏语言的统计机器翻译扩展项目。

Karthik Raghunathan 维护着个人主页:nlp.stanford.edu/~rkarthik/,可以通过 <kr@cs.stanford.edu> 与他联系。

Altaf Rahman 目前是美国加利福尼亚州 Yahoo Labs 的研究科学家。他主要研究搜索查询,解决如查询标签、查询解释排序、垂直搜索触发、模块排名等问题。他在德克萨斯大学达拉斯分校获得了自然语言处理博士学位。他的博士论文研究的是会议解析问题。Rahman 博士在主要的自然语言处理学术会议上发表了多篇论文,且被引用超过 200 次。他还曾研究过其他自然语言处理问题:命名实体识别、词性标注、统计解析器、语义分类器等。此前,他曾在 IBM 托马斯·J·沃森研究中心、巴黎第七大学和谷歌担任研究实习生。

第三章:www.PacktPub.com

支持文件、电子书、折扣优惠等

如需获取与你的书籍相关的支持文件和下载,请访问 www.PacktPub.com

你知道吗,Packt 提供每本书的电子书版本,包括 PDF 和 ePub 文件?你可以通过 www.PacktPub.com 升级到电子书版本,作为纸质书籍的顾客,你还可以享受电子书的折扣优惠。如需更多详情,请通过 <service@packtpub.com> 联系我们。

www.PacktPub.com,你还可以阅读免费的技术文章,注册各种免费的新闻通讯,并享受 Packt 书籍和电子书的独家折扣与优惠。

支持文件、电子书、折扣优惠等

www2.packtpub.com/books/subscription/packtlib

你是否需要即时解答你的 IT 问题?PacktLib 是 Packt 的在线数字书籍库。在这里,你可以搜索、访问并阅读 Packt 的全部书籍。

为什么要订阅?

  • 可以对 Packt 出版的每本书进行全面搜索

  • 复制、粘贴、打印并收藏内容

  • 按需获取并通过网页浏览器访问

Packt 账户持有者可免费访问

如果你在 www.PacktPub.com 拥有账户,可以立即访问 PacktLib 并查看 9 本完全免费的书籍。只需使用你的登录凭证即可立即访问。

前言

欢迎来到这本书,它是你在踏入新的咨询工作或面对新的自然语言处理(NLP)问题时,想要随身携带的必备工具书。本书起初是 LingPipe 的私人食谱库,Baldwin 在面临重复且棘手的 NLP 系统构建问题时不断参考这些食谱。我们是一家开源公司,但这些代码从未适合共享。现在,它们已经分享出来了。

说实话,LingPipe 的 API 就像任何复杂且丰富的 Java API 一样,令人望而生畏、晦涩难懂。再加上需要“黑魔法”的技巧来让 NLP 系统正常工作,我们就有了满足需求的完美条件——一本食谱书,最小化理论内容,最大化实践应用,并从 20 年的经验中提炼最佳实践。

本书旨在帮助你完成任务;理论见鬼去吧!拿起本书,构建下一代 NLP 系统,并给我们发个邮件告诉我们你做了什么。

LingPipe 是地球上最好的 NLP 系统构建工具;本书将教你如何使用它。

本书内容

第一章,简单分类器,解释了大多数 NLP 问题实际上是分类问题。本章介绍了基于字符序列的简单但强大的分类器,并引入了交叉验证等评估技术,以及精准率、召回率和始终能抵御虚假信息的混淆矩阵等度量标准。你将自学并从 Twitter 下载数据。本章最后展示了一个简单的情感分析示例。

第二章,查找与处理词汇,听起来可能很无聊,但其中有一些亮点。最后的食谱将展示如何处理中文/日文/越南语等不含空格的语言进行分词,以帮助定义词汇。我们还将展示如何封装 Lucene 分词器,它支持阿拉伯语等各种有趣的语言。书中后面的几乎所有内容都依赖于分词技术。

第三章,高级分类器,介绍了现代 NLP 系统的明星——逻辑回归分类器。20 年辛勤积累的经验隐藏在这一章中。我们将讨论构建分类器的生命周期,包括如何创建训练数据、如何利用主动学习“作弊”创建训练数据,以及如何调整和加速分类器的工作。

第四章,词汇和标记的标注,解释了语言是由单词构成的。本章重点介绍如何将类别应用于标记,从而推动 LingPipe 的许多高端应用,如实体检测(文本中的人名/地点/组织)、词性标注等。它从标注云开始,标注云被描述为“互联网的莫雷特发型”,并以条件随机场(CRF)的基础知识为结尾,后者可以为实体检测任务提供最先进的表现。在此过程中,我们还将探讨信心标记单词,这可能是更复杂系统中的一个重要维度。

第五章,查找文本中的跨度 - 分块,表明文本不仅仅是单词。它是单词的集合,通常是按跨度组织的。本章将从单词标注过渡到跨度标注,引入了查找句子、命名实体、基元 NP 和 VP 等功能。CRF 的完整功能将在特征提取和调优的讨论中得到体现。字典方法作为一种组合分块的方式也会被讨论。

第六章,字符串比较与聚类,重点介绍了如何在不依赖训练分类器的情况下比较文本。相关技术从极其实用的拼写检查到充满希望但常常令人沮丧的潜在狄利克雷分配(LDA)聚类方法不等。一些较不显眼的技术,如单链和完全链聚类,已经为我们带来了重大商业成功。不要忽视这一章。

第七章,查找概念/人名的共指,展望了未来,但不幸的是,你不会获得最终的解决方案,只能看到我们目前为止所做的最佳努力。这是工业界和学术界 NLP 努力的前沿领域,具有巨大的潜力。正是这种潜力让我们决定展示我们的努力,以便你能看到这项技术的实际应用。

本书所需的基础

你需要一些 NLP 问题的基础知识,扎实的 Java 基础,一台计算机,以及开发者思维。

本书的适用人群

如果你有 NLP 问题,或希望自学评论中的 NLP 问题,本书适合你。只要有些创造力,你就可以训练自己成为一名扎实的 NLP 开发者,一个如此稀有的存在,以至于它们出现的频率几乎和独角兽一样,结果就是在硅谷或纽约等热门科技领域中获得更有趣的就业前景。

约定

本书中,你将会看到多种文本风格,区分不同类型的信息。以下是这些风格的一些示例及其含义的解释。

Java 是一种相当糟糕的语言,适合放入一本限制每行代码字符数为 66 个的食谱书中。我们承认,代码很丑,我们为此道歉。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入以及 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> 发送电子邮件,并在邮件主题中注明书籍标题。

如果你在某个领域拥有专业知识,并且有兴趣为一本书写作或贡献内容,请查看我们的作者指南:www.packtpub.com/authors

<cookbook@lingpipe.com> 发送仇恨/喜爱/中立的邮件。我们确实在乎,但我们不会为你做作业或免费为你的初创公司做原型,然而,还是请和我们沟通。

客户支持

现在你是一本 Packt 书籍的自豪拥有者,我们有很多资源可以帮助你最大限度地利用你的购买。

我们确实提供咨询服务,甚至有一个公益(免费)项目,以及初创支持项目。NLP 很难,本书包含了我们知道的大部分内容,但或许我们能提供更多帮助。

下载示例代码

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

本书的所有源代码可以在 alias-i.com/book.html 上获得。

勘误

虽然我们已尽力确保内容的准确性,但错误仍然可能发生。如果你在我们的书籍中发现错误——无论是文本错误还是代码错误——我们将非常感激你能报告给我们。通过这样做,你可以帮助其他读者避免困扰,同时也帮助我们改进后续版本的书籍。如果你发现任何勘误,请访问 www.packtpub.com/submit-errata 提交,选择你的书籍,点击 勘误提交表单 链接,并输入勘误的详细信息。一旦你的勘误被验证通过,提交将被接受,勘误将上传到我们的网站,或添加到该书籍的现有勘误列表中,位于该书籍的勘误部分。任何现有的勘误可以通过访问 www.packtpub.com/support 并选择你的书籍查看。

盗版

互联网上的版权材料盗版问题在所有媒体中普遍存在。在 Packt,我们非常重视版权和许可证的保护。如果你在互联网上遇到我们作品的任何非法复制品,请立即提供位置地址或网站名称,以便我们采取相应的措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢你在保护我们的作者和我们为你提供有价值内容方面所作的帮助。

问题

如果你在书籍的任何方面遇到问题,可以通过<questions@packtpub.com>与我们联系,我们会尽力解决。

点击 lingpipe.com,进入我们的论坛,这是获取问题解答的最佳地方,看看你是否已经有了解决方案。

第一章:简单分类器

本章将涵盖以下内容:

  • 反序列化和运行分类器

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

  • 从 Twitter API 获取数据

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

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

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

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

  • 查看错误类别 – 假阳性

  • 理解精准度和召回率

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

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

  • 如何分类情感 – 简单版

介绍

本章介绍了 LingPipe 工具包,并将其与同类工具进行比较,然后直接深入到文本分类器的内容。文本分类器将一个类别分配给文本,例如,它们可以确定一句话的语言,或者告诉我们一条推文的情感是积极、消极还是中立。本章讲解了如何使用、评估和创建基于语言模型的文本分类器。这些是 LingPipe API 中最简单的基于机器学习的分类器。它们之所以简单,是因为它们只处理字符——稍后,分类器将引入单词/标记等概念。然而,不要被迷惑,字符语言模型在语言识别方面是理想的,它们曾是世界上一些最早的商业情感系统的基础。

本章还涵盖了至关重要的评估基础设施——事实证明,我们所做的几乎所有事情在某种层次的解释中都可以看作是分类器。因此,不要忽视交叉验证、精准度/召回率定义和 F-measure 的强大作用。

最棒的部分是你将学习如何以编程方式访问 Twitter 数据,来训练和评估你自己的分类器。虽然有一部分内容涉及到从磁盘读取和写入 LingPipe 对象的机制,这部分有点枯燥,但除此之外,这一章还是很有趣的。本章的目标是让你快速上手,掌握机器学习技术在自然语言处理NLP)领域的基本使用方法。

LingPipe 是一个面向 NLP 应用的 Java 工具包。本书将展示如何通过问题/解决方案的形式,使用 LingPipe 解决常见的 NLP 问题,使开发者能够快速部署解决方案来完成常见任务。

LingPipe 及其安装

LingPipe 1.0 于 2003 年发布,作为一个双重许可的开源 NLP Java 库。在本书写作时,我们即将达到 Google Scholar 上 2000 次点击,且已有成千上万的商业安装,用户包括大学、政府机构以及财富 500 强公司。

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

类似于 LingPipe 的项目

几乎所有的 NLP 项目都有糟糕的缩写,我们会公开自己的缩写。LingPipe语言处理管道(linguistic pipeline)的缩写,这也是 Bob Carpenter 放置初始代码的 cvs 目录的名称。

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

  • NLTK:这是主流的 Python 库,用于 NLP 处理。

  • 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:这个项目非常专注于围绕逻辑回归、潜在狄利克雷分配(Latent Dirichlet Allocation)等方面的可扩展性。聪明的人在推动这个项目。

  • Factorie:它来自马萨诸塞大学阿默斯特分校,是 Mallet 的一个替代方案。最初,它主要集中在图形模型上,但现在它也支持 NLP 任务。

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

那么,为什么要使用 LingPipe?

询问为什么选择 LingPipe 而不是上述提到的出色的免费竞争对手是非常合理的。原因有几个:

  • 文档:LingPipe 的类级文档非常详尽。如果工作是基于学术研究的,相关研究会被引用。算法清晰列出,底层数学解释详细,说明精确。文档缺少的是一种“如何完成任务”的视角;不过,这本书会覆盖这一内容。

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

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

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

  • 支持:LingPipe 有专职员工负责回答你的问题,并确保 LingPipe 执行其功能。罕见的 bug 通常在 24 小时内得到修复。他们对问题响应非常迅速,并且非常愿意帮助他人。

  • 咨询服务:你可以聘请 LingPipe 的专家为你构建系统。通常,他们会作为副产品教授开发人员如何构建 NLP 系统。

  • 一致性:LingPipe 的 API 由一个人,Bob Carpenter 设计,他非常注重一致性。虽然它并不完美,但你会发现它在设计上的规律性和眼光,这是学术界的工作中可能缺乏的。研究生来来去去,大学工具包中的贡献可能会有所不同。

  • 开源:虽然有许多商业供应商,但他们的软件是一个黑盒子。LingPipe 的开源性质提供了透明性,并且让你确信代码按我们要求的方式运行。当文档无法解释时,能够访问源代码来更好地理解它是一个巨大的安慰。

下载书籍代码和数据

你需要从 alias-i.com/book.html 下载本书的源代码,支持的模型和数据。使用以下命令解压和解压它:

tar –xvzf lingpipeCookbook.tgz

小提示

下载示例代码

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

或者,你的操作系统可能提供了其他方式来提取这个压缩包。所有示例假设你是在解压后的书籍目录中运行命令。

下载 LingPipe

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

LingPipe 的下载和安装说明可以在 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. 该分类器已在英语、西班牙语和日语上进行训练。我们已经输入了每种语言的一个示例——要获取一些日语内容,可以访问 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);

前面的代码片段是第一个 LingPipe 特定的代码,其中分类器是使用静态的AbstractExternalizable.readObject方法构建的。

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

注意

我们使用Externalizable而不是Serializable的原因是为了避免在更改任何方法签名或成员变量时破坏向后兼容性。Externalizable扩展了Serializable,并允许控制对象的读写方式。关于这一点的更多信息,请参阅 Josh Bloch 的书籍《Effective Java, 第二版》中关于序列化的精彩章节。

BaseClassifier<E>是基础的分类器接口,其中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具有一个特性,即所有类别的分数之和必须为 1,这可以通过conditionalProbability(int rank)方法和conditionalProbability(String category)方法访问。还有更多内容;你可以阅读 Javadoc 了解详细信息。当事情变得复杂时,这种分类方法将成为本书的核心工具,我们希望知道推文是英语、日语还是西班牙语的信心值。这些估算值必须加起来为 1。

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

显然,分类栈的设计考虑了很多因素。这是因为大量的工业 NLP 问题最终都由分类系统处理。

结果证明,我们最简单的分类器——在某种任意的意义上简单——产生了最丰富的估算值,这些估算值是联合分类。让我们深入了解一下。

准备工作

在前面的示例中,我们轻松地反序列化为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 则将相同长度所有序列的概率总和为 1。查看 Javadoc 以了解这部分数学如何进行。

  • 每个语言模型对于其类别外的数据没有任何了解。

  • 分类器会跟踪类别的边际概率,并将其纳入该类别的结果中。边际概率意味着我们通常会看到三分之二是英语,一六分之一是西班牙语,一六分之一是日语的迪士尼推文。这些信息与 LM 的估计结果结合在一起。

  • LM 是LanguageModel.Dynamic的编译版本,我们将在后续的配方中讨论训练时如何使用。

构造的LMClassifier将这些组件封装成一个分类器。

幸运的是,接口通过更具美学感的反序列化解决了这个问题:

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

该接口巧妙地隐藏了实现的内部细节,这是我们在示例程序中所采用的方式。

如何做…

这个步骤是我们第一次开始从分类器的功能中剥离出来,但首先,让我们玩一玩:

  1. 让你的魔法 shell 精灵召唤一个命令提示符,并输入:

    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),它被翻译为类别和输入的 log**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

另见

  • 在第六章中,有详细信息,《使用 LingPipe 4 进行文本分析》中,Bob CarpenterBreck Baldwin编写的字符语言模型部分,LingPipe 出版alias-i.com/lingpipe-book/lingpipe-book-0.5.pdf)介绍了语言模型。

从 Twitter API 获取数据

我们使用流行的twitter4j包来调用 Twitter 搜索 API,搜索推文并将其保存到磁盘。Twitter API 从版本 1.1 开始要求身份验证,我们需要获取认证令牌并将其保存在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。main()方法的开始部分位于src/com/lingpipe/cookbook/chapter1/TwitterSearch.java中:

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

上面的代码片段是相当标准的代码实现,尽管没有通常的面向外部代码的加固——try/catch、超时和重试。一个可能让人困惑的地方是使用query来处理搜索结果的分页——当没有更多页面时,它会返回null。当前的 Twitter API 每页最多返回 100 个结果,因此为了获得 1500 个结果,我们需要重新运行搜索,直到没有更多结果,或者直到我们获得 1500 条推文。下一步涉及一些报告和写入:

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文件进行语言识别测试。

另见

欲了解有关使用 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文件,并调用分类器的分类方法。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;
}

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

评估在构建稳固的自然语言处理系统中至关重要。它使开发人员和管理层能够将业务需求与系统性能进行映射,从而帮助向利益相关方传达系统改进的情况。“嗯,系统似乎做得更好”并不如“召回率提高了 20%,特异性在增加 50%的训练数据下仍然保持良好”那样有分量。

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

准备工作

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

打开 CSV 文件,并为至少 10 个示例标注你认为推文所用的语言,e代表英语,n代表非英语。如果你不想手动标注数据,分发包中有一个data/disney_e_n.csv文件,你可以使用这个文件。如果你对某条推文不确定,可以忽略它。未标注的数据会被忽略。请看下面的截图:

准备工作

下面是包含英文'e'和非英文'n'的人类标注的电子表格截图。它被称为真值数据或黄金标准数据,因为它正确地表示了该现象。

通常,这些数据被称为黄金标准数据,因为它代表了真相。"黄金标准"中的"黄金"是字面意思。备份并长期存储这些数据——它很可能是你硬盘上最宝贵的一组字节,因为它的生产成本很高,而且最清晰地表达了所做的工作。实现方式会不断变化;而评估数据则永远存在。来自The John Smith problem案例的约翰·史密斯语料库,在第七章,查找概念/人物之间的共指关系,是该特定问题的权威评估语料库,并成为 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,
    
    

混淆矩阵恰如其名,因为它最初几乎让每个人都感到困惑,但毫无疑问,它是分类器输出的最佳表示方式,因为它很难掩盖分类器的差劲表现。换句话说,它是一个优秀的虚假检测器。它清晰地展示了分类器做对了什么,做错了什么,以及它认为正确的答案是什么。

每一行的总和表示由真相/参考/黄金标准所知的属于该类别的项目。对于英语(e),共有 11 条推文。每一列表示系统认为属于相同标签类别的内容。对于英语(e),系统认为这 11 条推文都是英语,且没有非英语(n)。对于非英语类别(n),在真相中有 10 个案例,其中分类器错误地认为 1 个是英语(错误),并正确地认为 9 个是非英语(正确)。完美的系统表现会使所有非对角线位置的单元格值为零,从左上角到右下角。

它之所以被称为混淆矩阵的真正原因是,因为它很容易看出分类器混淆的类别。例如,英式英语和美式英语可能会高度混淆。此外,混淆矩阵能够很好地扩展到多个类别,稍后会看到。访问 Javadoc 以获取更详细的混淆矩阵说明——它值得深入掌握。

它是如何工作的……

基于本章前面配方中的代码,我们将重点介绍评估设置中的新颖之处。完整的代码位于src/com/lingpipe/cookbook/chapter1/RunConfusionMatrix.javamain()方法的开头显示在下面的代码片段中。代码首先从命令行参数中读取,寻找非默认的 CSV 数据和序列化分类器。此配方使用的默认值如下所示:

String inputPath = args.length > 0 ? args[0] : "data/disney_e_n.csv";
String classifierPath = args.length > 1 ? args[1] : "models/1LangId.LMClassifier";

接下来,将加载语言模型和.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);

请注意,分类器可以为 null,并且可以在稍后的时间指定;类别必须与注释和分类器产生的类别完全匹配。我们不会配置评估器来存储输入,因为在此配方中我们不打算使用此功能。请参阅查看错误类别 – 假阳性配方,其中存储并访问了输入。

接下来,我们将进行实际评估。循环将遍历.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>对象。

循环的最后一行将对创建的分类对象应用handle方法。评估器假设其handle方法所提供的数据是一个真值注释,该数据通过提取待分类数据、应用分类器进行分类、获得结果中的firstBest()分类,然后标记分类是否与刚刚构造的真值匹配。对于.csv文件中的每一行都会发生这种情况。

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

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

本段代码的详细分析留给读者自行阅读。就是这样;我们已经评估了分类器并打印出了混淆矩阵。

还有更多内容...

评估器有一个完整的toString()方法,可以提供大量的信息,说明你的分类器表现如何。输出中的这些方面将在后续配方中讲解。Javadoc 非常详尽,值得一读。

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

当分类器被定制时,自然语言处理的世界真正开始展开。本配方提供了如何通过收集分类器学习的示例来定制分类器的详细信息——这叫做训练数据。它也叫做黄金标准数据、真实值或地面真相。我们有一些来自前面配方的数据,将用它们。

准备工作

我们将为英语和其他语言创建一个定制的语言识别分类器。训练数据的创建涉及获取文本数据,并为分类器的类别进行标注——在这个例子中,标注的就是语言。训练数据可以来自多种来源。以下是一些可能性:

  • 诸如在前面评估配方中创建的黄金标准数据。

  • 已经以某种方式注释过的数据,针对你关心的类别。例如,维基百科有语言特定版本,方便用来训练语言识别分类器。这就是我们如何创建3LangId.LMClassifier模型的方式。

  • 要有创意——数据在哪里能帮助引导分类器朝正确方向发展?

语言识别不需要太多数据就能很好地工作,因此每种语言 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. 对于i从 1 到n

    • 在通过排除第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>,替代之前食谱中的分类器:

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. 输出从混淆矩阵开始。然后,我们将看到混淆矩阵左下角单元格中标记为分类器猜测的类别的p的实际六个假阳性实例。接着,我们将看到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 的精确度为真阳性 / (假阳性 + 真阳性)

    • 退化案例是做出一个非常自信的猜测,以获得 100%的精度。这可以最小化假阳性,但会导致召回率非常差。
  • 类别 X 的召回率或灵敏度为真阳性 / (假阴性 + 真阳性)

    • 退化案例是将所有数据猜测为属于类别 X,以获得 100%的召回率。这最小化了假阴性,但会导致精确度极差。
  • 类别 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文件,设置分类器并对其进行训练。如果有任何代码不清楚,请参考先前的内容。

本方法的新内容在于,当我们调用DynamicLMClassifierAbtractExternalizable.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 数据有很多重复项,即使使用了-filter:retweets选项来搜索 API,这也可能让人非常头痛。一个快速的方式来查看这些重复项是将文本在电子表格中排序,共享相同前缀的推文将会排在一起:

使用 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,推文不同但有很多单词重叠。请注意,第二个例子没有共享前缀。

它是如何工作的…

这个方法跳过了序列的部分步骤,使用了分词器来驱动去重过程。之所以这样做,是因为接下来的情感分析方法确实需要去重后的数据才能更好地工作。第二章,查找与处理单词,详细介绍了分词化的内容。

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类。Jaccard 距离通过两个字符串的标记交集与它们的标记并集之比来计算。请查看 Javadoc 了解更多信息。

以下示例中的嵌套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 构建的,使用的是语言模型分类器。我们现在倾向于使用逻辑回归分类器,因为它们通常表现得更好。第三章,高级分类器,讲解了逻辑回归分类器。

如何操作……

之前的配方关注的是语言识别——我们如何将分类器转到截然不同的情感分析任务呢?这比想象的要简单——所需要改变的只是训练数据,信不信由你。步骤如下:

  1. 使用 Twitter 搜索配方下载关于某个话题的推文,寻找其中有正面/负面评价的推文。我们使用disney作为示例,但你可以自由扩展。这个配方将与提供的 CSV 文件data/disneySentiment_annot.csv兼容。

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

  3. 如同分类器评估——混淆矩阵配方一样,标注true class列为三种类别之一:

    • p标注代表“正面”。示例是“哎呀,我爱迪士尼电影。#讨厌它”。

    • n标注代表“负面”。示例是“迪士尼真让我崩溃,事情本不该是这样的。”

    • o标注代表“其他”。示例是“关于迪士尼市中心的更新。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 引入更丰富的特征,但最终,由训练数据施加的标签就是传授给分类器的知识。根据你的观点,底层技术可能是神奇的,或者令人惊讶地简单。

它是如何工作的...

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

还有更多...

分类器非常简单,但如果不期望它们超出自身的能力范围,它们是非常有用的。语言识别作为分类问题表现很好,因为观察到的事件与正在进行的分类密切相关——即一种语言的单词和字符。情感分析则更为复杂,因为在这种情况下,观察到的事件与语言识别完全相同,并且与最终分类的关联性较弱。例如,“I love”这个短语可以很好地预测该句子是英语,但不能明确预测情感是正面、负面还是其他。如果推文是“I love Disney”,那么它是一个正面陈述。如果推文是“I love Disney, not”,那么它就是负面。处理情感及其他更复杂现象的复杂性通常会通过以下方式解决:

  • 创建更多的训练数据。即使是相对简单的技术,例如语言模型分类器,只要有足够的数据,也能表现得非常好。人类在抱怨或称赞某件事物时并没有那么富有创造力。训练一点,学习一点——主动学习的第三章,高级分类器,介绍了一种巧妙的方法来实现这一点。

  • 使用更复杂的分类器,这些分类器又使用更复杂的特征(关于数据的观察)来完成任务。有关更多信息,请查看逻辑回归配方。在否定的情况下,可能通过查找推文中的负面短语来帮助解决问题。这可能会变得非常复杂。

请注意,更合适的处理情感问题的方式是为正面非正面创建一个二分类器,为负面非负面创建另一个二分类器。这些分类器将有独立的训练数据,并允许推文同时是正面和负面。

作为分类问题的常见问题

分类器是许多工业级自然语言处理(NLP)问题的基础。本解决方案将通过将一些常见问题编码为基于分类的解决方案的过程进行介绍。我们将在可能的情况下,提取我们实际构建的真实世界示例,你可以将它们视为小型解决方案。

主题检测

问题:从财务文档(如 10Qs 和 10Ks)中提取脚注,并确定是否应用了可扩展商业报告语言XBRL)类别,如“前瞻性财务报表”。事实证明,脚注是所有信息的核心。例如,脚注是否指的是已退休的债务?性能需要达到超过 90%的精度,并保持可接受的召回率。

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

问答

问题:在大量基于文本的客户支持数据中识别常见问题,并开发回答能力,实现 90%的精确度自动回答。

解决方案:对日志进行聚类分析以找出常见问题——见第六章,字符串比较与聚类。这将生成一个非常大的常见问题集合,实际上是很少被提问的问题IAQs);这意味着 IAQ 的出现频率可能低至 1/20000。对分类器来说,正向数据相对容易找到,但负向数据在任何平衡分布中都难以获取——每当出现一个正向案例时,通常会有 19999 个负向案例。解决方案是假设任何大规模的随机样本都会包含极少的正向数据,并将其作为负向数据使用。一种改进方法是对负向数据运行训练好的分类器,以找出高分案例,并为可能找到的正向数据进行注释。

情感程度

问题:根据负面到正面情感的程度,将情感分类为 1 到 10 之间的等级。

解决方案:尽管我们的分类器提供了一个可以映射到 1 到 10 的评分,这并不是后台计算所做的工作。为了正确映射到程度量表,必须在训练数据中注释出这些区别——这条推文是 1 分,那条推文是 3 分,依此类推。然后,我们将训练一个 10 分类器,理论上,第一个最佳分类应该是这个程度。我们写理论上是因为尽管客户经常要求这种功能,我们从未找到一个愿意支持所需注释的客户。

非互斥类别分类

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

解决方案:我们经常使用多个二分类器来代替一个n分类器或多项分类器。分类器将被训练为正面/非正面和负面/非负面。然后,可以将推文标注为np

人物/公司/地点检测

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

解决方案:信不信由你,这实际上是一个词汇分类问题。请参见第六章,字符串比较与聚类

通常将任何新问题视为分类问题,即使分类器并不是作为底层技术使用,这也是有益的。它有助于澄清底层技术实际需要做什么。

第二章:查找和处理词语

在本章中,我们介绍以下配方:

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

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

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

  • 使用 Lucene/Solr 分词器

  • 使用 Lucene/Solr 分词器与 LingPipe

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

  • 修改分词器工厂

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

介绍

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

LingPipe 提供了广泛的分词器需求,这些需求在本书中没有涵盖。请查阅 Javadoc 以了解执行词干提取、Soundex(基于英语发音的标记)等的分词器。

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

LingPipe 分词器建立在一个通用的基础分词器模式上,基础分词器可以单独使用,也可以作为后续过滤分词器的来源。过滤分词器会操作由基础分词器提供的标记和空格。本章节涵盖了我们最常用的分词器 IndoEuropeanTokenizerFactory,它适用于使用印欧语言风格的标点符号和词汇分隔符的语言——例如英语、西班牙语和法语。和往常一样,Javadoc 中包含了有用的信息。

注意

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

分词规则大致基于 MUC-6 中使用的规则,但由于 MUC 分词器基于词汇和语义信息(例如,字符串是否为缩写),因此这些规则必须更为精细。

MUC-6 指的是 1995 年发起的消息理解会议,它创立了政府资助的承包商之间的竞争形式。非正式的术语是 Bake off,指的是 1949 年开始的比尔斯伯里烘焙大赛,且其中一位作者在 MUC-6 中作为博士后参与了该会议。MUC 对自然语言处理系统评估的创新起到了重要推动作用。

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。此时循环结束;否则,循环继续。

  • 该方法还会递增相应的空格。每个标记符后面都会有一个空格,但它可能是空字符串。

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

  • char数组的开头到第一个分词的字符会被忽略,并不会被报告为空格。

  • 从上一个分词的末尾到char数组末尾的字符被报告为下一个空格。

  • 空格可能是空字符串,因为有两个相邻的分词——注意输出中的撇号和相应的空格。

这意味着,如果输入不以分词开始,则可能无法重建原始字符串。幸运的是,分词器很容易根据自定义需求进行修改。我们将在本章后面看到这一点。

还有更多内容……

分词可能会非常复杂。LingPipe 分词器旨在覆盖大多数常见用例,但你可能需要创建自己的分词器以进行更精细的控制,例如,将“Victoria's Secret”中的“Victoria's”作为一个分词。如果需要这样的自定义,请查阅IndoEuropeanTokenizerFactory的源码,了解这里是如何进行任意分词的。

组合分词器——小写分词器

我们在前面的配方中提到过,LingPipe 分词器可以是基本的或过滤的。基本分词器,例如 Indo-European 分词器,不需要太多的参数化,事实上根本不需要。然而,过滤分词器需要一个分词器作为参数。我们使用过滤分词器的做法是调用多个分词器,其中一个基础分词器通常会被过滤器修改,产生一个不同的分词器。

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

准备工作

你需要下载书籍的 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。由于这个示例使用了 Indo-European 分词器作为基础分词器,你可以看到数字 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被过滤掉了。对于那些需要准确邻接信息的基于分词的过程,这可能会成为一个问题。在第四章的前景驱动或背景驱动的有趣短语检测配方中,我们将展示一个基于长度过滤的分词器,它保留了邻接信息。

它是如何工作的……

在这个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 分析器和一个小写过滤分词器。Lucene 分析器本质上是将字段映射到一个标记流。因此,如果你有一个现有的 Lucene 索引,你可以使用分析器和字段名称,而不是使用原始的分词器,正如我们在本章后面的部分所展示的那样。

准备工作

你需要下载本书的 JAR 文件,并配置 Java 和 Eclipse,以便运行示例。示例中使用的一些 Lucene 分析器是lib目录的一部分。然而,如果你想尝试其他语言的分析器,可以从 Apache Lucene 官网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并启动一个永久的while()循环。接下来,提供了提示,读取input,并用于构造Reader对象:

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);

输入文本用于构造StandardTokenizer,并提供 Lucene 的版本控制系统——这会生成一个TokenStream实例。接着,我们使用LowerCaseFilter创建最终的过滤tokenStream,并将基础tokenStream作为参数传入。

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

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

请注意,在 Lucene 4 中,一旦 tokenizer 被实例化,必须在使用 tokenizer 之前调用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();

另请参见

关于 Lucene 的一个优秀入门书籍是Text Processing with JavaMitzi MorrisColloquial Media Corporation,其中我们之前解释的内容比我们在此提供的食谱更清晰易懂。

将 Lucene/Solr 的 tokenizers 与 LingPipe 一起使用

我们可以将这些 Lucene 的 tokenizers 与 LingPipe 一起使用;这是非常有用的,因为 Lucene 拥有一套非常丰富的 tokenizers。我们将展示如何通过扩展Tokenizer抽象类将 Lucene 的TokenStream封装成 LingPipe 的TokenizerFactory

如何实现……

我们将稍微改变一下,提供一个非交互式的示例。请执行以下步骤:

  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,因为实现没有准确提供空格,而是使用了默认值。我们将在它是如何工作的……部分讨论这个限制。

它是如何工作的……

让我们来看一下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。根据 token 流创建了一个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并附加了术语和偏移量属性。在前面的食谱中,我们看到术语和偏移量属性包含 token 字符串,以及输入文本中的 token 起始和结束偏移量。token 偏移量在找到任何 tokens 之前也被初始化为-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()方法,并使用 token 流的incrementToken()方法从 token 流中获取任何 tokens。我们将使用OffsetAttribute来设置 token 的起始和结束偏移量。如果 token 流已经结束,或者incrementToken()方法抛出 I/O 异常,我们将结束并关闭TokenStream

nextWhitespace()方法有一些局限性,因为offsetAttribute聚焦于当前标记,而 LingPipe 分词器会将输入量化为下一个标记和下一个偏移量。这里的一个通用解决方案将是相当具有挑战性的,因为标记之间可能没有明确的空格——可以想象字符 n-grams。因此,default字符串仅供参考,以确保清楚表达。该方法如下:

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

代码还涵盖了如何序列化分词器,但我们在本步骤中不做详细讨论。

使用单元测试评估分词器

我们不会像对 LingPipe 的其他组件一样,用精确度和召回率等度量标准来评估印欧语言分词器。相反,我们会通过单元测试来开发它们,因为我们的分词器是启发式构建的,并预计在示例数据上能完美执行——如果分词器未能正确分词已知案例,那就是一个 BUG,而不是性能下降。为什么会这样呢?有几个原因:

  • 许多分词器非常“机械化”,适合于单元测试框架的刚性。例如,RegExTokenizerFactory显然是一个单元测试的候选对象,而不是评估工具。

  • 驱动大多数分词器的启发式规则是非常通用的,并且不存在以牺牲已部署系统为代价的过拟合训练数据的问题。如果你遇到已知的错误案例,你可以直接修复分词器并添加单元测试。

  • 假设标记和空格在语义上是中性的,这意味着标记不会根据上下文而变化。对于我们的印欧语言分词器来说,这并不完全正确,因为它会根据上下文的不同(例如,3.14 is pi.中的.与句末的.)处理.

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

对于基于统计的分词器,使用评估指标可能是合适的;这一点在本章的为没有空格的语言寻找单词的步骤中进行了讨论。请参阅第五章中的句子检测评估步骤,了解适合的基于跨度的评估技术。

如何实现...

我们将跳过代码步骤,直接进入源代码,构建分词器评估器。源代码在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(字符串)方法,如果需要,它会修改空格:

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 天,来自维基百科,这句话大致翻译为“Ganymede is running around Jupiter's moons, orbital period of about seven days”,这是机器翻译服务在 translate.google.com 上的翻译。注意到没有空格。

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

准备工作

我们将通过去除空格来近似非单词分隔的书写系统。这足以理解这个方法,并且在需要时可以轻松修改为实际的语言。获取大约 100,000 个英文单词并将它们存储在 UTF-8 编码的磁盘中。固定编码的原因是输入假定为 UTF-8——你可以通过更改编码并重新编译食谱来修改它。

我们使用了马克·吐温的《康涅狄格州的国王亚瑟宫廷人》(A Connecticut Yankee in King Arthur's Court),从古腾堡项目(www.gutenberg.org/)下载。古腾堡项目是一个很好的公共领域文本来源,马克·吐温是位杰出的作家——我们强烈推荐这本书。将你选定的文本放在食谱目录中,或者使用我们的默认设置。

如何操作...

我们将运行一个程序,稍微玩一下它,并解释它是如何工作的,使用以下步骤:

  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. 这是完美的空格插入。

它是如何工作的...

在所有的有趣操作中,涉及的代码非常少。酷的是,我们在 第一章,简单分类器 中的字符语言模型基础上构建。源代码位于 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-Levenshtein 距离。

阅读 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 编码;如果不是,就需要纠正字符编码并重新编译。然后,我们将所有多余的空格替换为单一空格。如果多个空格有特殊意义,这可能不是最好的做法。接着,我们进行了训练,正如我们在 第一章、简单分类器 中训练语言模型时所做的那样。

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

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,它将基础语言模型中的所有计数转换为预计算的概率,这样会更快。编译步骤可以将数据写入磁盘,以便后续使用而不需要重新训练;不过,访问 Javadoc 中关于 AbstractExternalizable 的部分,了解如何操作。接下来的几行配置 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 或者《使用编辑距离和语言模型进行拼写纠正》一书中的 第六章、字符串比较与聚类 中得到更好的描述。然而,基本思想是:输入的字符串与刚训练好的语言模型进行比较,从而得到一个分数,表明该字符串与模型的契合度。这个字符串将是一个没有空格的大单词——但请注意,这里没有使用分词器,所以拼写检查器会开始插入空格,并重新评估生成序列的分数。它会保留那些插入空格后,分数提高的序列。

请记住,语言模型是在带有空格的文本上训练的。拼写检查器会尽量在每个可能的位置插入空格,并保持一组“当前最佳”的空格插入结果。最终,它会返回得分最高的编辑序列。

请注意,要完成分词器,必须对修改过空格的文本应用合适的 TokenizerFactory,但这留给读者作为练习。

还有更多……

CompiledSpellChecker 也支持 n 最优输出;这允许对文本进行多种可能的分析。在高覆盖率/召回率的场景下,比如研究搜索引擎,它可能有助于应用多个分词方式。此外,可以通过直接扩展 WeightedEditDistance 类来调整编辑成本,从而调节系统的表现。

另见

如果没有实际提供非英语资源来支持这个配方,那么是没有帮助的。我们使用互联网上可用的资源为研究用途构建并评估了一个中文分词器。我们的中文分词教程详细介绍了这一点。你可以在alias-i.com/lingpipe/demos/tutorial/chineseTokens/read-me.html找到中文分词教程。

第三章。高级分类器

本章将介绍以下内容:

  • 一个简单的分类器

  • 带有标记的语言模型分类器

  • 朴素贝叶斯

  • 特征提取器

  • 逻辑回归

  • 多线程交叉验证

  • 逻辑回归中的调参

  • 自定义特征提取

  • 结合特征提取器

  • 分类器构建生命周期

  • 语言调优

  • 阈值分类器

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

  • 注释

介绍

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

一个简单的分类器

这个方法是一个思想实验,应该有助于清楚地了解机器学习的作用。回顾第一章中的训练你自己的语言模型分类器,在该方法中训练自己的情感分类器。考虑一下针对同一问题的保守方法——从输入构建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()方法开始,我们将进入一些标准代码操作,这些应该是你从第一章,简单分类器中熟悉的内容:

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);

这里没有什么新奇的内容——我们只是在训练一个分类器,如在第一章中所示的简单分类器,然后将该分类器提供给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类别。

这是过拟合的极端示例,但它本质上传达了过拟合模型的含义。过拟合模型过于贴合训练数据,无法很好地对新数据进行泛化。

让我们再想想前面语言识别分类器到底有什么问题——问题在于,整个句子/推文是错误的处理单位。单词/标记才是衡量使用何种语言的更好方式。一些将在后续方法中体现的改进包括:

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

  • 不仅仅是匹配/不匹配的决策,考虑一种更微妙的方法。简单的哪个语言匹配更多单词将是一个巨大的改进。

  • 随着语言的接近,例如英式英语与美式英语,可以为此调用概率。注意可能的区分词。

尽管这个方法可能对眼前的任务来说有些滑稽不合适,但考虑尝试情感分析来做一个更荒谬的例子。它体现了计算机科学中的一个核心假设,即输入的世界是离散且有限的。机器学习可以被视为对这个假设不成立的世界的回应。

还有更多……

奇怪的是,我们在商业系统中经常需要这种分类器——我们称之为管理分类器;它会预先在数据上运行。曾经发生过某个高级副总裁对系统输出中的某个示例不满。然后可以使用这个分类器训练精确的案例,从而立即修复系统并让副总裁满意。

带有标记的语言模型分类器

第一章,简单分类器,讲解了在不知道标记/单词是什么的情况下进行分类,每个类别都有一个语言模型——我们使用字符切片或 n-gram 来建模文本。第二章,寻找和处理单词,详细讨论了在文本中寻找标记的过程,现在我们可以利用这些标记来构建分类器。大多数时候,我们将标记化输入提供给分类器,因此这个方法是对概念的一个重要介绍。

如何做……

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

  1. 以下代码的例外情况与第一章中的训练你自己的语言模型分类器食谱中的内容相同,简单分类器DynamicLMClassifier类提供了一个静态方法,用于创建一个分词的语言模型分类器。需要一些设置。maxTokenNgram变量设置了分类器中使用的最大令牌序列大小——较小的数据集通常从较低阶(令牌数量)n-gram 中受益。接下来,我们将设置一个tokenizerFactory方法,选择第二章中所用的主力分词器,查找和使用词汇。最后,我们将指定分类器使用的类别:

    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先验时,之前的计算会变成:

    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

    在这些数据中,所有的标记都出现一次,除了steamy,它在hot类别中出现了两次,而out则在每个类别中各出现了一次。这就是我们的训练数据。现在,让我们计算输入文本属于hot类别或cold类别的概率。假设我们的输入是单词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_,以跟踪特征的来源,这在复杂的特征提取场景中很有帮助。

逻辑回归

逻辑回归可能是大多数工业分类器的基础,唯一的例外可能是朴素贝叶斯分类器。它几乎肯定是性能最好的分类器之一,尽管代价是训练过程较慢且配置和调优较为复杂。

逻辑回归也被称为最大熵、单神经元的神经网络分类等。到目前为止,本书中的分类器基于底层的字符或词元,但逻辑回归使用的是不受限的特征提取,这允许在分类器中编码任意的情况观察。

本教程与alias-i.com/lingpipe/demos/tutorial/logistic-regression/read-me.html中的一个更完整的教程非常相似。

逻辑回归如何工作

逻辑回归所做的就是对数据进行特征权重的向量运算,应用系数向量,并进行一些简单的数学计算,最终得出每个训练类的概率。复杂的部分在于如何确定系数。

以下是我们为 21 条推文(标注为英文e和非英文n)的训练结果所生成的一些特征。由于我们的先验会将特征权重推向0.0,因此特征相对较少,一旦某个权重为0.0,该特征就会被移除。请注意,类别n的所有特征都被设置为0.0,这与逻辑回归过程的特性有关,它将一类特征固定为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类的特征全为零。由于没有与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()方法从应该熟悉的类和方法开始——如果它们不熟悉,可以看看如何通过交叉验证进行训练和评估以及引入分词器工厂——在字符流中查找单词,这些都是第一章 简单分类器和第二章 查找与处理单词中的配方:

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;

请注意,我们使用的是XValidatingObjectCorpus,而使用像ListCorpus这样更简单的实现就足够了。我们不会利用它的任何交叉验证功能,因为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;

AnnealingSchedule最好通过查阅 Javadoc 来理解,但它的作用是改变拟合模型时允许系数变化的程度。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 参数训练方法,以及一个扩展配置能力的方法。这是 10 参数版本:

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

  3. 虽然我们不会使用它,但我们展示如何将训练好的模型序列化到磁盘。如何序列化 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报告会一直进行,直到达到设定的周期数或者搜索收敛。在以下情况下,周期数已满足:

    :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. 启动你的 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);
    

    重要的是,storeInputs 必须为 true,这样才能将折叠结果添加到 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 已在第一章中讲解过了,简单分类器

    
      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包含了he,这是特征词the的子串。对于语言识别,考虑子序列是合理的,但根据经验,这对情感分析和其他问题也会有很大帮助。

修改分词器为二到四字符的 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

最终,截距是最强的特征,而整体交叉验证性能在这种情况下受到了影响。

先验

先验的作用是限制逻辑回归完美拟合训练数据的倾向。我们使用的先验在不同程度上尝试将系数推向零。我们将从nonInformativeIntercept先验开始,它控制截距特征是否受到先验的归一化影响——如果为true,则截距不受先验的影响,这在前面的例子中是这样的。将其设置为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 个特征的总数。

假设方差为2,并且使用高斯先验:

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

我们将得到以下输出:

I : 0.38866670
! : 0.27367013
Disney : 0.22699340

奇怪的是,我们很少担心使用哪种先验,但方差在性能中起着重要作用,因为它可以迅速减少特征空间。拉普拉斯先验是自然语言处理应用中常见的先验。

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

退火计划和迭代次数

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

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

在调整时,如果搜索时间过长,我们将按数量级增大退火计划的第一个参数(.0025, .025, ..)——通常,我们可以在不影响交叉验证性能的情况下提高训练速度。此外,minImprovement值可以增加,以便更早结束收敛,这可以同时提高训练速度并防止模型过拟合——这被称为早停。在这种情况下,你的指导原则是在做出改变时查看交叉验证性能。

达到收敛所需的训练轮次可能会相当高,因此如果分类器迭代到maxEpochs -1,这意味着需要更多的轮次来收敛。确保设置reporter.setLevel(LogLevel.INFO);属性或更详细的级别,以获取收敛报告。这是另外一种强制早停的方法。

参数调优是一门黑艺术,只能通过实践来学习。训练数据的质量和数量可能是分类器性能的主要因素,但调优也能带来很大差异。

自定义特征提取

逻辑回归允许使用任意特征。特征是指可以对待分类数据进行的任何观察。一些例子包括:

  • 文本中的单词/标记。

  • 我们发现字符 n-gram 比单词或词干化后的单词效果更好。对于小数据集(少于 10,000 个训练单词),我们将使用 2-4 克。更大的训练数据集可以适合使用更长的 gram,但我们从未在 8-gram 字符以上获得过好结果。

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

  • 关于文本的元数据,例如,推文的位置或创建时间。

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

如何实现…

这个食谱的来源在src/com/lingpipe/cookbook/chapter3/ContainsNumberFeatureExtractor.java中。

  1. 特征提取器非常容易构建。以下是一个返回CONTAINS_NUMBER特征,权重为1的特征提取器:

    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
    
    

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

还有更多…

设计特征有点像艺术创作。逻辑回归应该能够应对无关特征,但如果用非常低级的特征来压倒它,可能会影响性能。

你可以通过思考需要哪些特征来决定文本或环境中的哪些证据帮助你(人类)做出正确的分类决定。查看文本时,尽量忽略你的世界知识。如果世界知识(例如,法国是一个国家)很重要,那么尝试用地名词典来建模这种世界知识,以生成CONTAINS_COUNTRY_MENTION

注意,特征是字符串,等价的唯一标准是完全匹配的字符串。12:01pm特征与12:02pm特征完全不同,尽管对人类而言,这两个字符串非常接近,因为我们理解时间。要获得这两个特征的相似性,必须有类似LUNCH_TIME的特征,通过时间计算得出。

组合特征提取器

特征提取器可以像第二章中讲解的分词器一样组合使用,查找与处理单词

如何实现…

本食谱将向你展示如何将前一个食谱中的特征提取器与一个常见的字符 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. 通过以下技术优化分类器:

    • 参数调优

    • 阈值处理

    • 语言调优

    • 添加训练数据

    • 精细化分类器定义

本食谱将具体呈现前四个步骤,并且本章有优化步骤的食谱。

准备工作

没有训练数据,分类器什么也做不了。请参考本章末尾的注解食谱,获取创建训练数据的提示。你也可以使用主动学习框架逐步生成训练语料库(稍后在本章中介绍),这就是本食谱中使用的数据。

接下来,通过从最简单的实现开始,减少风险,确保所解决的问题被正确界定,并且整体架构合理。用简单的代码将假设的输入与假设的输出连接起来。我们保证大多数情况下,输入或输出之一不会是你预期的。

本食谱假设你已经熟悉第一章中的评估概念,如交叉验证和混淆矩阵,以及目前为止介绍的逻辑回归食谱。

整个源代码位于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=positiveSentiment(正面情感),n=negativeSentiment(负面情感),o=other(其他),其中o涵盖了其他语言或中性情感。混淆矩阵的第一行表明,系统识别出45个真正的正例(true positives),8个被错误识别为n的负例(false negatives),以及17个被错误识别为o的负例:

    reference\response
          \p,n,o,
        p 45,8,17,
    
  6. 要获取p的假阳性(false positives),我们需要查看第一列。我们看到系统错误地将16n标注为p,并且将18o标注为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. 对于这个配方,我们将选择在n(负面)上以 50%的精度最大化召回率,因为我们希望确保拦截任何负面情绪,并且可以容忍假阳性。我们将选择 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,因为目前为止 5 个案例中有 3 个是正确的。在找到最后一个值之前,精度实际上会低于.65——它以.73的得分加粗显示。

  7. 所以,在没有任何调优的情况下,我们可以报告,在接受的 65%精度限制下,我们能够达到p的 30%召回率。这要求我们将分类器的阈值设置为.73,即如果我们拒绝p的得分低于.73,一些评论是:

    • 我们运气不错。通常,第一次运行分类器时,默认值不会立即揭示出有用的阈值。

    • 逻辑回归分类器有一个非常好的特性,它提供条件概率估计来进行阈值设置。并不是所有分类器都有这个特性——语言模型和朴素贝叶斯分类器通常将得分推向 0 或 1,这使得阈值设置变得困难。

    • 由于训练数据高度偏倚(这是接下来训练一点,学习一点——主动学习食谱中的内容),我们不能相信这个阈值。分类器必须指向新数据来设定阈值。请参考阈值分类器食谱,了解如何做到这一点。

    • 这个分类器看到的数据非常少,尽管支持评估,它仍然不是一个适合部署的好候选者。我们会更愿意至少有来自不同日期的 1,000 条推文。

在这个过程中,我们要么通过验证新数据上的表现来接受结果,要么通过本章其他食谱中的技术来改进分类器。食谱的最后一步是用所有训练数据训练分类器并写入磁盘:

corpus.setNumFolds(0);
LogisticRegressionClassifier<CharSequence> classifier 
  = Util.trainLogReg(corpus, tokenizerFactory, progressWriter);
AbstractExternalizable.compileTo(classifier, 
  new File("models/ClassifierBuilder.LogisticRegression"));

我们将在阈值分类器的食谱中使用生成的模型。

语言调优

这个方法将通过关注系统的错误并通过调整参数和特征进行语言学调整,来解决调优分类器的问题。我们将继续使用前一个方法中的情感分析用例,并使用相同的数据。我们将从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 <特征名称>来查找特征,看看是否有非零报告,或者通过category <特征名称> NON_ZERO来进行搜索。

  5. 我们在这些特征中寻找几个方面的东西。首先,显然有一些奇怪的特征得到了很高的分数——输出按类别从正到负排序。我们想要寻找的是特征权重中的某些信号——因此love作为与正面情绪相关联是有道理的。查看这些特征可能会令人感到惊讶且反直觉。大写字母的I和小写字母的i表明文本应该转换为小写。我们将进行此更改,看看它是否有所帮助。我们当前的表现是:

    Category p
    Recall: 0.64
    Prec  : 0.57
    
  6. 代码修改是将一个LowerCaseTokenizerFactory项添加到当前的IndoEuropeanTokenizerFactory类中:

    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. 查看假阳性时,我们可以建议更改特征提取。识别~Walt Disney的引号可能有助于分类器识别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
    

    此时,系统已经做了一些调优。配置应该保存到某个地方,并考虑接下来的步骤。它们包括:

    • 宣布胜利并部署。在部署之前,务必使用所有训练数据来测试新数据。阈值分类器配方将非常有用。

    • 标注更多数据。使用以下配方中的主动学习框架帮助识别高置信度的正确和错误案例。这可能比任何事情都能更好地提升性能,尤其是在我们处理的低计数数据上。

    • 看着 epoch 报告,系统始终没有自行收敛。将限制提高到 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的精度为.65n的精度为.5,具体原因请参见分类器构建生命周期部分。o类别在此情况下并不重要。p类别的精度似乎过低,只有.57,而n类别可以通过提高精度(超过.5)来增加回忆率。

  1. 除非小心地生成了适当的标注分布,否则我们不能使用交叉验证结果——所使用的主动学习方法往往不能生成这样的分布。即使有了良好的分布,分类器可能也已经通过交叉验证进行了调整,这意味着它很可能对那个数据集进行了过拟合,因为调优决策是为了最大化那些并不适用于新数据的集合的性能。

  2. 我们需要将训练好的分类器应用于新数据——经验法则是,不惜一切代价进行训练,但始终在新数据上进行阈值调整。我们遵循了第一章、简单分类器中的从 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,覆盖率也不差:在 1,500 条推文中找到了 141 个真正的正例。由于我们没有标注所有的 1,500 条推文,因此无法准确地说出分类器的召回率是多少,所以这个术语在日常使用中是被滥用的。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 必须达到 80%的精度,这意味着阈值将是 .76

解决方案非常简单。如果 p 的分数低于 .76,则它将被重新评分为 0.0。同样,如果 n 的分数低于 .54,则它也将被重新评分为 0.0。这样做的效果是,对于所有低于阈值的情况,o 将是最佳类别,因为 .75p 最多只能是 .25n,这仍然低于 n 的阈值,而 .53n 最多只能是 .47p,这也低于该类别的阈值。如果对所有类别都设定阈值,或者阈值较低,这可能会变得复杂。

回过头来看,我们正在处理一个条件分类器,在这个分类器中所有类别的得分之和必须为 1,我们打破了这一契约,因为我们会将任何p值低于.76的估算值降为0.0n也有类似的情况。最终得到的分类器必须是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. 从第一章的简单分类器中收集我们的.csv格式的培训数据,或使用我们在data/activeLearning/disneyDedupe.0.csv中的示例数据。我们的数据基于第一章的迪士尼推文。情感是主动学习的良好候选,因为它受益于高质量的培训数据,而创建高质量的培训数据可能很困难。如果您正在使用自己的数据,请使用 Twitter 搜索下载程序的.csv文件格式。

  2. 运行来自第一章的用 Jaccard 距离消除近似重复食谱中的.csv重复消除例程,简单分类器,以消除近似重复的推文。我们已经对我们的示例数据执行了此操作。我们从 1,500 条推文减少到了 1,343 条。

  3. 如果您有自己的数据,请根据标准注释在TRUTH列中注释大约 25 个示例:

    • p代表积极情绪

    • n代表负面情绪

    • o代表其他,这意味着未表达情绪,或推文不是英文

    • 确保每个类别都有几个示例

    我们的示例数据已经为此步骤进行了注释。如果你使用的是自己的数据,请务必使用第一个文件的格式(即0.csv格式),路径中不能有其他的.

    如何操作...

    注释推文的示例。请注意,所有类别都有示例。

  4. 运行以下命令。请勿在自己注释过的数据上执行此操作,除非先备份文件。我们的输入/输出例程是为了简化,而不是为了健壮性。你已被警告:

    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. 我们可以通过选择整个表格(不包括标题),然后按B列或GUESS列排序,来专注于可能的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,这将有助于记录结果。第一章中的如何进行交叉验证训练和评估部分,简单分类器,提供了完整的解释:

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");

这就是它的工作方式。记住,这种方法引入的偏差会使评估结果失效。一定要在新的保留数据上进行测试,以正确评估分类器的性能。

标注

我们提供的最有价值的服务之一是教客户如何创建黄金标准数据,也就是训练数据。几乎每一个成功驱动的 NLP 项目都涉及大量客户主导的标注工作。NLP 的质量完全取决于训练数据的质量。创建训练数据是一个相对直接的过程,但它需要细致的关注和大量资源。从预算角度来看,你可以预期在标注上的开支将与开发团队一样多,甚至更多。

如何做到这一点...

我们将使用推文的情感分析作为例子,并假设这是一个商业情境,但即便是学术性的努力也有类似的维度。

  1. 获取 10 个你预期系统处理的示例。以我们的例子来说,这意味着获取 10 条反映系统预期范围的推文。

  2. 花些力气从你预期的输入/输出范围中进行选择。可以随意挑选一些强有力的示例,但不要编造例子。人类在创建示例数据方面做得很糟糕。说真的,不要这么做。

  3. 对这些推文进行预期类别的标注。

  4. 与所有相关方开会讨论标注工作。这些相关方包括用户体验设计师、业务人员、开发人员和最终用户。会议的目标是让所有相关方了解系统实际会做什么——系统将处理 10 个示例并生成类别标签。你会惊讶于这一步骤能带来多少清晰度。以下是几种清晰度:

    • 分类器的上游/下游用户将清楚他们需要生产或消费什么。例如,系统接受 UTF-8 编码的英文推文,并生成一个 ASCII 单字符的pnu

    • 对于情感分析,人们往往希望有一个严重程度评分,而这非常难以获得。你可以预期标注成本至少会翻倍。值得吗?你可以提供一个置信度评分,但那只是关于类别是否正确的置信度,而不是情感的严重程度。这次会议将迫使大家讨论这一点。

    • 在这次会议中,解释每个类别可能需要至少 100 个示例,甚至可能需要 500 个,才能做到合理的标注。同时解释,切换领域可能需要新的标注。自然语言处理(NLP)对你的同事来说非常简单,因此他们往往低估了构建系统所需的工作量。

    • 不要忽视涉及到为这些内容付费的人。我想,如果这是你的本科论文,你应该避免让父母参与其中。

  5. 写下一个注释标准,解释每个类别背后的意图。它不需要非常复杂,但必须存在。注释标准应当在相关人员之间传阅。如果你在提到的会议上就有一个标准,那将加分;即使如此,最终它可能会有所不同,但这也没关系。一个例子是:

    • 如果一条推文对迪士尼的情感是明确正面的,那么它就是正向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);

如果这个配方需要进一步的解释,请参考第一章中的分类器评估—混淆矩阵配方,简单分类器

还有更多……

注释是一个非常复杂的领域,值得出一本专门的书,幸运的是,确实有一本好书,《机器学习中的自然语言注释》James Pustejovsky 和 Amber StubbsO'Reilly Media。要完成注释,可以使用亚马逊的 Mechanical Turk 服务,也有一些专门创建训练数据的公司,如 CrowdFlower。然而,外包时要小心,因为分类器的效果非常依赖数据的质量。

注释者之间的冲突解决是一个具有挑战性的领域。许多错误是由于注意力不集中造成的,但有些将持续作为合法的分歧领域。两种简单的解决策略是要么丢弃数据,要么保留两个注释。

第四章. 词语和标记的标注

本章我们将涵盖以下方法:

  • 有趣短语检测

  • 前景或背景驱动的有趣短语检测

  • 隐马尔可夫模型(HMM)——词性标注

  • N 最佳词标注

  • 基于置信度的标注

  • 训练词标注

  • 词语标注评估

  • 条件随机场(CRF)用于词/标记标注

  • 修改 CRF

介绍

本章的重点是词语和标记。像命名实体识别这样的常见提取技术,实际上已经编码成了这里呈现的概念,但这需要等到第五章,在文本中找到跨度 - Chunking时才能讲解。我们将从简单的寻找有趣的标记集开始,然后转向隐马尔可夫模型(HMM),最后介绍 LingPipe 中最复杂的组件之一——条件随机场(CRF)。和往常一样,我们会向你展示如何评估标注并训练你自己的标注器。

有趣短语检测

假设一个程序能够自动从一堆文本数据中找到有趣的部分,其中“有趣”意味着某个词或短语出现的频率高于预期。它有一个非常好的特性——不需要训练数据,而且适用于我们有标记的任何语言。你最常在标签云中看到这种情况,如下图所示:

有趣短语检测

上图展示了为lingpipe.com主页生成的标签云。然而,正如 Jeffery Zeldman 在www.zeldman.com/daily/0405d.shtml中指出的那样,标签云被认为是“互联网的穆雷发型”,因此如果你在网站上部署这样的功能,可能会站不住脚。

如何做到这一点……

要从一个包含迪士尼推文的小数据集中提取有趣短语,请执行以下步骤:

  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等。解决垃圾输出有很多方法——最直接的一步是使用语言识别分类器将非英语的内容过滤掉。

它是如何工作的……

在这里,我们将完整地浏览源代码,并通过解释性文字进行拆解:

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);

在收集输入数据后,第一个有趣的代码构建了一个标记化的语言模型,这与第一章中使用的字符语言模型有显著不同,简单分类器。标记化语言模型操作的是由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-gram 的语言模型。接下来,我们将查看结果:

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[]>> 搭配词按得分从高到低排序。得分的直观理解是,当标记的共现次数超过其在训练数据中单独出现的频率时,给予更高的得分。换句话说,短语的得分取决于它们如何偏离基于标记的独立假设。请参阅 Javadoc alias-i.com/lingpipe/docs/api/com/aliasi/lm/TokenizedLM.html 获取准确的定义——一个有趣的练习是创建你自己的得分系统,并与 LingPipe 中的做法进行比较。

还有更多……

鉴于此代码接近可在网站上使用,因此值得讨论调优。调优是查看系统输出并根据系统的错误做出修改的过程。一些我们会立即考虑的修改包括:

  • 一个语言 ID 分类器,方便用来过滤非英语文本

  • 思考如何更好地标记化数据

  • 改变标记长度,以便在摘要中包含 3-gram 和 unigram

  • 使用命名实体识别来突出专有名词

前景或背景驱动的有趣短语检测

和之前的食谱一样,这个食谱也会找到有趣的短语,但它使用了另一种语言模型来判断什么是有趣的。亚马逊的统计不可能短语(SIP)就是这样运作的。你可以通过他们的官网www.amazon.com/gp/search-inside/sipshelp.html清晰了解:

“亚马逊的统计学上不太可能出现的短语,或称为“SIPs”,是《搜索内容!™》项目中书籍文本中最具辨识度的短语。为了识别 SIPs,我们的计算机扫描所有《搜索内容!》项目中的书籍文本。如果它们发现某个短语在某本书中相对于所有《搜索内容!》书籍出现的频率很高,那么该短语就是该书中的 SIP。”

SIPs 在某本书中不一定是不太可能的,但相对于《搜索内容!》中的所有书籍,它们是不太可能的。

前景模型将是正在处理的书籍,而背景模型将是亚马逊《搜索内容!™》项目中的所有其他书籍。虽然亚马逊可能已经引入了一些不同的调整,但基本理念是相同的。

准备工作

有几个数据源值得查看,以便通过两个独立的语言模型得到有趣的短语。关键在于,你希望背景模型作为预期单词/短语分布的来源,帮助突出前景模型中的有趣短语。一些示例包括:

  • 时间分隔的推特数据:时间分隔的推特数据示例如下:

    • 背景模型:这指的是直到昨天关于迪士尼世界的一整年的推文。

    • 前景模型:今天的推文。

    • 有趣的短语:今天关于迪士尼世界在推特上的新内容。

  • 话题分隔的推特数据:话题分隔的推特数据示例如下:

    • 背景模型:关于迪士尼乐园的推文

    • 前景模型:关于迪士尼世界的推文

    • 有趣的短语:关于迪士尼世界说的而不是关于迪士尼乐园说的

  • 相似主题的书籍:关于相似主题的书籍示例如下:

    • 背景模型:一堆早期的科幻小说

    • 前景模型:儒勒·凡尔纳的世界大战

    • 有趣的短语:《世界大战》的独特短语和概念

如何操作……

这是运行一个前景或背景模型来处理关于迪士尼乐园与迪士尼世界推文的步骤:

  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. 下一个二元组是关于冬季梦想,它指的是一部电影的首映。

  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);

人们可以理解为什么我们把第二章,查找和使用单词,完全用来讨论标记化,但是事实证明,大多数 NLP 系统对于字符流如何被拆分成单词或标记非常敏感。在前面的代码片段中,我们看到三个标记化工厂对字符序列进行有效的破坏。前两个在第二章,查找和使用单词中已经得到了充分的介绍,但第三个是一个自定义工厂,需要仔细检查。LengthFilterTokenizerFactoryPreserveToken类的目的在于过滤短标记,同时不丢失相邻信息。目标是处理短语"Disney is my favorite resort",并生成标记(disney, _234, _235, favorite, resort),因为我们不希望在有趣的短语中出现短单词——它们往往能轻易通过简单的统计模型,并破坏输出。有关第三个标记器的源代码,请参见src/come/lingpipe/cookbook/chapter4/LengthFilterTokenizerFactoryPreserveToken.java。此外,请参阅第二章,查找和使用单词以了解更多说明。接下来是背景模型:

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-score,如BinomialDistribution.z(double, int, int)中定义的那样,其中成功概率由背景模型中的 n-gram 概率估计定义,成功的次数是该模型中 n-gram 的计数,试验次数是该模型中的总计数。

还有更多……

这个食谱是我们第一次遇到未知标记的地方,如果处理不当,它们可能具有非常不好的属性。很容易理解为什么这对于基于标记的语言模型的最大似然估计来说是个问题,这是一种通过将每个标记的似然性相乘来估计一些未见标记的语言模型的花哨名称。每个似然性是标记在训练中出现的次数除以数据中出现的标记总数。例如,考虑使用来自《康涅狄格州的亚瑟王》的数据进行训练:

“这个故事中提到的冷酷的法律和习俗是历史性的,用来说明它们的事件也是历史性的。”

这非常少的训练数据,但足以证明所提的观点。考虑一下我们如何通过语言模型来估计短语“The ungentle inlaws”。在训练数据中,“The”出现一次,共有 24 个单词;我们将给它分配 1/24 的概率。我们也将给“ungentle”分配 1/24 的概率。如果我们在这里停止,可以说“The ungentle”的概率是 1/24 * 1/24。但是,下一个单词是“inlaws”,它在训练数据中不存在。如果这个词元被赋予 0/24 的值,那么整个字符串的可能性将变为 0(1/24 * 1/24 * 0/20)。这意味着每当有一个未见的词元,且其估计值可能为零时,这通常是一个无用的特性。

解决这个问题的标准方法是替代并近似未在训练中看到的数据的值。解决此问题有几种方法:

  • 为未知词元提供一个低但非零的估计。这是一种非常常见的方法。

  • 使用字符语言模型与未知词元。这在类中有相关的规定——请参考 Javadoc。

  • 还有许多其他方法和大量的研究文献。好的搜索词是“back off”和“smoothing”。

隐马尔可夫模型(HMM)——词性

这个配方引入了 LingPipe 的第一个核心语言学功能;它指的是单词的语法类别或词性POS)。文本中的动词、名词、形容词等是什么?

如何操作...

让我们直接进入,回到那些尴尬的中学英语课堂时光,或者是我们相应的经历:

  1. 像往常一样,去你的命令提示符并键入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar: com.lingpipe.cookbook.chapter9.PosTagger 
    
    
  2. 系统将响应一个提示,我们将在其中添加一条豪尔赫·路易斯·博尔赫斯的引用:

    INPUT> Reality is not always probable, or likely.
    
    
  3. 系统将愉快地响应这个引用:

    Reality_nn is_bez not_* always_rb probable_jj ,_, or_cc likely_jj ._. 
    
    

每个词元后附加有一个_和一个词性标签;nn是名词,rb是副词,等等。完整的标签集和标注器语料库的描述可以在en.wikipedia.org/wiki/Brown_Corpus找到。多玩玩这个。词性标注器是 90 年代 NLP 领域最早的突破性机器学习应用之一。你可以期待它的表现精度超过 90%,尽管它在 Twitter 数据上可能会有点问题,因为底层语料库是 1961 年收集的。

它是如何工作的...

适合食谱书的方式是,我们并未透露如何构建词性标注器的基础知识。可以通过 Javadoc、Web 以及研究文献来帮助你理解底层技术——在训练 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 将词标记 n 的词性标记视为先前标注的函数。

由于这些标签在数据中并不是直接观察到的,这使得马尔可夫模型成为隐含的。通常,回看一两个标记。隐马尔可夫模型(HMM)中有许多值得理解的内容。

下一行的 HmmDecoder decoder 将 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> 标注。Tagging 没有迭代器或有用的标签/标记对封装,因此需要通过递增索引 i 来访问信息。

N-best 单词标注

计算机科学的确定性驱动特性并未体现在语言学的变数上,合理的博士们至少可以同意或不同意,直到乔姆斯基的亲信出现为止。本配方使用了在前一配方中训练的相同 HMM,但为每个单词提供了可能标签的排名列表。

这在什么情况下可能有帮助?想象一个搜索引擎,它不仅搜索单词,还搜索标签——不一定是词性。这个搜索引擎可以索引单词以及最优的 n 个标签,这些标签可以让匹配的标签进入非首选标签。这可以帮助提高召回率。

如何操作...

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 的 2 ^ 2.8 = 7 倍小。这个差异的来源在于将名词标记为绿色,而不是形容词。

它是如何工作的……

加载模型和命令输入输出的代码与之前的配方相同。不同之处在于获取和显示标注所使用的方法:

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.208的概率应该被标记为nn(名词单数),但正确的分析仍然是.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,变得相似,因为它们都是介词。

句子检测食谱中,来自第五章,文本中的跨度 – 分块,基于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中。第二章中的标记化配方,查找和处理单词,涵盖了标记化器的工作原理。请看下面的代码片段:

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);

在下面的代码片段中,我们的标准 I/O 循环会被调用以获取一些用户反馈。一旦我们从用户那获得一些文本,它将通过我们用于训练的相同标记器进行标记化,并且解码器将展示生成的标记。

注意,训练分词器不必与生产分词器相同,但必须小心不要以完全不同的方式进行分词;否则,HMM 将无法看到它训练时使用的标记。接着会使用回退模型,这可能会降低性能。看一下以下的代码片段:

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);
}

就是这样!为了真正将其封装成一个合适的句子检测器,我们需要将其映射回原始文本中的字符偏移量,但这部分在第五章,在文本中查找跨度——分块中有讲解。这足以展示如何使用 HMM。一个更完备的方法将确保每个 BOS 都有一个匹配的 EOS,反之亦然。而 HMM 并没有这样的要求。

还有更多……

我们有一个小型且易于使用的词性标注语料库;这使我们能够展示如何将 HMM 的训练应用于一个完全不同的问题,并得出相同的结果。这就像我们的如何分类情感——简单版的食谱,在第一章,简单分类器;语言识别和情感分类之间唯一的区别是训练数据。为了简单起见,我们将从一个硬编码的语料库开始——它位于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。训练数据非常小,因此错误是可以预期的。就像第一章,简单分类器中语言识别和情感分类的区别一样,通过仅仅改变训练数据,HMM 用来学习一个非常不同的现象。

词标注评估

词标注评估推动了下游技术的发展,比如命名实体识别,而这些技术又推动了如共指消解等高端应用。你会注意到,大部分评估与我们分类器的评估相似,唯一的不同是每个标签都像自己的分类器类别一样被评估。

这个食谱应能帮助你开始进行评估,但请注意,我们网站上有一个关于标注评估的非常好的教程,地址是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. 首先,我们将设置TaggerEvaluator,其包含一个空的HmmDecoder和一个控制是否存储标记的booleanHmmDecoder对象将在后续代码的交叉验证代码中设置:

    HmmDecoder decoder = null;
    boolean storeTokens = true;
    TaggerEvaluator<String> evaluator = new TaggerEvaluator<String>(decoder,storeTokens);
    
  2. 接下来,我们将加载前一个食谱中的TinyPosCorpus并使用它填充XValididatingObjectCorpus——这是一种非常巧妙的技巧,允许在语料库类型之间轻松转换。注意,我们选择了 10 折——语料库只有 11 个训练示例,因此我们希望最大化每个折叠中的训练数据量。如果你是这个概念的新手,请查看第一章,简单分类器中的如何进行训练和交叉验证评估食谱。请查看以下代码片段:

    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. 为评估的洪流做好准备。以下是其中的一小部分,即你应该从第一章,简单分类器中熟悉的混淆矩阵:

    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
    

就这样。你有了一个与第一章,简单分类器中的分类器评估密切相关的评估设置。

还有更多…

针对 n 最佳词标注,存在评估类,即NBestTaggerEvaluatorMarginalTaggerEvaluator,用于信心排名。同样,可以查看更详细的词性标注教程,里面有关于评估指标的详细介绍,以及一些示例软件来帮助调整 HMM。

条件随机场(CRF)用于词/标记标注

条件随机场CRF)是第三章的逻辑回归配方的扩展,应用于词标注。在第一章的简单分类器中,我们讨论了将问题编码为分类问题的各种方式。CRF 将序列标注问题视为找到最佳类别,其中每个类别(C)是 C*T 标签(T)分配到词元的其中之一。

例如,如果我们有词组Therain,并且标签d表示限定词,n表示名词,那么 CRF 分类器的类别集如下:

  • 类别 1d d

  • 类别 2n d

  • 类别 3n n

  • 类别 4d d

为了使这个组合计算的噩梦变得可计算,采用了各种优化方法,但这是大致的思路。疯狂,但它有效。

此外,CRF 允许像逻辑回归对分类所做的那样,在训练中使用随机特征。此外,它具有针对上下文优化的 HMM 样式观察的数据结构。它在词性标注中的使用并不令人兴奋,因为我们当前的 HMM 已经接近最先进的技术。CRF 真正有所作为的地方是像命名实体识别这样的使用案例,这些内容在第五章的在文本中寻找跨度 - 分块中有所涵盖,但我们希望在通过分块接口使演示更加复杂之前,先讨论纯 CRF 实现。

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. 控制台继续显示收敛结果,这些结果应该与你在第三章的逻辑回归配方中见过的非常相似,我们将得到标准的命令行提示符:

    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 特定方面,并参考第三章中的逻辑回归食谱,了解与配置相关的逻辑回归部分。

main()方法的顶部开始,我们将获取我们的语料库,这部分在前面三个食谱中有讨论:

Corpus<ObjectHandler<Tagging<String>>> corpus = new TinyPosCorpus();

接下来是特征提取器,它是 CRF 训练器的实际输入。它之所以是最终的,仅仅是因为一个匿名内部类将访问它,以展示在下一个食谱中如何进行特征提取:

final ChainCrfFeatureExtractor<String> featureExtractor
  = new SimpleCrfFeatureExtractor();

我们将在本食谱后面讨论这个类的工作原理。

接下来的配置块是针对底层逻辑回归算法的。有关更多信息,请参考第三章中的逻辑回归食谱,看看以下代码片段:

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如何工作的内容,请参考第二章,查找和使用单词

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

现在,我们将进入特征提取器部分。提供的实现 closely mimics 标准 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。特征值可以有用地调整为 1.0 以外的其他值,这对于更复杂的 CRF 方法非常有用,比如使用其他分类器的置信度估计。看看下面的食谱,以了解如何实现这一点。

与 HMM 类似,有些特征依赖于输入中的其他位置——这些被称为边缘特征。边缘特征接受两个参数:一个是生成特征的位置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 网站上也有一个更加详细的教程。

修改 CRF

CRF 的强大和吸引力来源于丰富的特征提取——通过提供反馈的评估工具来进行你的探索。本示例将详细介绍如何创建更复杂的特征。

如何操作……

我们不会训练和运行 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. 这反映了John标记的训练标签,它是由src/com/lingpipe/cookbook/chapter4/TinyPosCorpus.java文件中确定的。

  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));
}

现在我们打印出特征。接下来,我们将介绍如何构建特征提取器。假设你已经熟悉之前的示例。首先,构造函数引入了 Brown 语料库 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语料库甚至太小,无法带来这样的好处,但如果有更多的数据,at这个特征统一了theasome,这将帮助 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 中动作的关键所在,并且它们可以变得非常丰富。在第五章中的使用 CRF 和更好的特征进行命名实体识别方法,Finding Spans in Text – Chunking,就是一个例子。在这个方法中,我们将为前一个方法的令牌特征添加词性标注:

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 的目标就是预测这些标签。

接下来,从词性特征标注器中提取出前三个标签,并与相关的条件概率一起添加。CRF 将能够通过这些变化的权重进行有效的工作。

还有更多…

在生成新特征时,值得考虑数据的稀疏性。如果日期可能是 CRF 的重要特征,可能不适合做计算机科学中的标准操作——将日期转换为自 1970 年 1 月 1 日格林威治标准时间以来的毫秒数。原因是MILLI_1000000000特征将被视为与MILLI_1000000001完全不同。原因有几个:

  • 底层分类器并不知道这两个值几乎相同。

  • 分类器并不知道MILLI_前缀是相同的——这个通用前缀仅仅是为了方便人类。

  • 该特征在训练中不太可能出现多次,可能会被最小特征计数修剪掉。

而不是将日期标准化为毫秒,考虑使用一个抽象层来表示日期,这个日期在训练数据中可能有很多实例,例如忽略实际日期但记录日期存在性的has_date特征。如果日期很重要,那么计算关于日期的所有重要信息。如果它是星期几,那么映射到星期几。如果时间顺序很重要,那么映射到更粗略的度量,这些度量可能有许多测量值。一般来说,CRF 和底层的逻辑回归分类器对于无效特征具有鲁棒性,因此可以大胆尝试创新——添加特征不太可能使准确度更差。

第五章:在文本中查找跨度—分块

本章涵盖以下内容:

  • 句子检测

  • 句子检测的评估

  • 调整句子检测

  • 在字符串中标记嵌套的分块—句子分块示例

  • 段落检测

  • 简单的名词短语和动词短语

  • 基于正则表达式的命名实体识别(NER)分块

  • 基于词典的 NER 分块

  • 在单词标注和分块之间转换—BIO 编解码器

  • 基于隐马尔可夫模型(HMM)的 NER

  • 混合 NER 数据源

  • 用于分块的条件随机场(CRFs)

  • 使用更好的特征的条件随机场(CRFs)进行 NER

介绍

本章将告诉我们如何处理通常涵盖一个或多个单词/标记的文本跨度。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应该对你来说并不陌生,来自第二章,查找和处理单词。然后可以构建sentenceChunker。以下是标准的命令行交互输入/输出代码:

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,它是Chunks的基础。它与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 系统和各种共指解析研究系统中,我们已经涉及过此问题。这超出了食谱书的范围,但请注意这个问题。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累加器中。for循环外部创建String unannotatedText时,可以看到这个累加器的结果。现在,我们已经将句子分块正确地索引到文本字符串中。接下来,我们将创建一个合适的Chunking对象:

ChunkingImpl sentChunking = new ChunkingImpl(unannotatedText);
for (Chunk chunk : sentChunks) {
  sentChunking.add(chunk);
}

实现类ChunkingImplChunking是接口)在构造时需要底层文本,这就是为什么我们没有在前面的循环中直接填充它。LingPipe 通常会尝试使对象构造完整。如果可以不使用底层CharSequence方法创建Chunking,那么调用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");

前面的代码只是注释掉了两种可能的单字母倒数第二个标记的情况,这些情况是单个字符的单词。要查看其效果,请将句子模型更改为SentenceModel sentenceModel = new MySentenceModel();,并在EvaluateAnnotatedSentences.java类中重新编译并运行。

如果你将前面的代码视为一个合理的平衡,它可以找到以可能的缩写结尾的句子与非句子情况之间的平衡,例如[Hunter S. Thompson is a famous fellow.],它会将S.识别为句子边界。

扩展HeuristicSentenceModel对于多种类型的数据非常有效。Mitzi Morris 构建了MedlineSentenceModel.java,它设计得很好,适用于 MEDLINE 研究索引中提供的摘要。

看待前面问题的一种方式是,缩写不应被拆分为标记用于句子检测。IndoEuropeanTokenizerFactory应该进行调整,以将"people's"和其他缩写保持在一起。虽然这初看起来似乎稍微比第一个解决方案好,但它可能会遇到IndoEuropeanSentenceModel是针对特定的分词方式进行调整的问题,而在没有评估语料库的情况下,改变的后果是未知的。

另一种选择是编写一个完全新的句子检测类,支持SentenceModel接口。面对像 Twitter 流这样的高度新颖的数据集,我们可以考虑使用基于机器学习的跨度注释技术,如 HMMs 或 CRFs,这些内容在第四章,标注词汇和标记中以及本章末尾讨论过。

标记字符串中的嵌入块——句子块示例

先前食谱中展示块的方法不适用于需要修改底层字符串的应用程序。例如,一个情感分析器可能只想突出显示那些情感强烈的正面句子,而不标记其余句子,同时仍然显示整个文本。在生成标记化文本时的一个小难点是,添加标记会改变底层字符串。这个食谱提供了通过逆序添加块来插入块的工作代码。

如何实现...

尽管这个食谱在技术上可能不复杂,但它对于在文本中添加跨度注释非常有用,而无需从零开始编写代码。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>,或者通过两个或更多的换行符来设置,这也是段落通常如何呈现的方式。我们处于自然语言处理的领域,这里没有硬性规定,所以我们为这种含糊其辞表示歉意。我们将在本章中处理一些常见的示例,并将推广的部分留给你来完成。

如何实现...

我们从未为段落检测设置过评估工具,但它可以通过类似句子检测的方式进行实现。这个食谱将演示一个简单的段落检测程序,它做了一件非常重要的事情——在进行嵌入句子检测的同时,保持原始文档的偏移量。细节上的关注会在你需要以对句子或文档的其他子跨度(例如命名实体)敏感的方式标记文档时帮助你。请考虑以下示例:

Sentence 1\. Sentence 2
Sentence 3\. Sentence 4.

它被转化为以下内容:

{[Sentence 1.] [Sentence 2]}

{[Sentence 3.] [Sentence 4.]
}

在前面的代码片段中,[] 表示句子,{} 表示段落。我们将直接跳入这个配方的代码,位于 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);

我们的标准控制台输入/输出代码如下:

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);
  }

这里有一个更全面的教程,访问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.comfoo.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-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)分块

在许多网站和博客,特别是在网络论坛上,你可能会看到关键词高亮,这些关键词链接到你可以购买产品的页面。同样,新闻网站也提供关于人物、地点和流行事件的专题页面,例如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布尔值。分块器需要一个分词器,因为它根据符号匹配分词,并且在匹配过程中会忽略空白字符。

接下来是我们的标准输入/输出代码,用于控制台交互:

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);

字典块划分器在基于机器学习的系统中也非常有用。通常,总会有一些实体类别,使用这种方式最容易识别。混合命名实体识别源食谱介绍了如何处理多个命名实体来源。

词语标记与块之间的转换 – BIO 编解码器

在第四章中,标签词语与词元,我们使用了 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 的命名实体识别(NER)

HmmChunker使用 HMM 对标记化的字符序列进行块划分。实例包含用于该模型的 HMM 解码器和分词器工厂。块划分器要求 HMM 的状态符合块的逐个词元编码。它使用分词器工厂将块分解为词元和标签序列。请参考第四章中的隐马尔可夫模型(HMM) – 词性食谱,标签词语与词元

我们将讨论如何训练HmmChunker并将其用于CoNLL2002西班牙语任务。你可以并且应该使用自己的数据,但这个配方假设训练数据将采用CoNLL2002格式。

训练是通过一个ObjectHandler完成的,ObjectHandler提供了训练实例。

准备工作

由于我们希望训练这个 chunker,我们需要使用计算自然语言学习CoNLL)模式标注一些数据,或者使用公开的模式。为了提高速度,我们选择获取一个在 CoNLL 2002 任务中可用的语料库。

注意

ConNLL 是一个年度会议,赞助一个比赛。2002 年,这个比赛涉及了西班牙语和荷兰语的命名实体识别(NER)。

数据可以从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被编码为人物(person),其开始和继续的标记分别为 B-PER 和 I-PER。

注意

数据中有一些格式错误,必须修复这些错误,才能让我们的解析器处理它们。在数据目录解压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提供了一个基于长距离字符语言模型的 chunker,通过重新评分包含的字符语言模型 HMM chunker 的输出结果来运行。底层的 chunker 是CharLmHmmChunker的一个实例,它根据构造函数中指定的分词器工厂、n-gram 长度、字符数和插值比率进行配置。

让我们从main()方法开始;在这里,我们将设置 chunker,如果模型不存在则进行训练,然后允许输入以提取命名实体:

String modelFilename = "models/Conll2002_ESP.RescoringChunker";
String trainFilename = "data/ner/data/esp.train";

如果你在数据目录中解压了 CoNLL 数据(tar –xvzf ner.tgz),训练文件将位于正确的位置。记得修正esp.train文件第 221619 行的标注。如果你使用其他数据,请修改并重新编译类。

接下来的代码段会训练模型(如果模型不存在),然后加载序列化版本的分块器。如果你对反序列化有疑问,请参见第一章中的反序列化和运行分类器部分,了解更多内容。以下是代码片段:

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()方法首先进行一些File文件管理,然后设置CharLmRescoringChunker的配置参数:

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);

从第一个参数开始,numChunkingsRescored设置来自嵌入式Chunker的分块数量,这些分块将重新评分以提高性能。此重新评分的实现可能有所不同,但通常会使用更少的局部信息来改进基本的 HMM 输出,因为它在上下文上有限。maxNgram设置每种分块类型的最大字符数,用于重新评分的字符语言模型,而lmInterpolation决定模型如何进行插值。一个好的值是字符 n-gram 的大小。最后,创建一个分词器工厂。在这个类中有很多内容,更多信息请查阅 Javadoc。

方法中的下一部分将获取一个解析器,我们将在接下来的代码片段中讨论,它接受chunkerEstimatorsetHandler()方法,然后,parser.parse()方法进行实际训练。最后一段代码将模型序列化到磁盘——请参见第一章中的如何序列化 LingPipe 对象—分类器示例部分,了解其中发生的情况:

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 编解码器。剩余的参数将标签自定义为与 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);
}

接下来,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);
}

还有更多内容……

欲了解如何运行完整评估的更多细节,请参见教程中的评估部分:alias-i.com/lingpipe/demos/tutorial/ne/read-me.html

另见

有关CharLmRescoringChunkerHmmChunker的更多详情,请参见:

混合 NER 源

现在我们已经看过如何构建几种不同类型的命名实体识别(NER),接下来可以看看如何将它们组合起来。在本教程中,我们将结合正则表达式块器、基于词典的块器和基于 HMM 的块器,并将它们的输出合并,看看重叠情况。

我们将以与前几个食谱中相同的方式初始化一些块器,然后将相同的文本传递给这些块器。最简单的情况是每个块器返回唯一的输出。例如,我们考虑一个句子:“总统奥巴马原定于今晚在 G-8 会议上发表演讲”。如果我们有一个人名块器和一个组织块器,我们可能只会得到两个唯一的块。然而,如果我们再加入一个美国总统块器,我们将得到三个块: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. 如果允许重叠,我们将在合并的输出中看到PRESIDENTPERSON的块。

  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 的块类型,你可以选择它。

用于分块的 CRF

CRF 最著名的是在命名实体标注方面提供接近最先进的性能。本食谱将告诉我们如何构建这样的系统。该食谱假设你已经阅读、理解并尝试过条件随机场 – 用于词汇/标记标注的第四章,该章节涉及了基础技术。与 HMM 类似,CRF 将命名实体识别视为一个词汇标注问题,具有一个解释层,提供分块信息。与 HMM 不同,CRF 使用基于逻辑回归的分类方法,这使得可以包含随机特征。此外,本食谱遵循了一个优秀的 CRF 教程(但省略了细节),教程地址是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。我们知道它通过从输入文本中提取从 0 到 10 的子字符串来应用于John Smith。忽略–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 看到John出现在PER的开始位置,Smith出现在另一个PER的结束位置,而不是直接看到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);

当我们之前使用 CRF 时,输入数据是Tagging<String>类型。回顾TinyEntityCorpus.java,数据类型是Chunking类型。前述的BioTagChunkCodec通过提供的TokenizerFactoryboolean来帮助将Chunking转换为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

编解码器也将执行相反的操作。Javadoc 值得一看。一旦建立了这种映射,剩下的 CRF 与背后的词性标注案例是相同的,正如我们在条件随机场 – 用于词语/标记标注的配方中所展示的那样,参见第四章,标注词语和标记。考虑以下代码片段:

ChainCrfFeatureExtractor<String> featureExtractor = new SimpleCrfFeatureExtractor();

所有的机制都隐藏在一个新的 ChainCrfChunker 类中,它的初始化方式类似于逻辑回归,这是其底层技术。如需了解更多配置信息,请参阅 第三章中的 逻辑回归 示例,高级分类器

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)

在这个示例中,我们将展示如何为 CRF 创建一个逼真的、尽管不是最先进的、特征集。这些特征将包括标准化的标记、词性标签、词形特征、位置特征以及标记的前后缀。将其替换为 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}
    
    

它是如何工作的……

与其他示例一样,我们不会讨论那些与之前示例非常相似的部分——这里相关的前一个示例是 第四章中的 Modifying CRFs标记单词和标记。这完全相同,唯一不同的是我们将添加更多特征——可能来自意想不到的来源。

注意

CRFs 的教程涵盖了如何序列化/反序列化这个类。该实现并未覆盖这部分内容。

对象构造方式类似于 第四章中的 Modifying 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;
  }

这是一个很好的特征集,适合你的命名实体检测器。

第六章. 字符串比较与聚类

本章将涵盖以下几种方案:

  • 距离和接近度 – 简单编辑距离

  • 加权编辑距离

  • Jaccard 距离

  • Tf-Idf 距离

  • 使用编辑距离和语言模型进行拼写纠正

  • 大小写恢复修正器

  • 自动短语完成

  • 使用编辑距离的单链和完全链接聚类

  • 潜在狄利克雷分配(LDA)用于多主题聚类

介绍

本章从使用标准的语言中立技术来比较字符串开始。然后,我们将使用这些技术构建一些常用的应用程序。我们还将探讨基于字符串之间距离的聚类技术。

对于字符串,我们使用标准定义,即字符串是字符的序列。所以,显然,这些技术适用于单词、短语、句子、段落等,你在前几章中已经学会了如何提取这些内容。

距离和接近度 – 简单编辑距离

字符串比较是指用于衡量两个字符串相似度的技术。我们将使用距离和接近度来指定任意两个字符串的相似性。两个字符串的相似性越高,它们之间的距离就越小,因此,一个字符串与自身的距离为 0。相反的度量是接近度,意味着两个字符串越相似,它们的接近度就越大。

我们将首先看看简单编辑距离。简单编辑距离通过衡量将一个字符串转换为另一个字符串所需的编辑次数来计算距离。Levenshtein 在 1965 年提出的一种常见距离度量允许删除、插入和替换作为基本操作。加入字符交换后就称为 Damerau-Levenshtein 距离。例如,fooboo之间的距离为 1,因为我们是在将f替换为b

注意

有关距离度量的更多信息,请参考维基百科上的距离文章。

让我们看一些可编辑操作的更多示例:

  • 删除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: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 A3变成serpina3,但它们指的却是同一样东西。

另见

Jaccard 距离

Jaccard 距离是一种非常流行且高效的字符串比较方法。Jaccard 距离在标记级别进行操作,通过首先对两个字符串进行标记化,然后将共同标记的数量除以总的标记数量来比较两个字符串。在第一章《简单分类器》中的使用 Jaccard 距离消除近似重复项示例中,我们应用该距离来消除近似重复的推文。本篇会更详细地介绍,并展示如何计算它。

距离为 0 是完美匹配,也就是说,两个字符串共享所有的词项,而距离为 1 是完美不匹配,也就是说,两个字符串没有共同的词项。请记住,接近度和距离是相互逆的,因此接近度的范围也是从 1 到 0。接近度为 1 是完美匹配,接近度为 0 是完美不匹配:

proximity  = count(common tokens)/count(total tokens)
distance = 1 – proximity

标记由 TokenizerFactory 生成,在构造时传入。例如,让我们使用 IndoEuropeanTokenizerFactory,并看一个具体示例。如果 string1fruit flies like a bananastring2time 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使用基础分词器工厂构建自己。如果有任何疑问,参阅第二章,查找和处理词语

接下来,构建 Jaccard 距离类,并将分词器工厂作为参数:

JaccardDistance jaccardIndoEuropean = new JaccardDistance(indoEuropeanTf);
JaccardDistance jaccardCharacter = new JaccardDistance(characterTf);

JaccardDistance jaccardEnglishStopWord = new JaccardDistance(englishStopWordTf);

其余的代码只是我们标准的输入/输出循环和一些打印语句。就是这样!接下来是更复杂的字符串距离度量。

Tf-Idf 距离

一个非常有用的字符串间距离度量是由TfIdfDistance类提供的。它实际上与流行的开源搜索引擎 Lucene/SOLR/Elastic Search 中的距离度量密切相关,其中被比较的字符串是查询与索引中文档的比对。Tf-Idf 代表核心公式,即词频TF)乘以逆文档频率IDF),用于查询与文档中共享的词。关于这种方法的一个非常酷的地方是,常见词(例如,the)在文档中出现频繁,因此其权重被下调,而稀有词则在距离比较中得到上调。这有助于将距离集中在文档集中真正具有区分性的词上。

TfIdfDistance不仅对类似搜索引擎的应用非常有用,它对于聚类和任何需要计算文档相似度的问题也非常有用,而无需监督训练数据。它有一个理想的属性;分数被标准化为 0 到 1 之间的分数,并且对于固定的文档d1和不同长度的文档d2,不会使分配的分数过大。在我们的经验中,如果你想评估一对文档的匹配质量,不同文档对的分数是相当稳健的。

注意

请注意,有一系列不同的距离被称为 Tf-Idf 距离。此类中的距离定义为对称的,不像典型的用于信息检索目的的 Tf-Idf 距离。

Javadoc 中有很多值得一看的信息。然而,针对这些食谱,你需要知道的是,Tf-Idf 距离在逐字查找相似文档时非常有用。

如何做……

为了让事情稍微有点趣味,我们将使用我们的TfIdfDistance类来构建一个非常简单的推文搜索引擎。我们将执行以下步骤:

  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。如果你不熟悉标记器工厂,可以参考第二章中的修改标记器工厂食谱,以获取解释:

TokenizerFactory tokFact = IndoEuropeanTokenizerFactory.INSTANCE;
TfIdfDistance tfIdfDist = new TfIdfDistance(tokFact);

然后,我们将通过按“.”分割训练文本来获取将作为 IDF 组件的数据,这种方式大致上是句子检测——我们本可以像在第五章的句子检测食谱中那样进行正式的句子检测,但我们选择尽可能简单地展示这个例子:

String training = Files.readFromFile(new File(idfFile), Strings.UTF8);
for (String line: training.split("\\.")) {
  tfIdfDist.handle(line);
}

for循环中,有handle(),它通过语料库中的标记分布训练该类,句子即为文档。通常情况下,文档的概念要么小于(句子、段落和单词),要么大于通常所称的文档。在这种情况下,文档频率将是该标记所在的句子数。

接下来,我们加载我们要搜索的文档:

List<String[]> docsToSearch = Util.readCsvRemoveHeader(new File(searchableDocs));

控制台设置为读取查询:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
  System.out.println("enter a query: ");
  String query = reader.readLine();

接下来,每个文档将使用TfIdfDistance与查询进行评分,并放入ObjectToDoubleMap中,该映射用于跟踪相似度:

ObjectToDoubleMap<String> scoredMatches = new ObjectToDoubleMap<String>();
for (String [] line : docsToSearch) {
  scoredMatches.put(line[Util.TEXT_OFFSET], tfIdfDist.proximity(line[Util.TEXT_OFFSET], query));
}

最后,scoredMatches按相似度顺序被检索,并打印出前 10 个示例:

List<String> rankedDocs = scoredMatches.keysOrderedByValueList();
for (int i = 0; i < 10; ++i) {
  System.out.printf("%.2f : ", scoredMatches.get(rankedDocs.get(i)));
  System.out.println(rankedDocs.get(i));
}
}

尽管这种方法非常低效,因为每次查询都遍历所有训练数据,进行显式的TfIdfDistance比较并存储结果,但它对于玩转小数据集和比较度量指标来说并不是一种坏方法。

还有更多内容...

有一些值得强调的TfIdfDistance的细节。

有监督和无监督训练的区别

当我们训练TfIdfDistance时,在训练的使用上有一些重要的区别,这些区别与本书其他部分的使用不同。这里进行的训练是无监督的,这意味着没有人类或其他外部来源标记数据的预期结果。本书中大多数训练使用的是人类标注或监督数据。

在测试数据上训练是可以的

由于这是无监督数据,因此没有要求训练数据必须与评估或生产数据不同。

使用编辑距离和语言模型进行拼写纠正

拼写纠正接收用户输入的文本并提供纠正后的形式。我们大多数人都熟悉通过智能手机或像 Microsoft Word 这样的编辑器进行的自动拼写纠正。网络上显然有很多有趣的例子,展示了拼写纠正失败的情况。在这个例子中,我们将构建自己的拼写纠正引擎,并看看如何调整它。

LingPipe 的拼写纠正基于噪声信道模型,该模型模拟了用户的错误和预期用户输入(基于数据)。预期用户输入通过字符语言模型进行建模,而错误(或噪声)则通过加权编辑距离建模。拼写纠正是通过CompiledSpellChecker类来完成的。该类实现了噪声信道模型,并根据实际收到的消息,提供最可能的消息估计。我们可以通过以下公式来表达这一点:

didYouMean(received) = ArgMaxintended P(intended | received) 
= ArgMaxintended P(intended,received) / P(received) 
= ArgMaxintended P(intended,received) 
= ArgMaxintended P(intended) * P(received | intended)

换句话说,我们首先通过创建一个 n-gram 字符语言模型来构建预期消息的模型。语言模型存储了已见短语的统计数据,本质上,它存储了 n-gram 出现的次数。这给我们带来了P(intended)。例如,P(intended)表示字符序列the的可能性。接下来,我们将创建信道模型,这是一个加权编辑距离,它给出了输入错误的概率,即用户输入的错误与预期文本之间的差距。再例如,用户本来打算输入the,但错误地输入了teh,这种错误的概率是多少。我们将使用加权编辑距离来建模这种可能性,其中权重按对数概率进行缩放。请参考本章前面的加权编辑距离配方。

创建一个编译后的拼写检查器的常见方法是通过TrainSpellChecker实例。编译拼写检查训练类并将其读取回来后的结果就是一个编译过的拼写检查器。TrainSpellChecker通过编译过程创建了基本的模型、加权编辑距离和标记集。然后,我们需要在CompiledSpellChecker对象上设置各种参数。

可以选择性地指定一个分词工厂来训练对标记敏感的拼写检查器。通过分词,输入会进一步规范化,在所有未由空格分隔的标记之间插入单个空格。标记会在编译时输出,并在编译后的拼写检查器中读取回来。标记集的输出可能会被修剪,以删除任何低于给定计数阈值的标记。因为在没有标记的情况下我们只有字符,所以阈值在没有标记的情况下没有意义。此外,已知标记集可用于在拼写校正时限制替代拼写的建议,仅包括观察到的标记集中的标记。

这种拼写检查方法相较于纯粹基于字典的解决方案有几个优点:

  • 这个上下文得到了有效建模。如果下一个词是dealership,则Frod可以被纠正为Ford;如果下一个词是Baggins(《魔戒》三部曲中的角色),则可以纠正为Frodo

  • 拼写检查可以对领域敏感。这个方法相较于基于字典的拼写检查还有一个大优点,那就是修正是基于训练语料库中的数据进行的。因此,在法律领域,trt将被纠正为tort,在烹饪领域,它将被纠正为tart,在生物信息学领域,它将被纠正为TRt

如何操作...

让我们来看一下运行拼写检查的步骤:

  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 现在可以构建,接下来我们将从古腾堡计划中加载 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++;
  }
}

提示

我们在这个模型中包含了一个字典,我们将像处理其他数据一样将字典条目输入到训练器中。

通过多次训练字典中的每个单词,可能会使字典得到增强。根据字典的数量,它可能会主导或被源训练数据所主导。

另请参阅

大小写恢复校正器

大小写恢复拼写校正器,也叫做真大小写校正器,只恢复大小写,不更改其他任何内容,也就是说,它不会纠正拼写错误。当处理转录、自动语音识别输出、聊天记录等低质量文本时,这非常有用,因为这些文本通常包含各种大小写问题。我们通常希望增强这些文本,以构建更好的基于规则或机器学习的系统。例如,新闻和视频转录(如字幕)通常存在错误,这使得使用这些数据训练命名实体识别(NER)变得更加困难。大小写恢复可以作为不同数据源之间的标准化工具,确保所有数据的一致性。

如何操作……

  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

我们将重点讨论与前一个方法不同的部分,而不是重复所有源代码。我们将使用来自古腾堡计划的英文文本训练拼写检查器,并使用 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);

就是这样。大小写恢复变得简单。

另见

自动短语补全

自动短语补全与拼写校正不同,它是在用户输入的文本中,从一组固定短语中找到最可能的补全。

显然,自动短语补全在网络上无处不在,例如,在google.com上。例如,如果我输入 anaz 作为查询,谷歌会弹出以下建议:

自动短语补全

请注意,应用程序在完成补全的同时也在进行拼写检查。例如,即使查询到目前为止是anaz,但顶部的建议是amazon。这并不令人惊讶,因为以anaz开头的短语的结果数量可能非常少。

接下来,注意到它并不是进行单词建议,而是短语建议。比如一些结果,如amazon prime是由两个单词组成的。

自动补全和拼写检查之间的一个重要区别是,自动补全通常是基于一个固定的短语集,必须匹配开头才能完成。这意味着,如果我输入查询I want to find anaz,就不会有任何推荐补全。网页搜索的短语来源通常是来自查询日志的高频查询。

在 LingPipe 中,我们使用AutoCompleter类,它维护一个包含计数的短语字典,并通过加权编辑距离和短语似然性基于前缀匹配提供建议的补全。

自动补全器为给定的前缀找到得分最高的短语。短语与前缀的得分是短语得分和该前缀与短语任何前缀匹配的最大得分之和。短语的得分就是其最大似然概率估计,即其计数的对数除以所有计数的总和。

谷歌和其他搜索引擎很可能将它们的查询计数作为最佳得分短语的数据。由于我们这里没有查询日志,因此我们将使用美国人口超过 100,000 的城市的美国人口普查数据。短语是城市名称,计数是它们的人口。

如何操作...

  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(树状图),而不是一组对象的集合。树状图是一个二叉树,表示正在聚类的元素,其中每个分支附有距离值,表示两个子分支之间的距离。对于aaaaaaaaaabbbbbbb这些字符串,基于单链的树状图并采用EditDistance作为度量看起来是这样的:

3.0
 2.0
 1.0
 aaa
 aa
 aaaaa
 1.0
 bbbb
 bbb

上述树状图基于单链聚类,单链聚类将任何两个元素之间的最小距离作为相似性的度量。因此,当{'aa','aaa'}{'aaaa'}合并时,得分为 2.0,通过将两个a添加到aaa中。完全链接聚类则采用任何两个元素之间的最大距离,这将是 3.0,通过将三个a添加到aa中。单链聚类倾向于形成高度分离的聚类,而完全链接聚类则倾向于形成更紧密的聚类。

从树状图中提取聚类有两种方法。最简单的方法是设置一个距离上限,并保持所有在此上限或以下形成的聚类。另一种构建聚类的方法是继续切割最大距离的聚类,直到获得指定数量的聚类。

在这个示例中,我们将研究使用EditDistance作为距离度量的单链和完全链接聚类。我们将尝试通过EditDistance对城市名称进行聚类,最大距离为 4。

如何操作…

  1. 在您的 IDE 中运行HierarchicalClustering类,或者在命令行中输入以下内容:

    java -cp lingpipe-cookbook.1.0.jar:lib/lingpipe-4.1.0.jar com.lingpipe.cookbook.chapter6.HierarchicalClustering
    
    
  2. 输出是对同一基础集合Strings的各种聚类方法。在这个示例中,我们将交替展示源和输出。首先,我们将创建我们的字符串集合:

    public static void main(String[] args) throws UnsupportedEncodingException, IOException {
    
      Set<String> inputSet = new HashSet<String>();
      String [] input = { "aa", "aaa", "aaaaa", "bbb", "bbbb" };
      inputSet.addAll(Arrays.asList(input));
    
  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非常敏感。查阅 Javadoc 以获取 10 个实现类的可能变种。TfIdfDistance在聚类语言数据时非常有用。

K-means(++)聚类是一种基于特征提取的聚类方法。Javadoc 是这样描述它的:

K-means 聚类 可以视为一种迭代方法,旨在最小化项目与其聚类中心之间的平均平方距离……

另请参见…

潜在狄利克雷分配 (LDA) 用于多主题聚类

潜在狄利克雷分配 (LDA) 是一种基于文档中存在的标记或单词的文档聚类统计技术。像分类这样的聚类通常假设类别是互斥的。LDA 的一个特点是,它允许文档同时属于多个主题,而不仅仅是一个类别。这更好地反映了一个推文可以涉及迪士尼沃利世界等多个主题的事实。

LDA 的另一个有趣之处,就像许多聚类技术一样,是它是无监督的,这意味着不需要监督式训练数据!最接近训练数据的是必须提前指定主题的数量。

LDA 可以是探索你不知道的未知数据集的一个很好的方式。它也可能很难调整,但通常它会做出一些有趣的结果。让我们让系统运作起来。

对于每个文档,LDA 根据该文档中的单词分配一个属于某个主题的概率。我们将从转换为标记序列的文档开始。LDA 使用标记的计数,并不关心单词出现的上下文或顺序。LDA 在每个文档上操作的模型被称为“词袋模型”,意味着顺序并不重要。

LDA 模型由固定数量的主题组成,每个主题都被建模为一个单词分布。LDA 下的文档被建模为主题分布。对单词的主题分布和文档的主题分布都存在狄利克雷先验。如果你想了解更多幕后发生的事情,可以查看 Javadoc、参考教程和研究文献。

准备工作

我们将继续使用来自推文的.csv数据。请参考第一章,简单分类器,了解如何获取推文,或使用书中的示例数据。该配方使用data/gravity_tweets.csv

这个教程紧密跟随了alias-i.com/lingpipe/demos/tutorial/cluster/read-me.html中的教程,该教程比我们在这个配方中所做的更为详细。LDA 部分位于教程的最后。

如何做到的…

本节将对src/com/lingpipe/cookbook/chapter6/Lda.java进行源代码审查,并参考src/com/lingpipe/cookbook/chapter6/LdaReportingHandler.java辅助类,在使用其部分内容时进行讨论:

  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 参数设置在采样之前运行多少个周期。将其设置为大于 0 会产生一些理想的效果,避免样本之间的相关性。sampleLag 控制在烧入阶段完成后,采样的频率,numSamples 控制采样的数量。目前将进行 2000 次采样。如果 burninEpochs 为 1000,那么将会进行 3000 次采样,样本间隔为 1(每次都采样)。如果 sampleLag 为 2,那么将会有 5000 次迭代(1000 次烧入,2000 次每 2 个周期采样,总共 4000 个周期)。更多细节请参见 Javadoc 和教程:

    int burninEpochs = 0;
    int sampleLag = 1;
    int numSamples = 2000;
    
  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. 为了获取有关周期/样本的进度报告,创建了一个处理程序来传递所需的消息。它将 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. 看一下结果输出的样本,确认配置和搜索周期的早期报告:

    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. 完成时,我们将获得一个关于发现的主题的报告。第一个主题从按计数排序的单词列表开始。请注意,该主题没有标题。可以通过扫描具有高计数和高 Z 分数的单词来获取meaning主题。在这种情况下,有一个 Z 分数为 4.0 的单词moviea得到了 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. 在浏览主题时,我们发现仍然有许多低价值的词汇扰乱了主题,但Topic 18看起来有些有希望,其中bestever的 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. 进一步查看输出,我们会看到一些在Topic 18上得分很高的文档:

    DOC 34
    TOPIC    COUNT    PROB
    ----------------------
     18        3   0.270
     4        2   0.183
     3        1   0.096
     6        1   0.096
     8        1   0.096
     19        1   0.096
    
    Gravity(4) is(6) the(8) best(18) movie(19) I've(18) seen(18) in(3) a(4)
    
    DOC 50
    TOPIC    COUNT    PROB
    ----------------------
     18        6   0.394
     17        4   0.265
     5        2   0.135
     7        1   0.071
    
    The(17) movie(18) Gravity(7) has(17) to(17) be(5) one(18) of(18) the(18) best(18) of(18) all(17) time(5)
    
    
  24. 对于best movie ever主题,这两者看起来都是合理的。然而,请注意其他主题/文档分配相当糟糕。

诚实地说,我们不能完全宣称在这个数据集上取得了胜利,但我们已经阐明了 LDA 的工作原理及其配置。LDA 在商业上并不是巨大的成功,但它为国家卫生研究院和其他客户提供了有趣的概念级别实现。LDA 是一个调谐者的天堂,有很多方法可以对生成的聚类进行调整。查看教程和 Javadoc,并向我们发送您的成功案例。

第七章:在概念/人物之间寻找共指

本章将涵盖以下内容:

  • 与文档中的命名实体共指

  • 向共指中添加代词

  • 跨文档共指

  • John Smith 问题

介绍

共指是人类语言中的一种基本机制,它使得两句话可以指代同一个事物。对人类交流而言,它非常重要——其功能与编程语言中的变量名非常相似,但在细节上,作用范围的定义规则与代码块截然不同。共指在商业上不那么重要——也许本章将帮助改变这一点。这里有一个例子:

Alice walked into the garden. She was surprised.

共指存在于AliceShe之间;这些短语指代的是同一个事物。当我们开始探讨一个文档中的 Alice 是否与另一个文档中的 Alice 相同时,情况变得非常有趣。

共指,就像词义消歧一样,是下一代的工业能力。共指的挑战促使美国国税局(IRS)坚持要求一个能够明确识别个人的社会保障号码,而不依赖于其名字。许多讨论的技术都是为了帮助跟踪文本数据中的个人和组织,尽管成功程度不一。

与文档中的命名实体共指

如第五章中所见,文本中的跨度 – Chunking,LingPipe 可以使用多种技术来识别与人、地方、事物、基因等相关的专有名词。然而,分块并未完全解决问题,因为它无法帮助在两个命名实体相同的情况下找到一个实体。能够判断 John Smith 和 Mr. Smith、John 甚至完全重复的 John Smith 是同一个实体是非常有用的——它甚至在我们还是一个初创国防承包商时就成为了我们公司成立的基础。我们的创新贡献是生成按实体索引的句子,这种方法证明是总结某个实体所讨论内容的极佳方式,尤其是当这种映射跨越不同语言时——我们称之为基于实体的摘要化

注意

基于实体的摘要化的想法源自巴尔温在宾夕法尼亚大学研究生研讨会上的一次讲座。时任系主任的米奇·马库斯认为,展示所有提到某个实体的句子——包括代词——将是对该实体的极佳总结。从某种意义上说,这就是 LingPipe 诞生的原因。这一想法促使巴尔温领导了一个 UPenn 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类封装了计算共指的所有机制。从第五章,查找文本中的跨度 – 分块,你应该熟悉获取文档文本、检测句子,并遍历应用命名实体分块器到每个句子的代码:

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,工作始于 90 年代中期,并非当前最先进的共指消解系统。更现代的方法很可能会使用机器学习框架,利用 Baldwin 方法生成的特征和其他许多特征来开发一个性能更好的系统。有关该系统的论文可见于www.aclweb.org/anthology/W/W97/W97-1306.pdf

跨文档共指

跨文档共指(XDoc)将单个文档的id空间扩展到更广泛的宇宙。这一宇宙通常包括其他处理过的文档和已知实体的数据库。虽然注解本身非常简单,只需将文档范围内的 ID 替换为宇宙范围内的 ID 即可,但计算 XDoc 可能相当复杂。

本教程将告诉我们如何使用在多年部署此类系统过程中开发的轻量级 XDoc 实现。我们将为那些可能希望扩展/修改代码的人提供代码概述,但内容较为复杂,教程也相当密集。

输入采用 XML 格式,其中每个文件可以包含多个文档:

<doc id="1">
<title/>
<content>
Breck Baldwin and Krishna Dayanidhi wrote a book about LingPipe. 
</content>
</doc>

<doc id="2">
<title/>
<content>
Krishna Dayanidhi is a developer. Breck Baldwin is too. 
</content>
</doc>

<doc id="3">
<title/>
<content>
K-dog likes to cook as does Breckles.
</content>
</doc>

目标是生成注解,其中 Breck Baldwin 的提及在各个文档中共享与 Krishna 相同的 ID。注意,在最后一篇文档中,二者都是以昵称被提及的。

XDoc 的一个常见扩展是将已知实体的数据库DB)与文本中提到的这些实体进行链接。这弥合了结构化数据库和非结构化数据(文本)之间的鸿沟,许多人认为这是商业智能/客户声音/企业知识管理中的下一个重要发展方向。我们曾构建过将基因/蛋白质数据库与 MEDLINE 摘要、以及人物关注名单与自由文本等链接的系统。数据库还为人工编辑提供了一种自然的方式来控制 XDoc 的行为。

如何实现...

本食谱的所有代码都位于 com.lingpipe.cookbook.chapter7.tracker 包中。

  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 在所有三份文档中都被识别,但由于第三次提到的 ID Breckles 与前两次提到的不同,系统认为它们不是同一个实体。

  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 与字典文件中指定的相同:Breck 的 ID 为 1Krishna 的 ID 为 2。这是结构化数据库(如字典的性质)与非结构化文本之间的联系。其次,注意到我们的昵称已经被正确识别并分配到正确的 ID。第三,注意到类型现在是 MALE,而不是 OTHER

    <docs>
    <doc id="1">
    <title/>
    <content>
    <s index="0">
    <entity id="1" type="MALE">Breck Baldwin</entity> and <entity id="2" type="MALE">Krishna Dayanidhi</entity> wrote a book about <entity id="1000000001" type="OTHER">LingPipe.</entity>
    </s>
    </content>
    </doc>
    <doc id="2">
    <title/>
    <content>
    <s index="0">
    <entity id="2" start="0" type="MALE">K-dog</entity> likes to cook as does <entity id="1" start="28" type="MALE">Breckles</entity>.
    </s>
    </content>
    </doc>
    </docs>
    

这是对如何运行 XDoc 的简要介绍。在接下来的部分,我们将看到它是如何工作的。

它是如何工作的…

在这道食谱之前,我们一直尝试保持代码简单、直观并且易于理解,而不深入探讨大量源代码。这道食谱更为复杂。支撑这个食谱的代码无法完全放入预定的空间中进行解释。此处的阐述假设您会自行探究整个类,并参考本书中的其他食谱进行说明。我们提供这道食谱是因为 XDoc 核心参考是一个非常有趣的问题,我们现有的基础设施可能帮助其他人探索这一现象。欢迎来到游泳池的深水区。

批处理生命周期

整个过程由 RunTracker.java 类控制。main() 方法的总体流程如下:

  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,因为那可能是一个独特的名字。有很多复杂的理论解释这里发生了什么,参见刚性指示符(en.wikipedia.org/wiki/Rigid_designator)。接下来的几行代码几乎抹去了 2,000 年的哲学思想:

public boolean isXdcPhrase(String phrase,
          boolean hasMultiWordPhrase) {

    if (mXdcPhraseToEntitySet.containsKey(phrase)) {
        return false;
    }  
    if (phrase.indexOf(' ') == -1 && hasMultiWordPhrase) {
        return false;
    }
    if (PronounChunker.isPronominal(phrase)) {
        return false;
   }
    return true;
}

这种方法试图识别 XDoc 中的不良短语,而不是好的短语。推理如下:

  • 短语已经与一个实体相关联:这强制假设世界上只有一个 John Smith。这对于情报收集应用程序非常有效,因为分析师在分辨 John Smith 案例时几乎没有困难。你可以参考本章末尾的 The John Smith problem 配方了解更多内容。

  • 短语只有一个单词,并且与提及链或实体相关联的有多个单词短语:这假设较长的单词对 XDoc 更有利。请注意,实体创建的不同顺序可能导致单个单词短语在具有多单词别名的实体上,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() 中使用的那样。

XDocCoref.addMentionChainToEntity() 实体

返回到 XDocCoref.xdocCoref() 方法,我们已经介绍了如何通过 XDocCoref.promote() 创建一个新实体。接下来要讨论的选项是当提及链被解析为现有实体时会发生什么,即 XDocCoref.addMentionChainToEntity()。为了添加推测性提及,实体必须允许通过 Entity.allowSpeculativeAliases() 方法提供的推测性找到的提及。这是用户定义的字典实体的一个特性,已在用户定义实体中讨论过。如果允许推测性实体,则提及链会被添加到实体中,并且会根据它们是否为 xdc 短语来敏感处理:

private void addMentionChainToEntity(TTMentionChain chain, 
                Entity entity) {
    for (String phrase : chain.normalPhrases()) {
             mEntityUniverse.addPhraseToEntity(normalPhrase,
                entity);
        }
    }

添加提及链到实体的唯一变化就是增加了一个新的短语。这些附加的短语会像在提及链的提升过程中那样被分类为是否为xdc

到目前为止,我们已经了解了文档中的提及链是如何被提升为猜测实体,或者如何与EntityUniverse中的现有实体合并的。接下来,我们将探讨在XDocCoref.resolveMentionChain()中解析是如何进行的。

XDocCoref.resolveMentionChain()实体

XDocCoref.resolveMentionChain()方法组装了一个可能与被解析的提及链匹配的实体集合,并通过调用XDocCoref.resolveCandidates()尝试找到唯一的实体:

private void resolveMentionChain(TTMentionChain chain, Map<MentionChain,Entity> chainToEntity, ObjectToSet<Entity,MentionChain> entityToChainSet) {
        if (Tracker.DEBUG)
            System.out.println("XDOC: resolving mention chain " 
          + chain);
        int maxLengthAliasOnMentionChain = 0;
        int maxLengthAliasResolvedToEntityFromMentionChain = -1;
        Set<String> tokens = new HashSet<String>();
        Set<Entity> candidateEntities = new HashSet<Entity>();
        for (String phrase : chain.normalPhrases()) {
        String[] phraseTokens = mEntityUniverse.normalTokens(phrase);
         String normalPhrase 
      = mEntityUniverse.concatenateNormalTokens(phraseTokens);
         for (int i = 0; i < phraseTokens.length; ++i) {
                    tokens.add(phraseTokens[i]);
    }
         int length = phraseTokens.length;       
         if (length > maxLengthAliasOnMentionChain) {
                maxLengthAliasOnMentionChain = length;
        }
         Set<Entity> matchingEntities
           = mEntityUniverse.xdcEntitiesWithPhrase(phrase);
         for (Entity entity : matchingEntities) {
           if (null != TTMatchers.unifyEntityTypes(
            chain.entityType(),
            entity.type())) {
               if (maxLengthAliasResolvedToEntityFromMentionChain < length) 
                        maxLengthAliasResolvedToEntityFromMentionChain = length;
  candidateEntities.add(entity);
}
}
}   
resolveCandidates(chain,
                  tokens,
                  candidateEntities,
                          maxLengthAliasResolvedToEntityFromMentionChain == maxLengthAliasOnMentionChain,
                          chainToEntity,
                          entityToChainSet);}

该代码通过调用EntityUniverse.xdcEntitiesWithPhrase()从实体宇宙中查找实体集合。所有提及链的别名都会被尝试,而不考虑它们是否是有效的 XDoc 别名。在将实体添加到candidateEntities之前,返回的类型必须与TTMatchers.unifyEntityTypes所确定的提及链类型一致。这样,华盛顿(地点)就不会被解析为华盛顿(人名)。在此过程中,会做一些记录工作,以确定提及链上最长的别名是否与某个实体匹配。

resolveCandidates()方法

resolveCandidates()方法捕捉了一个关键假设,这一假设适用于文档内和 XDoc 共指的情况——这种不歧义的引用是唯一的解析基础。在文档内的案例中,一个类似的问题是“Bob 和 Joe 一起工作。他掉进了脱粒机。”这里的“他”指的是谁?单一指代词有唯一先行词的语言预期被称为唯一性假设。一个 XDoc 的例子如下:

  • Doc1:约翰·史密斯是《风中奇缘》中的一个角色

  • Doc2:约翰·史密斯是董事长或总经理

  • Doc3:约翰·史密斯受人尊敬

Doc3中的约翰·史密斯与哪个约翰·史密斯相匹配?也许,两者都不是。这个软件中的算法要求在匹配标准下应该有一个唯一的实体得以保留。如果有多个或没有,系统就会创建一个新的实体。其实现方式如下:

        private void resolveCandidates(TTMentionChain chain,
                                   Set<String> tokens,
                                   Set<Entity> candidateEntities,
                               boolean resolvedAtMaxLength,
                               Map<MentionChain,Entity> chainToEntity,
                               ObjectToSet<Entity,MentionChain> entityToChainSet) {
        filterCandidates(chain,tokens,candidateEntities,resolvedAtMaxLength);
        if (candidateEntities.size() == 0)
            return;
        if (candidateEntities.size() == 1) {
            Entity entity = Collections.<Entity>getFirst(candidateEntities);
            chainToEntity.put(chain,entity);
            entityToChainSet.addMember(entity,chain);
            return;
        }
        // BLOWN Uniqueness Presupposition; candidateEntities.size() > 1
        if (Tracker.DEBUG)
            System.out.println("Blown UP; candidateEntities.size()=" + candidateEntities.size());
    }

filterCandidates方法会删除因各种语义原因无法通过的所有候选实体。只有当实体宇宙中的一个实体有唯一的匹配时,才会发生共指。这里并没有区分候选实体过多(多个)或过少(零)的情况。在一个更高级的系统中,如果实体过多,可以尝试通过context进一步消除歧义。

这是 XDoc 代码的核心。其余的代码使用xdocCoref方法返回的实体宇宙相关索引对文档进行标注,这部分我们刚刚已经讲解过:

Entity[] entities  = mXDocCoref.xdocCoref(chains);

以下的for循环遍历了提到的链,这些链与xdocCoref返回的Entities[]对齐。对于每一个提到的链,提到的内容会被映射到它的跨文档实体:

Map<Mention,Entity> mentionToEntityMap
     = new HashMap<Mention,Entity>();
for (int i = 0; i < chains.length; ++i){ 
  for (Mention mention : chains[i].mentions()) {
         mentionToEntityMap.put(mention,entities[i]);
  }
}

接下来,代码将设置一系列映射,创建反映实体宇宙 ID 的块:

String[] sentenceTexts
        = sentenceTextList
            .<String>toArray(new String[sentenceTextList.size()])
Mention[][] sentenceMentions
            = sentenceMentionList
            .<Mention[]>toArray(new Mention[sentenceMentionList.size()][]);
int[][] mentionStarts
         = mentionStartList
            .<int[]>toArray(new int[mentionStartList.size()][]);

int[][] mentionEnds
            = mentionEndList
            .<int[]>toArray(new int[mentionEndList.size()][]);

实际的块创建在下一步进行:

Chunking[] chunkings = new Chunking[sentenceTexts.length];
  for (int i = 0; i < chunkings.length; ++i) {
   ChunkingImpl chunking = new ChunkingImpl(sentenceTexts[i]);
   chunkings[i] = chunking;
   for (int j = 0; j < sentenceMentions[i].length; ++j) {
    Mention mention = sentenceMentions[i][j];
    Entity entity = mentionToEntityMap.get(mention);
    if (entity == null) {
     Chunk chunk = ChunkFactory.createChunk(mentionStarts[i][j],
       mentionEnds[i][j],
       mention.entityType()
       + ":-1");
     //chunking.add(chunk); //uncomment to get unresolved ents as -1 indexed.
    } else {
     Chunk chunk = ChunkFactory.createChunk(mentionStarts[i][j],
       mentionEnds[i][j],
       entity.type()
       + ":" + entity.id());
     chunking.add(chunk);
    }
   }
  }

然后,块被用来创建文档的相关部分,并返回OutputDocument

        // needless allocation here and last, but simple
        Chunking[] titleChunkings = new Chunking[firstContentSentenceIndex];
        for (int i = 0; i < titleChunkings.length; ++i)
            titleChunkings[i] = chunkings[i];

        Chunking[] bodyChunkings = new Chunking[chunkings.length - firstContentSentenceIndex];
        for (int i = 0; i < bodyChunkings.length; ++i)
            bodyChunkings[i] = chunkings[firstContentSentenceIndex+i];

        String id = document.id();

        OutputDocument result = new OutputDocument(id,titleChunkings,bodyChunkings);
        return result;
    }

这是我们为 XDoc 共指提供的起点。希望我们已经解释了更多晦涩方法背后的意图。祝你好运!

约翰·史密斯问题

不同的人、地点和概念可能有相同的书面表示,但却是不同的。世界上有多个“约翰·史密斯”、“巴黎”和“银行”的实例,适当的跨文档共指系统应该能够处理这些情况。对于“银行”这样的概念(例如:河岸和金融银行),术语是词义消歧。本示例将展示巴尔温(Baldwin)和阿米特·巴加(Amit Bagga)当年为人物消歧开发的一个方法。

准备工作

这个示例的代码紧跟alias-i.com/lingpipe/demos/tutorial/cluster/read-me.html的聚类教程,但进行了修改,以更贴合最初的 Bagga-Baldwin 工作。代码量不小,但没有非常复杂的部分。源代码在src/com/lingpipe/cookbook/chapter7/JohnSmith.java

该类首先使用了标准的 NLP 工具包,包括分词、句子检测和命名实体检测。如果这个工具堆栈不熟悉,请参阅前面的示例:

public static void main(String[] args) 
      throws ClassNotFoundException, IOException {
    TokenizerFactory tokenizerFactory = IndoEuropeanTokenizerFactory.INSTANCE;
    SentenceModel sentenceModel
    = new IndoEuropeanSentenceModel();
    SENTENCE_CHUNKER 
    = new SentenceChunker(tokenizerFactory,sentenceModel);
    File modelFile
    = new File("models/ne-en-news-muc6.AbstractCharLmRescoringChunker");
    NAMED_ENTITY_CHUNKER 
    = (Chunker) AbstractExternalizable.readObject(modelFile);

接下来,我们将重新访问TfIdfDistance。不过,任务要求我们将类封装成处理Documents而非CharSequences,因为我们希望保留文件名,并能够操作用于后续计算的文本:

TfIdfDocumentDistance tfIdfDist = new TfIdfDocumentDistance(tokenizerFactory);

降级到引用的类,我们有以下代码:

public class TfIdfDocumentDistance implements Distance<Document> {
  TfIdfDistance mTfIdfDistance;
  public TfIdfDocumentDistance (TokenizerFactory tokenizerFactory) {
  mTfIdfDistance = new TfIdfDistance(tokenizerFactory);
  }

   public void train(CharSequence text) {
      mTfIdfDistance.handle(text);
   }

  @Override
  public double distance(Document doc1, Document doc2) {
    return mTfIdfDistance.distance(doc1.mCoreferentText,
              doc2.mCoreferentText);
  }

}

train方法与TfIdfDistance.handle()方法接口,并提供了一个distance(Document doc1, Document doc2)方法的实现,驱动下面讨论的聚类代码。train方法的作用仅仅是提取相关文本,并将其交给TfIdfDistance类来计算相关值。

引用类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的第一个句子。同时,如果第二个John Smith出现并带有不同的 ID,它将被忽略——这种情况是可能发生的。

最后,注意有一些错误检查,如果找不到John Smith,系统会向System.out报告错误。

如果我们在设置好TfIdfDocumentDistance后又回到main()方法中的普通 I/O 处理,我们将会有:

File dir = new File(args[0]);
       Set<Set<Document>> referencePartition
            = new HashSet<Set<Document>>();
        for (File catDir : dir.listFiles()) {
            System.out.println("Category from file=" + catDir);
            Set<Document> docsForCat = new HashSet<Document>();
            referencePartition.add(docsForCat);
            for (File file : catDir.listFiles()) {
                Document doc = new Document(file);
                tfIdfDist.train(doc.mText);
                docsForCat.add(doc);
            }
        }

我们没有讨论这个问题,但关于哪个文档引用了哪个Mr. Smith的真实注解编码在数据的目录结构中。johnSmith顶级目录中的每个子目录都被视为真实聚类。所以,referencePartition包含了真实数据。我们本可以将其包装为一个分类问题,每个子目录对应正确的分类。我们将这个作为练习留给你,要求将其嵌入到交叉验证语料库中,并用逻辑回归解决。

接下来,我们将通过将之前的类别展平为一个Documents的集合来构建测试集。我们本可以在前一步完成这个操作,但混合任务往往会产生错误,而且多出的for循环对执行速度几乎没有影响:

        Set<Document> docSet = new HashSet<Document>();
        for (Set<Document> cluster : referencePartition) {
            docSet.addAll(cluster);
        }

接下来,我们将启动聚类算法。我们将执行CompleteLinkSingleLink,由TfIdfDocumentDistance驱动,后者负责整个过程:


        HierarchicalClusterer<Document> clClusterer
            = new CompleteLinkClusterer<Document>(tfIdfDist);
        Dendrogram<Document> completeLinkDendrogram
            = clClusterer.hierarchicalCluster(docSet);

        HierarchicalClusterer<Document> slClusterer
            = new SingleLinkClusterer<Document>(tfIdfDist);
        Dendrogram<Document> singleLinkDendrogram
            = slClusterer.hierarchicalCluster(docSet);

聚类算法的细节在第五章中进行了介绍,文本中的跨度查找 – 分块。现在,我们将根据聚类数从1到输入数量的变化来报告性能。一个特别的地方是,Cross类别使用SingleLinkClusterer作为参考,而CompleteLinkClusterer作为响应:

System.out.println();
System.out.println(" -------------------------------------------"
        + "-------------");
System.out.println("|  K  |  Complete      |  Single        | "
        + " Cross         |");
System.out.println("|     |  P    R    F   |  P    R    F   |  P"
        + "     R    F   |");
System.out.println(" -------------------------------------------"
        +"-------------");
for (int k = 1; k <= docSet.size(); ++k) {
   Set<Set<Document>> clResponsePartition
       = completeLinkDendrogram.partitionK(k);
   Set<Set<Document>> slResponsePartition
       = singleLinkDendrogram.partitionK(k);

   ClusterScore<Document> scoreCL
       = new ClusterScore<Document>(referencePartition,
                                    clResponsePartition) PrecisionRecallEvaluation clPrEval 
      = scoreCL.equivalenceEvaluation();
   ClusterScore<Document> scoreSL
       = new ClusterScore<Document>(referencePartition,
                                     slResponsePartition);
PrecisionRecallEvaluation slPrEval 
  = scoreSL.equivalenceEvaluation();

ClusterScore<Document> scoreX
    = new ClusterScore<Document>(clResponsePartition
                                 slResponsePartition);
PrecisionRecallEvaluation xPrEval 
  = scoreX.equivalenceEvaluation();

System.out.printf("| %3d | %3.2f %3.2f %3.2f | %3.2f %3.2f %3.2f" 
      + " | %3.2f %3.2f %3.2f |\n",
                   k,
                   clPrEval.precision(),
                   clPrEval.recall(),
                   clPrEval.fMeasure(),
                   slPrEval.precision(),
                   slPrEval.recall(),
                   slPrEval.fMeasure(),
                   xPrEval.precision(),
                   xPrEval.recall(),
                   xPrEval.fMeasure());
 }
System.out.println(" --------------------------------------------"
         + "------------");
}

这就是我们为准备这个配方所需做的一切。这是一个罕见的现象要计算,这是一个玩具实现,但关键概念应该是显而易见的。

如何做...

我们只需运行这段代码,稍微调整一下:

  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-度量。第一行在这种情况下是只允许一个聚类,将允许百分之百的召回率和 23%的精确度,无论是完全链接还是单链接。查看得分,我们可以看到完全链接在0.60时报告了最佳的 F-度量——事实上,有 35 个聚类。单链接方法在0.78时将 F-度量最大化到 68 个聚类,并在不同数量的聚类上显示出更大的鲁棒性。交叉案例显示单链接和完全链接在直接比较中有很大的不同。请注意,为了可读性,一些K值已被消除:

    --------------------------------------------------------
    |  K  |  Complete      |  Single        
    |     |  P    R    F   |  P    R    F   
     --------------------------------------------------------
    |   1 | 0.23 1.00 0.38 | 0.23 1.00 0.38 
    |   2 | 0.28 0.64 0.39 | 0.24 1.00 0.38 
    |   3 | 0.29 0.64 0.40 | 0.24 1.00 0.39 
    |   4 | 0.30 0.64 0.41 | 0.24 1.00 0.39 
    |   5 | 0.44 0.63 0.52 | 0.24 0.99 0.39 
    |   6 | 0.45 0.63 0.52 | 0.25 0.99 0.39 
    |   7 | 0.45 0.63 0.52 | 0.25 0.99 0.40 
    |   8 | 0.49 0.62 0.55 | 0.25 0.99 0.40 
    |   9 | 0.55 0.61 0.58 | 0.25 0.99 0.40 
    |  10 | 0.55 0.61 0.58 | 0.25 0.99 0.41 
    |  11 | 0.59 0.61 0.60 | 0.27 0.99 0.42 
    |  12 | 0.59 0.61 0.60 | 0.27 0.98 0.42 
    |  13 | 0.56 0.41 0.48 | 0.27 0.98 0.43 
    |  14 | 0.71 0.41 0.52 | 0.27 0.98 0.43 
    |  15 | 0.71 0.41 0.52 | 0.28 0.98 0.43 
    |  16 | 0.68 0.34 0.46 | 0.28 0.98 0.44 
    |  17 | 0.68 0.34 0.46 | 0.28 0.98 0.44 
    |  18 | 0.69 0.34 0.46 | 0.29 0.98 0.44 
    |  19 | 0.67 0.32 0.43 | 0.29 0.98 0.45 
    |  20 | 0.69 0.29 0.41 | 0.29 0.98 0.45 
    |  30 | 0.84 0.22 0.35 | 0.33 0.96 0.49 
    |  40 | 0.88 0.18 0.30 | 0.61 0.88 0.72 
    |  50 | 0.89 0.16 0.28 | 0.64 0.86 0.73 
    |  60 | 0.91 0.14 0.24 | 0.66 0.77 0.71 
    |  61 | 0.91 0.14 0.24 | 0.66 0.75 0.70 
    |  62 | 0.93 0.14 0.24 | 0.87 0.75 0.81 
    |  63 | 0.94 0.13 0.23 | 0.87 0.69 0.77 
    |  64 | 0.94 0.13 0.23 | 0.87 0.69 0.77 
    |  65 | 0.94 0.13 0.23 | 0.87 0.68 0.77 
    |  66 | 0.94 0.13 0.23 | 0.87 0.66 0.75 
    |  67 | 0.95 0.13 0.23 | 0.87 0.66 0.75 
    |  68 | 0.95 0.13 0.22 | 0.95 0.66 0.78 
    |  69 | 0.94 0.11 0.20 | 0.95 0.66 0.78 
    |  70 | 0.94 0.11 0.20 | 0.95 0.65 0.77 
    |  80 | 0.98 0.11 0.19 | 0.97 0.43 0.59 
    |  90 | 0.99 0.10 0.17 | 0.97 0.30 0.46 
    | 100 | 0.99 0.08 0.16 | 0.96 0.20 0.34 
    | 110 | 0.99 0.07 0.14 | 1.00 0.11 0.19 
    | 120 | 1.00 0.07 0.12 | 1.00 0.08 0.14 
    | 130 | 1.00 0.06 0.11 | 1.00 0.06 0.12 
    | 140 | 1.00 0.05 0.09 | 1.00 0.05 0.10 
    | 150 | 1.00 0.04 0.08 | 1.00 0.04 0.08 
    | 160 | 1.00 0.04 0.07 | 1.00 0.04 0.07 
    | 170 | 1.00 0.03 0.07 | 1.00 0.03 0.07 
    | 180 | 1.00 0.03 0.06 | 1.00 0.03 0.06 
    | 190 | 1.00 0.02 0.05 | 1.00 0.02 0.05 
    | 197 | 1.00 0.02 0.04 | 1.00 0.02 0.04 
     --------------------------------------------------------
    
  6. 下面的输出限制了聚类的方式不是通过聚类大小,而是通过最大距离阈值。输出是对单链接聚类的,增加了.05距离,并且评估是 B-cubed 度量。输出是距离、精确度、召回率以及生成聚类的大小。在.80.9的表现非常好,但要小心在事后设置生产阈值。在生产环境中,我们将希望在设置阈值之前看到更多数据:

    B-cubed eval
    Dist: 0.00 P: 1.00 R: 0.77 size:189
    Dist: 0.05 P: 1.00 R: 0.80 size:171
    Dist: 0.10 P: 1.00 R: 0.80 size:164
    Dist: 0.15 P: 1.00 R: 0.81 size:157
    Dist: 0.20 P: 1.00 R: 0.81 size:153
    Dist: 0.25 P: 1.00 R: 0.82 size:148
    Dist: 0.30 P: 1.00 R: 0.82 size:144
    Dist: 0.35 P: 1.00 R: 0.83 size:142
    Dist: 0.40 P: 1.00 R: 0.83 size:141
    Dist: 0.45 P: 1.00 R: 0.83 size:141
    Dist: 0.50 P: 1.00 R: 0.83 size:138
    Dist: 0.55 P: 1.00 R: 0.83 size:136
    Dist: 0.60 P: 1.00 R: 0.84 size:128
    Dist: 0.65 P: 1.00 R: 0.84 size:119
    Dist: 0.70 P: 1.00 R: 0.86 size:108
    Dist: 0.75 P: 0.99 R: 0.88 size: 90
    Dist: 0.80 P: 0.99 R: 0.94 size: 60
    Dist: 0.85 P: 0.95 R: 0.97 size: 26
    Dist: 0.90 P: 0.91 R: 0.99 size:  8
    Dist: 0.95 P: 0.23 R: 1.00 size:  1
    Dist: 1.00 P: 0.23 R: 1.00 size:  1
    
  7. B-cubed(Bagga、Bierman 和 Baldwin)评估被设计为严重惩罚将大量文档关联在一起的情况。它假设将关于乔治·W·布什和乔治·H·W·布什这样的大型聚类合并在一起是更大的问题,而不是误将提到数据集中的一次性提到的机械师乔治·布什的情况。其他评分指标将同样认为这两种错误同样糟糕。这是文献中用于此现象的标准评分指标。

另请参阅

在研究文献中,关于这个具体问题有相当多的工作。我们并不是第一个考虑这个问题的人,但我们提出了主流的评估指标,并发布了一个语料库供其他团队与我们以及彼此进行比较。我们的贡献是基于实体的跨文档共指消解,使用向量空间模型,由 Bagga 和 Baldwin 提出,收录于ACL '98 第 36 届计算语言学会年会和第 17 届国际计算语言学会议论文集。自那时以来已经取得了许多进展——Google Scholar 上已有超过 400 次引用,如果这个问题对你来说很重要,它们值得一看。

posted @ 2025-07-17 15:21  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报