nlp-java-merge-0

47 阅读1小时+

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.9286530.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.7553140.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.7682950.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。然而,外包时要小心,因为分类器的效果非常依赖数据的质量。

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