原文:
annas-archive.org/md5/da04a1381a1aabc00d45640c27f0ad74译者:飞龙
前言
机器学习(ML)在现代数据驱动世界中扮演着至关重要的角色,并在金融预测、有效搜索、机器人技术、医疗保健中的数字成像等多个领域得到了广泛应用。这是一个快速发展的领域,每周都有新的算法和数据集被学术界和技术公司发布。本书将教会你如何在不同环境中使用 Go 执行各种机器学习任务。
你将了解开发 Go 机器学习应用程序并部署为生产系统所需的重要技术。最佳的学习方式是通过实践,所以请深入其中,开始将机器学习软件添加到自己的 Go 应用程序中。
本书面向对象
本书面向至少具备入门级 Go 知识以及机器学习旨在解决的问题类型模糊概念的开发商和数据科学家。不需要 Go 的高级知识,也不需要机器学习支撑的数学理论理解。
本书涵盖内容
第一章,《用 Go 介绍机器学习》,介绍了机器学习以及与机器学习相关的不同类型的问题。我们还将探讨机器学习开发的生命周期,以及创建和将机器学习应用程序投入生产的流程。
第二章,《设置开发环境》,解释了如何为机器学习应用程序和 Go 设置环境。我们还将了解如何安装交互式环境 Jupyter,以使用 Gota 和 gonum/plot 等库加速数据探索和可视化。
第三章,《监督学习》,介绍了监督学习算法,并演示了如何选择机器学习算法、训练它,并在之前未见过的数据上验证其预测能力。
第四章,《无监督学习》,重用了我们在本书中实现的数据加载和准备的相关技术,但将重点放在无监督机器学习上。
第五章,《使用预训练模型》,描述了如何加载预训练的 Go 机器学习模型并使用它进行预测。我们还将了解如何使用 HTTP 调用用其他语言编写的机器学习模型,这些模型可能位于不同的机器上,甚至位于互联网上。
第六章,《部署机器学习应用程序》,涵盖了机器学习开发生命周期的最后阶段:将用 Go 编写的机器学习应用程序投入生产。
第七章,《结论——成功的机器学习项目》,退后一步,从项目管理角度审视机器学习开发。
为了充分利用本书
代码示例,包括 bash 脚本和安装说明,在具有 8 GB RAM 和 500 GB SSD 硬盘的 Ubuntu 16.04 服务器上进行了测试。需要一台具有类似规格的机器。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持选项卡。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Machine-Learning-with-Go-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781838550356_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“go-deep库让我们能够非常快速地构建这个架构。”
代码块设置如下:
categories := []string{"tshirt", "trouser", "pullover", "dress", "coat", "sandal", "shirt", "shoe", "bag", "boot"}
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“通过点击新建 | 前往创建一个新的笔记本:”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发邮件。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现了我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并附上材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一章:使用 Go 介绍机器学习
在我们周围,自动化正在以细微的增量改变着我们的生活,这些增量处于数学和计算机科学的尖端。一个 Nest 恒温器、Netflix 的电影推荐和 Google 的图片搜索算法有什么共同之处?这些技术都是由当今软件行业中最聪明的大脑之一创造的,它们都依赖于机器学习(ML)技术。
在 2019 年 2 月,Crunchbase 列出了超过 4,700 家将自己归类为人工智能(AI)或机器学习的公司^([1])。其中大部分公司处于非常早期阶段,由天使投资者或风险投资家的早期轮次融资。然而,Crunchbase 在 2017 年和 2018 年的文章以及英国《金融时报》的文章都围绕着一个共同的认识,即机器学习越来越被依赖以实现持续增长([2]),并且其日益成熟将导致更广泛的应用([3]),尤其是如果能够解决机器学习算法决策不透明性的挑战([4])。甚至《纽约时报》还设有专门关于机器学习的专栏([5]),这是对其在日常生活中重要性的致敬。
本书将教会具有 Go 编程语言中级知识的软件工程师如何从概念到部署,以及更远地编写和制作一个机器学习应用程序。我们首先将分类适合机器学习技术的和机器学习应用程序的生命周期中的问题。然后,我们将解释如何使用 Go 语言设置一个专门适合数据科学开发的环境。接着,我们将提供主要机器学习算法、它们的实现及其陷阱的实用指南。我们还将提供一些关于使用其他编程语言产生的机器学习模型并在 Go 应用程序中集成的指导。最后,我们将考虑不同的部署模型以及 DevOps 和数据科学之间难以捉摸的交集。我们将结合我们自己的经验对管理机器学习项目进行一些评论。
机器学习理论是一个数学上高级的学科,但你可以在不完全理解它的情况下开发机器学习应用程序。本书将帮助你发展对使用哪些算法以及如何仅用基本数学知识来构建问题的直觉。
在我们的第一章中,我们将介绍 Go 机器学习应用程序的一些基本概念:
-
什么是机器学习?
-
机器学习问题类型
-
为什么要在 Go 中编写机器学习应用程序?
-
机器学习开发生命周期
什么是机器学习?
机器学习是统计学和计算机科学交叉的领域。这个领域的输出是一系列能够自主操作的算法,它们通过从数据集中推断出最佳决策或答案来解决问题。与传统的编程不同,程序员必须决定程序的规则,并费力地将这些规则编码在他们选择的编程语言的语法中,而机器学习算法只需要足够量的准备数据、从数据中学习的计算能力,以及通常需要一些知识来调整算法参数以改善最终结果。
结果系统非常灵活,并且能够很好地利用人类可能忽略的模式。想象一下从头开始编写一个电视剧推荐系统。你可能首先定义问题的输入和输出,然后找到一个包含电视剧发布日期、类型、演员和导演等详细信息的数据库。最后,你可能创建一个score函数,如果两对电视剧的发布日期接近、属于同一类型、共享演员或拥有相同的导演,则给予更高的评分。
推荐系统是一种预测算法,试图猜测用户会对一个输入样本赋予的评分。在在线零售中,广泛使用的一种应用是使用推荐系统根据用户的过去购买行为向用户推荐商品。
给定一部电视剧,你可以根据相似度评分递减对所有其他电视剧进行排名,并将前几部推荐给用户。在创建score函数时,你会在各种特征的相对重要性上进行判断,例如决定两个系列之间每对共享演员值一分。这种猜测工作,也称为启发式方法,是机器学习算法旨在为你做的事情,节省时间并提高最终结果的准确性,尤其是如果用户偏好发生变化,你必须定期更改评分函数以保持同步。
人工智能和机器学习这两个更广泛领域的区别是模糊的。虽然围绕机器学习的炒作可能相对较新^([6]),但这个领域的历史始于 1959 年,当时人工智能领域的领先专家 Arthur Samuel 首次使用了这些词^([7])。在 20 世纪 50 年代,像 Alan Turing^([8])和 Samuel 本人这样的发明家发明了诸如感知器、遗传算法等机器学习概念。在接下来的几十年里,实现通用人工智能的实践和理论困难导致了诸如基于规则的方法(如专家系统)等方法的产生,这些方法不是从数据中学习,而是从专家制定的规则中学习,这些规则是他们多年来学到的,并以 if-else 语句的形式编码。
机器学习的力量在于算法能够适应之前未见过的案例,这是 if-else 语句无法做到的。如果你不需要这种适应性,可能是因为所有案例事先都是已知的,那么坚持基本原理,使用传统的编程技术即可!
在 20 世纪 90 年代,意识到在现有技术下实现人工智能的可能性不大,人们越来越倾向于采用一种狭隘的方法来解决可以用统计和概率论相结合解决的问题。这导致了机器学习作为一个独立领域的发展。今天,机器学习和人工智能经常被互换使用,尤其是在市场营销文献中^([9])。
机器学习算法的类型
机器学习算法主要有两大类:监督学习和无监督学习。选择哪种类型的算法取决于你拥有的数据和项目目标。
监督学习问题
监督学习问题旨在根据提供的标记输入/输出对,推断输入和输出数据集之间最佳映射。标记数据集作为算法的反馈,允许算法评估其解决方案的优化程度。例如,给定 2010-2018 年每年的平均原油价格列表,你可能希望预测 2019 年的平均原油价格。算法在 2010-2018 年产生的误差将允许工程师估计其在目标预测年份 2019 年的误差。
标记对由一个包含独立变量的输入向量和一个包含依赖变量的输出向量组成。例如,用于面部识别的标记数据集可能包含带有面部图像数据的输入向量,以及编码照片中人物姓名的输出向量。标记集(或数据集)是标记对的集合。
给定一组标记的手写数字,你可能希望预测一个以前未见过的手写数字的标签。同样,给定一个标记为垃圾邮件或非垃圾邮件的电子邮件数据集,一个想要创建垃圾邮件过滤器的公司会希望预测一个以前未见过的消息是否为垃圾邮件。所有这些问题都是监督学习问题。
监督机器学习问题可以进一步分为预测和分类:
-
分类试图用一个已知的输出值来标记一个未知的输入样本。例如,你可以训练一个算法来识别猫的品种。该算法会通过标记它已知的品种来对未知猫进行分类。
-
相比之下,预测算法试图用一个已知或未知的输出值来标记一个未知的输入样本。这也被称为估计或回归。一个典型的预测问题是时间序列预测,其中预测序列的输出值是在之前未见过的某个时间值。
分类算法将尝试将输入样本与给定输出类别列表中的一个项目关联起来:例如,决定一张照片是否代表猫、狗或都不是,这是一个分类问题。预测算法将输入样本映射到输出域中的一个成员,该域可以是连续的:例如,尝试根据一个人的体重和性别猜测其身高,这是一个预测问题。
我们将在第三章“监督学习”中更详细地介绍监督学习算法。
无监督学习问题
无监督学习问题旨在从未标记的数据中学习。例如,给定一个市场研究数据集,聚类算法可以将消费者划分为不同的细分市场,为市场营销专业人士节省时间。给定一个医学扫描数据集,无监督分类算法可以将图像划分为不同类型的组织,以便进行进一步分析。一种称为降维的无监督学习方法与其他算法协同工作,作为预处理步骤,以减少另一个算法在训练时需要处理的数据量,从而缩短训练时间。我们将在第四章“无监督学习”中更详细地介绍无监督学习算法。
大多数机器学习算法都可以在广泛的编程语言中高效实现。虽然 Python 以其易用性和丰富的开源库而受到数据科学家的青睐,但 Go 为创建商业机器学习应用的开发者提供了显著的优势。
为什么要在 Go 中编写机器学习应用?
对于其他语言,尤其是 Python,有更完整的库,这些库已经受益于数十年的世界顶尖大脑的研究。一些 Go 程序员为了寻求更好的性能而转向 Go,但由于机器学习库通常是用 C 编写的,并通过它们的绑定暴露给 Python,因此它们不会像解释型 Python 程序那样遇到相同的性能问题。深度学习框架如 TensorFlow 和 Caffe 对 Go 的绑定非常有限,甚至没有。即使考虑到这些问题,Go 仍然是一个优秀甚至可能是最好的语言,用于开发包含机器学习组件的应用程序。
Go 的优势
对于试图在学术环境中改进最先进算法的研究人员来说,Go 可能不是最佳选择。然而,对于拥有产品概念且现金储备快速减少的初创公司来说,在短时间内以可维护和可靠的方式完成产品的开发是至关重要的,这正是 Go 语言大放异彩的地方。
Go(或 Golang)起源于 Google,其设计始于 2007 年([10])。其声明的目标是创建一个高效、编译的编程语言,感觉轻便且令人愉悦([11])。Go 从众多旨在提高生产应用程序生产力和可靠性的特性中受益:
-
易于学习和接纳新开发者
-
快速构建时间
-
运行时良好的性能
-
极佳的并发支持
-
优秀的标准库
-
类型安全
-
使用
gofmt易于阅读、标准化的代码 -
强制错误处理以最小化意外异常
-
明确、清晰的依赖管理
-
随着项目增长,易于适应的架构
所有这些原因使 Go 成为构建生产系统的优秀语言,尤其是网络应用程序。2018 年 Stack Overflow 开发者调查揭示,尽管只有 7% 的专业开发者将 Go 作为其主要语言,但它位列最受欢迎列表的第 5 位,并且与其他语言相比,Go 程序员的薪资非常高,这认可了 Go 程序员为企业带来的商业价值^([12])。
Go 成熟的生态系统
一些世界上最成功的技术公司将 Go 作为其生产系统的主要编程语言,并积极为其开发做出贡献,例如 Cloudflare([13])、Google、Uber([14])、Dailymotion^([15]) 和 Medium^([16])。这意味着现在有一个广泛的工具和库生态系统,可以帮助开发团队在 Go 中创建可靠、可维护的应用程序。甚至全球领先的容器技术 Docker 也是用 Go 编写的。
在撰写本文时,GitHub 上有 1,774 个用 Go 语言编写的仓库拥有超过 500 个星标,这通常被认为是质量和支持的优良指标。相比之下,Python 有 3,811 个,Java 有 3,943 个。考虑到 Go 相对较年轻,并且允许更快的生产就绪开发,用 Go 编写的得到良好支持的仓库数量相对较大,这构成了开源社区的高度认可。
Go 拥有众多稳定且得到良好支持的开放源代码机器学习库。按 GitHub 星标和贡献者数量,最受欢迎的 Go 机器学习库是 GoLearn^([17])。它也是最新更新的。其他 Go 机器学习库包括 GoML 和 Gorgonia,这是一个深度学习库,其 API 类似于 TensorFlow。
转移在其他语言中创建的知识和模型
数据科学家通常会探索不同的方法来解决机器学习问题,例如使用 Python,并创建一个可以在任何应用程序之外解决问题的模型。这些管道,如将数据输入和输出模型、向客户提供服务、持久化输出或输入、记录错误或监控延迟,并不属于这个交付成果,也不在数据科学家正常工作范围之内。因此,将模型从概念到 Go 生产应用程序需要多语言方法,如微服务。
本书中的大多数代码示例都使用了机器学习算法或绑定到库(如 OpenCV),这些库也在 Python 等语言中可用。这将使您能够快速将数据科学家的原型 Python 代码转换为生产 Go 应用程序。
然而,对于深度学习框架如 TensorFlow 和 Caffe,存在 Go 绑定。此外,对于更基本的算法,如决策树,相同的算法也已经在 Go 库中实现,并且如果以相同的方式配置,将产生相同的结果。综合考虑,这意味着可以在不牺牲准确性、速度或强迫数据科学家使用他们不习惯的工具的情况下,将数据科学产品完全集成到 Go 应用程序中。
机器学习开发生命周期
机器学习开发生命周期是一个创建并推向生产包含解决业务问题的机器学习模型的应用程序的过程。然后,该机器学习模型可以作为产品或服务提供的一部分通过应用程序提供给客户。
以下图表说明了机器学习开发生命周期过程:
定义问题和目标
在任何开发开始之前,必须定义要解决的问题以及理想结果的目标,以设定期望。问题的表述方式非常重要,因为这可能意味着无法解决的问题和简单解决方案之间的区别。这也可能涉及到关于任何算法的输入数据来源的讨论。
机器学习算法通常需要大量数据才能发挥最佳性能。在规划机器学习项目时,获取高质量数据是最重要的考虑因素。
机器学习问题的典型表述形式是“给定 X 数据集,预测 Y”。数据的可用性或缺乏可用性可能会影响问题的表述、解决方案及其可行性。例如,考虑以下问题:“给定一大组标注的手写数字图像”,预测一个之前未见过的图像的标签。深度学习算法已经证明,只要训练数据集足够大,工程师的工作量很小,就可以在这个特定问题上实现相对较高的准确性^([19])。如果训练集不大,问题立即变得更加困难,需要仔细选择要使用的算法。它还影响准确性,从而影响可达到的目标集。
Michael Nielsen 在 MNIST 手写数字数据集上进行的实验表明,对于大多数测试的算法,使用每个数字 1 个标注的输入/输出对进行训练与使用 5 个示例相比,准确率从大约 40%提高到大约 65%^([20])。通常,每个数字使用 10 个示例可以将准确率进一步提高 5%。
如果可用的数据不足以满足项目目标,有时可以通过对现有示例进行微小修改来人工扩大数据集以提高性能。在之前提到的实验中,Nielsen 观察到,向数据集中添加略微旋转或平移的图像可以将性能提高多达 15%。
获取和探索数据
我们之前已经论证,在指定项目目标之前理解输入数据集是至关重要的,尤其是与准确性相关的目标。一般来说,当有大量的训练数据集可用时,机器学习算法会产生最佳结果。用于训练它们的数据越多,它们的性能就越好。
因此,获取数据是机器学习开发生命周期中的一个关键步骤——这个步骤可能非常耗时且充满困难。在某些行业中,隐私法规可能导致个人数据不可用,这使得创建个性化产品变得困难,或者在使用之前需要对源数据进行匿名化。一些数据集可能可用,但可能需要如此广泛的准备甚至人工标记,这可能会给项目时间表或预算带来压力。
即使你没有专有数据集可以应用于你的问题,你也可能找到可用的公共数据集。通常,公共数据集已经引起了研究者的关注,因此你可能发现你试图解决的问题已经被解决,并且解决方案是开源的。以下是一些公共数据集的良好来源:
-
Skymind 开放数据集:
skymind.ai/wiki/open-datasets -
OpenML:
www.openml.org/ -
Kaggle:
www.kaggle.com/datasets -
英国政府开放数据:
data.gov.uk/ -
美国政府开放数据:
www.data.gov/
一旦获取了数据集,就应该对其进行探索,以获得对不同的特征(自变量)如何影响所需输出的基本理解。例如,当试图从自我报告的数据中预测正确的高度和体重时,研究人员在初步探索中确定,年龄较大的受试者更有可能低估肥胖,因此年龄在构建他们的模型时是一个相关特征。试图从所有可用数据中构建模型,即使是不相关的特征,在最坏的情况下可能会导致训练时间更长,并且通过引入噪声严重损害准确性。
花更多的时间来处理和转换数据集是值得的,因为这将提高最终结果的准确性,甚至可能缩短训练时间。本书中的所有代码示例都包括数据处理和转换。
在第二章《设置机器学习环境》中,我们将看到如何使用 Go 语言和一个名为Jupyter的基于浏览器的交互式工具来探索数据。
选择算法
算法的选取可以说是机器学习应用工程师需要做出的最重要的决定,也是需要投入最多研究的工作。有时,甚至需要将机器学习算法与传统计算机科学算法相结合,以便使问题更容易处理——这种例子就是我们后面将要讨论的推荐系统。
开始寻找解决特定问题的最佳算法的一个好方法是确定是否需要监督或无监督方法。我们在本章前面介绍了这两种方法。一般来说,当你拥有标记的数据集,并希望对之前未见过的样本进行分类或预测时,这将使用监督算法。当你希望通过将未标记的数据集聚类成不同的组来更好地理解它,可能为了随后对新样本进行分类,你将使用无监督学习算法。对每种算法的优点和缺点有更深入的了解,以及对你的数据进行彻底的探索,将提供足够的信息来选择算法。为了帮助你开始,我们在第三章《监督学习》中涵盖了各种监督学习算法,在第四章《无监督学习》中涵盖了无监督学习算法。
一些问题可以巧妙地应用机器学习技术和传统计算机科学。其中一个这样的问题是推荐系统,现在在像亚马逊和 Netflix 这样的在线零售商中非常普遍。这个问题要求,给定每个用户购买物品的数据集,预测用户最有可能购买的下 N 个物品。这在亚马逊的“购买 X 的人也购买 Y”系统中得到了体现。
解决方案的基本思想是,如果两个用户购买非常相似的商品,那么任何不在他们购买商品交集中的商品都是他们未来购买的好候选。首先,将数据集转换成将商品对映射到表示它们共现的分数。这可以通过计算相同客户购买两个商品的次数除以客户购买任一商品的次数来计算,得到一个介于 0 和 1 之间的数字。现在这提供了一个标记的数据集来训练一个监督算法,如二元分类器,以预测先前未见对对的分数。结合排序算法,给定一个单一的商品,可以生成一个按可购买性排序的商品列表。
准备数据
数据准备是指在训练算法之前对输入数据集所执行的过程。一个严谨的准备过程可以同时提高数据质量并减少算法达到所需精度所需的时间。数据准备的两个步骤是数据预处理和数据转换。我们将在第二章,设置开发环境,第三章,监督学习,和第四章,无监督学习中详细介绍数据准备。
数据预处理旨在将输入数据集转换为适合与所选算法一起工作的格式。预处理任务的典型示例是将日期列格式化为某种方式,或将 CSV 文件导入数据库,丢弃导致解析错误的任何行。输入数据文件中可能也存在需要填充(例如,使用平均值)或整个样本丢弃的缺失数据值。敏感信息,如个人信息,可能需要被移除。
数据转换是指对数据集进行采样、减少、增强或聚合的过程,使其更适合算法。如果输入数据集较小,可能需要通过人工创建更多示例来增强它,例如在图像识别数据集中旋转图像。如果输入数据集具有探索认为无关的特征,明智的做法是移除它们。如果数据集比问题所需的粒度更细,将其聚合到更粗的粒度可能有助于加快结果,例如,如果问题只需要对每个县进行预测,则将城市级数据聚合到县。
最后,如果输入数据集特别大,例如许多用于深度学习算法的图像数据集,那么从较小的样本开始,这将产生快速结果,以便在投资更多计算资源之前验证算法的可行性,这是一个好主意。
样本过程还将把输入数据集分成训练和验证子集。我们将在后面解释为什么这是必要的,以及应该使用多少数据比例。
训练
机器学习开发生命周期中最计算密集的部分是训练过程。在最简单的情况下,训练一个机器学习算法可能只需要几秒钟,而当输入数据集巨大且算法需要多次迭代才能收敛时,可能需要几天。后者通常与深度学习技术相关。例如,DeepMinds AlphaGo Zero 算法用了四十天时间才完全掌握围棋游戏,尽管它在仅仅三天后就已经很熟练了^([22])。在处理较小数据集和图像或声音识别以外的其他问题上的许多算法,可能不需要这么多的时间或计算资源。
基于云的计算资源正变得越来越便宜,因此,如果一个算法,尤其是深度学习算法,在您的 PC 上训练时间过长,您可以在云实例上部署和训练它,只需花费几美元。我们将在第六章中介绍部署模型,部署机器学习应用。
当算法正在训练时,尤其是如果训练阶段将花费很长时间,那么有一些实时指标来衡量训练进展情况是有用的,这样就可以在不等待训练完成的情况下中断、重新配置和重新启动。这些指标通常被归类为损失指标,其中损失指的是算法在训练或验证子集上犯的假设性错误。
在预测问题中最常见的损失度量指标如下:
-
均方误差(MSE)衡量输出变量与预测值之间平方距离的总和。
-
平均绝对误差(MAE)衡量输出变量与预测值之间绝对距离的总和。
-
Huber 损失是 MSE 和 MAE 的组合,它对异常值更稳健,同时仍然是均值和中值损失的良好估计器。
在分类问题中最常见的损失度量指标如下:
-
对数损失通过对错误分类进行惩罚来衡量分类器的准确性。它与交叉熵损失密切相关。
-
焦点损失是一种新的
损失函数,旨在防止当输入数据集稀疏时出现假阴性^([23])。
验证/测试
软件工程师熟悉测试和调试软件源代码,但如何测试机器学习模型呢?算法片段和数据输入/输出例程可以进行单元测试,但通常不清楚如何确保作为黑盒的机器学习模型本身是正确的。
确保机器学习模型正确性和足够准确的第一步是验证。这意味着将模型应用于预测或分类验证数据子集,并将结果准确性与项目目标进行比较。因为训练数据子集已经被算法看到,所以不能用来验证正确性,因为模型可能会遭受泛化能力差(也称为过拟合)的问题。为了举一个荒谬的例子,想象一个由哈希表组成的机器学习模型,该表记住每个输入样本并将其映射到相应的训练输出样本。该模型在之前记忆的训练数据子集上会有 100%的准确率,但在任何数据子集上都会有非常低的准确率,因此它将无法解决它打算解决的问题。验证测试针对这种现象。
此外,将模型输出与用户接受标准进行验证也是一个好主意。例如,如果你正在为电视剧构建推荐系统,你可能希望确保向儿童推荐的节目永远不会被评为 PG-13 或更高。与其试图将此编码到模型中,这将有一个非零的失败率,不如将此约束推入应用程序本身,因为不执行此约束的成本会太高。此类约束和业务规则应在项目开始时捕获。
集成和部署
机器学习模型与其他应用程序之间的边界必须定义。例如,算法是否会提供一个Predict方法来为给定的输入样本提供预测?是否需要调用者处理输入数据处理,还是算法实现会执行它?一旦这被定义,在测试或模拟机器学习模型以确保应用程序其余部分的正确性时,遵循最佳实践就会变得更容易。对于任何应用程序,关注点的分离都很重要,但对于那些一个组件表现得像黑盒的机器学习应用程序来说,这一点是至关重要的。
机器学习应用程序有几种可能的部署方法。对于 Go 应用程序来说,容器化特别简单,因为编译的二进制文件将没有依赖项(除非在某些非常特殊的情况下需要,例如需要绑定到深度学习库,如 TensorFlow)。不同的云服务提供商也接受无服务器部署,并提供不同的持续集成/持续部署(CI/CD)服务。使用 Go 等语言的部分优势在于,应用程序可以非常灵活地部署,利用可用于传统系统应用程序的工具,而不必求助于混乱的多语言方法。
在第六章,“部署机器学习应用”中,我们将深入探讨诸如部署模型、**平台即服务(PaaS)与基础设施即服务(IaaS)**的对比,以及针对机器学习应用的监控和警报等特定主题,利用为 Go 语言构建的工具。
重新验证
将模型投入生产而无需更新或重新训练的情况很少见。推荐系统可能需要定期重新训练,因为用户偏好会发生变化。用于汽车制造商和型号的图像识别模型可能需要随着市场上更多模型的推出而重新训练。为物联网群体中的每个设备生成一个模型的预测行为工具可能需要持续监控,以确保每个模型仍然满足所需的准确度标准,并对那些不满足标准的模型进行重新训练。
重新验证过程是一个持续的过程,其中测试模型的准确性,如果认为其准确性已降低,则触发自动或手动过程以重新训练它,确保结果始终是最优的。
摘要
在本章中,我们介绍了机器学习以及不同类型的机器学习问题。我们主张使用 Go 语言来开发机器学习应用。然后,我们概述了机器学习开发的生命周期,创建并部署机器学习应用的过程。
在下一章中,我们将解释如何为机器学习应用和 Go 设置开发环境。
进一步阅读
-
www.crunchbase.com/hub/machine-learning-companies,于 2019 年 2 月 9 日检索。 -
www.ft.com/content/133dc9c8-90ac-11e8-9609-3d3b945e78cf。机器学习将成为全球增长的动力。 -
news.crunchbase.com/news/venture-funding-ai-machine-learning-levels-off-tech-matures/。于 2019 年 2 月 9 日检索。 -
www.economist.com/science-and-technology/2018/02/15/for-artificial-intelligence-to-thrive-it-must-explain-itself。于 2019 年 2 月 9 日检索。 -
www.nytimes.com/column/machine-learning。于 2019 年 2 月 9 日检索。 -
例如,请参阅Google Trends for Machine Learning。
trends.google.com/trends/explore?date=all&geo=US&q=machine%20learning。 -
R. Kohavi 和 F. Provost,《机器学习术语表》,第 30 卷第 2-3 期,第 271-274 页,1998 年。30,第 2-3 期,第 271-274 页,1998 年。
-
图灵,艾伦(1950 年 10 月)。《计算机与智能》。Mind. 59(236):433–460。doi:10.1093/mind/LIX.236.433。于 2016 年 6 月 8 日检索。
-
www.forbes.com/sites/bernardmarr/2016/12/06/what-is-the-difference-between-artificial-intelligence-and-machine-learning/。检索日期:2019 年 2 月 9 日。 -
talks.golang.org/2012/splash.article。检索日期:2019 年 2 月 9 日。 -
talks.golang.org/2012/splash.article。检索日期:2019 年 2 月 9 日。 -
insights.stackoverflow.com/survey/2018/。检索日期:2019 年 2 月 9 日。 -
github.com/cloudflare。检索日期:2019 年 2 月 9 日。 -
github.com/uber。检索日期:2019 年 2 月 9 日。 -
github.com/dailymotion。检索日期:2019 年 2 月 9 日。 -
github.com/medium。检索日期:2019 年 2 月 9 日。 -
github.com/sjwhitworth/golearn。检索日期:2019 年 2 月 10 日。 -
查看托管在
yann.lecun.com/exdb/mnist/的 MNIST 数据集。检索日期:2019 年 2 月 10 日。 -
查看以下示例:
machinelearningmastery.com/handwritten-digit-recognition-using-convolutional-neural-networks-python-keras/。检索日期:2019 年 2 月 10 日。 -
cognitivemedium.com/rmnist。检索日期:2019 年 2 月 10 日。 -
从自我报告数据中预测校正体重、身高和肥胖患病率的回归模型:来自 BRFSS 1999-2007 的数据。Int J Obes (Lond)。2010 年 11 月;34(11):1655-64。doi:10.1038/ijo.2010.80。Epub 2010 年 4 月 13 日。
-
deepmind.com/blog/alphago-zero-learning-scratch/。检索日期:2019 年 2 月 10 日。 -
密集目标检测中的焦点损失。Lin 等人。ICCV 2980-2988。预印本可在
arxiv.org/pdf/1708.02002.pdf找到。
第二章:设置开发环境
就像传统的软件开发一样,机器学习应用开发需要掌握专业的样板代码和一个允许开发者以最低的摩擦和干扰速度进行工作的开发环境。软件开发者通常会在基本设置和数据整理任务上浪费大量时间。成为一个高效和专业的机器学习开发者需要能够快速原型化解决方案;这意味着在琐碎的任务上尽可能少地付出努力。
在上一章中,我们概述了主要的机器学习问题和你可以遵循以获得商业解决方案的开发流程。我们还解释了 Go 作为编程语言在创建机器学习应用时所提供的优势。
在本章中,我们将指导你完成设置 Go 开发环境的步骤,该环境针对机器学习应用进行了优化。具体来说,我们将涵盖以下主题:
-
如何安装 Go
-
使用 Jupyter 和 gophernotes 交互式运行 Go
-
使用 Gota 进行数据处理
-
使用 gonum/plot 和 gophernotes 进行数据可视化
-
数据预处理(格式化、清洗和采样)
-
数据转换(归一化和分类变量的编码)
本书附带的代码示例针对基于 Debian 的 Linux 发行版进行了优化。然而,它们可以被适应其他发行版(例如,将apt改为yum)和 Windows 的 Cygwin。
一旦你完成了这一章,你将能够快速探索、可视化和处理任何数据集,以便后续由机器学习算法使用。
安装 Go
开发环境是个人化的。大多数开发者会更倾向于选择一个代码编辑器或工具集,而不是另一个。虽然我们推荐使用 gophernotes 通过交互式工具如 Jupyter,但运行本书中的代码示例的唯一先决条件是 Go 1.10 或更高版本的正常安装。也就是说,go命令应该是可用的,并且GOPATH环境变量应该设置正确。
要安装 Go,从golang.org/dl/下载适用于你系统的二进制发布版。然后,参考以下与你的操作系统匹配的子节之一^([2])。
如果你只想使用 gophernotes 来运行 Go 代码,并且打算使用 Docker 作为安装方法,那么你可以跳过这一部分,直接进入使用 gophernotes 交互式运行 Go部分。
Linux、macOS 和 FreeBSD
二进制发布版被打包成 tar 包。提取二进制文件并将它们添加到你的PATH中。以下是一个示例:
tar -C /usr/local -xzf go$VERSION.$OS-$ARCH.tar.gz && \
export PATH=$PATH:/usr/local/go/bin
要配置GOPATH环境变量,你需要决定你的 Go 文件(包括任何个人仓库)将存放在哪里。一个可能的位置是$HOME/go。一旦你决定了这一点,设置环境变量,例如如下所示:
export GOPATH=$HOME/go
要使此说明永久生效,您需要将此行添加到 .bashrc。如果您使用其他外壳(例如 .zsh),请参阅官方 Go 安装说明,网址为 github.com/golang/go/wiki/SettingGOPATH。
确保您的 GOPATH 不与您的 Go 安装在同一目录中,否则这可能会引起问题。
Windows
二进制发布版本打包为 ZIP 文件或 MSI 安装程序,该安装程序会自动配置您的环境变量。我们建议使用 MSI 安装程序。但是,如果您不这样做,那么在将 ZIP 文件的内容提取到合适的位置(例如 C:\Program Files\Go)后,请确保您使用控制面板将 subdirectory bin 添加到您的 PATH 环境变量中。
一旦将二进制文件安装到合适的位置,您需要配置您的 GOPATH。首先,决定您想要您的 Go 文件(包括任何个人仓库)存放的位置。一个可能的位置是 C:\go。一旦您决定,将 GOPATH 环境变量设置为该目录的路径。
如果您不确定如何设置环境变量,请参阅官方 Go 安装说明,网址为 github.com/golang/go/wiki/SettingGOPATH。
确保您的 GOPATH 不与您的 Go 安装在同一目录中,否则这可能会引起问题。
使用 gophernotes 运行 Go 的交互式操作
Project Jupyter 是一个非营利组织,旨在开发面向数据科学的语言无关交互式计算^([3])。结果是成熟、支持良好的环境,可以探索、可视化和处理数据,通过提供即时反馈和与绘图库(如 gonum/plot)的集成,可以显著加速开发。
虽然它的第一个迭代版本,称为 iPython,最初只支持基于 Python 的处理器(称为 kernels),但 Jupyter 的最新版本已超过 50 个内核,支持包括 Go 语言在内的数十种语言,其中包含三个 Go 语言的内核^([4])。GitHub 支持渲染 Jupyter 文件(称为 notebooks)^([5]),并且有各种专门的在线共享笔记本的枢纽,包括 Google Research Colabs^([6])、Jupyter 的社区枢纽 NBViewer^([7]) 和其企业产品 JupyterHub^([8])。用于演示目的的笔记本可以使用 nbconvert 工具转换为其他文件格式,如 HTML^([9])。
在这本书中,我们将使用 Jupyter 和 Go 的 gophernotes 内核。在 Linux 和 Windows 上开始使用 gophernotes 的最简单方法是使用其 Docker^([10]) 镜像。
对于其他安装方法,我们建议检查 gophernotes GitHub 存储库的 README 页面:
github.com/gopherdata/gophernotes。
开始一个基于 gophernotes 的新项目步骤如下:
-
创建一个新目录来存放项目文件(这个目录不需要在您的
GOPATH中)。 -
(可选)在新目录中运行
git init来初始化一个新的 git 仓库。 -
从新目录中运行以下命令(根据您如何安装 Docker,您可能需要在其前面加上
sudo):docker run -it -p 8888:8888 -v $(pwd):/usr/share/notebooks gopherdata/gophernotes:latest-ds -
在终端中,将有一个以
?token=[一些字母和数字的组合]结尾的 URL。在现代网络浏览器中导航到这个 URL。您创建的新目录将被映射到/usr/share/notebooks,因此请导航到树形结构中显示的这个目录。
在 Windows 上,您可能需要修改前面的命令,将$(pwd)替换为%CD%。
现在我们已经学习了如何安装 Go 以及如何使用 gophernotes 设置基本开发环境,现在是时候学习数据预处理了。
示例 - 正面和负面评论中最常见的短语
在我们的第一个代码示例中,我们将使用多领域情感数据集(版本 2.0)^([11])。这个数据集包含了来自四个不同产品类别的亚马逊评论。我们将下载它,预处理它,并将其加载到 Gota 数据整理库中,以找到正面和负面评论中最常见的短语,这些短语在两者中不会同时出现。这是一个不涉及 ML 算法的基本示例,但将作为 Go、gophernotes 和 Gota 的实战介绍。
您可以在本书的配套仓库中找到完整的代码示例,该仓库位于github.com/PacktPublishing/Machine-Learning-with-Go-Quick-Start-Guide。
初始化示例目录和下载数据集
按照我们之前实施的过程,创建一个空目录来存放代码文件。在打开 gophernotes 之前,从www.cs.jhu.edu/~mdredze/datasets/sentiment/processed_acl.tar.gz下载数据集并将其解压到datasets/words目录下。在大多数 Linux 发行版中,您可以使用以下脚本完成此操作:
mkdir -p datasets/words && \
wget http://www.cs.jhu.edu/~mdredze/datasets/sentiment/processed_acl.tar.gz -O datasets/words-temp.tar.gz && \
tar xzvf datasets/words-temp.tar.gz -C datasets/words && \
rm datasets/words-temp.tar.gz
现在,启动 gophernotes 并导航到/usr/share/notebooks。通过点击New | Go创建一个新的 Notebook。您将看到一个空白的 Jupyter Notebook:
Jupyter 中的输入单元格带有In标签。当您在一个输入单元格中运行代码(Shift + Enter)时,将创建一个新的输出单元格,其中包含结果,并标记为Out。每个单元格都按其执行顺序编号。例如,In [1]单元格是在给定会话中运行的第一个单元格。
尝试运行一些 Go 语句,如下面的代码片段:
a := 1
import "fmt"
fmt.Println("Hello, world")
a
特别注意,即使没有调用fmt.Println(),a变量也会在输出单元格中显示。
在一个会话中定义的所有导入、变量和函数都将保留在内存中,即使你删除了输入单元格。要清除当前作用域,请转到内核 | 重新启动。
加载数据集文件
数据处理的基本任务之一是读取输入文件并加载其内容。完成此任务的一种简单方法是使用 io/ioutil 工具函数 ReadFile。与 .go 文件不同,在 .go 文件中你需要将此代码放在你的 main 函数内部,使用 gophernotes,你可以运行以下代码而不需要声明任何函数:
import "io/ioutil"
const kitchenReviews = "../datasets/words/processed_acl/kitchen"
positives, err := ioutil.ReadFile(kitchenReviews + "/positive.review")
negatives, err2 := ioutil.ReadFile(kitchenReviews + "/negative.review")
if err != nil || err2 != nil {
fmt.Println("Error(s)", err, err2)
}
上述代码将把具有积极情感的厨房产品评论内容加载到名为 positives 的字节切片中,将具有消极情感的评论内容加载到名为 negatives 的字节切片中。如果你已正确下载数据集并运行此代码,它不应该输出任何内容,因为没有错误。如果有任何错误出现,请检查数据集文件是否已提取到正确的文件夹。
如果你已经在文本编辑器中打开了 positive.review 或 negative.review 文件,你可能已经注意到它们是以空格或换行符分隔的对列表,即 phrase:frequency。例如,积极评论的开始如下:
them_it:1 hovering:1 and_occasional:1 cousin_the:2 fictional_baudelaire:1 their_struggles:1
在下一小节中,我们将解析这些对到 Go 结构体中。
将内容解析到结构体中
我们将使用 strings 包将数据文件的 内容解析成对数组的切片。字符串切片中的每个项目将包含一个对,例如 them_it:1。然后我们将进一步通过冒号符号分割这个对,并使用 strconv 包将整数频率解析为 int。每个 Pair 将是以下类型:
type Pair struct {
Phrase string
Frequency int
}
我们将按以下方式操作:
- 首先,观察这些对之间的分隔可以是换行符 (
\n) 或空格。我们将使用字符串包中的strings.Fields函数,该函数将字符串按任何连续的空白字符分割:
pairsPositive := strings.Fields(string(positives))
pairsNegative := strings.Fields(string(negatives))
- 现在,我们将迭代每个对,通过冒号分隔符分割,并使用
strconv包将频率解析为整数:
// pairsAndFilters returns a slice of Pair, split by : to obtain the phrase and frequency,
// as well as a map of the phrases that can be used as a lookup table later.
func pairsAndFilters(splitPairs []string) ([]Pair, map[string]bool) {
var (
pairs []Pair
m map[string]bool
)
m = make(map[string]bool)
for _, pair := range splitPairs {
p := strings.Split(pair, ":")
phrase := p[0]
m[phrase] = true
if len(p) < 2 {
continue
}
freq, err := strconv.Atoi(p[1])
if err != nil {
continue
}
pairs = append(pairs, Pair{
Phrase: phrase,
Frequency: freq,
})
}
return pairs, m
}
- 我们还将返回一个短语映射,以便我们可以在以后排除正负评论交集中的短语。这样做的原因是,正负评论中共同出现的单词不太可能是积极或消极情感的特征。这是通过以下函数完成的:
// exclude returns a slice of Pair that does not contain the phrases in the exclusion map
func exclude(pairs []Pair, exclusions map[string]bool) []Pair {
var ret []Pair
for i := range pairs {
if !exclusions[pairs[i].Phrase] {
ret = append(ret, pairs[i])
}
}
return ret
}
- 最后,我们将此应用于我们的对数组切片:
parsedPositives, posPhrases := pairsAndFilters(pairsPositive)
parsedNegatives, negPhrases := pairsAndFilters(pairsNegative)
parsedPositives = exclude(parsedPositives, negPhrases)
parsedNegatives = exclude(parsedNegatives, posPhrases)
下一步是将解析好的对加载到 Gota 中,这是 Go 的数据处理库。
将数据加载到 Gota 数据框中
Gota 库包含数据框、系列和一些通用数据处理算法的实现^([12])。数据框的概念对于许多流行的数据科学库和语言(如 Python 的 pandas、R 和 Julia)至关重要。简而言之,数据框是一系列列表(称为列或系列),每个列表的长度都相同。每个列表都有一个名称——列名或系列名,具体取决于库所采用的命名法。这种抽象模仿了数据库表,并成为数学和统计工具的简单基本构建块。
Gota 库包含两个包:dataframe 和 series 包。series 包包含表示单个列表的函数和结构,而 dataframe 包处理整个数据框——即整个表格——作为一个整体。Go 开发者可能希望使用 Gota 来快速排序、过滤、聚合或执行关系操作,例如两个表之间的内连接,从而节省实现 sort 接口等样板代码^([13])。
使用 Gota 创建新的数据框有几种方法:
-
dataframe.New(se ...series.Series): 接受一个系列切片(可以通过series.New函数创建)。 -
dataframe.LoadRecords(records [][]string, options ...LoadOption): 接受一个字符串切片的切片。第一个切片将是一个表示列名的字符串切片。 -
dataframe.LoadStructs(i interface{}, options ...LoadOption): 接受一个结构体的切片。Gota 将使用反射根据结构体字段名称来确定列名。 -
dataframe.LoadMaps(maps []map[string][]interface{}): 接受一个列名到切片映射的切片。 -
dataframe.LoadMatrix(mat Matrix): 接受与 mat64 矩阵接口兼容的切片。
在我们的案例中,因为我们已经将数据解析到结构体中,我们将使用 LoadStructs 函数,为正面评论和负面评论创建一个数据框:
dfPos := dataframe.LoadStructs(parsedPositives)
dfNeg := dataframe.LoadStructs(parsedNegatives)
如果你想检查数据框的内容,即 df,只需使用 fmt.Println(df)。这将显示数据框的前 10 行,包括其列名和一些有用的元数据,例如总行数。
寻找最常见的短语
现在数据已经被解析,共现短语已经被过滤,结果短语/频率对已经被加载到数据框中,接下来要做的就是找到正面和负面评论中最常见的短语并显示它们。在不使用数据框的情况下,可以通过创建一个实现 sort 接口的 type ByFrequency []Pair 类型来完成这项工作,然后使用 sort.Reverse 和 sort.Sort 来按频率降序排列正面和负面配对。然而,通过使用 Gota,我们可以每个数据框一行代码就实现这个功能:
dfPos = dfPos.Arrange(dataframe.RevSort("Frequency"))
dfNeg = dfNeg.Arrange(dataframe.RevSort("Frequency"))
现在打印数据框会显示厨房用品正面和负面评论中最常见的 10 个短语。对于正面评论,我们有以下输出:
[46383x2] DataFrame
Phrase Frequency
0: tic-tac-toe 10
1: wusthoff 7
2: emperor 7
3: shot_glasses 6
4: pulp 6
5: games 6
6: sentry 6
7: gravel 6
8: the_emperor 5
9: aebleskivers 5
... ...
<string> <int>
对于负面评论,我们有以下输出:
[45760x2] DataFrame
Phrase Frequency
0: seeds 9
1: perculator 7
2: probes 7
3: cork 7
4: coffee_tank 5
5: brookstone 5
6: convection_oven 5
7: black_goo 5
8: waring_pro 5
9: packs 5
... ...
<string> <int>
这完成了本例。在下一节中,我们将更详细地介绍 Gota 的其他转换和处理功能。
示例 - 使用 gonum/plot 探索身体质量指数数据
在上一节中,我们介绍了 gophernotes 和 Gota。在本节中,我们将探索包含 500 个性别、身高和 BMI 指数样本的数据集。我们将使用 gonum/plot 库来完成这项工作。这个库最初是 2012 年 Plotinum 库的分支^([15]),它包含几个使 Go 中的数据可视化变得更容易的包^([16]):
-
plot包包含布局和格式化接口。 -
plotter包抽象了常见图表类型(如柱状图、散点图等)的布局和格式化。 -
plotutil包包含常见图表类型的实用函数。 -
vg包公开了一个用于矢量图形的 API,在将图表导出到其他软件时特别有用。我们不会介绍这个包。
安装 gonum 和 gonum/plot
无论你是按照之前建议使用 Docker 镜像运行 gophernotes,还是使用其他方法,你都需要使用 gonum/plot。为此,运行 go get gonum.org/v1/plot/... 命令。如果你没有安装 gonum 库,并且没有使用 gophernotes Docker 镜像,你需要使用 go get github.com/gonum/... 命令单独安装它。
要从 Jupyter 打开终端,打开树视图(默认视图)的 Web UI,然后点击 新建 | 终端。
注意,尽管它们的名称相似,但 gonum 和 gonum/plot 并不属于同一个仓库,因此你需要分别安装它们。
加载数据
如果你已经克隆了项目仓库,它将已经包含在 datasets/bmi 文件夹中的 500 人 BMI 数据集。你也可以从 Kaggle^([14]) 下载数据集。数据集是一个包含以下几行数据的单个 CSV 文件:
Gender,Height,Weight,Index
Male,174,96,4
Male,189,87,2
Female,185,110,4
Female,195,104,3
Male,149,61,3
...
与上一节类似,我们将使用 io/ioutil 读取文件到字节切片,但这次,我们将利用 Gota 的 ReadCSV 方法(该方法接受一个 io.Reader 作为参数)直接将数据加载到数据框中,无需预处理:
b, err := ioutil.ReadFile(path)
if err != nil {
fmt.Println("Error!", err)
}
df := dataframe.ReadCSV(bytes.NewReader(b))
检查数据框以确保数据已正确加载:
[500x4] DataFrame
Gender Height Weight Index
0: Male 174 96 4
1: Male 189 87 2
2: Female 185 110 4
3: Female 195 104 3
4: Male 149 61 3
5: Male 189 104 3
6: Male 147 92 5
7: Male 154 111 5
8: Male 174 90 3
9: Female 169 103 4
... ... ... ...
<string> <int> <int> <int>
注意,序列的数据类型已被自动推断。
理解数据序列的分布
了解每个序列的一个好方法是绘制直方图。这将给你一个关于每个序列如何分布的印象。使用 gonum/plot,我们将为每个序列绘制直方图。然而,在我们绘制任何内容之前,我们可以通过 Gota 快速访问一些摘要统计信息,以获得对数据集的基本了解:
fmt.Println("Minimum", df.Col("Height").Min())
fmt.Println("Maximum", df.Col("Height").Max())
fmt.Println("Mean", df.Col("Height").Mean())
fmt.Println("Median", df.Col("Height").Quantile(0.5))
这告诉我们,样本个体的身高介于 140 厘米和 199 厘米之间,他们的平均身高和中位数分别为 169 厘米和 170 厘米,而平均数和中位数如此接近表明偏度较低——也就是说,分布是对称的。
要同时为所有列实现这一点的更快方法,请使用dataframe.Describe函数。这将生成另一个包含每列摘要统计数据的 dataframe:
[7x5] DataFrame
column Gender Height Weight Index
0: mean - 169.944000 106.000000 3.748000
1: stddev - 16.375261 32.382607 1.355053
2: min Female 140.000000 50.000000 0.000000
3: 25% - 156.000000 80.000000 3.000000
4: 50% - 170.000000 106.000000 4.000000
5: 75% - 184.000000 136.000000 5.000000
6: max Male 199.000000 160.000000 5.000000
<string> <string> <float> <float> <float>
现在,我们将使用直方图可视化分布。首先,我们需要将 Gota dataframe 的某一列转换为绘图友好的plotter.Values切片。这可以通过以下实用函数完成:
// SeriesToPlotValues takes a column of a Dataframe and converts it to a gonum/plot/plotter.Values slice.
// Panics if the column does not exist.
func SeriesToPlotValues(df dataframe.DataFrame, col string) plotter.Values {
rows, _ := df.Dims()
v := make(plotter.Values, rows)
s := df.Col(col)
for i := 0; i < rows; i++ {
v[i] = s.Elem(i).Float()
}
return v
}
dataframe.Col函数从给定的 dataframe 中提取所需的列——在我们的例子中是一个单独的列。您还可以使用dataframe.Select,它接受字符串切片的列名,以返回只包含所需列的 dataframe。这可以用于丢弃不必要的数据。
现在,我们可以使用 gonum/plot 创建给定列的直方图的 JPEG 图像,并选择一个标题:
// HistogramData returns a byte slice of JPEG data for a histogram of the column with name col in the dataframe df.
func HistogramData(v plotter.Values, title string) []byte {
// Make a plot and set its title.
p, err := plot.New()
if err != nil {
panic(err)
}
p.Title.Text = title
h, err := plotter.NewHist(v, 10)
if err != nil {
panic(err)
}
//h.Normalize(1) // Uncomment to normalize the area under the histogram to 1
p.Add(h)
w, err := p.WriterTo(5*vg.Inch, 4*vg.Inch, "jpg")
if err != nil {
panic(err)
}
var b bytes.Buffer
writer := bufio.NewWriter(&b)
w.WriteTo(writer)
return b.Bytes()
}
要使用 gophernotes 显示结果绘图,请使用显示对象的适当方法。在这种情况下,我们生成一个 JPEG 图像,因此调用display.JPEG与前面代码生成的字节切片将显示输出单元格中的绘图。完整的代码输入单元格如下:
Display.JPEG(HistogramData(SeriesToPlotValues(df, "Age"), "Age Histogram"))
通常,从 gonum 的内置绘图器创建新绘图的步骤如下:
-
使用
plot.New()创建一个新的绘图——这就像绘图将存在的画布。 -
设置任何绘图属性,例如其标题。
-
创建一个新的基于可用类型(
BarChart、BoxPlot、ColorBar、Contour、HeatMap、Histogram、Line、QuartPlot、Sankey或Scatter)的绘图器。 -
设置任何绘图器属性,并通过调用其
Add方法将绘图器添加到绘图中。 -
如果您想通过 gophernotes 显示绘图,请使用
WriterTo方法和一个字节数组缓冲区将绘图数据输出为字节数组的切片,可以传递给内置的显示对象。否则,使用p.Save将图像保存到文件。
如果您想在 gophernotes 中显示图像而不是保存它,可以使用绘图器的Save方法。例如,p.Save(5*vg.Inch, 4*vg.Inch, title + ".png")将绘图保存为 5 英寸 x 4 英寸的 PNG 文件。
500 人体重/身高/BMI 数据集的结果直方图如下:
在下面的例子中,我们不仅将加载数据并可视化,还将对其进行转换,使其更适合与机器学习算法一起使用。
示例 - 使用 Gota 预处理数据
机器学习算法训练过程的质量和速度取决于输入数据的质量。虽然许多算法对无关列和非规范化的数据具有鲁棒性,但有些则不是。例如,许多模型需要数据输入规范化,使其位于 0 到 1 之间。在本节中,我们将探讨使用 Gota 进行数据预处理的快速简单方法。对于这些示例,我们将使用包含 1,035 条记录的身高(英寸)和体重(磅)的主联赛棒球球员数据集^([17])。根据 UCLA 网站上的描述,数据集包含以下特征:
-
姓名: 球员姓名 -
队伍: 球员所属的棒球队 -
位置: 球员的位置 -
身高(英寸): 球员身高 -
体重(磅): 球员体重,单位为磅 -
年龄: 记录时的球员年龄
为了这个练习的目的,我们将以以下方式预处理数据:
-
删除姓名和队伍列
-
将身高和体重列转换为浮点类型
-
过滤掉体重大于或等于 260 磅的球员
-
标准化身高和体重列
-
将数据分为训练集和验证集,其中训练集大约包含 70%的行,验证集包含 30%
将数据加载到 Gota 中
数据集以 HTML 表格的形式提供在 UCLA 网站上^([17])。在本书的配套仓库中,你可以找到一个 CSV 版本。要快速将 HTML 表格转换为 CSV 格式,而无需编写任何代码,首先选中表格,然后将其复制并粘贴到电子表格程序,如 Microsoft Excel 中。然后,将电子表格保存为 CSV 文件。在文本编辑器中打开此文件,以确保文件中没有碎片或多余的行。
使用dataframe.ReadCSV方法加载数据集。检查 dataframe 会产生以下输出:
[1034x6] DataFrame
Name Team Position Height(inches) Weight(pounds) ...
0: Adam_Donachie BAL Catcher 74 180 ...
1: Paul_Bako BAL Catcher 74 215 ...
2: Ramon_Hernandez BAL Catcher 72 210 ...
3: Kevin_Millar BAL First_Baseman 72 210 ...
4: Chris_Gomez BAL First_Baseman 73 188 ...
5: Brian_Roberts BAL Second_Baseman 69 176 ...
6: Miguel_Tejada BAL Shortstop 69 209 ...
7: Melvin_Mora BAL Third_Baseman 71 200 ...
8: Aubrey_Huff BAL Third_Baseman 76 231 ...
9: Adam_Stern BAL Outfielder 71 180 ...
... ... ... ... ... ...
<string> <string> <string> <int> <int> ...
Not Showing: Age <float>
删除和重命名列
对于这个练习,我们决定我们不需要姓名或队伍列。我们可以使用 dataframe 的Select方法来指定我们希望保留的列名字符串的切片:
df = df.Select([]string{"Position", "Height(inches)", "Weight(pounds)", "Age"})
在此同时,身高和体重列应该重命名以去除单位。这可以通过Rename方法实现:
df = df.Rename("Height", "Height(inches)")
df = df.Rename("Weight", "Weight(pounds)")
得到的数据集如下:
[1034x4] DataFrame
Position Height Weight Age
0: Catcher 74 180 22.990000
1: Catcher 74 215 34.690000
2: Catcher 72 210 30.780000
3: First_Baseman 72 210 35.430000
4: First_Baseman 73 188 35.710000
5: Second_Baseman 69 176 29.390000
6: Shortstop 69 209 30.770000
7: Third_Baseman 71 200 35.070000
8: Third_Baseman 76 231 30.190000
9: Outfielder 71 180 27.050000
... ... ... ...
<string> <int> <int> <float>
将列转换为不同的类型
我们的数据框现在具有正确的列,且列名更简洁。然而,身高和体重列的类型为int,而我们需要它们为float类型,以便正确规范化它们的值。最容易的方法是在首次将数据加载到 dataframe 时添加此LoadOption。即func WithTypes(coltypes map[string]series.Type) LoadOption接受一个列名到系列类型的映射,我们可以使用它来在加载时执行转换。
然而,假设我们没有这样做。在这种情况下,我们通过用具有正确类型的新序列替换列来转换列类型。要生成此序列,我们可以使用 series.New 方法,以及 df.Col 来隔离感兴趣的列。例如,要从当前高度序列生成浮点数序列,我们可以使用以下代码:
heightFloat := series.New(df.Col("Height"), series.Float, "Height")
要替换列,我们可以使用 Mutate 方法:
df.Mutate(heightFloat)
现在对 Height 和 Weight 列都这样做会产生以下输出:
[1034x4] DataFrame
Position Height Weight Age
0: Catcher 74.00000 180.00000 22.990000
1: Catcher 74.00000 215.00000 34.690000
2: Catcher 72.00000 210.00000 30.780000
3: First_Baseman 72.00000 210.00000 35.430000
4: First_Baseman 73.00000 188.00000 35.710000
5: Second_Baseman 69.00000 176.00000 29.390000
6: Shortstop 69.00000 209.00000 30.770000
7: Third_Baseman 71.00000 200.00000 35.070000
8: Third_Baseman 76.00000 231.00000 30.190000
9: Outfielder 71.00000 180.00000 27.050000
... ... ... ...
<string> <float> <float> <float>
过滤掉不需要的数据
假设我们在探索数据后,不希望保留玩家体重大于或等于 260 磅的样本。这可能是因为没有足够重的玩家样本,因此任何分析都不会代表整个玩家群体。这样的玩家可以被称为当前数据集的异常值。
你可以在 godoc.org/github.com/kniren/gota 找到 Gota 库的参考(Godocs)。
Gota 数据帧可以使用 Filter 函数进行过滤。该函数接受一个 dataframe.F 结构,它由目标列、比较器和值组成,例如 {"Column", series.Eq, 1},这将仅匹配 Column 等于 1 的行。可用的比较器如下:
-
series.Eq: 仅保留等于给定值的行 -
series.Neq: 仅保留不等于给定值的行 -
series.Greater: 仅保留大于给定值的行 -
series.GreaterEq: 仅保留大于或等于给定值的行 -
series.Less: 仅保留小于给定值的行 -
series.LessEq: 仅保留小于或等于给定值的行
series.Comparator 类型是字符串的一个别名。这些字符串与 Go 语言本身使用的字符串相同。例如,series.Neq 等同于 "!="。
对于这个练习,我们将应用序列。我们将使用 less 过滤器来删除体重大于或等于 260 磅的行:
df = df.Filter(dataframe.F{"Weight", "<", 260})
归一化身高、体重和年龄列
数据归一化,也称为特征缩放,是将一组独立变量转换以映射到相同范围的过程。有几种方法可以实现这一点:
- 缩放 (最小/最大归一化):这将线性地将变量范围映射到 [0,1] 范围,其中序列的最小值映射到 0,最大值映射到 1。这是通过应用以下公式实现的:
- 均值归一化:如果应用以下公式,这将映射变量范围:
- 标准化 (z 分数归一化):这是一种非常常见的用于机器学习应用的归一化方法,它使用均值和标准差将值序列转换为它们的 z 分数,即数据点相对于均值的多少个标准差。这是通过计算序列的均值和标准差,然后应用以下公式来完成的:
注意,这并不保证将变量映射到封闭范围内。
可以使用以下实用函数实现缩放:
// rescale maps the given column values onto the range [0,1]
func rescale(df dataframe.DataFrame, col string) dataframe.DataFrame {
s := df.Col(col)
min := s.Min()
max := s.Max()
v := make([]float64, s.Len(), s.Len())
for i := 0; i < s.Len(); i++ {
v[i] = (s.Elem(i).Float() - min) / (max - min)
}
rs := series.Floats(v)
rs.Name = col
return df.Mutate(rs)
}
可以使用以下实用函数实现均值归一化:
// meanNormalise maps the given column values onto the range [-1,1] by subtracting mean and dividing by max - min
func meanNormalise(df dataframe.DataFrame, col string) dataframe.DataFrame {
s := df.Col(col)
min := s.Min()
max := s.Max()
mean := s.Mean()
v := make([]float64, s.Len(), s.Len())
for i := 0; i < s.Len(); i++ {
v[i] = (s.Elem(i).Float() - mean) / (max - min)
}
rs := series.Floats(v)
rs.Name = col
return df.Mutate(rs)
}
可以使用以下实用函数实现标准化:
// meanNormalise maps the given column values onto the range [-1,1] by subtracting mean and dividing by max - min
func standardise(df dataframe.DataFrame, col string) dataframe.DataFrame {
s := df.Col(col)
std := s.StdDev()
mean := s.Mean()
v := make([]float64, s.Len(), s.Len())
for i := 0; i < s.Len(); i++ {
v[i] = (s.Elem(i).Float() - mean) / std
}
rs := series.Floats(v)
rs.Name = col
return df.Mutate(rs)
}
对于这个例子,我们将使用以下代码对Height和Weight列应用缩放:
df = rescale(df, "Height")
df = rescale(df, "Weight")
结果如下。请注意,Height和Weight列的值现在位于 0 到 1 之间,正如预期的那样:
[1034x4] DataFrame
Position Height Weight Age
0: Catcher 0.437500 0.214286 22.990000
1: Catcher 0.437500 0.464286 34.690000
2: Catcher 0.312500 0.428571 30.780000
3: First_Baseman 0.312500 0.428571 35.430000
4: First_Baseman 0.375000 0.271429 35.710000
5: Second_Baseman 0.125000 0.185714 29.390000
6: Shortstop 0.125000 0.421429 30.770000
7: Third_Baseman 0.250000 0.357143 35.070000
8: Third_Baseman 0.562500 0.578571 30.190000
9: Outfielder 0.250000 0.214286 27.050000
... ... ... ...
<string> <float> <float> <float>
用于获取训练/验证子集的采样
在训练机器学习算法时,保留数据集的一部分用于验证是有用的。这用于测试模型对先前未见数据的泛化能力,从而确保当面对不属于训练集的现实生活数据时,其有用性。没有验证步骤,就无法确定模型是否具有好的预测能力。
尽管没有关于为验证保留多少数据集的公认惯例,但通常保留 10%到 30%的比例。关于为验证保留多少数据集的研究表明,模型的可调整参数越多,需要保留的数据集比例就越小^([18])。在这个练习中,我们将把我们的 MLB 数据集分为两个子集:一个包含大约 70%样本的训练子集,一个包含 30%样本的验证子集。有两种方法可以做到这一点:
-
选择前 70%的行以形成训练子集的一部分,剩下的 30%形成验证子集的一部分
-
选择随机的 70%样本形成训练子集,并使用剩余的样本进行验证
通常,为了避免确定性采样以确保两个子集都能代表总体人口,最好是避免确定性采样。为了实现随机采样,我们将使用math/rand包生成随机索引,并将其与 Gota 的dataframe.Subset方法结合。第一步是生成数据框索引的随机排列:
rand.Perm(df.Nrow())
现在,我们将从这个切片的前 70%用于训练,剩余的元素用于验证,结果如下所示:
// split splits the dataframe into training and validation subsets. valFraction (0 <= valFraction <= 1) of the samples
// are reserved for validation and the rest are for training.
func Split(df dataframe.DataFrame, valFraction float64) (training dataframe.DataFrame, validation dataframe.DataFrame) {
perm := rand.Perm(df.Nrow())
cutoff := int(valFraction * float64(len(perm)))
training = df.Subset(perm[:cutoff])
validation = df.Subset(perm[cutoff:len(perm)])
return training, validation
}
将此应用于我们的数据框split(df, 0.7)产生以下输出。第一个数据框是训练子集,第二个是验证子集:
[723x4] DataFrame
Position Height Weight Age
0: Relief_Pitcher 0.500000 0.285714 25.640000
1: Starting_Pitcher 0.500000 0.500000 33.410000
2: Second_Baseman 0.375000 0.235714 28.200000
3: Relief_Pitcher 0.562500 0.392857 33.310000
4: Outfielder 0.187500 0.250000 27.450000
5: Relief_Pitcher 0.500000 0.042857 27.320000
6: Relief_Pitcher 0.562500 0.428571 40.970000
7: Second_Baseman 0.250000 0.357143 33.150000
8: Outfielder 0.312500 0.071429 25.180000
9: Relief_Pitcher 0.562500 0.321429 29.990000
... ... ... ...
<string> <float> <float> <float>
[310x4] DataFrame
Position Height Weight Age
0: Relief_Pitcher 0.375000 0.285714 25.080000
1: Relief_Pitcher 0.437500 0.285714 28.310000
2: Outfielder 0.437500 0.357143 34.140000
3: Shortstop 0.187500 0.285714 25.080000
4: Starting_Pitcher 0.500000 0.428571 32.550000
5: Outfielder 0.250000 0.250000 30.550000
6: Starting_Pitcher 0.500000 0.357143 28.480000
7: Third_Baseman 0.250000 0.285714 30.960000
8: Catcher 0.250000 0.421429 30.670000
9: Third_Baseman 0.500000 0.428571 25.480000
... ... ... ...
<string> <float> <float> <float>
使用分类变量编码数据
在前面的数据框中,Position列是字符串。假设我们希望 ML 算法使用这个输入,因为,比如说,我们正在尝试预测球员的体重,而处于某些位置的球员往往有不同的身体组成。在这种情况下,我们需要编码字符串到一个算法可以使用的数值。
一种简单的方法是确定所有球员位置集合,并为集合中的每个成员分配一个递增的整数。例如,我们可能会得到{Relief_Pitcher, Starting_Pitcher, Shortstop, Outfielder,...}集合,然后我们将0分配给Relief_Pitcher,1分配给Starting_Pitcher,2分配给Shortstop,依此类推。然而,这种方法的问题在于数字的分配方式,因为它赋予了不存在分类的类别顺序以重要性。假设 ML 算法的一个步骤是计算跨类别的平均值。因此,它可能会得出结论,Starting_Pitcher是Relief_Pitcher和Shortstop的平均值!其他类型的算法可能会推断出不存在的相关性。
为了解决这个问题,我们可以使用独热编码。这种编码方式会将具有 N 个可能值的分类列拆分为 N 列。每一列,对应于一个分类,将具有值1,当输入属于该列时,否则为0。这也允许存在一个输入样本可能属于多个分类的情况。
使用 Gota 生成给定列的独热编码的步骤如下:
-
列出分类列的唯一值
-
为每个唯一值创建一个新的序列,如果行属于该类别则映射为
1,否则为0 -
通过添加步骤 2 中创建的序列并删除原始列来修改原始数据框
使用映射可以轻松地枚举唯一值:
func UniqueValues(df dataframe.DataFrame, col string) []string {
var ret []string
m := make(map[string]bool)
for _, val := range df.Col(col).Records() {
m[val] = true
}
for key := range m {
ret = append(ret, key)
}
return ret
}
注意,这是使用series.Records方法来返回给定列的值作为字符串的切片。同时,注意返回值的顺序不一定每次都相同。使用UniqueValues(df, "Position")在我们的数据框上运行此函数会得到以下唯一值:
[Shortstop Outfielder Starting_Pitcher Relief_Pitcher Second_Baseman First_Baseman Third_Baseman Designated_Hitter Catcher]
第二步是遍历数据框,在过程中创建新的序列:
func OneHotSeries(df dataframe.DataFrame, col string, vals []string) []series.Series {
m := make(map[string]int)
s := make([]series.Series, len(vals), len(vals))
//cache the mapping for performance reasons
for i := range vals {
m[vals[i]] = i
}
for i := range s {
vals := make([]int, df.Col(col).Len(), df.Col(col).Len())
for j, val := range df.Col(col).Records() {
if i == m[val] {
vals[j] = 1
}
}
s[i] = series.Ints(vals)
}
for i := range vals {
s[i].Name = vals[i]
}
return s
}
此函数将为分类变量的每个唯一值返回一个序列。这些序列将具有类别的名称。在我们的例子中,我们可以使用OneHotSeries(df, "Position", UniqueValues(df, "Position"))来调用它。现在,我们将修改原始数据框并删除Position列:
ohSeries := OneHotSeries(df, "Position", UniqueValues(df, "Position"))
for i := range ohSeries {
df = df.Mutate(ohSeries[i])
}
打印df会得到以下结果:
[1034x13] DataFrame
Position Height Weight Age Shortstop Catcher ...
0: Catcher 0.437500 0.214286 22.990000 0 1 ...
1: Catcher 0.437500 0.464286 34.690000 0 1 ...
2: Catcher 0.312500 0.428571 30.780000 0 1 ...
3: First_Baseman 0.312500 0.428571 35.430000 0 0 ...
4: First_Baseman 0.375000 0.271429 35.710000 0 0 ...
5: Second_Baseman 0.125000 0.185714 29.390000 0 0 ...
6: Shortstop 0.125000 0.421429 30.770000 1 0 ...
7: Third_Baseman 0.250000 0.357143 35.070000 0 0 ...
8: Third_Baseman 0.562500 0.578571 30.190000 0 0 ...
9: Outfielder 0.250000 0.214286 27.050000 0 0 ...
... ... ... ... ... ... ...
<string> <float> <float> <float> <int> <int> ...
Not Showing: Second_Baseman <int>, Outfielder <int>, Designated_Hitter <int>,
Starting_Pitcher <int>, Relief_Pitcher <int>, First_Baseman <int>, Third_Baseman <int>
总结来说,只需使用df = df.Drop("Position")删除Position列。
概述
在本章中,我们介绍了如何为 Go 设置一个针对机器学习应用优化的开发环境。我们解释了如何安装交互式环境 Jupyter,以使用 Gota 和 gonum/plot 等库加速数据探索和可视化。
我们还介绍了一些基本的数据处理步骤,例如过滤异常值、删除不必要的列和归一化。最后,我们讨论了采样。本章介绍了机器学习生命周期的前几个步骤:数据获取、探索和准备。现在你已经阅读了本章,你已经学会了如何将数据加载到 Gota 数据框中,如何使用数据框和序列包来处理和准备数据,使其符合所选算法的要求,以及如何使用 gonum 的 plot 包进行可视化。你还了解了不同的数据归一化方法,这是提高许多机器学习算法准确性和速度的重要步骤。
在下一章中,我们将介绍监督学习算法,并举例说明如何选择机器学习算法,训练它,并在未见过的数据上验证其预测能力。
进一步阅读
-
软件开发浪费。托德·塞达诺和保罗·拉尔夫。ICSE '17 第 39 届国际软件工程会议论文集。第 130-140 页。
-
请参阅官方 Go 安装说明
golang.org/doc/install。获取日期:2019 年 2 月 19 日。 -
jupyter.org/about. 获取日期:2019 年 2 月 19 日。 -
github.com/jupyter/jupyter/wiki/Jupyter-kernels. 获取日期:2019 年 2 月 19 日。 -
查看更多说明,请参阅
help.github.com/articles/working-with-jupyter-notebook-files-on-github/。获取日期:2019 年 2 月 19 日。 -
colab.research.google.com. 获取日期:2019 年 2 月 19 日。 -
nbviewer.jupyter.org/. 获取日期:2019 年 2 月 19 日。 -
jupyter.org/hub. 获取日期:2019 年 2 月 19 日。 -
github.com/jupyter/nbconvert. 获取日期:2019 年 2 月 19 日。 -
查看 Docker 安装说明,Linux 请参阅
docs.docker.com/install/,Windows 请参阅docs.docker.com/docker-for-windows/install/。获取日期:2019 年 2 月 19 日。 -
约翰·布利策,马克·德雷兹,费尔南多·佩雷拉。传记,宝莱坞,音响盒和搅拌机:情感分类的领域自适应。计算语言学协会(ACL),2007 年。
-
github.com/go-gota/gota. 获取日期:2019 年 2 月 19 日。 -
godoc.org/sort#Interface. 获取日期:2019 年 2 月 19 日。 -
www.kaggle.com/yersever/500-person-gender-height-weight-bodymassindex/version/2. 获取日期:2019 年 2 月 20 日。 -
code.google.com/archive/p/plotinum/. 获取日期:2019 年 2 月 20 日。 -
github.com/gonum/plot. 获取日期:2019 年 2 月 20 日。 -
wiki.stat.ucla.edu/socr/index.php/SOCR_Data_MLB_HeightsWeights. 获取日期:2019 年 2 月 20 日。 -
Guyon, Isabelle. 1996. A Scaling Law for the Validation-Set Training-Set Size Ratio. AT&T Bell Lab. 1.
第三章:监督学习
正如我们在第一章中学到的,监督学习是机器学习的两个主要分支之一。从某种意义上说,它与人类学习新技能的方式相似:有人向我们展示该怎么做,然后我们通过模仿他们的例子来学习。在监督学习算法的情况下,我们通常需要大量的例子,即大量的数据提供算法的输入以及预期输出应该是什么。算法将从这些数据中学习,然后能够根据它之前未见过的新输入预测输出。
使用监督学习可以解决大量问题。许多电子邮件系统会自动将新消息分类为重要或不重要,每当新消息到达收件箱时就会使用它。更复杂的例子包括图像识别系统,这些系统可以仅从输入像素值中识别图像内容([1])。这些系统最初是通过学习大量由人类手动标记的图像数据集来学习的,但随后能够自动对全新的图像进行分类。甚至可以使用监督学习来自动驾驶赛车:算法首先学习人类驾驶员如何控制车辆,最终能够复制这种行为([2])。
到本章结束时,您将能够使用 Go 实现两种类型的监督学习:
-
分类,其中算法必须学习将输入分类到两个或多个离散类别中。我们将构建一个简单的图像识别系统来展示这是如何工作的。
-
回归,其中算法必须学习预测一个连续变量,例如,在网站上出售的商品的价格。在我们的例子中,我们将根据输入预测房价,例如房屋的位置、大小和年龄。
在本章中,我们将涵盖以下主题:
-
何时使用回归和分类
-
如何使用 Go 机器学习库实现回归和分类
-
如何衡量算法的性能
我们将涵盖构建监督学习系统涉及的两个阶段:
-
训练,这是使用标记数据校准算法的学习阶段
-
推理或预测,即我们使用训练好的算法来实现其预期目的:从输入数据中进行预测
分类
在开始任何监督学习问题之前,第一步是加载数据并准备数据。我们将从加载MNIST Fashion 数据集^([3])开始,这是一个包含不同服装的小型、灰度图像集合。我们的任务是构建一个能够识别每张图像内容的系统;也就是说,它是否包含连衣裙、鞋子、外套等?
首先,我们需要通过在代码仓库中运行download-fashion-mnist.sh脚本来下载数据集。然后,我们将将其加载到 Go 中:
import (
"fmt"
mnist "github.com/petar/GoMNIST"
"github.com/kniren/gota/dataframe"
"github.com/kniren/gota/series"
"math/rand"
"github.com/cdipaolo/goml/linear"
"github.com/cdipaolo/goml/base"
"image"
"bytes"
"math"
"github.com/gonum/stat"
"github.com/gonum/integrate"
)
set, err := mnist.ReadSet("../datasets/mnist/images.gz", "../datasets/mnist/labels.gz")
让我们先看看图像的样本。每个图像都是 28 x 28 像素,每个像素的值在 0 到 255 之间。我们将使用这些像素值作为算法的输入:我们的系统将从图像中接受 784 个输入,并使用它们来根据包含的衣物项目对图像进行分类。在 Jupyter 中,您可以按以下方式查看图像:
set.Images[1]
这将显示数据集中 28 x 28 像素的图像之一,如下面的图像所示:
为了使这些数据适合机器学习算法,我们需要将其转换为我们在第二章,“设置开发环境”中学到的 dataframe 格式。首先,我们将从数据集中加载前 1000 张图像:
func MNISTSetToDataframe(st *mnist.Set, maxExamples int) dataframe.DataFrame {
length := maxExamples
if length > len(st.Images) {
length = len(st.Images)
}
s := make([]string, length, length)
l := make([]int, length, length)
for i := 0; i < length; i++ {
s[i] = string(st.Images[i])
l[i] = int(st.Labels[i])
}
var df dataframe.DataFrame
images := series.Strings(s)
images.Name = "Image"
labels := series.Ints(l)
labels.Name = "Label"
df = dataframe.New(images, labels)
return df
}
df := MNISTSetToDataframe(set, 1000)
我们还需要一个包含每个图像可能标签的字符串数组:
categories := []string{"tshirt", "trouser", "pullover", "dress", "coat", "sandal", "shirt", "shoe", "bag", "boot"}
首先保留一小部分数据以测试最终算法非常重要。这使我们能够衡量算法在训练过程中未使用的新数据上的表现。如果您不这样做,您很可能会构建一个在训练期间表现良好但在面对新数据时表现不佳的系统。首先,我们将使用 75%的图像来训练我们的模型,25%的图像来测试它。
在使用监督学习时,将数据分成训练集和测试集是一个关键步骤。通常,我们会保留 20-30%的数据用于测试,但如果您的数据集非常大,您可能可以使用更少的比例。
使用上一章中的Split(df dataframe.DataFrame, valFraction float64)函数来准备这两个数据集:
training, validation := Split(df, 0.75)
一个简单的模型——逻辑分类器
解决我们问题的最简单算法之一是逻辑分类器。这是数学家所说的线性模型,我们可以通过考虑一个简单的例子来理解它,在这个例子中,我们试图将以下两个图表上的点分类为圆圈或正方形。线性模型将尝试通过画一条直线来分隔这两种类型的点。这在左边的图表上效果很好,其中输入(图表轴上的)与输出(圆圈或正方形)之间的关系很简单。然而,它不适用于右边的图表,在右边的图表中,无法使用直线将点分成两个正确的组:
面对一个新的机器学习问题时,建议你从一个线性模型作为基线开始,然后将其与其他模型进行比较。尽管线性模型无法捕捉输入数据中的复杂关系,但它们易于理解,通常实现和训练速度也很快。你可能发现线性模型对于你正在解决的问题已经足够好,从而节省了时间,无需实现更复杂的模型。如果不是这样,你可以尝试不同的算法,并使用线性模型来了解它们的效果有多好。
基线是一个简单的模型,你可以将其用作比较不同机器学习算法时的参考点。
回到我们的图像数据集,我们将使用逻辑分类器来决定一张图片是否包含裤子。首先,让我们做一些最终的数据准备:将标签简化为“裤子”(true)或“非裤子”(false):
func EqualsInt(s series.Series, to int) (*series.Series, error) {
eq := make([]int, s.Len(), s.Len())
ints, err := s.Int()
if err != nil {
return nil, err
}
for i := range ints {
if ints[i] == to {
eq[i] = 1
}
}
ret := series.Ints(eq)
return &ret, nil
}
trainingIsTrouser, err1 := EqualsInt(training.Col("Label"), 1)
validationIsTrouser, err2 := EqualsInt(validation.Col("Label"), 1)
if err1 != nil || err2 != nil {
fmt.Println("Error", err1, err2)
}
我们还将对像素数据进行归一化,使其不再是存储在 0 到 255 之间的整数,而是表示为 0 到 1 之间的浮点数:
许多监督式机器学习算法只有在数据归一化(即缩放,使其在 0 到 1 之间)的情况下才能正常工作。如果你在训练算法时遇到困难,请确保你已经正确归一化了数据。
func NormalizeBytes(bs []byte) []float64 {
ret := make([]float64, len(bs), len(bs))
for i := range bs {
ret[i] = float64(bs[i])/255.
}
return ret
}
func ImageSeriesToFloats(df dataframe.DataFrame, col string) [][]float64 {
s := df.Col(col)
ret := make([][]float64, s.Len(), s.Len())
for i := 0; i < s.Len(); i++ {
b := []byte(s.Elem(i).String())
ret[i] = NormalizeBytes(b)
}
return ret
}
trainingImages := ImageSeriesToFloats(training, "Image")
validationImages := ImageSeriesToFloats(validation, "Image")
在正确准备数据之后,现在终于到了创建逻辑分类器并对其进行训练的时候了:
model := linear.NewLogistic(base.BatchGA, 1e-4, 1, 150, trainingImages, trainingIsTrouser.Float())
//Train
err := model.Learn()
if err != nil {
fmt.Println(err)
}
衡量性能
现在我们已经训练好了模型,我们需要通过将模型对每张图片的预测与真实情况(图片是否是一双裤子)进行比较来衡量其表现的好坏。一个简单的方法是测量准确率。
准确率衡量算法能够正确分类输入数据的比例,例如,如果算法的 100 个预测中有 90 个是正确的,那么准确率为 90%。
在我们的 Go 代码示例中,我们可以通过遍历验证数据集并计算正确分类的图片数量来测试模型。这将输出模型准确率为 98.8%:
//Count correct classifications
var correct = 0.
for i := range validationImages {
prediction, err := model.Predict(validationImages[i])
if err != nil {
panic(err)
}
if math.Round(prediction[0]) == validationIsTrouser.Elem(i).Float() {
correct++
}
}
//accuracy
correct / float64(len(validationImages))
精确率和召回率
测量准确率可能会非常误导。假设你正在构建一个系统来分类医疗患者是否会测试出罕见疾病,而在数据集中只有 0.1%的例子实际上是阳性的。一个非常差的算法可能会预测没有人会测试出阳性,然而它仍然有 99.9%的准确率,仅仅因为这种疾病很罕见。
一个分类比另一个分类有更多示例的数据集被称为不平衡。在衡量算法性能时,需要仔细处理不平衡数据集。
一种更好的衡量性能的方法是从将算法的每个预测放入以下四个类别之一开始:
我们现在可以定义一些新的性能指标:
-
精确度衡量的是模型真实预测中正确预测的比例。在下面的图中,这是从模型预测出的真实阳性(圆圈的左侧)除以模型所有阳性预测(圆圈中的所有内容)。
-
召回率衡量模型在识别所有正例方面的好坏。换句话说,真实阳性(圆圈的左侧)除以所有实际为正的数据点(圆圈的整个左侧):
上述图显示了模型在中心圆中预测为真实的数据点。实际上为真的点位于图的左侧一半。
精确度和召回率是在处理不平衡数据集时更稳健的性能指标。它们的范围在 0 到 1 之间,其中 1 表示完美性能。
下面的代码是计算真实阳性和假阴性的总数:
//Count true positives and false negatives
var truePositives = 0.
var falsePositives = 0.
var falseNegatives = 0.
for i := range validationImages {
prediction, err := model.Predict(validationImages[i])
if err != nil {
panic(err)
}
if validationIsTrouser.Elem(i).Float() == 1 {
if math.Round(prediction[0]) == 0 {
// Predicted false, but actually true
falseNegatives++
} else {
// Predicted true, correctly
truePositives++
}
} else {
if math.Round(prediction[0]) == 1 {
// Predicted true, but actually false
falsePositives++
}
}
}
我们现在可以使用以下代码计算精确度和召回率:
//precision
truePositives / (truePositives + falsePositives)
//recall
truePositives / (truePositives + falseNegatives)
对于我们的线性模型,我们得到了 100%的精确度,这意味着没有假阳性,召回率为 90.3%。
ROC 曲线
另一种衡量性能的方法是更详细地观察分类器的工作方式。在我们的模型内部,发生两件事:
-
首先,模型计算一个介于 0 到 1 之间的值,表示给定图像被分类为裤子对的可能性有多大。
-
设置一个阈值,只有得分超过阈值的图像才被分类为裤子。设置不同的阈值可以在牺牲召回率的同时提高精确度,反之亦然。
如果我们查看模型输出在所有不同的阈值从 0 到 1 之间的情况,我们可以更了解它的有用性。我们使用称为接收者操作特征(ROC)曲线的东西来做这件事,这是一个在不同阈值值下,数据集中真实阳性率与假阳性率的图表。以下三个示例显示了不良、中等和非常好的分类器的 ROC 曲线:
通过测量这些 ROC 曲线下的阴影区域,我们得到一个衡量模型好坏的简单指标,这被称为曲线下面积(AUC)。对于不良模型,这个值接近0.5,但对于非常好的模型,这个值接近1.0,表明模型可以同时实现高真实阳性率和低假阳性率。
gonum/stat包提供了一个用于计算 ROC 曲线的有用函数,一旦我们将模型扩展到处理数据集中的每个不同物品,我们就会使用它。
接收者操作特征,或ROC 曲线,是不同阈值值下真实阳性率与假阳性率的图表。它使我们能够可视化模型在分类方面的好坏。AUC 提供了一个简单的衡量分类器好坏的指标。
多分类模型
到目前为止,我们一直在使用二元分类;也就是说,如果图像显示一条裤子,则应输出true,否则输出false。对于某些问题,例如检测电子邮件是否重要,这可能就足够了。但在本例中,我们真正想要的是一个可以识别我们数据集中所有不同类型衣物的模型,即衬衫、靴子、连衣裙等。
对于某些算法实现,你可能需要首先对输出应用 one-hot 编码,如第二章中所示,设置开发环境。然而,在我们的例子中,我们将使用softmax 回归在goml/linear中,这会自动完成这一步。我们可以通过简单地给它输入(像素值)和整数输出(0,1,2 等,代表 T 恤、裤子、开衫等)来训练模型:
model2 := linear.NewSoftmax(base.BatchGA, 1e-4, 1, 10, 100, trainingImages, training.Col("Label").Float())
//Train
err := model2.Learn()
if err != nil {
fmt.Println(err)
}
当使用此模型进行推理时,它将为每个类别输出一个概率向量;也就是说,它告诉我们输入图像是 T 恤、裤子等的概率。这正是我们进行 ROC 分析所需要的,但如果我们要为每张图像提供一个单一的预测,我们可以使用以下函数来找到具有最高概率的类别:
func MaxIndex(f []float64) (i int) {
var (
curr float64
ix int = -1
)
for i := range f {
if f[i] > curr {
curr = f[i]
ix = i
}
}
return ix
}
接下来,我们可以为每个单独的类别绘制 ROC 曲线和 AUC。以下代码将遍历验证数据集中的每个示例,并使用新模型为每个类别预测概率:
//create objects for ROC generation
//as per https://godoc.org/github.com/gonum/stat#ROC
y := make([][]float64, len(categories), len(categories))
classes := make([][]bool, len(categories), len(categories))
//Validate
for i := 0; i < validation.Col("Image").Len(); i++ {
prediction, err := model2.Predict(validationImages[i])
if err != nil {
panic(err)
}
for j := range categories {
y[j] = append(y[j], prediction[j])
classes[j] = append(classes[j], validation.Col("Label").Elem(i).Float() != float64(j))
}
}
//Calculate ROC
tprs := make([][]float64, len(categories), len(categories))
fprs := make([][]float64, len(categories), len(categories))
for i := range categories {
stat.SortWeightedLabeled(y[i], classes[i], nil)
tprs[i], fprs[i] = stat.ROC(0, y[i], classes[i], nil)
}
我们现在可以计算每个类别的 AUC 值,这表明我们的模型在某些类别上的表现优于其他类别:
for i := range categories {
fmt.Println(categories[i])
auc := integrate.Trapezoidal(fprs[i], tprs[i])
fmt.Println(auc)
}
对于裤子,AUC 值为0.96,这表明即使是一个简单的线性模型在这种情况下也工作得非常好。然而,衬衫和开衫的得分都接近0.6。这从直观上是有道理的:衬衫和开衫看起来非常相似,因此模型正确识别它们要困难得多。我们可以通过为每个类别绘制 ROC 曲线作为单独的线条来更清楚地看到这一点:模型在衬衫和开衫上的表现最差,而在形状非常独特的衣物(如靴子、裤子、凉鞋等)上的表现最好。
以下代码加载 gonums 绘图库,创建 ROC 图,并将其保存为 JPEG 图像:
import (
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/plotutil"
"gonum.org/v1/plot/vg"
"bufio"
)
func plotROCBytes(fprs, tprs [][]float64, labels []string) []byte {
p, err := plot.New()
if err != nil {
panic(err)
}
p.Title.Text = "ROC Curves"
p.X.Label.Text = "False Positive Rate"
p.Y.Label.Text = "True Positive Rate"
for i := range labels {
pts := make(plotter.XYs, len(fprs[i]))
for j := range fprs[i] {
pts[j].X = fprs[i][j]
pts[j].Y = tprs[i][j]
}
lines, points, err := plotter.NewLinePoints(pts)
if err != nil {
panic(err)
}
lines.Color = plotutil.Color(i)
lines.Width = 2
points.Shape = nil
p.Add(lines, points)
p.Legend.Add(labels[i], lines, points)
}
w, err := p.WriterTo(5*vg.Inch, 4*vg.Inch, "jpg")
if err != nil {
panic(err)
}
if err := p.Save(5*vg.Inch, 4*vg.Inch, "Multi-class ROC.jpg"); err != nil {
panic(err)
}
var b bytes.Buffer
writer := bufio.NewWriter(&b)
w.WriteTo(writer)
return b.Bytes()
}
如果我们在 Jupyter 中查看图表,我们可以看到最差的类别紧贴着对角线,再次表明 AUC 接近0.5:
非线性模型——支持向量机
为了继续前进,我们需要使用不同的机器学习算法:一种能够对像素输入和输出类别之间的更复杂、非线性关系进行建模的算法。虽然一些主流的围棋机器学习库,如 Golearn,支持基本算法,如局部最小二乘法,但没有一个库支持像 Python 的 scikit-learn 或 R 的标准库那样广泛的算法集。因此,通常需要寻找实现绑定到广泛使用的 C 库的替代库,或者包含适用于特定问题的算法的可配置实现。对于这个例子,我们将使用一个称为支持向量机(SVM)的算法。与线性模型相比,SVM 可能更难使用——它们有更多的参数需要调整——但它们的优势在于能够对数据中的更复杂模式进行建模。
SVM 是一种更高级的机器学习方法,可用于分类和回归。它们允许我们对输入数据应用核,这意味着它们可以建模输入/输出之间的非线性关系。
SVM 模型的一个重要特性是它们能够使用核函数。简单来说,这意味着算法可以对输入数据进行变换,以便找到非线性模式。在我们的例子中,我们将使用LIBSVM库在图像数据上训练 SVM。LIBSVM 是一个开源库,具有多种语言的绑定,这意味着如果你想在 Python 的流行 scikit-learn 库中移植模型,它也非常有用。首先,我们需要做一些数据准备,使我们的输入/输出数据适合输入到 Go 库中:
trainingOutputs := make([]float64, len(trainingImages))
validationOutputs := make([]float64, len(validationImages))
ltCol:= training.Col("Label")
for i := range trainingImages {
trainingOutputs[i] = ltCol.Elem(i).Float()
}
lvCol:= validation.Col("Label")
for i := range validationImages {
validationOutputs[i] = lvCol.Elem(i).Float()
}
// FloatstoSVMNode converts a slice of float64 to SVMNode with sequential indices starting at 1
func FloatsToSVMNode(f []float64) []libsvm.SVMNode {
ret := make([]libsvm.SVMNode, len(f), len(f))
for i := range f {
ret[i] = libsvm.SVMNode{
Index: i+1,
Value: f[i],
}
}
//End of Vector
ret = append(ret, libsvm.SVMNode{
Index: -1,
Value: 0,
})
return ret
}
接下来,我们可以设置 SVM 模型,并使用径向基函数(RBF)核对其进行配置。RBF 核在 SVM 中是一个常见的选择,但训练时间比线性模型要长:
var (
trainingProblem libsvm.SVMProblem
validationProblem libsvm.SVMProblem
)
trainingProblem.L = len(trainingImages)
validationProblem.L = len(validationImages)
for i := range trainingImages {
trainingProblem.X = append(trainingProblem.X, FloatsToSVMNode(trainingImages[i]))
}
trainingProblem.Y = trainingOutputs
for i := range validationImages {
validationProblem.X = append(validationProblem.X, FloatsToSVMNode(validationImages[i]))
}
validationProblem.Y = validationOutputs
// configure SVM
svm := libsvm.NewSvm()
param := libsvm.SVMParameter{
SvmType: libsvm.CSVC,
KernelType: libsvm.RBF,
C: 100,
Gamma: 0.01,
Coef0: 0,
Degree: 3,
Eps: 0.001,
Probability: 1,
}
最后,我们可以将我们的模型拟合到 750 张图像的训练数据上,然后使用 svm.SVMPredictProbability 来预测概率,就像我们之前对线性多类模型所做的那样:
model := svm.SVMTrain(&trainingProblem, ¶m)
正如我们之前所做的那样,我们计算了 AUC 和 ROC 曲线,这表明该模型在各个方面的表现都更好,包括像衬衫和套头衫这样的困难类别:
过度拟合和欠拟合
SVM 模型在我们的验证数据集上的表现比线性模型要好得多,但为了了解下一步该做什么,我们需要介绍机器学习中的两个重要概念:过度拟合和欠拟合。这两个概念都指的是在训练模型时可能发生的问题。
如果一个模型欠拟合数据,它对输入数据中的模式解释得太简单,因此在评估训练数据集和验证数据集时表现不佳。这个问题还有另一个术语,即模型有高偏差。如果一个模型过拟合数据,它太复杂了,不能很好地推广到训练中没有包含的新数据点。这意味着当评估训练数据时,模型表现良好,但当评估验证数据集时表现不佳。这个问题还有另一个术语,即模型有高方差。
理解过拟合和欠拟合之间的区别的一个简单方法是看看以下简单的例子:在构建模型时,我们的目标是构建适合数据集的东西。左边的例子欠拟合,因为直线模型无法准确地将圆和正方形分开。右边的模型太复杂了:它正确地分离了所有的圆和正方形,但不太可能在新的数据上工作得很好:
我们的线性模型受到了欠拟合的影响:它太简单,无法模拟所有类别的差异。查看 SVM 的准确率,我们可以看到它在训练数据上得分为 100%,但在验证数据上只有 82%。这是一个明显的迹象表明它过拟合了:与训练数据相比,它在分类新图像方面表现得更差。
处理过拟合的一种方法是用更多的训练数据:即使是一个复杂的模型,如果训练数据集足够大,也不会过拟合。另一种方法是引入正则化:许多机器学习模型都有一个可以调整的参数,以减少过拟合。
深度学习
到目前为止,我们已经使用支持向量机(SVM)提高了我们模型的性能,但仍然面临两个问题:
-
我们的 SVM 过度拟合了训练数据。
-
也很难扩展到包含 60,000 张图像的全数据集:尝试用更多的图像训练最后一个示例,你会发现它变得慢得多。如果我们将数据点的数量加倍,SVM 算法所需的时间将超过加倍。
在本节中,我们将使用深度神经网络来解决这个问题。这类模型已经在图像分类任务上实现了最先进的性能,以及许多其他机器学习问题。它们能够模拟复杂的非线性模式,并且在大数据集上扩展良好。
数据科学家通常会使用 Python 来开发和训练神经网络,因为它可以访问如TensorFlow和Keras这样的深度学习框架,这些框架提供了极好的支持。这些框架使得构建复杂神经网络并在大型数据集上训练它们变得比以往任何时候都更容易。它们通常是构建复杂深度学习模型的最佳选择。在第五章,使用预训练模型中,我们将探讨如何从 Python 导出训练好的模型,然后从 Go 中进行推理。在本节中,我们将使用go-deep库从头开始构建一个更简单的神经网络,以演示关键概念。
神经网络
神经网络的基本构建块是一个神经元(也称为感知器)。这实际上与我们的简单线性模型相同:它将所有输入结合在一起,即 x[1],x[2],x[3]… 等等,根据以下公式生成一个单一的输出,即 y:
神经网络的魔力来自于当我们组合这些简单的神经元时会发生什么:
-
首先,我们创建一个包含许多神经元的层,我们将输入数据馈送到这个层中。
-
在每个神经元的输出处,我们引入一个激活函数。
-
然后,这个输入层的输出被馈送到另一个包含神经元和激活的层,称为隐藏层。
-
这种过程会重复多次隐藏层——层的数量越多,网络就被说成是越深。
-
一个最终的输出层的神经元将网络的输出结果组合成最终的输出。
-
使用称为反向传播的技术,我们可以通过找到每个神经网络的权重,即 w[0],w[1],w[2]…,来训练网络,使整个网络能够适应训练数据。
下面的图显示了这种布局:箭头代表每个神经元的输出,这些输出被馈送到下一层的神经元的输入中:
这个网络中的神经元被称为全连接或密集层。计算能力和软件的最近进步使得研究人员能够构建和训练比以往任何时候都更复杂的神经网络架构。例如,一个最先进的图像识别系统可能包含数百万个单独的权重,并且需要多天的计算时间来训练所有这些参数以适应大量数据集。它们通常包含不同类型的神经元排列,例如在卷积层中,这些层在这些类型的系统中执行更专业的学习。
在实践中成功使用深度学习所需的大部分技能涉及对如何选择和调整网络以获得良好性能的广泛理解。有许多博客和在线资源提供了更多关于这些网络如何工作以及它们应用到的各种问题的细节。
神经网络中的一个全连接层是指每个神经元的输入都连接到前一层中所有神经元的输出。
一个简单的深度学习模型架构
在构建一个成功的深度学习模型中,大部分的技能在于选择正确的模型架构:层的数量/大小/类型,以及每个神经元的激活函数。在开始之前,值得研究一下是否有人已经使用深度学习解决了与你类似的问题,并发布了一个效果良好的架构。一如既往,最好从简单的东西开始,然后迭代地修改网络以提高其性能。
对于我们的例子,我们将从以下架构开始:
-
输入层
-
包含两个各含 128 个神经元的隐藏层
-
一个包含 10 个神经元的输出层(每个输出类在数据集中都有一个)
-
隐藏层中的每个神经元将使用线性整流单元(ReLU)作为其输出函数
ReLUs 是神经网络中常用的激活函数。它们是向模型中引入非线性的一种非常简单的方式。其他常见的激活函数包括对数函数和双曲正切函数。
go-deep库让我们能够非常快速地构建这个架构:
import (
"github.com/patrikeh/go-deep"
"github.com/patrikeh/go-deep/training"
)
network := deep.NewNeural(&deep.Config{
// Input size: 784 in our case (number of pixels in each image)
Inputs: len(trainingImages[0]),
// Two hidden layers of 128 neurons each, and an output layer 10 neurons (one for each class)
Layout: []int{128, 128, len(categories)},
// ReLU activation to introduce some additional non-linearity
Activation: deep.ActivationReLU,
// We need a multi-class model
Mode: deep.ModeMultiClass,
// Initialise the weights of each neuron using normally distributed random numbers
Weight: deep.NewNormal(0.5, 0.1),
Bias: true,
})
神经网络训练
训练神经网络是另一个需要巧妙调整以获得良好结果的地方。训练算法通过计算模型与一小批训练数据(称为损失)的拟合程度,然后对权重进行小幅度调整以改善拟合。这个过程在不同的训练数据批次上反复进行。学习率是一个重要的参数,它控制算法调整神经元权重速度的快慢。
在训练神经网络时,算法会反复将所有输入数据输入到网络中,并在过程中调整网络权重。每次完整的数据遍历被称为一个epoch。
在训练神经网络时,监控每个 epoch 后网络的准确率和损失(准确率应该提高,而损失应该降低)。如果准确率没有提高,尝试降低学习率。继续训练网络,直到准确率停止提高:此时,网络被认为是收敛了。
以下代码使用0.006的学习率对模型进行500次迭代训练,并在每个 epoch 后打印出准确率:
// Parameters: learning rate, momentum, alpha decay, nesterov
optimizer := training.NewSGD(0.006, 0.1, 1e-6, true)
trainer := training.NewTrainer(optimizer, 1)
trainer.Train(network, trainingExamples, validationExamples, 500)
// training, validation, iterations
这个神经网络在训练集和验证集上都提供了 80%的准确率,这是一个好迹象,表明模型没有过拟合。看看你是否可以通过调整网络架构和重新训练来提高其性能。在第五章,“使用预训练模型”中,我们将通过在 Python 中构建一个更复杂的神经网络并导出到 Go 来重新审视这个例子。
回归
在掌握了分类部分中的许多关键机器学习概念之后,在本节中,我们将应用所学知识来解决回归问题。我们将使用包含加利福尼亚不同地区房屋群体信息的数据库^([4])。我们的目标将是使用如纬度/经度位置、中位数房屋大小、年龄等输入数据来预测每个群体的中位数房价。
使用download-housing.sh脚本下载数据集,然后将其加载到 Go 中:
import (
"fmt"
"github.com/kniren/gota/dataframe"
"github.com/kniren/gota/series"
"math/rand"
"image"
"bytes"
"math"
"github.com/gonum/stat"
"github.com/gonum/integrate"
"github.com/sajari/regression"
"io/ioutil"
)
const path = "../datasets/housing/CaliforniaHousing/cal_housing.data"
columns := []string{"longitude", "latitude", "housingMedianAge", "totalRooms", "totalBedrooms", "population", "households", "medianIncome", "medianHouseValue"}
b, err := ioutil.ReadFile(path)
if err != nil {
fmt.Println("Error!", err)
}
df := dataframe.ReadCSV(bytes.NewReader(b), dataframe.Names(columns...))
我们需要进行一些数据准备,在数据框中创建代表每个区域房屋平均房间数和卧室数的列,以及平均入住率。我们还将将中位数房价重新缩放为$100,000 为单位:
// Divide divides two series and returns a series with the given name. The series must have the same length.
func Divide(s1 series.Series, s2 series.Series, name string) series.Series {
if s1.Len() != s2.Len() {
panic("Series must have the same length!")
}
ret := make([]interface{}, s1.Len(), s1.Len())
for i := 0; i < s1.Len(); i ++ {
ret[i] = s1.Elem(i).Float()/s2.Elem(i).Float()
}
s := series.Floats(ret)
s.Name = name
return s
}
// MultiplyConst multiplies the series by a constant and returns another series with the same name.
func MultiplyConst(s series.Series, f float64) series.Series {
ret := make([]interface{}, s.Len(), s.Len())
for i := 0; i < s.Len(); i ++ {
ret[i] = s.Elem(i).Float()*f
}
ss := series.Floats(ret)
ss.Name = s.Name
return ss
}
df = df.Mutate(Divide(df.Col("totalRooms"), df.Col("households"), "averageRooms"))
df = df.Mutate(Divide(df.Col("totalBedrooms"), df.Col("households"), "averageBedrooms"))
df = df.Mutate(Divide(df.Col("population"), df.Col("households"), "averageOccupancy"))
df = df.Mutate(MultiplyConst(df.Col("medianHouseValue"), 0.00001))
df = df.Select([]string{"medianIncome", "housingMedianAge", "averageRooms", "averageBedrooms", "population", "averageOccupancy", "latitude", "longitude", "medianHouseValue" })
如我们之前所做的那样,我们需要将此数据分为训练集和验证集:
func Split(df dataframe.DataFrame, valFraction float64) (training dataframe.DataFrame, validation dataframe.DataFrame){
perm := rand.Perm(df.Nrow())
cutoff := int(valFraction*float64(len(perm)))
training = df.Subset(perm[:cutoff])
validation = df.Subset(perm[cutoff:])
return training, validation
}
training, validation := Split(df, 0.75)
// DataFrameToXYs converts a dataframe with float64 columns to a slice of independent variable columns as floats
// and the dependent variable (yCol). This can then be used with eg. goml's linear ML algorithms.
// yCol is optional - if it does not exist only the x (independent) variables will be returned.
func DataFrameToXYs(df dataframe.DataFrame, yCol string) ([][]float64, []float64){
var (
x [][]float64
y []float64
yColIx = -1
)
//find dependent variable column index
for i, col := range df.Names() {
if col == yCol {
yColIx = i
break
}
}
if yColIx == -1 {
fmt.Println("Warning - no dependent variable")
}
x = make([][]float64, df.Nrow(), df.Nrow())
y = make([]float64, df.Nrow())
for i := 0; i < df.Nrow(); i++ {
var xx []float64
for j := 0; j < df.Ncol(); j ++ {
if j == yColIx {
y[i] = df.Elem(i, j).Float()
continue
}
xx = append(xx, df.Elem(i,j).Float())
}
x[i] = xx
}
return x, y
}
trainingX, trainingY := DataFrameToXYs(training, "medianHouseValue")
validationX, validationY := DataFrameToXYs(validation, "medianHouseValue")
线性回归
与分类示例类似,我们将首先使用线性模型作为基线。不过,这次我们预测的是一个连续输出变量,因此我们需要一个不同的性能指标。回归中常用的指标是均方误差(MSE),即模型预测值与真实值之间平方差的和。通过使用平方误差,我们确保当低估和超估真实值时,值会增加。
对于回归问题,MSE(均方误差)的一个常见替代方法是平均绝对误差(MAE)。当你的输入数据包含异常值时,这可能很有用。
使用 Golang 回归库,我们可以按以下方式训练模型:
model := new(regression.Regression)
for i := range trainingX {
model.Train(regression.DataPoint(trainingY[i], trainingX[i]))
}
if err := model.Run(); err != nil {
fmt.Println(err)
}
最后,我们可以从验证集中计算出均方误差为0.51。这为我们提供了一个基准性能水平,我们可以将其作为比较其他模型的参考:
//On validation set
errors := make([]float64, len(validationX), len(validationX))
for i := range validationX {
prediction, err := model.Predict(validationX[i])
if err != nil {
panic(fmt.Println("Prediction error", err))
}
errors[i] = (prediction - validationY[i]) * (prediction - validationY[i])
}
fmt.Printf("MSE: %5.2f\n", stat.Mean(errors, nil))
随机森林回归
我们知道房价会根据位置的不同而变化,通常以我们线性模型难以捕捉的复杂方式变化。因此,我们将引入随机森林回归作为替代模型。
随机森林回归是集成模型的一个例子:它通过训练大量简单的基础模型,然后使用统计平均来输出最终预测。在随机森林中,基础模型是决策树,通过调整这些树和集成中模型的数量参数,你可以控制过拟合。
使用RF.go库,我们可以在房价数据上训练一个随机森林。首先,让我们对训练集和验证集进行一些数据准备:
func FloatsToInterfaces(f []float64) []interface{} {
iif := make([]interface{}, len(f), len(f))
for i := range f {
iif[i] = f[i]
}
return iif
}
tx, trainingY := DataFrameToXYs(training, "medianHouseValue")
vx, validationY := DataFrameToXYs(validation, "medianHouseValue")
var (
trainingX = make([][]interface{}, len(tx), len(tx))
validationX = make([][]interface{}, len(vx), len(vx))
)
for i := range tx {
trainingX[i] = FloatsToInterfaces(tx[i])
}
for i := range vx {
validationX[i] = FloatsToInterfaces(vx[i])
}
现在,我们可以拟合一个包含 25 个底层决策树的随机森林:
model := Regression.BuildForest(trainingX, trainingY, 25, len(trainingX), 1)
这在验证集上给出了一个大幅改进的 MSE 为0.29,但在训练数据上仅显示0.05的错误,表明了过拟合的迹象。
其他回归模型
你还可以尝试在这个数据集上使用许多其他回归模型。实际上,我们在前一个示例中使用的 SVM 和深度学习模型也可以用于回归问题。看看你是否能通过使用不同的模型来提高随机森林的性能。记住,这些模型中的某些将需要数据归一化,以便正确训练。
摘要
在本章中,我们涵盖了大量的内容,并介绍了许多重要的机器学习概念。解决监督学习问题的第一步是收集和预处理数据,确保数据已归一化,并将其分为训练集和验证集。我们涵盖了用于分类和回归的多种不同算法。在每个示例中,都有两个阶段:训练算法,然后进行推理;也就是说,使用训练好的模型对新输入数据进行预测。每次你在数据上尝试新的机器学习技术时,跟踪其与训练集和验证集的性能对比都是非常重要的。这有两个主要目的:它帮助你诊断欠拟合/过拟合,同时也提供了你模型工作效果的指示。
通常,选择一个足够简单但能提供良好性能的模型是最佳选择。简单模型通常运行更快,更容易实现和使用。在每个示例中,我们从一个简单的线性模型开始,然后评估更复杂的技术与这个基线。
在线有许多针对围棋的机器学习模型的不同实现。正如我们在本章中所做的,通常更快的是找到并使用现有的库,而不是从头开始完全实现算法。通常,这些库在数据准备和调整参数方面有略微不同的要求,所以请务必仔细阅读每个案例的文档。
下一章将重用我们在本章中实现的数据加载和准备技术,但将专注于无监督机器学习。
进一步阅读
-
yann.lecun.com/exdb/lenet/. 获取日期:2019 年 3 月 24 日。 -
blogs.nvidia.com/blog/2016/05/06/self-driving-cars-3/. 获取日期:2019 年 3 月 24 日。 -
github.com/zalandoresearch/fashion-mnist. 获取日期:2019 年 3 月 24 日。 -
colah.github.io/. 获取日期:2019 年 5 月 15 日。 -
karpathy.github.io/. 获取日期:2019 年 5 月 15 日。 -
www.dcc.fc.up.pt/~ltorgo/Regression/cal_housing.html. 获取日期:2019 年 3 月 24 日。
第四章:无监督学习
尽管大多数机器学习问题涉及标记数据,正如我们在上一章所看到的,还有一个重要的分支称为无监督学习。这适用于你可能没有输入数据标签的情况,因此算法不能通过尝试从每个输入预测输出标签来工作。相反,无监督算法通过尝试在输入中找到模式或结构来工作。当对具有许多不同输入变量的大型数据集进行探索性分析时,这可能是一种有用的技术。在这种情况下,绘制所有不同变量的图表以尝试发现模式将非常耗时,因此,可以使用无监督学习来自动完成这项工作。
作为人类,我们非常熟悉这个概念:我们做的许多事情从未被其他人明确地教给我们。相反,我们探索周围的世界,寻找并发现模式。因此,无监督学习对试图开发通用智能系统的研究人员特别感兴趣:能够独立学习所需知识的计算机^([1])。
在本章中,我们将介绍两种流行的无监督算法,并在 Go 语言中实现它们。首先,我们将使用聚类算法将数据集分割成不同的组,而不需要任何关于要寻找什么的指导。然后,我们将使用一种称为主成分分析的技术,通过首先在数据集中找到隐藏的结构来压缩数据集。
这只是对无监督学习能够实现的内容的表面触及。一些前沿算法能够使计算机执行通常需要人类创造力的任务。一个值得关注的例子是 NVIDIA 从草图创建逼真图片的系统([2])。你还可以在网上找到可以改变图像外观的代码示例,例如,将马变成斑马,或将橙子变成苹果([3])。
本章将涵盖以下主题:
-
聚类
-
主成分分析
聚类
聚类算法旨在将数据集分割成组。一旦训练完成,任何新数据在到达时都可以分配到相应的组中。假设你正在处理一个电子商务商店客户信息的数据集。你可能使用聚类来识别客户群体,例如,商业/私人客户。然后,可以使用这些信息来做出关于如何最好地服务这些客户类型的决策。
你也可以在应用监督学习之前使用聚类作为预处理步骤。例如,图像数据集可能需要手动标记,这通常既耗时又昂贵。如果你可以使用聚类算法将数据集分割成组,那么你可能可以通过只标记部分图像来节省时间,并假设每个簇包含具有相同标签的图像。
聚类技术也被应用于自动驾驶汽车的计算机视觉应用中,它可以用来帮助车辆在未知路段上导航。通过聚类车辆摄像头的图像数据,可以识别出每个输入图像中包含车辆必须行驶的道路的区域^([4])。
对于我们的示例,我们将使用一个包含不同类型鸢尾花测量数据的数据集,你可以使用代码仓库中的./download-iris.sh脚本来下载这个数据集。这个数据集通常用于演示监督学习:你可以使用机器学习来根据鸢尾花种类的特征对数据进行分类。然而,在这种情况下,我们不会向聚类算法提供标签,这意味着它必须纯粹从测量数据中识别聚类:
- 首先,像之前示例中那样将数据加载到 Go 中:
import (
"fmt"
"github.com/kniren/gota/dataframe"
"github.com/kniren/gota/series"
"io/ioutil"
"bytes"
"math/rand"
)
const path = "../datasets/iris/iris.csv"
b, err := ioutil.ReadFile(path)
if err != nil {
fmt.Println("Error!", err)
}
df := dataframe.ReadCSV(bytes.NewReader(b))
df.SetNames("petal length", "petal width", "sepal length", "sepal width", "species")
- 接下来,我们需要通过从数据中分割物种列来准备数据:这只是为了在聚类后对组进行最终评估。为此,使用之前示例中的
DataFrameToXYs函数:
features, classification := DataFrameToXYs(df, "species")
- 现在,我们可以训练一个名为k-means的算法来尝试将数据集分为三个聚类。k-means 通过最初随机选择每个聚类的中间点(称为质心),并将训练集中的每个数据点分配到最近的质心来工作。然后它迭代地更新每个聚类的位置,在每一步重新分配数据点,直到达到收敛。
k-means 是一个简单的算法,训练速度快,因此在聚类数据时是一个好的起点。然而,它确实需要你指定要找到多少个聚类,这并不总是显而易见的。其他聚类算法,如 DBSCAN,没有这个限制。
使用 goml 中的 k-means 实现,我们可以在数据中尝试找到三个聚类。通常,你可能需要通过试错来找出要使用多少个聚类——K。如果你在运行 k-means 后有很多非常小的聚类,那么你可能需要减少 K:
import (
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/plotutil"
"gonum.org/v1/plot/vg"
"github.com/cdipaolo/goml/cluster"
"github.com/cdipaolo/goml/base"
"bufio"
"strconv"
)
model := cluster.NewKMeans(3, 30, features)
if err := model.Learn(); err != nil {
panic(err)
}
一旦我们将模型拟合到数据上,我们就可以从中生成预测;也就是说,找出每个数据点属于哪个聚类:
func PredictionsToScatterData(features [][]float64, model base.Model, featureForXAxis, featureForYAxis int) (map[int]plotter.XYs) {
ret := make(map[int]plotter.XYs)
if features == nil {
panic("No features to plot")
}
for i := range features {
var pt struct{X, Y float64}
pt.X = features[i][featureForXAxis]
pt.Y = features[i][featureForYAxis]
p, _ := model.Predict(features[i])
ret[int(p[0])] = append(ret[int(p[0])], pt)
}
return ret
}
scatterData := PredictionsToScatterData(features, model, 2, 3)
现在,我们可以使用以下代码绘制聚类图:
func PredictionsToScatterData(features [][]float64, model base.Model, featureForXAxis, featureForYAxis int) (map[int]plotter.XYs) {
ret := make(map[int]plotter.XYs)
if features == nil {
panic("No features to plot")
}
for i := range features {
var pt struct{X, Y float64}
pt.X = features[i][featureForXAxis]
pt.Y = features[i][featureForYAxis]
p, _ := model.Predict(features[i])
ret[int(p[0])] = append(ret[int(p[0])], pt)
}
return ret
}
scatterData := PredictionsToScatterData(features, model, 2, 3)
这所做的就是使用输入特征中的两个,花瓣宽度和花瓣长度,来显示数据,如下面的图所示:
每个点的形状是根据鸢尾花种类设置的,而颜色是由 k-means 的输出设置的,即算法将每个数据点分配到哪个聚类。我们现在可以看到,聚类几乎完全匹配每个鸢尾花的种类:k-means 已经能够将数据细分为三个对应不同种类的不同组。
虽然 k-means 在这个案例中效果很好,但你可能会发现需要在你的数据集上使用不同的算法。Python 的 scikit-learn 库提供了一个有用的演示,说明了哪些算法在不同类型的数据集上效果最佳^([5])。你也可能会发现,以某种方式准备你的数据是有帮助的;例如,对其进行归一化或对其应用非线性变换。
主成分分析
主成分分析 (PCA) 是一种降低数据集维度的方法。我们可以将其视为压缩数据集的一种方式。假设你的数据集中有 100 个不同的变量。可能的情况是,这些变量中的许多是相互关联的。如果是这样,那么通过组合变量来构建一个较小的数据集,就有可能解释数据中的大部分变化。PCA 执行这项任务:它试图找到输入变量的线性组合,并报告每个组合解释了多少变化。
PCA 是一种降低数据集维度的方法:实际上,通过总结它,你可以专注于最重要的特征,这些特征解释了数据集中大部分的变化。
PCA 在机器学习中有两种用途:
-
在应用监督学习方法之前,它可能是一个有用的预处理步骤。在运行 PCA 后,你可能会发现,例如,95% 的变化仅由少数几个变量解释。你可以使用这些知识来减少输入数据中的变量数量,这意味着你的后续模型将训练得更快。
-
在构建模型之前可视化数据集时,它也可能很有帮助。如果你的数据有超过三个变量,在图表上可视化它并理解它包含的图案可能非常困难。PCA 允许你转换数据,以便你只需绘制数据的最重要方面。
对于我们的示例,我们将使用 PCA 来可视化鸢尾花数据集。目前,这个数据集有四个输入特征:花瓣宽度、花瓣长度、萼片宽度和萼片长度。使用 PCA,我们可以将其减少到两个变量,然后我们可以轻松地在散点图上可视化它们。
- 首先像之前一样加载花瓣数据,并按以下方式对其进行归一化:
df = Standardise(df, "petal length")
df = Standardise(df, "petal width")
df = Standardise(df, "sepal length")
df = Standardise(df, "sepal width")
labels := df.Col("species").Float()
df = DropColumn(df, "species")
- 接下来,我们需要将数据转换为矩阵格式。
gonum库有一个mat64类型,我们可以用它来完成这个任务:
import (
"github.com/gonum/matrix/mat64"
)
// DataFrameToMatrix converts the given dataframe to a gonum matrix
func DataFrameToMatrix(df dataframe.DataFrame) mat64.Matrix {
var x []float64 //slice to hold matrix entries in row-major order
for i := 0; i < df.Nrow(); i++ {
for j := 0; j < df.Ncol(); j ++ {
x = append(x, df.Elem(i,j).Float())
}
}
return mat64.NewDense(df.Nrow(), df.Ncol(), x)
}
features := DataFrameToMatrix(df)
PCA 通过寻找数据集的 特征向量 和 特征值 来工作。因此,大多数软件库需要数据以矩阵结构存在,以便可以使用标准线性代数例程,如 blas 和 lapack 来进行计算。
- 现在,我们可以利用 gonum 的
stat包来实现 PCA:
model := stat.PC{}
if ok := model.PrincipalComponents(features, nil); !ok {
fmt.Println("Error!")
}
variances := model.Vars(nil)
components := model.Vectors(nil)
这给我们提供了两个变量:components,这是一个矩阵,告诉我们如何将原始变量映射到新成分;以及variances,它告诉我们每个成分解释了多少方差。如果我们打印出每个成分的方差,我们可以看到前两个成分解释了整个数据集的 96%(成分 1 解释了 73%,成分 2 解释了 23%):
total_variance := 0.0
for i := range variances {
total_variance += variances[i]
}
for i := range variances {
fmt.Printf("Component %d: %5.3f\n", i+1, variances[i]/total_variance)
}
- 最后,我们可以将数据转换成新的成分,并保留前两个,以便我们可以用于可视化:
transform := mat64.NewDense(df.Nrow(), 4, nil)
transform.Mul(features, components)
func PCAToScatterData(m mat64.Matrix, labels []float64) map[int]plotter.XYs {
ret := make(map[int]plotter.XYs)
nrows, _ := m.Dims()
for i := 0; i < nrows; i++ {
var pt struct{X, Y float64}
pt.X = m.At(i, 0)
pt.Y = m.At(i, 1)
ret[int(labels[i])] = append(ret[int(labels[i])], pt)
}
return ret
}
scatterData := PCAToScatterData(transform, labels)
以下图表显示了每个数据点根据前两个主成分,而颜色表示每个数据点属于哪种鸢尾花物种。现在我们可以看到,三个组沿着第一个成分形成了明显的带状区域,这在将四个原始输入特征相互绘制时我们不容易看到:
你现在可以尝试训练一个监督学习模型,使用前两个 PCA 特征来预测鸢尾花物种:将其性能与在所有四个输入特征上训练的模型进行比较。
摘要
在本章中,我们介绍了无监督机器学习中的两种常见技术。这两种技术通常被数据科学家用于探索性分析,但也可以作为生产系统中数据处理管道的一部分。你学习了如何训练聚类算法自动将数据分组。这项技术可能被用于对电子商务网站上新注册的客户进行分类,以便他们能够获得个性化的信息。我们还介绍了主成分分析作为压缩数据的方法,换句话说,降低其维度。这可以用作在运行监督学习技术之前的数据预处理步骤,以减少数据集的大小。
在这两种情况下,都可以利用gonum和goml库在 Go 语言中以最少的代码构建高效的实现。
进一步阅读
-
deepmind.com/blog/unsupervised-learning/. 2019 年 4 月 12 日检索。 -
blogs.nvidia.com/blog/2019/03/18/gaugan-photorealistic-landscapes-nvidia-research/. 2019 年 4 月 12 日检索。 -
github.com/junyanz/CycleGAN. 2019 年 4 月 12 日检索。 -
robots.stanford.edu/papers/dahlkamp.adaptvision06.pdf. 2019 年 4 月 13 日检索。 -
scikit-learn.org/stable/modules/clustering.html#overview-of-clustering-methods. 2019 年 4 月 12 日检索。

被折叠的 条评论
为什么被折叠?



