JavaScript-机器学习实用指南-全-

JavaScript 机器学习实用指南(全)

原文:annas-archive.org/md5/86fc6595b85c1a353b88aee9d304e735

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我第一次深入探索机器学习ML)是在 2008 年,当时我正在为电动汽车的适应性牵引控制系统开发算法。不久之后,我离开了机械工程领域,共同创立了一家营销技术初创公司。几周后,我意识到机器学习对我和我公司的重要性,并决定阅读我能找到的所有关于机器学习的书籍和论文。

接下来的几年里,我埋头苦读,阅读了数十本教科书和数百篇学术论文,从头开始编写我能找到的所有算法,并逐渐形成了关于机器学习的直觉和哲学。

在那个时期,我发现了关于机器学习生态系统的一些事情,让我不太满意。当时有一个强烈的守门人文化。用除 Python 以外的语言编写机器学习的想法被认为是荒谬的。有一种观点认为,只有那些在学校学习过机器学习的人才能在这个领域取得成功。大部分公开可用的阅读材料,如在线的博客文章和教程,都采用了明显的数学语气,从而排斥了那些不熟悉线性代数和向量微积分的读者。

同时,我正在教授一个JavaScriptJS)编程训练营,我的许多学生——自学成才的网页开发者——对机器学习(ML)表示了兴趣。当时很难为他们指明正确的方向;他们唯一的选择就是转向 Python。

这让我感到沮丧,因为我知道我的学生足够聪明,能够掌握机器学习,我也知道机器学习并不需要局限于 Python。我还感觉到,许多使用流行 Python 库的开发者实际上并不理解算法的机制,在尝试实现它们时会遇到问题。守门人的行为适得其反,只是将这个强大的算法家族简化成了开发者随意应用的黑色盒子,从而阻碍了他们深入挖掘和学习。

我想向世界证明,机器学习可以教给任何人,并且可以用任何语言编写,所以我开始撰写一系列名为《JavaScript 中的机器学习》的文章。这些文章从基本原理开始教授机器学习算法,避免了术语,并侧重于实现而非数学描述。

我选择 JavaScript 有几个原因。首先,JavaScript 中缺乏机器学习库会迫使我的读者编写自己的实现,并亲自发现机器学习并不是魔法,只是代码。其次,JavaScript 当时还没有真正崭露头角(Node.js 当时还不存在),通常被认为不是解决严肃问题的理想编程语言,但我想要证明机器学习可以用任何语言编写。最后,我想使用一种大多数 Web 开发者,尤其是自学成才的开发者都会感到舒适的编程语言。选择 PHP 或 Java 这样的后端语言将意味着排除大量开发者,所以我选择了每个 Web 开发者都知道的语言:JavaScript。

尽管现在已经过时,但这个系列曾经很受欢迎。超过一百万的人阅读了我的文章,我收到了许多读者的来信,他们告诉我我的文章激励他们开始新的道路;我认为这是我最大的职业成功之一。

这本书是对我的《JavaScript 机器学习》系列的一个谦逊且现代的更新。自 2008 年以来,变化颇多。JavaScript 现在是最受欢迎的编程语言,机器学习正在迅速民主化。开发者可以通过一次 API 调用,使用 AWS 或 Google Cloud 调用巨大的计算资源,而今天的智能手机在处理能力上可以与十年前的台式机相媲美。

与我以前的文章系列类似,这本书将从第一原理开始教你机器学习算法。我们将专注于开发机器学习概念和实现,而不会过多地涉及数学描述。然而,与旧系列不同的是,今天的 JavaScript 领域实际上有可用的机器学习库和实现。因此,有时我们将编写自己的算法,而在其他时候我们将依赖现有的库。

这本书的目标不是教你所有存在的机器学习算法,也不是让你成为任何一种算法的专家。相反,我的目标是教你,一个有经验的 Web 开发者,你需要了解什么才能开始并熟悉机器学习,这样你就可以自信地开始自己的教育之旅。

这本书适合谁

这本书是为想要开始机器学习的有经验的 JavaScript 开发者而写的。一般来说,我会假设你是一个合格的 JavaScript 开发者,对机器学习或高中所学的数学之外的经验很少或没有。在 JavaScript 能力方面,你应该已经熟悉算法的基本概念、模块化代码和数据转换。我还假设你可以阅读 JavaScript 代码,并理解其意图和机制。

这本书不是为新手程序员准备的,尽管你仍然可能从中得到一些东西。这本书也不是为已经熟悉机器学习的读者准备的,因为大部分内容对你来说可能都很熟悉——尽管在这些页面中可能有一些小小的智慧之珠对你有所帮助。

如果您想进入机器学习领域但不知道在这样一个庞大且混乱的生态系统中从何开始,这本书非常适合您。无论您是想改变职业道路还是仅仅想学习新知识,我相信您会发现这本书很有帮助。

本书涵盖的内容

第一章,探索 JavaScript 的潜力,审视了 JavaScript 编程语言、其历史、生态系统以及其在机器学习问题中的应用。

第二章,数据探索,讨论了支撑和驱动每个机器学习算法的数据,以及您可以为机器学习应用预处理和准备数据所能做的各种事情。

第三章,机器学习算法概览,带您简要游览机器学习领域,将其划分为算法类别和家族,就像地图上的网格线帮助您导航不熟悉的地区一样。

第四章,使用聚类算法进行分组,实现了我们的第一个机器学习算法,重点关注自动发现和识别数据中的模式,以便将相似的项目分组在一起。

第五章,分类算法,讨论了广泛使用的机器学习算法家族,这些算法用于自动对具有一个或多个标签的数据点进行分类,例如垃圾邮件/非垃圾邮件、正面或负面情绪,或任意数量的任意类别。

第六章,关联规则算法,探讨了用于根据数据点共现频率建立关联的几种算法,例如在电子商务商店中经常一起购买的产品。

第七章,使用回归算法进行预测,探讨了时间序列数据,如服务器负载或股价,并讨论了可用于分析模式和预测未来的各种算法。

第八章,人工神经网络算法,为您讲解神经网络的基础知识,包括其核心概念、架构、训练算法和实现。

第九章,深度神经网络,更深入地探讨了神经网络,并探索了可以解决图像识别、计算机视觉、语音识别和语言建模等问题的一系列异构拓扑结构。

第十章,实践中的自然语言处理,讨论了自然语言处理与机器学习的交叉点。您将学习到一些常见的技巧和策略,这些技巧和策略可以在将机器学习应用于自然语言任务时使用。

第十一章,在实时应用程序中使用机器学习,讨论了在生产环境中部署机器学习应用的多种实用方法,特别关注数据管道过程。

第十二章,为您的应用程序选择最佳算法,回顾了基础知识,并讨论了在机器学习项目的早期阶段必须考虑的事项,特别关注为特定应用程序选择最佳算法或算法集。

为了充分利用本书

如果您有一段时间没有在 JS 中编程了,在开始之前给自己复习一下会更好。特别是,本书中的示例将使用 ES6/ES2015 语法;我将在第一章中带您了解新语法,但您也可能想自己熟悉一下。

如果您还没有安装 Node.js,您现在应该安装它。本书中的示例使用的是 Node.js 版本 9.6.0,尽管我预计大多数示例都可以在大于 8 的任何 Node.js 版本上运行,也可以在 Node.js 版本 10 上运行。

您不需要太多的数学教育就能读懂这本书,但我假设您注意到了您的高中数学课程。如果您对概率、统计学或代数记得不多,您可能需要复习这些主题,因为它们在机器学习中很常见。虽然我已经尽力避免深入探讨高级数学概念,但在这本书中我确实需要介绍一些,以便您至少对数学感到舒适,并愿意自己研究一些选定的数学概念。

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

一旦文件下载完成,请确保您使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Machine-Learning-with-JavaScript。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/HandsOnMachineLearningwithJavaScript_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“使用命令行、您最喜欢的 IDE 或文件浏览器,在您的机器上创建一个名为MLinJSBook的目录,并在其中创建一个名为Ch1-Ex1的子目录。”

代码块如下设置:

var items = [1, 2, 3 ];
for (var index in items) {
var item = items[index];
…
 }

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

['landscape.jpeg', 'lily.jpeg', 'waterlilies.jpeg'].forEach(filename => {
  console.log("Decolorizing " + filename + '...');
  decolorize('./files/' + filename)
    .then(() => console.log(filename + " decolorized"));
});

任何命令行输入或输出都应如下编写:

$ node --version
 V9.4.0

B****old:表示新术语、重要单词或您在屏幕上看到的单词。

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需更多关于 Packt 的信息,请访问packtpub.com

第一章:探索 JavaScript 的潜力

本章我们将涵盖以下主题:

  • 为什么选择 JavaScript?

  • 为什么选择机器学习,为什么现在?

  • JavaScript 的优缺点

  • CommonJS 创新计划

  • Node.js

  • TypeScript 语言

  • ES6 的改进

  • 准备开发环境

为什么选择 JavaScript?

我从 2010 年开始用 JavaScript 写有关 机器学习ML)的文章。当时,Node.js 还很新,JavaScript 正开始作为一种语言崭露头角。在互联网的大部分历史中,JavaScript 被视为一种玩具语言,用于在网页上创建简单的动态交互。

随着 2005 年 Prototype JavaScript 框架 的发布,人们对 JavaScript 的看法开始改变,该框架旨在简化 AJAX 请求并帮助开发者处理跨浏览器的 XMLHttpRequest。Prototype 框架还引入了熟悉的美元函数作为 document.getElementById 的别名,例如 $(“myId”)

一年后,John Resig 发布了广受欢迎的 jQuery 库。在撰写本文时,w3techs.com 报告称,jQuery 被用于 96% 的已知 JavaScript 库的网站(这占所有网站的 73%)。jQuery 致力于使常见的 JavaScript 操作跨浏览器兼容且易于实现,为全球的网页开发者带来了重要的工具,如 AJAX 请求、文档对象模型DOM)遍历和操作,以及动画。

然后,在 2008 年,Chrome 浏览器和 Chrome V8 JavaScript 引擎被发布。Chrome 和 V8 引入了相对于旧浏览器的显著性能提升:JavaScript 现在变得更快,这主要归功于 V8 引擎的创新即时编译器,它可以直接从 JavaScript 构建机器代码。

随着 jQuery 和 Chrome 浏览器的兴起,JavaScript 的受欢迎程度逐渐增加。开发者们历史上从未真正喜欢 JavaScript 这种编程语言,但有了 jQuery 的加入,在快速且现代的浏览器上运行,很明显 JavaScript 是一个未被充分利用的工具,并且能够完成比之前更多的事情。

2009 年,JavaScript 开发者社区决定将 JavaScript 从浏览器环境解放出来。CommonJS 创新计划在当年早期启动,几个月后 Node.js 随之诞生。CommonJS 模块的目标是开发一个标准库,并改善 JavaScript 的生态系统,使其能够在浏览器环境之外使用。作为这项努力的一部分,CommonJS 标准化了模块加载接口,允许开发者构建可以与他人共享的库。

2009 年中旬 Node.js 的发布,通过为 JavaScript 开发者提供了一个新的思考范式——将 JavaScript 作为服务器端语言,震撼了 JavaScript 世界。将 Chrome V8 引擎打包在内,使得 Node.js 出奇地快,尽管 V8 引擎并不应该独占软件性能的功劳。Node.js 实例使用事件循环来处理请求,因此尽管它是单线程的,但它可以处理大量的并发连接。

JavaScript 在服务器端的创新之处,其令人惊讶的性能,以及 npm 注册表的早期引入,让开发者能够发布和发现模块,这些都吸引了成千上万的开发者。与 Node.js 一起发布的标准库主要是低级 I/O API,开发者们竞相发布第一个优秀的 HTTP 请求包装器,第一个易于使用的 HTTP 服务器,第一个高级图像处理库,等等。JavaScript 生态系统的快速早期增长,让那些不愿采用新技术的开发者们产生了信心。JavaScript 第一次被视为一种真正的编程语言,而不仅仅是由于网络浏览器而容忍的东西。

当 JavaScript 作为编程平台逐渐成熟时,Python 社区正忙于研究机器学习,这在一定程度上受到了谷歌在市场上的成功启发。基础且非常流行的数值处理库 NumPy 于 2006 年发布,尽管它以某种形式存在了十年。一个名为scikit-learn的机器学习库于 2010 年发布,那是我决定开始向 JavaScript 开发者教授机器学习的时刻。

Python 中机器学习的流行以及使用工具(如 scikit-learn)构建和训练模型的便捷性,让我和许多人感到惊讶。在我看来,这种流行度的激增引发了一个机器学习泡沫;因为模型构建和运行变得如此容易,我发现许多开发者实际上并不了解他们所使用的算法和技术的工作原理。许多开发者哀叹他们的模型表现不佳,却不知道他们自己才是链条中的薄弱环节。

在当时,机器学习被视为神秘、神奇、学术性的,只有少数天才才能接触,而且只有 Python 开发者才能接触。我的看法不同。机器学习只是没有魔法涉及的一类算法。大多数算法实际上很容易理解和推理!

我不想向开发者展示如何在 Python 中导入贝叶斯,而是想展示如何从头开始构建算法,这是建立直觉的重要一步。我还想让我学生很大程度上忽略当时流行的 Python 库,因为我想要强化这样一个观念:机器学习算法可以用任何语言编写,Python 不是必需的。

我选择了 JavaScript 作为我的教学平台。坦白说,我选择 JavaScript 部分原因是因为当时很多人认为它是一种糟糕的语言。我的信息是机器学习很简单,你甚至可以用 JavaScript 来做! 幸运的是,对于我来说,Node.js 和 JavaScript 都变得极其流行,我的早期关于 JavaScript 中机器学习的文章在接下来的几年里被超过一百万名好奇的开发者阅读。

我还选择 JavaScript 部分原因是因为我不想让机器学习被视为只有学者、计算机科学家或甚至大学毕业生才能使用的工具。我相信,并且仍然相信,只要足够练习和重复,任何有能力的开发者都可以彻底理解这些算法。我选择 JavaScript 是因为它让我能够接触到新的前端和全栈 Web 开发者群体,其中许多人自学成才或从未正式学习过计算机科学。如果目标是使机器学习领域去神秘化和民主化,我觉得接触 Web 开发者社区比接触当时整体更熟悉机器学习的后端 Python 程序员社区要好得多。

Python 一直是,并且仍然是机器学习的首选语言,部分原因是语言的成熟度,部分原因是生态系统的成熟度,部分原因是 Python 早期机器学习努力的积极反馈循环。然而,JavaScript 世界的最新发展使得 JavaScript 对机器学习项目更具吸引力。我认为在几年内,我们将看到 JavaScript 在机器学习领域迎来一场重大的复兴,特别是在笔记本电脑和移动设备变得越来越强大,JavaScript 本身也日益流行的情况下。

为什么是机器学习,为什么是现在?

一些机器学习技术早在计算机本身出现之前就已经存在,但许多我们现在使用的现代机器学习算法都是在 20 世纪 70 年代和 80 年代发现的。当时它们很有趣但不实用,主要局限于学术界。

什么变化使得机器学习在流行度上有了巨大的提升?首先,计算机终于足够快,可以运行非平凡的神经网络和大型机器学习模型。然后发生了两件事:谷歌和亚马逊网络服务AWS)。谷歌以一种非常明显的方式证明了机器学习对市场的价值,然后 AWS 使可扩展的计算和存储资源变得容易获得(AWS 使其民主化并创造了新的竞争)。

谷歌的 PageRank 算法,这个为谷歌搜索提供动力的机器学习算法,让我们了解了机器学习的商业应用。谷歌的创始人谢尔盖和拉里向世界宣布,他们搜索引擎和随之而来的广告业务的巨大成功归功于 PageRank 算法:一个相对简单的线性代数方程,包含一个巨大的矩阵。

注意,神经网络也是相对简单的线性代数方程,包含一个巨大的矩阵。

那就是所有荣耀中的机器学习(ML);大数据带来了深刻的洞察力,这转化为巨大的市场成功。这使得全世界对机器学习产生了经济上的兴趣。

AWS 通过推出 EC2 和按小时计费,民主化了计算资源。研究人员和早期阶段的初创公司现在可以快速启动大型计算集群,训练他们的模型,并将集群规模缩小,避免了对强大服务器的巨额资本支出。这创造了新的竞争,并产生了一代专注于机器学习的初创公司、产品和倡议。

近期,机器学习在开发者和商业社区中又掀起了一股热潮。第一代专注于机器学习的初创公司和产品现在已经成熟,并在市场上证明了机器学习的价值,在许多情况下,这些公司正在接近或超越其竞争对手。公司保持市场竞争力的愿望推动了机器学习解决方案的需求。

2015 年末,谷歌推出了神经网络的库TensorFlow,通过民主化神经网络的方式激发了开发者们的热情,这与 EC2 民主化计算能力的方式非常相似。此外,那些专注于开发者的第一代初创公司也已经成熟,现在我们可以通过简单的 API 请求 AWS 或Google Cloud PlatformGCP),在图像上运行整个预训练的卷积神经网络CNN),并告诉我我是否在看着一只猫、一个女人、一个手提包、一辆车,或者同时看着这四者。

随着机器学习的民主化,它将逐渐失去其竞争优势,也就是说,公司将不再能够使用机器学习来超越竞争,因为他们的竞争对手也将使用机器学习。现在,该领域的每个人都使用相同的算法,竞争变成了数据战。如果我们想在技术上保持竞争,如果我们想找到下一个 10 倍改进,那么我们可能需要等待,或者最好是促成下一个重大的技术突破。

如果机器学习在市场上的成功不是如此之大,那么这个故事就结束了。所有重要的算法都将为所有人所知,战斗将转移到谁能够收集到最好的数据,在自己的园地里筑起围墙,或者最好地利用自己的生态系统。

但是,将 TensorFlow 这样的工具引入市场改变了这一切。现在,神经网络已经实现了民主化。构建模型、在 GPU 上训练和运行它以及生成真实结果出奇地简单。围绕神经网络的学术迷雾已经消散,现在成千上万的开发者正在尝试各种技术、进行实验和改进。这将引发机器学习(ML)的第二次重大浪潮,尤其是专注于神经网络。新一代以机器学习和神经网络为重点的初创公司和产品正在诞生,几年后当它们成熟时,我们应该会看到许多重大突破,以及一些突破性的公司。

我们看到的每一个新的市场成功都将创造对机器学习(ML)开发者的需求。人才库的增加和技术的民主化导致技术突破。每一次新的技术突破进入市场都会创造新的市场成功,并且随着该领域的加速发展,这个循环将持续下去。我认为,纯粹从经济角度来看,我们真的正走向一个人工智能AI)的繁荣。

JavaScript 的优势和挑战

尽管我对 JavaScript 在机器学习(ML)未来的乐观态度,但今天的大多数开发者仍然会选择 Python 来开发他们的新项目,几乎所有的大型生产系统都是用 Python 或其他更典型的机器学习语言开发的。

JavaScript,就像任何其他工具一样,有其优点和缺点。历史上对 JavaScript 的许多批评都集中在几个常见的主题上:类型强制转换中的奇怪行为、原型面向对象模型、组织大型代码库的困难,以及使用许多开发者称之为回调地狱的深度嵌套异步函数调用。幸运的是,大多数这些历史上的抱怨都通过引入ES6(即ECMAScript 2015),这个 JavaScript 语法的最新更新而得到了解决。

尽管最近语言有所改进,但大多数开发者仍然会建议不要使用 JavaScript 进行机器学习,原因之一是生态系统。Python 的机器学习生态系统如此成熟和丰富,以至于很难为选择其他生态系统找到理由。但这种逻辑是自我实现的也是自我挫败的;如果我们想让 JavaScript 的生态系统成熟,我们需要勇敢的人去跨越障碍,解决真实的机器学习问题。幸运的是,JavaScript 已经连续几年成为 GitHub 上最受欢迎的编程语言,并且几乎在所有指标上都在增长。

使用 JavaScript 进行机器学习有一些优势。其普及度是一个;虽然目前 JavaScript 中的机器学习并不非常流行,但 JavaScript 语言本身是流行的。随着机器学习应用需求的增加,以及硬件变得更快更便宜,机器学习在 JavaScript 世界中的普及是自然而然的事情。学习 JavaScript 的通用资源很多,维护 Node.js 服务器和部署 JavaScript 应用也是如此。Node 包管理器(npm)生态系统也很大,仍在增长,尽管成熟的机器学习包并不多,但有许多构建良好、有用的工具即将成熟。

使用 JavaScript 的另一个优势是语言的通用性。现代网络浏览器本质上是一个可携带的应用程序平台,它允许你在几乎任何设备上运行你的代码,基本上无需修改。像electron(虽然许多人认为它很臃肿)这样的工具允许开发者快速开发并部署可下载的桌面应用程序到任何操作系统。Node.js 让你可以在服务器环境中运行你的代码。React Native 将你的 JavaScript 代码带到原生移动应用程序环境中,并可能最终允许你开发桌面应用程序。JavaScript 不再局限于动态网络交互,现在它是一种通用、跨平台的编程语言。

最后,使用 JavaScript 使得机器学习(ML)对网页和前端开发者变得可访问,这个群体在历史上一直被排除在机器学习讨论之外。由于服务器是计算能力所在的地方,因此服务器端应用通常是机器学习工具的首选。这一事实在历史上使得网页开发者难以进入机器学习领域,但随着硬件的改进,即使是复杂的机器学习模型也可以在客户端运行,无论是桌面还是移动浏览器。

如果网页开发者、前端开发者和 JavaScript 开发者今天开始学习机器学习,那么这个社区将能够改善我们所有人明天可用的机器学习工具。如果我们采用这些技术并使其民主化,让尽可能多的人接触到机器学习背后的概念,我们最终将提升社区并培养下一代机器学习研究人员。

CommonJS 倡议

2009 年,一位名叫 Kevin Dangoor 的 Mozilla 工程师意识到,服务器端 JavaScript 需要大量的帮助才能变得有用。服务器端 JavaScript 的概念已经存在,但由于许多限制,尤其是 JavaScript 生态系统方面的限制,它并不受欢迎。

在 2009 年 1 月的一篇博客文章中,Dangoor 列举了一些 JavaScript 需要帮助的例子。他写道,JavaScript 生态系统需要一个标准库和标准接口,用于文件和数据库访问等。此外,JavaScript 环境需要一个方法来打包、发布和安装库和依赖项,以便其他人可以使用,还需要一个包仓库来托管所有上述内容。

所有这些最终导致了CommonJS倡议的诞生,它对 JavaScript 生态系统最显著的贡献是 CommonJS 模块格式。如果你有任何 Node.js 的工作经验,你可能已经熟悉 CommonJS:你的package.json文件是用 CommonJS 模块包规范格式编写的,而在一个文件中编写var app = require(‘./app.js’)并在app.js中写入module.exports = App,就是在使用 CommonJS 模块规范。

模块和包的标准化为 JavaScript 的普及率显著提升铺平了道路。开发者现在可以使用模块来编写跨越多个文件的复杂应用程序,而不会污染全局命名空间。包和库的开发者能够构建和发布比 JavaScript 标准库更高层次的抽象库。Node.js 和 npm 很快就会抓住这些概念,围绕包共享构建一个主要生态系统。

Node.js

2009 年 Node.js 的发布可能是 JavaScript 历史上最重要的时刻之一,尽管没有前一年 Chrome 浏览器和 Chrome 的 V8 JavaScript 引擎的发布,这一时刻是不可能实现的。

那些还记得 Chrome 浏览器发布的人也会认识到为什么 Chrome 能在浏览器大战中占据主导地位:Chrome 速度快,设计简约,风格现代,易于开发,而且 JavaScript 在 Chrome 上的运行速度比在其他浏览器上要快得多。

Chrome 背后是开源的 Chromium 项目,该项目反过来又开发了V8 JavaScript 引擎。V8 为 JavaScript 世界带来的创新是其新的执行模型:V8 包含一个即时编译器,它将 JavaScript 直接转换为原生机器代码,而不是实时解释 JavaScript。这一策略取得了成功,其卓越的性能和开源状态使得其他人也开始将其用于自己的目的。

Node.js 采用了 V8 JavaScript 引擎,在其周围添加了一个事件驱动架构,并添加了用于磁盘和文件访问的低级 I/O API。事件驱动架构最终证明是一个关键决策。其他服务器端语言和技术,如 PHP,通常使用线程池来管理并发请求,每个线程在处理请求时本身会阻塞。Node.js 是一个单线程进程,但使用事件循环避免了阻塞操作,并更倾向于异步、回调驱动的逻辑。尽管许多人认为 Node.js 的单线程特性是一个缺点,但 Node.js 仍然能够以良好的性能处理许多并发请求,这对吸引开发者到这个平台来说已经足够了。

几个月后,npm 项目发布了。在 CommonJS 所取得的基石工作上,npm 允许包开发者将他们的模块发布到一个集中的注册表(称为 npm 注册表),并允许包消费者使用 npm 命令行工具安装和维护依赖项。

如果没有 npm,Node.js 很可能无法进入主流。Node.js 服务器本身提供了 JavaScript 引擎、事件循环和一些低级 API,但随着开发者处理更大的项目,他们往往希望有更高层次的抽象。在发起 HTTP 请求或从磁盘读取文件时,开发者并不总是需要担心二进制数据、编写头信息和其他低级问题。npm 和 npm 注册表让开发者社区能够以模块的形式编写和分享他们自己的高级抽象,其他开发者可以简单地安装并 require() 这些模块。

与其他通常内置高级抽象的编程语言不同,Node.js 允许专注于提供低级构建块,而社区则负责其他部分。社区通过构建出色的抽象,如 Express.js 网络应用程序框架、Sequelize ORM 以及数以万计的其他库,这些库只需简单的 npm install 命令即可使用。

随着 Node.js 的出现,那些没有先前服务器端语言知识的 JavaScript 开发者现在能够构建完整的全栈应用程序。前端代码和后端代码现在可以由相同的开发者使用同一种语言编写。

有雄心的开发者现在用 JavaScript 构建整个应用程序,尽管他们在路上遇到了一些问题和解决方案。完全用 JavaScript 编写的单页应用程序变得流行,但也变得难以模板化和组织。社区通过构建框架来回应,例如 Backbone.js(Angular 和 React 等框架的精神前辈)、RequireJS(CommonJS 和 AMD 模块加载器)以及模板语言如 Mustache(JSX 的精神前辈)。

当开发者遇到单页应用程序的 SEO 问题,他们发明了同构应用程序的概念,或者能够在服务器端(以便网络爬虫可以索引内容)和客户端(以保持应用程序快速和 JavaScript 驱动)渲染的代码。这导致了更多 JavaScript 框架如MeteorJS的发明。

最终,构建单页应用的 JavaScript 开发者意识到,通常他们的服务器端和数据库需求很轻量,只需要认证、数据存储和检索。这导致了无服务器技术或数据库即服务(DBaaS)平台如Firebase的发展,这反过来又为移动 JavaScript 应用程序的普及铺平了道路。Cordova/PhoneGap 项目大约在同一时间出现,允许开发者将他们的 JavaScript 代码包裹在原生的 iOS 或 Android WebView 组件中,并将他们的 JavaScript 应用程序部署到移动应用商店。

在本书的整个过程中,我们将非常依赖 Node.js 和 npm。本书中的大多数示例将使用 npm 上可用的 ML 包。

TypeScript 语言

在 npm 上开发和共享新包并不是 JavaScript 流行带来的唯一结果。JavaScript 作为主要编程语言的日益普及导致许多开发者哀叹缺乏 IDE 和语言工具支持。历史上,IDE 在 C 和 Java 等编译和静态类型语言的开发者中更受欢迎,因为这些类型的语言更容易解析和静态分析。直到最近,才出现了针对 JavaScript 和 PHP 等语言的优秀 IDE,而 Java 已经有多年针对它的 IDE。

微软希望为他们的大规模 JavaScript 项目提供更好的工具和支持,但 JavaScript 语言本身存在一些问题,阻碍了这一进程。特别是,JavaScript 的动态类型(例如,var number 可能一开始是整数 5,但后来被分配给一个对象)排除了使用静态分析工具来确保类型安全,并且也使得 IDE 难以找到正确的变量或对象来自动完成。此外,微软希望有一个基于类和接口的面向对象范式,但 JavaScript 的面向对象编程范式是基于原型的,而不是类。

因此,微软发明了 TypeScript 语言,以支持大规模的 JavaScript 开发工作。TypeScript 将类、接口和静态类型引入了语言。与 Google 的 Dart 不同,微软确保 TypeScript 总是 JavaScript 的严格超集,这意味着所有有效的 JavaScript 也是有效的 TypeScript。TypeScript 编译器在编译时进行静态类型检查,帮助开发者尽早捕获错误。对静态类型的支持还有助于 IDE 更准确地解释代码,从而为开发者提供更好的体验。

TypeScript 对 JavaScript 语言的早期改进中,有一些已经被 ECMAScript 2015(或我们称之为 ES6)所取代。例如,TypeScript 的模块加载器、类语法和箭头函数语法已被 ES6 所吸收,现在 TypeScript 只使用这些结构的 ES6 版本;然而,TypeScript 仍然为 JavaScript 带来了静态类型,这是 ES6 无法实现的。

我在这里提到 TypeScript,因为虽然我们不会在本书的示例中使用 TypeScript,但我们考察的一些机器学习库的示例是用 TypeScript 编写的。

例如,在 deeplearn.js 教程页面上的一个示例显示了如下代码:

const graph = new Graph();
 // Make a new input in the graph, called 'x', with shape [] (a Scalar).
 const x: Tensor = graph.placeholder('x', []);
 // Make new variables in the graph, 'a', 'b', 'c' with shape [] and   
    random
 // initial values.
 const a: Tensor = graph.variable('a', Scalar.new(Math.random()));
 const b: Tensor = graph.variable('b', Scalar.new(Math.random()));
 const c: Tensor = graph.variable('c', Scalar.new(Math.random()));

语法看起来像 ES6 JavaScript,除了在 const x: Tensor = …: 中看到的新的冒号表示法,这段代码是在告诉 TypeScript 编译器 const x 必须是 Tensor 类的实例。当 TypeScript 编译此代码时,它首先检查 x 在所有使用的地方是否期望是 Tensor(如果不是,将抛出错误),然后简单地丢弃编译到 JavaScript 时的类型信息。将前面的 TypeScript 代码转换为 JavaScript 只需从变量定义中移除冒号和 Tensor 关键字即可。

您可以在跟随本书的过程中在自己的示例中使用 TypeScript,但是您必须更新我们稍后设置的构建过程以支持 TypeScript。

ES6 的改进

定义 JavaScript 语言本身的规范的 ECMAScript 委员会在 2015 年 6 月发布了一个新的规范,称为 ECMAScript 6/ECMAScript 2015。这个新标准简称为 ES6,是对 JavaScript 编程语言的重大修订,并增加了一些旨在使 JavaScript 程序开发更容易的新范式。

虽然 ECMAScript 定义了 JavaScript 语言的规范,但语言的实际实现依赖于浏览器供应商和各种 JavaScript 引擎的维护者。ES6 本身只是一个指南,由于浏览器供应商各自有自己的时间表来实现新的语言特性,JavaScript 语言及其实现略有分歧。ES6 定义的特性,如类,在主要浏览器中不可用,但开发者仍然想使用它们。

来到 Babel,JavaScript 转译器。Babel 可以读取和解析不同的 JavaScript 版本(如 ES6、ES7、ES8 和 React JSX),并将其转换为或编译为浏览器标准的 ES5。即使今天,浏览器厂商还没有完全实现 ES6,所以 Babel 对于希望编写 ES6 代码的开发者来说仍然是一个必不可少的工具。

本书中的示例将使用 ES6。如果你还不熟悉新的语法,以下是本书中将使用的一些主要特性。

Let 和 const

在 ES5 JavaScript 中,我们使用 var 关键字来定义变量。在大多数情况下,var 可以简单地替换为 let,这两个构造之间的主要区别是变量相对于代码块的可见性。以下来自 MDN 网络文档(或之前称为 Mozilla 开发者网络)的例子(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let)展示了这两个之间的微妙差异:

function varTest() {
  var x = 1;
  if (true) {
    var x = 2;  // same variable!
    console.log(x);  // 2
  }
  console.log(x);  // 2
 }

 function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // different variable
    console.log(x);  // 2
  }
  console.log(x);  // 1
 }

因此,虽然你必须在像前面那样的情况下更加小心,但在大多数情况下,你只需将 var 替换为 let

let 不同,const 关键字定义了一个常量变量;也就是说,你无法在以后重新分配用 const 初始化的变量。例如,以下代码会导致一个类似于 invalid assignment to const a 的错误信息:

const a = 1;
a = 2;

另一方面,使用 varlet 来定义 a 的相同代码将成功运行。

注意,如果 a 是一个对象,你可以修改 a 的对象属性。

以下代码将成功运行:

const obj = {};
obj.name = ‘My Object’;

然而,尝试重新定义对象,如 obj = {name: “other object”},会导致错误。

我发现,在大多数编程环境中,const 通常比 let 更合适,因为大多数你使用的变量永远不会需要重新定义。我的建议是尽可能多地使用 const,只有在有理由在以后重新定义变量时才使用 let

在 ES6 中,一个非常受欢迎的变化是类和类的继承的添加。之前,JavaScript 中的面向对象编程需要原型继承,这让许多开发者觉得不直观,就像以下 ES5 的例子:

var Automobile = function(weight, speed) {
   this.weight = weight;
   this.speed = speed;
}
Automobile.prototype.accelerate = function(extraSpeed) {
   this.speed += extraSpeed;
}
var RaceCar = function (weight, speed, boost) {
   Automobile.call(this, weight, speed);
   this.boost = boost;
}
RaceCar.prototype = Object.create(Automobile.prototype);
RaceCar.prototype.constructor = RaceCar;
RaceCar.prototype.accelerate = function(extraSpeed) {
  this.speed += extraSpeed + this.boost;
}

在前面的代码中,扩展一个对象需要在子类的 constructor 函数中调用父类,创建父类原型对象的克隆,并用子类的原型构造函数覆盖父类的原型构造函数。这些步骤被大多数开发者视为不直观且繁重。

然而,使用 ES6 类,代码将看起来像这样:

class Automobile {
 constructor(weight, speed) {
   this.weight = weight;
   this.speeed = speed;
 }
 accelerate(extraSpeed) {
   this.speed += extraSpeed;
 }
}
class RaceCar extends Automobile {
 constructor(weight, speed, boost) {
   super(weight, speed);
   this.boost = boost;
 }
 accelerate(extraSpeed) {
   this.speed += extraSpeed + this.boost;
 }
}

前面的语法更符合我们对面向对象编程的预期,并且使继承变得更加简单。

需要注意的是,在底层,ES6 类仍然使用 JavaScript 的原型继承范式。类只是现有系统之上的语法糖,因此这两种方法之间除了代码整洁性外,没有显著的区别。

模块导入

ES6 还定义了一个模块导入和导出接口。使用较旧的 CommonJS 方法,模块通过 module.exports 构造导出,模块通过 require(filename) 函数导入。ES6 方法看起来略有不同。在一个文件中,定义并导出一个类,如下面的代码所示:

Class Automobile {
…
}
export default Automobile

在另一个文件中,导入类,如下面的代码所示:

import Automobile from ‘./classes/automobile.js’;
const myCar = new Automobile();

目前,Babel 将 ES6 模块编译成与 CommonJS 模块相同的格式,所以如果你使用 Babel,你可以使用 ES6 模块语法或 CommonJS 模块语法。

箭头函数

ES5 JavaScript 中的一个奇特、有用但有些令人烦恼的方面是其对异步回调的广泛使用。你可能非常熟悉类似以下这样的 jQuery 代码:

$(“#link”).click(function() {
  var $self = $(this);
  doSomethingAsync(1000, function(resp) {
    $self.addClass(“wasFaded”);
    var processedItems = resp.map(function(item) {
      return processItem(item);
    });
    return shipItems(processedItems);
  });
});

我们被迫创建一个名为 $self 的变量,因为原始的 this 上下文在我们的内部匿名函数中丢失了。我们还因为需要创建三个单独的匿名函数而有大量的样板代码和难以阅读的代码。

箭头函数语法既是帮助我们用更短的语法编写匿名函数的语法糖,也是对函数式编程的更新,它保留了箭头函数内部 this 的上下文。

例如,上述代码可以用 ES6 写成如下所示:

$(“#link”).click(function() {
  dozsSomethingAsync(1000, resp => {
    $(this).addClass(“wasFaded”);
    const processedItems = resp.map(item => processItem(Item));
    return shipItems(processedItems);
  });
});

你可以在上述代码中看到,我们不再需要 $self 变量来保留 this,并且我们的 .map 调用要简单得多,不再需要 function 关键字、括号、大括号或 return 语句。

现在让我们看看一些等效函数。让我们看看以下代码:

const double = function(number) {
  return number * 2;
}

上述代码类似于:

const double = number => number * 2;
// Is equal to:
const double = (number) => { return number * 2; }

在上述示例中,我们可以省略 number 参数周围的括号,因为该函数只需要一个参数。如果函数需要两个参数,我们就会像下一个示例中那样需要添加括号。此外,如果我们的函数体只需要一行,我们可以省略函数体的大括号和 return 语句。

让我们看看另一个等效示例,具有多个参数,如下面的代码所示:

const sorted = names.sort(function (a, b) {
  return a.localeCompare(b);
});

上述代码类似于:

const sorted = names.sort((a, b) => a.localeCompare(b));

我发现箭头函数在像上述这样的情况下最有用,当你正在做数据转换,尤其是在使用 Array.mapArray.filterArray.reduceArray.sort 调用具有简单函数体时。由于 jQuery 倾向于使用 this 上下文提供数据,而匿名箭头函数不会提供 this,因此箭头函数在 jQuery 中不太有用。

对象字面量

ES6 对对象字面量进行了一些改进。有几个改进,但你最常看到的是对象属性的隐式命名。在 ES5 中,它将是这样的:

var name = ‘Burak’;
var title = ‘Author’;
var object = {name: name, title: title};

在 ES6 中,如果属性名和变量名与前面相同,你可以简化为以下形式:

const name = ‘Burak’;
const title = ‘Author’;
const object = {name, title};

此外,ES6 引入了对象扩展运算符,它简化了浅层对象合并。例如,看看以下 ES5 中的代码:

function combinePreferences(userPreferences) {
 var defaultPreferences = {size: ‘large’, mode: ‘view’};
 return Object.assign({}, defaultPreferences, userPreferences);
}

上述代码将从defaultPreferences创建一个新的对象,并合并userPreferences中的属性。将空对象传递给Object.assign实例的第一个参数确保我们创建一个新的对象,而不是覆盖defaultPreferences(在前面示例中这不是问题,但在实际使用场景中是问题)。

现在,让我们看看 ES6 中的相同代码:

function combinePreferences(userPreferences) {
 var defaultPreferences = {size: ‘large’, mode: ‘view’};
 return {...defaultPreferences, ...userPreferences};
}

这种方法与 ES5 示例做的是同样的事情,但在我看来,它比Object.assign方法更快、更容易阅读。例如,熟悉 React 和 Redux 的开发者经常在管理 reducer 状态操作时使用对象扩展运算符。

for...of 函数

在 ES5 中,通过数组中的for循环通常使用for (index in array)语法,它看起来像这样:

var items = [1, 2, 3 ];
for (var index in items) {
var item = items[index];
…
 }

此外,ES6 添加了for...of语法,这可以节省你一步,正如你从下面的代码中可以看到的那样:

const items = [1, 2, 3 ];
for (const item of items) {
 …
 }

承诺

以一种形式或另一种形式,承诺在 JavaScript 中已经存在了一段时间。所有 jQuery 用户都熟悉这个概念。承诺是对一个异步生成并在未来可能可用的变量的引用。

如果你之前没有使用某种第三方承诺库或 jQuery 的 deferred,那么在 ES5 中处理事情的方式是接受一个异步方法的回调函数,并在成功完成后运行该回调,如下面的代码所示:

function updateUser(user, settings, onComplete, onError) {
  makeAsyncApiRequest(user, settings, function(response) {
    if (response.isValid()) {
      onComplete(response.getBody());
    } else {
      onError(response.getError())
    }
  });
}
updateUser(user, settings, function(body) { ... }, function(error) { ... });

在 ES6 中,你可以返回一个封装异步请求的Promise,它要么被解决,要么被拒绝,如下面的代码所示:

function updateUser(user, settings) {
  return new Promise((resolve, reject) => {
    makeAsyncApiRequest(user, settings, function(response) {
      if (response.isValid()) {
        resolve(response.getBody());
      } else {
        reject(response.getError())
      }
    });
  });
}
updateUser(user, settings)
  .then(
    body => { ... },
    error => { ... }
  );

承诺的真正力量在于它们可以被当作对象传递,并且承诺处理器可以被链式调用。

async/await 函数

asyncawait关键字不是 ES6 特性,而是 ES8 特性。虽然承诺极大地改进了我们处理异步调用的方式,但承诺也容易受到大量方法链的影响,在某些情况下,迫使我们使用异步范式,而实际上我们只想编写一个异步但看起来像同步函数的函数。

现在让我们看看 MDN 异步函数参考页面上的以下示例(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function):

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}
async function asyncCall() {
  console.log('calling');
  var result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: "resolved"
}
asyncCall();

resolveAfter2Seconds 函数是一个普通的 JavaScript 函数,它返回一个 ES6 promise。魔法在于 asyncCall 函数,它被 async 关键字标记。在 asyncCall 内部,我们使用 await 关键字调用 resolveAfter2Seconds,而不是使用在 ES6 中更熟悉的 promise .then(result => console.log(result)) 构造。await 关键字使我们的 async 函数在继续之前等待 promise 解析,并直接返回 Promise 的结果。以这种方式,async/await 可以将使用 promises 的异步函数转换为类似同步函数,这应该有助于保持深层嵌套的 promise 调用和异步函数调用整洁且易于阅读。

asyncawait 功能是 ES8 的部分,而不是 ES6,所以当我们几分钟内设置 Babel 时,我们需要确保在我们的配置中包含所有新的 ECMAScript 版本,而不仅仅是 ES6。

准备开发环境

本书中的示例将使用网络浏览器环境和 Node.js 环境。虽然 Node.js 版本 8 和更高版本支持 ES6+,但并非所有浏览器供应商都完全支持 ES6+ 功能,因此我们将使用 Babel 将所有代码进行转译。

本书将尽可能为所有示例使用相同的工程项目结构,无论它们是在 Node.js 命令行中执行还是在浏览器中运行。因为我们正在尝试标准化这个项目结构,所以并非每个项目都会使用我们在本节中设置的所有功能。

您将需要的工具是:

  • 您喜欢的代码编辑器,例如 Vim、Emacs、Sublime Text 或 WebStorm

  • 一个最新的网络浏览器,如 Chrome 或 Firefox

  • Node.js 版本 8 LTS 或更高;本书将使用 9.4.0 版本进行所有示例

  • Yarn 软件包管理器(可选;您也可以使用 npm)

  • 各种构建工具,如 Babel 和 Browserify

安装 Node.js

如果您是 macOS 用户,通过软件包管理器如 HomebrewMacPorts 安装 Node.js 是最简单的方法。为了与本书中的示例获得最佳兼容性,请安装 9.4.0 或更高版本的 Node.js。

Windows 用户也可以使用 Chocolatey 软件包管理器来安装 Node.js,否则您可以遵循 Node.js 当前下载页面上的说明:nodejs.org/en/.

Linux 用户如果通过其发行版的软件包管理器安装 Node.js,应小心谨慎,因为提供的 Node.js 版本可能非常旧。如果您的软件包管理器使用低于 V8 的版本,您可以选择为软件包管理器添加仓库、从源代码构建或根据您的系统安装二进制文件。

安装 Node.js 后,通过在命令行中运行 node --version 确保它运行并且是正确的版本。输出将如下所示:

$ node --version
 V9.4.0

这也是测试 npm 是否正常工作的好时机:

$ npm --version
 5.6.0

可选安装 Yarn

Yarn 是一个类似于 npm 且与 npm 兼容的包管理工具,尽管我发现它运行更快,更容易使用。如果您在 macOS 上使用 Homebrew,您可以使用brew install yarn简单地安装它;否则,请按照 Yarn 安装指南页面上的说明操作(yarnpkg.com/en/docs/install#windows-stable)。

如果您想使用 npm 而不是 Yarn,您也可以;它们都尊重相同的package.json格式,尽管它们在addrequireinstall等命令的语法上略有不同。如果您使用 npm 而不是 Yarn,只需将命令替换为正确的函数;使用的包名都将相同。

创建和初始化示例项目

使用命令行、您喜欢的 IDE 或文件浏览器,在您的机器上创建一个名为MLinJSBook的目录,并创建一个名为Ch1-Ex1的子目录。

将命令行导航到Ch1-Ex1文件夹,并运行命令yarn init,它类似于npm init,将创建一个package.json文件,并提示您输入基本信息。根据提示进行回答,答案并不重要,但是当被提示输入应用程序的入口点时,请输入dist/index.js

接下来,我们需要安装一些我们将用于大多数示例项目的构建工具:

  • babel-core:Babel 转译器核心

  • babel-preset-env:解析 ES6、ES7 和 ES8 代码的 Babel 解析器预设

  • browserify:一个可以将多个文件编译成一个文件的 JavaScript 打包器

  • babelify:Browserify 的 Babel 插件

通过以下命令安装这些作为开发环境需求:

yarn add -D babel-cli browserify babelify babel-preset-env

创建一个 Hello World 项目

为了测试一切是否正在构建和运行,我们将创建一个非常简单的包含两个文件的 Hello World 项目,并添加我们的构建脚本。

首先,在您的Ch1-Ex1文件夹下创建两个子文件夹:srcdist。我们将为所有项目使用此约定:src将包含 JavaScript 源代码,dist将包含构建源代码以及项目所需的任何附加资源(图像、CSS、HTML 文件等)。

src文件夹中,创建一个名为greeting.js的文件,并包含以下代码:

const greeting = name => 'Hello, ' + name + '!';
export default greeting;

然后创建另一个名为index.js的文件,并包含以下内容:

import greeting from './greeting';
console.log(greeting(process.argv[2] || 'world'));

这个小型应用程序测试我们是否可以使用基本的 ES6 语法和模块加载,以及访问传递给 Node.js 的命令行参数。

接下来,打开Ch1-Ex1中的package.json文件,并将以下部分添加到文件中:

"scripts": {
 "build-web": "browserify src/index.js -o dist/index.js -t [ babelify -  
  -presets [ env ] ]",
 "build-cli": "browserify src/index.js --node -o dist/index.js -t [  
  babelify --presets [ env ] ]",
 "start": "yarn build-cli && node dist/index.js"
},

这定义了三个简单的命令行脚本:

  • Build-web使用 Browserify 和 Babel 将src/index.js接触到的所有内容编译成一个名为dist/index.js的单个文件

  • Build-clibuild-web类似,但它还使用了 Browserify 的 node 选项标志;如果没有这个选项,我们就无法访问传递给 Node.js 的命令行参数

  • Start仅适用于 CLI/Node.js 示例,并且构建和运行源代码

你的package.json文件现在应该看起来像以下这样:

{
"name": "Ch1-Ex1",
"version": "0.0.1",
"description": "Chapter one example",
"main": "src/index.js",
"author": "Burak Kanber",
"license": "MIT",
"scripts": {
  "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
  "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
  "start": "yarn build-cli && node dist/index.js"
},
"dependencies": {
  "babel-core": "⁶.26.0",
  "babel-preset-env": "¹.6.1",
  "babelify": "⁸.0.0",
  "browserify": "¹⁵.1.0"
}}

让我们对这个简单应用进行一些测试。首先,确保yarn build-cli命令可以正常工作。你应该会看到以下类似的内容:

$ yarn build-cli
yarn run v1.3.2
$ browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]
Done in 0.59s.

在这一点上,确认dist/index.js文件已经被构建,并尝试直接运行它,使用以下代码:

$ node dist/index.js
Hello, world!

也尝试将你的名字作为参数传递给命令,使用以下代码:

$ node dist/index.js Burak
Hello, Burak!

现在,让我们尝试build-web命令,如下所示代码。因为这个命令省略了node选项,我们预计我们的参数将不会起作用:

$ yarn build-web
yarn run v1.3.2
$ browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]
Done in 0.61s.
$ node dist/index.js Burak
Hello, world!

没有使用node选项,我们的参数不会被传递到脚本中,并且默认显示Hello, world!,这是预期的结果。

最后,让我们使用以下代码测试我们的yarn start命令,以确保它构建了应用程序的 CLI 版本,并且也传递了我们的命令行参数,使用以下代码:

$ yarn start "good readers"
yarn run v1.3.2
$ yarn build-cli && node dist/index.js 'good readers'
$ browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]
Hello, good readers!
Done in 1.05s.

yarn start命令成功构建了应用程序的 CLI 版本,并将我们的命令行参数传递给了程序。

我们将尽力为本书中的每个示例使用相同的结构,然而,请注意每个章节的开头,因为每个示例可能需要一些额外的设置工作。

摘要

在本章中,我们讨论了 JavaScript 在机器学习中的应用中的重要时刻,从 Google 的推出(www.google.com/)开始,到 2017 年底 Google 的deeplearn.js库发布结束。

我们讨论了使用 JavaScript 进行机器学习的优势,以及我们面临的挑战,特别是在机器学习生态系统方面。

然后,我们游览了 JavaScript 语言最近最重要的进展,并对最新的 JavaScript 语言规范 ES6 进行了简要介绍。

最后,我们使用 Node.js、Yarn 包管理器、Babel 和 Browserify——这些工具将在本书的其余部分示例中使用——设置了一个示例开发环境。

在下一章中,我们将开始探索和处理数据本身。

第二章:数据探索

对于初学者来说,关于机器学习ML)最重要的认识是,机器学习不是魔法。将大量数据集拿过来,天真地应用神经网络,并不会自动给你带来震撼的见解。机器学习建立在坚实且熟悉的数学原理之上,如概率、统计学、线性代数和向量微积分——不包括巫术(尽管一些读者可能会把向量微积分比作巫术)!

在本章中,我们将涵盖以下主题:

  • 概述

  • 变量识别

  • 数据清洗

  • 转换

  • 分析类型

  • 缺失值处理

  • 异常值处理

概述

我希望尽早澄清的一个误解是,实现机器学习算法本身是完成某些任务所需工作的主要部分。如果你是新手,你可能会有这样的印象,即你应该花费 95%的时间来实现神经网络,并且神经网络完全负责你得到的结果。构建一个神经网络,放入数据,神奇地得到结果。还有什么比这更容易的吗?

机器学习的现实是,你使用的算法只和你放入的数据一样好。此外,你得到的结果只和你处理和解释它们的能力一样好。古老的计算机科学缩写词GIGO(垃圾输入,垃圾输出)在这里非常适用:垃圾输入垃圾输出

在实现机器学习技术时,你还必须密切关注它们的预处理和后处理。数据预处理需要很多原因,这也是本章的重点。后处理与你对算法输出的解释有关,无论是你对算法结果的信心是否足够高,以至于可以采取行动,以及你将结果应用于业务问题的能力。由于结果的后处理强烈依赖于所讨论的算法,因此我们将根据本书中的具体示例来讨论后处理考虑事项。

数据预处理,就像数据后处理一样,通常取决于所使用的算法,因为不同的算法有不同的要求。一个直接的例子是图像处理,使用卷积神经网络CNNs),这在后面的章节中会有介绍。所有由单个 CNN 处理的图像都应具有相同的尺寸,或者至少具有相同数量的像素和相同数量的颜色通道(RGB 与 RGBA 与灰度等)。CNN 被配置为期望特定的输入数量,因此你给它的每一张图像都必须进行预处理,以确保它符合神经网络的要求。在将图像输入网络之前,你可能需要调整大小、缩放、裁剪或填充输入图像。你可能需要将彩色图像转换为灰度图像。你可能需要检测并从你的数据集中移除损坏的图像。

一些算法如果输入错误的数据,根本就无法工作。如果一个卷积神经网络(CNN)期望接收 10,000 个灰度像素强度输入(即一个 100 x 100 像素的图像),那么你不可能给它一个 150 x 200 像素大小的图像。这是我们最好的情况:算法会大声失败,我们能够在尝试使用我们的网络之前改变我们的方法。

然而,其他算法如果输入错误的数据,可能会无声无息地失败。算法看起来似乎在工作,甚至给出看似合理的但实际完全错误的结果。这是我们最坏的情况:我们以为算法按预期工作,但实际上我们陷入了垃圾进垃圾出(GIGO)的情况。想想看,你需要花多长时间才能发现算法实际上给你的是无意义的输出。你基于错误的分析或糟糕的数据做出了多少不良的商业决策?我们必须避免这些情况,而这一切都始于开始:确保我们使用的数据适合应用。

大多数机器学习(ML)算法对其处理的数据做出假设。一些算法期望数据具有特定的尺寸和形状(如神经网络),一些算法期望数据被分类,一些算法期望数据在某个范围内归一化(在 0 到 1 或-1 到+1 之间),一些算法对缺失值有弹性,而另一些则没有。最终,你的责任是理解算法对你数据的假设,并将数据与算法的期望相匹配。

大部分上述内容都与数据的格式、形状和大小有关。还有另一个考虑因素:数据的质量。一个数据点可能格式正确,并且与算法的期望相匹配,但仍然可能是错误的。也许有人记录了一个错误的测量值,也许有仪器故障,或者可能某些环境效应已经污染或损害了你的数据。在这些情况下,格式、形状和大小可能正确,但数据本身可能会损害你的模型,并阻止它收敛到一个稳定或准确的结果。在这些情况中,有问题的数据点可能是一个异常值,或者是一个似乎不适用于集合的数据点。

异常值在现实生活中存在,通常是有效数据。仅凭观察数据本身,我们往往无法确定异常值是否有效,我们还需要考虑上下文和算法来确定如何处理数据。例如,假设您正在进行一项元分析,将患者的身高与他们的心脏功能联系起来,并且您有 100 份医疗记录可供分析。其中一位患者的身高被记录为 7'3"(221 厘米)。这是否是一个打字错误?记录数据的人实际上是否意味着 6'3"(190 厘米)?在只有 100 个随机个体的情况下,其中一个人实际上那么高的可能性有多大?即使这会扭曲您原本看起来非常干净的结果,您是否仍然应该使用这个数据点进行分析?如果样本量是 100 万条记录而不是只有 100 条呢?在这种情况下,您实际上确实选择了一个非常高的人的可能性就更大了。如果样本量只有 100,但他们都是 NBA 球员呢?

如您所见,处理异常值并不简单。您应该始终谨慎对待删除数据,尤其是在有疑问的情况下。通过删除数据,您可能会无意中创造出一个自我实现的预言,即您有意识地或无意识地只选择了支持您假设的数据,即使您的假设是错误的。另一方面,使用不合法的坏数据可能会毁掉您的结果并阻碍进步。

在本章中,我们将讨论在数据预处理阶段必须考虑的多个不同因素,包括数据转换、处理缺失数据、选择正确的参数、处理异常值以及其他有助于数据预处理阶段的分析形式。

特征识别

想象一下,您负责在一个您帮助运营的电子商务网站上放置目标产品广告。目标是分析访客过去的购物趋势,并选择展示的产品以提高购物者购买的可能性。鉴于您拥有先见之明,您已经收集了数月来所有购物者的 50 个不同指标:您记录了过去的购买,这些购买的产品类别,每次购买的标价,用户在购买前在网站上的停留时间等等。

认为机器学习是一剂万能药,认为数据越多越好,以及认为对模型进行更多训练越好,您将所有 50 个维度的数据加载到算法中,并连续训练了数天。当测试您的算法时,您发现它在评估您训练的数据点时准确性非常高,但同时也发现当评估验证集时,算法表现糟糕。此外,模型训练时间非常长。这里出了什么问题?

首先,你假设你所有的 50 个数据维度都与当前任务相关。结果证明并非所有数据都相关。机器学习擅长在数据中找到模式,但并非所有数据实际上都包含模式。一些数据是随机的,而其他数据虽然不是随机的,但也不有趣。一个符合模式但无趣的数据例子可能是购物者在你的网站上浏览的时间:用户只能在清醒时购物,所以大多数用户在早上 7 点到午夜之间购物。这些数据显然遵循一个模式,但可能实际上并不影响用户的购买意图。当然,确实可能存在一个有趣的模式:也许夜猫子倾向于在深夜进行冲动购物——但也许不是。

其次,使用所有 50 个维度并长时间训练你的模型可能会导致模型过拟合:你的过拟合模型现在非常擅长识别某种行为代表史蒂夫·约翰逊(一个特定的购物者),而不是将史蒂夫的行为归纳为一个广泛适用的趋势。这种过拟合是由两个因素造成的:长时间的训练时间和训练集中存在无关数据。如果你记录的一个维度大部分是随机的,并且你花了大量时间在这个数据上训练模型,那么模型最终可能会将那些随机数据作为用户的标识符,而不是将其过滤掉作为非趋势。模型可能会学习到,当用户在网站上的时间是正好 182 秒时,他们会购买价值 120 美元的产品,仅仅是因为你在训练过程中多次在训练数据点上训练了模型。

让我们考虑一个不同的例子:人脸识别。你拥有成千上万张人们的面孔照片,并希望能够分析一张照片并确定主题人物是谁。你在自己的数据上训练了一个卷积神经网络(CNN),发现你的算法准确率相当低,只有 60%的时间能够正确识别主题人物。这里的问题可能在于你的 CNN 在处理原始像素数据时,未能自动识别真正重要的面部特征。例如,莎拉·简总是在她厨房里拍自拍,她最喜欢的勺子总是放在背景中展示。任何其他恰好也在照片中有勺子的人可能会被错误地识别为莎拉·简,即使他们的面孔相当不同。数据已经过度训练了神经网络,使其将勺子识别为莎拉·简,而不是真正查看用户的脸部。

在这两个例子中,问题始于数据预处理不足。在电子商务商店的例子中,你没有正确识别出真正重要的购物者特征,因此用大量无关数据训练了你的模型。在人脸检测的例子中,也存在相同的问题:照片中的每个像素并不代表一个人或其特征,算法在看到可靠的勺子模式后,学会了 Sarah Jane 是勺子。

为了解决这两个问题,你需要更好地选择提供给你的机器学习模型的特征。在电子商务的例子中,可能只有你记录的 50 个维度中的 10 个是相关的,为了解决这个问题,你必须确定这 10 个维度是什么,并且只在训练模型时使用这些维度。在人脸检测的例子中,也许神经网络不应该接收原始像素强度数据,而应该接收面部维度,例如鼻梁长度嘴巴宽度瞳孔间距瞳孔与眉毛之间的距离耳垂间距下巴到发际线的距离等等。这两个例子都说明了选择数据中最相关和适当特征的需要。适当选择特征将有助于提高模型的速度和准确性。

维度诅咒

在机器学习应用中,我们经常处理高维数据。如果我们为每位购物者记录 50 个不同的指标,我们就在一个 50 维的空间中工作。如果我们分析 100 x 100 像素的灰度图像,我们就在一个 10,000 维的空间中工作。如果图像是 RGB 彩色,维度将增加到 30,000 维(图像中每个像素的每个颜色通道都是一个维度)!

这个问题被称为维度诅咒。一方面,机器学习擅长分析具有许多维度的数据。人类不擅长在如此多的维度中找到可能分布的模式,尤其是如果这些维度以反直觉的方式相互关联。另一方面,随着我们添加更多维度,我们也需要更多的处理能力来分析数据,并且我们也需要更多的训练数据来构建有意义的模型。

一个明显体现维度诅咒的领域是自然语言处理NLP)。想象一下,你正在使用贝叶斯分类器对与品牌或其他主题相关的推文进行情感分析。正如你将在后面的章节中学到的,NLP 数据预处理的一部分是将输入字符串分解成n-gram,即单词组。这些 n-gram 是提供给贝叶斯分类器算法的特征。

考虑几个输入字符串:I love cheeseI like cheeseI hate cheeseI don't love cheeseI don't really like cheese。对我们来说,这些例子很简单,因为我们整个一生都在使用自然语言。然而,一个算法会如何看待这些例子呢?如果我们进行 1-gram 或unigram分析——这意味着我们将输入字符串分割成单个单词——我们在第一个例子中看到love,在第二个例子中看到like,在第三个例子中看到hate,在第四个例子中看到love,在第五个例子中看到like。我们的 unigram 分析可能对前三个例子是准确的,但对于第四和第五个例子失败了,因为它没有学习到don't lovedon't really like是连贯的陈述;算法只关注单个单词的影响。这个算法运行非常快,需要的存储空间也很小,因为在先前的例子中,上述四个短语中只使用了七个独特的单词(Ilovecheeselikehatedon'treally)。

您可以修改分词预处理以使用bigrams,即 2-gram,或者每次两个词的组合。这增加了我们数据的维度,需要更多的存储空间和处理时间,但也能得到更好的结果。算法现在可以看到像I lovelove cheese这样的维度,现在也能识别出don't loveI love是不同的。使用 bigram 方法,算法可能正确地识别前四个示例的情感,但对于第五个示例,它被解析为I don'tdon't reallyreally likelike cheese。分类算法将看到really likelike cheese,并错误地将它与第二个示例中的积极情感联系起来。尽管如此,bigram 方法在我们的示例中有 80%是有效的。

您现在可能想再次升级分词以捕获 trigrams,即每次三个词的组合。然而,算法并没有提高准确性,而是急剧下降,无法正确识别任何内容。现在我们的数据维度太多了。算法学习了I love cheese的含义,但没有任何其他训练示例包含这个短语,因此这种知识无法以任何方式应用。第五个示例被解析为 trigrams I don't reallydon't really likereally like cheese——这些之前都从未遇到过!这个算法最终给每个示例都给出了 50%的情感评分,因为训练集中没有足够的数据来捕捉所有相关的 trigrams 组合。

这是维度灾难在发挥作用:三元组方法确实可能比二元组方法提供更好的准确性,但前提是你有一个巨大的训练集,它提供了关于每次三个不同单词的所有可能组合的数据。你现在还需要大量的存储空间,因为三个单词的组合比两个单词的组合要多得多。因此,选择预处理方法将取决于问题的上下文、可用的计算资源以及你拥有的训练数据。如果你有大量的训练数据和大量的资源,三元组方法可能更准确,但在更现实的情况下,二元组方法可能总体上更好,即使它确实会错误分类一些推文。

前面的讨论涉及到特征选择特征提取维度的概念。一般来说,我们的目标是只选择相关的特征(忽略对我们不感兴趣的客户趋势),提取推导出更好地代表我们数据的特征(通过使用面部测量而不是照片像素),并最终降低维度,这样我们就可以使用尽可能少且最相关的维度。

特征选择和特征提取

特征选择和特征提取都是用于降维的技术,尽管它们是略有不同的概念。特征选择是指只使用与当前问题相关的变量或特征。一般来说,特征选择会查看单个特征(例如“网站停留时间”)并判断该单个特征的相关性。特征提取与此类似,然而特征提取通常查看多个相关特征并将它们组合成一个单一的特征(例如查看数百个单个像素并将它们转换为瞳孔间距测量)。在这两种情况下,我们都在降低问题的维度,但两者的区别在于我们是在简单地过滤掉不相关的维度(特征选择)还是通过组合现有特征来推导出一个新的代表性特征(特征提取)。

特征选择的目标是选择数据中特征或维度的子集,以优化模型的准确率。让我们看看解决这个问题的直观方法:对所有可能的维度子集进行穷举、暴力搜索。这种方法在现实世界的应用中不可行,但它有助于为我们界定问题。如果我们以电子商务商店为例,我们的目标是找到一些维度或特征的子集,从我们的模型中获得最佳结果。我们知道我们有 50 个特征可供选择,但我们不知道最佳特征集中有多少个。通过暴力解决此问题,我们首先一次只选择一个特征,并为每个特征训练和评估我们的模型。

例如,我们只会使用“网站停留时间”作为一个数据点,在该数据点上训练模型,评估模型,并记录模型的准确率。然后我们转向“过去总购买金额”,在该数据点上训练模型,评估模型,并记录结果。我们对剩余的每个特征重复此过程 48 次,并记录每个特征的性能。然后我们必须考虑每次两个特征的组合,例如通过在“网站停留时间”和“过去总购买金额”上训练和评估模型,然后训练和评估在“网站停留时间”和“最后购买日期”上,等等。在我们的 50 个特征集中有 1,225 个独特的特征对,我们必须对每一对重复此过程。然后我们必须考虑每次三个特征的组合,其中共有 19,600 种组合。然后我们必须考虑每次四个特征的组合,其中共有 230,300 个独特的组合。有 2,118,760 个五个特征的组合,以及近 1600 万个六个特征的组合可供我们选择,等等。显然,这种对最优特征集的全面搜索无法在合理的时间内完成:我们可能需要训练我们的模型数十亿次,才能找出最佳的子集特征!我们必须找到更好的方法。

通常,特征选择技术分为三类:过滤方法、包装方法和嵌入式方法。每个类别都有多种技术,你选择的技术将取决于你的数据、上下文以及特定情况下的算法。

过滤方法是最容易实现的,并且通常具有最佳性能。特征选择的过滤方法一次分析一个特征,并试图确定该特征与数据的相关性。过滤方法通常与之后使用的机器学习算法无关,而是更典型的分析特征本身的统计方法。

例如,你可以使用皮尔逊相关系数来确定一个特征是否与输出变量有线性关系,并移除与零非常接近的相关性特征。这种方法族在计算时间上会非常快,但缺点是无法识别相互交叉相关的特征,并且根据你使用的过滤器算法,可能无法识别非线性或复杂关系。

包装方法与之前描述的暴力方法类似,但目标是避免像之前那样对每个特征组合进行全面穷举搜索。例如,你可以使用遗传算法来选择特征子集,训练和评估模型,然后使用模型的评估作为进化压力来找到下一个要测试的特征子集。

遗传算法方法可能找不到完美的特征子集,但很可能会发现一个非常好的特征子集来使用。根据你实际使用的机器学习模型和数据集的大小,这种方法可能仍然需要很长时间,但不会像穷举搜索那样需要无法处理的大量时间。包装方法的优势在于它们与正在训练的实际模型交互,因此直接优化你的模型,而不是简单地尝试独立地统计过滤出单个特征。这些方法的重大缺点是实现所需结果所需的计算时间。

此外,还有一种称为嵌入式方法的方法族,然而这个技术族依赖于具有内置特征选择算法的算法,因此非常专业化;我们在这里不会讨论它们。

特征提取技术专注于将现有特征组合成新的、派生特征,这些特征更好地代表你的数据,同时消除额外的或冗余的维度。想象一下,你的电子商务购物者数据包括网站停留时间浏览时的总像素滚动距离作为维度。也想象一下,这两个维度都与购物者在网站上花费的金额有很强的相关性。自然地,这两个特征是相互关联的:用户在网站上花费的时间越多,他们滚动距离越远的可能性就越大。仅使用特征选择技术,如皮尔逊相关分析,你会发现在特征中应该保留网站停留时间总滚动距离。这种独立分析这些特征的特征选择技术已经确定这两个都与你的问题相关,但没有理解到这两个特征实际上高度相关,因此是冗余的。

一种更复杂的特征提取技术,例如主成分分析PCA),能够识别出网站停留时间和滚动距离实际上可以合并成一个单一的新特征(让我们称它为“网站参与度”),它封装了以前由两个单独特征表示的数据。在这种情况下,我们从网站停留时间和滚动距离测量中提取了一个新特征,并使用这个单一特征而不是两个原始特征分别。这与特征选择不同;在特征选择中,我们只是在训练模型时选择使用原始特征中的哪一个,然而在特征提取中,我们是从原始特征的关联组合中创建全新的特征。因此,特征选择和特征提取都减少了我们数据的维度,但以不同的方式做到这一点。

皮尔逊相关系数示例

让我们回到电子商务商店购物者的例子,并考虑我们如何使用皮尔逊相关系数来选择数据特征。考虑以下示例数据,它记录了购物者在网站上的停留时间和他们之前在购买上花费的金额所对应的购买金额:

购买金额 网站停留时间(秒) 过去购买金额
10.00 53 7.00
14.00 220 12.00
18.00 252 22.00
20.00 571 17.00
22.00 397 21.00
34.00 220 23.00
38.00 776 29.00
50.00 462 74.00
52.00 354 63.00
56.00 23 61.00

当然,在实际应用这个问题时,你可能会有数千或数百万行,以及数十列,每列代表数据的不同维度。

现在我们将手动选择这些数据的特点。购买金额列是我们的输出数据,或我们希望算法根据其他特征预测的数据。在这个练习中,我们可以选择使用网站停留时间和之前的购买金额、仅使用网站停留时间,或者仅使用之前的购买金额来训练模型。

当使用过滤器方法进行特征选择时,我们一次考虑一个特征,因此我们必须独立于过去购买金额与购买金额的关系,来查看网站停留时间与购买金额的关系。解决这个问题的一个手动方法是将我们的两个候选特征分别与“购买金额”列进行图表化,并计算相关系数以确定每个特征与购买金额数据的相关程度。

首先,我们将图表化网站停留时间与购买金额,并使用我们的电子表格工具计算皮尔逊相关系数:

即使是简单的数据视觉检查也暗示着网站停留时间和购买金额之间只有很小的关系——如果有的话。计算皮尔逊相关系数得到大约 +0.1 的相关性,这是非常弱、几乎不相关的两个数据集之间的相关性。

然而,如果我们绘制过去购买金额与当前购买金额的图表,我们会看到一个非常不同的关系:

图片

在这种情况下,我们的视觉检查告诉我们,过去购买金额和当前购买金额之间存在线性但有些嘈杂的关系。计算相关系数给我们一个相关性值为 +0.9,这是一个相当强的线性关系!

这种分析告诉我们,在训练我们的模型时可以忽略网站停留时间数据,因为似乎在该信息中几乎没有或没有统计意义。通过忽略网站停留时间数据,我们可以减少训练模型所需的维度数量,从而让我们的模型更好地泛化数据并提高性能。

如果我们还有 48 个其他数值维度需要考虑,我们可以简单地计算每个维度的相关系数,并丢弃那些相关性低于某个阈值的维度。然而,并非每个特征都可以使用相关系数进行分析,因此您只能将皮尔逊算法应用于那些进行此类统计分析有意义的特征;例如,使用皮尔逊相关系数分析列出最近浏览的产品类别的特征就没有意义。您可以使用,并且应该使用其他类型的特征选择过滤器来处理代表不同类型数据的维度。随着时间的推移,您将开发出一套适用于不同类型数据的分析技术工具箱。

很遗憾,在这里不可能对所有可能的特征提取和特征选择算法及工具进行详尽的解释;您将不得不研究各种技术并确定哪些适合您特征和数据的形式和风格。

对于过滤技术,可以考虑的算法包括皮尔逊和斯皮尔曼相关系数、卡方检验和信息增益算法,如库尔巴克-莱布勒散度。

对于包装技术,可以考虑的方法包括遗传算法、最佳优先搜索等树搜索算法、随机技术如随机爬山算法以及启发式技术如递归特征消除和模拟退火。所有这些技术旨在选择最佳的特征集以优化您模型的输出,因此任何优化技术都可以作为候选,然而,遗传算法非常有效且受欢迎。

特征提取有许多算法需要考虑,通常关注于特征之间的互相关,以确定最小化某些误差函数的新特征;也就是说,如何将两个或多个特征结合起来,使得损失的数据量最小。相关的算法包括主成分分析(PCA)、偏最小二乘法和自动编码。在自然语言处理(NLP)中,潜在语义分析很受欢迎。图像处理有许多专门的特征提取算法,例如边缘检测、角点检测和阈值处理,以及基于问题域的进一步专业化,如人脸识别或运动检测。

清洗和准备数据

在预处理数据时,特征选择并不是唯一需要考虑的因素。还有许多其他事情你可能需要做,以准备数据供最终分析数据的算法使用。可能存在测量误差,导致显著的异常值。数据中也可能存在需要平滑的仪器噪声。数据中可能存在某些特征的缺失值。这些都是可以根据上下文、数据和涉及的算法选择忽略或解决的问题。

此外,你使用的算法可能要求数据被归一化到某个值域。或者,也许你的数据格式与算法不兼容,例如神经网络通常期望你提供一个值向量,但你从数据库中得到的却是 JSON 对象。有时你可能只需要分析来自更大数据源的具体子集。如果你处理图像,你可能需要调整大小、缩放、填充、裁剪或降低图像到灰度。

这些任务都属于数据预处理的范畴。让我们看看一些具体的场景,并讨论每个场景的可能方法。

处理缺失数据

在许多情况下,几个数据点可能某些特征值缺失。如果你查看调查问题的 Yes/No 回答,几个参与者可能意外或故意跳过了一个给定问题。如果你查看时间序列数据,你的测量工具可能在某个时间段或测量中出现了错误。如果你查看电子商务购物习惯,某些特征可能对用户不相关,例如对作为匿名客人的用户来说的最后登录日期。具体情况和场景,以及你的算法对缺失数据的容忍度,决定了你必须采取的方法来修复缺失数据。

缺失的类别数据

在分类数据的情况下,例如可能未回答的 Yes/No 调查问题,或尚未对其类别进行标记的图像,通常最好的方法是创建一个新的类别,称为未定义N/A未知类似。或者,你可能能够选择一个合理的默认类别来用于这些缺失值,例如选择集合中最频繁的类别,或者选择代表数据点逻辑父类的类别。如果你正在分析用户上传的图片,并且缺少特定照片的类别标签,你可以使用用户声明的类别代替照片的个别类别。也就是说,如果一个用户被标记为时尚摄影师,你可以为该照片使用时尚类别,即使该用户也上传了一些旅行照片。这种方法将以误分类数据点的形式向系统中添加噪声,但实际上可能对算法泛化模型有积极的影响;模型最终可能学会时尚摄影和旅行摄影是相似的。

使用未定义N/A类别也是一个首选的方法,因为数据点没有类别的事实本身可能就很重要——无类别本身可以是一个有效的类别。数据集的大小、所使用的算法以及数据集中N/A类别的相对大小将影响这是否是一个合理的处理方法。例如,在分类场景中,可能出现两种效果。如果未分类的项目确实形成了一个模式(例如,时尚照片比其他照片更常被未分类),你可能会发现你的分类器错误地学习到时尚照片应该被分类为 N/A!在这种情况下,最好完全忽略未分类的数据点。

然而,如果未分类的照片由来自各种类别的照片均匀组成,你的分类器最终可能会将难以分类的照片识别为 N/A,这实际上可能是一个期望的效果。在这种情况下,你可以考虑 N/A 作为一个包含难以分类、损坏或无法解决的图片的类别。

缺失的数值数据

处理数值数据的缺失值比处理分类数据更复杂,因为通常没有合理的默认值来替换缺失的数值。根据数据集的不同,你可能可以使用零作为替代值,然而在某些情况下,使用该特征的均值或中位数可能更合适。在其他情况下,根据所使用的算法,用一个非常大的值填充缺失值可能是有用的:如果需要对数据点进行错误计算,使用大值将标记数据点为具有大错误,从而阻止算法考虑该点。

在其他情况下,你可以使用线性插值来填充缺失的数据点。这在某些时间序列应用中是有意义的。如果你的算法期望有 31 个数据点表示某些指标的增长,而你缺少第 12 天的一个值,你可以使用第 11 天和第 13 天的平均值作为第 12 天值的估计。

通常正确的做法是忽略并过滤掉缺失值的数据点,然而,你必须考虑这种行为的效应。如果具有缺失值的数据点强烈代表特定类别数据,你可能会在副作用中创建一个强烈的选择偏差,因为你的分析会忽略一个重要的群体。你必须平衡这种类型的副作用与其他方法可能引起的副作用:将缺失值置零会显著扭曲你的分布吗?使用平均值或中位数作为替代品会污染分析的其他部分吗?这些问题只能根据具体情况回答。

处理噪声

数据中的噪声可能来自许多来源,但通常不是一个重大问题,因为大多数机器学习技术对噪声数据集具有弹性。噪声可能来自环境因素(例如,空调压缩机随机启动并导致附近传感器的信号噪声),也可能来自转录错误(有人记录了错误的数据点,在调查中选择了错误的选项,或者 OCR 算法将3读作8),或者它可能是数据本身固有的(例如,温度记录的波动将遵循季节性模式,但具有嘈杂的日间模式)。

类别数据中的噪声也可能是由未归一化的类别标签引起的,例如,当类别应该是Fashion时,图像被标记为fashionfashions。在这些情况下,最佳做法是简单地归一化类别标签,可能通过强制所有类别标签变为单数并完全小写——这将把Fashionfashionfashions类别合并为一个单一的fashion类别。

时间序列数据中的噪声可以通过取多个值的移动平均来平滑;然而,首先你应该评估平滑数据对你的算法和结果是否重要。通常,如果噪声量很小,算法仍然足以满足实际应用,特别是如果噪声是随机的而不是系统性的。

考虑以下某个传感器每日测量的示例:

Day Value
1 0.1381426172
2 0.5678176776
3 0.3564009968
4 1.239499423
5 1.267606181
6 1.440843361
7 0.3322843208
8 0.4329166745
9 0.5499234277
10 -0.4016070826
11 0.06216906816
12 -0.9689103112
13 -1.170421963
14 -0.784125647
15 -1.224217169
16 -0.4689120937
17 -0.7458561671
18 -0.6746415566
19 -0.0429460593
20 0.06757010626
21 0.480806698
22 0.2019759014
23 0.7857692899
24 0.725414402
25 1.188534085
26 0.458488458
27 0.3017212831
28 0.5249332545
29 0.3333153146
30 -0.3517342423
31 -0.721682062

绘制这些数据会显示出一种嘈杂但周期性的模式:

这在许多情况下可能是可接受的,但其他应用可能需要更平滑的数据。

此外,请注意,一些数据点超过了+1 和-1,这可能在您的算法期望数据在-1 和+1 范围内时特别有意义。

我们可以对数据进行5-Day Moving Average处理以生成更平滑的曲线。要执行5-Day Moving Average,从第3天开始,将第1天到第5天的值相加,然后除以 5。结果成为第3天的移动平均值。

注意,在此方法中,我们失去了第1天和第2天,以及第30天和第31天,因为我们不能查看第1天之前的两天,也不能查看第31天之后的两天。然而,如果您需要这些天的值,您可以使用第1天、第2天、第30天和第31天的原始值,或者您可以使用第2天和第30天的3-Day Moving Averages以及第1天和第31天的单个值。如果您有更多历史数据,您可以使用上个月的数据,计算第1天和第2天的5-Day Moving Average(通过使用上个月最后两天来计算第1天)。如何处理这个移动平均的方法将取决于您可用的数据以及拥有每个数据点的 5 天平均值的重要性,以及将 5 天平均值与边界处的 3 天和 1 天平均值相结合的重要性。

如果我们计算我们这个月的5-Day Moving Average,数据将如下所示:

Day Value 5-Day Moving Average
1 0.1381426172
2 0.5678176776
3 0.3564009968 0.7138933792
4 1.239499423 0.974433528
5 1.267606181 0.9273268566
6 1.440843361 0.9426299922
7 0.3322843208 0.8047147931
8 0.4329166745 0.4708721403
9 0.5499234277 0.1951372817
10 -0.4016070826 -0.06510164468
11 0.06216906816 -0.3857693722
12 -0.9689103112 -0.6525791871
13 -1.170421963 -0.8171012043
14 -0.784125647 -0.9233174367
15 -1.224217169 -0.8787066079
16 -0.4689120937 -0.7795505266
17 -0.7458561671 -0.631314609
18 -0.6746415566 -0.3729571541
19 -0.0429460593 -0.1830133958
20 0.06757010626 0.006553017948
21 0.480806698 0.2986351872
22 0.2019759014 0.4523072795
23 0.7857692899 0.6765000752
24 0.725414402 0.6720364272
25 1.188534085 0.6919855036
26 0.458488458 0.6398182965
27 0.3017212831 0.561398479
28 0.5249332545 0.2533448136
29 0.3333153146 0.0173107096
30 -0.3517342423
31 -0.721682062

在某些情况下,移动平均线与每日数据点的差异很大。例如,在第三天,移动平均线是当日测量值的两倍。

然而,在需要单独考虑给定一天测量值的情况下,这种方法并不合适;然而,当我们把移动平均线与每日数据点绘图时,我们可以看到这种方法的价值:

图片

我们可以看到,移动平均线比每日测量值要平滑得多,并且移动平均线更好地代表了我们的数据的周期性和正弦性质。对我们来说,一个额外的优点是移动平均线数据不再包含位于我们的[-1, +1]范围之外的点;因为此数据中的噪声是随机的,随机波动在很大程度上相互抵消,使我们的数据回归到范围内。

增加移动平均线的窗口将导致越来越宽的平均值,降低分辨率;如果我们采用31 天移动平均线,我们就会得到整个月的平均测量值。如果你的应用只需要平滑数据而不是降低数据分辨率,你应该从应用最小的移动平均线窗口开始,足以清理数据,例如,一个 3 点移动平均线。

如果你处理的是非时间序列的测量值,那么移动平均线方法可能不适用。例如,如果你在任意和随机的时间测量传感器的值,而测量时间没有记录,那么移动平均线就不适用,因为平均要跨越的维度是未知的(也就是说,我们不知道平均移动的时间段)。

如果你仍然需要从你的数据中消除噪声,你可以尝试通过创建数据的直方图来对测量值进行分箱。这种方法改变了数据本身的性质,并且不适用于所有情况,然而,它可以用来模糊单个测量值的波动,同时仍然表示不同测量值的相对频率。

处理异常值

你的数据通常会包含异常值,或者远离数据集预期值的测量点。有时,异常值是由噪声或错误引起的(某人记录了 7'3"的高度而不是 6'3"),但有时,异常值是合法的数据点(一位拥有 1000 万 Twitter 粉丝的明星加入你的服务,而大多数用户只有 1 万到 10 万粉丝)。在两种情况下,你首先想要识别异常值,以便确定如何处理它们。

识别异常值的一种方法是通过计算数据集的平均值和标准差,并确定每个数据点与平均值的偏差。数据集的标准差代表数据的整体方差或分散度。考虑以下数据,它代表了你正在分析的用户账户的 Twitter 关注者数量:

关注者
1075
1879
3794
4111
4243
4885
7617
8555
8755
19422
31914
36732
39570
1230324

如你所见,最后一个值比集合中的其他值大得多。然而,如果你正在分析数百万条记录,每条记录有数十个特征,这种差异可能并不那么明显。为了自动化我们的异常值识别,我们首先应该计算所有用户的平均平均值,在这个例子中是100,205个关注者的平均值。然后,我们应该计算数据集的标准差,对于这个数据来说,是325,523个关注者的标准差。最后,我们可以通过确定每个数据点与平均值的偏差来检查每个数据点:找到数据点与平均值之间的绝对差值,然后除以标准差:

关注者 偏差
1075 0.3045078726
1879 0.3020381533
3794 0.2961556752
4111 0.2951819177
4243 0.2947764414
4885 0.2928043522
7617 0.2844122215
8555 0.2815308824
8755 0.2809165243
19422 0.248149739
31914 0.2097769366
36732 0.1949770517
39570 0.1862593113
1230324 3.471487079

这种方法产生了良好的结果:除了一个数据点外,所有数据点都在平均值的一个标准差内,我们的异常值与平均值的距离近 3.5 个标准差。一般来说,你可以将距离平均值两个或三个标准差以上的数据点视为异常值。

如果你的数据集代表正态分布,那么你可以使用68-95-99.7规则:68%的数据点预计将在一个标准差内,95%预计将在两个标准差内,99.7%的数据点预计将在三个标准差内。因此,在正态分布中,只有 0.3%的数据预计将比平均值远三个标准差。

注意,前面提供的数据不是正态分布,而且你的大部分数据也不会遵循正态分布,但标准差的概念仍然适用(每个标准差预期的数据点比率将根据分布而有所不同)。

现在已经识别出异常值,必须确定如何处理这个异常数据点。在某些情况下,最好保留数据集中的异常值并继续正常处理;基于实际数据的异常值通常是重要的数据点,不能被忽略,因为它们代表了数据中不常见但可能出现的值。

例如,如果你正在监控服务器的 CPU 负载平均值,并发现平均值为 2.0,标准差为 1.0,你不会想忽略负载平均值为 10.0 的数据点——这些数据点仍然代表了 CPU 实际经历的平均负载,对于许多类型的分析,忽略这些数据点可能是自相矛盾的,尽管这些点远离平均值。这些点应该被考虑并在分析中予以考虑。然而,在我们的 Twitter 粉丝示例中,我们可能希望忽略异常值,特别是如果我们分析的目标是确定 Twitter 用户受众的行为模式——我们的异常值很可能表现出完全不同的行为模式,这可能会简单地混淆我们的分析。

当考虑预期为线性、多项式、指数或周期性数据时,还有另一种处理异常值的方法,这些数据类型的数据集可以进行回归分析。考虑以下预期为线性的数据:

观察
1 1
2 2
3 3
4 4
5 5
6 6
7 22
8 8
9 9
10 10

在对此数据进行线性回归时,我们可以看到异常数据点使回归向上倾斜:

图片

对于这样一组小的数据点,回归中的误差可能并不显著,但如果你使用回归来外推未来的值,例如,对于第 30 次观察,预测值将远远偏离实际值,因为异常值引入的小误差在外推值的过程中会累积。在这种情况下,我们希望在执行回归之前移除异常值,以便回归的外推更加准确。

为了识别异常值,我们可以像之前一样执行线性回归,然后计算每个点的趋势线的平方误差。如果数据点超过例如 25%的误差,我们可以认为该点为异常值,并在第二次执行回归之前将其移除。一旦我们移除了异常值并重新执行了回归,趋势线将更好地拟合数据:

图片

转换和归一化数据

最常见的预处理任务是转换和/或归一化数据,使其能够被你的算法使用。例如,你可能从 API 端点接收 JSON 对象,需要将其转换为算法使用的向量。考虑以下 JSON 数据:

const users = [
     {
       "name": "Andrew",
       "followers": 27304,
       "posts": 34,
       "images": 38,
       "engagements": 2343,
       "is_verified": false
     },
     {
       "name": "Bailey",
       "followers": 32102,
       "posts": 54,
       "images": 102,
       "engagements": 9488,
       "is_verified": true
     },
     {
       "name": "Caroline",
       "followers": 19932,
       "posts": 12,
       "images": 0,
       "engagements": 19,
       "is_verified": false
     }
];

您处理数据的神经网络期望以向量形式输入数据,如下所示:

[followers, posts, images, engagements, is_verified]

在 JavaScript 中,在这种情况下转换我们的 JSON 数据最简单的方法是使用内置的 Array.map 函数。以下代码将生成一个向量数组(数组中的数组)。这种转换形式将在本书中非常常见:

const vectors = users.map(user => [
     user.followers,
     user.posts,
     user.images,
     user.engagements,
     user.is_verified ? 1 : 0
   ]);

注意,我们正在使用 ES6 箭头函数的最简形式,它不需要在参数周围使用括号,也不需要显式的返回语句,因为我们直接返回特征数组。一个等效的 ES5 示例将如下所示:

var vectors = users.map(function(user) {
     return [
       user.followers,
       user.posts,
       user.images,
       user.engagements,
       user.is_verified ? 1 : 0
     ];
   });

还请注意,is_verified 字段已使用三元运算符转换为整数,即 user.is_verified ? 1 : 0。神经网络只能处理数值,因此我们必须将布尔值表示为整数。

我们将在后面的章节中讨论使用自然语言与神经网络结合的技术。

另一种常见的数据转换是将数据值归一化到给定范围,例如在 -1 和 +1 之间。许多算法依赖于数据值落在这个范围内,然而,大多数现实世界的数据并不如此。让我们回顾一下本章前面提到的嘈杂的每日传感器数据,并假设我们能够通过一个简单的名为 measurements 的 JavaScript 数组访问这些数据(注重细节的读者会注意到,与前面的示例相比,我已更改了第 15 天的值):

Day Value
1 0.1381426172
2 0.5678176776
3 0.3564009968
4 1.239499423
5 1.267606181
6 1.440843361
7 0.3322843208
8 0.4329166745
9 0.5499234277
10 -0.4016070826
11 0.06216906816
12 -0.9689103112
13 -1.170421963
14 -0.784125647
15 -1.524217169
16 -0.4689120937
17 -0.7458561671
18 -0.6746415566
19 -0.0429460593
20 0.06757010626
21 0.480806698
22 0.2019759014
23 0.7857692899
24 0.725414402
25 1.188534085
26 0.458488458
27 0.3017212831
28 0.5249332545
29 0.3333153146
30 -0.3517342423
31 -0.721682062

如果我们希望将此数据归一化到 [-1, +1] 的范围,我们必须首先找到集合中所有数字的最大 绝对值,在这个例子中是第 15 天的值 -1.52。如果我们简单地使用 JavaScript 的 Math.max 来处理这些数据,我们会找到数轴上的最大值,即第 6 天的值 1.44——然而,第 15 天的负值比第 6 天的正值更负。

在 JavaScript 数组中找到最大绝对值可以通过以下方式实现:

const absolute_max = Math.max.apply(null, measurements.map(Math.abs));

absolute_max 的值将是 +1.524217169——当我们使用 measurements.map 调用 Math.abs 时,这个数字变成了正数。保持绝对最大值正数非常重要,因为在下一步中我们将除以最大值,并希望保留所有数据点的符号。

给定绝对最大值,我们可以这样规范化我们的数据点:

const normalized = measurements.map(value => value / absolute_max);

通过将每个数字除以集合中的最大值,我们确保所有值都位于范围[-1, +1]内。最大值将是(在这种情况下)-1,集合中的其他所有数字将比最大值更接近 0。规范化后,我们的数据现在看起来像这样:

Day Value Normalized
1 0.1381426172 0.09063184696
2 0.5678176776 0.3725306927
3 0.3564009968 0.2338256018
4 1.239499423 0.8132039508
5 1.267606181 0.8316440777
6 1.440843361 0.9453005718
7 0.3322843208 0.218003266
8 0.4329166745 0.284025586
9 0.5499234277 0.3607907319
10 -0.4016070826 -0.2634841615
11 0.06216906816 0.04078753963
12 -0.9689103112 -0.6356773373
13 -1.170421963 -0.7678839913
14 -0.784125647 -0.5144448332
15 -1.524217169 -1
16 -0.4689120937 -0.3076412623
17 -0.7458561671 -0.4893372037
18 -0.6746415566 -0.4426151145
19 -0.0429460593 -0.02817581391
20 0.06757010626 0.04433102293
21 0.480806698 0.3154450087
22 0.2019759014 0.1325112363
23 0.7857692899 0.5155231854
24 0.725414402 0.4759258831
25 1.188534085 0.7797668924
26 0.458488458 0.3008025808
27 0.3017212831 0.1979516366
28 0.5249332545 0.3443953167
29 0.3333153146 0.2186796747
30 -0.3517342423 -0.2307638633
31 -0.721682062 -0.4734771901

没有数据点位于[-1, +1]范围之外,你还可以看到,数据绝对值最大的第 15 天已经被规范化为-1。绘制数据显示了原始值和规范化值之间的关系:

数据的形状已经保留,图表只是通过一个常数因子进行了缩放。现在这些数据可以用于需要规范化范围的算法,例如 PCA。

你的数据可能比这些先前的例子复杂得多。也许你的 JSON 数据由复杂的对象组成,其中嵌套了实体和数组。你可能需要对具有特定子元素的项目进行分析,或者你可能需要根据用户提供的查询或过滤器生成数据的动态子集。

对于复杂的情况和数据集,你可能需要第三方库的帮助,例如DataCollection.js,这是一个向 JavaScript 数组添加 SQL 和 NoSQL 风格查询功能的库。想象一下,我们之前的用户JSON 数据还包含一个名为locale的对象,它提供了用户的国籍和语言:

const users = [
     {
       "name": "Andrew",
       "followers": 27304,
       "posts": 34,
       "images": 38,
       "engagements": 2343,
       "is_verified": false,
       "locale": {
         "country":"US",
         "language":"en_US"
       }
     },
     ...
 ];

要找到语言为en_US的用户,你可以使用DataCollection.js执行以下查询:

const collection = new DataCollection(users);
   const english_lang_users = collection.query().filter({locale__language__is: "en_US"}).values();

当然,你可以轻松地在纯 JavaScript 中完成上述操作:

const english_lang_users = users.filter(user => user.locale.language === 'en_US');

然而,纯 JavaScript 版本需要对未定义或 null locale对象进行一些繁琐的修改,以使其具有弹性,当然,在纯 JavaScript 中编写更复杂的过滤器变得越来越繁琐。大多数时候,我们将使用纯 JavaScript 来展示本书中的示例,然而,我们的示例将是人为设计的,并且比现实世界的用例要干净得多;如果你觉得需要,可以使用像DataCollection.js这样的工具。

摘要

在本章中,我们讨论了数据预处理,即向我们的机器学习算法提供尽可能有用的数据的艺术。我们讨论了适当特征选择的重要性以及特征选择的相关性,无论是对于过拟合还是对于维度灾难。我们探讨了相关系数作为帮助我们确定要选择适当特征的技术,并讨论了更复杂的包装方法用于特征选择,例如使用遗传算法来确定要选择的最佳特征集。然后我们讨论了更高级的主题——特征提取,这是一类可以将多个特征组合成新的单个特征,从而进一步降低数据维度的算法。

我们接着探讨了在处理现实世界数据集时可能会遇到的一些常见场景,例如缺失值、异常值和测量噪声。我们讨论了你可以使用的各种技术来纠正这些问题。我们还讨论了你可能需要执行的一些常见数据转换和归一化,例如将值归一化到某个范围或对对象进行矢量化。

在下一章中,我们将从宏观角度探讨机器学习,并开始介绍具体的算法及其应用。

第三章:机器学习算法巡礼

在本章中,我们将探讨对机器学习ML)能够完成的任务类型的不同分类方法,并对 ML 算法本身进行分类。组织 ML 领域的方法有很多种;我们可以根据我们提供给它们的训练数据类型来分类算法,我们可以根据我们期望从算法中获得的结果类型来分类,我们可以根据它们的特定方法和策略来分类算法,我们可以根据它们处理的数据格式来分类,等等。

在本章中,我们将讨论不同类型和类别的 ML 任务和算法,同时也会介绍你将在本书中遇到的一些算法。本章将只讨论算法的高级概念,以便我们在后面的章节中深入探讨。本章将涵盖以下主题:

  • 机器学习简介

  • 学习类型——无监督学习、监督学习和强化学习

  • 算法类别——聚类、分类、回归、降维、优化、自然语言处理和图像处理

在本章结束时,你应该对监督学习和无监督学习有一个理解,并且应该了解我们将在这本书中应用的整体算法景观。

机器学习简介

通常,ML 是我们对让计算机在没有明确编程算法洞察力的情况下学习的实践所赋予的名字。相反的实践——即用一组指令编程算法,使其能够应用于数据集——通常被称为启发式。这是我们算法的第一种分类:机器学习与启发式算法。如果你在管理防火墙时手动维护一个要阻止的 IP 地址范围的黑名单,那么可以说你已经为你的防火墙开发了一个启发式方法。另一方面,如果你开发了一个分析网络流量模式、从这些模式中推断并自动维护你的黑名单的算法,那么可以说你已经开发了一种针对防火墙的 ML 方法。

我们当然可以进一步细分我们的 ML 防火墙方法。如果你的算法设计时没有先验知识(事先的知识),也就是说,如果算法从头开始,那么它可以被称为无监督学习算法。另一方面,如果你通过展示应该被阻止的源请求的示例来训练算法,并期望它通过示例进行学习,那么这个算法可以被称为监督学习算法。

你实施的特定算法也可能属于另一个子类别。你的算法可能依赖于聚类相似请求以确定给定请求可能属于哪个簇,或者你的算法可能使用贝叶斯统计来确定请求应该被分类为好或坏的几率,或者你的算法可能使用聚类、分类和启发式等技术的组合!像许多其他分类系统一样,在分类特殊情况时往往存在模糊性,但就大部分而言,算法可以被分为不同的类别。

学习类型

所有机器学习算法都消耗数据作为输入,并期望生成见解、预测、分类或分析作为输出。一些算法有一个额外的训练步骤,在这个步骤中,算法在某个数据上被训练,测试以确保它们已经从训练数据中学习,然后在未来的某个日期给出一个你希望获得见解的新数据点或数据集。

所有使用训练数据的机器学习算法都期望数据是标记的,或者以某种方式标记出该数据的期望结果。例如,当构建垃圾邮件过滤器时,你必须首先教会或训练算法垃圾邮件与正常消息(称为ham)的外观区别。你必须首先在一系列消息上训练垃圾邮件过滤器,每条消息都标记为spamham,这样算法才能学会区分两者。一旦算法被训练,你就可以向它展示一条新的、以前从未见过的消息,并期望它能猜测该消息是 ham 还是 spam。在这个例子中,你用来训练算法的消息集被称为训练数据训练集,使用的标签是spamham,而算法进行的猜测工作被称为推理。这种在一系列预标记的训练数据上训练算法的实践被称为监督学习

其他算法不需要训练,或者可以在没有任何标签的数据集上检查数据,并直接从数据中得出见解。这被称为无监督学习,这种分类的特点是数据上没有标签。如果你在科学实验室工作,正在开发一个图像处理算法来检查培养皿中细菌培养物的图片,目的是让算法告诉你照片中可以看到多少不同的细菌菌落,那么你已经开发了一个无监督学习算法。在这种情况下,你不需要用带有预标记菌落数量的训练数据来训练算法;算法预计将从零开始寻找数据中的模式和结构。输入和输出与监督学习示例相似,即你将数据提供给算法,并期望得到见解作为输出,但不同之处在于没有训练步骤或算法需要的先验知识。

在监督学习和无监督学习之间,还存在进一步的分类,这些分类位于一个光谱上。例如,在半监督学习中,算法接收一个预标记的训练集,但并非每个标签都由训练数据表示。在这种情况下,算法预计将示例拟合到适用的已训练标签,但也预计在适当的时候生成新的标签。

另一种学习模式是强化学习。强化学习在许多方面与监督学习和无监督学习相似。在强化学习中,训练数据没有明确的标签,但算法生成的结果可能与某种惩罚或奖励相关;算法的目标是最终优化其结果,以使惩罚最小化。强化学习通常与监督学习结合使用。一个算法可能最初在带有标记的训练数据上训练,但随后预计将根据其对所做决策的反馈来更新其模型。

在大多数情况下,你会发现监督学习和无监督学习是两种主要的算法类别。

无监督学习

在无监督学习中,目标是无需对数据进行任何先前的标记,就从数据中推断结构或模式。由于数据未标记,通常无法评估学习算法的准确性,这是与监督学习的一个主要区别。无监督学习算法通常不会获得关于数据的任何先验知识,除非可能是通过算法本身给出的调整参数间接获得。

无监督学习通常用于可能通过肉眼解决的数据维度非常少的问题,但由于数据的维度很大,这使得人类推断变得不可能或非常困难。无监督学习也可以用于可能通过直觉解决的低维问题,但在需要处理大量数据的情况下,手动处理是不合理的。

假设你正在编写一个算法,该算法查看卫星图像数据,任务是识别建筑物并将它们聚类成地理位置分离的社区。如果你只有一张图像,或者只有几张图像,手动完成这项任务很容易。研究人员会在照片上标记所有建筑物,并视觉检查照片以确定建筑物的集群。然后,研究人员记录社区中心的纬度和经度,并将结果放入电子表格中。太好了,首席科学家说,还有三百万张图像要处理!这是一个低维问题(只有两个维度,纬度经度需要考虑)的例子,但由于任务的庞大体积而变得不切实际。显然需要一个更复杂的解决方案。

为了开发一种无监督学习方法来解决此问题,研究人员可能会将问题分为两个阶段:预处理分析。在预处理步骤中,每张图像都应该通过一个算法来检测照片中的建筑物并返回它们的纬度/经度坐标。这个预处理步骤可以通过几种方式来管理:一种方法是将图像发送给一组实习生进行手动标记;另一种方法可能是一个非机器学习的边缘检测算法,它寻找矩形形状;第三种方法可能是一个卷积神经网络CNN),它被训练来识别建筑物的图像。

一旦完成预处理并手头有一份建筑物坐标列表,就可以将这些坐标通过无监督聚类算法,如我们稍后将要探讨的 k-means 算法,进行运行。无监督算法不需要知道建筑物是什么,它不需要了解任何现有的社区或建筑物集群,也不需要任何其他先验知识。该算法只需能够读取数百万或数十亿个纬度/经度坐标,并将它们分组成以地理位置为中心的集群。

由于无监督算法无法判断其结果的准确性,因此无法保证该算法将生成与人口普查数据相匹配的邻域,或者该算法对“邻域”的概念在语义上是正确的。例如,如果一条宽阔的高速公路将城镇的两个部分分开,那么一个城镇或邻域可能被视为两个独立的邻域。同样,如果两个邻域之间没有明显的分隔,算法可能将两个被认为是不同的邻域合并成一个单一的群集。

在许多情况下,这种语义错误是可以接受的;这种方法解决问题的好处是它可以快速处理数百万或数十亿个数据点,并至少提供一种逻辑上的群集感。无监督群集的结果可以通过另一种算法进一步后处理,或者手动审查,以向结果添加语义信息。

无监督算法也可以在人类无法直观可视化的高维数据集中找到模式。在建筑群集问题中,研究人员很容易通过视觉检查二维地图并凭肉眼识别群集。现在想象一下,你有一组数据点,每个数据点存在于一个 100 维的空间中(即具有 100 个不同特征的数据)。如果你拥有的数据量非同寻常,例如超过 100 或 1,000 个数据点,对于人类来说几乎不可能解释这些数据,因为特征之间的关系在 100 维空间中难以可视化。

作为上述问题的虚构例子,想象你是一位心理学家,你的任务是解释给参与者的一千份调查问卷,问卷中有 100 个不同的问题,每个问题都是 1-10 分的评分。每个问题都是为了评估参与者的不同性格方面。你的目标是确定有多少不同的性格类型由受访者代表。

通过手工处理仅 1,000 个数据点当然是可以实现的,这在许多领域都是常见的做法。然而,在这种情况下,数据的高度维度使得发现模式变得非常困难。两位受访者可能对某些问题的回答非常相似,但对其他问题的回答却不同;这两位受访者是否足够相似,可以被认为是同一性格类型?而且这种性格类型与其他任何给定的性格类型有多相似?我们之前用来检测建筑群集的相同算法可以应用于这个问题,以便检测受访者的群集及其性格类型(对于阅读此文的任何实际心理学家表示歉意;我知道我极大地简化了这个问题!)。

在这种情况下,无监督聚类算法在可视化涉及到的 100 个维度上没有任何困难,其表现将与二维邻域聚类问题相似。同样需要注意:不能保证算法检测到的聚类在心理上是正确的,也不能保证问题本身被设计得正确,以适当捕捉所有不同的个性类型。这个算法唯一做出的承诺是,它将识别出相似数据点的聚类。

在第二章“数据探索”中,我们讨论了在将数据提供给机器学习算法之前预处理数据的重要性。我们现在开始理解后处理和解释结果的重要性,尤其是在查看无监督算法时。因为无监督算法只能判断它们的整体统计分布(即在这种情况下,任何点到其聚类中心的平均距离),而不是它们的语义错误(即有多少数据点是实际正确的),因此算法不能对其语义正确性做出任何断言。查看诸如均方根误差或标准差之类的指标可能会给你一些关于算法表现如何的线索,但这不能用作判断算法准确性的依据,而只能用来描述数据集的统计特性。查看这些指标不会告诉你结果是否正确,只会告诉你数据是聚集的还是分散的(某些邻域稀疏,其他邻域密集,等等)。

到目前为止,我们已经在聚类算法的背景下考虑了无监督学习,这确实是无监督学习算法的一个主要家族,但还有许多其他算法。例如,我们在第二章“数据探索”中对异常值检测的讨论就属于无监督学习的范畴;我们正在查看没有先验知识的无标签数据,并试图从这些数据中获取洞察。

无监督学习技术的另一个流行例子是主成分分析(PCA),我们在第二章“数据探索”中简要介绍了它。PCA 是一种常用的无监督学习算法,通常用于预处理中的特征检测和降维,这个算法适用于解释高维数据的使用场景。与旨在告诉你在数据集中有多少逻辑数据点聚类的聚类算法不同,PCA 旨在告诉你可以将数据集的哪些特征或维度整洁地组合成具有统计意义的派生特征。在某种程度上,PCA 可以被视为特征或维度的聚类,而不是数据点的聚类。

像 PCA 这样的算法并不一定需要专门用于预处理,实际上它可以作为你想要从中获得洞察的主要机器学习算法。

让我们回到我们的心理调查示例。与其对调查受访者进行聚类,我们可能更愿意用 PCA 分析问题本身。算法的结果会告诉你哪些调查问题彼此之间相关性最大,这种洞察可以帮助你重新编写实际的调查问题,以便更好地针对你想要研究的个性特征。此外,PCA 提供的降维可以帮助研究人员可视化问题、受访者和结果之间的关系。该算法会将你的 100 维、高度互联的特征空间转换为可以实际绘制和视觉检查的独立、低维空间。

与所有无监督学习算法一样,无法保证主成分算法在语义上是正确的,只能保证算法能够从统计上确定特征之间的关系。这意味着一些结果可能看起来没有意义或不直观;算法可能会将看起来结合在一起没有直观意义的题目组合在一起。在这种情况下,研究人员需要后处理和解释分析结果,可能需要修改问题或改变他们在下一轮调查中的方法。

无监督学习算法还有许多其他例子,包括将在后续章节中讨论的自动编码器神经网络。无监督学习算法最重要的特征是其输入数据中没有标签,这导致无法确定其结果的语义正确性。然而,不要犯将无监督学习算法视为比其他算法低级的错误,因为它们在数据预处理和许多其他类型的数据探索任务中非常重要。正如扳手和螺丝刀一样,每个工具在机器学习的世界中都有其位置和用途。

监督学习

与无监督学习一样,监督学习算法的目标是解释输入数据并生成输出作为洞察。与无监督学习不同,监督学习算法首先在标记的训练示例上进行训练。训练示例被算法用来构建模型,即数据属性与其标签之间关系的内部表示,然后该模型被应用于你希望从中获得洞察的新、未标记的数据点。

监督学习通常对机器学习学生来说更有趣,因为这个类别的算法旨在提供语义正确的结果。当监督学习算法运行良好时,结果几乎看起来像是魔法!你可以在 1,000 个预标记的数据点上训练一个算法,然后使用该模型处理数百万未来的数据点,并对结果中的语义准确性有一定的期望。

由于监督学习算法旨在提供语义正确的结果,我们首先必须讨论如何衡量这种正确性。首先,我们必须介绍真正例假正例真反例假反例的概念,然后我们将介绍准确率、精确率和召回率的概念。

准确率测量

想象一下,你正在为你的博客开发的一个评论系统开发垃圾邮件过滤器。垃圾邮件过滤器是一种监督学习算法,因为算法必须首先被告知什么是垃圾邮件,什么是正常邮件。你在许多垃圾邮件和正常邮件的例子上训练你的垃圾邮件系统,然后将其投入生产,并允许它对所有新的评论进行分类,自动阻止垃圾邮件,让真正的正常邮件通过。

让我们把一个正例想象成算法识别为垃圾邮件的评论(我们称之为正例,因为我们把算法称为垃圾邮件过滤器;这只是一个语义上的区别,因为我们也可以把过滤器称为正常邮件过滤器,并用正例来表示可疑的正常邮件)。让我们把一个反例想象成被识别为真正的(正常)邮件的评论。

如果你的算法将一条评论分类为垃圾邮件(正例),并且这样做是语义正确的(也就是说,当你阅读消息时,你也确定它是垃圾邮件),那么算法就生成了一个真正例,或者是一个真正且正确的结果。相反,如果一条真正的评论被错误地识别为垃圾邮件并被阻止,这被认为是假正例,或者是一个实际上不是正例的正例。同样,被识别为正常邮件的真正正常邮件是一个真反例,而被识别为正常邮件并通过的垃圾邮件被认为是假反例。期望算法提供 100%正确的结果是不合理的,所以在实践中总会存在一定数量的假正例和假反例。

如果我们考虑我们的四种结果准确度分类,我们可以计算每个分类的实例数量,并为每个分类确定一个比率:我们可以轻松地计算出误报率、真正率、误判率和真正率。然而,如果我们独立地讨论这四个比率,可能会显得有些笨拙,因此我们也可以将这些比率组合成其他类别。

例如,算法的召回率灵敏度是其真正阳性的比率,或者说是正分类为真正阳性的百分比。在我们的垃圾邮件示例中,召回率因此指的是在所有实际垃圾邮件中正确识别的垃圾邮件百分比。这可以计算为真正阳性除以实际阳性,或者也可以是真正阳性除以真正阳性加上假阴性(记住,假阴性是实际上是垃圾邮件但被错误地识别为正常邮件的评论)。在这种情况下,召回率指的是算法正确检测垃圾邮件评论的能力,或者简单地说,在所有实际的垃圾邮件中,我们识别了多少?

特异性与召回率相似,但表示算法的真正阴性率。特异性询问的问题是:在所有实际的正常邮件中,我们正确识别了多少?

另一方面,精确度定义为真正阳性数除以真正阳性数和假阳性数之和。在我们的垃圾邮件示例中,精确度回答了这样一个问题:在我们认为的垃圾邮件中,我们有多少猜测是正确的?这两个指标之间的区别在于,我们是否在考虑所有实际的垃圾邮件,或者考虑我们认为的垃圾邮件。

准确度与精确度和召回率都不同,它关注整体正确结果。它被定义为真正阳性和真正阴性率除以总试验次数(即,总体上有多少猜测是正确的)。机器学习的学生常犯的一个错误是只关注准确度,因为它直观上更容易理解,但准确度在评估算法性能时通常是不够的。

为了证明这一点,我们必须考虑我们的垃圾邮件过滤器对现实世界结果的影响。在某些情况下,你可能希望有一个垃圾邮件过滤器永远不会让任何一条垃圾邮件通过,即使这意味着错误地阻止了一些正常邮件。在其他情况下,确保所有正常邮件都能通过可能更好,即使这意味着一些垃圾邮件会绕过你的过滤器。两个不同的垃圾邮件过滤器可能有相同的准确度,但精确度和召回率的特征却完全不同。因此,尽管准确度非常有用,但它不能总是你考虑的唯一性能指标。

由于之前的数学定义可能有点难以理解,让我们用数字来举例。假设有 100 条消息,其中 70 条是真正的正常邮件,30 条是真正的垃圾邮件:

30 实际垃圾邮件(阳性) 70 实际正常邮件(阴性)
26 猜测的垃圾邮件 22(真正阳性) 4(假阳性)
74 猜测的正常邮件 8(假阴性) 66(真正阴性)

为了计算算法的准确率,我们将正确的猜测相加:22个真阳性(true positives)和66个真阴性(true negatives),总共是 88 个正确的猜测。因此,我们的准确率是 88%。

顺便说一句:88%的准确率对于复杂问题上的高级算法来说非常好,但对于垃圾邮件过滤器来说稍微有点差。

算法的召回率或灵敏度是真阳性率,即我们在查看实际上是垃圾邮件的示例时正确猜测的次数。这意味着我们只考虑前面表格中的左侧列。算法的召回率是实际正性中的真阳性数,即真阳性除以真阳性和假阴性的总和。在这种情况下,我们有 22 个真阳性和 30 个实际的垃圾邮件消息,因此我们算法的召回率是 22/30,或 73%。

算法的精确度与实际是垃圾邮件的消息无关,而是与我们猜测是垃圾邮件的消息有关。在这种情况下,我们只考虑最上面一行,即真阳性除以真阳性和假阳性的总和;也就是说,真阳性除以猜测的正性。在我们的例子中,有 22 个真阳性和 26 个总猜测的正性,因此我们的精确度是 22/26,或 84%。

注意,这个算法比它敏感。这意味着当它猜测垃圾邮件时,垃圾邮件的猜测是 84%正确的,但该算法也有倾向于猜测正常邮件,并且会错过很多实际的垃圾邮件。此外,总准确率是 88%,但它的精确度和召回率都低于这个数字。

另一种直观地思考这些性能指标的方法如下:精确度(precision)是算法在猜测为正时正确猜测的能力,而召回率(recall)是算法记住垃圾邮件样式的记忆能力。高精确度和低召回率意味着算法在猜测邮件是垃圾邮件时非常挑剔;算法在将邮件识别为垃圾邮件之前,必须确信该邮件是垃圾邮件。

该算法在说一条消息是垃圾邮件时非常精确

因此,它可能会牺牲一些垃圾邮件的误判,而让一些正常的邮件通过。另一方面,低精确度、高召回率的算法倾向于更积极地识别邮件为垃圾邮件,然而,它也会错误地阻止一些正常的邮件(该算法更好地“回忆”垃圾邮件的样子,对垃圾邮件更敏感,因此认为更多的邮件是垃圾邮件,并相应地采取行动)。

当然,一些算法可以具有高准确率、精确度和召回率——但更现实的是,你训练算法的方式将涉及精确度和召回率之间的权衡,你必须根据你系统的预期目标来平衡这些权衡。

监督学习算法

现在我们已经对准确率、精确率和召回率有了理解,我们可以继续讨论当前的主题:监督学习算法。监督学习和无监督学习算法之间的关键区别是有预标记数据的存在,通常在算法的训练阶段引入。一个监督学习算法应该能够从标记的训练数据中学习,然后分析一个新的、未标记的数据点并猜测该数据的标签。

监督学习算法进一步分为两个子类别:分类回归。分类算法旨在根据从训练数据中学到的泛化模式预测未见数据点的标签,如前所述。回归算法旨在预测新点的值,同样基于它在训练期间学到的泛化模式。虽然分类和回归在实践中感觉不同,但前面的描述揭示了这两个类别实际上是多么相似;两者之间的主要区别是回归算法通常处理连续数据,例如时间序列或坐标数据。然而,在本节的剩余部分,我们将仅讨论分类任务。

因为算法是从标记数据中构建模型的,所以预期该算法可以生成语义上正确的结果,这与无监督算法生成的统计上正确的成果形成对比。一个语义上正确的成果是指能够经得起外部审查的结果,使用与训练数据标记相同的技巧。在垃圾邮件过滤器中,一个语义上正确的成果是算法做出的一个人类会同意的猜测。

生成语义上正确结果的能力是由预标记的训练数据实现的。训练数据本身代表了问题的语义,这是算法学习生成其语义上正确结果的方式。请注意,整个讨论——以及准确率、精确率和召回率的整个讨论——都取决于向模型引入外部验证信息的能力。只有当外部实体独立验证结果时,你才能知道单个猜测是否正确,而且只有当外部实体提供了足够的数据点及其正确的标签来训练算法时,你才能教会算法做出语义上正确的猜测。你可以将监督学习算法的训练数据视为所有猜测起源的真理之源。

当监督学习算法运行良好时,它们确实可能看起来像是魔法,但存在许多潜在的陷阱。因为训练数据对算法至关重要,你的结果将仅与你的训练数据和训练方法一样好。训练数据中的某些噪声通常可以容忍,但如果训练数据中存在系统性的错误来源,你的结果也将存在系统性的错误。这些可能很难检测,因为模型的验证通常使用你预留的训练数据的一个子集——包含系统错误的相同数据被用来验证模型,所以你会认为模型运行得很好!

另一个潜在的陷阱是训练数据不足。如果你正在解决的问题高度多维,你需要相应的大量训练数据;训练数据必须足够,以便实际上向机器学习算法展示所有各种模式。你不应该期望只用 10 封邮件来训练垃圾邮件过滤器,并期望得到很好的结果。

这些因素通常会给监督学习带来一种启动成本。在获取或生成适当数量的训练示例以及其分布方面,需要投入一定量的投资。通常情况下,尽管并非总是如此,训练数据需要通过人类知识和评估来生成。这可能很昂贵,尤其是在图像处理和目标检测的情况下,通常需要许多标记的训练示例。在一个机器学习算法变得越来越容易获取的世界里,真正的竞争在于拥有最好的数据来工作。

在我们垃圾邮件过滤器的例子中,对训练数据的需求意味着你不仅需要编写和发布垃圾邮件过滤器,还需要花一些时间手动记录哪些邮件是垃圾邮件和正常邮件(或者让你的用户报告这一点)。在部署垃圾邮件过滤器之前,你应该确保你有足够的训练数据来训练和验证算法,这可能意味着你必须等到你有数百或数千个由人类标记的垃圾邮件示例。

假设你拥有适当数量的高质量训练数据,也可能在训练过程中管理不当,导致即使数据良好也会产生不良结果。机器学习新手常常认为更多的训练总是更好的,但这并不正确。

在这一点上,需要介绍两个新的概念:偏差方差。在训练机器学习模型时,你的希望是模型能够学习训练数据的一般属性,并能够从中进行外推。如果一个算法对数据的结构做出了重大错误的假设,可以说它具有高度的偏差,因此欠拟合。另一方面,一个模型可以表现出高方差,即对训练数据中的微小差异高度敏感。这被称为过拟合,可以理解为算法学习识别个别示例,或者个别示例中的特定噪声,而不是数据的总体趋势。

过度训练模型很容易导致过拟合。想象一下,你每天使用同一台键盘 10 年,但实际上这台键盘是一个布局奇特、有很多怪癖的奇怪模型。在这么长时间后,你能够非常熟练地在这样的键盘上打字是可以预料的。然后,出乎意料的是,键盘坏了,你得到了一台新的标准键盘,却发现你不知道如何在上面打字!你经过十年打字训练的肌肉记忆已经习惯了键盘上的标点符号位置恰到好处,字母“o”稍微向右偏移一点,等等。在使用新键盘时,你会发现你打不出一个没有错别字的单词。十年在糟糕键盘上的过度训练只教会了你如何在那个键盘上打字,而你并没有将你的技能推广到其他键盘上。模型过拟合的概念与此相同:你的算法非常擅长识别你的训练数据,而无法识别其他任何数据。

因此,训练一个模型并不像插入训练数据然后让算法任意时间训练那样简单。在这个过程中,一个关键步骤是将你的训练数据分成两部分:一部分用于训练算法,另一部分仅用于验证模型的结果。你不应该在验证数据上训练算法,因为这样你可能会训练模型去识别你的验证数据,而不是训练后再使用验证数据独立验证算法的准确性。需要验证集会增加生成训练数据的成本。如果你确定你需要 1,000 个示例来训练你的算法,你可能实际上需要生成总共 1,500 个示例,以便有一个合理的验证集。

验证数据不仅仅用于测试算法的整体准确性。你通常还会使用验证数据来确定何时停止训练。在训练过程中,你应该定期使用你的验证数据测试算法。随着时间的推移,你会发现验证的准确性会如预期地增加,然后在某个时刻,验证的准确性可能会实际上下降。这种方向的变化就是你的模型开始对你的训练数据过拟合的点。当你向算法展示训练集中的例子(这些是它直接学习的例子)时,算法总是会继续变得更加准确,但一旦模型开始对训练数据过拟合,它就会开始失去泛化的能力,因此在它未训练过的数据上表现会更差——而不是更好。因此,维护一个独立的验证数据集至关重要。如果你训练了一个算法,并且在测试自己的训练数据时它达到了 100%的准确率,那么你很可能已经过拟合了数据,并且它在未见过的数据上可能表现非常糟糕。算法已经超越了学习数据中的普遍趋势,开始记住具体的例子,包括数据中的各种噪声。

除了维护一个验证集之外,适当的数据预处理也会对抗过拟合。我们在第二章数据探索中讨论的各种噪声减少、特征选择、特征提取和降维技术都将有助于泛化你的模型并避免过拟合。

最后,因为你的算法推断的语义正确性只能由外部来源确定,所以通常无法知道一个猜测是否实际上正确(除非你收到关于特定猜测的用户反馈)。最好的情况是,你只能从你在训练和验证阶段计算出的精确度、召回率和准确率值中推断出算法的整体有效性。幸运的是,许多监督学习算法以概率方式呈现他们的结果(例如,我认为有 92%的可能性这是垃圾邮件),这样你可以对算法在推断上的信心有所了解。然而,当你将这种置信水平与模型的精确度和召回率以及你的训练数据可能存在的系统性错误结合起来时,即使是推断带来的置信水平也是值得怀疑的。

尽管存在这些潜在的陷阱,监督学习是一个非常强大的技术。从复杂问题域中只有几千个训练示例中推断出,并快速对数百万未见过的数据点进行推断的能力既令人印象深刻又非常有价值。

与无监督学习一样,监督学习算法也有很多种类型,每种都有其自身的优点和缺点。神经网络、贝叶斯分类器、k-最近邻、决策树和随机森林都是监督学习技术的例子。

强化学习

虽然监督学习和无监督学习是机器学习算法的两个主要子分类,但实际上它们是光谱的一部分,还有其他的学习模式。在本书的背景下,下一个最重要的学习模式是强化学习,它在某些方面可以被认为是监督学习和无监督学习的混合体;然而,大多数人会将强化学习归类为无监督学习算法。这就是分类变得有些模糊的那些情况之一!

在无监督学习中,几乎对要处理的数据一无所知,算法必须从一张白纸中推断出模式。在监督学习中,大量的资源被用于在已知示例上训练算法。在强化学习中,关于数据的信息(或可以知道的信息)是已知的(或可以知道的),但数据的知识并不是一个明确的标签或分类。相反,已知(或可以知道)的信息是基于使用数据做出的决策采取行动的结果。强化学习被许多人视为无监督学习算法,因为算法是从零开始的,然而强化学习也“闭合循环”,并基于自己的行动不断重新训练自己,这有一些类似于监督学习中的训练。

为了举一个荒谬且牵强的例子,想象你正在编写一个算法,该算法旨在取代政府的功能。该算法将接收国家的当前状况作为输入,并且必须作为输出,制定新的政策和法律,以优化国家在多个维度上的表现:公民幸福、经济健康、低犯罪率等等。强化学习对这一问题的处理是从零开始,对它的法律和政策将如何影响国家一无所知。然后,算法实施一项或一系列法律;因为它刚刚开始,实施的法律将是完全随机的。在法律实施一段时间并对其社会产生影响后,算法将再次阅读国家的状况,可能会发现它已经将国家变成了一个混乱的荒地。算法从这种反馈中学习,调整自己,并实施一套新的法律。随着时间的推移,并使用它最初实施的法律作为实验,算法将开始理解其政策的因果关系,并开始优化。如果给足够的时间,这种方法可能会发展出一个近乎完美的社会——前提是它不会因为最初的失败实验而意外地破坏社会。

强化学习技术与监督和无监督算法不同,它们直接与环境互动并监控其决策的影响,以便更新其模型。强化学习的目标不是检测模式或对数据进行分类,而是优化环境中的某些成本或奖励。相关环境可以是现实世界环境,如控制系统领域常见的情况,也可以是虚拟环境,如遗传算法的情况。在两种情况下,算法都必须有一种方法来表征整体的成本/惩罚奖励,并努力优化该值。强化学习是一种重要的优化技术,特别是在高维问题空间中,因为 brute-force trial-and-error 方法通常无法在合理的时间内实现。

强化学习算法的例子包括遗传算法,我们将在后面的章节中深入讨论,还有蒙特卡洛方法和梯度下降(我们将与神经网络一起讨论)。

算法分类

我们已经根据学习模式对机器学习算法进行了分类,但这并不是唯一分类算法的方法。另一种方法是按任务或功能对它们进行分类。在本节中,我们将简要介绍机器学习算法的基本功能并列举一些示例算法。

聚类

聚类算法旨在识别彼此相似的数据点组。相似的定义取决于数据的类型、问题域和使用的算法。直观理解聚类算法的最简单方法是可视化x/y网格上的点。聚类算法的目标通常是围绕相似点组画圆;每个画圈的点集被视为一个簇。簇通常事先未知,因此聚类算法通常被归类为无监督学习问题。

聚类算法的一些例子包括:

  • k-means,以及其变体如 k-medians

  • 高斯混合模型

  • 均值漂移

分类

分类是监督学习算法一个非常广泛(也非常受欢迎)的类别,其目标是尝试识别一个数据点属于某个分类(垃圾邮件或正常邮件;男性或女性;动物、矿物或植物等)。存在许多用于分类的算法,包括:

  • k-最近邻

  • 逻辑回归

  • 简单贝叶斯分类器

  • 支持向量机

  • (大多数)神经网络

  • 决策树

  • 随机森林

回归

回归算法旨在确定和描述变量之间的关系。在最简单的二维线性回归案例中,算法的目标是确定一条可以通过一组点的线,然而,更高阶和更高维的回归可以产生重要的见解并预测复杂数据。因为这些算法必然需要已知的数据点,所以它们被认为是监督学习算法。一些例子包括:

  • 线性回归

  • 多项式回归

  • 贝叶斯线性回归

  • 最小绝对偏差

维度降低

维度降低是一系列技术,其目的是将高维数据转换为低维数据。作为一个通用术语,这可以意味着完全丢弃维度(例如特征选择),或者创建新的单个维度,同时代表多个原始维度,但会损失一些分辨率(例如特征提取)。

一些可用于维度降低的算法包括:

  • 各种类型的回归

  • 主成分分析(PCA)

  • 图像变换(例如,将图像转换为灰度)

  • 词干提取和词形还原(在自然语言处理中)

优化

优化算法的目标是选择一组参数,或者一组参数的值,使得系统的成本或误差最小化(或者,使得系统的奖励最大化)。特征选择和特征提取实际上是一种优化形式;你是在修改参数,目的是在保留重要数据的同时降低维度。在最基本的优化技术中,穷举搜索,你只需尝试所有可能的参数组合,并选择结果最好的组合。在实践中,大多数问题都足够复杂,以至于穷举搜索可能需要不合理的时间(即在现代计算机上可能需要数百万年)。一些优化技术包括:

  • 穷举搜索(也称为穷尽搜索)

  • 梯度下降

  • 模拟退火

  • 遗传算法

自然语言处理

自然语言处理NLP)是一个独立的领域,包含许多在机器学习中不被考虑的技术。然而,NLP 通常与 ML 算法结合使用,因为这两个领域的结合是实现通用人工智能所必需的。许多 ML 分类算法在文本上操作而不是在数字上(例如我们的垃圾邮件过滤器),在这些情况下,我们依赖于 NLP 领域的技巧:特别是词干提取是一种快速且简单的文本分类器的维度降低技术。与 ML 相关的某些 NLP 技术包括:

  • 分词

  • 字符串距离

  • 词干提取或词形还原

  • TF-IDF

图像处理

与自然语言处理类似,图像处理是一个独立的研究领域,它与机器学习有重叠,但并不完全包含在机器学习之中。与自然语言处理一样,我们可能经常使用图像处理技术来降低图像的维度,然后再将机器学习算法应用于图像。一些与机器学习相关的图像处理技术包括:

  • 边缘检测

  • 尺度不变变换

  • 颜色空间转换

  • 目标检测

  • 循环神经网络

摘要

在本章中,我们讨论了我们可以如何对机器学习技术进行分类。特别是,我们讨论了无监督学习、监督学习和强化学习之间的区别,并展示了每个类别的各种示例。

我们还讨论了判断机器学习算法准确性的不同方法,特别是将准确率、精确率和召回率等概念应用于监督学习技术。我们还讨论了监督学习算法中训练步骤的重要性,并阐述了偏差、方差、泛化和过拟合的概念。

最后,我们探讨了机器学习算法可以根据任务或技术而不是学习模式进行分类,并介绍了一系列适合于聚类、分类、回归、降维、自然语言处理和图像处理类别的算法。

在下一章中,我们将深入探讨聚类算法。

第四章:使用聚类算法进行分组

一个常见且入门级的无监督学习问题是 聚类。通常,你拥有大量数据集,希望将其组织成更小的组,或者希望将其分解成逻辑上相似的组。例如,你可以尝试将家庭收入普查数据分为三个组:低收入、高收入和超级富豪。如果你将家庭收入数据输入到聚类算法中,你预计会看到三个数据点作为结果,每个数据点对应于你三个类别的平均值。即使这个一维的聚类家庭收入问题也可能很难手工完成,因为你可能不知道一个组应该在哪里结束,另一个组应该在哪里开始。你可以使用政府关于收入分组的定义,但没有保证这些分组在几何上是平衡的;它们是由政策制定者发明的,可能无法准确代表数据。

是一组逻辑上相似的数据点。它们可以是具有相似行为的用户、具有相似收入范围的公民、具有相似颜色的像素等等。k-means 算法是数值和几何的,因此它所识别的簇都将具有数值上的相似性,并且数据点在几何上彼此接近。幸运的是,大多数数据都可以用数值表示,因此 k-means 算法适用于许多不同的问题领域。

k-means 算法是一种强大、快速且流行的数值数据聚类算法。名称 k-means 由两部分组成:k,它代表我们希望算法找到的簇的数量,和means,这是确定那些簇中心位置的方法(例如,你也可以使用 k-medians 或 k-modes)。用简单的英语来说,我们可能会要求算法为我们找到三个簇中心,这些中心是它们所代表点的平均值。在这种情况下,k = 3,我们可以在提交报告时告诉我们的老板我们进行了 k = 3 的 k-means 分析。

K-means 算法是一个迭代算法,这意味着它会运行一个循环,并不断更新其模型,直到模型达到稳定状态,此时它将返回其结果。用叙述形式来说,k-means 算法的工作方式是这样的:绘制你想要分析的数据,并选择一个k的值。你事先必须知道k的值,或者至少有一个大概的估计(尽管我们也会在后面的章节中探讨一种绕过这个问题的方法)。随机创建k个点(如果k等于 5,就创建五个点),并将它们添加到你的图表中;这些点被称为质心,因为它们最终将代表簇的几何中心。对于图表中的每个数据点,找到离该点最近的质心,并将其连接或分配给该点。一旦所有分配都已完成,依次查看每个质心,并将其位置更新为分配给它的所有点的平均值。重复分配然后更新的过程,直到质心停止移动;这些质心的最终位置是算法的输出,可以被认为是你的簇中心。如果叙述难以理解,不要担心,随着我们从零开始构建这个算法,我们会更深入地探讨它。

在本章中,我们首先将讨论平均值和距离的概念以及它们如何应用于 k-means 算法。然后我们将描述算法本身,并从头开始构建一个 JavaScript 类来实现 k-means 算法。我们将用几个简单的数据集测试我们的 k-means 求解器,然后讨论在事先不知道k的值时应该做什么。我们将构建另一个工具来自动发现k的值。我们还将讨论对于 k-means 应用来说,错误的概念意味着什么,以及如何设计一个帮助实现我们目标的错误算法。以下是在本章中将要涉及的主题:

  • 平均值和距离

  • 编写 k-means 算法

  • 示例 1—简单 2D 数据上的 k-means

  • 示例 2—3D 数据

  • k未知时的 K-means

平均值和距离

k-means 算法依赖于两个概念来运行:平均值和距离。为了告诉你簇的中心在哪里,算法将计算这些点的平均值。在这种情况下,我们将使用算术平均值,即值的总和除以值的数量,来表示我们的平均值。在 ES5/经典 JavaScript(我还在这个例子中有意地明确指出,对于不熟悉计算平均值的读者),我们可能会编写一个像这样的函数:

/**
 * @param {Array.<number>} numbers
 * @return {float}
 */
function mean(numbers) {
    var sum = 0, length = numbers.length;

    if (length === 0) {
        /**
         * Mathematically, the mean of an empty set is undefined,
         * so we could return early here. We could also allow the function
         * to attempt dividing 0/0, would would return NaN in JavaScript but
         * fail in some other languages (so probably a bad habit to encourage).
         * Ultimately, I would like this function to not return mixed types,
         * so instead let's throw an error.
         */
        throw new Error('Cannot calculate mean of empty set');
    }

    for (var i = 0; i < length; i++) {
        sum += numbers[i];
    }

    return sum / length;
}

在 ES6 中,我们可以滥用我们的简写特权,并编写以下代码:

const mean = numbers => numbers.reduce((sum, val) => sum + val, 0) / numbers.length;

这是一个可以随时放在口袋里的 ES6 单行代码,然而,它假设所有值都已经数字化和定义好了,如果你给它一个空数组,它将返回 NaN。如果这个简写让人困惑,我们可以这样拆分它:

const sum = (numbers) => numbers.reduce((sum, val) => sum + val, 0);
const mean = (numbers) => sum(numbers) / numbers.length;

请记住,我们可以使用任何类型的平均值,包括中位数和众数。事实上,有时使用 k-medians 而不是 k-means 更可取。中位数在抑制异常值方面比平均值做得更好。因此,你应该始终问自己你实际上需要哪种平均值。例如,如果你想表示总资源消耗,你可能使用算术平均值。如果你怀疑异常值是由错误的测量引起的并且应该被忽略,k-medians 可能更适合你。

在此算法中,我们还需要一个距离的概念。它可以采用任何距离度量,然而,对于数值数据,你将主要使用典型的欧几里得距离——你在高中学习过的标准距离度量,在 ES5 JavaScript 中,对于二维数据如下所示:

/**
 * Calculates only the 2-dimensional distance between two points a and b.
 * Each point should be an array with length = 2, and both elements defined and numeric.
 * @param {Array.number} a
 * @param {Array.number} b
 * @return {float}
 */
function distance2d(a, b) {
    // Difference between b[0] and a[0]
    var diff_0 = b[0] - a[0];
    // Difference between b[1] and a[1]
    var diff_1 = b[1] - a[1];

    return Math.sqrt(diff_0*diff_0 + diff_1*diff_1);
}

然而,我们必须支持超过两个维度的更多维度,因此可以推广如下:

/**
 * Calculates the N-dimensional distance between two points a and b.
 * Each point should be an array with equal length, and all elements defined and numeric.
 * @param {Array.number} a
 * @param {Array.number} b
 * @return {float}
 */
function distance(a, b) {

    var length = a.length,
        sumOfSquares = 0;

    if (length !== b.length) {
        throw new Error('Points a and b must be the same length');
    }

    for (var i = 0; i < length; i++) {
        var diff = b[i] - a[i];
        sumOfSquares += diff*diff;
    }

    return Math.sqrt(sumOfSquares);
}

我们可以为此编写一个 ES6 单行代码,但它不会像较长的、明确的示例那样易于阅读:

const distance = (a, b) => Math.sqrt(
    a.map((aPoint, i) => b[i] - aPoint)
     .reduce((sumOfSquares, diff) => sumOfSquares + (diff*diff), 0)
);

带着这些工具,我们可以开始编写 k-means 算法本身。

编写 k-means 算法

K-means 算法相对简单易实现,因此在本章中我们将从头开始编写它。该算法只需要两条信息:k-means 中的k(我们希望识别的聚类数量),以及要评估的数据点。算法还可以使用一些额外的参数,例如允许的最大迭代次数,但这些不是必需的。算法的唯一必需输出是k个质心,或者表示数据点聚类中心的点列表。如果k等于 3,则算法必须返回三个质心作为其输出。算法还可以返回其他指标,例如总误差、达到稳态所需的总迭代次数等,但这些是可选的。

K-means 算法的高级描述如下:

  1. 给定参数k和要处理的数据,随机初始化k个候选质心

  2. 对于每个数据点,确定哪个候选质心与该点最近,并将该点分配给该质心

  3. 对于每个质心,将其位置更新为其分配给的所有点的平均位置

  4. 重复步骤 2步骤 3,直到质心的位置达到稳态(即,质心停止移动)

在此过程结束时,你可以返回质心的位置作为算法的输出。

设置环境

让我们花点时间来设置这个算法的开发环境。环境将如第一章中所述,探索 JavaScript 的潜力,然而,我们将在这里完整地走一遍整个过程。

首先,为该项目创建一个新的文件夹。我已将该文件夹命名为Ch4-kmeans。在Ch4-kmeans内部创建一个名为src的子文件夹。

接下来,将一个名为package.json的文件添加到Ch4-kmeans文件夹中。将该文件的内容添加如下:

{
  "name": "Ch4-kmeans",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 4 - kmeans",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "babel-core": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0"
  }
}

在创建package.json文件后,切换到您的终端程序,并在Ch4-kmeans文件夹中运行yarn install命令。

接下来,在Ch4-kmeans/src文件夹内创建三个新文件:index.jsdata.jskmeans.js。我们将在kmeans.js中编写实际的 k-means 算法,将一些示例数据加载到data.js中,并使用index.js作为我们的启动点来设置和运行多个示例。

在这一点上,您可能想要停下来测试一切是否正常工作。在index.js中添加一个简单的console.log("Hello");,然后从命令行运行yarn start命令。您应该看到文件编译并运行,在退出前将Hello打印到屏幕上。如果您遇到错误或看不到Hello,您可能需要退一步并仔细检查您的环境。如果一切正常,您可以删除index.js中的console.log("Hello");

初始化算法

在本节中,我们将工作在kmeans.js文件中。首先要做的事情是将我们的均值和距离函数添加到文件顶部。由于这些是通用的函数,可以统计地调用,我们不会在类内部定义它们。将以下内容添加到文件顶部:

/**
 * Calculate the mean of an array of numbers.
 * @param {Array.<number>} numbers
 * @return {number}
 */
const mean = numbers => numbers.reduce((sum, val) => sum + val, 0) / numbers.length;

/**
 * Calculate the distance between two points.
 * Points must be given as arrays or objects with equivalent keys.
 * @param {Array.<number>} a
 * @param {Array.<number>} b
 * @return {number}
 */
const distance = (a, b) => Math.sqrt(
    a.map((aPoint, i) => b[i] - aPoint)
     .reduce((sumOfSquares, diff) => sumOfSquares + (diff*diff), 0)
);

接下来,在kmeans.js文件中添加并导出KMeans类。我们将在本章的其余部分添加更多方法,但让我们从以下内容开始。将以下内容添加到您刚刚添加的代码下方:

class KMeans {

    /**
     * @param k
     * @param data
     */
    constructor(k, data) {
        this.k = k;
        this.data = data;
        this.reset();
    }

    /**
     * Resets the solver state; use this if you wish to run the
     * same solver instance again with the same data points
     * but different initial conditions.
     */
    reset() {
        this.error = null;
        this.iterations = 0;
        this.iterationLogs = [];
        this.centroids = this.initRandomCentroids();
        this.centroidAssignments = [];
    }

}

export default KMeans;

我们创建了一个名为KMeans的类,并将其作为此文件的默认导出。前面的代码还初始化了类将需要的某些实例变量,我们将在稍后描述。

类的构造函数接受两个参数,kdata,并将它们都存储为实例变量。k参数代表 k-means 中的k,或者算法输出中期望的簇数量。data参数是算法将处理的数据点的数组。

在构造函数的末尾,我们调用reset()方法,该方法用于初始化(或重置)求解器的状态。具体来说,我们在reset方法中初始化的实例变量包括:

  • iterations,它是一个简单的计数器,记录求解器已运行的迭代次数,从 0 开始

  • error,它记录了当前迭代中点到其质心的均方根误差RMSE

  • centroidAssignments,它是一个数据点索引数组,映射到一个质心索引

  • centroids,它将存储求解器在当前迭代中候选的k个质心

注意,在 reset 方法中,我们调用了 this.initRandomCentroids(),这是我们尚未定义的。k-means 算法必须从一个候选质心集合开始,所以那个方法的目的就是随机生成正确数量的质心。因为算法从一个随机状态开始,可以预期多次运行算法将基于初始条件返回不同的结果。这实际上是 k-means 算法的一个期望属性,因为它容易陷入局部最优,多次运行算法使用不同的初始条件可能有助于找到全局最优。

在我们生成随机质心之前,我们必须满足一些先决条件。首先,我们必须知道数据的维度。我们是在处理 2D 数据、3D 数据、10D 数据还是 1324D 数据?我们生成的随机质心必须与数据点的其他维度数量相同。这是一个容易解决的问题;我们假设所有数据点都有相同的维度,所以我们只需检查我们遇到的第一个数据点。将以下方法添加到 KMeans 类中:

/**
 * Determines the number of dimensions in the dataset.
 * @return {number}
 */
getDimensionality() {
    const point = this.data[0];
    return point.length;
}

在生成随机初始质心时,我们必须考虑的其他因素是质心应该接近我们正在处理的数据。例如,如果你的所有数据点都在 (0, 0) 和 (10, 10) 之间,你不会希望生成一个像 (1200, 740) 这样的随机质心。同样,如果你的数据点都是负数,你不会希望生成正的质心,等等。

我们为什么要关心随机质心的起始位置呢?在这个算法中,点会被分配到最近的质心,并逐渐 质心向簇中心移动。如果所有质心都在数据点的右侧,那么质心本身也会遵循类似的路径向数据移动,并可能全部聚集在一个单独的簇中,收敛到局部最优。通过确保质心在数据范围内部随机分布,我们更有可能避免这种类型的局部最优。

我们生成质心起始位置的方法将是确定数据的每个维度的范围,然后在那些范围内为质心的位置选择随机值。例如,想象在 x, *y 平面上的三个二维数据点:(1, 3),(5, 8) 和 (3, 0)。x* 维度的范围在 1 和 5 之间,而 y 维度的范围在 0 和 8 之间。因此,当创建随机初始化的质心时,我们将为其 x 位置选择一个介于 1 和 5 之间的随机数,为其 y 位置选择一个介于 0 和 8 之间的随机数。

我们可以使用 JavaScript 的 Math.minMath.max 来确定每个维度的数据范围。将以下方法添加到 KMeans 类中:


/**
 * For a given dimension in the dataset, determine the minimum
 * and maximum value. This is used during random initialization
 * to make sure the random centroids are in the same range as
 * the data.
 *
 * @param n
 * @returns {{min: *, max: *}}
 */
getRangeForDimension(n) {
    const values = this.data.map(point => point[n]);
    return {
        min: Math.min.apply(null, values),
        max: Math.max.apply(null, values)
    };
}

此方法首先收集数据点中给定维度的所有值作为数组,然后返回一个包含该范围 minmax 的对象。回到我们前面三个数据点((1,3),(5,8)和(3,0))的示例,调用 getRangeForDimension(0) 将返回 {min: 1, max: 5},而调用 getRangeForDimension(1) 将返回 {min: 0, max: 8}

对于我们来说,有一个包含所有维度及其范围的缓存对象将很有用,我们可以在初始化质心时使用它,所以也将以下方法添加到 KMeans 类中:

/**
 * Get ranges for all dimensions.
 * @see getRangeForDimension
 * @returns {Array} Array whose indices are the dimension number and whose members are the output of getRangeForDimension
 */
getAllDimensionRanges() {
    const dimensionRanges = [];
    const dimensionality = this.getDimensionality();

    for (let dimension = 0; dimension < dimensionality; dimension++) {
        dimensionRanges[dimension] = this.getRangeForDimension(dimension);
    }

    return dimensionRanges;

}

此方法简单地查看所有维度,并为每个维度返回 minmax 范围,结构为一个按维度索引的数组中的对象。此方法主要是为了方便,但我们很快就会使用它。

我们最终可以生成随机初始化的质心。我们需要创建 k 个质心,并且逐个维度地选择每个维度范围内的随机点。将以下方法添加到 KMeans 类中:


/**
 * Initializes random centroids, using the ranges of the data
 * to set minimum and maximum bounds for the centroids.
 * You may inspect the output of this method if you need to debug
 * random initialization, otherwise this is an internal method.
 * @see getAllDimensionRanges
 * @see getRangeForDimension
 * @returns {Array}
 */
initRandomCentroids() {

    const dimensionality = this.getDimensionality();
    const dimensionRanges = this.getAllDimensionRanges();
    const centroids = [];

    // We must create 'k' centroids.
    for (let i = 0; i < this.k; i++) {

        // Since each dimension has its own range, create a placeholder at first
        let point = [];

        /**
         * For each dimension in the data find the min/max range of that dimension,
         * and choose a random value that lies within that range. 
         */
        for (let dimension = 0; dimension < dimensionality; dimension++) {
            const {min, max} = dimensionRanges[dimension];
            point[dimension] = min + (Math.random()*(max-min));
        }

        centroids.push(point);

    }

    return centroids;

}

前面的算法包含两个循环;外循环创建 k 个候选质心。由于数据集的维度数量是任意的,并且每个维度本身也有一个任意的范围,因此我们必须逐个维度地工作,为每个质心生成随机位置。如果你的数据是三维的,内循环将分别考虑维度 0、1 和 2,确定每个维度的 minmax 值,在该范围内选择一个随机值,并将该值分配给质心点的特定维度。

测试随机质心生成

我们已经编写了大量的代码,所以现在是停止并测试我们工作的好时机。我们还应该开始设置我们的 data.js 文件,我们将使用它来存储一些示例数据。

打开 data.js 文件并添加以下内容:

const example_randomCentroids = [
    [1, 3], [5, 8], [3, 0]
];

export default {
    example_randomCentroids
};

使用的值与前面块中编写的简单数据点示例中的值相同。

现在,切换到 index.js 并添加以下代码:

import KMeans from './kmeans.js';
import example_data from './data.js';

console.log("\nML in JS Chapter 4 k-means clustering examples.");
console.log("===============================================\n");

console.log("Testing centroid generation:");
console.log("===============================================\n");

const ex_randomCentroids_solver = new KMeans(2, example_data.example_randomCentroids);

console.log("Randomly initialized centroids: ");
console.log(ex_randomCentroids_solver.centroids);
console.log("\n-----------------------------------------------\n\n");

首先,我们从各自的文件中导入 KMeans 类和 example_data 对象。我们在屏幕上打印一些有用的输出,然后为我们的简单数据初始化一个 KMeans 求解器实例。我们可以通过检查 ex_randomCentroids_solver.centroids 的值来检查随机初始化的质心。

添加此代码后,从命令行运行 yarn start,你应该会看到以下类似输出。请注意,由于质心初始化是随机的,你将不会看到与我相同的值;然而,我们想要确保随机质心位于正确的范围内。具体来说,我们希望我们的质心在 1 和 5 之间有 x 位置,在 0 和 8 之间有 y 位置。多次运行代码以确保质心有正确的位置:

$ yarn start
yarn run v1.3.2
$ yarn build-cli && node dist/index.js
$ browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]

 ML in JS Chapter 4 k-means clustering examples.
 ===============================================

 Testing centroid generation:
 ===============================================

 Randomly initialized centroids:
 [ [ 4.038663181817283, 7.765675509733137 ],
 [ 1.976405159755187, 0.026837564634993427 ] ]

如果你看到与前面块类似的内容,这意味着到目前为止一切正常,我们可以继续实现算法。

将点分配到质心

k-means 算法执行的迭代循环包含两个步骤:将每个点分配到最近的质心,然后更新质心的位置,使其成为分配给该质心的所有点的平均值。在本节中,我们将实现算法的第一部分:将点分配到质心。

从高层次来看,我们的任务是考虑数据集中的每个点,并确定哪个质心离它最近。我们还需要记录此分配的结果,以便我们可以稍后根据分配给它的点更新质心的位置。

将以下方法添加到KMeans类的主体中:

/**
 * Given a point in the data to consider, determine the closest
 * centroid and assign the point to that centroid.
 * The return value of this method is a boolean which represents
 * whether the point's centroid assignment has changed;
 * this is used to determine the termination condition for the algorithm.
 * @param pointIndex
 * @returns {boolean} Did the point change its assignment?
 */
assignPointToCentroid(pointIndex) {

    const lastAssignedCentroid = this.centroidAssignments[pointIndex];
    const point = this.data[pointIndex];
    let minDistance = null;
    let assignedCentroid = null;

    for (let i = 0; i < this.centroids.length; i++) {
        const centroid = this.centroids[i];
        const distanceToCentroid = distance(point, centroid);

        if (minDistance === null || distanceToCentroid < minDistance) {
            minDistance = distanceToCentroid;
            assignedCentroid = i;
        }

    }

    this.centroidAssignments[pointIndex] = assignedCentroid;

    return lastAssignedCentroid !== assignedCentroid;

}

此方法考虑单个数据点,由其索引给出,并依次考虑系统中的每个质心。我们还跟踪此点最后分配到的质心,以确定分配是否已更改。

在前面的代码中,我们遍历所有质心并使用我们的distance函数来确定点与质心之间的距离。如果距离小于迄今为止看到的最低距离,或者这是我们为该点考虑的第一个质心(在这种情况下minDistance将为 null),我们将记录距离和质心的索引位置。遍历所有质心后,我们现在将知道哪个质心是考虑中的点最近的。

最后,我们通过将质心分配给点的索引设置到this.centroidAssignments数组中来记录质心分配——在这个数组中,索引是点的索引,值是质心的索引。我们通过比较最后已知的质心分配和新质心分配来从这个方法返回一个布尔值——如果分配已更改,则返回true,如果没有更改,则返回false。我们将使用这个信息来确定算法何时达到稳态。

之前的方法只考虑单个点,因此我们还应该编写一个方法来处理所有点的质心分配。此外,我们编写的方法还应确定是否有任何点更新了其质心分配。将以下内容添加到KMeans类中:

/**
 * For all points in the data, call assignPointsToCentroids
 * and returns whether _any_ point's centroid assignment has
 * been updated.
 *
 * @see assignPointToCentroid
 * @returns {boolean} Was any point's centroid assignment updated?
 */
assignPointsToCentroids() {
    let didAnyPointsGetReassigned = false;
    for (let i = 0; i < this.data.length; i++) {
        const wasReassigned = this.assignPointToCentroid(i);
        if (wasReassigned) didAnyPointsGetReassigned = true;
    }
    return didAnyPointsGetReassigned;
}

此方法定义了一个名为didAnyPointsGetReassigned的变量,并将其初始化为false,然后遍历数据集中的所有点以更新它们的质心分配。如果有任何点被分配到新的质心,该方法将返回true。如果没有分配更改,该方法返回false。此方法的返回值将成为我们的终止条件之一;如果在迭代后没有点更新,我们可以认为算法已达到稳态,可以终止它。

现在我们来讨论 k-means 算法的第二部分:更新质心位置。

更新质心位置

在上一节中,我们实现了 k-means 算法的第一部分:查看数据集中的所有点并将它们分配到地理位置上最近的质心。算法的下一步是查看所有质心并更新它们的地理位置到分配给它们的所有点的平均值。

为了做一个类比,你可以想象每个点伸出手去抓住离它最近的质心。点给质心一个拉力,试图将其拉得更近。我们已经实现了算法的“伸出手去抓住”部分,现在我们将实现“拉质心更近”的部分。

从高层次来看,我们的任务是遍历所有质心,对于每个质心,确定分配给它的所有点的平均位置。然后我们将更新质心的位置到这个平均值。进一步分解,我们必须首先收集分配给质心的所有点,然后我们必须计算这些点的平均值,始终记住点可以有任意数量的维度。

让我们从收集分配给质心的所有点这个简单的任务开始。我们已经在 this.centroidAssignments 实例变量中有一个点索引到质心索引的映射。将以下代码添加到 KMeans 类的主体中:

/**
 * Given a centroid to consider, returns an array
 * of all points assigned to that centroid.
 *
 * @param centroidIndex
 * @returns {Array}
 */
getPointsForCentroid(centroidIndex) {
    const points = [];
    for (let i = 0; i < this.data.length; i++) {
        const assignment = this.centroidAssignments[i];
        if (assignment === centroidIndex) {
            points.push(this.data[i]);
        }
    }
    return points;
}

上述方法是相当标准的:遍历所有数据点,查找该点的质心分配,如果它被分配到所讨论的质心,我们就将该点添加到输出数组中。

我们现在可以使用这个点列表来更新质心的位置。我们的目标是更新质心的位置,使其成为我们之前找到的所有点的平均值。因为数据可能是多维的,我们必须独立考虑每个维度。

使用我们简单的点示例(1, 3)、(5, 8)和(3, 0),我们会找到一个平均位置为(3, 3.6667)。为了得到这个值,我们首先计算 x 维度的平均值((1 + 5 + 3)/ 3 = 3),然后计算 y 维度的平均值((3 + 8 + 0)/ 3 = 11/3 = 3.6666...)。如果我们工作在超过两个维度的情况下,我们只需对每个维度重复此过程。

我们可以用 JavaScript 编写这个算法。将以下代码添加到 KMeans 类的主体中:

/**
 * Given a centroid to consider, update its location to
 * the mean value of the positions of points assigned to it.
 * @see getPointsForCentroid
 * @param centroidIndex
 * @returns {Array}
 */
updateCentroidLocation(centroidIndex) {
    const thisCentroidPoints = this.getPointsForCentroid(centroidIndex);
    const dimensionality = this.getDimensionality();
    const newCentroid = [];
    for (let dimension = 0; dimension < dimensionality; dimension++) {
        newCentroid[dimension] = mean(thisCentroidPoints.map(point => point[dimension]));
    }
    this.centroids[centroidIndex] = newCentroid;
    return newCentroid;
}

上述方法只考虑一个质心,由其索引指定。我们使用刚刚添加的 getPointsForCentroid 方法来获取分配给该质心的点数组。我们初始化一个名为 newCentroid 的变量为空数组;这最终将替换当前的质心。

考虑一次一个维度,我们只收集该维度的点位置,然后计算平均值。我们使用 JavaScript 的 Array.map 方法来提取正确维度的位置,然后使用我们的 mean 函数来计算该维度的平均位置。

如果我们手动使用数据点(1, 3)、(5, 8)和(3, 0)来工作,我们首先检查维度 0,即x维度。thisCentroidPoints.map(point => point[dimension])的结果是维度 0 的数组[1, 5, 3],对于维度 1,结果是[3, 8, 0]。这些数组中的每一个都传递给mean函数,并且该维度的newCentroid使用平均值。

在此方法结束时,我们使用新计算出的质心位置更新我们的this.centroids数组。

我们还将编写一个便利方法来遍历所有质心并更新它们的位置。将以下代码添加到KMeans类的主体中:

/**
 * For all centroids, call updateCentroidLocation
 */
updateCentroidLocations() {
    for (let i = 0; i < this.centroids.length; i++) {
        this.updateCentroidLocation(i);
    }
}

在完成算法并将所有部分连接起来之前,我们还有一个最终的前提条件需要满足。我们将把误差的概念引入算法中。计算误差对于 k-means 算法的功能不是必需的,但你会看到,在某些情况下这可能会带来优势。

因为这是一个无监督学习算法,我们的误差概念与语义错误无关。相反,我们将使用一个误差度量,它表示所有点与其分配的质心之间的平均距离。我们将使用 RMSE 来做到这一点,它对更大的距离进行更严厉的惩罚,因此我们的误差度量将很好地指示聚类的紧密程度。

为了执行这个误差计算,我们遍历所有点并确定该点与其质心的距离。在将每个距离加到运行总和中之前,我们将其平方(在均方根中称为平方),然后除以点的数量(在均方根中称为平均),最后取整个数的平方根(在均方根中称为)。

将以下代码添加到KMeans类的主体中:

/**
 * Calculates the total "error" for the current state
 * of centroid positions and assignments.
 * Here, error is defined as the root-mean-squared distance
 * of all points to their centroids.
 * @returns {Number}
 */
calculateError() {

    let sumDistanceSquared = 0;
    for (let i = 0; i < this.data.length; i++) {
        const centroidIndex = this.centroidAssignments[i];
        const centroid = this.centroids[centroidIndex];
        const point = this.data[i];
        const thisDistance = distance(point, centroid);
        sumDistanceSquared += thisDistance*thisDistance;
    }

    this.error = Math.sqrt(sumDistanceSquared / this.data.length);
    return this.error;
}

现在我们已经准备好将所有东西连接起来并实现算法的主循环。

主循环

k-means 算法的所有支持和基础逻辑现在都已实现。最后要做的就是将它们全部连接起来并实现算法的主循环。要运行算法,我们应该重复将点分配给质心和更新质心位置的过程,直到质心停止移动。我们还可以执行可选步骤,例如计算误差并确保算法不超过某些最大允许的迭代次数。

将以下代码添加到KMeans类的主体中:

/**
 * Run the k-means algorithm until either the solver reaches steady-state,
 * or the maxIterations allowed has been exceeded.
 *
 * The return value from this method is an object with properties:
 * {
 *  centroids {Array.<Object>},
 *  iteration {number},
 *  error {number},
 *  didReachSteadyState {Boolean}
 * }
 *
 * You are most likely interested in the centroids property of the output.
 *
 * @param {Number} maxIterations Default 1000
 * @returns {Object}
 */
solve(maxIterations = 1000) {

    while (this.iterations < maxIterations) {

        const didAssignmentsChange = this.assignPointsToCentroids();
        this.updateCentroidLocations();
        this.calculateError();

        this.iterationLogs[this.iterations] = {
            centroids: [...this.centroids],
            iteration: this.iterations,
            error: this.error,
            didReachSteadyState: !didAssignmentsChange
        };

        if (didAssignmentsChange === false) {
            break;
        }

        this.iterations++;

    }

    return this.iterationLogs[this.iterationLogs.length - 1];

}

我们编写了一个solve方法,它还接受允许的最大迭代次数的限制,我们将其默认设置为1000。我们在while循环中运行算法,并在循环的每次迭代中调用assignPointsToCentroids(记录其输出值,didAssignmentsChange),调用updateCentroidLocations,并调用calculateError

为了帮助调试并维护算法所完成工作的历史记录,我们维护一个this.iterationLogs数组,并在每次迭代中记录质心位置、迭代次数、计算误差以及算法是否达到稳态(这是didAssignmentsChange的反面)。我们在记录日志时使用 ES6 的数组扩展运算符对this.centroids进行操作,以避免将此数组作为引用传递,否则迭代日志将显示质心的最后状态而不是其随时间的变化过程。

如果点/质心分配在连续的迭代中不发生变化,我们认为算法已经达到稳态,可以返回结果。我们通过使用break关键字提前退出while循环来实现这一点。如果算法从未达到稳态,while循环将继续执行,直到达到允许的最大迭代次数,并返回最新的可用结果。solve方法的输出仅仅是最近的迭代日志,其中包含了此类用户需要了解的所有信息。

示例 1 - 对简单 2D 数据的 k-means 算法

我们已经编写并编码了 k-means 算法的实现,所以现在是时候看看它是如何工作的了。在我们的第一个例子中,我们将运行我们的算法针对一个简单的二维数据集。数据本身将被设计得算法可以轻松找到三个不同的簇。

首先,修改data.js文件,在export default行之前添加以下数据:

const example_2d3k = [
    [1, 2], [2, 3], [2, 5], [1, 6], [4, 6],
    [3, 5], [2, 4], [4, 3], [5, 2], [6, 9],
    [4, 4], [3, 3], [8, 6], [7, 5], [9, 6],
    [9, 7], [8, 8], [7, 9], [11, 3], [11, 2],
    [9, 9], [7, 8], [6, 8], [12, 2], [14, 3],
    [15, 1], [15, 4], [14, 2], [13, 1], [16, 4]
];

然后,更新最终的导出行,使其看起来像这样:

export default {
    example_randomCentroids,
    example_2d3k
};

如果我们要绘制前面的数据点,我们会看到以下内容:

图片

从视觉上看,我们可以看到有三个整齐聚集的数据点组。当我们运行算法时,我们将使用k = 3,并期望质心能够整齐地定位到这三个簇的中心。让我们试试看。

打开index.js并添加以下内容。你可以替换你之前添加的代码(保留import语句),或者简单地添加到下面:

console.log("Solving example: 2d data with 3 clusters:");
console.log("===============================================\n");

console.log("Solution for 2d data with 3 clusters:");
console.log("-------------------------------------");
const ex_1_solver = new KMeans(3, example_data.example_2d3k);
const ex_1_centroids = ex_1_solver.solve();
console.log(ex_1_centroids);
console.log("");

在输出一些标题后,我们创建了一个新的名为ex_1_solverKMeans实例,并用 k = 3 和刚刚添加的example_data.example_2d3k初始化它。我们调用solve方法,不带任何参数(即,最大允许的迭代次数将是 1,000),并将输出捕获在变量ex_1_centroids中。最后,我们将结果打印到屏幕上并添加换行符——我们将在这一点之后添加更多的测试和示例。

注意,你的质心顺序可能与我不同,因为随机初始条件会有所不同。

你现在可以运行yarn start,应该会看到类似的输出。此外,由于随机初始化,解算器的某些运行可能会陷入局部最优,你将看到不同的质心。连续运行程序几次,看看会发生什么。以下是我的输出:

Solving example: 2d data with 3 clusters:
==========================================================

Solution for 2d data with 3 clusters:
---------------------------------------------------------
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 13.444444444444445, 2.4444444444444446 ],
 [ 7.6, 7.5 ] ],
 iteration: 1,
 error: 1.878739816915397,
 didReachSteadyState: true }

程序的输出告诉我们,算法在仅经过两次迭代后(迭代 1 是第二次迭代,因为我们从零开始计数),并且我们的质心位于(2.8,3.9),(13.4,2.4),和(7.6,7.5)。

让我们绘制这些质心与原始数据,看看它看起来像什么:

如您所见,k-means 已经出色地完成了其工作,报告的质心正好在我们预期的位置。

让我们深入了解一下这个算法在解决方案之后做了什么,通过打印iterationLogs。将以下代码添加到index.js的底部:

console.log("Iteration log for 2d data with 3 clusters:");
console.log("------------------------------------------");
ex_1_solver.iterationLogs.forEach(log => console.log(log));
console.log("");

再次运行yarn start,你应该会看到以下输出。像往常一样,根据初始条件,你的版本可能需要比我的更多或更少的迭代,所以你的输出会有所不同,但你应该会看到类似的东西:

Solving example: 2d data with 3 clusters:
=====================================================================

 Solution for 2d data with 3 clusters:
 --------------------------------------------------------------------
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 13.444444444444445, 2.4444444444444446 ],
 [ 7.6, 7.5 ] ],
 iteration: 4,
 error: 1.878739816915397,
 didReachSteadyState: true }

 Iteration log for 2d data with 3 clusters:
 ----------------------------------------------------------------------
 { centroids: [ [ 2.7, 3.7 ], [ 9, 4.125 ], [ 10.75, 5.833333333333333 ] ],
 iteration: 0,
 error: 3.6193538404281806,
 didReachSteadyState: false }
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 9.714285714285714, 3.857142857142857 ],
 [ 10.75, 5.833333333333333 ] ],
 iteration: 1,
 error: 3.4964164297074007,
 didReachSteadyState: false }
 { centroids: [ [ 3.0833333333333335, 4.25 ], [ 11.375, 2.75 ], [ 10, 6.7 ] ],
 iteration: 2,
 error: 3.19709069137691,
 didReachSteadyState: false }
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 13.444444444444445, 2.4444444444444446 ],
 [ 7.6, 7.5 ] ],
 iteration: 3,
 error: 1.878739816915397,
 didReachSteadyState: false }
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 13.444444444444445, 2.4444444444444446 ],
 [ 7.6, 7.5 ] ],
 iteration: 4,
 error: 1.878739816915397,
 didReachSteadyState: true }

如您所见,算法经过五次迭代才达到稳定状态,而之前只有两次。这是正常的,也是预期的,因为两次运行之间的随机初始条件不同。查看日志,您可以看到算法报告的错误随着时间的推移而下降。注意,第一个质心(2.8,3.9)在第一次迭代后到达其最终位置,而其他质心则需要更多时间才能赶上。这是因为第一个质心被随机初始化到一个非常接近其最终位置的位置,从(2.7,3.7)开始,到(2.8,3.9)结束。

虽然可能性不大,但在该数据集上捕捉到算法陷入局部最优是有可能的。让我们将以下代码添加到index.js的底部,以多次运行解算器,看看它是否会找到局部最优而不是全局最优:

console.log("Test 2d data with 3 clusters five times:");
console.log("----------------------------------------");
for (let i = 0; i < 5; i++) {
    ex_1_solver.reset();
    const solution = ex_1_solver.solve();
    console.log(solution);
}
console.log("");

yarn start运行几次,直到你看到意外结果。在我的情况下,我找到了以下解决方案(我省略了前面程序的其他输出):

Test 2d data with 3 clusters five times:
--------------------------------------------------------------
 { centroids:
 [ [ 13.444444444444445, 2.4444444444444446 ],
 [ 2.8181818181818183, 3.909090909090909 ],
 [ 7.6, 7.5 ] ],
 iteration: 2,
 error: 1.878739816915397,
 didReachSteadyState: true }
 { centroids:
 [ [ 2.8181818181818183, 3.909090909090909 ],
 [ 7.6, 7.5 ],
 [ 13.444444444444445, 2.4444444444444446 ] ],
 iteration: 1,
 error: 1.878739816915397,
 didReachSteadyState: true }
 { centroids:
 [ [ 7.6, 7.5 ],
 [ 13.444444444444445, 2.4444444444444446 ],
 [ 2.8181818181818183, 3.909090909090909 ] ],
 iteration: 3,
 error: 1.878739816915397,
 didReachSteadyState: true }
 { centroids:
 [ [ 11.333333333333334, 2.3333333333333335 ],
 [ 5.095238095238095, 5.619047619047619 ],
 [ 14.5, 2.5 ] ],
 iteration: 2,
 error: 3.0171467652692345,
 didReachSteadyState: true }
 { centroids:
 [ [ 7.6, 7.5 ],
 [ 2.8181818181818183, 3.909090909090909 ],
 [ 13.444444444444445, 2.4444444444444446 ] ],
 iteration: 2,
 error: 1.878739816915397,
 didReachSteadyState: true }

解算器的第四次运行得到了与其他运行不同的答案:它发现了(11.3,2.3),(5.1,5.6),(14.5,2.5)作为解决方案。因为其他解决方案比这个更常见,我们可以假设算法已经陷入了局部最优。让我们将这些值与剩余的数据进行比较,看看它看起来像什么:

在前面的图表中,我们的数据点用圆圈表示,我们预期的质心用三角形表示,而我们得到的不寻常的结果用X标记表示。查看图表,你可以理解算法是如何得出这个结论的。一个质心,位于(5.1,5.6)的X标记,捕捉了两个不同的簇,并且位于它们之间。其他两个质心将第三个簇分成了两部分。这是一个完美的局部最优解的例子:这是一个有意义的解决方案,它逻辑上聚类了数据点,但它并不是数据最佳可用的解决方案(全局最优解)。很可能是右边的两个质心都在该簇内部随机初始化,并且被困在那里。

这总是 k-means 算法以及所有机器学习算法的一个潜在结果。基于初始条件和数据集的怪癖,算法可能会偶尔(甚至经常)发现局部最优解。幸运的是,如果你比较前面输出中的两个解决方案的误差,全局解决方案的误差为 1.9,局部最优解报告的误差为 3.0。在这种情况下,我们的误差计算已经很好地完成了工作,并正确地表示了聚类的紧密程度。

为了解决 k-means 算法的这个问题,你应该通常运行它多次,并寻找一致性(例如,五次运行中有四次同意),或者最小误差,并使用那个作为你的结果。

示例 2 – 3D 数据

因为我们已经编写了 k-means 算法来处理任意数量的维度,我们也可以用 3D 数据(或 10D,或 100D 或任何你需要的维度)来测试它。虽然这个算法可以处理超过三个维度,但我们无法可视地绘制高维,因此无法直观地检查结果——所以我们将用 3D 数据进行测试,然后继续。

打开data.js并在文件的中间添加以下内容——在任何export default行之前都可以:

const example_3d3k = [
    [1, 1, 1],
    [1, 2, 1],
    [2, 1, 2],
    [2, 2, 3],
    [2, 4, 3],
    [5, 4, 5],
    [5, 3, 4],
    [6, 2, 6],
    [5, 3, 6],
    [6, 4, 7],
    [9, 1, 4],
    [10, 2, 5],
    [9, 2, 5],
    [9, 2, 4],
    [10, 3, 3]
];

然后将导出行修改为如下所示(将example_3d3k变量添加到导出中):

export default {
    example_randomCentroids,
    example_2d3k,
    example_3d3k
};

前面的数据,当在三维中绘制时,看起来是这样的:

图片

如你所见,有三个清晰的簇,我们预计 k-means 可以轻松处理这个问题。现在,切换到index.js并添加以下内容。我们只是为这个例子创建了一个新的求解器,加载 3D 数据,并打印结果:

console.log("Solving example: 3d data with 3 clusters:");
console.log("===============================================\n");
console.log("Solution for 3d data with 3 clusters:");
console.log("-------------------------------------");
const ex_2_solver = new KMeans(3, example_data.example_3d3k);
const ex_2_centroids = ex_2_solver.solve();
console.log(ex_2_centroids);
console.log("");

使用yarn start运行程序,你应该看到以下内容。我已经省略了早期 2D 示例的输出:

Solving example: 3d data with 3 clusters:
====================================================================

Solution for 3d data with 3 clusters:
---------------------------------------------------------------------
 { centroids: [ [ 1.6, 2, 2 ], [ 5.4, 3.2, 5.6 ], [ 9.4, 2, 4.2 ] ],
 iteration: 5,
 error: 1.3266499161421599,
 didReachSteadyState: true }

幸运的是,我们的求解器给了我们 3D 数据点,因此我们知道,至少,算法可以区分二维和三维问题。我们看到它仍然只进行了少量迭代,并且误差是一个合理的数字(意味着它是定义的,不是负数,也不是太大)。

如果我们将这些质心与原始数据相对应,我们会看到以下情况:

图片

圆圈代表数据点,就像之前一样,现在我们可以看到我们的黑色菱形质心已经找到了它们簇的中间位置。我们的算法已经证明它可以用于三维数据,并且对于您给出的任何维度的数据,它都会同样有效。

当 k 未知时的 k-means

到目前为止,我们能够提前定义算法应该找到多少个簇。在每个示例中,我们开始项目时都知道我们的数据有三个簇,因此我们手动编程了 k 的值为 3。这仍然是一个非常有用的算法,但您可能并不总是知道您的数据中有多少个簇。为了解决这个问题,我们需要扩展 k-means 算法。

我在 k-means 实现中包含可选的错误计算的主要原因是为了帮助解决这个问题。在任何机器学习算法中使用错误度量——不仅允许我们寻找解决方案,还允许我们寻找产生最佳解决方案的最佳 参数

从某种意义上说,我们需要构建一个元机器学习算法,或者是一个修改我们算法及其参数的算法。我们的方法将简单但有效:我们将构建一个新的类,称为 KMeansAutoSolver,而不是指定一个 k 的值,我们将指定一个要测试的 k 值的范围。新的求解器将为范围内的每个 k 值运行我们的 k-means 代码,并确定哪个 k 值产生最低的错误。此外,我们还将对每个 k 值运行多次试验,以避免陷入局部最优解。

将一个名为 kmeans-autosolver.js 的文件添加到 src/ 文件夹中。将以下代码添加到该文件中:

import KMeans from './kmeans.js';

class KMeansAutoSolver {

    constructor(kMin = 1, kMax = 5, maxTrials = 5, data) {
        this.kMin = kMin;
        this.kMax = kMax;
        this.maxTrials = maxTrials;
        this.data = data;
        this.reset();
    }

    reset() {
        this.best = null;
        this.log = [];
    }

    solve(maxIterations = 1000) {

        for (let k = this.kMin; k < this.kMax; k++) {

            for (let currentTrial = 0; currentTrial < this.maxTrials; currentTrial++) {

                const solver = new KMeans(k, this.data);
                // Add k and currentTrial number to the solution before logging
                const solution = Object.assign({}, solver.solve(maxIterations), {k, currentTrial});
                this.log.push(solution);

                if (this.best === null || solution.error < this.best.error) {
                    this.best = solution;
                }

            }

        }

        return this.best;

    }
}

export default KMeansAutoSolver;

KMeansAutoSolver 类包含一个构造函数,该函数接受 kMinkMaxmaxTrialsdata 参数。data 参数与您提供给 KMeans 类的数据相同。您不是为类提供一个 k 的值,而是提供一个要测试的 k 值的范围,该范围由 kMinkMax 指定。此外,我们还将编程这个求解器为每个 k 值运行 k-means 算法多次,以避免找到局部最优解,正如我们之前所展示的。

类的主要部分是 solve 方法,与 KMeans 类一样,它也接受一个 maxIterations 参数。solve 方法返回与 KMeans 类相同的内容,除了我们还在输出中添加了 k 的值和 currentTrial 数量。将 k 的值添加到输出中有点冗余,因为您可以直接计算返回的质心的数量,但看到输出中的这个值还是不错的。

solve方法的主体很简单。对于kMinkMax之间的每个k我们运行KMeans求解器maxTrials. 如果解决方案在误差方面优于当前最佳解决方案,我们就将其记录为最佳解决方案。方法结束时,我们返回具有最佳(最低)误差的解决方案。

让我们试试看。打开data.js并添加以下内容:

const example_2dnk = [
 [1, 2], [1, 1], [2, 3], [2, 4], [3, 3],
 [4, 4], [2, 12], [2, 14], [3, 14], [4, 13],
 [4, 15], [3, 17], [8, 4], [7, 6], [7, 5],
 [8, 7], [9, 7], [9, 8], [8, 14], [9, 15],
 [9, 16], [10, 15], [10, 14], [11, 14], [10, 18]
];

并更新导出行如下:

export default {
    example_randomCentroids,
    example_2d3k,
    example_3d3k,
    example_2dnk
};

绘制这些数据,我们看到四个整齐的聚类:

然而,对于这个示例的目的,我们不知道预期有多少个聚类,只知道它可能在 1 到 5 之间。

接下来,打开index.js并在文件顶部导入KMeansAutoSolver

import KMeansAutoSolver from './kmeans-autosolver';

然后,在文件底部添加以下内容:

console.log("Solving example: 2d data with unknown clusters:");
console.log("===============================================\n");
console.log("Solution for 2d data with unknown clusters:");
console.log("-------------------------------------");
const ex_3_solver = new KMeansAutoSolver(1, 5, 5, example_data.example_2dnk);
const ex_3_solution = ex_3_solver.solve();
console.log(ex_3_solution);

运行yarn start,你应该看到类似以下输出(省略了之前的输出):

Solving example: 2d data with unknown clusters:
================================================================

Solution for 2d data with unknown clusters:
----------------------------------------------------------------
 { centroids:
 [ [ 2.1666666666666665, 2.8333333333333335 ],
 [ 9.571428571428571, 15.142857142857142 ],
 [ 8, 6.166666666666667 ],
 [ 3, 14.166666666666666 ] ],
 iteration: 2,
 error: 1.6236349578000828,
 didReachSteadyState: true,
 k: 4,
 currentTrial: 0 }

立即可以看到,求解器找到了k: 4的答案,这正是我们预期的,并且算法在仅三次迭代中就达到了稳态,误差值很低——这些都是好兆头。

将这些质点与我们的数据作图,我们看到算法已经确定了k的正确值和质心位置:

注意,我们的 k-means 自动求解器也容易受到局部最优的影响,并且不一定总能猜出k的正确值。原因?增加k的值意味着我们可以将更多的质心分配到数据中,并减少误差值。如果我们有 25 个数据点,并将k的范围设置为 1 到 30 之间,求解器最终可能会找到一个 k = 25 的解决方案,每个质心都位于每个单独的数据点上,总误差为 0!这可以被认为是过拟合,其中算法找到了正确的答案,但没有充分泛化问题以给出我们想要的结果。即使使用自动求解器,你也必须小心你给出的k值范围,并尽可能保持范围小。

例如,如果我们把前面的例子中的kMax从 5 增加到 10,我们会发现它给出了k = 7作为最佳结果。像往常一样,局部最优是有意义的,但它并不是我们真正想要的:

因为自动求解器只使用误差值计算作为其唯一指导,你可能能够调整误差计算,使其也考虑k的值,并惩罚具有过多聚类的解决方案. 之前的误差计算是一个纯粹几何计算,表示每个点与其质心的平均距离。我们可能希望升级我们的误差计算,使其也偏好具有较少质心的解决方案。让我们看看这将是什么样子。

返回kmeans.js文件并修改calculateError方法。找到以下行:

const thisDistance = distance(point, centroid);

并修改它以将k的值添加到距离中:

const thisDistance = distance(point, centroid) + this.k;

当单独运行 KMeans 类时,这种修改不会造成伤害,因为那个求解器的 k 值将是恒定的。唯一可能不希望这种修改的情况是,如果你实际上是在解释和使用误差值作为 距离 的表示,而不是仅仅寻找更低的误差值。这意味着,如果你 需要 误差成为距离的表示,那么你不应该进行这种修改。然而,在所有其他情况下,这可能是有益的,因为这种修改将倾向于具有更少簇的解。

现在,回到 index.js 并修改 ex_3_solver 以搜索从 1 到 30 的 k 值范围。再次使用 yarn start 运行程序,你会看到自动求解器再次正确地返回了 k = 4 的结果!虽然之前具有低误差率的局部最优解是 k = 7,但将 k 的值添加到误差中,使得求解器现在更倾向于 k = 4 的解。由于对误差计算的这种修改,我们在选择 kMinkMax 时可以稍微不那么小心,这在当我们不知道 k 会是多少时非常有帮助。

虽然我们的误差计算不再是簇紧度的几何表示,但你可以看到,在尝试优化某些系统属性时,对误差计算的深思熟虑可以给你很大的灵活性。在我们的例子中,我们不仅想要找到最紧密的几何簇,还想要找到尽可能少的簇数量的最紧密几何簇,因此将误差计算更新为考虑 k 是一个非常有益的步骤。

摘要

在本章中,我们讨论了聚类问题,即将数据点分组到逻辑上相似的组中。具体来说,我们介绍了 k-means 算法,这是机器学习中最流行的数值聚类算法。然后我们以 KMeans JavaScript 类的形式实现了 k-means 算法,并用二维和三维数据进行了测试。我们还讨论了在事先不知道所需簇的数量时如何处理聚类问题,并构建了一个新的 JavaScript 类 KMeansAutoSolver 来解决这个问题。在这个过程中,我们还讨论了误差计算的影响,并对我们的误差计算进行了修改,以帮助我们的解决方案泛化并避免过拟合。

在下一章中,我们将探讨分类算法。分类算法是监督学习算法,可以看作是聚类算法的更复杂扩展。与仅仅根据相似性或接近性对数据点进行分组不同,分类算法可以被训练来学习应该应用于数据的特定标签。

第五章:分类算法

分类问题涉及在数据中检测模式,并使用这些模式将数据点分配到一组相似的数据点中。如果这还不够具体,这里有一些分类问题的例子:分析一封电子邮件以确定它是否为垃圾邮件;检测一段文本的语言;阅读一篇文章并将其分类为财经、体育、政治、观点文章或犯罪;以及确定你在 Twitter 上发布的关于产品的评论是正面还是负面(这个最后的例子通常被称为情感分析)。

分类算法是解决分类问题的工具。根据定义,它们是监督学习算法,因为它们始终需要一个标记的训练集来构建模型。有许多分类算法,每个都是基于特定的原则设计的,或者针对特定类型的输入数据。

在本章中,我们将讨论四种分类器:k-最近邻KNN)、朴素贝叶斯、支持向量机SVMs)和随机森林。以下是每个算法的简要介绍:

  • KNN 算法是最简单的分类器之一,当你的数据集具有数值特征和聚类模式时,它表现得很好。在本质上,它与 k-means 聚类算法相似,因为它依赖于绘制数据点和测量点与点之间的距离。

  • 朴素贝叶斯分类器是基于贝叶斯概率的有效且通用的分类器。虽然它可以用于数值数据,但它最常用于文本分类问题,如垃圾邮件检测和情感分析。当正确实现时,朴素贝叶斯分类器对于狭窄领域既快又高度准确。朴素贝叶斯分类器是我首选的分类算法之一。

  • SVMs 在精神上是非常先进的 KNN 算法形式。SVM 绘制你的数据并试图找到你已标记的类别之间的分隔线。通过一些非平凡的数学方法,SVM 可以将非线性模式线性化,因此这个工具对于线性和非线性数据都有效。

  • 随机森林是分类算法中相对较新的发展,但它们既有效又灵活,因此许多研究人员(包括我自己)的首选分类器。随机森林构建了一个决策树集合(我们稍后将要讨论的另一种分类器),每个决策树都包含数据特征的一个随机子集。决策树可以处理数值和分类数据,它们可以执行回归和分类任务,并且还帮助进行特征选择,因此它们正成为许多研究人员面对新问题时首先抓取的工具。

k-最近邻

KNN 是一个简单、快速且直接的分类算法。它对于自然聚类的分类数值数据集非常有用。在某些方面,它将类似于 k-means 聚类算法,但主要区别在于 k-means 是一个无监督算法,而 KNN 是一个监督学习算法。

如果你手动执行 KNN 分析,过程如下:首先,将所有训练数据点绘制在图上,并给每个点标注其类别或标签。当你想要对一个新的、未知的数据点进行分类时,将其放在图上,并找到距离它最近的 k 个点(即 最近邻)。k 应该是一个奇数,以避免平局;3 是一个不错的起点,但某些应用可能需要更多,而某些应用则可以用 1 来完成。报告大多数 k 个最近邻被分类为何种类别,这将作为算法的结果。

找到测试点的 k 个最近邻是直接的,但如果你的训练数据非常大,则可以使用一些优化。通常,在评估一个新点时,你会计算它与每个其他训练点之间的欧几里得距离(我们在第四章[84fd2c4d-41b4-46c4-82e5-4d8e55bb0066.xhtml],使用聚类算法进行分组中介绍的高中几何距离度量),并按距离排序。这个算法相当快,因为训练数据通常不超过 10,000 个点。

如果你有很多训练示例(以百万计)或者你真的需要算法非常快,你可以进行两种优化。第一种是跳过距离度量中的平方根运算,而使用平方距离。虽然现代 CPU 非常快,但平方根运算仍然比乘法和加法慢得多,所以你可以通过避免平方根来节省几毫秒。第二种优化是只考虑距离测试点某个边界矩形内的点;例如,只考虑每个维度上距离测试点位置 +/- 5 个单位的点。如果你的训练数据密集,这种优化不会影响结果,但会加快算法速度,因为它将避免计算许多点的距离。

以下是对 KNN 算法的高级描述:

  1. 记录所有训练数据和它们的标签

  2. 给定一个要评估的新点,生成它到所有训练点的距离列表

  3. 按照从近到远的顺序对距离列表进行排序

  4. 丢弃除了 k 个最近距离之外的所有距离

  5. 确定哪个标签代表了你的 k 个最近邻中的大多数;这是算法的结果

一个更高效的版本通过限制距离列表只包含 k 项来避免维护一个需要排序的大距离列表。现在让我们编写我们自己的 KNN 算法实现。

构建 KNN 算法

由于 KNN 算法相当简单,我们将构建自己的实现:

  1. 创建一个新的文件夹,并将其命名为Ch5-knn

  2. 到该文件夹中添加以下package.json文件。请注意,这个文件与之前的示例略有不同,因为我们为jimp库添加了一个依赖项,这是一个我们将用于第二个示例的图像处理库:

{
  "name": "Ch5-knn",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 5 - k-nearest-neighbor",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "jimp": "⁰.2.28"
  }
}
  1. 运行yarn install命令以下载和安装所有依赖项,然后创建名为srcdistfiles的子文件夹。

  2. src文件夹内,创建一个index.js文件和一个knn.js文件。

你还需要一个data.js文件。对于这些示例,我使用了一个比这本书能打印的更大的数据集,所以你应该花一分钟时间从这个书的 GitHub 账户下载Ch5-knn/src/data.js文件。

让我们从knn.js文件开始。就像前一章中的 k-means 示例一样,我们需要一个距离测量函数。让我们使用来自第四章,使用聚类算法进行分组的函数;将以下内容添加到knn.js的开头:

/**
 * Calculate the distance between two points.
 * Points must be given as arrays or objects with equivalent keys.
 * @param {Array.<number>} a
 * @param {Array.<number>} b
 * @return {number}
 */
const distance = (a, b) => Math.sqrt(
    a.map((aPoint, i) => b[i] - aPoint)
        .reduce((sumOfSquares, diff) => sumOfSquares + (diff*diff), 0)
);

如果你真的需要对你的 KNN 实现进行性能优化,你可能在这里省略Math.sqrt操作,只返回平方距离。然而,我再次强调,由于这个算法本质上非常快,你应该只有在处理极端问题、大量数据或非常严格的速度要求时才需要这样做。

接下来,让我们添加我们的 KNN 类的骨架。将以下内容添加到knn.js中,在距离函数下方:

class KNN {

    constructor(k = 1, data, labels) {
        this.k = k;
        this.data = data;
        this.labels = labels;
    }

}

export default KNN;

构造函数接受三个参数:k或分类新点时考虑的邻居数量;将训练数据拆分为单独的数据点;以及它们对应标签的数组。

接下来,我们需要添加一个内部方法,该方法考虑一个测试点并计算从测试点到训练点的距离的排序列表。我们将称之为距离图。将以下内容添加到 KNN 类的主体中:

generateDistanceMap(point) {

    const map = [];
    let maxDistanceInMap;

    for (let index = 0, len = this.data.length; index < len; index++) {

        const otherPoint = this.data[index];
        const otherPointLabel = this.labels[index];
        const thisDistance = distance(point, otherPoint);

        /**
         * Keep at most k items in the map. 
         * Much more efficient for large sets, because this 
         * avoids storing and then sorting a million-item map.
         * This adds many more sort operations, but hopefully k is small.
         */
        if (!maxDistanceInMap || thisDistance < maxDistanceInMap) {

            // Only add an item if it's closer than the farthest of the candidates
            map.push({
                index,
                distance: thisDistance,
                label: otherPointLabel
            });

            // Sort the map so the closest is first
            map.sort((a, b) => a.distance < b.distance ? -1 : 1);

            // If the map became too long, drop the farthest item
            if (map.length > this.k) {
                map.pop();
            }

            // Update this value for the next comparison
            maxDistanceInMap = map[map.length - 1].distance;

        }
    }

    return map;
}

这个方法可能更容易阅读,但简单的版本对于非常大的训练集来说并不高效。我们在这里做的是维护一个可能包含 KNN 的点列表,并将它们存储在map中。通过维护一个名为maxDistanceInMap的变量,我们可以遍历每个训练点,进行简单的比较以确定该点是否应该添加到我们的候选列表中。如果我们正在迭代的点比我们的候选点中最远的点更近,我们可以将该点添加到列表中,重新排序列表,移除最远的点以保持列表较小,然后更新mapDistanceInMap

如果这听起来像是一项繁重的工作,一个更简单的版本可能会遍历所有点,将每个点及其距离测量值添加到映射中,对映射进行排序,然后返回前k个条目。这种实现的缺点是,对于一个包含一百万个点的数据集,你需要构建一个包含一百万个点的距离映射,然后在内存中对这个巨大的列表进行排序。在我们的版本中,你只需要始终保留k个候选项,因此你永远不需要存储一个单独的一百万点映射。我们的版本确实需要在将项目添加到映射时调用Array.sort。这种方式本身就不太高效,因为每次添加到映射时都会调用排序函数。幸运的是,排序操作仅针对k个项,其中k可能类似于 3 或 5。排序算法的计算复杂度很可能是O(n log n)(对于快速排序或归并排序实现),因此当k=3时,更复杂的版本在约 30 个数据点时比简单版本更高效,当k=5时,这种情况发生在大约 3,000 个数据点时。然而,两种版本都非常快,对于小于 3,000 个点的数据集,你不会注意到任何区别。

最后,我们将算法与一个predict方法结合起来。predict方法必须接受一个测试点,并且至少返回该点的确定标签。我们还将向该方法添加一些额外的输出,并报告k个最近邻的标签以及每个标签所贡献的投票数。

将以下内容添加到 KNN 类的主体中:

predict(point) {

    const map = this.generateDistanceMap(point);
    const votes = map.slice(0, this.k);
    const voteCounts = votes
        // Reduces into an object like {label: voteCount}
        .reduce((obj, vote) => Object.assign({}, obj, {[vote.label]: (obj[vote.label] || 0) + 1}), {})
    ;
    const sortedVotes = Object.keys(voteCounts)
        .map(label => ({label, count: voteCounts[label]}))
        .sort((a, b) => a.count > b.count ? -1 : 1)
    ;

    return {
        label: sortedVotes[0].label,
        voteCounts,
        votes
    };

}

这个方法在 JavaScript 中需要进行一些数据类型转换,但在概念上很简单。首先,我们使用我们刚刚实现的方法生成我们的距离映射。然后,我们移除所有数据,只保留k个最近点,并将这些数据存储在votes变量中。如果你使用k=3,那么votes将是一个长度为三的数组。

现在我们有了k个最近邻,我们需要确定哪个标签代表了大多数邻居。我们将通过将投票数组缩减成一个名为voteCounts的对象来完成这项工作。为了了解我们希望voteCounts看起来像什么,想象我们在寻找三个最近邻,可能的类别是MaleFemalevoteCounts变量可能看起来像这样:{"Female": 2, "Male": 1}

然而,我们的工作还没有完成——在将我们的投票汇总成一个投票计数对象之后,我们仍然需要对其进行排序并确定多数标签。我们通过将投票计数对象映射回一个数组,然后根据投票数对数组进行排序来完成这项工作。

有其他方法可以处理这个投票计数问题;任何你能想到的方法都可以工作,只要你在最后能够返回多数投票。我喜欢从结构和从一种结构转换到另一种结构所需的转换来思考数据,但只要你能报告最高票,算法就会工作。

knn.js文件中,我们只需要做这些。算法已经完成,只需要少于 70 行代码。

让我们设置index.js文件并准备运行一些示例。记住,你首先需要下载data.js文件——请参阅 Packt 的 GitHub 账户或我的个人 GitHub 账户github.com/bkanber/MLinJSBook

将以下内容添加到index.js的顶部:

import KNN from './knn.js';
import {weight_height} from './data.js';

让我们在几个简单的例子上尝试我们的算法。

示例 1 – 身高、体重和性别

KNN,就像 k-means 一样,可以处理高维数据——但是,就像 k-means 一样,我们只能在二维平面上绘制示例数据,所以我们将保持示例简单。我们将要解决的第一个问题是:我们能否仅根据一个人的身高和体重预测其生物性别?

我从这个例子中下载了一些数据,数据来自一项关于人们对自身体重感知的全国性纵向调查。数据中包括受访者的身高、体重和性别。以下是数据在图表中的样子:

图片

只需看一下前面的图表数据,你就可以感受到 KNN 在评估聚类数据时为什么如此有效。确实,男性和女性之间没有清晰的边界,但如果你要评估一个体重 200 磅、身高 72 英寸的新数据点,很明显,围绕该点的所有训练数据都是男性,你的新点也很可能是男性。相反,一个体重 125 磅、身高 62 英寸的新受访者已经进入了图表中的女性区域,尽管也有几个男性具有这些特征。图表的中间,大约在 145 磅和 65 英寸高,是最模糊的,男性和女性的训练点分布均匀。我预计算法在这个区域的新点将不确定。因为在这个数据集中没有清晰的分割线,我们需要更多的特征或更多的维度来获得更好的边界分辨率。

在任何情况下,让我们尝试几个例子。我们将选择五个我们预期肯定是男性、肯定是女性、可能是男性、可能是女性和无法确定的点。将以下代码添加到index.js文件中,在两个导入行下面:

console.log("Testing height and weight with k=5");
console.log("==========================");

 const solver1 = new KNN(5, weight_height.data, weight_height.labels);

 console.log("Testing a 'definitely male' point:");
 console.log(solver1.predict([200, 75]));
 console.log("\nTesting a 'probably male' point:");
 console.log(solver1.predict([170, 70]));
 console.log("\nTesting a 'totally uncertain' point:");
 console.log(solver1.predict([140, 64]));
 console.log("\nTesting a 'probably female' point:");
 console.log(solver1.predict([130, 63]));
 console.log("\nTesting a 'definitely female' point:");
 console.log(solver1.predict([120, 60]));

在命令行中运行yarn start,你应该看到以下输出。由于 KNN 不是随机的,这意味着它在评估时没有使用任何随机条件,你应该看到与我完全相同的输出——除非两个投票有相同的距离,否则投票顺序和它们的索引可能会有所不同。

如果你运行yarn start时出现错误,请确保你的data.js文件已经正确下载并安装。

下面是前面代码的输出:

Testing height and weight with k=5
======================================================================

 Testing a 'definitely male' point:
 { label: 'Male',
 voteCounts: { Male: 5 },
 votes:
 [ { index: 372, distance: 0, label: 'Male' },
 { index: 256, distance: 1, label: 'Male' },
 { index: 291, distance: 1, label: 'Male' },
 { index: 236, distance: 2.8284271247461903, label: 'Male' },
 { index: 310, distance: 3, label: 'Male' } ] }

 Testing a 'probably male' point:
 { label: 'Male',
 voteCounts: { Male: 5 },
 votes:
 [ { index: 463, distance: 0, label: 'Male' },
 { index: 311, distance: 0, label: 'Male' },
 { index: 247, distance: 1, label: 'Male' },
 { index: 437, distance: 1, label: 'Male' },
 { index: 435, distance: 1, label: 'Male' } ] }

 Testing a 'totally uncertain' point:
 { label: 'Male',
 voteCounts: { Male: 3, Female: 2 },
 votes:
 [ { index: 329, distance: 0, label: 'Male' },
 { index: 465, distance: 0, label: 'Male' },
 { index: 386, distance: 0, label: 'Male' },
 { index: 126, distance: 0, label: 'Female' },
 { index: 174, distance: 1, label: 'Female' } ] }

 Testing a 'probably female' point:
 { label: 'Female',
 voteCounts: { Female: 4, Male: 1 },
 votes:
 [ { index: 186, distance: 0, label: 'Female' },
 { index: 90, distance: 0, label: 'Female' },
 { index: 330, distance: 0, label: 'Male' },
 { index: 51, distance: 1, label: 'Female' },
 { index: 96, distance: 1, label: 'Female' } ] }

 Testing a 'definitely female' point:
 { label: 'Female',
 voteCounts: { Female: 5 },
 votes:
 [ { index: 200, distance: 0, label: 'Female' },
 { index: 150, distance: 0, label: 'Female' },
 { index: 198, distance: 1, label: 'Female' },
 { index: 147, distance: 1, label: 'Female' },
 { index: 157, distance: 1, label: 'Female' } ] }

该算法已经确定了性别,就像我们通过查看图表进行视觉判断一样。您可以随意玩转这个例子,并尝试不同的 k 值,看看结果可能会有何不同。

现在我们来看一个 KNN 实际应用的第二个例子。这次,我们将选择一个 k = 1 真正发光的问题。

示例 2 – 去色照片

KNN 算法非常容易受到局部噪声的影响,当预期类别之间存在大量重叠时,它并不很有用。它通常对更高级的任务,如心理分析、人口统计或行为分析,并不很有用。但它是您工具箱中一个非常有用的工具,因为它可以很容易地帮助完成底层任务。

在这个例子中,我们将使用我们的 KNN 类去色照片。具体来说,我们将从彩色输入照片中提取,并限制它们只使用 16 种颜色方案。我们将使用 KNN 来选择给定像素的适当替代颜色。

我们的工作流程将如下所示:

  1. 使用 jimp 库读取输入图像

  2. 遍历图像中的每个像素:

    1. 在我们的 16 种颜色方案中找到最相似的颜色

    2. 用新颜色替换那个像素

  3. 基于十六色方案写入一个新输出文件

在我们开始之前,验证 以下内容是否存在于您的 data.js 文件中。如果您从 GitHub 下载了这本书的 data.js 文件,那么它应该已经在那里了。然而,如果您从其他地方获取了性别调查数据,您需要在 data.js 文件中包含以下内容:

export const colors_16 = {
 data: [
 [0, 0, 0], // black
 [128, 128, 128], // gray
 [128, 0, 0], //maroon
 [255, 0, 0], // red
 [0, 128, 0], // green
 [0, 255, 0], // lime
 [128, 128, 0], // olive
 [255, 255, 0], // yellow
 [0, 0, 128], // navy
 [0, 0, 255], // blue
 [128, 0, 128], // purple
 [255, 0, 255], // fuchsia
 [0, 128, 128], // teal
 [0, 255, 255], // aqua
 [192, 192, 192], // silver
 [255, 255, 255], // white
 ],

 labels: [
 'Black',
 'Gray',
 'Maroon',
 'Red',
 'Green',
 'Lime',
 'Olive',
 'Yellow',
 'Navy',
 'Blue',
 'Purple',
 'Fuchsia',
 'Teal',
 'Aqua',
 'Silver',
 'White',
 ]
 };

上述颜色定义代表一个常见的 16 种颜色方案。您也可以自己尝试不同的颜色方案;您可以使用这种方法将图像着色为蓝色调、暖色调或棕褐色调等。您还可以通过增加训练数据的大小来允许远超过 16 种颜色。

让我们先编写几个辅助函数。在 src 文件夹中创建一个名为 decolorize.js 的新文件。确保您已将 jimp 添加到您的 package.json 中——如果您不确定,请在命令行中运行 yarn add jimp。将以下导入添加到文件顶部:

import KNN from './knn.js';
import {colors_16} from './data.js';
import jimp from 'jimp'

然后,创建并导出一个函数,该函数接受一个图像文件名并写入一个去色图像的新文件。我在代码片段中留下了一些温和的注释,描述了工作流程;大部分代码只是处理数据格式。一般来说,我们的方法是打开并读取输入文件,遍历所有像素,使用 KNN 为该像素找到一个替代颜色,将新颜色写入像素,然后最终使用修改后的颜色写入一个新输出文件:

const decolorize = filename => {

  return jimp.read(filename)
    .then(image => {

      // Create a KNN instance with our color scheme as training data
      // We use k=1 to find the single closest color
      // k > 1 wouldn't work, because we only have 1 label per training point
      const mapper = new KNN(1, colors_16.data, colors_16.labels);
      const {width, height} = image.bitmap;

      // For every pixel in the image...
      for (let x = 0; x < width; x++) {
      for (let y = 0; y < height; y++) {

      // Do some work to get the RGB value as an array: [R,G,B]
      const originalColorHex = image.getPixelColor(x, y);
      const originalColorRgb = jimp.intToRGBA(originalColorHex);
      const pixelPoint = [originalColorRgb.r, originalColorRgb.g, originalColorRgb.b];

      // Ask the KNN instance what the closest color from the scheme is
      const closestColor = mapper.predict(pixelPoint);

      // Then get that color in hex format, and set the pixel to the new color
      const newColor = colors_16.data[colors_16.labels.indexOf(closestColor.label)];
      const newColorHex = jimp.rgbaToInt(newColor[0], newColor[1], newColor[2], 255);
      image.setPixelColor(newColorHex, x, y);

    }
  }

  const ext = image.getExtension();
  image.write(filename.replace('.'+ext, '') + '_16.' + ext);

  })
  .catch(err => {
    console.log("Error reading image:");
    console.log(err);
  })
};

export default decolorize

我们现在有一个函数,它将接受一个文件名并创建一个新的去色照片。如果你还没有创建,请在Ch5-knn目录下创建一个名为files的文件夹。找到一些你喜欢的图片并将它们添加到files文件夹中。或者,你可以使用书中 GitHub 上的图像示例,它们是landscape.jpeglily.jpegwaterlilies.jpeg

最后,打开index.js文件,并将以下内容添加到文件底部:

['landscape.jpeg', 'lily.jpeg', 'waterlilies.jpeg'].forEach(filename => {
  console.log("Decolorizing " + filename + '...');
  decolorize('./files/' + filename)
    .then(() => console.log(filename + " decolorized"));
});

如果你正在使用自己的示例文件,请确保更新前面代码中显示的粗体文件名。

使用yarn start运行代码,你应该会看到以下输出(你的输出中可能还有其他 KNN 实验的结果):

 Decolorizing images
 =======================================================
 Decolorizing landscape.jpeg...
 Decolorizing lily.jpeg...
 Decolorizing waterlilies.jpeg...
 lily.jpeg decolorized
 waterlilies.jpeg decolorized
 landscape.jpeg decolorized

如果有任何关于文件名或权限的错误,请解决它们。在files文件夹中查找你的新照片。我不知道你用哪种格式阅读这本书,以及这些图片将如何显示给你,但以下是我的landscape.jpeg文件,原始和处理的。

原始:

去色版本:

我认为它在前景和风景方面做得非常好,然而,有限的调色板无疑影响了背景中的天空、水和山脉。尝试向训练数据中添加 8 或 16 种颜色,看看会发生什么。

我喜欢这个项目作为 KNN 示例,因为它表明机器学习ML)算法并不总是用于复杂分析。其中许多可以作为你日常工具箱的一部分使用,用较小的模型训练,帮助你处理更简单的数据处理任务。

我还应该在这里记一笔关于测量颜色之间距离的事情。我们采用的方法,即使用欧几里得距离公式来测量 RGB 值之间的距离,在感知上并不准确。当涉及到人类视觉感知时,RGB 空间略有扭曲,因此我们的欧几里得距离测量并不完全准确。就我们的目的而言,它们已经足够接近了,因为我们正在降低到非常低的分辨率。如果你需要进行感知上准确的照片处理,你可能需要将所有 RGB 值转换到一个更准确的颜色空间,例如Lab,或者更新你的距离函数来测量感知距离,而不仅仅是点之间的几何距离。

让我们从 KNN 转向一个更复杂的方法来分类对象,基于几个世纪前的概率理论,至今仍然强大:贝叶斯分类。

天真贝叶斯分类器

天真贝叶斯分类器是一种概率分类器,或者是一种将概率分布分配给潜在结果的算法。与MaleFemale这样的二进制分类不同,概率分类器会告诉你这个数据点有 87%的概率是Male,有 13%的概率是Female

并非所有概率分类器都是贝叶斯分类器,它们也不一定都是朴素分类器。在这个上下文中,“朴素”一词并不是对分类器的隐晦侮辱——它是一个在概率论中有特定意义的数学术语,我们稍后会进一步讨论。术语“贝叶斯”或“贝叶斯派”意味着分类器中使用的原理最初是由 18 世纪的数学家托马斯·贝叶斯牧师发表的,他因其在概率论中的贝叶斯定理而闻名。

让我们先来复习一下概率。首先,你应该知道概率可以与连续分布离散分布一起工作。连续分布是变量是一个数字,可以具有任何值的分布。离散分布只有有限数量的可能状态,即使这个数量很大。连续值是像每周 54.21 分钟的活动;每股 23.34 美元;总共 18 次登录这样的东西。离散值是真/假好莱坞八卦政治体育本地事件,或者世界新闻,甚至是文章中单个单词的频率。概率论中的大多数定理都可以用于连续和离散分布,尽管两者之间的实现细节会有所不同。

在我们例子中使用的离散概率中,你处理的是各种事件发生的概率。事件是从实验中可能得到的结果的集合。这个经典的说明性例子涉及一副扑克牌;想象你从洗好的牌堆中随机抽取一张牌。你抽到的牌是红心的概率是多少?当我们提出这个问题时,我们是在询问某个事件的概率,具体来说是这张牌是红心的概率。我们可以给我们的事件一个标签,比如用H表示红心,然后我们可以将短语“这张牌是红心的概率”简短地表示为P(H)。答案是 13/52,或者 1/4,或者 0.25,所以你也可以说P(H) = 0.25。在我们的场景中还有许多其他可能的事件。抽到方块 5 的概率是多少?抽到黑桃的概率是多少?抽到人头牌的概率是多少?数值小于 5 的概率是多少?所有这些都是事件类型,每个事件都有其自己的概率。

并非所有事件都是独立的。例如,假设实验是“你昨天喝了汽水吗?”,并且我们在调查美国人。我们可以定义事件S为“昨天喝了汽水”。通过调查美国所有人(或者至少是一个代表性样本),我们发现近 50%的受访者表示是的!(根据耶鲁大学的数据,实际上是 48%)所以我们可以这样说,事件S的概率是 50%,或者P(S) = 0.5。我们还可以定义一个事件为S',这是事件“昨天没有喝汽水”的概率,或者其逆事件。

我们希望更深入地了解公民的饮食习惯,所以我们向调查中添加了另一个问题:你昨天在快餐店吃饭了吗?我们将这个事件命名为M,代表麦当劳,我们发现P(M) = 0.25,即全国的四分之一。

我们现在可以问更复杂的问题,例如:吃快餐是否会影响人们喝汽水?我们可以询问在昨天吃了快餐的情况下,某人喝了汽水的概率。这被称为S事件在M条件下的条件概率,即P(S|M)

如果我们在同一份调查中询问关于喝汽水和吃快餐的问题,那么我们可以通过找到同时进行这两个事件的受访者的概率(这写成P(S ∩ M),发音为概率 S 交集 M),然后除以P(M)来计算P(S|M)。完整的公式是P(S|M) = P(S ∩ M) / P(M)

假设有 20%的受访者既喝了汽水又吃了快餐。我们现在可以计算出P(S|M) = 0.2 / 0.25 = 0.8。如果你昨天吃了快餐,那么昨天喝了汽水的概率是 80%。

注意,这不是你在吃快餐时喝了汽水的概率。要回答这个问题,你必须去快餐店并对那里的人进行调查。我们的版本在因果关系方面承诺较少。

现在你想问相反的问题:如果某人昨天喝了汽水,那么他们昨天吃快餐的概率是多少?这是在询问P(M|S)。我们本可以直接反转前面的公式,但假设我们失去了原始的调查数据,无法再确定P(S ∩ M)

我们可以使用贝叶斯定理来正确地反转我们的概率:

P(M|S) = P(S|M) * P(M) / P(S)

幸运的是,我们记得这三个值,并发现:

P(M|S) = 0.8 * 0.25 / 0.5 = 0.4

知道某人昨天喝了汽水,那么他们昨天吃快餐的概率是 40%。这比任何吃快餐的人的基准概率 25%要高。

这如何应用于朴素贝叶斯分类器?我们使用前面的条件概率定理将特征与其相应的类别联系起来。在垃圾邮件过滤器中,我们提出这样的问题:这个文档包含单词credit时,它是垃圾邮件的概率是多少?以及这个文档包含单词transfer时,它是垃圾邮件的概率是多少?我们对文档中的每个单词都提出这样的问题,然后我们将这些概率结合起来,得到文档是垃圾邮件的整体概率。朴素贝叶斯分类器之所以被称为朴素,是因为它假设所有事件都是独立的。实际上,这是一个错误的假设。包含单词credit的电子邮件更有可能也包含单词transfer,但在实践中,这些分类器仍然非常准确,尽管存在错误的假设。

分词

我们还必须简要讨论一下分词的概念。我们将在第十章“实践中的自然语言处理”中深入讨论分词,当我们讨论自然语言编程时,但我们现在确实需要对其有一个简短的介绍。分词是将文档分解成单个分词的行为。你可以把分词想象成单词,但并非所有单词都是分词,也不是所有分词都是单词。

最简单的分词器可能是通过空格分割文档。结果将是一个包含单词、它们的字母大小写和标点符号的数组。一个稍微高级一点的分词器可能会将所有内容转换为小写并删除任何非字母数字字符。现在,所有分词都是小写单词、数字以及包含数字的单词。你的分词器可以删除常见单词,如“和”和“的”——这被称为停用词过滤。你还可以在分词器中实现词干提取,即从单词中删除不必要的结尾。例如,“parties”、“partied”和“party”都可能变成“parti”。这是一个很好的降维技术,有助于你的分类器关注单词的意义而不是特定的时态或用法。你可以通过词形还原更进一步,这与词干提取类似,但实际上是将单词语法上转换为它们的词根形式,因此“running”、“runs”和“ran”都会变成“run”。

分词可以采取更高级的形式。一个分词不一定是单个单词;它可以是单词的对或三联组。这些分别被称为二元组三元组。分词也可以从元数据生成。特别是电子邮件垃圾邮件过滤器,当将消息头中的某些信息作为分词包含在内时,表现非常好:电子邮件是否通过了 SPF 检查,是否有有效的 DKIM 密钥,发送者的域名等等。分词器还可以修改某些字段中的分词;例如,发现给电子邮件主题行中的分词加上前缀(与正文内容相反)可以提高垃圾邮件过滤性能。与其将“现在购买药品”分词为“购买”、“药品”、“现在”,不如将其分词为“SUBJ_buy”、“SUBJ_pharmaceuticals”、“SUBJ_now”。这种前缀的效果是允许分类器分别考虑主题和正文中的单词,这可能会提高性能。

不要低估分词器的重要性。通常,通过深思熟虑地选择分词器算法,你可以获得显著的准确率提升。在这个例子中,我们将使用一个简单、直观但仍相当有效的分词器。

构建算法

现在让我们构建朴素贝叶斯分类器。以下是构建算法需要遵循的步骤:

  1. 为项目创建一个名为Ch5-Bayes的新文件夹。像往常一样,创建srcdatadist文件夹,并添加以下package.json文件:
{
 "name": "Ch5-Bayes",
 "version": "1.0.0",
 "description": "ML in JS Example for Chapter 5 - Bayes",
 "main": "src/index.js",
 "author": "Burak Kanber",
 "license": "MIT",
 "scripts": {
 "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
 "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
 "start": "yarn build-cli && node dist/index.js"
 },
 "dependencies": {
 "babel-core": "⁶.26.0",
 "babel-plugin-transform-object-rest-spread": "⁶.26.0",
 "babel-preset-env": "¹.6.1",
 "babelify": "⁸.0.0",
 "browserify": "¹⁵.1.0"
 }
 }
  1. 一旦添加了package.json文件,请在命令行中运行yarn install来安装所有项目依赖项。

  2. 导航到书籍的 GitHub 账户,并在data文件夹中下载四个文件。这些文件应命名为train_negative.txttrain_positive.txttest_negative.txttest_positive.txt。这些文件包含来自www.imdb.com/的电影评论,并使用 IMDB 的星级评分系统预先分类为正面评论和负面评论。我们将使用这些数据来训练并验证一个检测电影评论情感的算法。

  3. src文件夹中创建一个bayes.js文件。将以下分词器函数添加到文件顶部:

export const simpleTokenizer = string => string
 .toLowerCase()
 .replace(/[^\w\d]/g, ' ')
 .split(' ')
 .filter(word => word.length > 3)
 .filter((word, index, arr) => arr.indexOf(word, index+1) === -1);

此函数接受一个字符串作为输入,并返回一个标记数组作为输出。首先将字符串转换为小写,因为我们的分析是区分大小写的。然后删除任何非单词或数字字符,并用空格替换。通过空格分割字符串以获取标记数组。接下来,过滤掉任何长度为三个字符或更短的标记(因此会移除像thewas这样的单词,而像thisthat这样的单词会被保留)。分词器的最后一行过滤掉非唯一标记;我们只考虑文档中单词的存在,而不是这些单词的使用次数。

注意,分词器中的filter函数不会保留单词顺序。为了保留单词顺序,你需要在最后的过滤行前后添加.reverse()。然而,我们的算法不考虑单词顺序,因此保留它是不必要的。

  1. 创建BayesClassifier类并将其从bayes.js文件中导出。将以下内容添加到文件中:
class BayesClassifier {

 constructor(tokenizer = null) {
 this.database = {
 labels: {},
 tokens: {}
 };

 this.tokenizer = (tokenizer !== null) ? tokenizer : simpleTokenizer;
 }
 }

 export default BayesClassifier;

分类器的构造函数只接受一个tokenizer函数,但是默认为我们创建的简单分词器。将分词器配置为可配置的,这样你就可以尝试适合你特定数据集的更好的分词器。

训练朴素贝叶斯分类器是一个简单的过程。首先,简单地计算你看到的每个类别的文档数量。如果你的训练集包含 600 条正面电影评论和 400 条负面电影评论,那么你应该分别有 600 和 400 作为你的文档计数。接下来,对要训练的文档进行分词。你必须始终确保在训练期间使用与评估期间相同的分词器。对于训练文档中的每个标记,记录你在该类别中所有文档中看到该标记的次数。例如,如果你的训练数据有 600 条正面电影评论,而单词beautiful出现在其中的 100 条,那么你需要在positive类别中为标记beautiful维护一个计数为 100。如果标记beautiful在你的负面评论训练数据中只出现了三次,那么你必须单独维护这个计数。

让我们将这个翻译成代码。这是一个非常简单的操作,但我们也在将工作分配给许多小的计数和递增函数;我们将在评估阶段也使用这些计数函数:

/**
 * Trains a given document for a label.
 * @param label
 * @param text
 */
train(label, text) {
  this.incrementLabelDocumentCount(label);
  this.tokenizer(text).forEach(token => this.incrementTokenCount(token, label));
}

 /**
 * Increments the count of documents in a given category/label
 * @param label
 */
incrementLabelDocumentCount(label) {
  this.database.labels[label] = this.getLabelDocumentCount(label) + 1;
}

 /**
 * Returns the number of documents seen for a given category/label.
 * If null is passed as the label, return the total number of training documents seen.
 * @param label
 */
getLabelDocumentCount(label = null) {
  if (label) {
    return this.database.labels[label] || 0;
  } else {
    return Object.values(this.database.labels)
      .reduce((sum, count) => sum + count, 0);
  }
}

 /**
 * Increment the count of a token observed with a given label.
 * @param token
 * @param label
 */
incrementTokenCount(token, label) {
  if (typeof this.database.tokens[token] === 'undefined') {
    this.database.tokens[token] = {};
  }

  this.database.tokens[token][label] = this.getTokenCount(token, label) + 1;
}

 /**
 * Get the number of times a token was seen with a given category/label.
 * If no label is given, returns the total number of times the token was seen
 * across all training examples.
 * @param token
 * @param label
 * @returns {*}
 */
getTokenCount(token, label = null) {
  if (label) {
    return (this.database.tokens[token] || {})[label] || 0;
  } else {
    return Object.values(this.database.tokens[token] || {})
      .reduce((sum, count) => sum + count, 0);
  }
}

如您所见,train()方法相当简单:增加给定标签的文档计数(例如,垃圾邮件非垃圾邮件正面情感负面情感);然后,对于文档中的每个标记,增加给定标签的标记计数(例如,beautiful在正面情感文档中出现了 100 次,在负面情感文档中出现了 3 次)。这些计数保存在BayesClassifier类的一个实例变量this.database中。

为了对新文档进行预测,我们需要单独考虑我们在训练过程中遇到的每个标签,计算该标签的概率,并返回最可能的标签。让我们从实现预测的反向工作开始;我们首先添加predict方法,然后反向工作,填充我们需要的所有其他方法。

首先,将以下predict方法添加到BayesClassifier类中:

/**
 * Given a document, predict its category or label.
 * @param text
 * @returns {{label: string, probability: number, probabilities: array}}
 */
predict(text) {
  const probabilities = this.calculateAllLabelProbabilities(text);
  const best = probabilities[0];

  return {
    label: best.label,
    probability: best.probability,
    probabilities
  };

}

此方法接受一个输入字符串或文档,并返回一个result对象,其中包含最可能的标签或类别,该标签或类别的概率,以及训练过程中遇到的所有标签的概率数组。

接下来,添加predict方法所依赖的方法,用于计算输入文档中每个标签的概率:

/**
 * Given a document, determine its probability for all labels/categories encountered in the training set.
 * The first element in the return array (element 0) is the label/category with the best match.
 * @param text
 * @returns {Array.<Object>}
 */
calculateAllLabelProbabilities(text) {
  const tokens = this.tokenizer(text);
  return this.getAllLabels()
    .map(label => ({
      label,
      probability: this.calculateLabelProbability(label, tokens)
    }))
    .sort((a, b) => a.probability > b.probability ? -1 : 1);
}

此方法将输入文本进行分词,然后生成一个包含所有标签及其概率的数组,按最可能到最不可能的顺序排序。现在您需要将这两个方法添加到类中——首先,是简单的getAllLabels()方法:

/**
 * Get all labels encountered during training.
 * @returns {Array}
 */
getAllLabels() {
  return Object.keys(this.database.labels);
}

然后添加更复杂的calculateLabelProbability函数,该函数负责计算单个标签适合文档的概率:

/**
 * Given a token stream (ie a tokenized document), calculate the probability that
 * this document has a given label.
 * @param label
 * @param tokens
 * @returns {number}
 */
calculateLabelProbability(label, tokens) {

  // We assume that the a-priori probability of all labels are equal.
  // You could alternatively calculate the probability based on label frequencies.
  const probLabel = 1 / this.getAllLabels().length;

  // How significant each token must be in order to be considered;
  // Their score must be greater than epsilon from the default token score
  // This basically filters out uninteresting tokens from consideration.
  // Responsible for 78% => 87.8% accuracy bump (e=.17) overall.
  const epsilon = 0.15;

  // For each token, we have to calculate a "token score", which is the probability of this document
  // belonging to a category given the token appears in it.
  const tokenScores = tokens
    .map(token => this.calculateTokenScore(token, label))
    .filter(score => Math.abs(probLabel - score) > epsilon);

 // To avoid floating point underflow when working with really small numbers,
 // we add combine the token probabilities in log space instead.
 // This is only used because of floating point math and should not affect the algorithm overall.
  const logSum = tokenScores.reduce((sum, score) => sum + (Math.log(1-score) - Math.log(score)), 0);
  const probability = 1 / (1 + Math.exp(logSum));

  return probability;
}

calculateLabelProbability方法中的内联注释说明了该方法的具体工作方式,但这一步的基本目标是计算文档中每个标记的概率,然后将单个标记概率组合成一个标签的整体概率。

例如,如果一部电影评论说beautiful [but] awful garbage,这个方法负责查看所有标记(but被分词器省略)并确定它们与给定标签(例如,正面负面)的匹配程度。

让我们假设我们正在为 正面 类别标签运行这个方法。单词 beautiful 会得到一个强烈的分数,可能是 90%,但标记 awfulgarbage 都会得到弱的分数,例如 5%。这种方法会报告说,对于这个文档,正面 标签的概率很低。另一方面,当这个方法为 负面 类别标签运行时,beautiful 标记得到一个低的分数,但 awfulgarbage 都得到高的分数,所以该方法会返回文档为负面的高概率。

这种方法涉及一些技巧。第一个技巧是准确性增强。如果一个标记是模糊的(例如像 thatmovie 这样的词,它适用于所有类别),它就会被从考虑中移除。我们通过过滤掉接近 50% 的标记分数来实现这一点;具体来说,我们忽略所有分数在 35-65% 之间的标记。这是一个非常有效的技术,可以提高大约 10% 的准确性。它之所以工作得很好,是因为它过滤掉了那些边缘标记中的噪声。如果单词 movie 有一个正面的分数 55%,但它通常出现在正面和负面的文档中,它会使所有文档都偏向正面类别。我们的方法是不考虑那些最具影响力的标记。

第二个技巧是我们的对数和技巧。通常,将单个单词或标记概率组合成整体概率的方法如下——假设你已经有一个名为 tokenScores 的数组变量:

const multiplyArray = arr => arr.reduce((product, current) => current * product, 1);
const tokenScores = []; // array of scores, defined elsewhere
const inverseTokenScores = tokenScores.map(score => 1 - score);
const combinedProbability = multiplyArray(tokenScores) / (multiplyArray(tokenScores) + multiplyArray(inverseTokenScores));

换句话说,假设你有一些称为 p1p2p3、... pN 的单个标记的概率;获取所有这些标记的联合概率的方法是:

p = (p1 * p2 * p3 * ... pN) / ( (p1 * p2 * p3 * ... pN) + (1-p1 * 1-p2 * 1-p3 * ... 1-pN) )

这种方法在处理小的浮点数时有一些问题。如果你开始将小的浮点数相互相乘,你可能会得到非常小的数,以至于浮点数学无法处理它,这会导致 浮点下溢,或者在 JavaScript 中是 NaN。解决方案是将这个计算转换为对数空间,并通过添加每个概率的自然对数值来管理整个计算,并在最后移除对数。

拼图的最后一块是生成给定标签的每个单个标记的概率。这正是贝叶斯定理真正发挥作用的地方。我们寻找的是类似于 P(L|W) 的概率,或者说是给定一个 单词标签 的概率。我们需要为文档中的每个标记以及我们考虑的每个标签计算这个概率。然而,我们手头没有 P(L|W) 的值,所以我们可以使用贝叶斯定理来得到一个等价的表达式:

P(L|W) = P(W|L)P(L) / P(W|L)P(L) + P(W|L')P(L')

这可能看起来很复杂,但实际上并不糟糕。我们正在将P(L|W)的目标转化为更容易计算的概率,例如P(W|L)(给定标签时单词出现的概率,或在该标签中的频率)和P(L)(任何给定标签的概率)。分母也使用了逆概率,P(W|L')(单词出现在任何其他标签中的概率)和P(L')(任何其他标签的概率)。

我们进行这种转换是因为我们可以在训练过程中通过计数 token 和标签来获得单词频率;我们不需要记录哪些 token 出现在哪些文档中,我们可以保持我们的数据库简单且快速。

之前提到的表达式就是我们所说的token score,或者说是给定文档中包含一个单词时,文档具有某个标签的概率。为了使问题更加具体,我们可以提出这样的问题:P("positive review" | "beautiful"),或者说是给定单词 beautiful 时,文档是正面电影评论的概率。

如果评论是正面或负面的概率各占 50%,并且我们在 10%的正面评论中看到了单词beautiful,而在 1%的负面评论中只看到了它,那么我们的P(L|W)概率大约是 91%。(这个计算的公式是(0.1 * 0.5) / ( (0.1 * 0.5) + (0.01 * 0.5) ),使用前面的公式。)你可以将这个 91%的数字理解为单词beautiful积极性。通过以这种方式分析文档中的所有单词,我们可以将它们的积极性分数结合起来,得到一个文档是正面的整体概率。这同样适用于任何类型的分类,无论是正面/负面电影评论,垃圾邮件/非垃圾邮件,还是英语/法语/西班牙语语言检测。

在计算 token 分数时,我们还需要考虑另一件事。如果我们以前从未见过一个 token,或者我们只见过它一两次,我们应该怎么办?对我们来说,最好的方法是调整我们计算出的 token 分数的加权平均值;我们希望加权平均,使得罕见单词的分数接近 50/50。

让我们实现前面提到的所有逻辑。这个方法可能比较长,但正如你所见,大部分工作只是简单地获取我们需要计算的各种变量的正确计数。我们还定义了一个强度用于罕见单词加权;我们将强度定义为三,这意味着我们必须看到这个 token 三次,它才能具有与默认的 50/50 加权等效的权重:

 /**
 * Given a token and a label, calculate the probability that
 * the document has the label given that the token is in the document.
 * We do this by calculating the much easier to find Bayesian equivalent:
 * the probability that the token appears, given the label (the word frequency in that category).
 * This method also adjusts for rare tokens.
 * @param token
 * @param label
 * @returns {number}
 */
calculateTokenScore(token, label) {
  const rareTokenWeight = 3;

  const totalDocumentCount = this.getLabelDocumentCount();
  const labelDocumentCount = this.getLabelDocumentCount(label);
  const notLabelDocumentCount = totalDocumentCount - labelDocumentCount;

  // Assuming equal probabilities gave us 1% accuracy bump over using the frequencies of each label
  const probLabel = 1 / this.getAllLabels().length;
  const probNotLabel = 1 - probLabel;

  const tokenLabelCount = this.getTokenCount(token, label);
  const tokenTotalCount = this.getTokenCount(token);
  const tokenNotLabelCount = tokenTotalCount - tokenLabelCount;

  const probTokenGivenLabel = tokenLabelCount / labelDocumentCount;
  const probTokenGivenNotLabel = tokenNotLabelCount / notLabelDocumentCount;
  const probTokenLabelSupport = probTokenGivenLabel * probLabel;
  const probTokenNotLabelSupport = probTokenGivenNotLabel * probNotLabel;

  const rawWordScore =
    (probTokenLabelSupport)
    /
    (probTokenLabelSupport + probTokenNotLabelSupport);

  // Adjust for rare tokens -- essentially weighted average
  // We're going to shorthand some variables to make reading easier.
  // s is the "strength" or the "weight"
  // n is the number of times we've seen the token total
  const s = rareTokenWeight;
  const n = tokenTotalCount;
  const adjustedTokenScore =
    ( (s * probLabel) + (n * (rawWordScore || probLabel)) )
    /
    ( s + n );

  return adjustedTokenScore;
}

为了回顾这个算法的工作方式,这里有一个简要的总结:

训练:

  1. 接受一个输入文档和已知的标签或类别

  2. 将输入文档分词成一个 token 数组

  3. 记录你看到这个特定标签的文档总数

  4. 对于每个 token,记录你看到这个 token 与这个特定标签一起出现的次数

预测:

  1. 接受一个输入文档并将其分词

  2. 对于每个可能的标签(你在训练过程中遇到的全部标签),以及文档中的每个标记,计算该标记的标记分数(从数学上讲,给定特定标记的文档具有该标签的概率)

  3. 你可能需要过滤标记分数的重要性

  4. 你可能需要调整罕见词的标记分数

  5. 对于每个可能的标签,将标记分数组合成一个单一的、整体的标签概率(例如,文档属于这个类别或标签的概率)

  6. 报告具有最高整体概率的标签

在添加了所有代码后,我们准备训练和测试我们的朴素贝叶斯分类器。我们将使用 IMDB 电影评论来训练它,并尝试猜测从未见过的评论的情感。

示例 3 – 电影评论情感

我们将使用我们的朴素贝叶斯分类器来解决情感分析问题,或者检查一段文本并确定它是否具有整体正面或负面情感的问题。这在广告、营销和公共关系中是一个常见的分析;大多数品牌经理想知道推特上的人对他们的品牌或产品是好评还是差评。

本例的训练数据将来自www.imdb.com/。我们将使用正面和负面的电影评论来训练我们的分类器,然后使用我们的分类器来检查未经训练(但已预先标记)的评论,看看它能正确识别多少。

如果你还没有这样做,请从本项目的 GitHub 页面上的data目录下载数据文件。你需要所有四个文本文件:train_positive.txttrain_negative.txttest_positive.txttest_negative.txt。我们将使用两个训练文件进行训练,两个测试文件进行验证。

接下来,在src文件夹中创建一个index.js文件。将以下代码添加到文件顶部:

import readline from 'readline';
import fs from 'fs';
import BayesClassifier, {simpleTokenizer} from "./bayes";

const classifier = new BayesClassifier(simpleTokenizer);

我们导入readlinefs库来帮助我们处理训练文件。接下来,创建一个utility函数来帮助我们训练分类器:

const trainer = (filename, label, classifier) => {

  return new Promise((resolve) => {
    console.log("Training " + label + " examples...");
    readline.createInterface({
      input: fs.createReadStream(filename)
    })
      .on('line', line => classifier.train(label, line))
      .on('close', () => {
        console.log("Finished training " + label + " examples.");
        resolve();
      });
  });
}

这个helper函数接受一个文件名、一个标签和一个BayesClassifier类的实例。它逐行读取输入文件,并针对给定的标签在每个标签上训练分类器。所有逻辑都被封装在一个承诺中,这样我们就可以在外部检测到训练器何时完成。

接下来,添加一个辅助实用工具来测试分类器。为了测试分类器,它必须首先被训练。测试函数将打开一个已知标签的测试文件,并使用分类器的predict方法测试文件中的每一行。实用工具将计算分类器正确和错误识别的例子数量,并报告:

const tester = (filename, label, classifier) => {

  return new Promise((resolve) => {
    let total = 0;
    let correct = 0;
    console.log("Testing " + label + " examples...");
    readline.createInterface({ input: fs.createReadStream(filename) })
      .on('line', line => {
        const prediction = classifier.predict(line);
        total++;
        if (prediction.label === label) {
          correct++;
        }
      })
      .on('close', () => {
        console.log("Finished testing " + label + " examples.");
        const results = {total, correct};
        console.log(results);
        resolve(results);
      });
  }); 
}

我们也将这个操作封装在一个承诺中,并确保将结果作为承诺解决的一部分提供,这样我们就可以从外部检查结果。

最后,添加一些引导代码。这段代码将在两个训练文件上训练分类器,等待训练完成,然后在对两个测试文件进行测试后报告整体结果:

Promise.all([
  trainer('./data/train_positive.txt', 'positive', classifier),
  trainer('./data/train_negative.txt', 'negative', classifier)
])
  .then(() => {
    console.log("Finished training. Now testing.");

    Promise.all([
      tester('./data/test_negative.txt', 'negative', classifier),
      tester('./data/test_positive.txt', 'positive', classifier)
    ])
      .then(results => results.reduce(
        (obj, item) => ({total: obj.total + item.total, correct: obj.correct + item.correct}), {total: 0, correct: 0}
      ))
      .then(results => {
        const pct = (100 * results.correct / results.total).toFixed(2) + '%';
        console.log(results);
        console.log("Test results: " + pct);
      });
 })

一旦添加了这段代码,你就可以通过在命令行中输入yarn start来运行程序。你应该会看到以下类似的输出:

Training positive examples...
Training negative examples...
Finished training positive examples.
Finished training negative examples.
Finished training. Now testing.
Testing negative examples...
Testing positive examples...
Finished testing positive examples.
{ total: 4999, correct: 4402 }
Finished testing negative examples.
{ total: 5022, correct: 4738 }
{ total: 10021, correct: 9140 }
Test results: 91.21%

这个简单、概率性的分类器准确率超过 91%!9%的错误率可能看起来并不令人印象深刻,但在机器学习世界中,这实际上是一个非常良好的结果,特别是考虑到分类器的实现简便性和操作速度。正是这些结果使得朴素贝叶斯分类器在文本分类中非常受欢迎。通过更细致的标记化,尤其是在狭窄领域,如垃圾邮件检测,你可以将朴素贝叶斯分类器的准确率提高到 95%以上。

让我们看看一个单独的例子是什么样的。如果你想在自己的文档上测试一些文档,可以将以下代码添加到index.js文件中:

Promise.all([
  trainer('./data/train_positive.txt', 'positive', classifier),
  trainer('./data/train_negative.txt', 'negative', classifier)
])
  .then(() => {

    const tests = [
      "i really hated this awful movie, it was so bad I didn't even know what to do with myself",
      "this was the best movie i've ever seen. it was so exciting, i was on the edge of my seat every minute",
      "i am indifferent about this"
    ];

    tests.forEach(test => {
      console.log("Testing: " + test);
      const result = classifier.predict(test);
      console.log(result);
    });
  });

运行前面的代码会产生以下代码:

Training positive examples...
Training negative examples...
Finished training positive examples.
Finished training negative examples.

Testing: i really hated this awful movie, it was so bad I didn't even know what to do with myself
{ label: 'negative',
 probability: 0.9727173302897202,
 probabilities:
 [ { label: 'negative', probability: 0.9727173302897202 },
 { label: 'positive', probability: 0.027282669710279664 } ] }

Testing: this was the best movie i've ever seen. it was so exciting, i was on the edge of my seat every minute
{ label: 'positive',
 probability: 0.8636681390743286,
 probabilities:
 [ { label: 'positive', probability: 0.8636681390743286 },
 { label: 'negative', probability: 0.13633186092567148 } ] }

Testing: i am indifferent about this
{ label: 'negative',
 probability: 0.5,
 probabilities:
 [ { label: 'negative', probability: 0.5 },
 { label: 'positive', probability: 0.5 } ] }

分类器按预期工作。我们强烈的负面陈述有 97%的概率是负面的。我们的正面陈述有 86%的概率是正面的。即使我们的中立陈述返回了负面标签,也报告了正面和负面情绪的 50/50 概率分割。

我们通过简单地计算我们在文档中看到单词的次数,并使用几个世纪的概率理论来解释数据,就完成了所有这些工作,并取得了很高的准确率。我们不需要神经网络、高级框架或深度自然语言编程知识来获得这些结果;因此,朴素贝叶斯分类器应该是你在研究机器学习时应该关注的核心算法之一。

在接下来的章节中,我们将探讨两个不应被忽视的分类算法:SVM 和随机森林。

支持向量机

支持向量机(SVM)是一种数值分类器,在某些方面与 KNN 算法相似,尽管 SVM 在数学上更为先进。SVM 不是将测试点与其最近的点进行比较,而是试图在数据点的类别之间绘制边界线,创建一个区域,其中该区域内的所有点都将被视为该类别的成员。

考虑这张图片(来自维基百科关于 SVM 的文章)。数据点的两个类别由一条直线分开。分隔类别的线被选为最大间隔线,这意味着这条分割线在其两侧都有最多的空间,与你可以绘制的任何其他分割线相比:

图片

正如这里实现的那样,SVM 在某些有限情况下是有用的,但它不是一个强大的工具,因为它要求类别必须是线性可分的;也就是说,它要求你可以在两个类别之间画一条直线。这个 SVM 也是一个二元分类器,意味着它只处理两个类别或类别。

考虑以下数据(此图像及之后的图像均由 Shiyu Ji 提供,并授权使用 Creative Commons CC BY-SA 4.0 许可)。尽管只有两个类别,但它们并不是线性可分的;只有圆形或椭圆形才能将这两个类别分开:

虽然 SVM 自 20 世纪 60 年代以来就存在,但直到 1992 年研究人员才找到了解决这个问题的方法。通过使用一种称为核技巧的技术,可以将非线性可分的数据转换成更高维度的线性可分数据。在这种情况下,通过核转换数据将增加一个第三维度,而正是这个新的第三维度变得线性可分:

应用核技巧后,数据已经被映射到三维空间。红色数据点在第三维度上被向下拉,而紫色点则被向上拉。现在可以绘制一个平面(在二维空间中直线的三维等价物),以分离这两个类别。

通过适当选择核和参数,支持向量机可以处理各种形状的数据。虽然支持向量机总是会在数据上绘制一条线、一个平面或超平面(平面的更高维版本)——这些总是直线——但算法首先将数据转换成可以用直线分离的形式。

可以与 SVM 一起使用的核类型有很多。每种核以不同的方式转换数据,而适当的核选择将取决于你的数据形状。在我们的案例中,我们将使用径向基函数核,这是一种适用于聚类数据的良好通用核。SVM 本身有设置和参数需要调整,例如错误成本参数,但请记住,你选择的核也可能有自己的可配置参数。例如,径向基函数使用一个称为gamma的参数,它控制核的曲率。

由于 SVM 需要大量的数学知识,我们不会尝试自己构建。相反,我们将使用一个现成的库和一个流行的经典数据集。我们将使用的数据集被称为iris flower数据集。这个特定的数据集是在 1936 年由 Edgar Anderson(一位植物学家)和 Ronald Fisher(一位统计学家和生物学家)创建的。Anderson 选择了三种鸢尾花物种,具体是* Iris setosa Iris versicolor* 和 * Iris virginica。对于每种物种,Anderson 选择了 50 个样本,并测量了花瓣长度、花瓣宽度、萼片长度和萼片宽度,并记录了测量值以及物种名称(萼片*是保护花蕾在开花前生长的绿色叶子)。

Iris数据集是许多机器学习算法的常见玩具或测试数据集,原因有几个。这是一个小数据集:只有 150 个样本,四个维度或特征,以及三个类别。数据是多维的,但只有四个特征,仍然容易可视化和直观理解。数据中的模式也很有趣,对分类器提出了非平凡的挑战:一种物种(* Iris setosa)与其他两种物种明显分离,但 * Iris versicolor 和 * Iris virginica* 则更为交织。

因为数据是四维的,不能直接可视化,但我们可以将两个特征的所有组合分别绘制到网格中。此图像由维基百科用户 Nicoguaro 提供,并授权为 CC BY 4.0:

图片

你可以理解为什么这个数据集对研究人员来说很有趣。在几个维度上,例如花瓣长度与花瓣宽度的比较,* Iris versicolor* 和 * Iris virginica* 有很大的重叠;在其他维度上,它们看起来几乎是线性可分的,例如在花瓣长度与花瓣宽度图上。

最后,让我们实现一个支持向量机(SVM)来帮我们解决这个问题。

创建一个名为Ch5-SVM的新文件夹,并添加以下package.json文件:

{
 "name": "Ch5-SVM",
 "version": "1.0.0",
 "description": "ML in JS Example for Chapter 5 - Support Vector Machine",
 "main": "src/index.js",
 "author": "Burak Kanber",
 "license": "MIT",
 "scripts": {
 "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
 "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
 "start": "yarn build-cli && node dist/index.js"
 },
 "dependencies": {
 "babel-core": "⁶.26.0",
 "babel-plugin-transform-object-rest-spread": "⁶.26.0",
 "babel-preset-env": "¹.6.1",
 "babelify": "⁸.0.0",
 "browserify": "¹⁵.1.0",
 "libsvm-js": "⁰.1.3",
 "ml-cross-validation": "¹.2.0",
 "ml-dataset-iris": "¹.0.0",
 "ml-random-forest": "¹.0.2"
 }
 }

一旦文件就绪,运行yarn install来安装所有依赖项。我们不会使用data.js文件,而是将使用MLJS库附带的Iris数据集。

接下来,创建一个src文件夹和一个index.js文件。在index.js的顶部,导入以下内容:

import SVM from 'libsvm-js/asm';
import IrisDataset from 'ml-dataset-iris';

接下来,我们需要从IrisDataset库中提取数据。这个 SVM 算法的实现要求我们的标签必须是整数(它不支持将字符串作为标签),因此我们必须将数据集中的物种名称映射到整数:

const data = IrisDataset.getNumbers();
const labels = IrisDataset.getClasses().map(
  (elem) => IrisDataset.getDistinctClasses().indexOf(elem)
);

让我们也编写一个简单的函数来衡量准确度,或者更具体地说,损失(或误差)。这个函数必须接受一个预期值的数组以及一个实际值的数组,并返回错误猜测的比例:

const loss = (expected, actual) => {
  let incorrect = 0,
  len = expected.length;
  for (let i in expected) {
    if (expected[i] !== actual[i]) {
      incorrect++;
    }
  }
  return incorrect / len;
};

我们现在准备实现 SVM 类。我们将以两种方式测试我们的分类器:首先,我们将在整个数据集上训练分类器,然后在整个数据集上测试它;这将测试算法拟合数据的能力。然后我们将使用交叉验证方法,只对数据的子集进行训练,并在未见过的数据上进行测试;这将测试算法泛化其学习的能力。

将以下代码添加到 index.js

console.log("Support Vector Machine");
console.log("======================");

const svm = new SVM({
  kernel: SVM.KERNEL_TYPES.RBF,
  type: SVM.SVM_TYPES.C_SVC,
  gamma: 0.25,
  cost: 1,
  quiet: true
});

svm.train(data, labels);

const svmPredictions = svm.predict(data);
const svmCvPredictions = svm.crossValidation(data, labels, 5);

console.log("Loss for predictions: " + Math.round(loss(labels, svmPredictions) * 100) + "%");
console.log("Loss for crossvalidated predictions: " + Math.round(loss(labels, svmCvPredictions) * 100) + "%");

我们使用一些合理的参数初始化 SVM。我们选择径向基函数作为我们的核函数,我们选择一个称为 CSVC 的特定算法作为我们的 SVM(这是最常见的 SVM 算法),我们选择成本为 1,gamma 为 0.25。成本和 gamma 都将对分类器围绕类别绘制边界的方式产生类似的影响:值越大,围绕聚类的曲线和边界就越紧密。

svm.crossValidation 方法接受三个参数:数据、标签以及将数据分割成多少个段,每个遍历保留一个段用于验证。

从命令行运行 yarn start,你应该会看到以下内容:

 Support Vector Machine
 =============================================
 Loss for predictions: 1%
 Loss for crossvalidated predictions: 3%

这是一个非常强的结果。SVM 能够正确回忆起 99% 的训练示例,这意味着在完全训练后,只有几个数据点被错误地猜测。在交叉验证时,我们只看到 3% 的损失;只有可能五例中的 150 个例子被错误地猜测。交叉验证步骤很重要,因为它更准确地代表了现实世界的性能;你应该调整算法的参数,以便交叉验证的准确率最大化。

对于完全训练的算法,获得 100% 的准确率很容易:我们可以简单地过度拟合数据并记住每个数据点的类别。将 gamma 和 cost 的值都改为 50 并重新运行算法。你应该会看到类似以下内容:

 Support Vector Machine
 =============================================
 Loss for predictions: 0%
 Loss for crossvalidated predictions: 25%

通过提高成本和 gamma 的值,我们正在围绕现有数据点绘制非常紧密的边界。当成本和 gamma 的值足够高时,我们甚至可能为每个数据点绘制单独的圆圈!当测试完全训练的分类器时(例如,每个训练点都已记住),结果是完美的分数,但在交叉验证数据集时,分数会很糟糕。我们的交叉验证使用 80% 的数据用于训练,并保留 20% 用于验证;在这种情况下,我们过度拟合了训练数据,以至于分类器根本无法对未见过的数据点进行分类。分类器记住了数据,但没有从中学习。

作为经验法则,成本值的良好起点大约是 1。较高的成本会更严厉地惩罚训练错误,这意味着你的分类边界将试图更紧密地包裹训练数据。成本参数试图在边界的简单性和训练数据的召回率之间取得平衡:较低的成本将倾向于更简单、更平滑的边界,而较高的成本将倾向于更高的训练准确率,即使这意味着绘制更复杂的边界。这可能会导致样本空间的大部分区域在现实世界数据中被错误分类,特别是如果你的数据集高度分散。较高的成本值对于非常紧密聚集和清晰分离的数据效果更好;你越信任你的数据,你可以将成本设置得越高。成本参数最常见的是介于 0.01 和 100 之间,尽管当然也有可能需要更大或更小的成本值。

同样,gamma 值也控制 SVM 边界的形状和曲率,然而,这个值在应用核技巧转换数据时的数据预处理中产生影响。结果是与成本参数相似,但源于完全不同的机制。gamma 参数本质上控制单个训练样本的影响。gamma 值较低将导致训练点周围的边界更平滑、更宽,而较高的值将导致边界更紧密。gamma 的一个常见经验法则是将其设置为大约 1/M,其中 M 是数据中的特征数量。在我们的例子中,我们的数据有四个特征或维度,因此我们将 gamma 设置为 1/4 或 0.25。

当第一次训练支持向量机(SVM)时,你应该始终使用交叉验证来调整你的参数。与任何机器学习(ML)算法一样,你必须调整参数以适应你的数据集,并确保你对问题进行了充分的泛化,而不是过度拟合你的数据。有系统地调整和测试参数:例如,选择五个可能的成本值和五个可能的 gamma 值,使用交叉验证测试所有 25 种组合,并选择具有最高准确率的参数。

接下来,我们将探讨机器学习的一个现代工作马:随机森林。

随机森林

随机森林算法是现代的、多才多艺的、健壮的、准确的,并且对于你可能会遇到的几乎所有新的分类任务都值得考虑。它不总是给定问题域的最佳算法,并且在高维和非常大的数据集上存在问题。如果你有超过 20-30 个特征或超过,比如说,10 万个训练点,它肯定会在资源和训练时间上遇到困难。

然而,随机森林在许多方面都是优秀的。它可以轻松处理不同类型的特征,这意味着一些特征可以是数值型的,而其他特征可以是分类型的;你可以将如number_of_logins: 24这样的特征与如account_type: guest这样的特征混合。随机森林对噪声非常鲁棒,因此在实际数据上表现良好。随机森林旨在避免过拟合,因此训练和实现起来非常简单,需要的调整和微调比其他算法少。随机森林还会自动评估你数据中每个特征的重要性,因此可以免费帮助你降低维度或选择更好的特征。尽管随机森林在高维数据上可能成本较高,但根据我的经验,大多数现实世界的机器学习问题只涉及大约十几个特征和几千个训练点,而随机森林可以处理这些。这些优点使随机森林成为通用分类任务的优秀算法选择。

因此,我非常难过地报告说,在写作的时候,我在 JavaScript 生态系统中没有找到高质量的随机森林分类器。无论如何,我将继续撰写这一部分——甚至向你展示一个我认为可能存在一些错误或问题的现有库——希望在你阅读这段文字的时候,一切都已经修复,高质量的随机森林将在 JavaScript 中轻松可用。

随机森林是一种基于决策树的集成分类器。集成分类器由多个或许多个单独的分类器组成,它们都对预测进行投票。在第二章“数据探索”中,我们多次运行了 k-means 算法,并使用不同的随机初始条件,以避免陷入局部最优;这是一个基本的集成分类示例。

随机森林是一组决策树。你可能已经熟悉决策树了:在日常生活中,决策树更常被称为流程图。在机器学习(ML)的背景下,决策树是由算法自动训练和构建的,而不是手工绘制。

首先,让我们讨论单个决策树。决策树在随机森林之前就已经存在,但历史上对机器学习的贡献仅限于中等水平。决策树背后的概念与手绘流程图相同。当决策树评估一个数据点时,它会依次检查每个特征:花瓣长度是否小于 1.5 厘米?如果是,检查萼片长度;如果不是,检查花瓣宽度。最终,决策树会到达一个最终的叶子或节点,在那里不再可能做出决策,然后树会预测数据点的类别。

决策树通过使用信息理论中的几个概念自动训练,例如信息增益、熵以及一个称为基尼不纯度的度量。本质上,这些技术用于确定最重要的分支决策是什么。决策树希望尽可能小和简单,因此这些技术用于确定如何最好地在决策之间分割数据集。树的第一个分支应该检查花瓣宽度还是萼片长度?如果它检查萼片长度,应该是在 2.0 厘米还是 1.5 厘米处分割?哪些比较将导致整个数据集的最佳分割?这种训练是递归进行的,每个特征和每个训练点都会被评估以确定其对整体的影响。

结果是一个既快速又易于理解和调试的分类器。与神经网络不同,其中每个神经元的影響非常抽象,也与贝叶斯分类器不同,后者需要概率方面的技能才能理解,决策树可以被表示为流程图,并由研究人员直接解释。

很不幸,决策树本身并不非常准确,它们对训练数据或噪声的变化不稳健,可能会陷入局部最优,而且有一些问题类别决策树处理得并不好(比如经典的 XOR 问题,会导致树变得非常复杂)。

在 20 世纪 90 年代中期,研究人员找到了两种新的决策树集成方法。首先,开发了样本袋装法(或自助聚集)技术。在这种方法中,你创建多个决策树,每个树基于训练数据的完全随机子集(有放回),并在做出预测时使用所有树的多数投票。袋装法之所以有效,是因为单个树的噪声方差很高,但对于许多不相关的树,噪声往往会相互抵消。想象一下在体育馆里,观众们跟着他们最喜欢的乐队一起唱歌——人群总是听起来很和谐,因为唱得尖锐的人会被唱得平的人所抵消。

随机森林建立在 bagging(袋装法)的基础上,不仅随机化每个树接收到的样本,还随机化每个树接收到的特征。与样本袋装法相反,你可以称之为特征袋装法。如果你为我们的Iris数据集(该数据集有四个特征和 150 个数据点)构建一个包含 50 棵树的随机森林,你可能期望每棵树只有 100 个独特的数据点,并且只有四个特征中的两个。像样本袋装法一样,特征袋装法旨在解耦每个决策树,并减少集成整体的方差。特征袋装法还旨在识别最重要的特征,如果你需要节省资源,你总是可以从数据集中移除最不重要的特征。当你尝试预测一个数据点时,每棵树都会提交它的投票;有些树可能会非常错误,但整个集成将做出一个非常好的预测,对噪声具有鲁棒性。

让我们构建一个随机森林,并用我们的Iris数据对其进行测试。你应该已经在package.json文件中安装了随机森林和交叉验证库,从 SVM 部分开始;如果没有,你应该使用yarn add安装ml-cross-validationml-random-forest

Ch5-SVM示例的现有index.js文件顶部,导入适当的类:

import {RandomForestClassifier} from 'ml-random-forest';
import crossValidation from 'ml-cross-validation';

你应该已经从 SVM 部分设置了labelsdata。现在,将以下内容添加到文件底部,在 SVM 示例下方:

console.log("======================");
console.log("Random Forest");
console.log("======================");

const rfOptions = {
  maxFeatures: 3,
  replacement: true,
  nEstimators: 100,
  useSampleBagging: true
};

const rf = new RandomForestClassifier(rfOptions);
rf.train(data, labels);
const rfPredictions = rf.predict(data);

const confusionMatrix = crossValidation.kFold(RandomForestClassifier, data, labels, rfOptions, 10);
const accuracy = confusionMatrix.getAccuracy();

console.log("Predictions:");
console.log(rfPredictions.join(","));
console.log("\nLoss for predictions: " + Math.round(loss(labels, rfPredictions) * 100) + "%");
console.log("Loss for crossvalidated predictions: " + Math.round( (1 - accuracy) * 100) + "%\n");
console.log(confusionMatrix);

与 SVM 示例类似,我们以两种方式评估随机森林。我们首先在全部训练数据上训练森林,并评估其召回率,然后我们使用交叉验证来了解其实际性能。在这个例子中,我们使用 MLJS 的交叉验证和混淆矩阵工具来评估分类器的性能。

使用yarn start运行代码,你应该会看到以下类似的内容:

Random Forest
======================================================================
Predictions:
0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0, 0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,1,2,0,2,0,2,0,0,2,2,2,1,2,1,2,2,1, 2,2,2,2,2,2,2,2,2,0,1,1,2,2,0,2,2,2,1,1,1,2,2,0,1,0,0,2,0,0,2,2,2,2,2,
2,0,2,2,2,2,2,2,0,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2

Loss for predictions: 31%
Loss for crossvalidated predictions: 33%

ConfusionMatrix {
 labels: [ 0, 1, 2 ],
 matrix: [ [ 43, 6, 1 ], [ 8, 11, 31 ], [ 1, 2, 47 ] ] }

很不幸,这个算法的准确性非常差。实际上,这种表现并不典型,尤其是对于Iris数据集,这个数据集对于算法来说应该非常容易解释。

我想要确定这些糟糕的结果是由于实现问题而不是概念问题,所以我用相同的 Iris 数据集通过我日常使用的熟悉的随机森林库运行,使用相同的选项和参数,但得到了非常不同的结果:我的随机森林的交叉验证损失仅为 2%。不幸的是,我必须将这个糟糕的准确率归咎于算法的具体实现,而不是随机森林本身。虽然我花了一些时间调查这个问题,但我并没有能够快速地识别出这个实现的问题。有可能我误用了这个工具,然而,更有可能的是,在库的某个地方有一个负号应该是一个正号(或者类似愚蠢且灾难性的错误)。我对于随机森林在Iris数据集上的性能的个人预测是大约 95%的准确率,我熟悉的随机森林库得到了 98%的准确率,但这个库只得到了 70%的准确率。

更糟糕的是,我无法在 JavaScript 中找到一个适用于Iris数据集的随机森林库。虽然有几个随机森林库,但没有一个是现代的、维护良好的和正确的。Andrej Karpathy 有一个废弃的随机森林库似乎可以工作,但它只能处理二分类(只有 1 和-1 作为标签),还有几个其他随机森林库在类似的方式下有限制。《MLJS》随机森林库是我们之前使用的最接近一个工作、维护良好的库,所以我希望无论问题是什么,它都会在你阅读这篇文章的时候被发现并解决。

我不希望你们因为使用随机森林而气馁。如果你在除了 JavaScript 以外的语言中工作,有许多随机森林库可供选择。你应该熟悉它们,因为它们很快就会成为你大多数分类问题的首选。至于 JavaScript,虽然从零开始构建随机森林比构建贝叶斯分类器更难,但它们仍然是可以实现的。如果你能够正确实现决策树,或者从不同的语言中移植一个,构建随机森林就会变得非常简单——森林中的树做了大部分工作。

虽然 JavaScript 的机器学习工具集一直在进步,但这个随机森林的例子完美地突出了还有很多工作要做。你必须谨慎行事。我开始写这个例子时,预期至少有 95%的准确率,基于我对随机森林的先前经验。但如果没有期望或经验呢?我会接受这个工具的 70%准确率吗?我会说服自己随机森林不适合这项工作吗?这会让我在将来不愿意使用随机森林吗?也许吧!JavaScript 生态系统中的机器学习会有更多这样的地雷;小心它们。

在我们结束这一章之前,我想回顾一下我们刚才看到的混淆矩阵,因为这可能对你来说是一个新概念。我们在前面的章节中讨论了精确度、召回率和准确率。混淆矩阵是这些值可以从任何分类中得出的原始数据。这是随机森林的混淆矩阵,再次呈现:

ConfusionMatrix {
 labels: [ 0, 1, 2 ],
 matrix: [ [ 43, 6, 1 ], [ 8, 11, 31 ], [ 1, 2, 47 ] ] }

如果我们将这些组织成表格,可能看起来是这样的:

Guessed I. setosa Guessed I. versicolor Guessed I. virginica
Actual I. setosa 43 6 1
实际 I. versicolor 8 11 31
实际 I. virginica 1 2 47

混淆矩阵是猜测与实际类别之间的矩阵(或表格)。在理想的世界里,你希望混淆矩阵除了对角线外都是零。混淆矩阵告诉我们随机森林在猜测Iris setosaIris virginica方面做得相当不错,但它错误地将大多数Iris versicolor标记为Iris virginica。考虑到数据的形状,这并不太令人惊讶;回想一下,后两种物种重叠相当多(然而,随机森林仍然应该能够解决这个问题)。

我们为随机森林编写的代码还打印出了每个数据点的个别预测,看起来是这样的:

0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,1,2,0,2,0,2,0,0,2,2,2,1,2,1,2,2,1,2,2,2,2,2,2,2,2,2,0,1,1,2,2,0,2,2,2,1,1,1,2,2,0,1,0,0,2,0,0,2,2,2,2,2,2,0,2,2,2,2,2,2,0,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2

这些数字并不完全与混淆矩阵中的数字相同,因为这些预测来自完全训练的树,而混淆矩阵来自交叉验证过程;但你可以看到它们仍然很相似。前 50 个预测应该都是 0(对于Iris setosa),而且大多数确实是。接下来的 50 个预测应该是全部 1,但主要是 2;混淆矩阵告诉我们同样的事情(即大多数I. versicolor被错误地标记为I. virginica)。最后 50 个预测应该是全部 2,大部分是正确的。混淆矩阵是查看预期猜测与实际猜测之间差异的一种更紧凑和直观的方式,这正是你在微调算法时需要的信息。

简而言之,随机森林是一种优秀的分类算法,但目前还没有令人信服的 JavaScript 实现。我鼓励您成为 JavaScript 进化的参与者,构建自己的随机森林,或者至少将这个算法记在心里以备将来使用。

摘要

分类算法是一种监督学习算法,其目的是分析数据并将未见过的数据点分配到预存在的类别、标签或分类中。分类算法是机器学习(ML)中一个非常受欢迎的子集,有众多分类算法可供选择。

具体来说,我们讨论了简单直观的 k 近邻算法,该算法在图上比较数据点与其邻居。我们还讨论了优秀且非常受欢迎的朴素贝叶斯分类器,它是一种经典的基于概率的分类器,在文本分类和情感分析问题空间中占据主导地位(尽管它也可以用于许多其他类型的问题)。我们还讨论了支持向量机,这是一种适用于非线性可分数据的先进几何分类器。最后,我们讨论了随机森林分类器,这是一种强大且稳健的集成技术,依赖于决策树,但不幸的是,在 JavaScript 中只有一种有疑问的实现。

我们还讨论了交叉验证和混淆矩阵,这两种强大的技术可以用来评估你模型的准确性。

在下一章中,我们将探讨关联规则,这些规则为我们提供了更多的预测能力。如果有人在商店购买了面包和黄油,他们更有可能还会购买牛奶,还是购买熟食肉类?关联规则可以帮助我们建模和解释这些关系。

第六章:关联规则算法

关联规则学习,或称关联规则挖掘,是一种相对较新的无监督学习技术,最初用于在杂货店发现购买商品之间的关联。关联规则挖掘的目标是发现商品集合之间的有趣关系,例如,发现为应对飓风做准备的人通常会购买 Pop-Tarts、瓶装水、电池和手电筒。

在第五章,“分类算法”中,我们介绍了条件概率的概念。在本章中,我们将把这一概念进一步拓展,并将其应用于关联规则学习。回想一下,条件概率询问(并回答)的问题是:如果我们知道某事,另一件事发生的概率是多少?或者,如果某人买了瓶装水和电池,他们购买 Pop-Tarts 的概率是多少?这个概率很高,正如我们很快就会看到的。

在关联规则学习中,我们的目标是查看交易或事件数据库,并通过概率将最常见的子集相互关联。这可以通过一个例子更容易理解。想象你经营一家电子商务商店,你的任务是创建一个个性化的主页小部件,向购物者推荐产品。你可以使用他们完整的订单历史数据库,你必须使用购物者的浏览历史来推荐他们很可能购买的商品。

自然地,解决这个问题有几种方法。没有理由你不能在商店整个订单历史上训练一个神经网络来推荐新产品——除了时间和复杂性。在数百万笔交易上训练神经网络既耗时又非常难以直观地检查和理解。另一方面,关联规则学习为我们提供了一个简单快捷的工具,这个工具基于基本的概率概念。

假设你的电子商务商店是一家销售精品、精选家居装饰和家具的直邮业务。你的目标是确定最常一起购买的商品组合,例如:90%购买躺椅和茶几的人也购买了脚凳,80%购买巨型挂钟的人也购买了干墙安装锚固件套装。

如果你有一种快速有效的方法来搜索数百万笔以前的订单以找到这些关系,你可以将当前购物者的浏览历史与其他购物者的购买历史进行比较,并显示购物者最有可能购买的商品。

关联规则学习不仅限于电子商务。另一个明显的应用是实体店,比如你当地的超市。如果 90%购买牛奶和鸡蛋的购物者也会购买面包,那么把面包放在附近可能会让购物者更容易找到它。或者,你可能想把面包放在商店的对面,因为你知道购物者将不得不走过很多通道,并且可能在这个过程中购买更多商品。如何使用这些数据取决于你,这取决于你想要优化什么:购物者的便利性还是整个购物篮的价值。

初看起来,这似乎是一个容易编写的算法——毕竟我们只是在计算概率。然而,在大型数据库和大量可能的商品选择中,检查每个商品组合的频率会变得非常耗时,因此我们需要比暴力穷举搜索方法更复杂一些的方法。

在本章中,我们将讨论:

  • 从数学角度的关联规则学习

  • Apriori 算法的描述

  • 关联规则学习的各种应用

  • 各种关联规则算法的工作示例

让我们从数学的角度来探讨关联规则学习。

数学角度的描述

关联规则学习假设你有一个事务数据库来学习。这并不指代任何特定的技术,而是指存储事务的数据库概念——数据库可以是内存中的数组、Excel 文件,或者你生产环境中的 MySQL 或 PostgreSQL 实例中的表。由于关联规则学习最初是为超市中的产品开发的,原始的事务数据库是每个购物者在一次购物过程中购买的商品列表——本质上是一个收银通道的收据档案。然而,事务数据库可以是任何单次会话中发生的商品或事件的列表,无论这个会话是购物之旅、网站访问还是去看医生。目前,我们将考虑超市的例子。我们将在后面的章节中讨论关联规则的其他用途。

事务数据库是一个行代表会话、列代表商品的数据库。考虑以下:

收据 鸡蛋 牛奶 面包 奶酪 洗发水
1
2
3
4
5

这样的表格可以被视为一个事务数据库。请注意,我们并没有记录每个商品购买的数量,只是记录商品是否被购买。在大多数关联规则学习中,通常忽略商品的数量和顺序。

根据表中的信息,我们可以组合出各种事件发生的概率。例如,购物者购买洗发水的概率,或P(E[Shampoo]),是 20%。购物者同时购买奶酪和面包的概率是 40%,因为有两位购物者同时购买了奶酪和面包。

从数学上讲,牛奶面包被称为项集,通常写作{milk, bread}。项集类似于我们在第五章“分类算法”中引入的概率事件的概念,但项集专门用于这种情况,而事件是概率中更一般的概念。

在关联规则学习中,一个项集作为交易的一部分出现的概率被称为该项集的支持度。刚才我们提到,某人购买牛奶和面包的概率是 40%;这是另一种说法,即{milk, bread}项集的支持度为 40%。用数学表示,我们可以写成supp({milk, bread}) = 40%

然而,计算项集的支持度并不能让我们完全达到关联规则学习。我们首先需要定义什么是关联规则。关联规则的形式是 X -> Y,其中XY都是项集。完整写出来,一个示例关联规则可以是{eggs, milk} -> {cheese},这关联了购买鸡蛋和牛奶与购买奶酪。尽管左侧可以有任意数量的项目,但关联规则几乎总是只有右侧有一个项目。关联规则本身并不能告诉我们关于关联的信息;我们还需要查看各种指标,如关联的置信度提升度,以了解关联有多强。

对于关联规则来说,最重要的指标是其置信度,这本质上是指规则被发现为真的频率。置信度也恰好是条件概率P(E[Y]|E[X]),或给定某人购买了X中的项目,他们购买Y中项目的概率。

使用我们在第五章“分类算法”中关于条件概率的知识,以及关联规则学习中的新概念“支持”和“置信度”,让我们写出一些等价式,这将帮助我们巩固这些数学概念。

首先,让我们假设项集X是鸡蛋和牛奶,或X = {eggs, milk},而Y = {cheese}

X的支持,或supp(X),等同于在交易中找到X中项目的概率,或P(E[X])。在这种情况下,鸡蛋和牛奶出现在五笔交易中的三笔,因此其支持度为 60%。同样,Y(仅奶酪)的支持度为 80%。

关联规则 X -> Y 的置信度定义为 conf(X -> Y) = supp(X ∪ Y) / supp(X)。另一种说法是,规则的置信度是规则中所有项的支持度除以左侧的支持度。在概率论中,∪ 符号表示 并集——基本上是一个布尔 OR 操作。因此,XY 项集的 并集 是出现在 X 或 Y 中的任何项。在我们的例子中,并集是鸡蛋、牛奶和奶酪。

如果 supp(X) = P(EX),那么 supp(X ∪ Y) = P(EX ∩ XY)。回想一下,∩ 是 交集 的符号,或者说本质上是一个布尔 AND 操作。这是项集语义与概率事件语义不同的一种情况——两个项集的 并集 与包含这些项集的两个事件的 交集 有关。尽管符号有点令人困惑,但我们想要表达的是:当我们开始将关联规则符号翻译成标准的概率符号时,这个 置信度 公式开始看起来非常像条件概率的公式。

由于在条件概率中,P(E[Y] | E[X]) = P(E[X] ∩ E[Y]) / P(E[X]) 这个关系定义了条件概率,并且我们知道 supp(X ∪ Y) = P(E[X] ∩ E[Y]),我们还知道 P(E[X]) = supp(X),我们发现关联规则的置信度就是它的条件概率。

回到我们的示例规则 {eggs, milk} ⇒ {cheese},我们发现这个规则的置信度为 1.0。XY(或 {eggs, milk, cheese})的并集在五笔交易中出现了三次,其支持度为 0.6。我们将这个支持度除以左侧的支持度,即 supp ({eggs, milk}),我们也在五笔交易中找到了它。将 0.6 除以 0.6 得到 1.0,这是可能的最大置信值。每次购物者购买鸡蛋和牛奶时,他们也会购买奶酪。或者,用条件概率的说法,给定他们购买了鸡蛋和牛奶,购买奶酪的概率是 100%。与购买奶酪的概率只有 80% 相比,我们明显看到鸡蛋、牛奶和奶酪之间存在正相关关系。

这种偶然关系可以通过一个称为提升度的概念进一步探索。提升度定义为组合项的支持度除以左侧和右侧各自的支持度(即,假设它们是独立的)。公式是 提升度(X -> Y) = supp(X ∪ Y) / ( supp(X) * supp(Y) )。这个公式本质上衡量了XY相互之间是依赖还是独立。如果XY一起的支持度与XY分别的支持度相同,那么规则的提升度将是 1,XY可以被认为是完全相互独立的。随着两个项集的相互依赖性增加,提升度的值也会增加。在我们的例子中,{鸡蛋,牛奶,奶酪}的支持度再次是 0.6,{鸡蛋,牛奶}的支持度是 0.6,而{奶酪}的支持度是 0.8。将这些值与提升度公式结合起来,我们得到 提升度(X -> Y) = 0.6 / (0.6 * 0.8) = 1.25。这个规则据说有 25%的提升度,这表明{鸡蛋,牛奶}{奶酪}之间存在某种依赖关系。

在开发关联规则时,研究人员可以使用几种其他指标,尽管在我们的示例中我们不会遇到这些指标。例如有信念度杠杆作用集体力量等指标,但大部分情况下,熟悉的支持度、置信度和提升度概念就足够了。

如果你从这个部分学到了什么,让它成为这一点:许多计算机科学和机器学习中的现代问题都可以用几个世纪的概率理论来解决。关联规则学习是在 20 世纪 90 年代开发的,但其核心概念可以追溯到数百年前。正如我们在第五章中看到的,分类算法,我们可以使用概率理论来开发强大的机器学习ML)算法,关联规则学习也是提高你对概率理论知识的另一个论据。

现在我们来探讨分析事务型数据库的挑战,以及关联规则算法可能的工作方式。

算法视角

我们现在面临的是一个更加困难的任务,即在数据库中识别频繁项集。一旦我们知道我们想要为哪些项集和关联生成规则,计算规则的支持度和置信度就相当容易了。然而,困难在于自动发现数百万笔交易中数以千计的可能项的频繁且有趣的项集。

假设你的电子商务商店只有 100 种独特的商品。显然,你的客户在会话期间可以购买任意数量的商品。让我们说一个购物者只买了两种商品——从你的目录中考虑两种商品的不同组合有 4,950 种。但你还需要考虑购买三种商品的购物者,这其中有 161,700 种组合需要搜索。如果你的产品目录包含 1,000 种商品,在搜索频繁项集时,你需要考虑的三个商品组合有 1,660 万种。

显然,需要一个更高级的算法来搜索事务数据库中的频繁项集。请注意,频繁项集搜索只是解决方案的一半;一旦找到频繁项集,你仍然必须从它们中生成关联规则。然而,由于频繁项集搜索比生成关联规则要困难得多,因此项集搜索成为大多数算法的关键焦点。

在本节中,我们将描述一种原始的频繁项集搜索算法:Apriori 算法。我们这样做只是为了教育目的;你不太可能需要实现自己的 Apriori 算法版本,因为现在有更新、更快的频繁项集搜索算法可用。然而,我认为研究并理解这些经典算法很重要,特别是那些解决非常广大搜索空间的算法。大多数搜索非常广大空间的算法都使用某种公理化或启发式证明的技巧来极大地减少搜索空间,Apriori 也不例外。

Apriori 算法首先扫描事务数据库,并记录每个单独物品的支持度(或频率)。结果是物品列表或哈希表,例如鸡蛋 = 0.6,牛奶 = 0.6,洗发水 = 0.2。

下一步是找到两个物品的组合并确定它们在数据库中的支持度(或频率)。这一步骤的结果可能类似于 {鸡蛋, 牛奶} = 0.6{鸡蛋, 面包} = 0.2{鸡蛋, 奶酪} = 0.6{鸡蛋, 洗发水} = 0.0,等等。暴力搜索、穷举搜索方法的问题从这一步开始。如果你目录中有 100 个物品,你需要计算 4,950 对的支持度。如果你目录中有 1,000 个物品,你必须计算近 500,000 对的支持度。我不知道亚马逊([www.amazon.com/](https://www.amazon.com/))卖了多少产品(2017 年 1 月的最新报告称有 3.68 亿),但假设他们现在有 4 亿个产品,有 8 x 10¹⁶对物品需要考虑(那是八十万亿对物品)。而且这只是物品的。我们还需要查看每个物品的三元组、四元组,等等。

Apriori 用来减少搜索空间的巧妙技巧是通过最小支持度或最小感兴趣频率来过滤唯一产品列表。例如,如果我们设定最小支持度为 0.25,我们会发现{洗发水}不符合条件,因此洗发水永远不会成为我们的频繁项集分析的一部分,因为它简单地没有频繁购买。

如果洗发水本身购买频率不够高,不足以被认为是频繁的,那么任何包含洗发水的项目对也将同样不足以被考虑。如果洗发水出现在 20%的购买中,那么{鸡蛋, 洗发水}这对必须出现在(或等于)20%的购买中更少(或等于)的频率。我们不仅可以从搜索中排除洗发水,还可以从考虑中排除任何包含洗发水的集合。如果洗发水本身购买频率足够低以至于我们可以忽略它,那么{鸡蛋, 洗发水}{面包, 洗发水}{鸡蛋, 面包, 洗发水}也将同样足够低以至于我们可以忽略它们。这大大减少了我们的搜索空间。

我们可以在检查更大组合的项目时将这种方法进一步深化。在我们的例子中,{鸡蛋}的支持度为 60%,而{面包}的支持度为 40%。如果我们设定的最小支持度为 25%,这两个项目单独都符合条件,应该在我们的频繁数据集分析中考虑。然而,{鸡蛋, 面包}的组合支持度仅为 20%,可以被舍弃。同样地,我们能够从二级搜索中消除任何包含{洗发水}的组合,现在我们也可以从三级搜索中消除任何包含{鸡蛋, 面包}的组合。因为鸡蛋和面包一起出现的频率很低,所以任何包含鸡蛋和面包的三个或更多项目的组合也必须很少见。因此,我们可以从考虑中排除像{鸡蛋, 面包, 奶酪}{鸡蛋, 面包, 牛奶}{鸡蛋, 面包, 洗发水}这样的组合,因为它们都包含了罕见的鸡蛋面包组合。

虽然这种方法大大减少了寻找频繁项集所需的时间,但你应该谨慎使用这种方法,因为可能会意外地跳过一些有趣但相对罕见的组合。大多数 Apriori 实现都将允许你为生成的关联规则设置最小支持和最小置信度。如果你将最小支持度设定为高值,你的搜索将会更快,但你可能会得到更明显或不太有趣的结果;如果你将支持度设定得较低,你可能会在等待搜索完成上花费很长时间。通常,关联规则是在找到频繁项集之后生成的,所以你设定的任何最小置信度水平都不会影响搜索时间——只有最小支持度变量会对搜索时间产生重大影响。

还应注意的是,对于频繁项集搜索,存在更多高级且更快的算法。特别是,我们将在本章后面实验 FP-Growth 算法。然而,Apriori 算法是理解实际中频繁项集搜索如何工作的绝佳起点。

在我们实现库之前,让我们看看一些可能有助于关联规则的场景。

关联规则应用

关联规则算法的原始用途是市场篮子分析,例如我们在本章中一直使用的杂货店示例。这是关联规则挖掘的一个明确应用。市场篮子分析可以用于实体店和电子商务店,并且可以根据不同的星期日、季节或甚至特定罕见事件(如即将到来的音乐会或飓风)维护不同的模型。

事实上,在 2004 年,《纽约时报》(以及其他媒体)报道说,沃尔玛使用关联规则挖掘来提前了解如何为飓风储备商店。沃尔玛发现,在飓风来临前的最高提升关联并不是瓶装水或手电筒,而是草莓 Pop-Tarts。另一个具有高置信度的关联是啤酒。我对啤酒并不感到太惊讶,但草莓 Pop-Tarts 这种洞察力只能从机器学习中真正获得!

想象一下,如果你在 2004 年的沃尔玛担任数据科学家。查看不同时间段各种产品的单个销售量很容易。可能草莓 Pop-Tarts 作为一种小额商品,在飓风期间相对销售量的百分比变化非常小。这就是你可能自然忽略的、看似不重要的数据点。Pop-Tarts 销量略有上升,那又如何?但如果你挖掘频繁项集和关联规则的数据,你可能会发现{瓶装水,电池} -> {草莓 Pop-Tarts}规则在飓风来临前的几天出现了异常强的置信度和大约 8.0 的提升(提升值非常高)。在飓风季节之外,这种关联可能不存在或太弱而无法被选中。但当飓风即将来临,草莓 Pop-Tarts 成为必需的飓风补给品,几乎肯定是因为它们的长期保质期以及它们能让孩子们和成年人快乐的特性。看到这个关联,你会告诉商店增加草莓 Pop-Tarts 的库存,并将它们放在商店最前面——紧挨着瓶装水和电池——从而在 Pop-Tarts 销售上大赚一笔。

虽然这种类型的场景是关联规则设计的目的,但你可以将频繁项集挖掘和关联规则应用于任何事务数据库。如果你将网站会话视为一个事务,并且如果你可以捕获采取的行动(例如 登录加入愿望清单的商品下载案例研究)作为你的项目,你就可以将相同的算法和关联规则挖掘应用于网站访客行为。你可以开发关联规则,例如 {下载案例研究, 查看定价页面} -> {输入信用卡},来模拟访客行为并优化你网站的布局和功能,以鼓励你希望的行为。

请记住,关联规则不仅在它们为正时才有价值。当它们为负时,同样有价值。很多时候,你需要冷酷、硬性的事实来改变你对之前顽固信念的看法。在对数据集进行关联规则挖掘时,如果没有看到你预期看到的关联,这可以与发现意外的关联一样强大。看到你直觉上认为的强关联的置信度实际上非常低,或者低于你的阈值,这可以帮助你放弃可能阻碍你或你的产品的过时思维。

有许多关于关联规则挖掘在许多和不同领域被使用的例子。是的,关联规则可以用来在飓风来临之前最大化 Pop-Tarts 的利润,但关联规则也可以用来根据其特征和功率输出来描述飓风本身。尽管关联规则学习是为篮子分析开发的,但其基于条件概率的基础使其适用于几乎任何可以用项目和事务表示的统计系统。

以医疗诊断为例。如果每位医生的诊断被视为一个事务,每种医疗状况或环境因素被视为一个项目,我们可以应用关联规则挖掘来发现现有条件、环境因素和新诊断之间的惊人关联。你可能会发现 {空气质量差,饮食差} -> {哮喘} 规则具有高置信度或提升,这可以告知研究人员和医生如何治疗哮喘,也许可以通过更仔细地关注饮食来实现。

关联规则可以应用于许多其他领域,如遗传学、生物信息学和 IT 安全。由于这些方法可以如此广泛地使用,因此很难认识到何时应该应用关联规则。一个很好的经验法则是:如果你的数据集包含事务,或者如果你可以看到自己计算许多事件组合的条件概率,你可能需要考虑关联规则挖掘。

让我们来看看几个用于关联规则挖掘的 JavaScript 库。

示例 – 零售数据

在这个例子中,我们将使用 Apriori 算法来分析一个零售数据集。首先,为这个项目创建一个名为Ch6-Apriori的新文件夹,并添加以下package.json文件:

{
  "name": "Ch6-Apriori",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 6 - Association Rules",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "apriori": "¹.0.7",
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "node-fpgrowth": "¹.0.0"
  }
}

在添加package.json文件后,从命令行运行yarn install以安装依赖项。

接下来,创建一个src目录,并从本书的 GitHub 仓库下载所需的数据文件retail-data.json到文件夹中。

现在将index.js文件添加到src文件夹中,并添加以下代码:

import receipts from './retail-data.json';
import Apriori  from 'apriori';
import {FPGrowth} from 'node-fpgrowth';

const results = (new Apriori.Algorithm(0.02, 0.9, false))
    .analyze(receipts.slice(0, 1000));

console.log(results.associationRules
    .sort((a, b) => a.confidence > b.confidence ? -1 : 1));

上述代码导入数据集和 Apriori 库。然后,使用最小支持度为0.02(2%)和最小规则置信度为 90%初始化一个新的 Apriori 求解器。我们还在数据集中仅分析前 1000 张收据;由于 Apriori 算法本质上比较慢,所以在最初实验时你可能想要限制数据集的大小。

使用yarn start运行程序,你应该会看到类似以下输出的结果。输出将比这里显示的更长;花点时间探索你自己的控制台输出:

[ a {
 lhs:
 [ 'KNITTED UNION FLAG HOT WATER BOTTLE',
 'RED WOOLLY HOTTIE WHITE HEART.',
 'SET 7 BABUSHKA NESTING BOXES' ],
 rhs: [ 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 confidence: 1 },
 a {
 lhs:
 [ 'RETRO COFFEE MUGS ASSORTED',
 'SAVE THE PLANET MUG',
 'VINTAGE BILLBOARD DRINK ME MUG',
 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 rhs: [ 'KNITTED UNION FLAG HOT WATER BOTTLE' ],
 confidence: 1 },
 a {
 lhs:
 [ 'RETRO COFFEE MUGS ASSORTED',
 'SAVE THE PLANET MUG',
 'VINTAGE BILLBOARD DRINK ME MUG' ],
 rhs: [ 'WHITE HANGING HEART T-LIGHT HOLDER' ],
 confidence: 1 },

这些关联规则都具有 1.0 的置信度,这意味着右侧(标记为rhs)在左侧出现时 100%的情况下都会出现。

在结果中向下滚动一点,你可能会找到以下规则:

 a {
 lhs: [ 'HAND WARMER BABUSHKA DESIGN', 'HAND WARMER RED RETROSPOT' ],
 rhs: [ 'HAND WARMER BIRD DESIGN' ],
 confidence: 0.9130434782608696 },

这个规则实际上告诉我们,当购物者购买 babushka 和红色复古设计的手暖器时,他们有 91%的可能性也会购买鸟形设计的手暖器。你有没有想过,当你在亚马逊购物时,为什么经常看到类似你刚刚购买或添加到购物车中的商品的建议?这就是原因——显然,购物者经常购买足够多的相似商品,以至于关联规则通过了它需要通过的各种阈值,尽管平均购物者可能不需要三个不同设计的手暖器。但迎合平均购物者并不总是目标;你想要迎合那些会花更多钱的购物者,而你可以通过统计数据找到这样的购物者。

尝试调整 Apriori 设置。如果你降低最小置信度会发生什么?如果你增加最小支持度会发生什么?

在保持最小支持度不变的同时降低最小置信度应该会给你更多的关联规则结果,而不会对执行时间产生实际影响。大部分执行时间都花在发现频繁项集上,此时置信度尚未是一个定义好的参数;置信度只在组合规则时发挥作用,不会影响单个项集。

提高最小支持度将加快算法的速度,然而,你会发现得到的结果不那么有趣。随着你提高最小支持度,你会发现规则的左侧变得更加简单。你以前会看到左侧有三个或四个项的规则,现在你将开始看到只有一项或可能两项的更简单的左侧项集。包含多个项的项集自然倾向于具有较低的支持值,所以随着你提高最小支持度,你最终会得到更简单的关联。

另一方面,降低最小支持度将大大增加执行时间,但也会产生更有趣的结果。请注意,可能存在支持度一般但置信度非常高的规则;这些规则通常成立,但发生频率较低。随着你降低最小支持度,你会发现新出现的规则在置信度值范围内均匀分布。

还可以尝试增加receipts.slice所给的限制。如果你保持最小支持度参数不变,不仅程序会变慢,而且输出中的规则也会更少。原因在于支持值取决于数据集的大小。一个在 1,000 笔交易中出现在 2%的项集可能只在 2,000 笔交易中的 1%出现,这取决于项的分布。如果你有非常多的项选择,或者如果你的项分布是指数衰减的(即,长尾分布),你会发现你需要随着考虑的项的数量成比例地调整最小支持度值。

为了演示这一点,我从一个最小支持度为 0.02、最小置信度为 0.9 以及从收据变量中选取 1,000 项的限制开始。在这些参数下,Apriori 算法找到了 67 条关联规则。当我将限制从 1,000 增加到 2,000 时,算法没有找到任何规则。在前 1,000 笔交易中的频繁项集与后 1,000 笔交易中的项集差异足够大,以至于当我增加限制时,大多数项集的支持值都降低了。

为了找到更多结果,我必须降低最小支持度。我首先尝试将最小支持度设置为 0.01,然而,在等待程序完成两个小时后,我不得不取消那次尝试。我再次尝试设置为 0.015。这次,程序在 70 秒内完成,并给了我 12 个结果。在 0.010 和 0.015 之间,必须存在某个点,使得项集的数量会急剧增加——确实,程序在最小支持度为 0.0125 时找到了 584 条规则。

项集的支持简单是其所有交易中的频率。我们可以用频率来重新表述与支持相关的一切。如果我们考虑 2,000 笔交易,支持值为 0.0125 对应于 25 次出现。换句话说,我刚刚生成的 584 条规则列表只包括在我的 2,000 笔交易数据集中至少被购买 25 次的商品。为了生成只购买过,比如说 5 次或更多次的产品规则,我需要设置最小支持值为 0.0025——一个我相当确信会烧毁我的笔记本电脑的值。

在这里,需要比 Apriori 更精细的算法变得明显。不幸的是,JavaScript 生态系统在这方面仍然缺乏。另一个流行的频繁项集挖掘算法 ECLAT 似乎没有任何 JavaScript 实现。

我们还有另一个可用的频繁项集挖掘算法:FP-Growth 算法。这个算法应该能够轻松地处理我们的任务,然而,我们可用的库只执行频繁项集搜索,并不生成关联规则。一旦发现了频繁项集,生成关联规则就变得容易多了,但我将这个练习留给读者。现在,让我们看看 FP-Growth 库。

index.js文件中,你可以取消与 Apriori 求解器相关的现有行的注释,并添加以下代码:

const fpgrowth = new FPGrowth(0.01);
fpgrowth.exec(receipts)
    .then(result => {
        console.log(result.itemsets);
        console.log("Completed in " + result.executionTime + "ms.");
    });

FP-Growth 实现不生成关联规则,因此它只接受最小支持值作为参数。在这个例子中,我们没有截断receipts交易数据库,因为算法应该能够处理更大的数据集。完整的交易数据库大约有 26,000 条记录,所以最小支持值为0.01对应于被购买至少260次的产品。

从命令行运行yarn start,你应该看到类似以下输出的内容:

[ { items: [ 'DECORATIVE WICKER HEART LARGE' ], support: 260 },
 { items: [ 'MINIATURE ANTIQUE ROSE HOOK IVORY' ], support: 260 },
 { items: [ 'PINK HEART SHAPE EGG FRYING PAN' ], support: 260 },
 ... 965 more items ]
 Completed in 14659ms.

注意,支持值是以绝对值给出的,即项目在数据库中出现的次数。虽然这些只是频繁项集而不是关联规则,但它们仍然很有用。如果你看到以下类似的频繁项集,你可能想在用户浏览糖碗页面时展示玫瑰茶壶:

{ items: [ 'REGENCY SUGAR BOWL GREEN', 'REGENCY TEAPOT ROSES ' ],
 support: 247 }

虽然我认为在 JavaScript 生态系统中,关联规则学习方面还有一些工作要做,但 Apriori 和 FP-Growth 算法都是可用且有用的。特别是 Apriori 的实现,在大多数现实世界的用例中应该很有用,这些用例通常包含较少的交易和较小的商品目录。虽然 FP-Growth 的实现不生成关联规则,但通过找到频繁出现的商品集合,你仍然可以做很多事情。

摘要

在本章中,我们讨论了关联规则学习,或是在事务数据库中寻找频繁项集的方法,并通过概率将它们相互关联。我们了解到,关联规则学习最初是为了市场篮子分析而发明的,但由于其背后的概率理论和事务数据库的概念都具有广泛的应用性,因此它在许多领域都有应用。

接着,我们深入探讨了关联规则学习的数学原理,并研究了频繁项集挖掘的典型算法方法:Apriori 算法。在尝试我们自己零售数据集上的示例之前,我们探讨了关联规则学习的其他可能应用。

第七章:使用回归算法进行预测

在本章中,我们将简要介绍使用回归算法进行预测。我们还将讨论时间序列分析以及我们如何使用数字信号处理技术来辅助我们的分析。到本章结束时,你将看到时间序列和连续值数据中常见的许多模式,并了解哪些类型的回归适合哪些类型的数据。此外,你还将学习一些数字信号处理技术,例如滤波、季节性分析和傅里叶变换。

预测是一个非常广泛的概念,涵盖了多种类型的任务。本章将为你提供一套适用于时间序列数据的初始概念和算法工具箱。我们将关注基础,并讨论以下主题:

  • 回归与分类

  • 回归基础

  • 线性、指数和多项式回归

  • 时间序列分析基础

  • 低通和高通滤波

  • 季节性和减法分析

  • 傅里叶分析

这些概念构建了一个基本的工具箱,你可以在处理现实世界的预测和分析问题时使用。还有许多其他适用于特定情况的工具,但我认为这些主题是绝对的基础。

让我们从比较和对比机器学习(ML)中回归和分类的相似之处和不同之处开始。

回归与分类

本书的大部分内容都与分类任务有关,分析的目标是将数据点拟合到预定义的多个类别或标签之一。在分类数据时,你可以通过将预测值与真实值进行比较来判断你的算法的准确性;一个猜测的标签要么是正确的,要么是错误的。在分类任务中,你通常可以确定一个猜测的标签与数据的匹配可能性或概率,并且你通常选择具有最大可能性的标签。

让我们比较和对比分类任务与回归任务。两者在最终目标上相似,即根据先前的知识或数据进行预测。两者在目标上相似,即我们希望创建某种函数或逻辑,将输入值映射到输出值,并使映射函数尽可能准确和通用。然而,回归和分类之间的主要区别在于,在回归中,你的目标是确定值的数量而不是其标签。

假设你有一份关于你管理的服务器随时间处理负载的历史数据。这些数据是时间序列的,因为数据随时间演变。数据也是连续的(与离散的相对),因为输出值可以是任何实数:1,或 2.3,或 2.34353,等等。在时间序列分析或回归分析中的目标不是标记数据,而是预测例如下周四晚上 20:15 的服务器负载将会是多少。为了实现这个目标,你必须分析时间序列数据,并尝试从中提取模式,然后使用这些模式进行未来预测。你的预测也将是一个真实且连续的数字,例如我预测下周四晚上服务器负载将是 2.75

在分类任务中,你可以通过将预测与真实值进行比较,并计算预测正确或错误的数量来判断算法的准确性。由于回归任务涉及连续值,不能简单地确定预测是否正确。如果你预测服务器负载将是 2.75,结果实际上是 2.65,你能说预测是正确的吗?或者错误的?如果结果是 2.74 呢?当分类垃圾邮件非垃圾邮件时,要么预测正确,要么预测错误。然而,当你比较连续值时,你只能确定预测有多接近,因此必须使用其他指标来定义算法的准确性。

通常,你将使用不同的算法集来分析连续或时间序列数据,而不是用于分类任务。然而,有一些机器学习算法可以通过轻微的修改来处理回归和分类任务。最值得注意的是,决策树、随机森林和神经网络都可以用于分类和回归任务。

在本章中,我们将探讨以下概念:

  • 最小二乘回归技术,如线性回归、多项式回归、幂律回归等

  • 趋势分析或平滑

  • 季节性分析或模式减法

回归基础

在进行回归分析时,有两个主要和整体的目标。首先,我们希望确定和识别数据中任何潜在的、系统的模式。如果我们能识别出系统的模式,我们可能能够识别出导致这些模式的现象,并更深入地理解整个系统。如果你通过分析发现有一个每 16 小时重复一次的模式,你将处于一个更好的位置来弄清楚是什么现象导致了这个模式,并采取行动。与所有机器学习任务一样,这个 16 小时的模式可能深深隐藏在数据中,并且可能一眼看不出来。

第二个主要目标是利用对基本模式的知识来做出未来的预测。你所做的预测将仅与驱动预测的分析一样好。如果你的数据中有四个不同的系统性模式,而你只识别并建模了其中的三个,那么你的预测可能不准确,因为你没有完全建模涉及的现实世界现象。

实现这两个目标依赖于你识别和正式(即数学上)描述模式和现象的能力。在某些情况下,你可能无法完全识别模式的根本原因;即使如此,如果模式是可靠的,你的分析是好的,即使你不完全理解原因,你仍然能够预测系统的未来行为。这是所有机器学习问题的情况;机器学习最终分析的是行为和结果——我们可以衡量的东西,但深入了解原因只能有所帮助。

在所有机器学习问题中,我们还得应对噪声。在分类问题中,噪声可以有多种形式,例如缺失或错误的数据值,或者不可定义的人类行为。在回归问题中,噪声也可以有多种形式:传感器可能容易受到环境噪声的影响,基本过程可能存在随机波动,或者噪声可能由许多难以预测的小型系统性因素引起。

无论是在进行回归分析还是分类分析,噪声总是使模式更难以识别。在回归分析中,你的目标是能够将数据中的系统性行为(实际模式)与随机噪声源分开。在某些情况下,也很重要将噪声建模为一种行为,因为噪声本身可能对你的预测产生重大影响;在其他情况下,噪声可以被忽略。

为了说明系统性模式和噪声之间的区别,考虑以下数据集。图中没有单位,因为这只是一个关于某些依赖参数Y随某些独立参数X变化的抽象示例:

图片

在这个例子中,我们可以清楚地看到系统性模式和噪声之间的区别。系统性模式是稳定的线性增长——Y 值通常随着 X 值的增加而增加,尽管由于噪声导致的点与点之间的波动,Y 值有所波动。通过在这个数据中建模系统性模式,我们就能对当 X 值为 75、100 或-20 时 Y 值将是什么做出合理的预测。噪声是否重要将取决于具体的应用;你可以忽略噪声,或者你可以对其进行建模并将其包含在分析中。

在第一章,“探索 JavaScript 的潜力”中,我们了解了一种处理噪声的技术:使用移动平均进行平滑。我们不是绘制单个点,而是可以一起取三个点的组合并绘制它们的平均值。如果噪声确实是真正的随机且均匀分布的(也就是说,所有噪声效应的平均值接近于零),则移动平均将倾向于消除一些噪声。如果你平均三个点,并且每个点由于噪声产生的影响分别增加+1、-2 和+1.2,那么移动平均将减少噪声的总影响至+0.2。当我们绘制移动平均时,我们通常会找到一个更平滑的模式:

图片

移动平均减少了噪声的影响,并帮助我们更多地关注系统模式——但我们并没有更接近于能够预测未来的值,例如当X为 75 时。移动平均仅帮助我们减少数据集中数据点的噪声影响。例如,当你查看X = 4时的 Y 值时,测量的值大约是 21,而平滑后的值是 28。在这种情况下,28 的平滑值更好地代表了X = 4处的系统模式,尽管在此点的实际测量值是 21。很可能是由于在这次测量时存在一个显著的随机噪声源,导致测量值与系统模式之间存在很大的差异。

在处理噪声时请谨慎。重要的是要认识到,在前面的例子中,实际测量的 Y 值在X = 4时确实是 21。平滑的移动平均是一种理想化。这是我们试图穿过噪声以看到信号的努力,但我们不能忘记实际测量受到了噪声的显著影响。这个事实是否对你的分析重要,很大程度上取决于你试图解决的问题。

那么,我们如何处理预测这些数据未来值的难题呢?移动平均在插值数据时可能对我们有所帮助,但在外推到未来的 X 值时则不然。当然,你可以猜测当X = 75时该值将会是多少,因为此例简单且易于可视化。然而,由于这是一本关于机器学习的书,我们可以假设现实世界的问题不会如此容易通过肉眼分析,我们需要引入新的工具。

这个问题的解决方案是回归。与所有预测性机器学习问题一样,我们希望创建某种抽象函数,可以将输入值映射到输出值,并使用该函数进行预测。在分类任务中,该映射函数可能是一个贝叶斯预测器或基于随机森林的启发式方法。在回归任务中,映射函数通常是一个描述直线、多项式或其他适合数据的形状的数学函数。

如果你曾经在 Excel 或 Google Sheets 中绘制过数据,那么你很可能已经使用了线性回归。这些程序的趋势线功能执行线性回归,以确定最佳拟合数据的映射函数。以下图表是由线性回归确定的趋势线,线性回归是一种用于找到最佳拟合数据的数学线的算法:

此外,Excel 还给我们提供了另一条信息,称为R²值,它是趋势线如何适合数据的表示。R²值接近 1.0 表示趋势线解释了点之间的大部分方差;R²值低表示模型没有解释方差。

我们之前看到的趋势线和移动平均的主要区别在于趋势线是一个实际的数学模型。当你找到一个线性回归的趋势线时,你将得到一个描述整条线的数学公式。移动平均只存在于数据点存在的地方;我们只能在 X = 0 和 X = 50 之间有一个移动平均。另一方面,趋势线由直线的数学公式描述,并且向左和向右无限延伸。如果你知道趋势线的公式,你可以将任何 X 的值代入该公式,并得到 Y 值的预测。例如,如果你发现一条线的公式是 Y = 2.5 x X + 22,你可以将 X = 75 代入,你将得到预测的 Y = 2.5 x 75 + 22,或者 Y = 209.5。从移动平均中无法得到这样的预测。

线性回归只是回归算法的一种类型,专门用于找到适合数据的直线。在本章中,我们将探讨几种其他类型的回归算法,每种算法都有不同的形状。在所有情况下,你可以使用一个描述回归如何适合数据的度量。通常,这个度量将是均方根误差RMSE),它是每个点与趋势线比较的平方误差平均值的平方根。大多数回归算法都是最小二乘法回归,旨在找到最小化 RMSE 的趋势线。

让我们看看几个回归形状的例子以及如何在 JavaScript 中将它们拟合到数据中。

示例 1 – 线性回归

在我们深入第一个例子之前,让我们花一分钟时间设置我们的项目文件夹和依赖项。创建一个名为 Ch7-Regression 的新文件夹,并在该文件夹内添加以下 package.json 文件:

{
  "name": "Ch7-Regression",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 7 - Regression",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0",
    "dspjs": "¹.0.0",
    "regression": "².0.1"
  }
}

然后,从命令行运行 yarn install 命令来安装所有依赖项。接下来,创建一个名为 src 的文件夹,并添加一个名为 index.js 的空文件。最后,从书籍的 GitHub 仓库下载 data.js 文件到 src 文件夹。

在这个例子中,我们将处理上一节中的噪声线性数据。作为提醒,数据本身看起来是这样的:

我们的目标是找到一个适合数据的直线公式,并在 X = 75 时对未来值进行预测。我们将使用汤姆·亚历山大的 regression 库,它可以执行多种类型的回归,并提供基于结果回归进行预测的能力。

index.js 文件中,将以下导入语句添加到文件顶部:

import * as data from './data';
import regression from 'regression';

与所有机器学习问题一样,你应该首先可视化你的数据,并在选择算法之前尝试理解数据的整体形状。在这种情况下,我们可以看到数据遵循线性趋势,因此我们将选择线性回归算法。

在线性回归中,目标是确定最佳拟合数据的直线公式的参数。直线的公式具有以下形式:y = mx + b,有时也写作 y = ax + b,其中 x 是输入变量或自变量,y 是目标或因变量,m(或 a)是直线的斜率梯度,而 b 是直线的截距(当 X = 0 时的 Y 值)。因此,线性回归输出的最小要求是 ab 的值,这两个参数是决定直线形状的唯一两个参数。

将以下导入行添加到 index.js

console.log("Performing linear regression:");
console.log("=============================");
const linearModel = regression.linear(data.linear);
console.log("Slope and intercept:");
console.log(linearModel.equation);
console.log("Line formula:");
console.log(linearModel.string);
console.log("R² fitness: " + linearModel.r2);
console.log("Predict X = 75: " + linearModel.predict(75)[1]);

对数据进行线性回归将返回一个模型;该模型本质上封装了 ab 的值,即直线的斜率和截距。这个特定的库不仅返回 linearModel.equation 属性中的直线参数,还提供了直线公式的字符串表示,计算回归的 R² 拟合度,并给我们一个名为 predict 的方法,我们可以用它将新的 X 值插入到模型中。

通过在命令行中发出 yarn start 命令来运行代码。你应该会看到以下输出:

 Performing linear regression:
 =============================
 Slope and intercept:
 [ 2.47, 22.6 ]
 Line formula:
 y = 2.47x + 22.6
 R² fitness: 0.96
 Predict X = 75: 207.85

回归确定,最适合我们数据的直线公式是 y = 2.47x + 22.6。我用来创建测试数据的原始公式是 y = 2.5x + 22。确定方程与实际方程之间的小差异是由于我添加到数据集中的随机噪声的影响。正如你所见,线性回归很好地超越了噪声,发现了潜在的规律。如果我们绘制这些结果,我们将看到以下情况:

图片

如前图所示,回归的结果与 Excel 或 Google Sheets 的趋势线功能给出的结果完全相同,区别在于我们是在 JavaScript 中生成的趋势线。

当被要求预测 X = 75 的未来值时,回归返回 Y = 207.85。使用我的原始公式,真实值应该是 209.5。我添加到数据中的噪声量相当于任何给定点的随机和均匀噪声水平 +/- 12.5,因此当考虑到噪声引起的不确定性时,预测值非常接近实际值。

然而,需要注意的是,随着你预测越来越远离原始数据域,回归误差会累积。当预测 X = 75 时,预测值与实际值之间的误差仅为 1.65。另一方面,如果我们预测 X = 1000,则真正的公式会返回 2,522,但回归会预测 2,492.6。在 X = 1000 时,实际值与预测值之间的误差现在为 29.4,接近 30,远超过由于噪声引起的不确定性。回归是非常有用的预测工具,但你必须始终记住,这些误差会累积,因此随着你远离数据集的域,预测将变得不那么准确。

这种预测误差的原因在于方程斜率的回归。原始方程中线的斜率是 2.5。这意味着对于 X 值的每单位变化,我们应该期望 Y 值变化 2.5 个单位。另一方面,回归确定斜率为 2.47。因此,对于 X 值的每单位变化,回归会继承一个微小的误差 -0.03。预测值将比实际值略低,这个量乘以你的预测的 X 距离。对于每 10 个单位的 X,回归会继承总共 -0.3 的误差。对于每 100 个单位的 X,回归会继承 -3.0 的误差,依此类推。当我们外推到 X=1000 时,我们继承了 -30 的误差,因为那个每单位微小的误差 -0.03 乘以我们在 x 轴上移动的距离。

当我们查看数据域内的值——X = 0 和 X = 50 之间的值——由于斜率略有差异,我们只会得到非常小的预测误差。在我们的数据域内,回归通过略微增加 y 截距值(原始值为 +22,回归返回 +22.6)来纠正斜率误差。由于我们数据中的噪声,回归公式 y = 2.47x + 22.6 比实际公式 y = 2.5x + 22 更好地拟合。回归找到一个略微平缓的斜率,并通过将整个线提高 0.6 个单位(y 截距的差异)来弥补这一点,因为这样更适合数据和噪声。这个模型在 X = 0 和 X = 50 之间拟合得非常好,但当我们尝试预测 X = 1000 时的值时,y 截距中轻微的 +0.6 修改已不足以弥补如此巨大距离上的斜率下降。

像这个例子中找到的线性趋势非常常见。有许多类型的数据表现出线性关系,只要你不尝试过度外推数据,就可以简单而准确地建模。在下一个例子中,我们将查看指数回归。

示例 2 – 指数回归

连续数据模式中的另一个常见趋势是 *指数增长**,它也通常被视为 指数衰减。 在指数增长中,未来的值与当前值成比例。这种类型增长的一般公式可以写成:

y = y[0] (1 + r) x

y[0] 表示数量的初始值(当 x = 0 时),而 r 是该数量的增长率。

例如,如果你在股市投资并期望每年有 5%的回报率(r = 0.05),初始投资为 10,000 美元,五年后你可以期望有 12,763 美元。指数增长公式适用于这里,因为明年你拥有的金额与今年你拥有的金额成比例,两年后你拥有的金额与明年你拥有的金额成比例,依此类推。这仅适用于你重新投资回报,导致你积极投资的金额每年增加。

指数增长方程的另一种形式如下:

y = ae^(bx)

其中 b = ln(1 + r)a 是初始值 y[0],而 e 是约等于 2.718 的欧拉常数。这种形式上的轻微变换在数学上更容易操作,并且通常是数学家用于分析的首选形式。在我们的股市投资例子中,我们可以将五年增长公式重写为 y = 10000e^(ln(1.05)5),我们将会得到同样的结果 12,763 美元。

指数增长有时被称为 曲棍球棒增长,因为曲线的形状类似于曲棍球的轮廓:

指数增长的例子包括:

  • 人口增长;即世界人口或细菌培养生长

  • 病毒式增长,例如疾病感染的分析或 YouTube 视频的病毒式传播

  • 机械或信号处理中的正反馈回路

  • 经济增长,包括复利

  • 摩尔定律下计算机的处理能力

重要的是要注意,在几乎所有情况下,指数增长都是不可持续的。例如,如果你在预测培养皿中细菌菌落的增长,你可能会观察到一段时间的指数增长,然而一旦培养皿中的食物和空间耗尽,其他因素将占主导地位,增长将不再呈指数形式。同样,如果你的网站通过激励新用户邀请朋友来增加会员,你可能会看到一段时间内会员数的指数增长,但最终市场将饱和,增长会放缓。因此,在分析指数增长模型时必须谨慎,并理解推动指数增长的条件最终可能会改变。与线性回归类似,指数回归只适用于数据的适度外推。你的网站会员可能一年内会呈指数增长,但不可能持续十年;地球上只有 70 亿人口,你不能有 200 亿会员。

如果增长率r或参数k(称为增长常数)为负,那么你将得到指数衰减而不是指数增长。尽管在指数衰减中,未来的值与当前值成比例,但未来的值在比例上比当前值

指数衰减的一个实际应用是在碳-14 年代测定中。因为放射性碳-14 同位素以 5730 年的半衰期衰变为非放射性碳-12——这意味着在总体上,每 5730 年有一半的碳-14 衰变为碳-12——科学家可以使用指数衰减公式来计算出物体必须有多久才能达到碳-14 与碳-12 的适当比例。

指数衰减在物理学和力学中也有所体现,尤其是在弹簧-质量-阻尼问题中。验尸官和法医也可以利用这一原理,根据尸体温度以指数衰减的方式逐渐接近室温来确定死亡时间。

在指数回归中,我们的目标是确定参数ab的值——初始值和增长常数。让我们用 JavaScript 来尝试一下。我们希望分析的数据是指数衰减的,并添加了随机传感器噪声:

图片

前面的图表显示了一些量,它从接近 100 开始衰减到大约 0。例如,这可以代表在 Facebook 上分享的帖子随时间变化的访问者数量。

尝试使用 Excel 或 Google Sheets 来拟合趋势线在这种情况下并不能帮助我们。线性趋势线并不适合指数曲线,不合适的拟合可以通过较差的 R²值来表示:

图片

现在我们使用 JavaScript 来找到这个数据的回归,并预测数据集开始前一秒的值。将以下代码添加到index.js中;这是线性回归代码:

console.log("Performing exponential regression:");
console.log("=============================");
const expModel = regression.exponential(data.exponential);
console.log("Initial value and rate:");
console.log(expModel.equation);
console.log("Exponential formula:");
console.log(expModel.string);
console.log("R² fitness: " + expModel.r2);
console.log("Predict X = -1: " + expModel.predict(-1)[1]);

使用yarn start运行程序,你应该看到以下输出,紧随线性回归示例的输出之后:

 Performing exponential regression:
 =============================
 Initial value and rate:
 [ 94.45, -0.09 ]
 Exponential formula:
 y = 94.45e^(-0.09x)
 R² fitness: 0.99
 Predict X = -1: 103.34

我们可以立即看到高 R²值 0.99,这表明回归已经很好地拟合了数据。如果我们将这个回归与原始数据一起绘制,我们会看到以下结果:

图片

此外,我们还得到了 X = -1 时的预测值为 103,这与我们的数据拟合得很好。我用来生成测试数据的方程的原始参数是a = 100b = -0.1,而预测的参数是a = 94.5b = -0.09。噪声的存在对起始值产生了重大影响,如果没有噪声,起始值应该是 100,但实际上测量值为 96。当比较回归值a与实际值a时,你还必须考虑回归值a接近测量值(即使它离系统值相当远)这一事实。

在下一节中,我们将探讨多项式回归。

示例 3 – 多项式回归

多项式回归可以被认为是线性回归的更一般形式。多项式关系的形式为:

y = a[0] + a[1]x¹ + a[2]x² + a[3]x³ + ... + a[n]x^n

多项式可以有任意多个项,这被称为多项式的次数。对于多项式的每个次数,自变量x乘以某个参数a[n],X 值被提升到n次幂。一条直线被认为是一次多项式;如果你更新前面的多项式公式以删除高于一次的所有次数,你将剩下:

y = a[0] + a[1]x

其中a[0]是 y 轴截距,a[1]是线的斜率。尽管符号略有不同,但这与y = mx + b是等价的。

二次方程,你可能还记得从高中数学中学过的,它仅仅是二次的多项式,或者y = a[0] + a[1]x + a[2]x²。三次方程是三次的多项式,四次方程是四次的多项式,以此类推。

多项式和多项式回归的属性使它们如此强大,是因为在有限的值范围内,几乎任何形状都可以用足够次数的多项式来描述。只要你不尝试过度外推,多项式回归甚至可以拟合正弦形状。多项式回归在某种程度上表现出与其他机器学习算法相似的性质,即如果你尝试过度外推,它们可能会过拟合并对于新的数据点变得非常不准确。

因为多项式可以是任何次数,所以你也必须配置回归的附加参数;这个参数可以猜测,或者你可以寻找最大化 R²拟合度的次数。这种方法与我们用于 k-means(当你事先不知道聚类数量时)的方法类似。

我们希望拟合的数据,当绘制时,看起来像这样:

这个小数据窗口看起来是正弦形的,但实际上是多项式的;记住,多项式方程可以复制许多类型的形状。

将以下代码添加到index.js的底部:

console.log("Performing polynomial regression:");
console.log("=============================");
const polyModel = regression.polynomial(data.polynomial, {order: 2});
console.log("Polynomial parameters");
console.log(polyModel.equation);
console.log("Polynomial formula:");
console.log(polyModel.string);
console.log("R² fitness: " + polyModel.r2);
console.log("Predict X = 6: " + polyModel.predict(6)[1]);

注意,我们已经将回归配置为{order: 2},这意味着我们正在尝试用二次公式拟合数据。使用yarn start运行程序,可以看到以下输出:

 Performing polynomial regression:
 =============================
 Polynomial parameters
 [ 0.28, -17.83, -6.6 ]
 Polynomial formula:
 y = 0.28x² + -17.83x + -6.6
 R² fitness: 0.75
 Predict X = 6: -103.5

这组数据的 R²拟合度相当低,为0.75,这表明我们可能使用了错误的order参数值。尝试将顺序增加到{order: 4}并重新运行程序,可以得到以下结果:

 Performing polynomial regression:
 =============================
 Polynomial parameters
 [ 0.13, 1.45, -2.59, -40.45, 0.86 ]
 Polynomial formula:
 y = 0.13x⁴ + 1.45x³ + -2.59x² + -40.45x + 0.86
 R² fitness: 0.99
 Predict X = 6: 146.6

现在回归拟合得更好了,但代价是方程中添加了额外的多项式项。如果我们用原始数据来绘制这个回归,我们会看到以下输出,这确实很好地拟合了数据:

在下一节中,我们将探讨可以在时间序列数据上执行的其他类型分析,包括低通滤波器、高通滤波器和季节性分析。

其他时间序列分析技术

回归分析是分析连续数据的良好起点,然而,在分析特定的时间序列数据时,还有许多其他技术可以采用。虽然回归可以用于任何连续数据的映射,但时间序列分析专门针对随时间演变的连续数据。

时间序列数据有很多例子,例如:

  • 服务器负载随时间变化

  • 股票价格随时间变化

  • 用户活动随时间变化

  • 气候模式随时间变化

分析时间序列数据时的目标与使用回归分析连续数据时的目标相似。我们希望识别和描述影响随时间变化的值的各种因素。本节将描述一些超越回归的技术,您可以使用这些技术来分析时间序列数据。

在本节中,我们将探讨来自数字信号处理领域的技巧,该领域在电子学、传感器分析和音频信号处理中都有应用。虽然你的具体时间序列问题可能与这些领域无关,但数字信号处理应用中使用的工具可以应用于任何处理数字信号的领域。其中最显著的工具和技术包括滤波、季节性检测和频谱分析。我们将讨论这些技术,但我将把实现自己的示例和实验留给你。

滤波

在数字信号处理的背景下,滤波是一种用于过滤掉信号的高频或低频成分的技术。这些分别被称为低通滤波器高通滤波器;低通滤波器允许低频信号通过,同时从信号中移除高频成分。还有带通滤波器陷波滤波器,它们允许一定范围内的频率通过或从信号中截断一定范围的频率。

在电子学中,通过使用电容器、电阻和其他简单的电子元件来设计滤波器,以便只允许高于或低于一个截止频率的频率通过电路。在数字信号处理中,可以通过一个无限脉冲响应滤波器实现相同的效果,这是一种可以再现电子电路对时间序列数据影响的算法。

为了说明这一点,考虑以下数据:

图片

这组数据是通过组合两个正弦信号生成的,一个是低频信号,另一个是高频信号。如果我们单独绘制这两个信号,我们可以看到它们是如何组合成整体信号的:

图片

在过滤整体信号时,目标是提取信号的低频或高频成分,同时过滤掉另一个成分。这被称为减法处理,因为我们是从信号中移除(过滤)一个成分。

通常情况下,你应该使用低通滤波来隔离时间序列数据中的大范围、一般性的周期性趋势,同时忽略较快的周期性趋势。另一方面,当你希望探索短期周期性趋势而忽略长期趋势时,应该使用高通滤波。这种方法的一个例子是在分析访客流量时;你可以使用高通和低通滤波来选择性地忽略月度趋势与日度趋势。

季节性分析

在上一节的基础上,我们还可以使用数字信号处理来分析季节性趋势。季节性趋势是长期周期性(即低频)趋势,你希望从整体数据中减去,以便分析数据中的其他可能非周期性趋势。考虑以下图表:

图片

这份数据显示了活动周期性波动之上的线性增长组合。具体来说,这个数据趋势有两个周期性成分(一个低频和一个高频)和一个线性成分。

为了分析这些数据,首先的方法是识别线性趋势,无论是通过一个大的移动平均窗口还是通过线性回归。一旦确定了线性趋势,就可以从数据中减去它,以仅隔离周期性部分。以下是如何展示这个方法的:

图片

因为信号是可加的,所以你可以从原始数据中减去线性趋势,以隔离信号的非线性成分。如果你已经通过回归或其他方式识别了多个趋势,你可以继续从原始信号中减去你已识别的趋势,最终只剩下未识别的信号成分。一旦你识别并减去了所有的系统模式,你将只剩下传感器噪声。

在这种情况下,一旦你从数据中识别并减去线性趋势,你可以对结果信号进行滤波,以隔离低频和高频成分,或者可以对剩余信号进行傅里叶分析,以识别剩余成分的具体频率和幅度。

傅里叶分析

傅里叶分析是一种数学技术,用于将时间序列信号分解为其各自的频率分量。回想一下,任意阶数的多项式回归可以复制几乎任何信号形状。以类似的方式,多个正弦振荡器的总和可以复制几乎任何周期性信号。如果你曾经看到过示波器频谱分析仪在工作,你就看到了傅里叶变换应用于信号的实时结果。简而言之,傅里叶变换将周期性信号,如我们在上一节中看到的,转换成类似以下的公式:

a[1]sin(f[1]+φ[1]) + a[2]sin(f[2]+φ[2]) + a[3]sin(f[3]+φ[3]) + ... + a[n]sin(f[n]+φ[n])

其中 f[n] 代表频率,a[n] 代表其幅度,φ[n] 代表相位偏移。通过组合任意数量的这些正弦信号,可以复制几乎任何周期性信号。

进行傅里叶分析有许多原因。最直观的例子与音频和声音处理相关。如果你取一个一秒钟长的音频样本,记录钢琴上演奏的 A4 音符,并对它进行傅里叶变换,你会看到 440 Hz 的频率具有最大的振幅。你还会看到 440 Hz 的谐波,如 880 Hz 和 1,320 Hz,也具有一定的能量。你可以使用这些数据来辅助音频指纹识别、自动调音、可视化以及许多其他应用。傅里叶变换是一种采样算法,因此它容易受到混叠和其他采样误差的影响。傅里叶变换可以用来部分重建原始信号,但在转换过程中会丢失很多细节。这个过程与对图像进行下采样然后再尝试上采样是相似的。

在几乎每个领域,傅里叶变换都有许多其他应用。傅里叶变换之所以受欢迎,是因为在数学上,许多类型的操作在频域中比在时域中更容易执行。在数学、物理和工程中,有许多问题在时域中非常难以解决,但在频域中却容易解决。

傅里叶变换是一种由特定算法执行的计算过程。最流行的傅里叶变换算法称为快速傅里叶变换FFT),之所以命名为 FFT,是因为它比其前身离散傅里叶变换快得多。FFT 有一个显著的限制,即要分析样本的数量必须是 2 的幂,也就是说,它必须是 128、256、512、1,024、2,048 等等样本长。如果你有 1,400 个样本要分析,你必须将其截断到 1,024 个样本或填充到 2,048 个样本。通常,你会对较大的样本进行加窗;在钢琴音符录音的例子中,我们从实时或录制的信号中提取了一秒钟的样本。如果音频采样率为 44,100 Hz,那么我们就有了 44,100 个样本(一秒钟的样本)要提供给傅里叶变换。

当对来自较大信号的样本进行填充、截断或加窗时,你应该使用一个窗函数,这是一个在两端逐渐减小信号的函数,以便它不会被你的窗口锐利地截断。有许多类型的窗函数,每种都有其自己的数学特性和对信号处理的独特影响。一些流行的窗函数包括矩形窗和三角形窗,以及高斯窗、兰佐斯窗、汉宁窗、汉明窗和布莱克曼窗,它们在不同的分析类型中都具有可取的特性。

类似于 FFT 算法的傅里叶变换算法的输出是一个频域频谱。更具体地说,FFT 算法的输出将是一个数组或哈希表,其中键是频率桶(例如 0-10 Hz,10-20 Hz 等),值是幅度和相位。这些可能表示为复数、多维数组或算法实现中特定的其他结构。

所有采样算法都存在一些限制;这些限制是信号处理本身的限制。例如,如果您的信号包含高于奈奎斯特频率的成分,或者采样率的一半,就会发生混叠。在音频中,常见的采样率为 44,100 Hz,任何高于 22,050 Hz 的频率都会发生混叠,或者被错误地表示为低频信号。因此,使用低通滤波器预处理信号是一种常见的技术。同样,FFT 算法只能解析到奈奎斯特频率。FFT 算法将只返回与样本缓冲区大小一样多的频率桶,所以如果您提供 1,024 个样本,您将只得到 1,024 个频率桶。在音频中,这意味着每个频率桶的带宽为 44,100 Hz / 1,024 = 43 Hz。这意味着您可能无法区分 50 Hz 和 55 Hz,但您很容易就能区分 50 Hz 和 500 Hz。为了获得更高的分辨率,您需要提供更多的样本,然而,这反过来又会降低您窗口的时间分辨率。

您可以使用 FFT 来分析我们在上一节中看到的时间序列数据的周期部分。最好在从信号中减去线性趋势后执行 FFT。然而,如果您有足够高的频率分辨率,线性趋势可能只被解释为傅里叶变换的低频成分,因此是否需要减去线性趋势将取决于您的具体应用。

通过将 FFT 添加到本章中您所学的其他工具,您已经准备好应对大多数现实世界的回归或时间序列分析任务。每个问题都是独特的,您将必须仔细考虑您任务中需要哪些特定的工具。

摘要

在本章中,您学习了在预测、信号处理、回归和时间序列数据分析中使用的许多技术。由于预测和时间序列分析是一个广泛的类别,没有单一的算法可以涵盖所有情况。相反,本章为您提供了一个初始的工具箱,其中包含了一些重要的概念和算法,您可以从这些算法开始应用到您的预测和回归任务中。

具体来说,你学习了回归和分类之间的区别。分类将标签分配给数据点,而回归则试图预测数据点的数值。并非所有的回归都是必要的预测,但回归是预测中使用的最显著的单一技术。

在学习回归的基本知识之后,我们探索了几种特定的回归类型。具体来说,我们讨论了线性、多项式和对数回归。我们看到了回归如何处理噪声,以及我们如何利用它来预测未来的值。

然后,我们转向更广泛的时间序列分析概念,并讨论了核心概念,例如从信号中提取趋势。我们讨论了在数字信号处理中适用的工具,这些工具适用于时间序列分析,例如低通和高通滤波器、季节性分析和傅里叶变换。

在下一章中,我们将探讨更高级的机器学习模型。具体来说,我们将学习神经网络——顺便提一下,神经网络也可以执行回归。

第八章:人工神经网络算法

人工神经网络(ANNs)或简称 NNs,可能是今天最流行的机器学习(ML)工具,如果不是最广泛使用的。当时的科技媒体和评论喜欢关注神经网络,许多人认为它们是神奇的算法。人们相信神经网络将为通用人工智能(AGI)铺平道路——但技术现实却大不相同。

虽然神经网络功能强大,但它们是高度专业化的机器学习模型,专注于解决单个任务或问题——它们不是可以现成解决问题的神奇大脑。一个表现出 90%准确率的模型通常被认为是好的。神经网络训练缓慢,需要精心设计和实现。尽管如此,它们确实是高度熟练的问题解决者,可以解开甚至非常困难的问题,例如图像中的物体识别。

神经网络可能在实现通用人工智能(AGI)中扮演重要角色。然而,许多其他机器学习(ML)和自然语言处理(NLP)领域也将需要参与其中。因为人工神经网络(ANNs)仅仅是专门的问题解决者,普遍认为通往 AGI 的道路是通过成千上万的人工神经网络的大集合,每个网络针对一个特定的任务进行优化。我个人认为,我们可能会很快看到类似 AGI 的东西。然而,AGI 最初只能通过巨大的资源来实现——不是指计算能力,而是指训练数据。

在本章中,你将学习神经网络的基础知识。神经网络有许多使用方式,以及许多可能的拓扑结构——我们将在本章和第九章深度神经网络中讨论其中的一些。每种神经网络拓扑都有其自身的目的、优势和劣势。

首先,我们将从概念上讨论神经网络。我们将检查它们的组件和构建,并探讨它们的应用和优势。我们将讨论反向传播算法以及如何训练人工神经网络。然后,我们将简要地看一下人工神经网络的数学,接着深入探讨野外神经网络的实用建议。最后,我们将使用TensorFlow.js库演示一个简单神经网络的例子。

本章我们将涵盖以下主题:

  • 神经网络的概念概述

  • 反向传播训练

  • 示例——TensorFlow.js中的 XOR

神经网络的概念概述

人工神经网络(ANNs)几乎与计算机一样历史悠久,最初确实是由电子硬件构建的。第一个 ANN 是在 20 世纪 70 年代开发的,用于自适应地过滤电话线路传输中的回声。尽管它们最初取得了早期成功,但 ANNs 在 1985 年中期之前一直不太受欢迎,那时反向传播训练算法被普及。

ANNs 是基于我们对生物大脑的理解构建的。ANN 包含许多相互连接的神经元。这些神经元连接的方式、结构和组织被称为网络的拓扑结构(或形状)。每个单独的神经元是一个简单的结构:它接受几个数值输入值,并输出一个数值,这个数值可能随后被传输到几个其他神经元。以下是一个简单的、概念性的神经元示例:

神经网络图

神经元通常但并非总是排列成层。神经元的具体排列和连接由网络的拓扑结构定义。然而,大多数人工神经网络(ANNs)将具有三到四个全连接层,或者每一层的每个神经元都连接到下一层的每个神经元的层。在这些常见的拓扑结构中,第一层是输入层,最后一层是输出层。输入数据直接馈送到输入神经元,算法的结果从输出神经元读取。在输入层和输出层之间,通常有一到两个用户或程序员不直接交互的隐藏层。以下图显示了具有三个层的神经网络:

神经网络图

输入层有四个神经元,单个隐藏层有六个神经元,输出层有两个神经元。描述这种网络的简写方法是列出每层的神经元数量,因此可以简称为4-6-2 网络。这样的网络能够接受四个不同的特征,并输出两份数据,例如 X/Y 坐标,两个属性的布尔值,或者如果输出被视为二进制位,甚至可以是 0-3 的数字。

当使用 ANN 进行预测时,你实际上是在使用前馈模式下的网络,这实际上相当简单。我们将深入讨论神经元的机制,但就目前而言,你需要知道的是,一个神经元接受多个输入,并根据简单的加权总和和光滑函数(称为激活函数)生成单个输出。

为了做出预测,你直接将输入数据加载到输入神经元中。如果你的问题是图像识别问题,那么每个输入神经元可能被提供单个像素的灰度强度(处理一个 50 x 50 像素的灰度图像可能需要 2,500 个输入神经元)。输入神经元被激活,意味着它们的输入被求和、加权、加偏差,然后将结果输入到激活函数中,该函数将返回一个数值(通常在-1 和+1 之间,或 0 和+1 之间)。输入神经元随后将它们的激活输出发送到隐藏层的神经元,这些神经元经历相同的过程,并将结果发送到输出层,输出层再次被激活。算法的结果是输出层激活函数的值。如果你的图像识别问题是具有 15 个可能类别的分类问题,那么输出层将有 15 个神经元,每个神经元代表一个类别标签。输出神经元将返回 1 或 0 的值(或介于两者之间的分数),具有最高值的输出神经元是图像最可能代表的类别。

为了理解像这样的网络实际上是如何产生结果的,我们需要更仔细地研究神经元。在人工神经网络中,神经元有几个不同的特性。首先,一个神经元保持一组(一个向量)的权重。每个输入到神经元的值都乘以其相应的权重。如果你看前面图像中隐藏层最顶部的神经元,你可以看到它从输入层的神经元接收四个输入。因此,隐藏层中的每个神经元必须有一个包含四个权重的向量,每个权重对应于前一层发送信号的神经元。权重基本上决定了特定输入信号对相关神经元的重要性。例如,最顶部的隐藏层神经元可能对最底部的输入神经元有一个权重为 0;在这种情况下,两个神经元基本上是未连接的。另一方面,下一个隐藏神经元可能对最底部的输入神经元有一个非常高的权重,这意味着它非常重视其输入。

每个神经元还有一个偏差。偏差不适用于任何一个单独的输入,而是在激活函数被调用之前添加到加权输入的总和中。偏差可以看作是神经元激活阈值的修饰器。我们很快就会讨论激活函数,但让我们先看看神经元的更新图示:

图片

描述神经元的数学形式大致如下,其中粗体数字 wx 代表输入和权重的向量(即[x[1], x[2], x[3]]),非粗体 by 分别代表神经元的偏差和输出,而 fn(...) 代表激活函数。下面是具体内容:

y = fn( w·x + b)

wx 之间的点表示两个向量的向量点积。另一种写w·x的方式是 w[1]x[1] + w[2]x[2] + w[3]x[3] + … + w[n]x[n],或者简单地表示为 Σ[j] w[j]x[j]*。

总的来说,网络中神经元的权重和偏置实际上负责学习和计算。当你训练一个神经网络时,你是在逐渐更新权重和偏置,目的是将它们配置来解决你的问题。具有相同拓扑结构(例如,两个全连接的 10-15-5 网络)但不同权重和偏置的两个神经网络是不同的网络,将解决不同的问题。

激活函数在这个过程中的作用是什么?人工神经元的原始模型被称为感知器,其激活函数是阶跃函数。基本上,如果一个神经元的w·x + b大于零,那么神经元将输出 1。另一方面,如果w·x + b小于零,那么神经元将输出零。

这个早期的感知器模型之所以强大,是因为可以用人工神经元来表示逻辑门。如果你曾经上过布尔逻辑或电路的课程,你就会知道你可以使用 NAND 门来构建任何其他类型的逻辑门,并且用感知器构建 NAND 门是极其容易的。

想象一个接受两个输入的感知器,每个输入的权重为-2。感知器的偏置为+3。如果两个输入都是 0,那么w·x + b = +3(仅仅是权重,因为所有输入都是零)。由于感知器的激活函数是阶跃函数,在这种情况下神经元的输出将是 1(+3 大于零,因此阶跃函数返回+1)。

如果输入是 1 和 0,无论顺序如何,那么w·x + b = +1,因此感知器的输出也将是 1。然而,如果两个输入都是 1,那么w·x + b = -1。两个输入,权重都是-2,将克服神经元的偏置+3,激活函数(返回 1 或 0)将返回 0。这就是 NAND 门的逻辑:如果两个输入都是 1,感知器将返回 0,对于任何其他输入组合,它将返回 1。

这些早期结果在 20 世纪 70 年代激发了计算机科学和电子学界的兴趣,人工神经网络(ANNs)受到了大量的炒作。然而,我们很难自动训练神经网络。感知器可以通过手工制作来表示逻辑门,并且对神经网络进行一定程度的自动训练是可能的,但对于大规模问题仍然难以接近。

问题在于用作感知器激活函数的阶跃函数。在训练人工神经网络时,你希望网络权重或偏置的微小变化只会导致网络输出的微小变化。但阶跃函数阻碍了这个过程;权重的一个微小变化可能不会改变输出,但下一个微小变化可能导致输出发生巨大变化!这是因为阶跃函数不是一个平滑函数——一旦越过阈值,它就会从 0 突然跳到 1,而在所有其他点上它恰好是 0 或恰好是 1。这种感知器的限制,以及因此人工神经网络的重大限制,导致了十多年的研究停滞。

最终,1986 年,研究人员重新发现了几年前就已经发现的一种训练技术。他们发现,这种称为反向传播的技术使训练变得更快、更可靠。因此,人工神经网络经历了第二次发展。

反向传播训练

有一个关键的洞察力将神经网络研究从停滞中带入了现代时代:为神经元选择更好的激活函数。阶跃函数导致网络自动训练出现问题,因为网络参数(权重和偏置)的微小变化可能会交替产生没有效果或突然的重大效果。显然,这不是一个可训练系统的期望属性。

自动训练人工神经网络的一般方法是从输出层开始,逆向工作。对于训练集中每个示例,你以前馈模式(即预测模式)运行网络,并将实际输出与期望输出进行比较。用于比较期望结果与实际结果的好指标是均方误差MSE);测试所有训练示例,并对每个示例计算输出与期望值之间的差异并平方。将所有平方误差相加,并除以训练示例的数量,你就得到了一个成本函数或损失函数。成本函数是给定网络拓扑的权重和偏置的函数。训练人工神经网络的目标是将成本函数降低到——理想情况下——零。你可以使用人工神经网络在所有训练示例上的准确率作为成本函数,但均方误差在训练中具有更好的数学特性。

反向传播算法的关键在于以下洞察:如果你知道所有神经元的权重和偏置,如果你知道输入和期望的输出,以及如果你知道神经元使用的激活函数,你可以从输出神经元开始反向工作,以发现哪些权重或偏置对大的误差有贡献。也就是说,如果神经元 Z 有来自神经元 A、B 和 C 的输入,它们的权重分别为 100、10 和 0,你就会知道神经元 C 对神经元 Z 没有影响,因此神经元 C 没有对神经元 Z 的误差做出贡献。另一方面,神经元 A 对神经元 Z 有巨大的影响,所以如果神经元 Z 有一个大的误差,那么很可能是神经元 A 的责任。反向传播算法之所以得名,是因为它通过网络将输出神经元的误差反向传播。

将这个概念进一步扩展,如果你还知道激活函数及其与权重、偏置和误差之间的关系,你可以确定权重需要改变多少才能使神经元的输出产生相应的变化。当然,在人工神经网络中有很多权重,它是一个高度复杂的系统,所以我们使用的方法是对权重进行微小的调整——我们只能使用对权重微小变化的简化近似来预测网络输出的变化。这个方法的一部分被称为梯度下降,之所以得名,是因为我们试图通过调整权重和偏置来降低成本函数的梯度(斜率)。

为了形象地理解这一点,想象一个挂在两棵树之间的尼龙吊床。吊床代表成本函数,而xy轴(从天空看)抽象地代表网络的偏置和权重(实际上,这是一个多千维度的图像)。存在一些权重和偏置的组合,使得吊床挂得最低:那个点就是我们的目标。我们是一只坐在吊床表面某处的微小蚂蚁。我们不知道吊床最低点在哪里,而且我们太小了,即使布料上的褶皱或折痕也能让我们偏离方向。但我们知道吊床是光滑且连续的,我们可以在我们周围摸索。只要我们在每一步都朝下山方向前进,我们最终会在吊床中找到最低点——或者至少,一个接近我们起始点(局部最小值)的低点,这取决于吊床形状的复杂程度。

这种梯度下降的方法要求我们数学上理解和能够描述成本函数的梯度,这意味着我们也必须理解激活函数的梯度。函数的梯度本质上是其斜率或导数。我们不能使用感知器原始的步函数作为激活函数的原因是步函数在所有点上都是不可微分的;步函数在 0 和 1 之间巨大的、瞬间的跳跃是一个不可微分的间断。

一旦我们弄清楚我们应该使用梯度下降和反向传播来训练我们的神经网络,其他事情就变得容易了。我们不再使用步函数作为神经元激活函数,而是开始使用 Sigmoid 函数。Sigmoid 函数通常呈阶梯函数形状,但它们被平滑了,是连续的,并且在所有点上都是可微分的。以下是一个 Sigmoid 函数与步函数的例子:

图片

有许多类型的 Sigmoid 函数;前一个函数由方程 y = 1 / (1+e^(-x)) 描述,被称为逻辑函数逻辑曲线。其他流行的 Sigmoid 函数是双曲正切(即 tanh),其范围从 -1 到 +1,与逻辑函数的范围 0 到 +1 相比。另一个流行的激活函数是修正线性单元ReLU),它常用于图像处理和输出层。还有 softplus 函数,其导数实际上是逻辑函数本身。你选择的激活函数将取决于你想要的特定数学属性。在不同的网络层中使用不同的激活函数也很常见;隐藏层通常会使用逻辑或 tanh 激活函数,而输出层可能会使用 softmax,输入层可能会使用 ReLU。然而,你可以为你的神经元发明自己的激活函数,但你必须能够微分该函数并确定其梯度,以便将其与反向传播算法集成。

对神经元激活函数的这种微小改变对我们训练人工神经网络产生了巨大的影响。一旦我们开始使用可微分的激活函数,我们就能计算成本和激活函数的梯度,并利用这些信息来确定在反向传播算法中如何精确地更新权重。神经网络训练变得更快、更强大,神经网络被推进到现代时代,尽管它们仍然需要等待硬件和软件库的跟进。更重要的是,神经网络训练成为了一项数学研究——尤其是矢量微积分——而不是仅限于计算机科学家的研究。

示例 - TensorFlow.js 中的 XOR 操作

在这个例子中,我们将使用TensorFlow.js前馈神经网络来解决 XOR 问题。首先,让我们探索 XOR 问题,以及为什么它对我们来说是一个好的起点。

XOR,或称为排他或操作,是一个布尔运算符,当且仅当其输入中只有一个为真时返回真。与您更熟悉的常规布尔 OR 运算符相比,后者在两个输入都为真时返回真——XOR 在两个输入都为真时返回假。以下是一个比较 XOR 和 OR 的表格;我已经突出显示了 OR 和 XOR 不同的情况:

输入 1 输入 2 OR XOR
False False False False
False True True True
True False True True
True True True False

为什么 XOR 问题对我们来说是一个好的测试?让我们在图上绘制 XOR 操作:

观察前面的图表,我们可以看到在 XOR 操作中涉及的两个类别在图上不是线性可分的。换句话说,不可能画出一条直线来将前面的图中的圆圈与 X 分开。

XOR 操作非常简单,但类别不是线性可分的事实,使得 XOR 操作在测试新的分类算法时是一个极好的起点。您不需要一个复杂的数据集来测试新的库或算法是否适合您。

在跳入 TensorFlow 示例之前,让我们首先讨论我们如何手动构建一个解决 XOR 的神经网络。我们将设计自己的权重和偏差,看看我们是否能开发出一个手动神经网络来解决 XOR。

首先,我们知道网络需要两个输入和一个输出。我们知道输入和输出是二进制的,因此我们必须选择范围在[0, 1]的激活函数;ReLU 或 sigmoid 是合适的,而 tanh,其范围是[-1, 1],则不太合适。

最后,我们知道 XOR 不是线性可分的,因此不能轻易解决;我们需要在网络上添加一个隐藏层。因此,让我们尝试构建一个 2-2-1 神经网络:

接下来,我们需要考虑网络中神经元的权重和偏差。我们知道网络需要设计成对两个输入都为真时有一个惩罚。因此,一个隐藏层神经元应该表示一个弱正信号(即,当输入被激活时它会被激活),而另一个隐藏层神经元应该表示一个强负信号(即,如果两个输入都为真,这个神经元应该压倒弱正神经元)。

这是一个可以用来实现 XOR 的一组权重的示例:

让我们进行几个示例计算。我将从两个输入都为真的不同情况开始。隐藏的 h1 神经元将有一个总加权输入为 4,因为每个输入的权重是 2,且两个输入都为真。h1 神经元还有一个 -1 的偏差,然而,这个偏差不足以使神经元失活。因此,h1 神经元的带偏差输入总和是 3;由于我们还没有决定一个特定的激活函数,我们不会尝试猜测实际的激活会变成什么——只需说一个 +3 的输入足以激活神经元。

我们现在将注意力转向隐藏的 h2 神经元。它也接收来自两个输入神经元的输入,然而,这些权重是负的,因此它接收到的无偏差输入总和是 -4。h2 的偏差是 +3,所以 h2 的总带偏差输入是 -1。如果我们选择 ReLU 激活函数,神经元的输出将是零。无论如何,h2 没有被激活。

最后,我们来看看输出节点。它从 h1 接收一个 +2 的加权输入,但从 h2 接收不到输入。由于输出节点的偏差是 -3(本质上要求 h1 和 h2 都被激活),输出节点将返回 0 或假。这是当两个输入都设置为真或 1 时 XOR 的预期结果。

让我们类似地列出其他 XOR 情况的结果。h1h2Out 列代表神经元在应用激活函数之前的加权带偏差输入(因为我们还没有选择一个)。只需记住,每个神经元将向下一个神经元传输 [0, 1] 范围内的值;激活函数应用后,它不会发送像 -1 或 3 这样的值:

In 1 In 2 h1 h2 Out
0 0 -1 3 -1
0 1 1 1 1
1 0 1 1 1
1 1 3 -1 -1

前面的表格证明了手工制作的 ANN 对所有 XOR 测试案例都有效。它也让我们对网络的内部工作原理有了一点了解。隐藏的 h1 和 h2 神经元有特定的作用。h1 神经元默认是关闭的,但很容易满足,如果任何输入是活跃的,它就会变得活跃;h1 实质上是一个典型的 OR 操作。另一方面,h2 默认是开启的,只有当两个输入都开启时才能被关闭;h2 实质上是一个 NAND 操作。输出神经元需要 h1 和 h2 都活跃,因此输出神经元是一个 AND 操作。

让我们现在使用 TensorFlow.js 库来看看我们是否能取得同样的成功。在你的电脑上,创建一个名为 Ch8-ANN 的新文件夹。添加以下 package.json 文件,然后执行 yarn install

{
  "name": "Ch8-ANN",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 8 - ANN",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "build-web": "browserify src/index.js -o dist/index.js -t [ babelify --presets [ env ] ]",
    "build-cli": "browserify src/index.js --node -o dist/index.js -t [ babelify --presets [ env ] ]",
    "start": "yarn build-cli && node dist/index.js"
  },
  "dependencies": {
    "@tensorflow/tfjs": "⁰.9.1",
    "babel-core": "⁶.26.0",
    "babel-plugin-transform-object-rest-spread": "⁶.26.0",
    "babel-preset-env": "¹.6.1",
    "babelify": "⁸.0.0",
    "browserify": "¹⁵.1.0"
  }
}

现在添加 src/index.js 文件并导入 TensorFlow:

import * as tf from '@tensorflow/tfjs';

TensorFlow 不仅仅是一个 ANN 库。TensorFlow 库提供了一系列在 ANN 和通用 ML(机器学习)以及线性代数(即向量/矩阵数学)问题中都很有用的构建块。因为 TensorFlow 更像是一个工具箱而不是一个单一的工具,所以解决任何给定问题的方式总是多种多样的。

让我们从创建一个顺序模型开始:

const model = tf.sequential();

TensorFlow 的模型是高级容器,本质上运行函数;它们是从输入到输出的映射。你可以使用 TensorFlow 的低级算子(库中附带线性代数工具)来构建你的模型,或者你可以使用一个高级模型类。在这种情况下,我们正在构建一个顺序模型,它是 TensorFlow 通用模型的一个特例。你可以将顺序模型视为一个仅向前传播,不涉及任何内部递归或反馈循环的神经网络。顺序模型本质上是一个传统的神经网络。

接下来,让我们向模型中添加层:

model.add(tf.layers.dense({units: 4, activation: 'relu', inputDim: 2}));
model.add(tf.layers.dense({units: 4, activation: 'relu'}));
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));

我们正在将三层添加到我们的模型中。所有层都是密集层,这意味着它们与下一层完全连接。这正是从传统的神经网络中期望得到的结果。我们为每一层指定了单元——单元是 TensorFlow 对神经元的称呼,因为 TensorFlow 可以在 ANN(人工神经网络)以外的环境中使用。我选择在每个层中使用四个神经元而不是两个,因为我发现额外的神经元大大提高了训练过程的速度和鲁棒性。我们在第一层中指定了inputDim,告诉该层它应该期望每个数据点有两个输入。第一和第二层使用 ReLU 激活函数。第三层,也就是输出层,只有一个单元/神经元,并使用熟悉的 sigmoid 激活函数,因为我希望结果能够更快地趋近于 0 或 1。

现在我们必须编译模型,然后才能使用它。我们将指定一个损失函数,这可以是库中预构建的损失函数,也可以是我们提供的自定义损失函数。我们还将指定我们的优化器;我们在本章前面讨论了梯度下降,但还有许多其他优化器可供选择,例如 Adam、Adagrad 和 Adadelta。在这种情况下,我们将使用随机梯度下降优化器(对于传统的神经网络来说是典型的),然而,我们将选择binaryCrossentropy损失函数,这对于我们的二分类任务来说比均方误差更合适:

const learningRate = 1;
const optimizer = tf.train.sgd(learningRate);
model.compile({loss: 'binaryCrossentropy', optimizer, metrics: ['accuracy']});

我们还设置了梯度下降优化器的学习率;学习率决定了反向传播训练算法在每次训练生成或 epoch 中修改权重和偏置的程度。较低的学习率会导致网络训练时间更长,但会更稳定。较高的学习率会使网络训练得更快,但可靠性较低;如果学习率过高,你的网络可能根本无法收敛。

最后,我们在编译步骤中添加了metrics: ['accuracy']。这允许我们在最终调用model.evaluate时获取关于网络准确性的报告。

接下来,我们将设置我们的训练数据,这仅仅是四个数据点。TensorFlow 在张量上操作,张量本质上是一种数学矩阵。TensorFlow 的张量是不可变的,对张量执行的所有操作都会返回新的张量,而不是修改现有的张量。如果您需要就地修改张量,您必须使用 TensorFlow 的变量,这些变量是围绕张量的可变包装器。TensorFlow 要求所有数学运算都通过张量进行,以便库可以优化 GPU 处理:

// XOR data x values.
const xs = tf.tensor([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
],
// Shape of the tensor is 4 rows x 2 cols
[4, 2]);

// XOR data y values.
const ys = tf.tensor([ 0, 1, 1, 0 ], [4, 1]);

因为张量是矩阵,所以每个张量都有一个形状。对于二维张量,形状定义为[行,列]。对于三维张量,形状是[行,列,深度];图像处理通常使用三维张量,其中行和列代表像素的 Y 和 X 坐标,深度代表该像素的颜色通道(例如,RGBA)。由于我们有四个训练示例,并且每个训练示例需要两个输入字段,因此我们的输入张量具有四行两列的形状。同样,我们的目标值张量具有四行一列的形状。如果您尝试使用错误的输入和输出形状进行计算或训练模型,TensorFlow 将抛出错误。

我们的最后一步是用数据训练模型,然后评估模型。TensorFlow 的model.fit方法是训练模型的方法,一旦训练完成,我们可以使用model.evaluate来获取统计数据,例如准确性和损失,我们还可以使用model.predict在前馈或预测模式下运行模型:

model.fit(xs, ys, {epochs: 1000}).then(() => {
    console.log("Done training. Evaluating model...");
    const r = model.evaluate(xs, ys);

    console.log("Loss:");
    r[0].print();
    console.log("Accuracy:");
    r[1].print();

    console.log("Testing 0,0");
    model.predict(tf.tensor2d([0, 0], [1, 2])).print();
    console.log("Testing 0,1");
    model.predict(tf.tensor2d([0, 1], [1, 2])).print();
    console.log("Testing 1,0");
    model.predict(tf.tensor2d([1, 0], [1, 2])).print();
    console.log("Testing 1,1");
    model.predict(tf.tensor2d([1, 1], [1, 2])).print();
});

添加代码后,从命令行运行yarn start。对我来说,运行这个模型大约需要 60 秒。当模型完成时,您应该看到以下类似输出。请注意,ANNs 和随机梯度下降优化器在初始化和处理时使用随机值,因此模型的某些运行可能不成功,具体取决于特定的随机初始条件。以下是将获得的输出:

Done training. Evaluating model...
 Loss:
 Tensor
 0.00011571444338187575
 Accuracy:
 Tensor
 1
 Testing 0, 0
 Tensor
 [[0.0001664],]
 Testing 0, 1
 Tensor
 [[0.9999378],]
 Testing 1, 0
 Tensor
 [[0.9999322],]
 Testing 1, 1
 Tensor
 [[0.0001664],]

前面的输出显示模型已经学会了模拟 XOR。损失值非常低,而准确率为 1.0,这对于这样一个简单的问题来说是必需的。在现实世界的问题中,80-90%的准确率更为现实。此外,程序的输出显示了四个测试案例的每个案例的单独预测。您可以看到 sigmoid 激活函数的影响,值非常接近 0 和 1,但并未完全达到。内部,TensorFlow 将这些值四舍五入,以确定分类是否正确。

到目前为止,你应该对网络参数进行一些实验。如果你减少训练的 epoch 数量会发生什么?如果你将 ReLU 层切换为 sigmoid 层会发生什么?如果你将前两层中的单元/神经元数量减少到两个会发生什么?如果你增加训练的 epoch 数量,会发生什么?学习率对训练过程有什么影响?这些都是最好通过试错而不是讲座来发现的事情。这是一个无限灵活的神经网络模型,能够处理比简单的 XOR 示例更复杂的问题,因此你应该通过实验和研究来深入了解所有这些属性和参数。

虽然这个例子只是一个简单的异或(XOR)样本,但这种方法也可以用于许多其他类型的 ANN 问题。我们已经创建了一个三层二进制分类器,它可以自动训练和评估自己——这是终极的纯神经网络。虽然我会在下一章尝试一些高级神经网络模型,例如卷积网络和循环网络,但我将把这些概念的应用留给你们去实际操作。

摘要

本章介绍了人工神经网络的概念,并从概念角度进行了讨论。你了解到神经网络由单个神经元组成,这些神经元是简单的加权加法机,可以对它们的输出应用激活函数。你还了解到神经网络可以有多种拓扑结构,网络中的拓扑以及神经元之间的权重和偏置才是实际工作的部分。你还学习了反向传播算法,这是神经网络自动训练的方法。

我们还研究了经典的 XOR 问题,并通过神经网络的角度来审视它。我们讨论了使用 ANN 解决 XOR 的挑战和解决方法,甚至亲手构建了一个完全训练好的 ANN 来解决 XOR 问题。然后我们介绍了TensorFlow.js库,并使用它构建了一个纯神经网络,并成功使用该神经网络训练和解决 XOR 问题。

在下一章,我们将更深入地探讨高级 ANN 拓扑结构。特别是,我们将讨论卷积神经网络CNN),它在图像处理中广泛使用,我们还将查看循环神经网络RNN),它在人工智能和自然语言任务中常用。

第九章:深度神经网络

在上一章中,我们讨论了神经网络及其基本操作。具体来说,我们讨论了全连接前馈神经网络,这只是众多可能的 ANN 拓扑结构中的一种简单拓扑。在本章中,我们将重点关注两种高级拓扑:卷积神经网络CNN)和一种称为长短期记忆LSTM)网络的循环神经网络。CNNs 通常用于图像处理任务,如目标检测和图像分类。LSTM 网络常用于自然语言处理或语言建模问题。

这些异构的 ANN 拓扑被认为是深度神经网络DNNs)。虽然这个术语没有很好地定义,但 DNNs 通常被理解为在输入层和输出层之间有多个隐藏层的 ANN。卷积网络架构可以非常深,网络中有十个或更多的层。循环架构也可以很深,然而,它们的大部分深度来自于信息可以通过网络向前或向后流动的事实。

在本章中,我们将探讨 TensorFlow 在 CNN 和 RNN 架构方面的能力。我们将讨论 TensorFlow 自己提供的这些拓扑示例,并查看它们在实际中的应用。特别是,我们将讨论以下主题:

  • CNNs

  • 简单 RNN

  • 门控循环单元网络

  • LSTM 网络

  • 用于高级应用的 CNN-LSTM 网络

让我们从查看一个经典的机器学习ML)问题开始:从图像中识别手写数字。

卷积神经网络

为了说明 CNNs,让我们首先想象一下我们如何使用标准的全连接前馈 ANN 来处理图像分类任务。我们从一个 600 x 600 像素大小、有三个颜色通道的图像开始。这样的图像中编码了 1,080,000 条信息(600 x 600 x 3),因此我们的输入层需要 1,080,000 个神经元。如果网络中的下一层包含 1,000 个神经元,我们只需要在第一层和第二层之间维护十亿个权重。显然,问题已经变得难以承受。

假设本例中的 ANN 可以训练,我们还会遇到规模和位置不变性的问题。如果你的任务是识别图像中是否包含街道标志,网络可能难以理解街道标志可以位于图像的任何位置。网络在颜色上也可能有问题;如果大多数街道标志是绿色的,它可能难以识别蓝色标志。这样的网络需要许多训练示例来解决规模、颜色和位置变化的问题。

在卷积神经网络(CNN)变得流行之前,许多研究人员将这个问题视为一个降维问题。一种常见的策略是将所有图像转换为灰度,通过三倍的比例减少数据量。另一种策略是将图像下采样到更易于管理的尺寸,例如 100 x 100 像素,甚至更小,这取决于所需的处理类型。将我们的 600 x 600 像素图像转换为灰度并缩小到 100 x 100 像素将使输入神经元的数量减少 100 倍,从一百万减少到一万,并将输入层和包含 1000 个神经元的隐藏层之间的权重数量从十亿减少到只有一千万。

即使使用了这些降维技术,我们仍然需要一个包含数千万个权重的非常大的网络。在处理之前将图像转换为灰度可以避免颜色检测问题,但仍然不能解决尺度和位置变化问题。我们仍然在解决一个非常复杂的问题,因为阴影、梯度以及图像的整体变化性需要我们使用一个非常大的训练集。

另一种常见的预处理策略是对图像执行各种操作,例如噪声减少、边缘检测和平滑处理。通过减少阴影并强调边缘,ANN 可以得到更清晰的信号来学习。这种方法的缺点是预处理任务通常是低效的;相同的边缘检测算法被应用于集合中的每一张图像,无论该特定的边缘检测算法是否对特定图像有效。

因此,挑战在于将图像预处理任务直接集成到人工神经网络(ANN)中。如果 ANN 本身管理预处理任务,网络就可以学习最佳且最有效的方法来预处理图像,以优化网络的准确性。回想一下第八章,《人工神经网络算法》,我们可以在神经元中使用任何激活函数,只要我们能对激活函数进行微分并使用其在反向传播算法中的梯度。

简而言之,CNN 是一个包含多个——可能很多——预处理层的 ANN,这些预处理层在最终到达一个或两个执行实际分类的完全连接层之前对图像进行转换。通过将预处理任务集成到网络中,反向传播算法可以将预处理任务作为网络训练的一部分进行调整。网络不仅会学习如何分类图像,还会学习如何为你的任务预处理图像。

除了标准的 ANN 层类型外,卷积网络还包含几种不同的层类型。这两种类型的网络都包含输入层、输出层和一个或多个完全连接层。然而,CNN 还结合了卷积层、ReLU 层和池化层。让我们逐一看看每个层。

卷积和卷积层

卷积是一种数学工具,它将两个函数组合成一个新的函数;具体来说,新函数表示当另一个函数在其上扫过时,一个函数点乘产生的曲线下的面积。如果这很难想象,不要担心;最简单的方法是将其想象成动画,不幸的是,我们无法在书中打印动画。本章中卷积的数学细节并不重要,但我确实鼓励你阅读有关该主题的额外资料。

大多数图像过滤器——如模糊、锐化、边缘检测和浮雕——都可以通过卷积操作实现。在图像上下文中,卷积由一个卷积矩阵表示,这通常是一个小矩阵(3 x 3、5 x 5 或类似)。卷积矩阵比要处理的图像小得多,卷积矩阵在图像上扫过,因此卷积应用于整个图像的输出构建了一个应用了效果的新图像。

考虑以下梵高的睡莲图像。这是原始图像:

图片

我可以使用我的图像编辑器的卷积矩阵过滤器来创建锐化效果。这与图像编辑器的锐化过滤器有相同的效果,只不过我是手动编写卷积矩阵的:

图片

结果是原始图像的锐化版本:

图片

我也可以编写一个使图像模糊的卷积矩阵:

图片

这会产生以下图像。效果微妙,因为油画本身有点模糊,但效果是有的:

图片

卷积也可以用来浮雕或检测边缘:

图片

前面的矩阵会产生以下效果:

图片

CNN 使用多个卷积层,每个层包含多个卷积过滤器,来构建图像模型。卷积层和卷积过滤器本身是通过反向传播算法进行训练的,网络最终会发现正确的过滤器来增强网络试图识别的特征。与所有学习问题一样,CNN 开发的过滤器类型可能不一定能被人类轻易理解或解释,但在许多情况下,你会发现你的网络开发了许多执行模糊、边缘检测、颜色隔离和梯度检测的卷积过滤器。

除了从图像中提取有用特征外,卷积操作实际上提供了特征的空间和位置独立性。卷积层不是完全连接的,因此能够检查图像的特定区域。这减少了层间权重的维度要求,并有助于我们避免对特征空间位置的依赖。

这些操作中涉及的数据量仍然很大,因此卷积层通常紧接着是池化层,这本质上是对图像进行下采样。最常见的是使用 2 x 2 最大池化,这意味着对于源特征中的每个 2 x 2 像素区域,池化层将下采样 2 x 2 区域到一个像素,该像素具有源 2 x 2 区域中最大像素的值。因此,2 x 2 池化层将图像大小减少到原来的四分之一;因为卷积操作(可能也会降低维度)已经发生,这种下采样通常可以减少计算需求而不会丢失太多信息。

在某些情况下,卷积神经网络将使用简单的 ReLU 激活函数直接跟随卷积操作并直接在池化之前;这些 ReLU 函数有助于避免图像或卷积操作产生的特征图过度饱和。

简单卷积神经网络的一个典型架构看起来可能是这样的:

  • 输入层,具有宽度 x 高度 x 颜色深度神经元

  • 卷积层,具有 M x M 大小的 N 个卷积滤波器

  • 最大池化层

  • 第二个卷积层

  • 第二个最大池化层

  • 全连接输出层

CNN 的更复杂架构通常包括更多的卷积和池化层组合,并且可能在达到池化层之前涉及两个连续的卷积层。

网络中每个后续的卷积层在其之前的卷积层之上运行。第一个卷积层只能执行简单的卷积,例如边缘检测、平滑和模糊。然而,下一个卷积层能够将先前卷积的结果组合成更高层次的特征,例如基本形状或颜色模式。第三个卷积层可以进一步结合先前层的信息来检测复杂特征,例如轮子、路标和手提包。最后的全连接层或层则类似于标准的前馈人工神经网络,并根据卷积层所隔离的高层次特征对图像进行实际分类。

现在我们尝试使用 TensorFlow.js 在 MNIST 手写数字数据集上实际应用这项技术。

示例 - MNIST 手写数字

我们不如从构建一个从第一原理开始的示例,而是通过一个优秀的 TensorFlow.js MNIST 示例来逐步了解。此示例的目标是训练一个卷积神经网络(CNN)来对手写数字图像进行分类。更具体地说,此示例的目标是在 MNIST 手写数字数据集上实现高准确率。在本节中,我们将通过在代码上执行实验并观察其结果来了解代码和算法。

当前版本的此示例可以在 TensorFlow.js 的 GitHub 上找到:github.com/tensorflow/tfjs-examples/tree/master/mnist。然而,由于在撰写本文后存储库可能已更新,我还在本书的示例存储库中添加了我使用的版本作为 Git 子模块。如果您正在使用本书的存储库并且尚未这样做,请从存储库目录中的命令行运行 git submodule initgit submodule update

在终端中,导航到 Ch5-CNN。此路径是一个符号链接,因此如果您的系统上不起作用,您可以改用导航到 tfjs-examples/mnist

接下来,从命令行运行 yarn 来构建代码,最后运行 yarn watch,这将启动一个本地服务器并将您的浏览器打开到 http://localhost:1234。如果您有使用该端口的任何其他程序,您必须首先终止它们。

页面将首先从 Google 的服务器下载 MNIST 图像。然后,它将训练一个 CNN 进行 150 个周期,定期更新显示损失和准确性的两个图表。回想一下,损失通常是像均方误差(MSE)这样的指标,而准确率是正确预测的百分比。最后,页面将显示一些示例预测,突出显示正确与错误的预测。

我对这个页面的测试运行产生了一个准确率约为 92% 的 CNN:

图片

通常,错误的预测是可以理解的。在这个例子中,数字 1 看起来确实有点像数字 2。尽管我遇到过我也会预测错误的例子,但不太可能有人会犯这个特定的错误:

图片

打开 index.js,我们可以在文件顶部看到网络的拓扑结构:

model.add(tf.layers.conv2d({
  inputShape: [28, 28, 1],
  kernelSize: 5,
  filters: 8,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.conv2d({
  kernelSize: 5,
  filters: 16,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.flatten());
model.add(tf.layers.dense(
    {units: 10, kernelInitializer: 'varianceScaling', activation: 'softmax'}));

这个网络有两个卷积层,每个卷积层后面跟着一个池化层,然后是一个单一的用于预测的全连接层。这两个卷积层都使用kernelSize5,这意味着卷积滤波器是一个 5 x 5 的矩阵。第一个卷积层使用八个过滤器,而第二个使用 16 个。这意味着第一层将创建并使用八个不同的卷积滤波器,因此识别图像的八个不同的图形特征。这些特征可能是抽象的,但在第一层中,常见的特征是表示边缘检测、模糊或锐化,或者梯度识别。

第二个卷积层使用 16 个特征,这些特征可能比第一层的特征更高级。这一层可能试图识别直线、圆形、曲线、波浪线等。高级特征比低级特征多,因此第一层使用比第二层少的过滤器是有意义的。

最终的密集层是一个包含 10 个神经元的全连接层,每个神经元代表一个数字。softmax 激活函数确保输出被归一化到 1。这个最终层的输入是第二个池化层的展平版本。数据需要展平,因为卷积和池化层通常是多维的。卷积和池化层使用代表高度、宽度和颜色深度的矩阵,这些矩阵本身又是通过卷积滤波器使用的结果堆叠在一起的。例如,第一个卷积层的输出将是一个体积为[28 x 28 x 1] x 8 大小的体积。括号内的部分是单个卷积操作(即滤波后的图像)的结果,并且已经生成了八个这样的操作。当将此数据连接到向量层,如标准的密集层或全连接层时,它也必须被展平成一个向量。

进入最终密集层的数据比第一层输出的数据小得多。最大池化层的作用是降低图像的尺寸。poolSize参数为[2, 2]意味着一个 2 x 2 像素窗口将被减少到一个单一值;由于我们使用的是最大池化,这将是在该集合中最大的值(最亮的像素)。strides参数意味着池化窗口将以每次两个像素的步长移动。这种池化将图像的高度和宽度都减半,这意味着图像和数据在面积上减少了四倍。第一次池化操作后,图像被减少到 14 x 14,第二次后变为 7 x 7。由于第二个卷积层有 16 个过滤器,这意味着展平层将有7 * 7 * 16 = 784个神经元。

让我们看看通过在输出之前添加另一个全连接层,我们是否能从这个模型中挤出更多准确度。在最佳情况下,增加另一个层将使我们能够更好地解释卷积产生的 16 个特征的相互作用。

然而,增加另一个层会增加所需的训练时间,并且它也可能不会提高结果。完全有可能,通过增加另一个层,我们不再能发现更多信息。始终记住,人工神经网络只是构建和导航数学景观,寻找数据中的形状。如果数据不是高度多维的,增加我们能力的一个维度可能只是不必要的。

在代码的最终密集层之前添加以下行:

model.add(tf.layers.dense(
    {units: 100, kernelInitializer: 'varianceScaling', activation: 'sigmoid'}));

在上下文中,代码现在应该看起来像这样,新行被突出显示:

model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.flatten());
model.add(tf.layers.dense(
 {units: 100, kernelInitializer: 'varianceScaling', activation: 'sigmoid'}));
model.add(tf.layers.dense(
    {units: 10, kernelInitializer: 'varianceScaling', activation: 'softmax'}));

const LEARNING_RATE = 0.15;

由于您已从命令行发出yarn watch,代码应自动重建。刷新页面并观察结果:

算法的学习速度比原始版本慢,这是预期的,因为我们增加了一个新层,因此增加了模型的复杂性。让我们稍微增加训练限制。

找到TRAIN_BATCHES变量并将其更新为300。该行现在应如下所示:

const TRAIN_BATCHES = 300;

保存文件以触发重建并重新加载页面。让我们看看我们是否能打败基线:

看起来我们确实打败了 92%的基线分数,然而我必须谨慎地提醒,不要过于乐观。有可能我们已经过度训练和过度拟合了模型,并且有可能它在现实生活中表现不佳。此外,由于训练和验证是随机的,这个网络的真正准确度可能与基线相当。确实,92%已经是一个非常好的结果,我不期望任何模型能做得更好。然而,这仍然是一个鼓舞人心的结果,因为新层增加的负担并不大。

在这个阶段,请撤销您的更改,以便您使用文件的原始副本进行工作。让我们进行一个不同的实验。看看我们能否在不损失太多准确度的情况下将网络规模缩小到多小,这将会很有趣。

首先,让我们减少第二个卷积层使用的卷积滤波器数量。我的理由是数字使用相当简单的形状:圆形、线条和曲线。也许我们不需要捕捉 16 种不同的特征。也许 8 个就足够了。在第二个卷积层中,将filters: 8更改为filters: 2。您的代码现在应该如下所示:

...
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(tf.layers.conv2d({
  kernelSize: 5,
  filters: 2,
  strides: 1,
  activation: 'relu',
  kernelInitializer: 'varianceScaling'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
...

重新运行代码,我们看到我们仍然得到了相当准确的度,尽管方差略高于基线:

这支持了这样一个总体观点,即使用的形状和特征相对较少。然而,当我们查看测试示例时,我们也发现错误比以前更难以理解。也许我们没有损失很多准确率,但我们的模型变得更加抽象:

图片

我鼓励你继续探索和实验这个示例,因为通过阅读代码你可以学到很多东西。我想特别指出这个示例的一个方面,那就是data.js文件,它负责处理 MNIST 数据集。在你的实际应用中,你可能会需要采用类似的方法,因为你的训练数据不一定总是在本地机器上。这个文件处理从远程源下载数据,将其分为测试集和验证集,并为训练算法维护请求的批次。如果你需要从远程源获取训练数据,这是一个很好的、轻量级的方法。我们将在第十一章深入讨论这个主题,在实时应用中使用机器学习

这里有一些你可以尝试的实验想法:

  • 在保持 90%以上准确率的同时,尽可能使网络规模最小化。

  • 在保持 85%以上准确率的同时,使网络尽可能小。

  • 在少于 50 个 epoch 中将模型训练到 90%以上的准确率。

  • 发现实现 90%以上准确率所需的最少训练示例数量(在data.js中将NUM_TRAIN_ELEMENTS的值减少以使用更少的训练示例)

在下一节中,我们将探讨使用循环神经网络进行序列预测。

循环神经网络

在许多情况下,神经网络需要记忆。例如,当建模自然语言上下文很重要时,也就是说,句子中较晚出现的单词的意义受到句子中较早出现的单词的意义的影响。这与朴素贝叶斯分类器使用的做法形成对比,朴素贝叶斯分类器只考虑单词袋,不考虑它们的顺序。同样,时间序列数据可能需要一些记忆才能做出准确的预测,因为未来的值可能与当前或过去的值相关。

RNN 是一组 ANN 拓扑结构,其中信息不一定只在一个方向上流动。与前馈神经网络相比,RNN 允许神经元的输出反向输入到它们的输入中,从而创建一个反馈循环。循环网络几乎总是时间相关的。然而,时间概念是灵活的;句子中的有序单词可以被认为是时间相关的,因为一个单词必须跟在另一个单词之后。RNN 的时间相关性不一定与时钟上实际时间的流逝相关。

在最简单的情况下,对 RNN 的要求仅仅是神经元的输出值需要连接——通常是通过权重或衰减因子——不仅连接到下一层的神经元,而且也连接回自己的输入。如果你熟悉数字信号处理中的有限脉冲响应FIR)滤波器,这种类型的神经元可以被视为 FIR 滤波器的一种变体。这种类型的反馈会产生一种记忆,因为之前的激活值部分保留并用作神经元下一个周期的输入。你可以将这想象成神经元产生的回声,变得越来越微弱,直到回声不再可闻。因此,以这种方式设计的网络将具有有限的记忆,因为最终回声会消失得无影无踪。

另一种 RNN 的风格是全循环 RNN,其中每个神经元都与网络中的每个其他神经元相连,无论是正向还是反向。在这种情况下,不仅仅是单个神经元可以听到自己的回声;网络中的每个神经元都可以听到其他每个神经元的回声。

虽然这些类型的网络功能强大,但在许多情况下,网络需要比回声持续更长时间的内存。为了解决长期记忆的问题,发明了一种非常强大、异类的拓扑结构,称为LSTM。LSTM 拓扑使用一种称为 LSTM 单元的异类神经元,能够存储所有之前的输入和激活值,并在计算未来激活值时回忆它们。当 LSTM 网络首次推出时,它打破了令人印象深刻的一系列记录,尤其是在语音识别、语言建模和视频处理方面。

在下一节中,我们将简要讨论 TensorFlow.js 提供的三种不同类型的 RNN 拓扑:SimpleRNN(或全循环 RNN)、门控循环单元GRU)网络和 LSTM 网络。

SimpleRNN

TensorFlow.js提供的第一个 RNN 层是 SimpleRNN 层类型,它由一个 SimpleRNNCell 神经元组成。这是一种异类的神经元,可以将自己的输出反馈到输入。这种神经元的输入是一个时间依赖值的向量;每个输入值的激活输出被反馈到下一个值的输入,依此类推。可以指定一个介于 0 和 1 之间的dropout因子;这个值代表每个回声的强度。以这种方式设计的神经元在许多方面类似于 FIR 滤波器。

实际上,这种 RNN 架构是由数字信号处理领域关于 FIR 滤波器的前期工作所实现的。这种架构的优势在于数学原理已被充分理解。可以展开一个 RNN,这意味着可以创建一个多层前馈 ANN,其结果与较少层的 RNN 相同。这是因为神经元反馈的回声是有限的。如果一个神经元已知回声 20 次,那么这个神经元可以被建模为 21 个前馈神经元(包括源神经元)。训练这些网络的初步努力受到了 FIR 滤波器工作的启发,因为分析非常相似。

考虑以下由弗朗索瓦·德洛什(François Deloche)创作的图像(原创作品,CC BY-SA 4.0),它说明了循环神经元的展开:

标记为V的循环表示神经元的反馈操作。当给神经元提供未来输入值(X)时,前一次激活的输出达到输入并成为输入因子。如图所示,这可以建模为一系列简单的神经元的线性序列。

从 TensorFlow 的角度来看,循环层的操作被 TensorFlow 层 API 抽象化。让我们看看 TensorFlow.js 的另一个示例,该示例说明了各种 RNN 架构的可互换性。

从本书的 GitHub 仓库中,导航到Ch9-RNN目录,这再次是一个指向tfjs-examples/addition-rnn目录的符号链接。(如果你仍然在运行之前的 RNN 示例,你需要在运行 yarn watch 命令的终端中按Ctrl + C来停止它。)首先,运行yarn命令来构建代码,然后运行yarn watch再次启动本地服务器并导航到http://localhost:1234

这个特定的例子旨在通过示例教授 RNN 整数加法。训练数据将是一系列问题,如24 + 2214 + 54,以字符串形式表示,网络需要能够解码字符串,将其数值化,学习答案,并将知识扩展到新的示例。

当页面加载时,你会看到以下表单。保持默认设置并点击Train Model按钮:

你将看到类似于以下损失和准确度图,这表明在 100 个训练周期后,该模型的准确度为 93.8%:

损失和相似度图

你还会看到模型针对随机测试输入的测试结果:

让我们更仔细地看看这是如何在底层工作的。打开 index.js 文件并找到 createAndCompileModel 函数。我将假设您为这个示例选择了 SimpleRNN 网络类型,并省略了处理 GRU 和 LSTM 架构的 switch/case 语句:

function createAndCompileModel(
    layers, hiddenSize, rnnType, digits, vocabularySize) {
    const maxLen = digits + 1 + digits;

    const model = tf.sequential();
    model.add(tf.layers.simpleRNN({
        units: hiddenSize,
        recurrentInitializer: 'glorotNormal',
        inputShape: [maxLen, vocabularySize]
    }));
    model.add(tf.layers.repeatVector({n: digits + 1}));
    model.add(tf.layers.simpleRNN({
        units: hiddenSize,
        recurrentInitializer: 'glorotNormal',
        returnSequences: true
    }));
    model.add(tf.layers.timeDistributed(
        {layer: tf.layers.dense({units: vocabularySize})}));
    model.add(tf.layers.activation({activation: 'softmax'}));
    model.compile({
        loss: 'categoricalCrossentropy',
        optimizer: 'adam',
        metrics: ['accuracy']
    });
    return model;
}

这段代码构建了一个包含两个循环层、一个时间分布的全连接层和输出层的模型。vocabularySize 参数表示涉及的总唯一字符数,这些字符是数字 0-9、加号和空格字符。maxLen 参数表示输入字符串的最大长度;对于两位数加法问题,maxLen 将是五个字符,因为必须包括加号。

在这个例子中,特别值得注意的是 timeDistributed 层类型。这是 TensorFlow API 中的一个层包装器,旨在在层中创建一个神经元体积,其中每个切片代表一个时间切片。这与前一个例子中 CNN 使用的体积在精神上相似,其中体积的深度代表一个单独的卷积操作。然而,在这个例子中,体积的深度代表一个时间切片。

timeDistributed 包装器允许每个时间切片由一个单独的密集层或全连接层处理,而不是仅用单个神经元向量来尝试解释时间依赖性数据,在这种情况下,时间数据可能会丢失。timeDistributed 包装器是必需的,因为之前的 simpleRNN 层使用了 returnSequences: true 参数,这导致层不仅输出当前时间步,还输出层历史中遇到的所有时间步。

接下来,让我们看看 GRU 架构。

门控循环单元

GRU 架构由特殊、异质的神经元组成,这些神经元使用几个内部机制来控制神经元的记忆和反馈。GRU 是一项较新的发明,仅在 2014 年作为 LSTM 神经元的简化版本被开发出来。虽然 GRU 比 LSTM 更新,但我首先介绍它,因为它稍微简单一些。

在 GRU 和 LSTM 神经元中,输入信号被发送到多个激活函数。每个内部激活函数可以被认为是一个标准的 ANN 神经元;这些内部神经元被组合起来,以赋予整体神经元其记忆能力。从外部看,GRU 和 LSTM 神经元都看起来像是能够接收时间依赖性输入的神经元。从内部看,这些异质神经元使用更简单的神经元来控制从前一个激活中衰减或增强多少反馈,以及将多少当前信号存储到内存中。

GRU 和 LSTM 神经元相较于简单的 RNN 神经元有两个主要优势。首先,这些神经元的记忆不会像简单 RNN 神经元的回声那样随时间衰减。其次,记忆是可配置和自学习的,也就是说,神经元可以通过训练学习到特定记忆对当前激活的重要性。

考虑以下插图,也是由 François Deloche(本人作品,CC BY-SA 4.0)提供的:

图片

流程图一开始可能有点难以理解。Z[t]信号是一个向量,它控制了多少激活被存储到记忆中并传递给未来的值,而R[t]信号控制了应该从记忆中遗忘多少先前值。这些信号都连接到标准的激活函数,而这些激活函数又都有自己的权重。从某种意义上说,GRU 本身就是一个微型的神经网络。

在这一点上,可能会有人好奇为什么神经元的记忆不能简单地通过编程来实现,例如,使用神经元可以查询的键/值存储。这些架构之所以被使用,是因为反向传播算法需要数学可微性。即使是像 RNN 这样的异构拓扑,也是通过数学方法如梯度下降进行训练的,因此整个系统必须是数学上可表示的。因此,研究人员需要使用前面的技术来创建一个网络,其中每个组件都是数学上可分析和可微的。

http://localhost:1234的测试页面上,将RNN 类型参数更改为 GRU,同时保持所有其他参数不变,然后再次点击训练模型。图表将更新,你应该会看到以下内容:

图片

在这种情况下,训练过程花费了更长的时间,但准确性从 SimpleRNN 类型的 92%提高到了 95%。增加的训练时间并不令人惊讶,因为 GRU 架构实际上将网络使用的激活函数数量增加了三倍。

虽然许多因素会影响网络的准确性,但有两个明显的因素脱颖而出。首先,GRU 拓扑具有长期记忆,而 SimpleRNN 最终会忘记先前值,因为它们的回声衰减。其次,GRU 对激活信号输入未来激活以及保留信息的控制更加精确。这些网络的参数是通过反向传播算法训练的,因此神经元的遗忘性本身是通过训练进行优化的。

接下来,让我们看看那个启发 GRU 并开辟了全新研究领域拓扑结构:LSTM。

长短期记忆

LSTM 在 1997 年被引入,由于其在解决历史难题方面的出色准确率,在学术人工神经网络社区中引起了轰动。特别是,LSTM 在许多自然语言处理任务、手写识别和语音识别方面表现出色。在许多情况下,LSTM 网络以很大的差距打破了之前的准确率记录。许多处于语音识别和语言建模前沿的系统都使用了 LSTM 网络。很可能是像苹果的 Siri 和谷歌的 Assistant 这样的系统,在它们的语音识别和语言解析模型中都使用了 LSTM 网络。

LSTM 网络之所以得名,是因为它能够长时间保留短期记忆(例如,句子中较早使用过的单词的记忆)。在训练过程中,这避免了被称为“梯度消失”的问题,这是简单 RNN 在先前激活的回声逐渐消失时所遭受的问题。

与 GRU 一样,LSTM 神经元是一种具有复杂内部工作的异类神经元细胞。具体来说,LSTM 神经元有三个内部使用的:一个输入门,它控制允许进入神经元的值的量;一个遗忘门,它管理神经元的记忆;以及一个输出门,它控制允许进入神经元输出的信号的量。门的组合,加上神经元之间都是相互连接的,使得 LSTM 对神经元记住哪些信号以及如何使用这些信号具有非常精细的控制。与 GRU 中的门一样,LSTM 中的门也可以被视为具有自己权重的独立标准神经元。

考虑以下由弗朗索瓦·德洛什(François Deloche)制作的图形(本人作品,CC BY-SA 4.0):

图片

I[t]信号控制允许进入细胞的输入信号的比例。O[t]信号控制允许从细胞中输出的输出量,而F[t]信号控制细胞保留先前值的量。记住,这些都是矢量量,因此输入、输出和记忆可以按元素进行控制。

LSTM 在需要记忆和先前值知识的任务上表现出色,尽管细胞复杂的内部工作(涉及五个不同的激活函数)导致训练时间更长。回到你的浏览器中的测试页面,将RNN 类型切换为 LSTM,然后点击训练模型

图片

LSTM(长短期记忆网络)的准确率达到了近 98%,超过了 SimpleRNN 和 GRU RNN 拓扑结构。当然,这个网络训练时间比其他两个都要长,因为简单的事实是,需要训练的神经元(在 LSTM 细胞内部)更多。

LSTM 网络有许多最先进的用途。它们在音频分析中非常受欢迎,如语音识别,因为音频高度依赖于时间。单个音频样本本身是没有意义的;只有当数千个音频样本在上下文中一起取用时,音频剪辑才开始有意义。一个用于识别语音的 LSTM 首先会被训练来将短音频剪辑(大约 0.1-0.25 秒)解码成音素,即语音声音的文本表示。然后,另一个 LSTM 层会被训练来将音素序列连接起来,以确定最可能说出的短语。第一层 LSTM 依赖于时间依赖性来解释原始音频信号。第二层 LSTM 依赖于时间依赖性为自然语言提供上下文——例如,使用上下文和语法来确定是说了在哪里还是我们在哪里

LSTM 的另一个最先进用例是 CNN-LSTM。这种网络拓扑结合了 CNN 和 LSTM;一个典型的应用将是视频剪辑中的动作检测。模型的 CNN 部分分析单个视频帧(就像它们是独立的图像一样),以识别对象及其位置或状态。模型的 LSTM 部分将单个帧组合在一起,并围绕它们生成一个时间依赖的上下文。如果没有 LSTM 部分,模型将无法判断棒球是静止的还是运动的,例如。是 CNN 检测到的对象先前状态的记忆为确定视频中发生的动作提供了上下文。模型的 CNN 部分识别出棒球,然后 LSTM 部分理解球是在移动的,可能被扔出或击中。

CNN-LSTM 的另一种变体用于自动描述图像。可以给 CNN-LSTM 展示一张站在湖边码头上的女人的图像。模型的 CNN 部分会单独识别图像中的女人、码头和湖作为对象。然后,LSTM 部分可以根据 CNN 收集到的信息生成图像的自然语言描述;是 LSTM 部分在语法上编译了描述,“湖边的码头上的女人”。记住,自然语言描述是时间依赖的,因为单词的顺序很重要。

关于 LSTM 网络的一个最后注意事项与 LSTM 单元中使用的有关。虽然输入、遗忘和输出门通常使用标准的激活神经元,但也可以使用整个神经网络作为门本身。以这种方式,LSTM 可以使用其他模型作为其知识和记忆的一部分。这种方法的典型用例将是自动语言翻译。例如,单个 LSTM 可以用来模拟英语和法语,而一个整体的 LSTM 可以管理两者之间的翻译。

我个人的信念是,LSTM 网络,或其变体,如 GRU 拓扑结构,将在通往通用人工智能(AGI)的道路上扮演关键角色。在尝试模拟通用人类智能时,拥有强大的记忆能力是一个基本要求,而 LSTM 非常适合这种用例。这些网络拓扑结构是人工神经网络(ANN)研究的前沿,因此预计在未来几年内将看到重大进展。

摘要

在本章中,我们讨论了两种高级神经网络拓扑结构:卷积神经网络(CNN)和循环神经网络(RNN)。我们以图像识别的背景讨论了 CNN,特别是手写数字识别的问题。在探索 CNN 的同时,我们还讨论了图像滤波背景下的卷积操作本身。

我们还讨论了如何通过 RNN 架构使神经网络保持记忆。我们了解到 RNN 有许多应用,从时间序列分析到自然语言建模。我们讨论了几种 RNN 架构类型,例如简单的全循环网络和 GRU 网络。最后,我们讨论了最先进的 LSTM 拓扑结构,以及它如何用于语言建模和其他高级问题,如图像标题或视频注释。

在下一章中,我们将探讨一些自然语言处理的实际方法,特别是与机器学习算法最常结合使用的技术。

第十章:自然语言处理实践

自然语言处理是解析、分析和重建自然语言(如书面或口语英语、法语或德语)的科学(和艺术)。这不是一项容易的任务;自然语言处理NLP)是一个完整的研究领域,拥有充满活力的学术研究社区和来自主要科技公司的重大资金支持。每当谷歌、苹果、亚马逊和微软投资其谷歌助手、Siri、Alexa 和 Cortana 产品时,NLP 领域就会获得更多资金。简而言之,NLP 是您能够与手机交谈,手机也能对您说话的原因。

Siri 不仅仅是 NLP。作为消费者,我们喜欢批评我们的人工智能AI)助手当它们犯下可笑的错误。但它们确实是工程奇迹,它们能够做到任何正确的事情都是一个奇迹!

如果我看向我的手机并说,“Ok Google,给我去 7-Eleven 的路线”,我的手机将自动唤醒并对我回应,“好的,去 Main Ave 的 7-Eleven,下一个右转”。让我们思考一下要完成这个任务需要什么:

  • 我的睡眠中的手机正在监控我预先训练的“OK Google”短语。

  • 音频缓冲区在训练的 OK Google 声音波上得到音频哈希匹配,并唤醒手机。

  • 手机开始捕捉音频,这只是一个表示声音波强度的数字时间序列向量。

  • 语音音频被解码为音素,或语音声音的文本表示。为每个话语生成几个候选者。

  • 将候选音素组合在一起,试图形成单词。算法使用最大似然或其他估计器来确定哪种组合最有可能是在当前上下文中实际使用的句子。

  • 结果句子必须解析其意义,因此执行了许多类型的预处理,并且每个单词都被标记为其可能的词性POS)。

  • 一个学习系统(通常是人工神经网络)将尝试根据短语的主题、宾语和动词确定意图。

  • 实际意图必须由子例程执行。

  • 必须制定对用户的响应。在响应无法脚本化的情况下,它必须通过算法生成。

  • 文本到语音算法将响应解码为音素,然后必须合成听起来自然的语音,该语音随后通过手机的扬声器播放。

恭喜你,你正在走向获得你的 Slurpee!您的体验由多个人工神经网络、各种 NLP 工具的多种用途、庞大的数据集以及数百万工程师小时的努力来构建和维护。这种体验还解释了 NLP 和 ML 之间的密切关系——它们不是同一件事,但它们在技术前沿并肩作战。

显然,NLP 的内容远不止 25 页所能涵盖的主题。本章的目标不是全面介绍;它的目标是使你熟悉在解决涉及自然语言的 ML 问题时最常用的策略。我们将快速浏览七个与 NLP 相关的概念:

  • 测量字符串距离

  • TF-IDF 度量

  • 文本分词

  • 词干提取

  • 语音学

  • 词性标注

  • 使用 Word2vec 进行词嵌入

如果这些主题看起来令人畏惧,请不要担心。我们将逐一介绍每个主题,并展示许多示例。在 NLP 中涉及许多术语,以及许多边缘情况,所以乍一看这个主题似乎难以接近。但毕竟,这个主题是自然语言:我们每天都在说它!一旦我们学会了术语,这个主题就变得相当直观,因为我们大家对语言都有非常强烈的直观理解。

我们将从一个简单的问题开始我们的讨论:你如何测量quitquote之间的距离?我们已经知道我们可以测量空间中两点之间的距离,那么现在让我们来看看如何测量两个单词之间的距离。

字符串距离

总是能够测量两点之间某种形式的距离是非常方便的。在之前的章节中,我们使用了点之间的距离来辅助聚类和分类。我们也可以在 NLP 中对单词和段落做同样的事情。当然,问题是单词由字母组成,而距离由数字组成——那么我们如何从两个单词中得出一个数字呢?

输入 Levenshtein 距离这是一个简单的度量,它衡量将一个字符串转换为另一个字符串所需的单字符编辑次数。Levenshtein 距离允许插入、删除和替换。Levenshtein 距离的一种修改版本,称为Damerau-Levenshtein 距离,也允许交换两个相邻字母。

为了用示例说明这个概念,让我们尝试将单词crate转换为单词plate

  • r替换为l以得到clate

  • c替换为p以得到plate

crate 和 plate 之间的 Levenshtein 距离因此是 2。

激光器之间的距离是 3:

  • 删除p以得到late

  • 插入一个r以得到later

  • t替换为s以得到laser

让我们在代码中确认这些示例。创建一个名为Ch10-NLP的新目录,并添加以下package.json文件:

{
  "name": "Ch10-NLP",
  "version": "1.0.0",
  "description": "ML in JS Example for Chapter 10 - NLP",
  "main": "src/index.js",
  "author": "Burak Kanber",
  "license": "MIT",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {
    "compromise": "¹¹.7.0",
    "natural": "⁰.5.6",
    "wordnet-db": "³.1.6"
  }
}

然后从命令行运行yarn install来安装依赖项。这个package.json文件与之前章节中的文件略有不同,因为wordnet-db依赖项与 Browserify 打包器不兼容。因此,我们将不得不在本章中省略一些高级 JavaScript 功能。

创建一个名为src的目录,并向其中添加一个index.js文件,你将在其中添加以下内容:

const compromise = require('compromise');
const natural = require('natural');

你将在本章的其余部分使用这些导入,所以请将它们保存在index.js文件中。然而,本章中我们使用的其余代码将是可互换的;如果你愿意,在处理本章中的示例时可以删除旧的不相关代码。

让我们使用natural.js库来看看 Levenshtein 距离:

[
    ['plate', 'laser'],
    ['parachute', 'parasail'],
    ['parachute', 'panoply']
]
    .forEach(function(pair) {
        console.log("Levenshtein distance between '"+pair[0]+"' and '"+pair[1]+"': "
            + natural.LevenshteinDistance.apply(null, pair)
        );
    });

在命令行中运行yarn start,你会看到以下输出:

Levenshtein distance between 'plate' and 'laser': 3
Levenshtein distance between 'parachute' and 'parasail': 5
Levenshtein distance between 'parachute' and 'panoply': 7

尝试对几对单词进行实验,看看你是否能在大脑中计算出距离,以获得对它的直观感受。

Levenshtein 距离有许多用途,因为它是一个度量标准,而不是任何特定的工具。其他系统,如拼写检查器、建议器和模糊匹配器,在自己的算法中使用 Levenshtein 或编辑距离度量。

让我们看看一个更高级的度量标准:TF-IDF 分数,它表示一个特定单词在文档集中有多有趣或重要。

词频-逆文档频率

在搜索相关性、文本挖掘和信息检索中最受欢迎的度量标准之一是词频-逆文档频率TF-IDF)分数。本质上,TF-IDF 衡量一个词对特定文档的重要性。因此,TF-IDF 度量标准因此只在单词属于更大文档集的文档的上下文中才有意义。

想象一下,你有一批文档,比如不同主题的博客文章,你希望使其可搜索。你的应用程序的最终用户运行了一个搜索查询,搜索的是fashion style。那么,你如何找到匹配的文档并根据相关性对它们进行排序?

TF-IDF 分数由两个单独但相关的组成部分组成。第一个是词频,即在给定文档中一个特定词的相对频率。如果一个 100 字的博客文章中包含单词fashion四次,那么该文档中单词fashion的词频是 4%。

注意,词频只需要一个词和一个文档作为参数;TF-IDF 的词频组件不需要整个文档集。

单独的词频不足以确定相关性。像thisthe这样的词在大多数文本中都非常常见,并且会有很高的词频,但这些词通常与任何搜索都不相关。

因此,我们在计算中引入了第二个度量标准:逆文档频率。这个度量标准本质上是一个给定单词出现在文档中的百分比的倒数。如果你有 1,000 篇博客文章,而单词fashion出现在其中的 50 篇,那么该单词的非逆文档频率是 5%。逆文档频率是这个概念的扩展,通过取逆文档频率的对数给出。

如果 n[fashion]是包含单词fashion的文档数量,而N是文档总数,那么逆文档频率由log(N / n[fashion])给出。在我们的例子中,单词fashion的逆文档频率大约是 1.3。

如果我们现在考虑单词the,它可能出现在 90%的文档中,我们发现the的逆文档频率是 0.0451,远小于我们为fashion得到的 1.3。因此,逆文档频率衡量的是给定单词在文档集中的稀有程度或独特性;值越高,意味着单词越稀有。计算逆文档频率所需的参数是术语本身和文档语料库(与仅需要一个文档的词频不同)。

TF-IDF 分数是通过将词频和逆文档频率相乘来计算的。结果是单个指标,它封装了单个术语对特定文档的重要性或兴趣,考虑了您所看到的所有文档。像thethat这样的词可能在任何单个文档中具有高词频,但由于它们在所有文档中都普遍存在,它们的总体 TF-IDF 分数将非常低。像fashion这样的词,只存在于文档的子集中,将具有更高的 TF-IDF 分数。当比较两个都包含单词fashion的单独文档时,使用它更频繁的文档将具有更高的 TF-IDF 分数,因为两个文档的逆文档频率部分将是相同的。

在对搜索结果进行相关性评分时,最常见的方法是计算搜索查询中每个术语以及语料库中每个文档的 TF-IDF 分数。每个查询术语的个别 TF-IDF 分数可以相加,得到的总和可以称为该特定文档的相关性分数。一旦所有匹配的文档都以这种方式评分,就可以按相关性排序并按此顺序显示它们。大多数全文搜索引擎,如 Lucene 和 Elasticsearch,都使用这种相关性评分方法。

让我们通过使用natural.js TF-IDF 工具来实际看看。将以下内容添加到index.js中:

const fulltextSearch = (query, documents) => {
    const db = new natural.TfIdf();
    documents.forEach(document => db.addDocument(document));
    db.tfidfs(query, (docId, score) => {
        console.log("DocID " + docId + " has score: " + score);
    });
};

fulltextSearch("fashion style", [
    "i love cooking, it really relaxes me and makes me feel at home",
    "food and restaurants are basically my favorite things",
    "i'm not really a fashionable person",
    "that new fashion blogger has a really great style",
    "i don't love the cinematic style of that movie"
]);

此代码定义了一个fulltextSearch函数,该函数接受一个搜索查询和要搜索的文档数组。每个文档都添加到 TF-IDF 数据库对象中,其中它被natural.js自动分词。使用yarn start运行程序,您将看到以下输出:

DocID 0 has score: 0
DocID 1 has score: 0
DocID 2 has score: 0
DocID 3 has score: 3.4271163556401456
DocID 4 has score: 1.5108256237659907

前两个文档与时尚或风格无关,返回的分数为零。这些文档中时尚风格的词频组件为零,因此整体分数变为零。第三个文档的分数也是零。然而,该文档确实提到了时尚,但是分词器无法将单词时尚的时尚相匹配,因为没有进行词干提取。我们将在本章后面的部分深入讨论分词和词干提取,但就目前而言,了解词干提取是一种将单词还原为其词根形式的操作就足够了。

第三个和第四个文档的分数不为零。第三个文档的分数更高,因为它包含了时尚风格这两个词,而第四个文档只包含了风格这个词。这个简单的指标在捕捉相关性方面做得出奇的好,这也是为什么它被广泛使用的原因。

让我们更新我们的代码以添加一个词干提取操作。在应用词干提取到文本之后,我们预计第二个文档也将有一个非零的相关性分数,因为时尚的应该被词干提取器转换为时尚。将以下代码添加到index.js中:

const stemmedFulltextSearch = (query, documents) => {
    const db = new natural.TfIdf();
    const tokenizer = new natural.WordTokenizer();
    const stemmer = natural.PorterStemmer.stem;
    const stemAndTokenize = text => tokenizer.tokenize(text).map(token => stemmer(token));

    documents.forEach(document => db.addDocument(stemAndTokenize(document)));
    db.tfidfs(stemAndTokenize(query), (docId, score) => {
        console.log("DocID " + docId + " has score: " + score);
    });
};

stemmedFulltextSearch("fashion style", [
    "i love cooking, it really relaxes me and makes me feel at home",
    "food and restaurants are basically my favorite things",
    "i'm not really a fashionable person",
    "that new fashion blogger has a really great style",
    "i don't love the cinematic style of that movie"
]);

我们已经添加了一个stemAndTokenize辅助方法,并将其应用于添加到数据库中的文档以及搜索查询。使用yarn start运行代码,你会看到更新的输出:

DocID 0 has score: 0
DocID 1 has score: 0
DocID 2 has score: 1.5108256237659907
DocID 3 has score: 3.0216512475319814
DocID 4 has score: 1.5108256237659907

如预期的那样,第二个文档现在有一个非零分数,因为词干提取器能够将单词时尚的转换为时尚。第二个和第四个文档的分数相同,但这仅仅是因为这是一个非常简单的例子;在一个更大的语料库中,我们不会期望时尚风格这两个词的逆文档频率是相等的。

TF-IDF 不仅用于搜索相关性和排名。这个指标在许多用例和问题领域中得到了广泛的应用。TF-IDF 的一个有趣用途是文章摘要。在文章摘要中,目标是减少一段文字,只保留几个能够有效总结该段落的句子。

解决文章摘要问题的方法之一是将文章中的每个句子或段落视为一个单独的文档。在为 TF-IDF 索引每个句子之后,然后评估每个单词的 TF-IDF 分数,并使用这些分数对整个句子进行评分。选择前三或五个句子,并按原始顺序显示它们,你将得到一个不错的摘要。

让我们看看这个实际应用,使用natural.jscompromise.js。将以下代码添加到index.js中:

const summarize = (article, maxSentences = 3) => {
    const sentences = compromise(article).sentences().out('array');
    const db = new natural.TfIdf();
    const tokenizer = new natural.WordTokenizer();
    const stemmer = natural.PorterStemmer.stem;
    const stemAndTokenize = text => tokenizer.tokenize(text).map(token => stemmer(token));
    const scoresMap = {};

    // Add each sentence to the document
    sentences.forEach(sentence => db.addDocument(stemAndTokenize(sentence)));

    // Loop over all words in the document and add that word's score to an overall score for each sentence
    stemAndTokenize(article).forEach(token => {
        db.tfidfs(token, (sentenceId, score) => {
            if (!scoresMap[sentenceId]) scoresMap[sentenceId] = 0;
            scoresMap[sentenceId] += score;
        });
    });

    // Convert our scoresMap into an array so that we can easily sort it
    let scoresArray = Object.entries(scoresMap).map(item => ({score: item[1], sentenceId: item[0]}));
    // Sort the array by descending score
    scoresArray.sort((a, b) => a.score < b.score ? 1 : -1);
    // Pick the top maxSentences sentences
    scoresArray = scoresArray.slice(0, maxSentences);
    // Re-sort by ascending sentenceId
    scoresArray.sort((a, b) => parseInt(a.sentenceId) < parseInt(b.sentenceId) ? -1 : 1);
    // Return sentences
    return scoresArray
        .map(item => sentences[item.sentenceId])
        .join('. ');

};

之前的summarize方法实现了以下步骤:

  • 使用compromise.js从文章中提取句子

  • 将每个单独的句子添加到 TF-IDF 数据库中

  • 对于文章中的每个单词,计算其在每个句子中的 TF-IDF 分数

  • 将每个单词的 TF-IDF 分数添加到每个句子的总分数列表(scoresMap对象)中

  • scoresMap转换为数组,以便排序更简单

  • 按降序相关性分数对scoresArray进行排序

  • 删除除了得分最高的句子之外的所有句子

  • 按句子的时间顺序重新排序scoresArray

  • 通过连接得分最高的句子来构建摘要

让我们在代码中添加一个简单的文章,并尝试使用三句和五句的摘要。在这个例子中,我会使用本节的前几段,但你可以用任何你喜欢的内容替换文本。将以下内容添加到index.js中:

const summarizableArticle = "One of the most popular metrics used in search relevance, text mining, and information retrieval is the term frequency - inverse document frequency score, or tf-idf for short. In essence, tf-idf measures how significant a word is to a particular document. The tf-idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. Imagine you have a corpus of documents, like blog posts on varying topics, that you want to make searchable. The end user of your application runs a search query for fashion style. How do you then find matching documents and rank them by relevance? The tf-idf score is made of two separate but related components. The first is term frequency, or the relative frequency of a specific term in a given document. If a 100-word blog post contains the word fashion four times, then the term frequency of the word fashion is 4% for that one document. Note that term frequency only requires a single term and a single document as parameters; the full corpus of documents is not required for the term frequency component of tf-idf. Term frequency by itself is not sufficient to determine relevance, however. Words like this and the appear very frequently in most text and will have high term frequencies, but those words are not typically relevant to any search.";

console.log("3-sentence summary:");
console.log(summarize(summarizableArticle, 3));
console.log("5-sentence summary:");
console.log(summarize(summarizableArticle, 5));

当你使用yarn start运行代码时,你会看到以下输出:

3-sentence summary:
 the tf idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. if a 100-word blog post contains the word fashion four times then the term frequency of the word fashion is 4% for that one document. note that term frequency only requires a single term and a single document as parameters the full corpus of documents is not required for the term frequency component of tf idf

 5-sentence summary:
 one of the most popular metrics used in search relevance text mining and information retrieval is the term frequency inverse document frequency score or tf idf for short. the tf idf metric therefore only makes sense in the context of a word in a document that's part of a larger corpus of documents. the first is term frequency or the relative frequency of a specific term in a given document. if a 100-word blog post contains the word fashion four times then the term frequency of the word fashion is 4% for that one document. note that term frequency only requires a single term and a single document as parameters the full corpus of documents is not required for the term frequency component of tf idf

这些摘要的质量展示了tf-idf 度量的强大功能和灵活性,同时也突出了这样一个事实:你并不总是需要高级的 ML 或 AI 算法来完成有趣的任务。TF-IDF 有许多其他用途,所以你应该考虑在需要将单词或术语与语料库中的文档的相关性相关联时使用此度量。

在本节中,我们使用了分词器和词干提取器,但没有正式介绍它们。这些是 NLP 中的核心概念,所以现在让我们正式介绍它们。

分词

分词是将输入字符串(如句子、段落,甚至是一个对象,如电子邮件)转换为单个tokens的行为。一个非常简单的分词器可能会将句子或段落按空格分割,从而生成单个单词的 tokens。然而,tokens 不一定是单词,输入字符串中的每个单词也不一定需要被分词器返回,分词器生成的每个 tokens 也不一定需要在原始文本中存在,而且一个 tokens 也不一定只代表一个单词。因此,我们使用token这个词而不是word来描述分词器的输出,因为 tokens 并不总是单词。

在使用机器学习算法处理文本之前进行分词的方式对算法的性能有重大影响。许多 NLP 和 ML 应用使用词袋模型方法,其中只关注单词或 tokens,而不关注它们的顺序,就像我们在第五章中探讨的朴素贝叶斯分类器一样,分类算法。然而,生成二元组(即相邻单词的成对)的分词器实际上在用于词袋模型算法时,会保留原始文本的一些位置和语义意义。

文本标记化有许多方法。如前所述,最简单的方法是将句子通过空格拆分以生成一个标记流,其中包含单个单词。然而,简单方法存在许多问题。首先,算法将大写单词视为与其小写版本不同;Buffalo 和 buffalo 被视为两个不同的单词或标记。有时这是可取的,有时则不然。过于简化的标记化还将像won't这样的缩写视为独立且与单词will not不同,后者将被拆分为两个单独的标记,willnot

在大多数情况下,即在 80%的应用中,一个人应该考虑的最简单的标记化是,将所有文本转换为小写,删除标点符号和新行,删除格式化和标记,如 HTML,甚至删除停用词或常见单词,如thisthe。在其他情况下,需要更高级的标记化,在某些情况下,需要更简单的标记化。

在本节中,我一直在描述标记化行为作为一个复合过程,包括大小写转换、删除非字母数字字符和停用词过滤。然而,标记化库将各自有自己的观点,关于标记化器的角色和责任。您可能需要将库的标记化工具与其他工具结合使用,以实现所需的效果。

首先,让我们构建自己的简单标记化器。这个标记化器将字符串转换为小写,删除非字母数字字符,并删除长度少于三个字符的单词。将以下内容添加到您的index.js文件中,要么替换 Levenshtein 距离代码,要么添加到其下方:

const tokenizablePhrase = "I've not yet seen 'THOR: RAGNAROK'; I've heard it's a great movie though. What'd you think of it?";

const simpleTokenizer = (text) =>
    text.toLowerCase()
        .replace(/(\w)'(\w)/g, '$1$2')
        .replace(/\W/g, ' ')
        .split(' ')
        .filter(token => token.length > 2);

console.log(simpleTokenizer(tokenizablePhrase));

这个simpleTokenizer会将字符串转换为小写,删除单词中间的撇号(因此won't变为wont),并通过将所有其他非单词字符替换为空格来过滤掉所有其他非单词字符。然后,它通过空格字符拆分字符串,返回一个数组,并最终删除任何少于三个字符的项目。

运行yarn start,您将看到以下内容:

[ 'ive', 'not', 'yet', 'seen', 'thor',
 'ragnarok', 'ive', 'heard', 'its',
 'great', 'movie', 'though',
 'whatd', 'you', 'think' ]

这个标记流可以被提供给一个算法,无论是按顺序还是无序。例如,朴素贝叶斯分类器将忽略顺序,并将每个单词视为独立进行分析。

让我们比较我们的简单标记化器与natural.jscompromise.js提供的两个标记化器。将以下内容添加到您的index.js文件中:

console.log("Natural.js Word Tokenizer:");
console.log((new natural.WordTokenizer()).tokenize(tokenizablePhrase));

使用yarn start运行代码将产生以下输出:

Natural.js Word Tokenizer:
 [ 'I', 've', 'not', 'yet', 'seen',
 'THOR', 'RAGNAROK', 'I', 've',
 'heard', 'it', 's', 'a', 'great', 'movie',
 'though', 'What', 'd', 'you', 'think',
 'of', 'it' ]

如您所见,短单词已被保留,并且像I've这样的缩写已被拆分为单独的标记。此外,大小写也被保留。

让我们尝试另一个natural.js标记化器:

console.log("Natural.js WordPunct Tokenizer:");
console.log((new natural.WordPunctTokenizer()).tokenize(tokenizablePhrase));

这将产生:

Natural.js WordPunct Tokenizer:
 [ 'I', '\'', 've', 'not', 'yet', 'seen',
 '\'', 'THOR', ': ', 'RAGNAROK', '\'', '; ',
 'I', '\'', 've', 'heard', 'it', '\'', 's',
 'a', 'great', 'movie', 'though', '.', 'What',
 '\'', 'd', 'you', 'think', 'of',
 'it', '?' ]

然而,这个标记化器继续在标点符号上拆分,但标点符号本身被保留。在标点符号重要的应用中,这可能是有需求的。

其他分词库,例如compromise.js中的分词库,采取了一种更智能的方法,甚至在分词的同时进行词性标注,以便在分词过程中解析和理解句子。让我们尝试几种compromise.js的分词技术:

console.log("Compromise.js Words:");
console.log(compromise(tokenizablePhrase).words().out('array'));
console.log("Compromise.js Adjectives:");
console.log(compromise(tokenizablePhrase).adjectives().out('array'));
console.log("Compromise.js Nouns:");
console.log(compromise(tokenizablePhrase).nouns().out('array'));
console.log("Compromise.js Questions:");
console.log(compromise(tokenizablePhrase).questions().out('array'));
console.log("Compromise.js Contractions:");
console.log(compromise(tokenizablePhrase).contractions().out('array'));
console.log("Compromise.js Contractions, Expanded:");
console.log(compromise(tokenizablePhrase).contractions().expand().out('array'));

使用yarn start运行新代码,您将看到以下内容:

Compromise.js Words:
 [ 'i\'ve', '', 'not', 'yet', 'seen',
 'thor', 'ragnarok', 'i\'ve', '', 'heard',
 'it\'s', '', 'a', 'great', 'movie', 'though',
 'what\'d', '', 'you', 'think', 'of', 'it' ]
 Compromise.js Adjectives:
 [ 'great' ]
 Compromise.js Nouns:
 [ 'thor', 'ragnarok', 'movie' ]
 Compromise.js Questions:
 [ 'what\'d you think of it' ]
 Compromise.js Contractions:
 [ 'i\'ve', 'i\'ve', 'it\'s', 'what\'d' ]
 Compromise.js Contractions, Expanded:
 [ 'i have', 'i have', 'it is', 'what did' ]

words()分词器不会像natural.js分词器那样将缩写词分开。此外,compromise.js还为您提供从文本中提取特定实体类型的能力。我们可以分别提取形容词、名词、动词、疑问词、缩写词(甚至具有扩展缩写词的能力);我们还可以使用compromise.js提取日期、标签、列表、从句和数值。

您的标记不必直接映射到输入文本中的单词和短语。例如,当为电子邮件系统开发垃圾邮件过滤器时,您可能会发现将一些来自电子邮件头部的数据包含在标记流中可以大幅提高准确性。电子邮件是否通过 SPF 和 DKIM 检查可能对您的垃圾邮件过滤器来说是一个非常强烈的信号。您还可能发现区分正文文本和主题行也是有益的;可能的情况是,作为超链接出现的单词比纯文本中的单词是更强的信号。

通常,对这种半结构化数据进行分词的最简单方法是在标记前加上一个或一组通常不允许分词器使用的字符。例如,电子邮件主题行中的标记可能以_SUBJ:为前缀,而出现在超链接中的标记可能以_LINK:为前缀。为了说明这一点,这里是一个电子邮件标记流的示例:

['_SPF:PASS',
 '_DKIM:FAIL',
 '_SUBJ:buy',
 '_SUBJ:pharmaceuticals',
 '_SUBJ:online',
 '_LINK:pay',
 '_LINK:bitcoin',
 'are',
 'you',
 'interested',
 'buying',
 'medicine',
 'online']

即使朴素贝叶斯分类器以前从未见过关于药品的引用,它也可能发现大多数垃圾邮件邮件都未能通过 DKIM 检查,但仍将此消息标记为垃圾邮件。或者,也许您与会计部门紧密合作,他们经常收到有关付款的电子邮件,但几乎从未收到包含指向外部网站的超链接中的单词pay的合法电子邮件;在纯文本中出现的*pay*标记与在超链接中出现的_LINK:pay标记之间的区分可能对电子邮件是否被分类为垃圾邮件有决定性的影响。

实际上,最早期的垃圾邮件过滤突破之一,由 Y Combinator 的保罗·格雷厄姆开发,就是使用这种带有注释的电子邮件标记的方法,显著提高了早期垃圾邮件过滤器的准确性。

另一种分词方法是n-gram分词,它将输入字符串分割成 N 个相邻标记的 N 大小组。实际上,所有分词都是 n-gram 分词,然而,在前面的例子中,N 被设置为 1。更典型的是,n-gram 分词通常指的是 N > 1 的方案。最常见的是二元组三元组分词。

二元和三元标记化的目的是保留围绕单个单词的一些上下文。与情感分析相关的一个例子是易于可视化。短语I did not love the movie将被标记化(使用单语标记化器,或 n-gram 标记化器,其中 N = 1)为Ididnotlovethemovie。当使用如朴素贝叶斯这样的词袋算法时,算法将看到单词love并猜测句子具有积极情感,因为词袋算法不考虑单词之间的关系。

另一方面,二元标记化器可以欺骗一个简单的算法去考虑单词之间的关系,因为每一对单词都变成了一个标记。使用二元标记化器处理的前一个短语将变成I diddid notnot lovelove thethe movie。尽管每个标记由两个单独的单词组成,但算法是在标记上操作的,因此会将not loveI love区别对待。因此,情感分析器将围绕每个单词有更多的上下文,并能区分否定(not love)和积极短语。

让我们在先前的示例句子上尝试natural.js二元标记化器。将以下代码添加到index.js中:

console.log("Natural.js bigrams:");
console.log(natural.NGrams.bigrams(tokenizablePhrase));

使用yarn start运行代码将产生:

Natural.js bigrams:
 [ [ 'I', 've' ],
 [ 've', 'not' ],
 [ 'not', 'yet' ],
 [ 'yet', 'seen' ],
 [ 'seen', 'THOR' ],
 [ 'THOR', 'RAGNAROK' ],
 [ 'RAGNAROK', 'I' ],
 [ 'I', 've' ],
 [ 've', 'heard' ],
 [ 'heard', 'it' ],
 [ 'it', 's' ],
 [ 's', 'a' ],
 [ 'a', 'great' ],
 [ 'great', 'movie' ],
 [ 'movie', 'though' ],
 [ 'though', 'What' ],
 [ 'What', 'd' ],
 [ 'd', 'you' ],
 [ 'you', 'think' ],
 [ 'think', 'of' ],
 [ 'of', 'it' ] ]

n-gram 标记化最大的问题是它会显著增加数据域的熵。当在 n-gram 上训练算法时,你不仅要确保算法学习到所有重要的单词,还要学习到所有重要的单词对。单词对的数量比唯一的单词数量要多得多,因此 n-gram 标记化只有在你有一个非常庞大且全面的训练集时才能工作。

一种巧妙地绕过 n-gram 熵问题的方法,尤其是在处理情感分析中的否定时,是将否定词后面的标记以与处理电子邮件标题和主题行相同的方式进行转换。例如,短语not love可以被标记为not, _NOT:love,或者not, !love,甚至只是!love(将not作为一个单独的标记丢弃)。

在这个方案下,短语I did not love the movie将被标记化为Ididnot_NOT:lovethemovie。这种方法的优势在于上下文否定仍然得到了保留,但总的来说,我们仍然使用低熵的单语标记,这些标记可以用较小的数据集进行训练。

标记文本有许多方法,每种方法都有其优缺点。正如往常一样,你选择的方法将取决于手头的任务、可用的训练数据以及问题域本身。

在接下来的几节中,请始终牢记分词的主题,因为这些主题也可以应用于分词过程。例如,您可以在分词后对单词进行词干提取以进一步减少熵,或者您可以根据它们的 TF-IDF 分数过滤您的标记,因此只使用文档中最有趣的单词。

为了继续我们关于熵的讨论,让我们花一点时间来讨论词干提取

词干提取

词干提取是一种可以应用于单个单词的转换类型,尽管通常词干操作发生在分词之后。在分词后进行词干提取非常常见,以至于natural.js提供了一个tokenizeAndStem便利方法,可以附加到String类原型上。

具体来说,词干提取将单词还原为其词根形式,例如将running转换为run。在分词后对文本进行词干提取可以显著减少数据集的熵,因为它本质上去除了具有相似意义但时态或词形不同的单词。您的算法不需要分别学习单词runrunsrunningrunnings,因为它们都将被转换为run

最受欢迎的词干提取算法,即Porter词干提取器,是一种定义了多个阶段规则的启发式算法。但本质上,它归结为从单词末尾切掉标准的动词和名词词形变化,并处理出现的特定边缘情况和常见不规则形式。

从某种意义上说,词干提取是一种压缩算法,它丢弃了关于词形变化和特定单词形式的信息,但保留了由词根留下的概念信息。因此,在词形变化或语言形式本身很重要的场合不应使用词干提取。

由于同样的原因,词干提取在概念信息比形式更重要的情况下表现优异。主题提取就是一个很好的例子:无论是某人写关于自己作为跑者的经历还是观看田径比赛的经历,他们都是在写关于跑步。

由于词干提取减少了数据熵,因此在数据集较小或适度大小时非常有效地使用。然而,词干提取不能随意使用。如果在不必要的情况下使用词干提取,非常大的数据集可能会因准确性降低而受到惩罚。您在提取文本时会破坏信息,具有非常大的训练集的模型可能已经能够使用这些额外信息来生成更好的预测。

在实践中,您永远不需要猜测您的模型是否在带词干或不带词干的情况下表现更好:您应该尝试两种方法,看看哪种表现更好。我无法告诉您何时使用词干提取,我只能告诉您为什么它有效,以及为什么有时它不起作用。

让我们尝试一下natural.js的 Porter 词干提取器,并将其与之前的分词结合起来。将以下内容添加到index.js中:

console.log("Tokenized and stemmed:");
console.log(
    (new natural.WordTokenizer())
        .tokenize(
            "Writing and write, lucky and luckies, part parts and parted"
        )
        .map(natural.PorterStemmer.stem)

使用yarn start运行代码,你会看到以下内容:

Tokenized and stemmed:
 [ 'write', 'and', 'write',
 'lucki', 'and', 'lucki',
 'part', 'part', 'and', 'part' ]

这个简单的例子说明了不同形式的单词是如何被简化为其概念意义的。它还说明了,并不能保证词干提取器会创建出真实的单词(你不会在词典中找到lucki),而只是它会为一系列结构相似的单词减少熵。

有其他词干提取算法试图从更语言学角度来解决这个问题。这种类型的词干提取被称为词元化,而词元的对应物称为词元,或单词的词典形式。本质上,词元化器是一个词干提取器,它首先确定单词的词性(通常需要一个词典,如WordNet),然后应用针对该特定词性的深入规则,可能涉及更多的查找表。例如,单词better在词干提取中保持不变,但通过词元化它被转换成单词good。在大多数日常任务中,词元化并不是必要的,但在你的问题需要更精确的语言学规则或显著减少熵时可能是有用的。

我们在讨论自然语言处理或语言学时,不能不讨论最常见的交流方式:语音。语音转文字或文字转语音系统实际上是如何知道如何说出英语中定义的数十万个单词,以及任意数量的名字的呢?答案是声音学

声音学

语音检测,如语音转文字系统中使用的,是一个出人意料困难的问题。说话的风格、发音、方言和口音,以及节奏、音调、速度和发音的变化如此之多,再加上音频是一个简单的一维时间域信号的事实,因此,即使是当今最先进的智能手机技术也只是良好,而非卓越

虽然现代语音转文字技术比我要展示的深入得多,但我希望向你展示声音学算法的概念。这些算法将一个单词转换成类似声音散列的东西,使得识别听起来相似的字词变得容易。

元音算法就是这样一种声音学算法。它的目的是将一个单词简化为一个简化的声音形式,最终目标是能够索引相似的发音。元音算法使用 16 个字符的字母表:0BFHJKLMNPRSTWXY。0 字符代表th音,X代表shch音,其他字母按常规发音。几乎所有的元音信息都在转换中丢失,尽管如果它们是一个单词的第一个声音,一些元音会被保留。

一个简单的例子说明了音位算法可能在哪里有用。想象一下,你负责一个搜索引擎,人们不断搜索“知识就是力量,法国是培根”。你熟悉艺术史,会明白实际上是弗朗西斯·培根说过“知识就是力量”,而你的用户只是听错了引言。你希望在你的搜索结果中添加一个“你是指:弗朗西斯·培根”的链接,但你不知道如何解决这个问题。

让我们看看 Metaphone 算法如何将France is BaconFrancis Bacon这两个术语音位化。在index.js中添加以下内容:

console.log(
    (new natural.WordTokenizer())
        .tokenize("Francis Bacon and France is Bacon")
        .map(t => natural.Metaphone.process(t))
);

当你使用yarn start运行代码时,你会看到以下内容:

[ 'FRNSS', 'BKN', 'ANT', 'FRNS', 'IS', 'BKN' ]

弗朗西斯已经变成了FRNSS,法国变成了FRNS,而培根变成了BKN。直观上,这些字符串代表了用来发音单词的最易区分的音素。

在音位化之后,我们可以使用 Levenshtein 距离来衡量两个单词之间的相似度。如果你忽略空格,FRNSS BKNFRNS IS BKN之间只有一个 Levenshtein 距离(添加了I);因此这两个短语听起来非常相似。你可以使用这些信息,结合搜索词的其余部分和反向查找,来确定France is BaconFrancis Bacon可能的误读,并且Francis Bacon实际上是你在搜索结果中应该展示的正确主题。像France is Bacon这样的音位拼写错误和误解非常普遍,以至于我们在一些拼写检查工具中也使用它们。

在语音到文本系统中,使用了一种类似的方法。录音系统尽力捕捉你发出的特定元音和辅音音素,并使用音位索引(音位映射到各种词典单词的反向查找)来提出一组候选单词。通常,一个神经网络将确定哪种单词组合最有可能,考虑到音位形式的置信度和结果语句的语义意义或无意义。最有意义的单词集就是展示给你的。

natural.js库还提供了一个方便的方法来比较两个单词,如果它们听起来相似则返回true。尝试以下代码:

console.log(natural.Metaphone.compare("praise", "preys"));
console.log(natural.Metaphone.compare("praise", "frays"));

运行时,这将返回true然后false

当你的问题涉及发音或处理类似发音的单词和短语时,你应该考虑使用音位算法。这通常限于更专业的领域,但语音到文本和文本到语音系统变得越来越受欢迎,你可能会发现自己需要更新你的搜索算法以适应语音相似音素,如果用户未来通过语音与你服务互动的话。

说到语音系统,现在让我们看看 POS 标注以及它是如何用于从短语中提取语义信息的——例如,您可能对智能手机助手下达的命令。

词性标注

词性POS)标注器分析一段文本,如一个句子,并确定句子中每个单词的词性。唯一实现这一点的方法是字典查找,因此它不是一个仅从第一原理开发的算法。

POS 标注的一个很好的用例是从命令中提取意图。例如,当你对 Siri 说“请从约翰的比萨店为我订一份披萨”时,人工智能系统将使用词性对命令进行标注,以便从命令中提取主语、谓语、宾语以及任何其他相关细节。

此外,POS 标注通常用作其他 NLP 操作的辅助工具。例如,主题提取就大量使用了 POS 标注,以便将人、地点和主题从动词和形容词中分离出来。

请记住,由于英语语言的歧义性,POS 标注永远不会完美。许多词既可以作名词也可以作动词,因此许多 POS 标注器将为给定单词返回一系列候选词性。执行 POS 标注的库具有广泛的复杂性,从简单的启发式方法到字典查找,再到基于上下文尝试确定词性的高级模型。

compromise.js 库具有灵活的 POS 标注器和匹配/提取系统。compromise.js 库的独特之处在于它旨在“足够好”但不是全面的;它仅训练了英语中最常见的单词,这对于大多数情况来说足够提供 80-90% 的准确性,同时仍然是一个快速且小巧的库。

让我们看看 compromise.js 的 POS 标注和匹配的实际效果。将以下代码添加到 index.js 中:

const siriCommand = "Hey Siri, order me a pizza from John's pizzeria";
const siriCommandObject = compromise(siriCommand);

console.log(siriCommandObject.verbs().out('array'));
console.log(siriCommandObject.nouns().out('array'));

使用 compromise.js 允许我们从命令中提取仅动词,或仅名词(以及其他词性)。使用 yarn start 运行代码将产生:

[ 'order' ]
[ 'siri', 'pizza', 'john\'s pizzeria' ]

POS 标记器已将 order 识别为句子中的唯一动词;然后可以使用此信息来加载 Siri 人工智能系统中内置的用于下订单的正确子程序。然后可以将提取出的名词发送到子程序,以确定要下何种类型的订单以及从哪里下。

令人印象深刻的是,POS 标注器还将 John's pizzeria 识别为一个单独的名词,而不是将 John'spizzeria 视为单独的名词。标注器已经理解 John's 是一个所有格,因此适用于其后的单词。

我们还可以使用 compromise.js 编写用于常见命令的解析和提取规则。让我们试一个例子:

console.log(
    compromise("Hey Siri, order me a pizza from John's pizzeria")
        .match("#Noun [#Verb me a #Noun+ *+ #Noun+]").out('text')
);

console.log(
    compromise("OK Google, write me a letter to the congressman")
        .match("#Noun [#Verb me a #Noun+ *+ #Noun+]").out('text')
);

使用 yarn start 运行代码将产生:

order me a pizza from John's
write me a letter to the congressman

相同的匹配选择器能够捕捉这两个命令,通过匹配组(用[]表示)忽略命令的接收者(Siri 或 Google)。因为这两个命令都遵循动词-名词-名词的模式,所以两者都会匹配选择器。

当然,仅凭这个选择器本身是不够构建一个完整的 AI 系统,如 Siri 或 Google Assistant 的。这个工具将在 AI 系统过程的早期使用,以便根据预定义但灵活的命令格式确定用户的整体意图。你可以编程一个系统来响应诸如“打开我的#名词”这样的短语,其中名词可以是“日历”、“电子邮件”或Spotify,或者“给#名词写一封电子邮件”,等等。这个工具可以用作构建自己的语音或自然语言命令系统的第一步,以及用于各种主题提取应用。

在本章中,我们讨论了 NLP 中使用的基石工具。许多高级 NLP 任务将 ANN 作为学习过程的一部分,但对于许多新手实践者来说,如何将单词和自然语言发送到 ANN 的输入层并不明确。在下一节中,我们将讨论“词嵌入”,特别是 Word2vec 算法,它可以用来将单词输入到 ANN 和其他系统中。

词嵌入和神经网络

在本章中,我们讨论了各种 NLP 技术,特别是关于文本预处理。在许多用例中,我们需要与 ANN 交互以执行最终分析。分析的类型与这一节无关,但想象你正在开发一个情感分析 ANN。你适当地标记和词干化你的训练文本,然后,当你尝试在预处理后的文本上训练你的 ANN 时,你意识到你不知道如何将单词输入到神经网络中。

最简单的方法是将网络中的每个输入神经元映射到一个独特的单词。在处理文档时,你可以将输入神经元的值设置为该单词在文档中的词频(或绝对计数)。你将拥有一个网络,其中一个输入神经元对单词“时尚”做出反应,另一个神经元对“技术”做出反应,另一个神经元对“食物”做出反应,等等。

这种方法可以工作,但它有几个缺点。ANN 的拓扑结构必须预先定义,因此在开始训练网络之前,你必须知道你的训练集中有多少独特的单词;这将成为输入层的大小。这也意味着一旦网络被训练,它就无法学习新单词。要向网络添加新单词,你实际上必须从头开始构建和训练一个新的网络。

此外,在整个文档语料库中,你可能会遇到成千上万的独特单词。这会对 ANN 的效率产生巨大的负面影响,因为你将需要一个有 10,000 个神经元的输入层。这将大大增加网络所需的训练时间,以及系统的内存和处理需求。

每个神经元对应一个单词的方法在直观上感觉效率不高。虽然你的语料库包含 10,000 个独特的单词,但其中大多数将是罕见的,并且只出现在少数文档中。对于大多数文档,只有几百个输入神经元会被激活,其他则设置为零。这相当于所谓的稀疏矩阵稀疏向量,或者是一个大部分值都是零的向量。

因此,当自然语言与人工神经网络(ANNs)交互时,需要一种更高级的方法。一种被称为词嵌入的技术族可以分析文本语料库,并将每个单词转换为一个固定长度的数值向量。这个向量与哈希(如 md5 或 sha1)作为任意数据的固定长度表示方式类似,也是单词的固定长度表示。

词嵌入提供了几个优势,尤其是在与人工神经网络结合使用时。由于单词向量长度固定,网络的拓扑结构可以在事先决定,并且也可以处理初始训练后新词的出现。

单词向量也是密集向量,这意味着你不需要在你的网络中有 10,000 个输入神经元。单词向量(以及输入层)的大小一个好的值是在 100-300 项之间。这个因素本身就可以显著降低你的 ANN 的维度,并允许模型训练和收敛更快。

有许多词嵌入算法可供选择,但当前最先进的选项是谷歌开发的 Word2vec 算法。这个特定的算法还有一个令人向往的特性:在向量表示方面,相似的单词会聚集在一起。

在本章的早期,我们看到了我们可以使用字符串距离来衡量两个单词之间的印刷距离。我们还可以使用两个单词的音位表示之间的字符串距离来衡量它们听起来有多相似。当使用 Word2vec 时,你可以测量两个单词向量之间的距离,以获取两个单词之间的概念距离。

Word2vec 算法本身是一个浅层神经网络,它在你文本语料库上自我训练。该算法使用 n-gram 来发展单词之间的上下文感觉。如果你的语料库中“时尚”和“博主”经常一起出现,Word2vec 将为这些单词分配相似的向量。如果“时尚”和“数学”很少一起出现,它们的结果向量将被一定距离分开。因此,两个词向量之间的距离代表了它们的概念和上下文距离,或者两个单词在语义内容和上下文方面有多相似。

Word2vec 算法的这一特性也赋予了最终处理数据的 ANN 自己的效率和准确性优势,因为词向量将为相似单词激活相似的输入神经元。Word2vec 算法不仅降低了问题的维度,还为词嵌入添加了上下文信息。这种额外的上下文信息正是 ANN 非常擅长捕捉的信号类型。

以下是一个涉及自然语言和人工神经网络的常见工作流程示例:

  • 对所有文本进行分词和词干提取

  • 从文本中移除停用词

  • 确定适当的 ANN 输入层大小;使用此值既用于输入层也用于 Word2vec 的维度

  • 使用 Word2vec 为你的文本生成词嵌入

  • 使用词嵌入来训练 ANN 以完成你的任务

  • 在评估新文档时,在将其传递给 ANN 之前对文档进行分词、词干提取和向量化

使用 Word2vec 等词嵌入算法不仅可以提高你模型的速度和内存性能,而且由于 Word2vec 算法保留的上下文信息,它可能还会提高你模型的准确性。还应注意的是,Word2vec 就像 n-gram 分词一样,是欺骗朴素词袋算法考虑词上下文的一种可能方式,因为 Word2vec 算法本身使用 n-gram 来开发嵌入。

虽然词嵌入主要在自然语言处理中使用,但同样的方法也可以用于其他领域,例如遗传学和生物化学。在这些领域中,有时能够将蛋白质或氨基酸序列向量化是有利的,这样相似的结构的向量嵌入也将相似。

摘要

自然语言处理是一个研究领域,拥有许多高级技术,并在机器学习、计算语言学和人工智能中有广泛的应用。然而,在本章中,我们专注于在日常工作任务中最普遍使用的特定工具和策略。

本章中介绍的技术是构建模块,可以混合搭配以实现许多不同的结果。仅使用本章中的信息,你可以构建一个简单的全文搜索引擎,一个用于语音或书面命令的意图提取器,一个文章摘要器,以及许多其他令人印象深刻的工具。然而,当这些技术与高级学习模型(如 ANNs 和 RNNs)结合时,NLP 的最令人印象深刻的应用才真正出现。

尤其是您学习了关于单词度量,如字符串距离和 TF-IDF 相关性评分;预处理和降维技术,如分词和词干提取;语音算法,如 Metaphone 算法;词性提取和短语解析;以及使用词嵌入算法将单词转换为向量。

您还通过众多示例介绍了两个优秀的 JavaScript 库,natural.jscompromise.js,这些库可以轻松完成与机器学习相关的多数 NLP 任务。您甚至能用 20 行代码编写一个文章摘要器!

在下一章中,我们将讨论如何将您迄今为止所学的一切整合到一个实时、面向用户的 JavaScript 应用程序中。

第十一章:在实时应用中使用机器学习

在本书中,你已经学习了许多机器学习算法和技术。然而,剩下的工作是将这些算法部署到现实世界的应用中。本章专门讨论与在现实世界、实际应用和生产环境中使用机器学习相关的建议。

理想化的机器学习算法使用与实际使用之间存在许多差异。在我们的示例中,我们一步训练和执行模型,响应一个命令。我们假设模型不需要以任何方式序列化、保存或重新加载。我们没有考虑用户界面的响应性、在移动设备上执行或构建客户端和服务器之间的 API 接口。

真实应用的范围可能比我们讨论的例子大几个数量级。你如何在一个包含数十亿数据点的数据集中训练一个人工神经网络(ANN)?你如何收集、存储和处理这么多的信息?

在本章中,我们将讨论以下主题:

  • 前端架构

  • 后端架构

  • 数据管道

  • 可以用来构建生产级机器学习系统的工具和服务

序列化模型

本书中的示例仅构建、训练和测试模型,然后在毫秒后将其销毁。我们之所以能够这样做,是因为我们的示例使用的是有限的训练数据,最坏的情况也只需要几分钟就能完成训练。在实际应用中,通常会使用更多的数据,并且需要更多的时间来训练。在生产应用中,训练好的模型本身是一项宝贵的资产,应该根据需要存储、保存和加载。换句话说,我们的模型必须是可序列化的。

序列化本身通常不是一个难题。模型本质上是对训练数据的压缩版本。一些模型确实可能非常大,但它们仍然只是训练它们的数据大小的一小部分。使序列化问题变得具有挑战性的是,它引发了许多其他架构问题,你必须考虑的第一个问题就是模型存储的位置和方式。

令人失望的是,没有正确答案。模型可以根据其大小、复杂性、使用频率、可用技术等因素存储在几乎任何地方。朴素贝叶斯分类器只需要存储标记和文档计数,并且仅使用键/值查找,没有高级查询,因此单个 Redis 服务器可以托管一个在数十亿文档上训练的巨大分类器。非常大的模型可以序列化到一个专用数据库中,甚至可能是一个专用的图数据库集群。中等大小的模型可以序列化为 JSON 或二进制格式,并存储在数据库的 BLOB 字段中,托管在文件服务器或 API(如 Amazon S3)上,或者如果足够小,可以存储在浏览器本地存储中。

大多数机器学习库都内置了序列化和反序列化功能,因为最终这种功能依赖于库的实现细节。大多数库包括save()load()等方法,但你仍需参考你所使用的特定库的文档。

确保在编写自己的库时包含序列化功能。如果你想支持多个存储后端,最好将序列化功能与核心逻辑解耦,并实现一个驱动程序和接口架构。

这只是我们现在需要回答的第一个问题,因为我们已经有一个可序列化的模型。可序列化模型也是可移植的,这意味着它们可以从一台机器移动到另一台机器。例如,你可以将预训练模型下载到智能手机上进行离线使用。你的 JavaScript 应用程序可以使用 Web Worker 下载并维护一个用于语音检测的现成模型,请求麦克风权限,并通过 Chrome 扩展仅通过语音命令使网站可导航。

在本节中,我们将讨论一旦模型可序列化和可移植后出现的各种架构考虑因素。

在服务器上训练模型

由于训练复杂模型涉及的时间、数据、处理能力和内存需求,通常在服务器上而不是在客户端训练模型是可取的。根据用例,模型的评估也可能需要在服务器上完成。

在考虑模型训练和评估的位置方面,有几个范例需要考虑。一般来说,你的选择将是完全在服务器上训练和评估,完全在客户端训练和评估,或者是在服务器上训练但在客户端评估。让我们探讨每个范例的一些示例。

最简单的实现方式是在服务器上同时训练和评估模型。这种方法的优点在于你可以决定并控制模型的整个执行环境。你可以轻松分析训练和执行模型所需的服务器负载,并根据需要调整服务器规模。由于数据很可能存储在你也控制的数据库中,因此完全控制的服务器更容易访问大量训练数据。你不必担心客户端运行的是哪种版本的 JavaScript,或者你是否能够访问客户端的 GPU 进行训练。在服务器上训练和执行模型还意味着由于模型的存在,客户端机器不会增加额外的负载。

完全服务器端方法的缺点主要是需要设计良好的、健壮的 API。如果你有一个需要快速响应时间的模型评估的应用,你需要确保你的 API 能够快速且可靠地提供服务。这种方法还意味着无法进行离线模型评估;客户端需要连接到你的服务器才能使任何操作生效。大多数被称为软件即服务SaaS)的应用或产品将使用服务器端模型,如果你在向客户提供付费服务,这种方法应该是你首先考虑的。

相反,模型也可以在客户端完全进行训练和评估。在这种情况下,客户端本身需要访问训练数据,并且需要足够的处理能力来训练模型。这种方法通常不适用于需要大量训练集或长时间训练时间的模型,因为没有办法确保客户端的设备能够处理数据。你还得应对那些可能没有 GPU 或处理能力训练甚至简单模型的旧设备。

然而,对于训练数据来自设备本身且需要高度数据隐私或数据所有权的应用来说,客户端训练和评估是一个很好的方法。将处理限制在客户端设备上可以确保用户数据不会被传输到任何第三方服务器,并且可以直接由用户删除。指纹扫描、生物识别分析、位置数据分析、电话分析等应用是采用完全客户端方法的良好候选者。这种方法还确保了模型可以在离线状态下进行训练和评估,无需互联网连接。

在某些情况下,混合方法可以将两者的优点结合起来。需要大量训练数据的高级模型可以在服务器上训练并序列化。客户端在首次连接到你的应用时,可以下载并存储训练好的模型以供离线使用。客户端本身负责评估模型,但在此情况下不需要训练模型。

混合方法允许你在服务器上训练和定期更新复杂模型。序列化模型比原始训练数据小得多,因此可以发送到客户端进行离线评估。只要客户端和服务器使用兼容的库或算法(即,两边都使用TensorFlow.js),客户端就可以利用服务器的处理能力进行训练,但在对评估步骤要求较低的情况下,使用自己的离线处理能力。

混合模型的示例用例包括语音或图像识别,可能是用于人工智能助手或增强现实(AR)应用程序。在 AR 应用程序的情况下,服务器负责维护数百万个训练图像并训练(例如)一个 RNN 来分类物体。一旦训练完成,这个模型就可以被序列化、存储并由客户端下载。

让我们想象一个增强现实(AR)应用程序,该程序连接到设备的摄像头并显示一个标注的视频流,用于识别物体。当应用程序首次启动时,客户端会下载 AR RNN 模型并将其存储在设备的本地存储中,同时存储版本信息。当视频流首次启动时,应用程序从存储中检索模型并将其反序列化到客户端自己的 RNN 实现中。理想情况下,客户端的 RNN 实现将使用与服务器上相同的库和版本。

为了对视频的每一帧进行分类和标注,客户端需要在仅仅 16 毫秒内(对于 60 FPS 的视频)完成所有必要的工作。这是可行的,但在实践中并非每一帧都用于分类;每 3 帧中就有 1 帧(相隔 50 毫秒)就足够了。混合方法在这里表现出色;如果视频的每一帧都需要上传到服务器、评估然后返回,应用程序将遭受严重的性能损失。即使模型性能非常出色——例如,模型评估需要 5 毫秒——你也可能因为 HTTP 请求所需的往返时间而额外体验 100 毫秒的延迟。

在混合方法下,客户端不需要将图像发送到服务器进行评估,而是可以直接根据现在加载到内存中的先前训练模型立即评估图像。一个设计良好的客户端会定期检查服务器以获取模型更新,并在必要时更新它,但仍然允许过时的模型离线运行。当应用程序“正常工作”时,用户最满意,混合模型为你提供了性能和弹性。服务器仅用于可以异步进行的任务,例如下载更新模型或将信息发送回服务器。

因此,混合方法最适合需要大型、复杂模型但模型评估需要非常快速或离线进行的用例。当然,这不是一个绝对规则。还有许多其他情况下,混合方法最为合理;如果你有多个客户端且无法承担服务器资源来处理所有他们的评估,你可能使用混合方法来卸载你的处理责任。

在设计执行模型训练或评估的客户端应用程序时,必须格外小心。虽然评估比训练快得多,但如果实现不当,它仍然是非平凡的,可能会在客户端引起 UI 性能问题。在下一节中,我们将探讨一个现代网络浏览器功能,称为 web workers,它可以用于在独立线程中执行处理,保持你的 UI 响应。

Web workers

如果你正在为网络浏览器应用程序开发,你当然会想使用 web worker 在后台管理模型。Web workers 是一个浏览器特定功能,旨在允许后台处理,这正是我们在处理大型模型时想要的。

Web workers 可以与 XMLHttpRequestIndexedDBpostMessage 交互。Web worker 可以使用 XMLHttpRequest 从服务器下载模型,使用 IndexedDB 本地存储它,并使用 postMessage 与 UI 线程通信。这三个工具结合使用,为响应式、高性能以及可能离线体验提供了完整的基础。其他 JavaScript 平台,如 React Native,也具有类似的 HTTP 请求、数据存储和进程间通信功能。

Web workers 可以与其他浏览器特定功能(如 service workers 和设备 API)结合使用,以提供完整的离线体验。Service workers 可以缓存特定资产以供离线使用,或智能地在在线和离线评估之间切换。浏览器扩展平台以及如 React Native 这样的移动平台也提供了一系列机制来支持缓存数据、后台线程和离线使用。

不论是哪个平台,概念都是相同的:当有互联网连接时,应用程序应该异步下载和上传数据;应用程序应该缓存(并版本控制)它需要运行的任何内容,如预训练模型;并且应用程序应该独立于 UI 评估模型。

容易错误地假设模型足够小且运行速度快,可以与 UI 在同一线程中运行。如果你的平均评估时间仅为 5 毫秒,并且你每 50 毫秒只需要进行一次评估,那么可能会变得自满,并跳过在单独线程中评估模型的额外细节。然而,市场上各种设备的范围使得你甚至不能假设性能上有数量级的相似性。例如,如果你在一个带有 GPU 的现代手机上测试了你的应用程序,你可能无法准确评估它在旧手机 CPU 上的性能。评估时间可能会从 5 毫秒跳到 100 毫秒。在设计不良的应用程序中,这会导致 UI 延迟或冻结,但在设计良好的应用程序中,UI 将保持响应,但更新频率较低。

幸运的是,Web Worker 和postMessage API 使用简单。IndexedDB API 是一个低级 API,最初可能难以使用,但有许多用户友好的库可以抽象出细节。你下载和存储预训练模型的具体方式完全取决于你应用程序的实现细节和所选的具体机器学习算法。较小的模型可以序列化为 JSON 并存储在IndexedDB中;更高级的模型可以直接集成到IndexedDB中。确保在你的服务器端 API 中包含一个比较版本信息的机制;你应该有一种方法可以询问服务器当前模型的版本,并将其与自己的副本进行比较,以便可以使其无效并更新模型。

在设计你的 Web Worker 的消息传递 API 时也要多加思考。你将使用postMessageAPI(在所有主流浏览器中都可用)来在 UI 线程和后台线程之间进行通信。这种通信至少应该包括检查模型状态的方法以及向模型发送数据点以供评估的方法。但你也会希望展望未来的功能,并使你的 API 灵活且具有前瞻性。

你可能需要计划的功能示例包括持续改进的模型,这些模型根据用户反馈重新训练,以及针对每个用户的模型,这些模型学习单个用户的行为或偏好。

持续改进和针对每个用户的模型

在你应用程序的生命周期中,最终用户很可能会以某种方式与你的模型进行交互。通常,这种交互可以用作进一步训练模型的反馈。这种交互还可以用来根据用户的需求定制模型,以适应他们的兴趣和行为。

两个概念的良例是垃圾邮件过滤器。垃圾邮件过滤器应该随着用户将消息标记为垃圾邮件而不断改进。当垃圾邮件过滤器拥有大量数据点用于训练时,它们最为强大,而这些数据可以来自应用程序的其他用户。每当用户将一条消息标记为垃圾邮件时,这种知识应该应用于模型,并且其他用户也应该能够享受到他们自己垃圾邮件过滤器的自动改进。

垃圾邮件过滤器也是应该针对每个用户定制的模型的良例。我认为是垃圾邮件的东西可能和你认为的不同。我积极地标记那些我没有注册的营销邮件和新闻通讯为垃圾邮件,但其他用户可能希望在自己的收件箱中看到这些类型的消息。同时,有些消息是每个人都同意是垃圾邮件的,因此设计我们的应用程序以使用一个中央、持续更新的模型会很好,这个模型可以本地优化以更好地适应特定用户的行为。

贝叶斯分类器非常适合这种描述,因为贝叶斯定理是为了通过新信息进行更新而设计的。在第五章,“分类算法”中,我们讨论了 Naive Bayes 分类器的实现,该实现能够优雅地处理稀有词汇。在该方案中,一个权重因子将词汇概率偏向中性,这样稀有词汇就不会对模型产生过强的干扰。一个针对用户的垃圾邮件过滤器可以使用同样的技术,但不是将词汇偏向中性,而是偏向中心模型的概率。

在这种用法中,稀有词汇的权重因子变成了一个平衡中心模型和本地模型的权重因子。你使权重因子越大,中心模型就越重要,用户影响本地模型所需的时间就越长。较小的权重因子将更敏感于用户反馈,但也可能导致性能的不规律。在典型的稀有词汇实现中,权重因子在 3 到 10 的范围内。然而,在针对用户的模型中,权重因子应该更大——可能是 50 到 1,000,考虑到中心模型是由数百万个示例训练的,不应该轻易被少量本地示例所覆盖。

在将数据发送回服务器以进行持续模型改进时,必须小心谨慎。你不应该将电子邮件消息发送回服务器,因为这会创建一个不必要的安全风险——尤其是如果你的产品不是一个电子邮件托管服务提供商,而只是一个电子邮件客户端。如果你也是电子邮件托管服务提供商,那么你可以简单地发送电子邮件 ID 回服务器,将其标记为垃圾邮件并供模型训练;客户端和服务器将分别维护自己的模型。如果你不是电子邮件托管服务提供商,那么你应该格外小心,确保用户数据的安全。如果你必须将令牌流发送回服务器,那么你应该在传输过程中对其进行加密,并对其进行匿名化。你也可以考虑使用一个在分词和词干提取后对令牌进行盐化和散列的标记器(例如,使用 sha1 或 hmac)。分类器在处理散列数据时与处理可读数据一样有效,但会添加一个额外的混淆层。最后,确保不要记录 HTTP 请求和原始令牌数据。一旦数据以令牌计数的形式进入模型,它就足够匿名化了,但请确保间谍无法将特定的令牌流与特定的用户联系起来。

当然,朴素贝叶斯分类器并不是唯一可以持续更新或根据用户定制的模型。大多数机器学习算法都支持模型的持续更新。如果一个用户指出一个循环神经网络(RNN)在图像分类上犯了错误,那么这个用户的数据点可以被添加到模型的训练集中,模型可以定期完全重新训练,或者可以与新训练示例一起批量更新。

一些算法支持真正实时的模型更新。朴素贝叶斯分类器只需要更新标记和文档计数,这些甚至可能存储在内存中。knn 和 k-means 算法类似地允许在任何时候将数据点添加到模型中。一些用于强化学习的 ANN(人工神经网络)也依赖于实时反馈。

其他算法更适合定期批量更新。这些算法通常依赖于梯度下降或随机方法,并在训练期间需要许多示例的反馈循环;例如,ANN 和随机森林。确实可以使用单个数据点重新训练 ANN 模型,但批量训练更有效。在更新模型时,请注意不要过拟合模型;过多的训练并不总是好事。

在某些情况下,最好基于更新的训练集完全重新训练模型。这样做的一个原因是为了避免训练数据中的短期趋势过拟合。通过完全重新训练模型,你可以确保最近的训练示例与旧的训练示例具有相同的权重;这可能是或可能不是所希望的。如果模型定期自动重新训练,请确保训练算法正在查看正确的信号。它应该能够平衡准确性、损失和方差,以开发可靠的模型。由于机器学习训练在很大程度上是随机的,因此不能保证两次训练运行将以相同的质量或相似的时间完成。你的训练算法应该控制这些因素,并在必要时能够丢弃不良模型,例如,如果在最大训练轮数限制内没有达到目标准确性或损失。

在这一点上,一个新的问题出现了:你如何收集、存储和处理数 GB 或 TB 的训练数据?你如何以及在哪里存储和分发序列化模型给客户?你如何从数百万用户那里收集新的训练示例?这个话题被称为数据管道,我们将在下一节讨论。

数据管道

在开发生产级 ML 系统时,你不太可能得到以可处理格式提供的训练数据。生产级 ML 系统通常是更大应用程序系统的一部分,你使用的数据可能来自多个不同的来源。ML 算法的训练集可能是你更大数据库的一个子集,结合存储在内容分发网络CDN)上的图像和来自 Elasticsearch 服务器的的事件数据。在我们的示例中,我们得到了一个隔离的训练集,但在现实世界中,我们需要以自动化和可重复的方式生成训练集。

将数据引导通过生命周期各个阶段的过程被称为数据管道。数据管道可能包括运行 SQL 或 Elasticsearch 查询的对象选择器,允许基于事件或日志的数据流入的事件订阅,聚合,连接,将数据与第三方 API 的数据结合,净化,标准化和存储。

在理想的实现中,数据管道充当了更大应用程序环境和 ML 过程之间的抽象层。ML 算法应该能够读取数据管道的输出,而不需要了解数据的原始来源,类似于我们的示例。在这种方法下,ML 算法不需要了解应用程序的实现细节;管道本身负责知道应用程序是如何构建的。

由于可能存在许多可能的数据源和无限多的应用程序架构方式,没有一种数据管道可以适用于所有情况。然而,大多数数据管道将包含以下组件,我们将在接下来的章节中讨论:

  • 数据查询和事件订阅

  • 数据连接或聚合

  • 转换和标准化

  • 存储和交付

让我们来看看这些概念,并介绍一些可以实现它们的工具和技术。

数据查询

想象一下像 Disqus 这样的应用程序,它是一个可嵌入的评论表单,网站所有者可以使用它来为博客文章或其他页面添加评论功能。Disqus 的主要功能是允许用户对帖子进行点赞或留言,然而,作为一个额外的功能和收入来源,Disqus 可以提供内容推荐并在赞助内容旁边展示它们。内容推荐系统是一个 ML 系统的例子,它是更大应用程序的一个功能。

在 Disqus 这样的应用中的内容推荐系统并不一定需要与评论数据交互,但可能会使用用户的喜欢历史来生成与当前页面类似的推荐。这样的系统还需要分析喜欢页面的文本内容,并将其与网络中所有页面的文本内容进行比较,以便做出推荐。Disqus 不需要帖子的内容来提供评论功能,但需要在数据库中存储关于页面的元数据(如 URL 和标题)。因此,帖子内容可能不会存储在应用程序的主数据库中,尽管喜欢和页面元数据可能会存储在那里。

建立在 Disqus 推荐系统周围的数据管道首先需要查询主数据库以获取用户喜欢的页面——或者喜欢当前页面的用户所喜欢的页面——并返回它们的元数据。然而,为了找到类似的内容,系统将需要使用每个喜欢帖子的文本内容。这些数据可能存储在单独的系统,比如 MongoDB 或 Elasticsearch 这样的二级数据库,或者 Amazon S3 或其他数据仓库中。该管道需要根据主数据库返回的元数据检索文本内容,并将内容与元数据关联起来。

这是在数据管道早期阶段的一个多数据选择器或数据源的例子。一个数据源是主要应用程序数据,它存储帖子和喜欢元数据。另一个数据源是二级服务器,它存储帖子的文本内容。

该管道的下一步可能涉及找到与用户喜欢的帖子相似的一批候选帖子,这可能通过请求 Elasticsearch 或其他能够找到相似内容的服务来实现。然而,相似的内容并不一定是正确的内容来提供,因此这些候选文章最终将由一个(假设的)人工神经网络(ANN)进行排名,以确定要显示的最佳内容。在这个例子中,数据管道的输入是当前页面,输出是数据管道的一个列表,例如 200 个相似的页面,然后 ANN 将对这些页面进行排名。

如果所有必要的数据都驻留在主数据库中,整个管道可以通过一个 SQL 语句和一些 JOIN 操作来实现。即使在这种情况下,也应该在机器学习算法和数据管道之间开发一定程度的抽象,因为您可能决定在未来更新应用程序的架构。然而,在其他情况下,数据将驻留在不同的位置,因此需要开发一个更周全的管道。

构建这个数据管道有许多方法。你可以开发一个执行所有管道任务的 JavaScript 模块,在某些情况下,你甚至可以使用标准的 Unix 工具编写 bash 脚本来完成任务。在复杂性的另一端,有专门用于数据管道的工具,如 Apache KafkaAWS Pipeline。这些系统设计为模块化,允许你定义特定的数据源、查询、转换和聚合模块,以及连接它们的流程。例如,在 AWS Pipeline 中,你定义 数据节点,这些节点了解如何与你的应用程序中的各种数据源进行交互。

管道的最早阶段通常是某种数据查询操作。必须从更大的数据库中提取训练示例,同时考虑到数据库中的每条记录并不一定是训练示例。例如,在垃圾邮件过滤器的情况下,你应该只选择被用户标记为垃圾邮件或非垃圾邮件的消息。那些被垃圾邮件过滤器自动标记为垃圾邮件的消息可能不应该用于训练,因为这可能会引起正反馈循环,最终导致不可接受的误报率。

类似地,你可能想阻止被你的系统阻止或禁止的用户影响你的模型训练。一个恶意行为者可能会通过对自己数据进行不适当的行为来故意误导机器学习模型,因此你应该将这些数据点作为训练示例排除。

或者,如果你的应用程序要求最近的数据点应该比旧的数据点优先考虑,你的数据查询操作可能需要对用于训练的数据设置基于时间限制,或者选择一个按时间顺序逆序排列的固定限制。无论情况如何,确保你仔细考虑你的数据查询,因为它们是你数据管道中的基本第一步。

然而,并非所有数据都需要来自数据库查询。许多应用程序使用 pub/sub 或事件订阅架构来捕获流数据。这些数据可能是来自多个服务器的活动日志聚合,或者来自多个来源的实时交易数据。在这些情况下,事件订阅者将是你的数据管道的早期部分。请注意,事件订阅和数据查询不是互斥的操作。通过 pub/sub 系统传入的事件仍然可以根据各种标准进行过滤;这仍然是一种数据查询的形式。

当事件订阅模型与批量训练方案结合时,可能会出现一个潜在问题。如果你需要 5,000 个数据点,但每秒只收到 100 个,你的管道需要维护一个数据点的缓冲区,直到达到目标大小。有各种消息队列系统可以协助完成这项工作,例如 RabbitMQ 或 Redis。需要这种功能的管道可能会在队列中保留消息,直到达到 5,000 条消息的目标,然后才将消息释放到管道的其余部分进行批量处理。

如果数据是从多个来源收集的,它很可能需要以某种方式连接或聚合。现在让我们看看需要将数据与外部 API 数据连接的情况。

数据连接和聚合

让我们回到我们的 Disqus 内容推荐系统示例。想象一下,数据管道能够直接从主数据库查询点赞和帖子元数据,但没有系统在应用程序中存储帖子的文本内容。相反,开发了一个以 API 形式存在的微服务,该 API 接受帖子 ID 或 URL,并返回页面的净化文本内容。

在这种情况下,数据管道需要与微服务 API 交互,以获取每个帖子的文本内容。这种方法是完全有效的,尽管如果帖子内容请求的频率很高,可能需要实施一些缓存或存储。

数据管道需要采用与事件订阅模型中消息缓冲类似的方法。管道可以使用消息队列来排队仍需要内容的帖子,并对队列中的每个帖子向内容微服务发出请求,直到队列耗尽。随着每个帖子内容的检索,它被添加到帖子元数据中,并存储在单独的队列中,用于完成请求。只有当源队列耗尽且目标队列满时,管道才应继续下一步。

数据连接不一定需要涉及微服务 API。如果管道从两个需要合并的独立来源收集数据,可以采用类似的方法。管道是唯一需要理解两个数据源和格式之间关系的组件,让数据源和机器学习算法独立于这些细节进行操作。

当需要数据聚合时,队列方法也工作得很好。这种情况的一个例子是,输入是流式输入数据,输出是标记计数或值聚合的管道。在这些情况下,使用消息队列是可取的,因为大多数消息队列确保消息只能被消费一次,从而防止聚合器产生任何重复。当事件流非常高频时,这一点尤其有价值,因为将每个事件作为它到来时进行标记可能会导致备份或服务器过载。

由于消息队列确保每条消息只被消费一次,高频事件数据可以直接流入一个队列,其中消息由多个并行工作的进程消费。每个工作进程可能负责对事件数据进行分词,然后将分词流推送到不同的消息队列。消息队列软件确保没有两个工作进程处理相同的消息,每个工作进程可以作为独立单元运行,只关注分词。

当分词器将结果推送到新的消息队列时,另一个工作进程可以消费这些消息并汇总分词计数,每秒、每分钟或每 1,000 个事件(以适用于应用程序的方式)将自身的结果传递到管道的下一步。这种风格管道的输出可能被输入到一个持续更新的贝叶斯模型中,例如。

以这种方式设计的数据管道的一个好处是性能。如果您试图订阅高频事件数据,对每条消息进行分词,汇总分词计数,并更新模型,您可能被迫使用一个非常强大(且昂贵)的单个服务器。服务器同时需要高性能 CPU、大量 RAM 和高吞吐量网络连接。

然而,通过将管道分解为阶段,您可以针对每个阶段的特定任务和负载条件进行优化。接收源事件流的消息队列只需要接收事件流,但不需要处理它。分词工作进程不一定是高性能服务器,因为它们可以并行运行。汇总队列和工作进程将处理大量数据,但不需要保留数据超过几秒钟,因此可能不需要太多 RAM。最终的模型,即源数据的压缩版本,可以存储在更普通的机器上。由于数据管道鼓励模块化设计,因此数据管道的许多组件可以用通用硬件构建。

在许多情况下,您需要在管道中从一种格式转换数据到另一种格式。这可能意味着将原生数据结构转换为 JSON,转置或插值值,或对值进行哈希处理。现在让我们讨论在数据管道中可能发生的几种数据转换类型。

转换和归一化

当您的数据通过管道传输时,可能需要将其转换为与算法输入层兼容的结构。在管道中的数据可以进行许多可能的转换。例如,为了在数据到达基于分词的分类器之前保护敏感用户数据,您可能需要对分词应用加密哈希函数,这样它们就不再是人类可读的。

更典型的情况是,转换类型将与清理、归一化或转置相关。清理操作可能涉及删除不必要的空白或 HTML 标签,从标记流中删除电子邮件地址,以及从数据结构中删除不必要的字段。如果你的管道已订阅事件流作为数据源,并且事件流将源服务器 IP 地址附加到事件数据中,那么从数据结构中移除这些值是一个好主意,这样既可以节省空间,也可以最大限度地减少潜在数据泄露的表面积。

类似地,如果你的分类算法不需要电子邮件地址,那么管道应该移除这些数据,以便它与尽可能少的服务器和系统交互。如果你设计了一个垃圾邮件过滤器,你可能想考虑只使用电子邮件地址的域名部分而不是完全合格的地址。或者,电子邮件地址或域名可以通过管道进行哈希处理,这样分类器仍然可以识别它们,但人类却不能。

确保审查数据中的其他潜在安全和隐私问题。如果你的应用程序在事件流中收集最终用户的 IP 地址,但分类器不需要这些数据,那么应尽早将其从管道中移除。随着新欧洲隐私法律的实施,这些考虑因素变得越来越重要,每个开发者都应该意识到隐私和合规问题。

数据转换的常见类别之一是归一化。当处理给定字段或特征的数值范围时,通常希望将范围归一化,使其具有已知的最小和最大边界。一种方法是将同一字段的全部值归一化到[0,1]的范围内,使用遇到的最高值作为除数(例如,序列1, 2, 4可以归一化为0.25, 0.5, 1)。数据是否需要以这种方式归一化完全取决于消耗数据的算法。

另一种归一化的方法是转换值成为百分位数。在这个方案中,非常大的异常值不会使算法产生太大的偏差。如果大多数值位于 0 到 100 之间,但少数点包括像 50,000 这样的值,算法可能会给予大值过大的优先级。然而,如果数据以百分位数归一化,那么你保证不会有任何超过 100 的值,异常值也会被纳入与数据其他部分相同的范围。这好不好取决于算法。

数据管道也是计算派生或二阶特征的好地方。想象一个随机森林分类器,它使用 Instagram 个人资料数据来确定个人资料属于人类还是机器人。Instagram 个人资料数据将包括用户的关注者数量、朋友数量、帖子数量、网站、简介和用户名。然而,随机森林分类器在使用这些字段的原有表示时可能会遇到困难,但是通过应用一些简单的数据转换,你可以达到 90%的准确率。

在 Instagram 的情况下,一种有用的数据转换是计算比率。关注者数量和粉丝数量作为单独的特征或信号,可能对分类器没有太大帮助,因为它们被处理得相对独立。但是,朋友与关注者的比率可能成为一个非常强烈的信号,可能会暴露出机器人用户。一个有 1,000 个朋友的 Instagram 用户不会引起任何警报,同样,一个有 50 个粉丝的 Instagram 用户也不会;独立处理,这些特征不是强烈的信号。然而,一个朋友与关注者比率为 20(或 1,000/50)的 Instagram 用户几乎肯定是一个设计来关注其他用户的机器人。同样,像帖子与关注者比或帖子与朋友比这样的比率可能最终比任何单独的特征都强。

文本内容,如 Instagram 用户的个人资料简介、网站或用户名,通过从它们中提取二阶特征也能变得有用。分类器可能无法对网站的 URL 做任何事情,但也许可以用一个布尔值特征has_profile_website作为信号。如果在你的研究中,你注意到机器人的用户名中往往有很多数字,你可以从用户名本身提取特征。一个特征可以计算用户名中字母与数字的比例,另一个布尔值特征可以表示用户名是否以数字开头或结尾,一个更高级的特征可以确定用户名中是否使用了字典中的单词(因此区分@themachinelearningwriter和像@panatoe234这样的乱码)。

提取的特征可以是任何复杂度或简单度。另一个简单的特征可能是 Instagram 个人资料是否在个人资料简介字段中包含 URL(与专门的网站字段相对);这可以通过正则表达式检测,布尔值用作特征。一个更高级的特征可以自动检测用户内容中使用的语言是否与用户指定的地区设置相同。如果用户声称他们在法国,但总是用俄语写标题,这确实可能是一个住在法国的俄罗斯人,但结合其他信号,如关注者与粉丝的比例远非 1,这些信息可能表明是一个机器人用户。

还有一些低级转换可能需要应用于管道中的数据。如果源数据是 XML 格式,但分类器需要 JSON 格式,则管道应负责解析和格式转换。

还可以应用其他数学转换。如果数据的原生格式是面向行的,但分类器需要面向列的数据,则管道可以在处理过程中执行向量转置操作。

同样,管道可以使用数学插值来填充缺失值。如果你的管道订阅了实验室环境中一套传感器发出的事件,并且单个传感器在几次测量中离线,那么在两个已知值之间进行插值以填充缺失数据可能是合理的。在其他情况下,缺失值可以用总体均值或中位数来替换。用均值或中位数替换缺失值通常会导致分类器优先考虑该数据点的特征,而不是通过提供一个空值来破坏分类器。

通常,在数据管道中的转换和归一化方面,有两个方面需要考虑。第一个是源数据和目标格式的机械细节:XML 数据必须转换为 JSON,行必须转换为列,图像必须从 JPEG 格式转换为 BMP 格式,等等。这些机械细节并不太复杂,因为你已经知道系统所需的源和目标格式。

另一个考虑因素是您数据的语义或数学转换。这是一个特征选择和特征工程练习,并不像机械转换那样直接。确定要推导出哪些二阶特征既是一门艺术也是一门科学。艺术在于提出新的衍生特征想法,而科学在于严格测试和实验你的工作。以我在 Instagram 机器人检测方面的经验为例,我发现 Instagram 用户名中的字母与数字比例是一个非常微弱的信号。经过一些实验后,我放弃了这个想法,以避免给问题添加不必要的维度。

到目前为止,我们有一个假设的数据管道,它收集数据,将其连接和聚合,处理它,并将其归一化。我们几乎完成了,但数据仍需要交付给算法本身。一旦算法被训练,我们可能还希望序列化模型并存储它以供以后使用。在下一节中,我们将讨论在传输和存储训练数据或序列化模型时需要考虑的一些因素。

存储和交付数据

一旦你的数据处理管道完成了所有必要的处理和转换,它剩下的任务就是将数据传递给你的算法。理想情况下,算法不需要了解数据管道的实现细节。算法应该有一个单一的位置可以与之交互,以获取完全处理过的数据。这个位置可能是一个磁盘上的文件,一个消息队列,一个如 Amazon S3 这样的服务,一个数据库,或者一个 API 端点。你选择的方法将取决于你可用的资源,你的服务器系统的拓扑或架构,以及数据的格式和大小。

只定期训练的模型通常是处理起来最简单的情况。如果你正在开发一个图像识别 RNN,它学习大量图像的标签,并且只需要每几个月重新训练一次,一个很好的方法是将所有图像以及一个清单文件(将图像名称与标签相关联)存储在 Amazon S3 或磁盘上的专用路径上。算法首先加载并解析清单文件,然后根据需要从存储服务加载图像。

类似地,一个 Instagram 机器人检测算法可能只需要每周或每月重新训练一次。算法可以直接从数据库表、存储在 S3 或本地磁盘上的 JSON 或 CSV 文件中读取训练数据。

这种情况很少发生,但在一些特殊的数据管道实现中,你也可以为算法提供一个作为微服务构建的专用 API 端点;算法会首先查询 API 端点以获取训练点引用的列表,然后依次从 API 请求每个引用。

需要在线更新或近似实时更新的模型,另一方面,最好通过消息队列来提供服务。如果一个贝叶斯分类器需要实时更新,算法可以订阅消息队列,并在更新到来时应用它们。即使使用复杂的分阶段管道,如果你设计好了所有组件,处理新数据和更新模型也可能在几秒钟内完成。

回到垃圾邮件过滤器示例,我们可以设计一个高性能的数据管道,如下所示:首先,一个 API 端点接收用户的反馈。为了保持用户界面的响应性,这个 API 端点只负责将用户的反馈放入消息队列,并且可以在不到一毫秒内完成其任务。然后,数据处理管道订阅消息队列,在另几个毫秒内就会知道有新消息。管道随后对消息应用一些简单的转换,如分词、词干提取,甚至可能对标记进行散列。

管道下一步将把标记流转换成标记及其计数的哈希表(例如,从 hey hey there 转换为 {hey: 2, there: 1});这样可以避免分类器需要多次更新同一个标记的计数。这一处理阶段在最坏的情况下也只需额外几毫秒。最后,完全处理后的数据被放置在一个单独的消息队列中,分类器会订阅这个队列。一旦分类器意识到数据,它就可以立即将更新应用到模型上。如果分类器由 Redis 支持,例如,这一最终阶段也只需几毫秒。

我们所描述的整个过程,从用户反馈到达 API 服务器到模型更新的时间,可能只需要 20 毫秒。考虑到互联网(或任何其他方式)的通信速度受光速限制,纽约和旧金山之间 TCP 数据包往返的最佳情况场景是 40 毫秒;在实际操作中,良好互联网连接的平均跨国家延迟约为 80 毫秒。因此,我们的数据管道和模型能够在用户甚至收到他们的 HTTP 响应之前 20 毫秒就根据用户反馈进行自我更新。

并非每个应用程序都需要实时处理。为 API、数据管道、消息队列、Redis 存储和分类器托管分别管理服务器,在努力和预算方面可能都是过度的。您需要确定最适合您用例的方案。

最后要考虑的不是数据管道相关的问题,而是模型本身的存储和交付,特别是在混合方法中,模型在服务器上训练但在客户端评估的情况下。首先要问自己的问题是模型是否被认为是公共的还是私有的。例如,私有模型不应存储在公共的 Amazon S3 存储桶中;相反,S3 存储桶应设置访问控制规则,并且您的应用程序需要获取一个带有过期时间的签名下载链接(S3 API 可以帮助完成这项工作)。

下一个考虑因素是模型的大小以及客户端下载模型的频率。如果公共模型经常被下载但更新不频繁,使用 CDN 以利用边缘缓存可能是最好的选择。例如,如果您的模型存储在 Amazon S3 上,那么 Amazon CloudFront CDN 将是一个不错的选择。

当然,你总是可以构建自己的存储和交付解决方案。在本章中,我假设了一个云架构,然而如果你只有一个专用的或共址服务器,你可能只想将序列化的模型存储在磁盘上,并通过你的网络服务器软件或应用程序的 API 提供服务。在处理大型模型时,确保考虑如果许多用户同时尝试下载模型会发生什么。如果太多人同时请求文件,你可能会无意中饱和服务器的网络连接,你可能会超出服务器 ISP 设置的任何带宽限制,或者你可能会发现服务器的 CPU 在移动数据时陷入 I/O 等待状态。

如前所述,没有一种适合所有情况的数据管道解决方案。如果你是一个为了乐趣或仅仅为几个用户开发应用程序的爱好者,你有很多数据存储和交付的选择。然而,如果你在一个大型企业项目中以专业身份工作,你将不得不考虑数据管道的所有方面以及它们如何影响应用程序的性能。

我将给阅读这一部分的爱好者提供一条最后的建议。虽然对于爱好项目来说,你确实不需要一个复杂的、实时的数据处理管道,但你仍然应该构建一个。能够设计和构建实时的数据处理管道是一项非常具有市场价值和稀缺的技能,而且很多人都不具备这项技能。如果你愿意投入实践去学习机器学习算法,那么你也应该练习构建性能良好的数据处理管道。我并不是说你应该为每一个爱好项目都构建一个庞大而复杂的数据处理管道——只是说你应该尝试几次,使用几种不同的方法,直到你不仅对概念感到舒适,也对实现感到舒适。熟能生巧,而实践意味着亲自动手。

摘要

在本章中,我们讨论了与生产中机器学习应用相关的许多实际问题。学习机器学习算法当然是构建机器学习应用的核心,但构建应用远不止简单地实现算法。应用最终需要与各种设备上的用户进行交互,因此,仅仅考虑你的应用能做什么是不够的——你还必须计划它将如何以及在哪里被使用。

我们本章开始时讨论了可序列化和可移植的模型,并学习了模型训练和评估的不同架构方法。我们讨论了完全服务器端的方法(常见于 SaaS 产品),完全客户端的方法(对于敏感数据很有用),以及一种混合方法,即模型在服务器上训练但在客户端评估。你还学习了关于 Web Workers 的内容,这是一个有用的浏览器特定功能,你可以使用它来确保在客户端评估模型时有一个性能良好且响应迅速的用户界面。

我们还讨论了持续更新或定期重新训练的模型,以及客户端和服务器之间传递反馈的各种方法。你还学习了关于按用户模型,或者可以由一个中心真实来源训练但可以通过个别用户的特定行为进行优化的算法。

最后,你学习了关于数据管道以及各种管理数据从一系统到下一系统收集、组合、转换和交付的机制。在我们对数据管道的讨论中,一个中心主题是使用数据管道作为机器学习算法和其余生产系统之间的一层抽象。

我想要讨论的最后一个话题是许多机器学习学生都好奇的:你究竟是如何为特定问题选择正确的机器学习算法的?机器学习专家通常发展出一种指导他们决策的直觉,但这种直觉可能需要数年才能形成。在下一章中,我们将讨论你可以使用的实用技术,以缩小针对任何给定问题的适当机器学习算法的选择范围。

第十二章:为您的应用程序选择最佳算法

软件工程过程有三个不同的阶段:构思、实施和部署。本书主要关注过程的实施阶段,这是软件工程师开发项目核心功能(即机器学习算法)和特性的阶段。在最后一章,我们讨论了与部署阶段相关的问题。我们的学习几乎已经完成。

在这一章的最后,我们将转向构思阶段,以完善我们对整个机器学习开发过程的理解。具体来说,我们将讨论如何为给定问题选择最佳算法。机器学习生态系统正在演变,令人畏惧,充满了连经验丰富的软件开发者都感到陌生的术语。我经常看到机器学习的初学者在过程的开始阶段陷入困境,不知道在广阔而陌生的领域中从何入手。他们还没有意识到,一旦你克服了选择算法和解读术语的初步障碍,剩下的旅程就会容易得多。

本章的目标是提供一个指南针,一个简单的指南,你可以用它来在领域中找到自己的道路。选择正确的算法并不总是容易,但有时也是可能的。本章的前几节将教你四个简单的决策点——本质上就是四个多项选择题——你可以使用这些决策点来专注于最适合你项目的算法。在经过这个过程后,大多数情况下,你将只剩下一个或两个算法可供选择。

然后,我们将继续通过讨论与规划机器学习系统相关的其他主题来继续我们的教育。我们将讨论你选择了错误算法的明显迹象,这样你就可以尽早识别错误。你还将学习如何区分使用错误的算法与一个糟糕的实现之间的区别。

我还会给你展示如何结合两种不同的机器学习模型(ML models)的例子,这样你就可以从适合各自任务的独立模型中组合出更大的系统。如果设计得当,这种方法可以产生非常好的结果。

我之所以称这一章为指南针而不是地图,是有原因的。它不是一本涵盖计算机科学家所知的所有机器学习算法的全面指南。就像指南针一样,你也必须运用你的智慧和技能来找到自己的道路。使用本章来找到你自己的项目的起点,然后继续进行你自己的研究。虽然本书中讨论的 20 多种算法和技术为你提供了对整个领域的广泛视角,但它们只是生态系统的一小部分。

当我们来到这本书的结尾时,我想给你一条最后的建议。要成为某个领域的专家,需要持续致力于练习和玩耍。如果你想成为一名世界级的钢琴家,你必须花无数个小时用节拍器进行细致的死记硬背练习,练习指法练习,学习有挑战性的练习曲

但你还得去“玩”,这是探索发生和创造力发展的地方。经过三十分钟的练习后,钢琴家可能会花三十分钟即兴创作爵士乐,尝试旋律和对位,学习音乐的“je ne sais quoi”或音阶和模式中的情感本质。这种玩耍的探索,创造力中的实验,以死记硬背所不具备的方式发展了音乐的直觉感。死记硬背——细致的工作和学习——反过来又发展了机械感和技能,这是玩耍所不能做到的。练习和玩耍在良性循环中相互提升。有技能的人能够比没有技能的人探索得更远、更深入,而更深层次的东西所带来的兴奋感正是推动技能发展的练习的动力。时间和耐心、练习和玩耍、动机和纪律是你从新手到专家所需要的一切。

机器学习与爵士钢琴相反,但成为专家的道路是相同的。机器学习的死记硬背——相当于练习音阶——是构建和实现算法。我特别推荐从头开始编写算法作为练习;这是真正理解内部发生情况的唯一方法。不要只写一次算法来证明自己可以做到。要在不同的环境中、不同的编程语言、不同的架构、不同的数据集上多次编写算法,并且一直这样做,直到你能够几乎从头到尾地写出整个算法。我相当确信我可以在任何三种编程语言中闭着眼睛编写一个朴素贝叶斯分类器,就像你在编写了数十个之后也能做到的那样。

机器学习的乐趣在于实验。本章是关于为你的应用程序选择最佳算法,但这并不是法律规则。如果你从不实验,你就永远不会对算法或数据发展出丰富的直觉。尝试其他方法、参数或算法的变体,并从实验中学习。你会惊讶于实验成功有多频繁,但更重要的是,实验应该成为你的练习和教育的一部分。

让我们从讨论四个主要决策点开始,这些决策点可以帮助你在算法上磨练技能:

  • 学习方式

  • 当前任务

  • 数据的格式或形式

  • 可用资源

我们还将讨论当一切出错时应该做什么,最后我们将讨论将多个模型组合在一起。

学习方式

选择机器学习算法时,首先要考虑学习过程的模式:监督学习、无监督学习或强化学习。这些模式之间几乎没有重叠;一般来说,一个算法要么是监督学习,要么是无监督学习,但不会两者兼具。这大约将你的选择缩小了一半,幸运的是,判断哪种学习模式适用于你的问题非常容易。

监督学习和无监督学习之间的区别在于你是否需要标记的训练示例来教授算法。如果你只有数据点,而没有与之关联的标签或类别,那么你只能执行无监督学习。因此,你必须选择一个无监督学习算法,例如 k-means 聚类、回归、主成分分析PCA)或奇异值分解。

监督学习和无监督学习之间的另一个明显区别是是否存在判断语义准确性的方法。如果你的应用中判断准确性的概念没有意义(因为你没有标记的训练数据或参考数据),那么你面临的是一个无监督学习问题。

然而,在某些情况下,你可能没有训练数据,但问题最好通过监督学习来解决。认识到拥有训练数据和需要训练数据之间的区别很重要。当你需要训练数据时,你很可能面对的是一个监督学习问题。如果你需要训练数据但没有,你必须想出某种方法来获取训练数据。

为问题生成训练数据的最简单方法是自行生成。在图像分类任务中,你可以手动标记几百张图片来生成你的训练数据。这很耗时,但对于小规模训练集来说可行。

一种更可扩展的方法是使用像 Amazon Mechanical Turk 这样的服务,通过这个服务,你可以支付给人工工作者每张图片 0.05-0.10 美元的标签费用。Mechanical Turk 方法在机器学习研究人员和数据科学家中变得非常流行,因为它是一种快速且可扩展的方式,以合理的成本生成大量训练数据。在 Mechanical Turk 上为 5,000 张图片生成标签可能需要 250 美元,并且可能需要一两天的时间。如果你认为 250 美元很贵,考虑一下如果你亲自标记这 5,000 张图片需要花费多少时间。

有更多巧妙的方法来生成训练数据,例如将责任转移到你的应用程序的用户身上。多年前,当 Facebook 首次引入在照片中标记人的功能时,他们要求照片的上传者围绕每个主题的脸部画一个框并标记他们。经过数十亿张照片的上传,Facebook 拥有一个巨大的训练集,不仅能够识别面部形状,还能识别照片中的特定人物。如今,在标记照片时不再需要围绕人们的脸部画框,通常 Facebook 能够自动识别照片中的每个主题。我们,作为用户,为他们提供了这个庞大的训练集。

监督学习通过在预标注数据上训练算法来显现,其目标是算法从标签中学习,并且能够将这种知识扩展并应用于新的、未见过的例子。如果你发现自己处于一个有很多数据点,但其中只有一些,而不是全部都有正确答案或标签的情况,你可能需要一个监督学习算法。如果你有百万封电子邮件,其中 5,000 封被手动过滤为垃圾邮件或非垃圾邮件,并且如果目标是扩展这种知识到其他 995,000 条消息,那么你正在寻找一个监督学习算法。

如果你需要判断算法的语义准确性,你也必须使用监督学习算法。无监督算法没有真相来源;这些算法将聚类、平滑或外推数据,但没有权威的参考,无法判断算法的准确性。另一方面,监督学习算法可以通过预标注的训练数据作为真相来源来判断其语义准确性。

虽然我们在这本书中没有涵盖强化学习算法,但它们的特点是算法必须通过影响其环境来尝试优化行为。算法的输出与行动结果之间存在一定的距离,因为环境本身就是一个因素。强化学习的一个例子是教 AI 通过扫描屏幕并使用鼠标和键盘控制来玩电子游戏。玩《超级马里奥兄弟》的 AI 只能通过在控制板上按上、下、左、右、A 和 B 的组合来与环境互动。算法的输出在环境中采取行动,环境将奖励或惩罚这些行动。因此,算法试图最大化其奖励,例如通过收集金币、通过关卡、击败敌人以及不落入无底洞。

强化学习算法是应用机器人学、控制系统设计、基于模拟的优化、硬件在环模拟以及许多其他领域的重大主题,这些领域将物理世界与算法世界相结合。当您研究的系统——环境——是一个复杂的黑盒,无法直接建模时,强化学习最为有效。例如,强化学习被用于优化将在任意环境中使用的系统的控制策略,例如必须自主导航未知地形的机器人。

最后,有一些任务只需要优化一个模型已知或可以直接观察的系统。这些只是与机器学习问题有间接关系,更合适地称为优化问题。如果您必须根据当前交通状况从 A 点选择最佳驾驶路线到 B 点,例如,您可以使用遗传算法。如果您必须优化配置不同模型的一小部分参数,您可能尝试网格搜索。如果您必须确定复杂系统的边界条件,蒙特卡洛方法可能有所帮助。如果您必须找到连续系统的全局最优解,那么随机梯度下降可能适合您的目的。

优化算法通常用于解决机器学习算法的问题。确实,用于训练人工神经网络的反向传播算法使用梯度下降作为其优化器。k-means 或其他无监督算法的参数可以通过网格搜索自动调整,以最小化方差。我们在这本书中没有深入讨论优化算法,但您应该了解它们及其用例。

当为您的应用程序选择算法时,从最简单的问题开始:我需要监督学习还是无监督学习?确定您是否拥有或可以生成训练数据;如果您不能,您被迫使用无监督算法。问问自己是否需要判断算法输出的准确性(监督学习),或者您是否只是在探索数据(无监督)。

在您确定了学习模式,这将大致将您的选择减半之后,您可以通过考虑具体任务或研究目标来进一步专注于您需要的算法。

当前任务

最有效地划分机器学习算法的世界的方法是考虑当前任务,或者算法的预期结果和目的。如果您能确定您问题的目标——也就是说,您是否需要根据输入预测连续值,对数据进行分类,对文本进行分类,降低维度等——您就能将您的选择减少到只有几个算法。

例如,在你需要预测一个连续输出值的情况下——例如预测未来某天的服务器负载——你可能会需要一个回归算法。可供选择的回归算法只有少数几个,本指南中的其他决策点将有助于进一步减少这些选项。

在你需要检查数据并识别彼此相似的数据点的情况下,聚类算法将是最合适的。你选择的特定聚类算法将取决于其他决策点,例如数据的格式或形式、关系的线性或非线性,以及你拥有的资源(时间、处理能力、内存等)。

如果你的算法目的是将数据点分类为一打可能的标签之一,你必须从几个分类算法中选择一个。再次强调,从你可用的分类算法家族中选择正确的算法将取决于数据的形式、你对准确度的要求以及施加在你身上的任何资源限制。

初学者在这个阶段面临的一个常见问题是项目实际目标与单个算法能力之间的不明确性。有时,问题的业务目标是抽象的,只部分定义。在这些情况下,业务目标通常只能通过使用几个单独的算法来实现。机器学习的学生可能难以确定为了实现目标必须组合的具体技术步骤。

一个说明性的例子是编写一个分析图像并返回图像内容自然语言描述的应用程序的业务目标。例如,当上传一张公园小径的照片时,目标可能是返回文本“一条小径上有一个公园长椅和垃圾桶,背景有树木”。人们很容易专注于项目的单一业务目标,并假设一个业务目标对应一个算法。

然而,这个例子至少需要两个或三个机器学习算法。首先,一个卷积神经网络CNN)必须能够识别图像中的对象。然后,另一个算法必须能够确定对象之间的空间关系。最后,一个自然语言处理或机器学习算法必须能够从前两个算法的输出中提取信息,并从该信息中构建自然语言表示。

能够理解业务目标并将其转化为具体的技术步骤需要时间和经验来培养。你必须能够解析业务目标,并反向工作以将其分解为单个子任务。一旦确定了子任务,确定哪个算法最适合每个子任务就成为了这个决策过程中的一个简单练习。我们很快就会讨论算法组合的话题,但到目前为止,重要的启示是,某些业务目标可能需要多个机器学习算法。

在某些情况下,手头的任务将你的选择缩小到只有一个算法。例如,图像中的目标检测最好通过卷积神经网络(CNN)来实现。当然,有许多不同的专门子类型的 CNN 可以执行目标检测(如 RCNN、Fast RCNN、Mask RCNN 等),但在这个案例中,我们能够将竞争范围缩小到仅 CNN。

在其他情况下,手头的任务可以通过几个或许多算法来完成,在这种情况下,你必须使用额外的决策点来选择最适合你应用的最佳算法。例如,情感分析可以通过许多算法来实现。朴素贝叶斯分类器、最大熵模型、随机森林和人工神经网络(尤其是循环神经网络)都可以解决情感分析问题。

你可能还需要组合多个算法以实现情感分析器最佳精度。因此,使用哪种方法的决策不仅取决于手头的任务,还取决于你组合中使用的其他算法的数据的形式和格式。并非每个算法都适用于与其他所有算法组合使用,因此形式和格式决策点实际上是递归的,你需要将其应用于你为追求业务目标而确定的每个子任务。

格式、形式、输入和输出

我所描述的数据的格式和形式包含几个概念。最表面地,数据的格式与输入和输出的具体数据类型(例如,整数、连续数字/浮点数、文本和离散类别)有关。数据的形式封装了数据结构之间的关系以及问题或解决方案空间的整体形状。这些因素可以帮助你在手头的任务提供了多个算法选择的情况下选择合适的算法。

当处理文本(格式)时,例如,你必须考虑文本在问题空间和当前任务中的处理方式;我称之为数据的形式。在过滤垃圾邮件时,通常没有必要映射单个单词之间的关系。在分析文本以进行情感分析时,确实可能需要映射一些单词之间的关系(例如,处理否定或其他语言修饰语),这将需要额外的维度。在解析文本以构建知识图谱时,你需要细致地映射不仅单个单词之间的关系,还有它们的概念意义之间的关系,这需要更高的维度。

在这些例子中,数据格式是相同的——所有三个例子都处理文本,但形式不同。问题空间的结构不同。垃圾邮件过滤器有一个更简单的问题空间,具有较低维度和线性可分的关系;每个单词都是独立处理的。情感分析器具有不同的形式,需要问题空间中的一些额外维度来编码某些单词之间的关系,但并不一定需要所有单词之间所有关系。知识图谱问题需要一个高度维度的空间,将单词之间复杂的语义和空间关系映射进去。

在这些文本分析案例中,你常常可以问自己一系列简单的问题,这些问题有助于你缩小到正确的算法:每个单词是否可以独立处理?我们需要考虑单词是否被其他单词修改(例如不像不像,或非常好)吗?我们需要维护相隔很远的单词之间的关系(例如,一篇在第一段介绍主题但在许多段落之后继续提及该主题的维基百科文章)?

你跟踪的每一项关系都会给问题空间增加一个维度,因此你必须选择一个能够在问题空间维度上有效工作的算法。在处理像知识图谱问题这样的高维问题时使用低维度的算法,例如朴素贝叶斯,是不会得到好结果的;这就像一个人一生都在三维空间中生活,却试图可视化物理中的十维超弦理论空间。

相反,一个高度维度的算法,如长短期记忆LSTM)RNN,可以解决低维问题,如垃圾邮件过滤,但它有一个代价:训练高度维度算法所需的时间和资源。它们与问题的难度不相称。贝叶斯分类器可以在几十秒内训练数百万份文档,但 LSTM RNN 可能需要数小时来训练同样的任务,并且评估数据点的速度慢一个数量级。即使如此,也不能保证 LSTM RNN 在准确性方面优于贝叶斯分类器。

你还必须考虑形式和维度性,当处理数值数据时。与统计分析相比,时间序列分析需要额外的维度来捕捉数据的顺序性,除了数据的值。这类似于词袋模型文本算法(如朴素贝叶斯)与保留文本顺序的算法(如 LSTM RNN)之间的区别。

最后,数据的结构格式可能也是一个需要考虑的因素,尽管格式通常可以成功转换为更易于处理的格式。在第十章,《实践中的自然语言处理》中,我们讨论了 Word2vec 词嵌入算法,该算法将文本转换为数值向量,以便它们可以作为神经网络(需要数值输入)的输入。格式转换实际上使我们的决策过程更加困难,因为它允许我们从更广泛的算法中选择。使用 Word2vec 意味着我们可以将文本作为输入提供给通常不接受文本的算法,因此给我们提供了更多的选择。

另一种常见的格式转换是对连续数值的量化或分桶。例如,一个从 0 到 10 的连续数值可以量化为三个桶:小、中、大。这种转换允许我们在只处理离散值或类别的算法中使用我们的连续值数据。

你还应该考虑从算法中获取的输出形式和格式。在分类任务中,分类器的名义输出将是一个离散标签,用于描述输入。但并非所有分类器都是同等创建的。决策树分类器将输出一个标签作为其输出,而贝叶斯分类器将输出所有可能标签的概率作为其输出。在这两种情况下,名义输出是一个可能的标签,但贝叶斯分类器还会返回其猜测的置信度以及所有可能猜测的概率分布。在某些任务中,你从算法中需要的可能只是一个单一标签;在其他情况下,概率分布可能是有用的,甚至可能是必需的。

算法输出的形式与模型的数学机制密切相关。这意味着即使没有对算法本身的深入了解,你也可以通过观察其输出的形式来评估模型的数学属性。如果一个分类器将其输出作为概率的一部分,那么它很可能是一个概率分类器,可以用于你怀疑非确定性方法比确定性方法更有效的任务。

同样,如果一个算法返回语义排序的输出(与无序输出相对),那么这表明该算法本身模型化和记住了一些形式的排序。即使你的应用程序不需要直接排序的输出,你也可能选择这个算法,因为你认识到你的数据形式包含了嵌入在数据序数中的信息。另一方面,如果你知道你的数据序数中不包含任何相关信息,那么返回排序输出(因此将序数作为维度进行建模)的算法可能就是过度设计。

如果此时你还有几个算法可供选择,那么最后一步确定最佳算法将是要考虑你所能利用的资源,并将它们与你对准确性和速度的要求进行权衡。

可用资源

通常情况下,从一系列算法选项中很难找出一个明显的胜者。例如,在情感分析问题中,有几种可能的方法,而且通常并不清楚应该选择哪一种。你可以选择带有嵌入否定词的朴素贝叶斯分类器、使用二元组的朴素贝叶斯分类器、LSTM RNN、最大熵模型以及几种其他技术。

如果格式和形式决策点在这里没有帮助你——例如,如果你没有对概率分类器的需求——你可以根据你可用的资源和性能目标来做出决定。贝叶斯分类器轻量级,训练时间快,评估时间非常快,内存占用小,存储和 CPU 需求相对较小。

另一方面,LSTM RNN 是一个复杂的模型,训练时间较长,评估时间适中,对 CPU/GPU 的要求显著,尤其是在训练期间,并且比贝叶斯分类器有更高的内存和存储需求。

如果没有其他因素为你提供如何选择算法的明确指导,你可以根据你或你的应用程序用户可用的资源(CPU、GPU、内存、存储或时间)来做出决定。在这种情况下,几乎总是存在权衡;更复杂的模型通常比简单的模型更准确。当然,这并不总是正确的,因为朴素贝叶斯分类器在垃圾邮件过滤方面始终优于其他方法;但这是因为垃圾邮件检测问题空间的形态和维度。

在某些情况下,你的应用的限制因素将是训练或评估时间。如果一个应用需要 1 毫秒或更少的评估,可能不适合使用人工神经网络(ANN),而一个可以容忍 50 毫秒评估的应用则更加灵活。

在其他情况下,限制因素或可用的资源是算法所需的精度。如果你认为错误的预测是一种资源消耗,你可能能够确定算法所需精度的下限。机器学习的世界并不太不同于高性能体育的世界,因为在金牌和铜牌之间的差距可能只是几秒钟的时间。

这同样适用于机器学习算法:对于特定问题,最先进的算法可能具有 94%的准确率,而更常见的算法可能只有 90%的准确率。如果错误预测的成本足够高,那么这四个百分点的差异可能就是你在选择算法以及投入多少时间和精力解决问题时的决定性因素。另一方面,如果错误预测的成本很低,那么更常见的算法可能基于资源、时间和努力来实现,可能是最佳选择。

如果你仔细考虑这四个决策点——学习模式、当前任务、数据的格式和形式,以及你拥有的资源——你通常会发现最佳算法的选择非常明显。这并不总是如此;有时你将不得不基于你最熟悉的算法和流程做出判断。

有时候,在选择了一个算法之后,你会发现你的结果无法接受。立即放弃你的算法并选择一个新的算法可能会很有诱惑力,但在这里要小心,因为通常很难区分算法选择不当和算法配置不当。因此,你必须准备好在一切出错时调试你的系统。

当出错时

在机器学习中,可能的不理想结果范围很广。这些可能包括模型根本不起作用,或者模型虽然有效但在这个过程中使用了不必要的资源。负面结果可能由许多因素引起,例如选择不适当的算法、特征工程不良、不当的训练技术、预处理不足或结果解释错误。

在最佳情况下——即负面结果的最好情况——问题将在你实施早期阶段自行显现。你可能会在训练和验证阶段发现你的 ANN 从未达到超过 50%的准确率。在某些情况下,ANN 在经过几个训练周期后可能会迅速稳定在 25%的准确率,并且不再提高。

在这种方式下训练过程中显现出来的问题是最容易调试的。一般来说,这些都是你选择了错误算法的迹象。在第五章《分类算法》中,我向你介绍了随机森林分类器,我们发现它在我们示例问题中的准确率低得无法接受。使用随机森林的决定仅仅是错误的决策吗?或者这是一个合理的决策,但由于参数选择不当而受阻?在这种情况下,答案是都不是。我对使用随机森林以及参数选择都很有信心,所以我用不同的编程语言将相同的数据和参数通过一个随机森林库运行,得到了更符合我预期的结果。这指向了第三个可能的原因:我选择的随机森林算法库的具体实现可能存在问题。

有时候问题更难调试,尤其是当你没有从第一原理编写算法时。在第五章《分类算法》中,我向你介绍了随机森林分类器,我们发现它在我们示例问题中的准确率低得无法接受。使用随机森林的决定仅仅是错误的决策吗?或者这是一个合理的决策,但由于参数选择不当而受阻?在这种情况下,答案是都不是。我对使用随机森林以及参数选择都很有信心,所以我用不同的编程语言将相同的数据和参数通过一个随机森林库运行,得到了更符合我预期的结果。这指向了第三个可能的原因:我选择的随机森林算法库的具体实现可能存在问题。

不幸的是,如果没有经验带来的信心,很容易假设随机森林只是那个问题的算法选择不当。这就是为什么我鼓励实践和玩耍,理论和实验。如果没有对随机森林背后概念的彻底理解,我可能被误导,认为算法本身是问题所在,而且如果没有实验的倾向,我可能永远无法确认算法和参数确实合适。

当事情出错时,我的建议是回归到第一原理。回到你的工程设计过程的最初阶段,依次考虑每一步。算法的选择是否合适?数据的形式和格式是否合适?算法能否足够地解决问题空间的维度?我是否适当地训练了算法?问自己这些问题,直到你确定你最没有信心的一项,然后开始探索和实验。

从我的角度来看,机器学习的最坏情况是一个“无声失败”的算法。这些情况是指一个算法在训练和验证中成功,被部署到实际应用中,但使用现实世界数据产生了较差的结果。该算法未能推广其知识,只是足够好地记住了训练数据以通过验证。这种无声的失败发生是因为算法在验证期间显示出良好的准确性,让你产生了虚假的安全感。然后你将算法部署到生产环境中,信任其结果,但几个月或一年后发现算法表现极差,做出了影响真实人或过程的错误决策,现在需要纠正。

因此,你必须始终监控你的算法在实际工作负载下的性能。你应该定期抽查算法的工作,以确保其在现实世界中的准确性与你训练期间观察到的相当。如果一个算法在训练期间达到 85%的准确性,而在对 20 个数据点的生产抽查中产生了 15 个正确答案(75%),那么这个算法可能正在按预期工作。然而,如果你发现只有 50%的现实世界评估是正确的,你应该扩大对算法的审计,并可能基于从更真实的数据点中抽取的更新后的训练集重新训练它。

这些无声的失败通常是由过度训练或训练不当引起的。即使你遵循了训练的最佳实践,并将预标记的数据集分成单独的训练和验证集,仍然可能过度训练和泛化不足。在某些情况下,源数据本身可能存在问题。例如,如果你的整个训练和验证集都是根据大学生的调查结果生成的,那么模型可能无法准确评估老年人的调查结果。在这种情况下,即使你在独立数据集上验证了你的模型,训练和验证数据本身也不是现实世界条件的适当随机抽样。在实际使用中,你将看到比验证结果更低的准确性,因为用于训练模型的数据源已被选择偏差所损害。

类似的情况也可能发生在其他类型的分类任务中。一个在电影评论上训练的情感分析算法可能无法推广到餐厅评论;这两个数据源之间的术语和语气——数据的形式——可能不同。

如果你的模型表现不佳,你真正不知道下一步该做什么,转向实验和探索。使用相同的训练集测试不同的算法并比较结果。尝试生成一个新的训练集,要么更广泛,要么更窄。尝试不同的标记化技术,不同的预处理技术,甚至可能尝试同一算法的不同实现。在网上搜索其他研究人员如何处理类似问题,最重要的是,永远不要放弃。挫折是学习过程的一部分。

模型组合

有时,为了实现单一的商业目标,你需要结合多个算法和模型,并将它们协同使用来解决单个问题。实现这一目标有两种主要方法:串联组合模型和并行组合模型。

在模型串联组合中,第一个模型的输出成为第二个模型的输入。一个简单的例子是在分类器 ANN 之前使用的 Word2vec 词嵌入算法。Word2vec 算法本身就是一个 ANN,其输出被用作另一个 ANN 的输入。在这种情况下,Word2vec 和分类器是分别训练但一起评估的,串联进行。

你也可以将 CNN 视为模型的串联组合;每一层(卷积、最大池化和全连接)的操作都有不同的目的,本质上是一个独立的模型,其输出为下一层提供输入。然而,在这种情况下,整个网络既被评估也被作为一个单一单元进行训练。

并行运行的模型通常被称为集成,随机森林是一个简单的例子。在随机森林中,许多单独的决策树并行运行,并将它们的输出组合。更普遍地说,并行运行的模型不需要是相同类型的算法。例如,在分析情感时,你可以并行运行一个二元朴素贝叶斯分类器和一个 LSTM RNN,并使用它们输出的加权平均来产生比单独运行更准确的结果。

在某些情况下,你可以组合模型以更好地处理异构数据。让我们想象一个商业目标,即根据用户的书面内容和从其个人资料中衍生出的许多其他特征,将用户分类到十个心理测量类别之一。也许目标是分析 Twitter 上的用户,以确定将他们放置在哪个营销垂直领域:时尚达人周末战士运动狂热者等等。你可以使用的数据包括他们推文历史的文本内容,他们的朋友列表和内容互动,以及一些衍生指标,如平均发帖频率,平均 Flesch-Kincaid 阅读难度,关注者与朋友的比例等等。

这个问题是一个分类任务,但它是一个需要通过多个技术步骤才能实现的企业目标。因为输入数据不均匀,我们必须将问题分解成几个部分,并分别解决。然后我们将这些部分组合起来,以形成一个准确且高效的机器学习系统。

首先,我们可以获取用户的推文文本内容,并通过朴素贝叶斯分类器来决定内容最适合的 10 个类别之一。该分类器将返回一个概率分布,例如 5% fashionista,60% sports junkie,和 25% weekend warrior。这个分类器单独可能不足以解决问题;周末战士和运动狂热者倾向于写类似的话题,贝叶斯分类器无法区分两者,因为它们有很多重叠。

幸运的是,我们可以将文本分类与其他信号结合起来,例如用户在推特上发布图片的频率,他们在周末和周中发推文的频率等等。像随机森林这样的算法,可以处理异构输入数据,在这里会很有用。

我们可以采取的方法是使用贝叶斯分类器生成的 10 个概率,将它们与从用户个人资料数据直接导出的另外 10 个特征结合起来,然后将这 20 个特征的组合列表输入到随机森林分类器中。随机森林将学会何时信任贝叶斯分类器的输出,何时更依赖其他信号。例如,当贝叶斯分类器难以区分运动狂热者和周末战士时,随机森林可能会根据额外的上下文在这两者之间做出区分。

此外,随机森林将能够学习何时信任贝叶斯概率,何时不信任。随机森林可能会学会,当贝叶斯分类器以 90% 的概率判断 fashionista 时,它通常是正确的。它可能会以类似的方式学会,当贝叶斯分类器在高概率下判断 weekend warrior 时是不可靠的,并且对于相当一部分时间,周末战士可能会被误认为是运动狂热者。

从直观的角度来看,随机森林是适用于此用例的好算法。因为它基于决策树,能够根据特定属性的值创建决策点。随机森林可能会生成如下逻辑结构:

  • 如果 bayes_fashionista_probability 大于 85%,则返回 fashionista

  • 如果 bayes_weekend_warrior_probability 大于 99%,则返回 weekend warrior

  • 如果 bayes_weekend_warrior_probability 小于 99%,则继续:

    • 如果 twitter_weekend_post_frequency 大于 70%,则返回 weekend warrior

    • 否则,如果 bayes_sports_junkie_probability 大于 60%,则返回 sports junkie

在这个简化的例子中,随机森林已经学会了信任贝叶斯分类器对时尚达人类别的判断。然而,只有当概率非常高时,森林才会信任贝叶斯分类器对周末战士的判断。如果贝叶斯分类器对周末战士的分类不太确定,那么随机森林可以转向用户周末发推文的频率作为一个单独的信号,用于区分周末战士和运动狂热者。

当精心设计时,像这样的组合模型可以是非常强大的工具,能够处理许多情况。这项技术允许你将业务目标分解成多个技术目标,为每种类型的数据或分类选择最佳算法,并将结果组合成一个连贯且自信的响应。

摘要

本书的大部分内容都集中在实现用于解决特定问题的机器学习算法上。然而,算法的实现只是软件开发设计过程的一部分。工程师还必须擅长选择适合她问题的正确算法或系统,并且能够处理出现的问题。

在本章中,你学习了一个简单的四点决策过程,可以帮助你为特定用例选择最佳算法或算法。通过排除法,你可以通过根据每个决策点排除算法来逐步减少你的选择。最明显的是,当你面对监督学习问题时,你不应该使用无监督算法。你可以通过考虑手头的具体任务或业务目标,考虑输入和输出数据的格式和形式或问题空间,以及对你可用的资源进行成本效益分析来进一步排除选项。

我们还讨论了在现实世界中使用机器学习模型时可能出现的某些问题,例如由于训练实践不当而导致的隐蔽的静默故障问题,或者由于算法选择不当或网络拓扑不合适而导致的更明显的故障。

最后,我们讨论了将模型以串联或并行方式组合的想法,以便利用算法的特定优势,尤其是在面对异构数据时。我展示了一个随机森林分类器的例子,它使用直接信号和另一个贝叶斯分类器的输出作为其输入;这种方法有助于消除贝叶斯分类器产生的混淆信号,因为贝叶斯分类器本身可能无法准确解决重叠类别。

我还有很多东西想教给你。这本书仅仅是一个概述,是对机器学习核心概念和算法的快速介绍。我所展示的每一个算法都是一个研究领域,其深度远远超过 10 或 20 页所能教授的内容。

我不期望这本书能解决你所有的机器学习问题,你也不应该这样期望。然而,我希望这本书为你提供了一个坚实的理解基础,你可以在此基础上构建未来的教育。在像机器学习这样的领域,既神秘又充满术语,最大的挑战往往是知道从哪里开始。希望这些页面中的信息已经给你足够的理解和清晰度,能够独自导航更广泛的机器学习世界。

当你阅读完这些最后的页面时,我不期望你在这个机器学习语言的熟练度上已经达到流利,但希望你现在至少能够进行对话。虽然你可能还不能独自设计复杂的 ANN 拓扑结构,但你至少应该对核心概念感到舒适,能够与其他研究人员交流,并找到自己继续深入学习资源的途径。你也可以解决许多以前可能无法解决的问题,如果那样的话,我就达到了我的目标。

我有一个最后的请求:如果你继续你的机器学习教育,尤其是在 JavaScript 生态系统内,请回馈社区。正如你所看到的,今天有很多高质量的 JavaScript 机器学习库和工具,但生态系统中也存在很大的空白。一些算法和技术在 JavaScript 世界中尚不存在,我鼓励你寻找机会,尽可能填补这些空白,无论是通过为开源软件做出贡献,还是为他人编写教育材料。

感谢你抽出时间阅读这本关于 JavaScript 机器学习的谦逊导论——我希望它对你有所帮助。

posted @ 2025-09-03 10:08  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报