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

44 阅读1小时+

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-erra…,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过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.00537.00
14.0022012.00
18.0025222.00
20.0057117.00
22.0039721.00
34.0022023.00
38.0077629.00
50.0046274.00
52.0035463.00
56.002361.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类别。

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

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

DayValue
10.1381426172
20.5678176776
30.3564009968
41.239499423
51.267606181
61.440843361
70.3322843208
80.4329166745
90.5499234277
10-0.4016070826
110.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
200.06757010626
210.480806698
220.2019759014
230.7857692899
240.725414402
251.188534085
260.458488458
270.3017212831
280.5249332545
290.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,数据将如下所示:

DayValue5-Day Moving Average
10.1381426172
20.5678176776
30.35640099680.7138933792
41.2394994230.974433528
51.2676061810.9273268566
61.4408433610.9426299922
70.33228432080.8047147931
80.43291667450.4708721403
90.54992342770.1951372817
10-0.4016070826-0.06510164468
110.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
200.067570106260.006553017948
210.4808066980.2986351872
220.20197590140.4523072795
230.78576928990.6765000752
240.7254144020.6720364272
251.1885340850.6919855036
260.4584884580.6398182965
270.30172128310.561398479
280.52493325450.2533448136
290.33331531460.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个关注者的标准差。最后,我们可以通过确定每个数据点与平均值的偏差来检查每个数据点:找到数据点与平均值之间的绝对差值,然后除以标准差:

关注者偏差
10750.3045078726
18790.3020381533
37940.2961556752
41110.2951819177
42430.2947764414
48850.2928043522
76170.2844122215
85550.2815308824
87550.2809165243
194220.248149739
319140.2097769366
367320.1949770517
395700.1862593113
12303243.471487079

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

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

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

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

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

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

观察
11
22
33
44
55
66
722
88
99
1010

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

图片

对于这样一组小的数据点,回归中的误差可能并不显著,但如果你使用回归来外推未来的值,例如,对于第 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 天的值):

DayValue
10.1381426172
20.5678176776
30.3564009968
41.239499423
51.267606181
61.440843361
70.3322843208
80.4329166745
90.5499234277
10-0.4016070826
110.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
200.06757010626
210.480806698
220.2019759014
230.7857692899
240.725414402
251.188534085
260.458488458
270.3017212831
280.5249332545
290.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。规范化后,我们的数据现在看起来像这样:

DayValueNormalized
10.13814261720.09063184696
20.56781767760.3725306927
30.35640099680.2338256018
41.2394994230.8132039508
51.2676061810.8316440777
61.4408433610.9453005718
70.33228432080.218003266
80.43291667450.284025586
90.54992342770.3607907319
10-0.4016070826-0.2634841615
110.062169068160.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
200.067570106260.04433102293
210.4808066980.3154450087
220.20197590140.1325112363
230.78576928990.5155231854
240.7254144020.4759258831
251.1885340850.7797668924
260.4584884580.3008025808
270.30172128310.1979516366
280.52493325450.3443953167
290.33331531460.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 来解决这个问题。在这个过程中,我们还讨论了误差计算的影响,并对我们的误差计算进行了修改,以帮助我们的解决方案泛化并避免过拟合。

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