Go-深度学习实用指南-全-
Go 深度学习实用指南(全)
原文:
zh.annas-archive.org/md5/cea3750df3b2566d662a1ec564d1211d
译者:飞龙
序言
Go 是由 Google 设计的开源编程语言,旨在高效处理大型项目。它使得构建可靠、简单和高效的软件变得简单直接。
本书立即进入了在 Go 语言中实现深度神经网络(DNNs)的实用性方面。简单来说,书名已包含其目的。这意味着书中将涉及大量的技术细节、大量代码以及(不算太多的)数学。当你最终合上书本或关闭 Kindle 时,你将知道如何(以及为什么)实现现代可扩展的 DNNs,并能够根据自己在任何行业或疯狂科学项目中的需求重新利用它们。
本书适合谁
本书适合数据科学家、机器学习工程师和深度学习爱好者,他们希望将深度学习引入其 Go 应用程序中。预计读者熟悉机器学习和基本的 Golang 代码,以便从本书中获益最大化。
本书内容涵盖了什么
第一章,在 Go 中深度学习简介,介绍了深度学习的历史和应用。本章还概述了使用 Go 进行机器学习的情况。
第二章,什么是神经网络及如何训练?,介绍了如何构建简单的神经网络,以及如何检查图形,还涵盖了许多常用的激活函数。本章还讨论了用于神经网络的梯度下降算法的不同选项和优化。
第三章,超越基础神经网络 – 自编码器和 RBM,展示了如何构建简单的多层神经网络和一个自编码器。本章还探讨了一个概率图模型,即用于无监督学习创建电影推荐引擎的 RBM 的设计和实现。
第四章,CUDA – GPU 加速训练,探讨了深度学习的硬件方面,以及 CPU 和 GPU 如何满足我们的计算需求。
第五章,基于递归神经网络的下一个词预测,深入探讨了基本 RNN 的含义及其训练方法。您还将清楚地了解 RNN 架构,包括 GRU/LSTM 网络。
第六章,卷积神经网络进行对象识别,向您展示如何构建 CNN 以及如何调整一些超参数(如 epoch 数量和批处理大小)以获得所需的结果,并在不同计算机上顺利运行。
第七章,使用深度 Q 网络解决迷宫,介绍了强化学习和 Q-learning,以及如何构建 DQN 来解决迷宫问题。
第八章,使用变分自编码器生成模型,展示了如何构建 VAE,并探讨了 VAE 相对于标准自编码器的优势。本章还展示了如何理解在网络上变化潜在空间维度的影响。
第九章,构建深度学习管道,讨论了数据管道的定义及为何使用 Pachyderm 来构建或管理它们。
第十章,扩展部署,涉及到 Pachyderm 底层的多种技术,包括 Docker 和 Kubernetes,还探讨了如何利用这些工具将堆栈部署到云基础设施。
为了更好地使用本书
本书主要使用 Go 语言,Go 的 Gorgonia 包,Go 的 Cu 包,以及 NVIDIA 提供的支持 CUDA 的 CUDA(加驱动程序)和支持 CUDA 的 NVIDIA GPU。此外,还需要 Docker 用于第三部分,管道、部署及其他!
下载示例代码文件
您可以从您的帐户在 www.packt.com 下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packt.com/support 并注册,以便将文件直接发送到您的邮箱。
按照以下步骤下载代码文件:
-
登录或注册 www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
下载完成后,请确保使用最新版本的解压软件解压缩文件夹:
-
Windows 下的 WinRAR/7-Zip
-
Mac 下的 Zipeg/iZip/UnRarX
-
Linux 下的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上:github.com/PacktPublishing/Hands-On-Deep-Learning-with-Go
。如果代码有更新,将在现有的 GitHub 仓库中更新。
我们还有其他来自丰富图书和视频目录的代码包,可以在 github.com/PacktPublishing/Hands-On-Deep-Learning-with-Go
查看!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图示的彩色图像。你可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789340990_ColorImages.pdf
。
使用的约定
本书中使用了多种文本约定。
CodeInText
:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个示例:"将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。"
代码块设置如下:
type nn struct {
g *ExprGraph
w0, w1 *Node
pred *Node
}
当我们希望引起您对代码块的特定部分的注意时,相关的行或项将以粗体显示:
intercept Ctrl+C
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
doneChan := make(chan bool, 1)
任何命令行输入或输出均显示如下:
sudo apt install nvidia-390 nvidia-cuda-toolkit libcupti-dev
粗体:指示一个新术语、一个重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词在文本中显示为这样。以下是一个示例:"从管理面板中选择系统信息。"
警告或重要提示显示如此。
提示和技巧显示如此。
联系我们
我们始终欢迎读者的反馈。
总体反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送邮件至customercare@packtpub.com
。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,请向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表格链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法复制,请向我们提供位置地址或网站名称,我们将不胜感激。请联系我们,链接为copyright@packt.com
。
如果您有兴趣成为作者:如果您在某个专题上有专业知识并且有意撰写或贡献一本书籍,请访问authors.packtpub.com。
评论
请留下您的评论。一旦您阅读并使用了本书,请在购买它的网站上留下评论。潜在的读者可以看到并使用您的客观意见来做出购买决策,我们在 Packt 能够了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!
欲了解更多有关 Packt 的信息,请访问packt.com。
第一部分:Go 语言中的深度学习、神经网络及其训练方法
本节介绍了深度学习(DL)及其在 Go 语言中设计、实现和训练深度神经网络(DNNs)所需的库。我们还涵盖了用于无监督学习的自编码器的实现,以及用于 Netflix 风格协同过滤系统的受限玻尔兹曼机(RBM)的实现。
本节包含以下章节:
-
第一章,Go 语言深度学习介绍
-
第二章,什么是神经网络,如何训练?
-
第三章,超越基本神经网络 - 自编码器和受限玻尔兹曼机
-
第四章,CUDA - GPU 加速训练
第一章:在 Go 中深度学习介绍
本书将非常快速地进入在 Go 中实现Deep Neural Networks(DNNs)的实际操作方面。简单地说,本书的标题包含了它的目标。这意味着会有大量的技术细节、大量的代码以及(不是太多的)数学。当你最终关闭本书或关闭你的 Kindle 时,你将知道如何(以及为什么)实现现代可扩展的 DNN,并能够根据你所从事的任何行业或疯狂的科学项目的需求重新利用它们。
我们选择 Go 语言反映了我们的 DNNs 执行的操作类型所建立的 Go 库景观的成熟性。当然,在选择语言或库时进行的权衡存在很多争论,我们将在本章的一个部分专门讨论我们的观点,并为我们所做的选择辩护。
但是,没有上下文的代码意味着什么?为什么我们要关心这种看似混乱的线性代数、微积分、统计学和概率论的混合?为什么要使用计算机在图像中识别事物或在金融数据中识别异常模式?而且,也许最重要的是,这些任务的方法有什么共同之处?本书的初步部分将尝试提供一些这方面的背景信息。
科学探索,当被分解为代表其机构和行业专业化的学科时,是由进步思想所统治的。通过这一点,我们指的是一种动力,一种向前推进,朝着某种目标前进。例如,医学的理想目标是能够识别和治愈任何疾病或疾病。物理学家的目标是完全理解自然界的基本法则。进步趋势是朝着这个方向的。科学本身就是一种优化方法。那么Machine Learning(ML)的最终目标可能是什么呢?
让我们直接了当地说吧。我们认为这是创造人工通用智能(AGI)的过程。这就是我们的目标:一个通用学习计算机,可以处理工作,让人们留下生活。正如我们在详细讨论Deep Learning(DL)的历史时将看到的那样,顶尖人工智能实验室的创始人们一致认为 AGI 代表着今天世界上许多复杂问题的元解决方案,从经济学到医学再到政府。
本章将涵盖以下主题:
-
为什么选择 DL?
-
DL——历史及应用
-
在 Go 中 ML 的概述
-
使用 Gorgonia
介绍 DL
现在我们将提供深度学习(DL)为何重要及其如何融入人工智能(AI)讨论的高层视角。然后,我们将看一看 DL 的历史发展,以及当前和未来的应用。
为什么选择 DL?
那么,亲爱的读者,你是谁?你为何对 DL 感兴趣?你是否对 AI 有自己的私人愿景?还是有更为谦虚的目标?你的起源故事是什么?
在我们对同事、老师和见面会熟人的调查中,有更为正式对机器感兴趣的人的起源故事有一些共同特征。无论你是在电脑上与看不见的敌人玩游戏,有时会出现故障,还是在上世纪 90 年代后期追踪id Software's Quake中的实际机器人;对软硬件结合思考和独立行动的概念在我们每个人的生活中都有影响。
随着时间的流逝,随着年龄、教育和对流行文化的接触,你的想法逐渐精炼,也许你最终成为了研究员、工程师、黑客或者业余爱好者,现在你想知道如何参与启动这个宏大的机器。
如果你的兴趣更为温和,比如你是一名数据科学家,想要了解前沿技术,但对这些关于有意识的软件和科幻的讨论并不感冒,那么在 2019 年,你在很多方面都比大多数人更为准备充足。不管我们的抱负大小如何,每个人都必须通过试验和错误理解代码的逻辑和辛勤工作。幸运的是,我们有非常快的显卡。
这些基本真理的结果是什么呢?现在,在 2019 年,DL 已经以多种方式影响了我们的生活。一些棘手的问题正在解决。有些微不足道,有些则不然。是的,Netflix 有你最尴尬的电影喜好模型,但 Facebook 为视觉障碍者提供了自动图像标注。理解 DL 的潜力就像看到有人第一次看到爱人照片时脸上的喜悦表情一样简单。
深度学习(DL)- 一个历史
现在我们将简要介绍 DL 的历史以及它出现的历史背景,包括以下内容:
-
AI的概念
-
计算机科学/信息论的起源
-
当前有关 DL 系统状态/未来的学术工作
虽然我们特别关注 DL,但这个领域并非从无到有。它是机器学习中的一组模型/算法,是计算机科学的一个分支。它构成了 AI 的一种方法。另一种所谓的符号 AI则围绕着手工制作(而不是学习得来的)特征和编码中的规则,而不是从数据中算法提取模式的加权模型。
在成为一门科学之前,思考机器的想法在古代就是虚构。希腊的冶金之神赫菲斯托斯用金银制造了自动机器人。它们为他服务,是人类想象力自然地考虑如何复制自身体现形式的早期例子。
将历史推进几千年,20 世纪信息理论和计算机科学的几位关键人物建立了 AI 作为一个独特领域的平台,包括我们将要涵盖的 DL 最近的工作。
第一个重要人物,Claude Shannon,为我们提供了通信的一般理论。具体地,在他的里程碑论文A Mathematical Theory of Computation中,他描述了如何在使用不完善介质(例如使用真空管进行计算)时避免信息丢失。特别是他的噪声信道编码定理,对于可靠地处理任意大量数据和算法而言至关重要,而不引入介质本身的错误到通信通道中。
1936 年,Alan Turing 描述了他的Turing machine,为我们提供了一个通用的计算模型。他所描述的基本构建块定义了机器可能计算的极限。他受到 John Von Neumann 关于stored-program的想法的影响。Turing 工作的关键洞见在于数字计算机可以模拟任何形式推理过程(Church-Turing假设)。下图显示了图灵机的工作过程:
所以,你是想告诉我们,图灵先生,计算机也可以像我们一样推理?!
John Von Neumann 本人受到了图灵 1936 年论文的影响。在晶体管开发之前,当真空管是唯一的计算手段时(如 ENIAC 及其衍生系统),John Von Neumann 发表了他的最终作品。在他去世时仍未完成,题为The Computer and the Brain。尽管未完成,但它初步考虑了计算模型如何在大脑中运作,正如它们在机器中一样,包括早期神经科学对神经元和突触之间连接的观察。
自从 1956 年 AI 首次被定义为一个独立的研究领域,而 ML 的术语则是在 1959 年被创造出来,这个领域经历了一个被广泛讨论的起伏过程——既有兴奋和充裕的资金的时期,也有私营部门资金不存在且研究会议甚至不接受强调神经网络方法用于构建 AI 系统的论文的时期。
在 AI 领域内部,这些竞争方法消耗了大量的研究经费和人才。符号 AI 在手工制定规则以完成像图像分类、语音识别和机器翻译等高级任务的不可能性方面达到了其限制。ML 试图根本性地重新配置这一过程。与其将一堆人类编写的规则应用于数据并希望得到答案,人类劳动力反而被用于构建一台可以在已知答案时从数据中推断规则的机器。这是监督学习的一个例子,其中机器在处理数千个带有关联cat标签的示例图像后学习了本质cat-ness。
简单来说,这个想法是要建立一个能够推广的系统。毕竟,我们的目标是通用人工智能(AGI)。拍下家庭最新的毛茸茸的猫的照片,计算机利用其对猫本质的理解正确识别出一只猫!在机器学习中,一个被认为对构建通用人工智能至关重要的研究领域是迁移学习,其中我们可以将理解猫本质的机器转移到在识别猫本质时进行操作的机器上。这是世界各地许多人工智能实验室采取的方法:将系统建立在系统之上,利用在一个领域中的算法弱点与另一个领域中的统计学几乎确定性的增强,并希望构建一个更好地为人类(或企业)需求服务的系统。
为人类需求服务 的概念引向了关于人工智能伦理(以及我们将要探讨的深度学习方法)的一个重要观点。媒体和学术或行业圈子已经就这些系统的伦理影响进行了大量讨论。如果由于计算机视觉的进步,我们可以轻松实现自动化广泛的监视,这对我们社会意味着什么?自动武器系统或制造业又如何?想象一下庞大的仓库不再有任何人类员工,这已不再是一种遥远的设想。那么,曾经从事这些工作的人们将何去何从?
当然,全面考虑这些重要问题超出了本书的范围,但这正是我们工作的背景。你将成为为数不多能够构建这些系统并推动该领域发展的幸运之一。牛津大学未来人类研究所(由尼克·博斯特罗姆领导)和麻省理工学院未来生命研究所(由麦克斯·泰格马克领导)的工作是学术界围绕人工智能伦理问题进行辩论的两个例子。然而,这场辩论并不仅限于学术或非营利圈子;DeepMind,一个旗下的阿尔法母公司旨在成为“AGI 的阿波罗计划”,于 2017 年 10 月推出了“DeepMind 伦理与社会”。
这些可能看似与代码、CUDA 和神经网络识别猫图片的世界毫无关系,但随着技术的进步和这些系统的日益先进及广泛应用,我们的社会将面临真实的后果。作为研究人员和开发者,我们有责任找到一些答案,或者至少有应对这些挑战的想法。
DL – 炒作还是突破?
深度学习及其相关的炒作是近来的一个发展。大多数关于其出现的讨论集中在 2012 年的 ImageNet 基准测试上,深度卷积神经网络将错误率比上一年提高了 9%,这是一个显著的改进,而以往的优胜者最多只能通过使用模型中手工制作的特征来进行增量改进。以下图表展示了这一改进:
尽管最近的炒作,使 DL 正常运转的组成部分——使我们能够训练深度模型的组成部分,在图像分类和各种其他任务中已被证明非常有效。这些是由 Geoffrey Hinton 及其在多伦多大学的团队于 1980 年代开发的。他们的早期工作发生在本章早些时候讨论的流动时期之一。事实上,他们完全依赖于来自加拿大高级研究所(CIFAR)的资助。
随着 21 世纪正式开始,2000 年 3 月爆发的科技泡沫再度膨胀,高性能 GPU 的可用性以及计算能力的普遍增长意味着这些技术,尽管几十年前已经开发,但由于缺乏资金和行业兴趣而未被使用,突然变得可行。以往在图像识别、语音识别、自然语言处理和序列建模中只能看到渐进改进的基准都调整了它们的y-轴。
不仅仅是硬件的巨大进步与旧算法的结合使我们达到了这一点。还有算法上的进步,允许我们训练特别深的网络。其中最著名的是批归一化,于 2015 年引入。它确保各层之间的数值稳定,并可以防止梯度爆炸,显著减少训练时间。关于批归一化为何如此有效仍存在活跃的讨论。例如,2018 年 5 月发表的一篇论文驳斥了原始论文的核心前提,即并非内部协变移位被减少,而是使优化景观更加平滑,即梯度可以更可靠地传播,学习率对训练时间和稳定性的影响更加可预测。
从古希腊神话的民间科学到信息理论、神经科学和计算机科学的实际突破,特别是计算模型,这些集合产生了网络架构和用于训练它们的算法,这些算法能够很好地扩展到解决 2018 年多个基础 AI 任务,这些任务几十年来一直难以解决。
定义深度学习
现在,让我们退后一步,从一个简单且可操作的深度学习(DL)定义开始。当我们逐步阅读本书时,对这个术语的理解会逐渐加深,但现在,让我们考虑一个简单的例子。我们有一个人的图像。如何向计算机展示这个图像?如何教计算机将这个图像与人这个词关联起来?
首先,我们找出了这个图像的一个表示,比如图像中每个像素的 RGB 值。然后,我们将这个数值数组(连同几个可训练参数)输入到一系列我们非常熟悉的操作中(乘法和加法)。这样就产生了一个新的表示,我们可以用它来与我们知道的映射到标签“人”的表示进行比较。我们自动化这个比较过程,并随着进行更新我们参数的值。
这个描述涵盖了一个简单的、浅层的机器学习系统。我们将在后面专门讨论神经网络的章节中进一步详细介绍,但是现在,为了使这个系统变得更深入,我们增加了对更多参数的操作。这使我们能够捕获更多关于我们所代表的事物(人的形象)的信息。影响这个系统设计的生物模型是人类神经系统,包括神经元(我们用我们的表现填充的东西)和突触(可训练的参数)。
下图显示了正在进行中的机器学习系统:
因此,深度学习只是对 1957 年的感知器的一种进化性变化,这是最简单和最原始的二元分类器。这种变化,再加上计算能力的显著增强,是使得一个系统不能工作和使得一辆车能够自主驾驶的差异。
除了自动驾驶汽车,深度学习和相关方法在农业、作物管理和卫星图像分析中也有许多应用。先进的计算机视觉技术驱动能够除草和减少农药使用的机器。我们拥有几乎实时的快速准确语音搜索。这些是社会的基础,从食品生产到通信。此外,我们还处于引人注目的实时视频和音频生成的前沿,这将使当今关于“假新闻”等隐私争论或戏剧显得微不足道。
在我们达到通用人工智能之前,我们可以利用我们沿途发现的发现来改善我们周围的世界。深度学习就是这些发现之一。它将推动自动化的增加,只要伴随其而来的政治变革是支持的,就能在任何行业中提供改进,意味着商品和服务将变得更便宜、更快速和更广泛地可用。理想情况下,这意味着人们将越来越多地从他们祖先的日常中获得自由。
进步的阴暗面也不容忽视。可以识别受害者的机器视觉也可以识别目标。事实上,未来生命研究所关于自主武器的公开信(自主武器:AI 和机器人研究人员的公开信),由史蒂芬·霍金和埃隆·马斯克等科学技术名人支持,是学术部门、工业实验室和政府之间关于进步的正确方式的相互作用和紧张关系的一个例子。在我们的世界中,传统上国家控制着枪支和金钱。先进的 AI 可以被武器化,这是一场也许一个组织赢,其余人输的竞赛。
更具体地说,机器学习领域的发展速度非常快。我们如何衡量这一点?首屈一指的机器学习会议 Neural Information Processing Systems(NIPS)在 2017 年的注册人数比 2010 年增加了七倍多。
2018 年的注册活动更像是一场摇滚音乐会,而不是干燥的技术会议,这反映在组织者自己发布的以下统计数据中:
de facto 机器学习预印本的中心库 arXiv,其增长曲线呈现出极端的“曲棍球”形态,新工具的出现帮助研究人员追踪所有新工作。例如特斯拉的 AI 主管 Andrej Karpathy 的网站 arxiv-sanity(www.arxiv-sanity.com/
)。该网站允许我们对论文进行排序/分组,并组织一个接口,从中轻松地提取我们感兴趣的研究成果。
我们无法预测未来五年进展速度的变化。风险投资家和专家的专业猜测从指数增长到下一个 AI 冬季即将来临不等。但是,我们现在拥有技术、库和计算能力,知道如何在自然语言处理或计算机视觉任务中充分利用它们,可以帮助解决真实世界的问题。
这就是我们的书的目的所在,展示如何实现这一点。
Go 中的机器学习概述
本节将审视 Go 中的机器学习生态系统,首先讨论我们从一个库中期望的基本功能,然后依次评估每个主要的 Go 机器学习库。
Go 的 ML 生态系统在历史上一直相当有限。该语言于 2009 年推出,远早于深度学习革命,该革命吸引了许多新程序员加入。你可能会认为 Go 在库和工具的增长方面与其他语言一样。然而,历史决定了我们的网络基础数学操作的许多高级 API 出现为 Python 库(或具有完整的 Python 绑定)。有许多著名的例子,包括 PyTorch、Keras、TensorFlow、Theano 和 Caffe(你明白的)。
不幸的是,这些库要么没有 Go 的绑定,要么绑定不完整。例如,TensorFlow 可以进行推断(这是一只猫吗?),但无法进行训练(到底什么是猫?好的,我会看这些例子并找出来)。虽然这利用了 Go 在部署时的优势(编译成单个二进制文件、编译速度快且内存占用低),但从开发者的角度来看,你将被迫在两种语言之间工作(用 Python 训练模型,用 Go 运行模型)。
除了在设计、实施或故障排除时语法转换的认知冲击之外,您可能会遇到的问题还涉及环境和配置问题。这些问题包括:我的 Go 环境配置正确吗?我的 Python 2 二进制链接到 Python 还是 Python 3?TensorFlow GPU 正常工作吗?如果我们的兴趣在于设计最佳模型并在最短时间内进行训练和部署,那么 Python 或 Go 绑定库都不合适。
此时很重要的一点是问:那么,我们希望从 Go 中的DL 库中得到什么?简单来说,我们希望尽可能地摆脱不必要的复杂性,同时保留对模型及其训练方式的灵活性和控制。
在实践中这意味着什么?以下清单概述了这个问题的答案:
-
我们不想直接与基本线性代数子程序(BLAS)接口,以构建乘法和加法等基本操作。
-
我们不想在每次实现新网络时都定义张量类型和相关函数。
-
我们不想每次训练网络时都从头开始实现随机梯度下降(SGD)。
本书将涵盖以下一些内容:
-
自动或符号微分:我们的深度神经网络试图学习某个函数。它通过计算梯度(梯度下降优化)来迭代地解决“如何设计一个函数,使其能够接受输入图像并输出标签猫”的问题,同时考虑到损失函数(我们的函数有多错误?)。这使我们能够理解是否需要改变网络中的权重以及改变的幅度,微分的具体方式是通过链式法则(将梯度计算分解),这给我们提供了训练数百万参数深度网络所需的性能。
-
数值稳定化函数:这对 DL 至关重要,正如本书后面章节中将会探讨的那样。一个主要的例子就是批归一化或者 BatchNorm,常被称为附带函数。它的目标是将我们的数据放在同一尺度上,以增加训练速度,并减少最大值通过层级导致的梯度爆炸的可能性(这是我们将在第二章,《什么是神经网络以及如何训练一个?》中详细讨论的内容)。
-
激活函数:这些是引入非线性到我们神经网络各层的数学操作,帮助确定哪些层中的神经元将被激活,将它们的值传递到网络中的下一层。例如 Sigmoid,修正线性单元(ReLU)和 Softmax。这些将在第二章,《什么是神经网络以及如何训练一个?》中更详细地讨论。
-
梯度下降优化:我们还将在第二章,《什么是神经网络以及如何训练一个?》中广泛讨论这些。但是作为 DNN 中主要的优化方法,我们认为这是任何以 DL 为目的的库必须具备的核心功能。
-
CUDA 支持:Nvidia 的驱动程序允许我们将 DNN 中涉及的基本操作卸载到 GPU 上。GPU 非常适合并行处理涉及矩阵变换的工作负载(事实上,这是它们最初的用途:计算游戏的世界几何),可以将训练模型的时间减少一个数量级甚至更多。可以说,CUDA 支持对现代 DNN 实现至关重要,因此在前述主要 Python 库中是可用的。
-
部署工具:正如我们将在第九章,《构建深度学习流水线》中详细讨论的那样,模型的部署用于训练或推理经常被忽视。随着神经网络架构变得更加复杂,并且可用的数据量变得更大,例如在 AWS GPU 上训练网络,或将训练好的模型部署到其他系统(例如集成到新闻网站的推荐系统)是一个关键步骤。这将改进你的训练时间并扩展可以使用的计算量。这意味着也可以尝试更复杂的模型。理想情况下,我们希望一个库能够轻松地与现有工具集成或具备自己的工具。
现在我们已经为我们理想的库设定了一个合理的需求集,让我们来看看社区中的一些流行选项。以下列表并非详尽无遗,但它涵盖了 GitHub 上大多数主要的与 ML 相关的 Go 项目,从最狭窄到最通用。
ML 库
我们现在将考虑每个主要的 ML 库,根据我们之前定义的标准来评估它们的效用,包括任何负面方面或缺陷。
Go 中的词嵌入
Go 中的词嵌入 是一个特定任务的 ML 库示例。它实现了生成词嵌入所需的两层神经网络,使用Word2vec
和GloVe
。它是一个出色的实现,快速而干净。它非常好地实现了有限数量的特性,特定于通过Word2vec
和GloVe
生成词嵌入的任务。
这是一个用于训练 DNNs 的核心功能示例,称为 SGD 的优化方法。这在由斯坦福团队开发的GloVe
模型中使用。然而,代码特定集成于GloVe
模型中,而在 DNNs 中使用的其他优化方法(如负采样和 skip-gram)对其无用。
对于 DL 任务来说,这可能是有用的,例如,用于生成文本语料库的嵌入式层或密集向量表示,这可以在长短期记忆(LSTM)网络中使用,我们将在第五章中讨论,使用递归神经网络进行下一个单词预测。然而,我们需要的所有高级功能(例如梯度下降或反向传播)和模型特性(LSTM 单元本身)都不存在。
Go 或 Golang 的朴素贝叶斯分类和遗传算法
这两个库构成 Go 中 ML 库的另一组特定任务的示例。两者都写得很好,提供了特定功能的原语,但这些原语并不通用。在朴素贝叶斯分类器lib
中,需要手动构建矩阵,然后才能使用,而传统的通用算法方法根本不使用矩阵。已经有一些工作正在将它们整合到 GA 中;然而,这项工作尚未进入我们在此引用的 GA 库。
Go 中的 ML
一个具有更通用集合有用功能的库是 GoLearn。虽然 DL 特定的功能在其愿望清单上,但它具有实现简单神经网络、随机森林、聚类和其他 ML 方法所需的基本原语。它严重依赖于 Gonum,这是一个提供float64
和complex128
矩阵结构以及对它们进行线性代数操作的库。
让我们从代码的角度来看一下这意味着什么,如下所示:
type Network struct {
origWeights *mat.Dense
weights *mat.Dense // n * n
biases []float64 // n for each neuron
funcs []NeuralFunction // for each neuron
size int
input int
}
这里,我们有 GoLearn 对神经网络外观的主要定义。它使用 Gonum 的mat
库来定义权重,以创建密集矩阵的权重。它有偏差、函数、大小和输入,所有这些都是基本前馈网络的基本要素。(我们将在第三章,超越基本神经网络 - 自编码器和 RBM中讨论前馈网络)。
缺少的是轻松定义高级网络架构内部和跨层次连接的能力(例如 RNN 及其派生物,以及 DL 中的必要函数,如卷积操作和批量归一化)。手动编码这些将会显著增加项目开发时间,更不用说优化它们的性能所需的时间了。
另一个重要的缺失特性是对 DL 中使用的网络架构进行训练和扩展的 CUDA 支持。我们将在第四章,CUDA - GPU 加速训练中介绍 CUDA,但是如果没有这种支持,我们将局限于不使用大量数据的简单模型,也就是我们在本书的目的中感兴趣的模型类型。
用于 Golang 的机器学习库
这个库的不同之处在于它实现了自己的矩阵操作,而不依赖于 Gonum。实际上,它是一系列实现的集合,包括以下内容:
-
线性回归
-
逻辑回归
-
神经网络
-
协作过滤
-
用于异常检测系统的高斯多变量分布
就个别而言,这些都是强大的工具;事实上,线性回归通常被描述为数据科学家工具箱中最重要的工具之一,但出于我们的目的,我们真正关心的只是该库的神经网络部分。在这里,我们看到类似于 GoLearn 的限制,例如有限的激活函数以及用于层内和层间连接的工具的缺乏(例如,LSTM 单元)。
作者还有一个实现 CUDA 矩阵操作的额外库;然而,无论是这个库还是go_ml
库本身,在撰写本文时已经有四年没有更新了,因此这不是一个你可以简单导入并立即开始构建神经网络的项目。
GoBrain
另一个目前未处于积极开发状态的库是 GoBrain。你可能会问:为什么要去审查它?简而言之,它之所以引人关注,是因为除了 Gorgonia 之外,它是唯一尝试实现更高级网络架构原语的库。具体而言,它将其主要网络(一个基本的前馈神经网络)扩展为新的东西,即Elman 递归神经网络或SRN。
自 1990 年引入以来,这是第一个包括循环或连接网络隐藏层和相邻上下文单元的网络架构。这使得网络能够学习序列依赖性,例如单词的上下文或潜在的语法和人类语言的句法。对当时来说具有开创性的是,SRN 提供了这些单元可能是通过作用于语音流中的潜在结构的学习过程而产生的的愿景。
SRN 已经被更现代的递归神经网络取代,我们将在第五章详细介绍递归神经网络进行下一个单词预测。然而,在 GoBrain 中,我们有一个有趣的例子,这是一个包含了我们工作所需内容开端的库。
一组针对 Go 编程语言的数值库
除了稍后将介绍的 Gorgonia 之外,可能对 DL 有用的最全面的库是 Gonum。最简单的描述是,Gonum 试图模拟 Python 中广为人知的科学计算库,即 NumPy 和 SciPy 的许多功能。
让我们看一个构建矩阵的代码示例,我们可以用它来表示 DNN 的输入。
初始化一个矩阵,并用一些数字支持它,如下所示:
// Initialize a matrix of zeros with 3 rows and 4 columns.
d := mat.NewDense(3, 4, nil)
fmt.Printf("%v\n", mat.Formatted(d))
// Initialize a matrix with pre-allocated data. Data has row-major storage.
data := []float64{
6, 3, 5,
-1, 9, 7,
2, 3, 4,}
d2 := mat.NewDense(3, 3, data)
fmt.Printf("%v\n", mat.Formatted(d2))
对矩阵执行操作,如下所示的代码:
a := mat.NewDense(2, 3, []float64{
3, 4, 5,
1, 2, 3,
})
b := mat.NewDense(3, 3, []float64{ 1, 1, 8,
1, 2, -3,
5, 5, 7,
})
fmt.Println("tr(b) =", mat.Trace(b))
c := mat.Dense{}
c.Mul(a, b)
c.Add(c, a)
c.Mul(c, b.T())
fmt.Printf("%v\n", mat.Formatted(c))
在这里,我们可以看到 Gonum 为我们提供了我们在 DNN 中层间交换的矩阵操作所需的基本功能,即c.Mul
和c.Add
。
当我们决定扩展我们的设计野心时,这时我们遇到了 Gonum 的限制。它没有 GRU/LSTM 单元,也没有带有反向传播的 SGD。如果我们要可靠且高效地构建我们希望完全推广的 DNN,我们需要在其他地方寻找更完整的库。
使用 Gorgonia
在撰写本书时,有两个通常被认为适用于 Go 中深度学习的库,分别是 TensorFlow 和 Gorgonia。然而,虽然 TensorFlow 在 Python 中拥有全面的 API 并且广受好评,但在 Go 语言中并非如此。正如前面讨论的那样,TensorFlow 的 Go 绑定仅适用于加载已在 Python 中创建的模型,而不能从头开始创建模型。
Gorgonia 是从头开始构建的 Go 库,能够训练 ML 模型并进行推理。这是一种特别有价值的特性,特别是如果你已经有现有的 Go 应用程序或者想要构建一个 Go 应用程序。Gorgonia 允许你在现有的 Go 环境中开发、训练和维护你的 DL 模型。在本书中,我们将专门使用 Gorgonia 来构建模型。
在继续构建模型之前,让我们先了解一些 Gorgonia 的基础知识,并学习如何在其中构建简单的方程。
Gorgonia 的基础知识
Gorgonia 是一个较低级别的库,这意味着我们需要自己构建模型的方程和架构。这意味着没有一个内置的 DNN 分类器函数会像魔法一样创建一个具有多个隐藏层的完整模型,并立即准备好应用于您的数据集。
Gorgonia 通过提供大量运算符来简化多维数组的操作,从而促进 DL。我们可以利用这些层来构建模型。
Gorgonia 的另一个重要特性是性能。通过消除优化张量操作的需求,我们可以专注于构建模型和确保架构正确,而不是担心我们的模型是否高效。
由于 Gorgonia 比典型的 ML 库略低级,构建模型需要更多步骤。但这并不意味着在 Gorgonia 中构建模型是困难的。它需要以下三个基本步骤:
-
创建计算图。
-
输入数据。
-
执行图。
等等,什么是计算图?计算图是一个有向图,其中每个节点都是一个操作或一个变量。变量可以输入到操作中,操作将产生一个值。然后这个值可以输入到另一个操作中。更熟悉的术语来说,图像一个接收所有变量并产生结果的函数。
变量可以是任何东西;我们可以将其设定为单个标量值、一个向量(即数组)或一个矩阵。在 DL 中,我们通常使用一个更一般化的结构称为张量;张量可以被视为类似于n维矩阵。
以下截图显示了n维张量的可视化表示:
我们将方程表示为图形,因为这样更容易优化我们模型的性能。这是通过将每个节点放入有向图中来实现的,这样我们就知道它的依赖关系。由于我们将每个节点建模为独立的代码片段,我们知道它执行所需的只是它的依赖关系(可以是其他节点或其他变量)。此外,当我们遍历图形时,我们可以知道哪些节点彼此独立,并可以并行运行它们。
例如,考虑以下图示:
因为 A,B 和 C 是独立的,我们可以轻松并行计算它们。计算 V 需要 A 和 B 准备好。然而,W 只需要 B 准备好。这一过程一直延续到下一个级别,直到我们准备计算最终输出 Z。
简单示例 - 加法。
理解这一切如何组合在一起的最简单方法是通过构建一个简单的示例。
首先,让我们实现一个简单的图形来将两个数字加在一起 - 基本上是这样的:c = a + b:
- 首先,让我们导入一些库,最重要的是 Gorgonia,如下所示:
package main
import (
"fmt"
"log"
. "gorgonia.org/gorgonia"
)
- 然后,让我们开始我们的主函数,如下所示:
func main() {
g := NewGraph()
}
- 接下来,让我们添加我们的标量,如下所示:
a = NewScalar(g, Float64, WithName("a"))
b = NewScalar(g, Float64, WithName("b"))
- 然后,非常重要的是,让我们定义我们的操作节点,如下所示:
c, err = Add(a, b)
if err != nil {
log.Fatal(err)
}
注意,现在c实际上没有值;我们只是定义了我们计算图的一个新节点,因此在它有值之前我们需要执行它。
- 要执行它,我们需要为其创建一个虚拟机对象,如下所示:
machine := NewTapeMachine(g)
- 然后,设置
a
和b
的初始值,并继续让机器执行我们的图,如下所示:
Let(a, 1.0)
Let(b, 2.0)
if machine.RunAll() != nil {
log.Fatal(err)
}
完整的代码如下:
package main
import (
"fmt"
"log"
. "gorgonia.org/gorgonia"
)
func main() {
g := NewGraph()
var a, b, c *Node
var err error
// define the expression
a = NewScalar(g, Float64, WithName("a"))
b = NewScalar(g, Float64, WithName("b"))
c, err = Add(a, b)
if err != nil {
log.Fatal(err)
}
// create a VM to run the program on
machine := NewTapeMachine(g)
// set initial values then run
Let(a, 1.0)
Let(b, 2.0)
if machine.RunAll() != nil {
log.Fatal(err)
}
fmt.Printf("%v", c.Value())
// Output: 3.0
}
现在,我们已经在 Gorgonia 中构建并执行了我们的第一个计算图!
向量和矩阵
当然,我们不是为了能够加两个数字才来这里的;我们是为了处理张量,最终是深度学习方程,所以让我们迈出第一步,朝着更复杂的东西迈进。
这里的目标是现在创建一个计算以下简单方程的图形:
z = Wx
注意W是一个n x n矩阵,而x是大小为n的向量。在本示例中,我们将使用n = 2。
再次,我们从这里开始相同的基本主函数:
package main
import (
"fmt"
"log"
G "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
func main() {
g := NewGraph()
}
您会注意到,我们选择将 Gorgonia 包别名为G
。
然后,我们像这样创建我们的第一个张量,矩阵W
:
matB := []float64{0.9,0.7,0.4,0.2}
matT := tensor.New(tensor.WithBacking(matB), tensor.WithShape(2, 2))
mat := G.NewMatrix(g,
tensor.Float64,
G.WithName("W"),
G.WithShape(2, 2),
G.WithValue(matT),
)
您会注意到,这一次我们做了一些不同的事情,如下所列:
-
我们首先声明一个带有我们想要的矩阵值的数组
-
然后,我们从该矩阵创建一个张量,形状为 2 x 2,因为我们希望是一个 2 x 2 的矩阵
-
在这一切之后,我们在图中创建了一个新的节点用于矩阵,并将其命名为
W
,并用张量的值初始化了它
然后,我们以相同的方式创建我们的第二个张量和输入节点,向量x
,如下所示:
vecB := []float64{5,7}
vecT := tensor.New(tensor.WithBacking(vecB), tensor.WithShape(2))
vec := G.NewVector(g,
tensor.Float64,
G.WithName("x"),
G.WithShape(2),
G.WithValue(vecT),
)
就像上次一样,我们接着添加一个操作节点z
,它将两者相乘(而不是加法操作):
z, err := G.Mul(mat, vec)
然后,和上次一样,创建一个新的计算机并运行它,如下所示,然后打印结果:
machine := G.NewTapeMachine(g)
if machine.RunAll() != nil {
log.Fatal(err)
}
fmt.Println(z.Value().Data())
// Output: [9.4 3.4]
可视化图形
在许多情况下,通过将io
或ioutil
添加到您的导入中,并将以下行添加到您的代码中,可以非常方便地可视化图形:
ioutil.WriteFile("simple_graph.dot", []byte(g.ToDot()), 0644)
这将生成一个 DOT 文件;您可以在 GraphViz 中打开它,或者更方便地将其转换为 SVG。您可以通过安装 GraphViz 并在命令行中输入以下内容,在大多数现代浏览器中查看它:
dot -Tsvg simple_graph.dot -O
这将生成simple_graph.dot.svg
;您可以在浏览器中打开它以查看图的渲染,如下所示:
你可以看到,在我们的图中,我们有两个输入,W
和x
,然后将其馈送到我们的运算符中,这是一个矩阵乘法和一个向量,给我们的结果同样是另一个向量。
构建更复杂的表达式
当然,我们已经大部分讲解了如何构建简单的方程;但是,如果你的方程稍微复杂一些,例如以下情况会发生:
z = Wx + b
我们也可以通过稍微改变我们的代码来轻松完成这一点,添加以下行:
b := G.NewScalar(g,
tensor.Float64,
G.WithName("b"),
G.WithValue(3.0)
)
然后,我们可以稍微更改z
的定义,如下所示:
a, err := G.Mul(mat, vec)
if err != nil {
log.Fatal(err)
}
z, err := G.Add(a, b)
if err != nil {
log.Fatal(err)
}
正如你所看到的,我们创建了一个乘法运算符节点,然后在此基础上创建了一个加法运算符节点。
或者,你也可以只在一行中完成,如下所示:
z, err := G.Add(G.Must(G.Mul(mat, vec)), b)
注意,我们在这里使用Must
来抑制错误对象;我们这样做仅仅是为了方便,因为我们知道将此节点添加到图中的操作会成功。重要的是要注意,你可能希望重新构造此代码,以便单独创建用于添加节点的代码,以便每一步都能进行错误处理。
如果你现在继续构建和执行代码,你会发现它将产生以下结果:
// Output: [12.4 6.4]
计算图现在看起来像以下的屏幕截图:
你可以看到,W
和x
都输入到第一个操作中(我们的乘法操作),稍后又输入到我们的加法操作中以生成我们的结果。
这是使用 Gorgonia 的简介!正如你现在希望看到的那样,它是一个包含了必要原语的库,它将允许我们在接下来的章节中构建第一个简单的,然后是更复杂的神经网络。
总结
本章简要介绍了 DL,包括其历史和应用。随后讨论了为什么 Go 语言非常适合 DL,并演示了我们在 Gorgonia 中使用的库与 Go 中其他库的比较。
下一章将涵盖使神经网络和 DL 工作的魔力,其中包括激活函数、网络结构和训练算法。
第二章:什么是神经网络,以及如何训练一个?
虽然我们现在已经讨论了 Go 及其可用的库,但我们还没有讨论什么构成了神经网络。在上一章的末尾,我们使用 Gorgonia 构建了一个图,当通过适当的虚拟机执行时,对一系列矩阵和向量执行几个基本操作(特别是加法和乘法)。
我们现在将讨论如何构建一个神经网络并使其正常工作。这将教会你如何构建后续在本书中讨论的更高级神经网络架构所需的组件。
本章将涵盖以下主题:
-
一个基本的神经网络
-
激活函数
-
梯度下降和反向传播
-
高级梯度下降算法
一个基本的神经网络
让我们首先建立一个简单的神经网络。这个网络将使用加法和乘法的基本操作来处理一个 4 x 3 的整数矩阵,初始化一个由 3 x 1 列向量表示的权重系数,并逐渐调整这些权重,直到它们预测出,对于给定的输入序列(并在应用 Sigmoid 非线性后),输出与验证数据集匹配。
神经网络的结构
这个示例的目的显然不是建立一个尖端的计算机视觉系统,而是展示如何在参数化函数的背景下使用这些基本操作(以及 Gorgonia 如何处理它们),其中参数是随时间学习的。本节的关键目标是理解学习网络的概念。这个学习实际上只是网络的连续、有意识的重新参数化(更新权重)。这是通过一个优化方法完成的,本质上是一小段代码,代表了一些基础的本科水平的微积分。
Sigmoid 函数(以及更一般的激活函数)、随机梯度下降(SGD)和反向传播将在本章的后续部分中详细讨论。目前,我们将在代码的上下文中讨论它们;即,它们在何处以及如何使用,以及它们在我们计算的函数中的作用。
当你读完这本书或者如果你是一个有经验的机器学习实践者时,下面的内容会看起来像是进入神经网络架构世界的一个极其简单的第一步。但如果这是你第一次接触,务必仔细注意。所有使魔法发生的基础都在这里。
网络由什么构成?以下是我们玩具例子神经网络的主要组成部分:
-
输入数据:这是一个 4 x 3 矩阵。
-
验证数据:这是一个 1 x 4 列向量,或者实际上是一个四行一列的矩阵。在 Gorgonia 中表示为
WithShape(4,1)
。 -
激活(Sigmoid)函数:这为我们的网络和我们正在学习的函数引入了非线性。
-
突触: 也称为可训练权重,是我们将使用 SGD 优化的网络的关键参数。
我们的计算图中,每个组件及其相关操作都表示为节点。当我们逐步解释网络的操作时,我们将使用我们在第一章,《Go 深度学习入门》中学到的技术生成图形可视化。
我们也将稍微超前设计我们的网络。这意味着什么?考虑以下代码块:
type nn struct {
g *ExprGraph
w0, w1 *Node
pred *Node
}
我们将网络的关键组件嵌入名为nn
的struct
中。这不仅使我们的代码易读,而且在我们希望对深度(多层)网络的每一层的多个权重执行优化过程(SGD/反向传播)时,它也能很好地扩展。正如你所见,除了每层的权重外,我们还有一个表示网络预测的节点,以及*ExprGraph
本身。
我们的网络有两层。这些是在网络的前向传递过程中计算的。前向传递代表我们在计算图中希望对值节点执行的所有数值转换。
具体来说,我们有以下内容:
-
l0
:输入矩阵,我们的X
-
w0
:可训练参数,我们网络的权重,将通过 SGD 算法进行优化 -
l1
:对l0
和w0
的点积应用 Sigmoid 函数的值 -
pred
:表示网络预测的节点,反馈到nn struct
的适当字段
那么,我们在这里的目标是什么?
我们希望建立一个系统,该系统学习一个最能够模拟列序列0, 0, 1, 1
的函数。现在,让我们深入研究一下!
你的第一个神经网络
让我们从基本的包命名和导入我们需要的包开始。这个过程分为以下步骤进行:
- 在这个示例中,我们将使用与 Gorgonia 相同开发者提供的
tensor
库。我们将用它来支持与计算图中的各自节点关联的张量:
package main
import (
"fmt"
"io/ioutil"
"log"
. "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
- 创建一个变量,将使用以下代码捕获错误:
var err error
现在我们可以定义用于嵌入神经网络图、权重和预测(输出)的主struct
。在更深的网络中,我们会有w0
、w1
、w2
、w3
等,一直到wn
。这个struct
还可能包含我们稍后章节详细讨论的额外网络参数。例如,在卷积神经网络(CNN)中,还会有每层的 dropout 概率,这有助于防止网络过度拟合我们的训练数据。重点是,无论架构有多高级或者论文有多新颖,你都可以扩展以下struct
以表达任何网络的属性:
type nn struct {
g *ExprGraph
w0, w1 *Node
pred *Node
}
现在,我们将考虑实例化一个新的nn
的方法。在这里,我们为我们的权重矩阵或者在这个特定情况下是我们的行向量创建节点。这个过程推广到支持n阶张量的任何节点的创建。
以下方法返回ExprGraph
,并附加了新节点:
func newNN(g *ExprGraph) *nn {
// Create node for w/weight (needs fixed values replaced with random values w/mean 0)
wB := []float64{-0.167855599, 0.44064899, -0.99977125}
wT := tensor.New(tensor.WithBacking(wB), tensor.WithShape(3, 1))
w0 := NewMatrix(g,
tensor.Float64,
WithName("w"),
WithShape(3, 1),
WithValue(wT),
)
return nn{
g: g,
w0: w0,
}
}
现在,我们已经向图中添加了一个节点,并且用实值张量支持它,我们应该检查我们的计算图,看看这个权重是如何出现的,如下表所示:
这里需要注意的属性是类型(一个float64
的矩阵)、Shape
为(3, 1)
,当然,这个向量中占据的三个值。这不算是一个图;事实上,我们的节点很孤单,但我们很快就会添加到它上面。在更复杂的网络中,每个我们使用的层都会有一个由权重矩阵支持的节点。
在我们这样做之前,我们必须添加另一个功能,使我们能够将代码扩展到这些更复杂的网络。在这里,我们正在定义网络的可学习部分,这对计算梯度至关重要。正是这些节点的列表,Grad()
函数将操作它们。以这种方式分组这些节点使我们能够在一个函数中计算跨n层网络的权重梯度。扩展这一点意味着添加w1
、w2
、w3
和wn
,如下面的代码所示:
func (m *nn) learnables() Nodes {
return Nodes{m.w0}
}
现在,我们来到网络的核心部分。执行以下函数时,将使用操作和节点扩展我们的图,这些节点表示输入和隐藏层。重要的是要注意,这是一个将在我们网络的主要部分中调用的函数;现在,我们要提前定义它:
func (m *nn) fwd(x *Node) (err error) {
var l0, l1 *Node
// Set first layer to be copy of input
l0 = x
// Dot product of l0 and w0, use as input for Sigmoid
l0dot := Must(Mul(l0, m.w0))
// Build hidden layer out of result
l1 = Must(Sigmoid(l0dot))
// fmt.Println("l1: \n", l1.Value())
m.pred = l1
return
}
我们可以看到在隐藏层l1
上应用Sigmoid
函数,正如我们在详细讨论网络组件时简要提到的那样。我们将在本章的下一部分详细介绍它。
现在,我们可以编写我们的main
函数,在其中实例化我们的网络和所有先前描述的各种方法。让我们详细地走一遍它。这个过程的第一步如下所示:
func main() {
rand.Seed(31337)
intercept Ctrl+C
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
doneChan := make(chan bool, 1)
// Create graph and network
g := NewGraph()
m := newNN(g)
接下来,我们定义我们的输入矩阵,如下所示:
// Set input x to network
xB := []float64{0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1}
xT := tensor.New(tensor.WithBacking(xB), tensor.WithShape(4, 3))
x := NewMatrix(g,
tensor.Float64,
WithName("X"),
WithShape(4, 3),
WithValue(xT),
)
然后,我们定义实际上将成为我们的验证数据集的部分,如下所示:
// Define validation dataset
yB := []float64{0, 0, 1, 1}
yT := tensor.New(tensor.WithBacking(yB), tensor.WithShape(4, 1))
y := NewMatrix(g,
tensor.Float64,
WithName("y"),
WithShape(4, 1),
WithValue(yT),
)
让我们看看我们的图现在加入了X
和y
之后的样子:
我们可以看到个别节点w
、X
和y
。就像我们查看w
时所做的那样,请注意每个节点的类型、Shape
和Value
。
现在,我们调用我们的nn
的fwd
方法,并真正构建我们的图,包括X
、y
和w
之间的计算关系,如下面的代码所示:
// Run forward pass
if err = m.fwd(x); err != nil {
log.Fatalf("%+v", err)
}
优化过程从这里开始。我们的网络已经做出了第一个预测,现在我们将定义并计算一个cost
函数,这将帮助我们确定我们的权重有多大错误,并且之后我们需要调整权重以使我们更接近目标y
(我们的验证数据集)。在这个例子中,我们将重复这个过程固定次数,以使这个相对简单的网络收敛。
以下代码首先计算损失(即,我们错过了多少?)。然后,我们取cost
作为验证数据的Mean
:
losses := Must(Sub(y, m.pred))
cost := Must(Mean(losses))
让我们创建一个var
来跟踪时间内cost
的变化,如下所示:
var costVal Value
Read(cost, costVal)
在我们继续计算网络中的梯度之前,让我们使用以下代码生成我们图表状态的可视化,这应该已经很熟悉了:
ioutil.WriteFile("pregrad.dot", []byte(g.ToDot()), 0644)
使用以下代码将其转换为 PNG:
dot -Tpng pregrad.dot -O
我们现在有一个连接包含我们数据(输入、权重和验证)及我们将在其上执行的操作的图表。
图表变得过大,无法一次性包含在一页内,因此我们现在只考虑此步骤的重要部分。首先注意到我们的权重节点现在有一个Grad
字段,当前没有值(已运行前向传播,但我们尚未计算梯度),如下表所示:
我们现在还有一些梯度操作;以下是关于这一步的摘录图表:
现在,让我们计算梯度,相对于权重的 cost(表示为m.learnables
)。这一步在以下代码中显示:
if _, err = Grad(cost, m.learnables()...); err != nil {
log.Fatal(err)
}
我们现在可以实例化将处理我们图表的 VM。我们也选择我们的solver
,在本例中是一个普通的 SGD,如下所示:
// Instantiate VM and Solver
vm := NewTapeMachine(g, BindDualValues(m.learnables()...))
solver := NewVanillaSolver(WithLearnRate(0.001), WithClip(5))
// solver := NewRMSPropSolver()
我们为我们的vm
提供的一个新选项是BindDualValues
。这个选项确保我们计算的梯度与包含导数值的节点绑定。这意味着,不是节点说“去节点 x 找梯度的值”,而是值立即可被vm
访问。这是我们在图表上修改权重节点的样子:
Value
字段现在包含输出相对于节点的偏导数。我们现在终于准备好运行我们的完整训练循环。对于这样一个简单的示例,我们将运行循环一定次数,具体来说,10000
次循环,如下例所示:
for i := 0; i < 10000; i++ {
if err = vm.RunAll(); err != nil {
log.Fatalf("Failed at inter %d: %v", i, err)
}
solver.Step(NodesToValueGrads(m.learnables()))
fmt.Println("\nState at iter", i)
fmt.Println("Cost: \n", cost.Value())
fmt.Println("Weights: \n", m.w0.Value())
// vm.Set(m.w0, wUpd)
// vm.Reset()
}
fmt.Println("Output after Training: \n", m.pred.Value())
}
虽然我们已经熟悉使用 VM 计算图的概念,但是这里我们添加了一个调用solver
的步骤,这是我们之前定义的。Step
通过可训练节点的序列(也就是我们的权重)进行工作,添加梯度并乘以我们之前指定的学习率。
就是这样!现在,我们运行我们的程序,并期望训练后的输出是 0、0、1、1,如下面的代码所示:
Output after Training:
C [[0.00966449][0.00786506][0.99358898][0.99211957]]
这已经足够接近了,可以宣布我们的网络已经收敛了!
激活函数
现在你知道如何构建一个基本的神经网络了,让我们来看看模型中某些元素的目的。其中一个元素是Sigmoid,它是一个激活函数。有时也称为传输函数。
正如你之前学到的,一个给定的层可以简单地定义为应用于输入的权重;加上一些偏置然后决定激活。激活函数决定一个神经元是否被激活。我们还将这个函数放入网络中,以帮助创建输入和输出之间更复杂的关系。在此过程中,我们还需要它是一个能够与我们的反向传播一起工作的函数,这样我们可以通过优化方法(即梯度下降)轻松地优化我们的权重。这意味着我们需要函数的输出是可微的。
在选择激活函数时有几个要考虑的因素,如下所示:
-
速度:简单的激活函数比复杂的激活函数执行速度更快。这一点很重要,因为在深度学习中,我们倾向于通过大量数据运行模型,因此会多次执行每个函数。
-
可微性:正如我们已经注意到的,函数在反向传播过程中能够区分是有用的。具有梯度使我们能够调整权重,使网络更接近收敛。简言之,它允许我们计算错误,通过最小化成本函数来改进我们的模型。
-
连续性:它应该在整个输入范围内返回一个值。
-
单调性:虽然这个属性并不是严格必要的,但它有助于优化神经网络,因为它在梯度下降过程中会更快地收敛。使用非单调函数是可能的,但总体上会导致更长的训练时间。
阶跃函数
当然,最基本的激活函数可能是一个阶跃函数。如果x
的值大于一个固定值a
,那么y
要么是0
要么是1
,如下面的代码所示:
func step(x) {
if x >= 0 {
return 1
} else {
return 0
}
}
如你在下图中所见,step
函数非常简单;它接受一个值然后返回0
或1
:
这是一个非常简单的函数,对深度学习来说并不特别有用。这是因为这个函数的梯度是一个恒定的零,这意味着当我们进行反向传播时,它将不断产生零,这在我们执行反向传播时几乎没有(如果有的话)任何改进。
线性函数
对step
函数的可能扩展是使用linear
函数,如下面的代码所示:
func linear(x){
return 0.5 * x
}
这仍然非常简单,如果我们将其绘制出来,它看起来会像以下的图表:
然而,这个函数仍然不是很有用。如果我们查看其梯度,我们会看到,当我们对该函数进行微分时,我们得到的是一个与值a
相等的直线。这意味着它遇到了与步函数相同的问题;也就是说,我们不会从反向传播中看到太多的改进。
此外,如果我们堆叠多层,你会发现我们得到的结果与仅有一层并没有太大不同。这在试图构建具有多层、特别是具有非线性关系的模型时并不实用。
Rectified Linear Units
修正线性单元(ReLU)是目前最流行的激活函数。我们将在后面的章节中将其用作许多高级架构的主要激活函数。
可以描述为:
func relu(x){
return Max(0,x)
}
如果我们将其绘制出来,它看起来会像以下的图表:
正如你所见,它与线性函数极为相似,除了它趋向于零(因此表明神经元未激活)。
ReLU 还具有许多有用的属性,如下所示:
-
它是非线性的:因此,堆叠多层这些单元不一定会导致与单层相同
-
它是可微的:因此,可以与反向传播一起使用
-
它很快:在我们多次运行这个计算以跨层或训练网络的传递时,计算速度很重要
如果输入为负,ReLU 趋向于零。这可能是有用的,因为这会导致较少的神经元被激活,从而可能加速我们的计算。然而,由于可能结果为0
,这可能会迅速导致神经元死亡,并且永远不会再次激活,给定某些输入。
Leaky ReLU
我们可以修改 ReLU 函数,在输入为负时具有较小的梯度 —— 这可以非常快速地完成,如下所示:
func leaky_relu(x) {
if x >= 0 {
return x
} else {
return 0.01 * x
}
}
前述函数的图表如下所示:
请注意,此图表已经进行了强调修改,因此y对x的斜率实际上是0.1
,而不是通常认为的0.01
,这是被视为 Leaky ReLU 的特征之一。
由于它总是产生一个小的梯度,这应该有助于防止神经元永久性地死亡,同时仍然给我们带来 ReLU 的许多好处。
Sigmoid 函数
Sigmoid 或逻辑函数也相对流行,如下所示:
func sigmoid(x){
return 1 / (1 + Exp(-x))
}
输出如下:
Sigmoid 还有一个有用的特性:它可以将任何实数映射回在0
和1
之间的范围内。这对于生成偏好于在0
和1
之间输出的模型非常有用(例如,用于预测某事物的概率模型)。
它也具有我们正在寻找的大多数属性,如下所列:
-
它是非线性的。因此,堆叠多层这样的函数不一定会导致与单层相同的结果。
-
它是可微分的。因此,它适用于反向传播。
-
它是单调递增的。
然而,其中一个缺点是与 ReLU 相比,计算成本更高,因此总体来说,使用此模型进行训练将需要更长的时间。
Tanh
训练过程中保持较陡的梯度也可能有所帮助;因此,我们可以使用tanh
函数而不是Sigmoid
函数,如下面的代码所示:
func tanh(x){
return 2 * (1 + Exp(-2*x)) - 1
}
我们得到以下输出:
tanh
函数还有另一个有用的特性:其斜率比Sigmoid
函数陡峭得多;这有助于具有tanh
激活函数的网络在调整权重时更快地下降梯度。两种函数的输出在以下输出中绘制:
但我们应该选择哪一个呢?
每个激活函数都很有用;然而,由于 ReLU 具有所有激活函数中最有用的特性,并且易于计算,因此这应该是您大部分时间使用的函数。
如果您经常遇到梯度陷入困境,切换到 Leaky ReLU 可能是一个好主意。然而,通常可以降低学习速率来防止这种情况,或者在较早的层中使用它,而不是在整个网络中使用它,以保持在整个网络中具有更少激活的优势。
Sigmoid
作为输出层最有价值,最好以概率作为输出。例如,tanh
函数也可能很有价值,例如,我们希望层次不断调整值(而不是向上偏置,如 ReLU 和 Sigmoid)。
因此,简短的答案是:这取决于您的网络以及您期望的输出类型。
但是应该注意,虽然这里提出了许多激活函数供您考虑,但其他激活函数也已被提出,例如 PReLU、softmax 和 Swish,这取决于手头的任务。这仍然是一个活跃的研究领域,并且被认为远未解决,因此请继续关注!
梯度下降和反向传播
在本章的第一部分示例代码的背景下,我们已经讨论了反向传播和梯度下降,但当 Gorgonia 为我们大部分工作时,真正理解起来可能会有些困难。因此,现在我们将看一下实际的过程本身。
梯度下降
反向传播是我们真正训练模型的方法;这是一种通过调整模型权重来最小化预测误差的算法。我们通常通过一种称为梯度下降的方法来实现。
让我们从一个基本的例子开始 —— 比如说我们想要训练一个简单的神经网络来完成以下任务,即通过将一个数字乘以 0.5:
输入 | 目标 |
---|---|
1 | 0.5 |
2 | 1.0 |
3 | 1.5 |
4 | 2.0 |
我们有一个基本的模型可以开始使用,如下所示:
y = W * x
因此,首先,让我们猜测 W 实际上是两个。以下表格显示了这些结果:
输入 | 目标 | W * x |
---|---|---|
1 | 0.5 | 2 |
2 | 1.0 | 4 |
3 | 1.5 | 6 |
4 | 2.0 | 8 |
现在我们有了我们猜测的输出,我们可以将这个猜测与我们预期的答案进行比较,并计算相对误差。例如,在这个表格中,我们使用了平方误差的总和:
输入 | 目标 | W * x | 绝对误差 | 平方误差 |
---|---|---|---|---|
1 | 0.5 | 2 | -1.5 | 2.25 |
2 | 1.0 | 4 | -3.0 | 9 |
3 | 1.5 | 6 | -4.5 | 20.25 |
4 | 2.0 | 8 | -6.0 | 36 |
通过将前述表格中最后一列的值相加,我们现在有了平方误差的总和,为 67.5。
我们当然可以通过 brute force 方式计算从 -10 到 +10 的所有值来得出一个答案,但肯定还有更好的方法吧?理想情况下,我们希望有一种更高效的方法,适用于不是简单四个输入的数据集。
更好的方法是检查导数(或梯度)。我们可以通过稍微增加权重再次进行同样的计算来做到这一点;例如,让我们试试 W = 2.01。以下表格显示了这些结果:
输入 | 目标 | W * x | 绝对误差 | 平方误差 |
---|---|---|---|---|
1 | 0.5 | 2.01 | -1.51 | 2.2801 |
2 | 1.0 | 4.02 | -3.02 | 9.1204 |
3 | 1.5 | 6.03 | -4.53 | 20.5209 |
4 | 2.0 | 8.04 | -6.04 | 36.4816 |
这给了我们一个平方误差和为 68.403;这更高了!这意味着,直觉上来说,如果我们增加权重,误差可能会增加。反之亦然;如果我们减少权重,误差可能会减少。例如,让我们尝试 W = 1.99,如下表所示:
输入 | 目标 | W * x | 绝对误差 | 平方误差 |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
4 | 2 | 4.04 | -1.996 | 3.984016 |
8 | 4 | 8.08 | -3.992 | 15.93606 |
16 | 8 | 15.84 | -7.984 | 63.74426 |
这给了我们一个较低的误差值为 83.66434。
如果我们绘制给定范围内 W 的误差,你会发现有一个自然的底点。这是我们通过梯度下降来最小化误差的方式。
对于这个具体的例子,我们可以轻松地将误差作为权重函数来绘制。
目标是沿着斜坡向下走,直到误差为零:
让我们尝试对我们的例子应用一个权重更新来说明这是如何工作的。一般来说,我们遵循的是所谓的增量学习规则,其基本原理与以下类似:
new_W = old_W - eta * derivative
在这个公式中,eta 是一个常数,有时也被称为学习率。回想一下,在 Gorgonia 中调用 solver
时,我们将学习率作为其中的一个选项,如下所示:
solver := NewVanillaSolver(WithLearnRate(0.001), WithClip(5))
你会经常看到一个 0.5
项被添加到关于输出的误差的导数中。这是因为,如果我们的误差函数是一个平方函数,那么导数将是 2
,所以 0.5
项被放在那里来抵消它;然而,eta 是一个常数(所以你也可以考虑它被吸收到 eta 项中)。
因此,首先,我们需要计算关于输出的误差的导数是什么。
如果我们假设我们的学习率是 0.001
,那么我们的新权重将会是以下内容:
new_W = 1.00 - 0.001 * 101.338
如果我们要计算这个,new_W
将会是 1.89866
。这更接近我们最终目标权重 0.5
,并且通过足够的重复,我们最终会达到目标。你会注意到我们的学习率很小。如果我们设置得太大(比如说,设为 1
),我们会调整权重到太负的方向,这样我们会在梯度周围打转,而不是向下降。学习率的选择非常重要:太小的话模型收敛会太慢,太大的话甚至可能会发散。
反向传播
这是一个简单的例子。对于具有数千甚至数百万参数的复杂模型,以及涉及多层的情况,我们需要更加智能地将这些更新传播回我们的网络。对于具有多层的网络(相应地增加了参数的数量),新的研究结果显示,例如包含 10,000 层的 CNNs 也不例外。
那么,我们怎么做呢?最简单的方法是通过使用我们知道导数的函数来构建你的神经网络。我们可以在符号上或更实际地进行这样的操作;如果我们将其构建成我们知道如何应用函数以及我们知道如何反向传播(通过知道如何编写导数函数),我们就可以基于这些函数构建一个神经网络。
当然,构建这些函数可能会耗费大量时间。幸运的是,Gorgonia 已经包含了所有这些功能,因此我们可以进行所谓的自动微分。正如我之前提到的,我们为计算创建了一个有向图;这不仅允许我们进行前向传播,还允许我们进行反向传播!
例如,让我们考虑一些更多层次的东西(虽然仍然简单),就像下面的这个例子,其中 i 是输入,f 是具有权重 w1 的第一层,g 是具有权重 w2 的第二层,o 是输出:
首先,我们有一个与 o 有关的错误,让我们称之为 E。
为了更新我们在 g 中的权重,我们需要知道关于 g 的输入的误差的导数。
根据导数的链式法则,当处理导数时,我们知道这实际上等效于以下内容:
dE_dg = dE_do * do_dg * dg_dw2
也就是说,关于 g (dE_dg) 的误差导数实际上等同于关于输出的误差导数 (dE_do),乘以关于函数 g (do_dg) 的输出的导数,然后乘以函数 g 关于 w2 的导数。
这给了我们更新权重在 g 中的导数速率。
现在我们需要对 f 做同样的事情。怎么做?这是一个重复的过程。我们需要关于 f 的输入的误差的导数。再次使用链式法则,我们知道以下内容是正确的:
dE_df = dE_do * do_dg * dg_df * df_dw1
你会注意到这里与先前导数的共同之处,dE_do * do_dg。
这为我们提供了进一步优化的机会。每次都不必计算整个导数;我们只需要知道我们正在反向传播的层的导数以及我们正在反向传播到的层的导数,这在整个网络中都是成立的。这被称为反向传播算法,它允许我们在整个网络中更新权重,而无需不断重新计算特定权重相对于错误的导数,并且我们可以重复使用先前计算的结果。
随机梯度下降
我们可以通过简单的改变进一步优化训练过程。使用基本(或批量)梯度下降,我们通过查看整个数据集来计算调整量。因此,优化的下一个明显步骤是:我们可以通过查看少于整个数据集来计算调整量吗?
事实证明答案是肯定的!由于我们预期要对网络进行多次迭代训练,我们可以利用我们预期梯度会被多次更新这一事实,通过为较少的示例计算来减少计算量。我们甚至可以仅通过计算单个示例来实现这一点。通过为每次网络更新执行较少的计算,我们可以显著减少所需的计算量,从而实现更快的训练时间。这本质上是梯度下降的随机逼近,因此它得名于此。
高级梯度下降算法
现在我们了解了 SGD 和反向传播,让我们看看一些高级优化方法(基于 SGD),这些方法通常提供一些优势,通常是在训练时间(或将成本函数最小化到网络收敛点所需的时间)上的改进。
这些改进的方法包括速度作为优化参数的一般概念。引用 Wibisono 和 Wilson,在他们关于优化中的加速方法的论文开篇中说道:
"在凸优化中,存在一种加速现象,可以提高某些基于梯度的算法的收敛速度。"
简而言之,这些先进的算法大多依赖于类似的原则——它们可以快速通过局部最优点,受其动量的驱动——本质上是我们梯度的移动平均。
动量
在考虑梯度下降的优化时,我们确实可以借鉴现实生活中的直觉来帮助我们的方法。其中一个例子就是动量。如果我们想象大多数误差梯度实际上就像一个碗,理想的点在中间,如果我们从碗的最高点开始,可能需要很长时间才能到达碗的底部。
如果我们考虑一些真实的物理现象,碗的侧面越陡,球沿着侧面下降时速度越快。以此为灵感,我们可以得到我们可以考虑的 SGD 动量变体;我们试图通过考虑,如果梯度继续朝同一方向下降,我们给予它更多的动量来加速梯度下降。另外,如果我们发现梯度改变方向,我们会减少动量的量。
虽然我们不想陷入繁重的数学中,但有一个简单的公式可以计算动量。如下所示:
V = 动量 * m - lr * g
这里,m 是先前的权重更新,g 是关于参数 p 的当前梯度,lr 是我们求解器的学习率,而 momentum 是一个常数。
因此,如果我们想确切地了解如何更新我们的网络参数,我们可以通过以下方式调整公式:
P(新) = p + v = p + 动量 * m - lr * g
这在实践中意味着什么?让我们看看一些代码。
首先,在 Gorgonia 中,所有优化方法或求解器的基本接口如下所示:
type Solver interface {
Step([]ValueGrad) error
}
然后,我们有以下函数,为Solver
提供构建选项:
type SolverOpt func(s Solver)
当然,设置的主要选项是使用动量本身;这个SolverOpt
选项是WithMomentum
。适用的求解器选项包括WithL1Reg
、WithL2Reg
、WithBatchSize
、WithClip
和WithLearnRate
。
让我们使用本章开头的代码示例,但不使用普通的 SGD,而是使用动量求解器的最基本形式,如下所示:
vm := NewTapeMachine(g, BindDualValues(m.learnables()...))
solver := NewMomentum()
就这样!但这并没有告诉我们太多,只是 Gorgonia 就像任何优秀的机器学习库一样,足够灵活和模块化,我们可以简单地替换我们的求解器(并衡量相对性能!)。
让我们来看看我们正在调用的函数,如下所示的代码:
func NewMomentum(opts ...SolverOpt) *Momentum {
s := Momentum{
eta: 0.001,
momentum: 0.9,
}
for _, opt := range opts {
opt(s)
}
return s
}
我们可以在这里看到我们在原始公式中引用的momentum
常数,以及eta
,这是我们的学习率。这就是我们需要做的一切;将动量求解器应用于我们的模型!
Nesterov 动量
在 Nesterov 动量中,我们改变了计算梯度的位置/时间。我们朝着先前累积梯度的方向跳得更远。然后,在这个新位置测量梯度,并相应地进行修正/更新。
这种修正防止了普通动量算法更新过快,因此在梯度下降试图收敛时产生更少的振荡。
RMSprop
我们也可以从不同的角度思考优化:如果我们根据特征重要性调整学习率会怎样?当我们在更新常见特征的参数时,我们可以降低学习率,然后在处理不常见特征时增加学习率。这也意味着我们可以花更少的时间优化学习率。有几种变体的这种想法已被提出,但迄今最受欢迎的是 RMSprop。
RMSprop 是 SGD 的修改形式,虽然未公开,但在 Geoffrey Hinton 的《机器学习的神经网络》中有详细阐述。RMSprop 听起来很高级,但也可以简单地称为自适应梯度下降。基本思想是根据某些条件修改学习率。
这些条件可以简单地陈述如下:
-
如果函数的梯度很小但一致,那么增加学习率
-
如果函数的梯度很大但不一致,那么降低学习率
RMSprop 的具体做法是通过将权重的学习率除以先前梯度的衰减平均来实现的。
Gorgonia 本身支持 RMSprop。与动量示例类似,您只需更换您的solver
。以下是您如何定义它,以及您想要传递的多个solveropts
:
solver = NewRMSPropSolver(WithLearnRate(stepSize), WithL2Reg(l2Reg), WithClip(clip))
检查底层函数时,我们看到以下选项及其相关的默认衰减因子、平滑因子和学习率:
func NewRMSPropSolver(opts...SolverOpt) * RMSPropSolver {
s: = RMSPropSolver {
decay: 0.999,
eps: 1e-8,
eta: 0.001,
}
for _,
opt: = range opts {
opt(s)
}
return s
}
总结
在本章中,我们介绍了如何构建简单的神经网络以及如何检查您的图表,以及许多常用的激活函数。然后,我们介绍了神经网络通过反向传播和梯度下降进行训练的基础知识。最后,我们讨论了一些不同的梯度下降算法选项以及神经网络的优化方法。
下一章将介绍构建实用的前馈神经网络和自编码器,以及受限玻尔兹曼机(RBMs)。
第三章:超越基本神经网络 - 自编码器和 RBM
现在我们已经学会了如何构建和训练一个简单的神经网络,我们应该构建一些适合实际问题的模型。
在本章中,我们将讨论如何构建一个能识别和生成手写体的模型,以及执行协同过滤。
在本章中,我们将涵盖以下主题:
-
加载数据 – 修改的国家标准技术研究所(MNIST)数据库
-
建立手写体识别的神经网络
-
建立自编码器 – 生成 MNIST 数字
-
建立受限玻尔兹曼机(RBM)以进行类似 Netflix 的协同过滤
加载数据 – MNIST
在我们甚至可以开始训练或构建我们的模型之前,我们首先需要获取一些数据。事实证明,许多人已经在线上提供了可供我们使用的数据。其中一个最好的精选数据集就是 MNIST,我们将在本章的前两个示例中使用它。
我们将学习如何下载 MNIST 并将其加载到我们的 Go 程序中,以便在我们的模型中使用。
什么是 MNIST?
在本章中,我们将使用一个名为 MNIST 的流行数据集。这个数据集由 Yann LeCun、Corinna Cortes 和 Christopher Burges 在yann.lecun.com/exdb/mnist
上提供。
这个数据库因其由两个包含黑白手写数字图像的数据库混合而成的事实而得名。它是一个理想的数据集示例,已经经过预处理和良好的格式化,因此我们可以立即开始使用它。当您下载它时,它已经分成了训练集和测试(验证)集,训练集中有 60,000 个标记示例,测试集中有 10,000 个标记示例。
每个图像正好是 28 x 28 像素,包含一个从 1 到 255 的值(反映像素强度或灰度值)。这对我们来说非常简化了事情,因为这意味着我们可以立即将图像放入矩阵/张量中,并开始对其进行训练。
加载 MNIST
Gorgonia 在其examples
文件夹中带有一个 MNIST 加载器,我们可以通过将以下内容放入我们的导入中轻松在我们的代码中使用它:
"gorgonia.org/gorgonia/examples/mnist"
然后,我们可以将以下行添加到我们的代码中:
var inputs, targets tensor.Tensor
var err error
inputs, targets, err = mnist.Load(“train”, “./mnist/, “float64”)
这将我们的图像加载到名为inputs
的张量中,并将我们的标签加载到名为targets
的张量中(假设您已将相关文件解压缩到一个名为mnist
的文件夹中,该文件夹应该在您的可执行文件所在的位置)。
在这个例子中,我们正在加载 MNIST 的训练集,因此会产生一个大小为 60,000 x 784 的二维张量用于图像,以及一个大小为 60,000 x 10 的张量用于标签。Gorgonia 中的加载器还会很方便地将所有数字重新缩放到 0 到 1 之间;在训练模型时,我们喜欢小而标准化的数字。
建立手写体识别的神经网络
现在我们已经加载了所有这些有用的数据,让我们好好利用它。因为它充满了手写数字,我们应该确实构建一个模型来识别这种手写和它所说的内容。
在第二章,什么是神经网络以及如何训练一个中,我们演示了如何构建一个简单的神经网络。现在,是时候建立更为重要的东西了:一个能够识别 MNIST 数据库中手写内容的模型。
模型结构简介
首先,让我们回顾一下原始示例:我们有一个单层网络,我们希望从一个 4x3 矩阵得到一个 4x1 向量。现在,我们必须从一个 MNIST 图像(28x28 像素)得到一个单一的数字。这个数字是我们的网络关于图像实际代表的数字的猜测。
以下截图展示了在 MNIST 数据中可以找到的粗略示例:一些手写数字的灰度图像旁边有它们的标签(这些标签是单独存储的):
层
请记住,我们正在处理张量数据,因此我们需要将这些数据与那些数据格式联系起来。一个单独的图像可以是一个 28x28 的矩阵,或者可以是一个 784 个值的长向量。我们的标签当前是从 0 到 9 的整数。然而,由于这些实际上是分类值而不是从 0 到 9 的连续数值,最好将结果转换为向量。我们不应该要求我们的模型直接产生这个输出,而是应该将输出视为一个包含 10 个值的向量,其中位置为 1 告诉我们它认为是哪个数字。
这为我们提供了正在使用的参数;我们需要输入 784 个值,然后从我们训练过的网络中获取 10 个值。例如,我们按照以下图表构建我们的层:
这种结构通常被描述为具有两个隐藏层,每个层分别有300和100个单元。这可以用以下代码在 Gorgonia 中实现:
type nn struct {
g *gorgonia.ExprGraph
w0, w1, w2 *gorgonia.Node
out *gorgonia.Node
predVal gorgonia.Value
}
func newNN(g *gorgonia.ExprGraph) *nn {
// Create node for w/weight
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 300), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(300, 100), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
w2 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(100, 10), gorgonia.WithName("w2"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
return &nn{
g: g,
w0: w0,
w1: w1,
w2: w2,
}
}
我们还使用了你在第二章什么是神经网络以及如何训练一个中学到的 ReLU 激活函数。事实证明,ReLU 非常适合这个任务。因此,我们网络的前向传播看起来像是这样:
func (m *nn) fwd(x *gorgonia.Node) (err error) {
var l0, l1, l2 *gorgonia.Node
var l0dot, l1dot*gorgonia.Node
// Set first layer to be copy of input
l0 = x
// Dot product of l0 and w0, use as input for ReLU
if l0dot, err = gorgonia.Mul(l0, m.w0); err != nil {
return errors.Wrap(err, "Unable to multiply l0 and w0")
}
// Build hidden layer out of result
l1 = gorgonia.Must(gorgonia.Rectify(l0dot))
// MOAR layers
if l1dot, err = gorgonia.Mul(l1, m.w1); err != nil {
return errors.Wrap(err, "Unable to multiply l1 and w1")
}
l2 = gorgonia.Must(gorgonia.Rectify(l2dot))
var out *gorgonia.Node
if out, err = gorgonia.Mul(l2, m.w2); err != nil {
return errors.Wrapf(err, "Unable to multiply l2 and w2")
}
m.out, err = gorgonia.SoftMax(out)
gorgonia.Read(m.out, &m.predVal)
return
}
您可以看到,我们网络的最终输出传递到 Gorgonia 的SoftMax
函数。这通过将所有值重新缩放到 0 到 1 之间的值来压缩我们的输出总和为 1。这非常有用,因为我们使用的 ReLU 激活单元可能会产生非常大的数值。我们希望有一个简单的方法使我们的值尽可能接近我们的标签,看起来有点像以下内容:
[ 0.1 0.1 0.1 1.0 0.1 0.1 0.1 0.1 0.1 ]
使用SoftMax
训练的模型将产生如下值:
[ 0 0 0 0.999681 0 0.000319 0 0 0 0 ]
通过获取具有最大值的向量元素,我们可以看到预测的标签是4
。
训练
训练模型需要几个重要的组件。我们有输入,但我们还需要有损失函数和解释输出的方法,以及设置一些其他模型训练过程中的超参数。
损失函数
损失函数在训练我们的网络中发挥了重要作用。虽然我们没有详细讨论它们,但它们的作用是告诉我们的模型什么时候出错,以便它可以从错误中学习。
在本例中,我们使用了经过修改以尽可能高效的方式实现的交叉熵损失版本。
应该注意的是,交叉熵损失通常用伪代码表示,如下所示:
crossEntropyLoss = -1 * sum(actual_y * log(predicted_y))
然而,在我们的情况下,我们要选择一个更简化的版本:
loss = -1 * mean(actual_y * predicted_y)
所以,我们正在实现损失函数如下:
losses, err := gorgonia.HadamardProd(m.out, y)
if err != nil {
log.Fatal(err)
}
cost := gorgonia.Must(gorgonia.Mean(losses))
cost = gorgonia.Must(gorgonia.Neg(cost))
// we wanna track costs
var costVal gorgonia.Value
gorgonia.Read(cost, &costVal)
作为练习,你可以将损失函数修改为更常用的交叉熵损失,并比较你的结果。
Epochs(时期)、iterations(迭代)和 batch sizes(批量大小)
由于我们的数据集现在要大得多,我们也需要考虑如何实际进行训练。逐个项目进行训练是可以的,但我们也可以批量训练项目。与其在 MNIST 的所有 60,000 个项目上进行训练,不如将数据分成 600 次迭代,每次迭代 100 个项目。对于我们的数据集,这意味着将 100 x 784 的矩阵作为输入而不是 784 个值的长向量。我们也可以将其作为 100 x 28 x 28 的三维张量输入,但这将在后面的章节中涵盖适合利用这种结构的模型架构时再详细讨论。
由于我们是在一个编程语言中进行操作,我们只需构建如下循环:
for b := 0; b < batches; b++ {
start := b * bs
end := start + bs
if start >= numExamples {
break
}
if end > numExamples {
end = numExamples
}
}
然后,在每个循环内,我们可以插入我们的逻辑来提取必要的信息以输入到我们的机器中:
var xVal, yVal tensor.Tensor
if xVal, err = inputs.Slice(sli{start, end}); err != nil {
log.Fatal("Unable to slice x")
}
if yVal, err = targets.Slice(sli{start, end}); err != nil {
log.Fatal("Unable to slice y")
}
// if err = xVal.(*tensor.Dense).Reshape(bs, 1, 28, 28); err != nil {
// log.Fatal("Unable to reshape %v", err)
// }
if err = xVal.(*tensor.Dense).Reshape(bs, 784); err != nil {
log.Fatal("Unable to reshape %v", err)
}
gorgonia.Let(x, xVal)
gorgonia.Let(y, yVal)
if err = vm.RunAll(); err != nil {
log.Fatalf("Failed at epoch %d: %v", i, err)
}
solver.Step(m.learnables())
vm.Reset()
在深度学习中,你会经常听到另一个术语,epochs(时期)。Epochs 实际上只是多次运行输入数据到你的数据中。如果你回忆一下,梯度下降是一个迭代过程:它非常依赖于重复来收敛到最优解。这意味着我们有一种简单的方法来改进我们的模型,尽管只有 60,000 张训练图片:我们可以重复这个过程多次,直到我们的网络收敛。
我们当然可以以几种不同的方式来管理这个问题。例如,当我们的损失函数在前一个 epoch 和当前 epoch 之间的差异足够小时,我们可以停止重复。我们还可以运行冠军挑战者方法,并从在我们的测试集上出现为冠军的 epochs 中取权重。然而,因为我们想保持我们的例子简单,我们将选择一个任意数量的 epochs;在这种情况下,是 100 个。
当我们在进行这些操作时,让我们也加上一个进度条,这样我们可以看着我们的模型训练:
batches := numExamples / bs
log.Printf("Batches %d", batches)
bar := pb.New(batches)
bar.SetRefreshRate(time.Second / 20)
bar.SetMaxWidth(80)
for i := 0; i < *epochs; i++ {
// for i := 0; i < 1; i++ {
bar.Prefix(fmt.Sprintf("Epoch %d", i))
bar.Set(0)
bar.Start()
// put iteration and batch logic above here
bar.Update()
log.Printf("Epoch %d | cost %v", i, costVal)
}
测试和验证
训练当然很重要,但我们还需要知道我们的模型是否确实在做它声称要做的事情。我们可以重复使用我们的训练代码,但让我们做一些改变。
首先,让我们删除 solver
命令。我们正在测试我们的模型,而不是训练它,所以我们不应该更新权重:
solver.Step(m.learnables())
其次,让我们将数据集中的图像保存到一个便于处理的文件中:
for j := 0; j < xVal.Shape()[0]; j++ {
rowT, _ := xVal.Slice(sli{j, j + 1})
row := rowT.Data().([]float64)
img := visualizeRow(row)
f, _ := os.OpenFile(fmt.Sprintf("images/%d - %d - %d - %d.jpg", b, j, rowLabel, rowGuess), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
jpeg.Encode(f, img, &jpeg.Options{jpeg.DefaultQuality})
f.Close()
}
如您所见,以下几点是正确的:
-
b
是我们的批次号 -
j
是批次中的项目编号 -
rowLabel
是由 MNIST 提供的标签 -
rowGuess
是我们模型的猜测或预测
现在,让我们添加一些方法来将我们的数据标签和预测提取到更易读的格式中(即作为从 0 到 9 的整数)。
对于我们的数据标签,让我们添加以下内容:
yRowT, _ := yVal.Slice(sli{j, j + 1})
yRow := yRowT.Data().([]float64)
var rowLabel int
var yRowHigh float64
for k := 0; k < 10; k++ {
if k == 0 {
rowLabel = 0
yRowHigh = yRow[k]
} else if yRow[k] > yRowHigh {
rowLabel = k
yRowHigh = yRow[k]
}
}
对于我们的预测,我们首先需要将它们提取到熟悉的格式中。在这种情况下,让我们将它们放入一个张量中,这样我们就可以重复使用我们之前的所有代码:
arrayOutput := m.predVal.Data().([]float64)
yOutput := tensor.New(
tensor.WithShape(bs, 10), tensor.WithBacking(arrayOutput)
)
注意,从 m.predVal
输出的结果是包含我们预测值的 float64
数组。您也可以检索对象的原始形状,这有助于您创建正确形状的张量。在这种情况下,我们已经知道形状,所以我们直接放入这些参数。
预测代码当然与从预处理的 MNIST 数据集中提取标签类似:
// get prediction
predRowT, _ := yOutput.Slice(sli{j, j + 1})
predRow := predRowT.Data().([]float64)
var rowGuess int
var predRowHigh float64
// guess result
for k := 0; k < 10; k++ {
if k == 0 {
rowGuess = 0
predRowHigh = predRow[k]
} else if predRow[k] > predRowHigh {
rowGuess = k
predRowHigh = predRow[k]
}
}
为了所有这些辛勤工作,您将获得一个包含以下标签和猜测的图像文件夹:
您会发现,在其当前形式下,我们的模型在处理一些(可能是糟糕的)手写时存在困难。
仔细观察
或者,您可能还想检查输出的预测,以更好地理解模型中发生的情况。在这种情况下,您可能希望将结果提取到 .csv
文件中,您可以使用以下代码来完成:
arrayOutput := m.predVal.Data().([]float64)
yOutput := tensor.New(tensor.WithShape(bs, 10), tensor.WithBacking(arrayOutput))
file, err := os.OpenFile(fmt.Sprintf("%d.csv", b), os.O_CREATE|os.O_WRONLY, 0777)
if err = xVal.(*tensor.Dense).Reshape(bs, 784); err != nil {
log.Fatal("Unable to create csv", err)
}
defer file.Close()
var matrixToWrite [][]string
for j := 0; j < yOutput.Shape()[0]; j++ {
rowT, _ := yOutput.Slice(sli{j, j + 1})
row := rowT.Data().([]float64)
var rowToWrite []string
for k := 0; k < 10; k++ {
rowToWrite = append(rowToWrite, strconv.FormatFloat(row[k], 'f', 6, 64))
}
matrixToWrite = append(matrixToWrite, rowToWrite)
}
csvWriter := csv.NewWriter(file)
csvWriter.WriteAll(matrixToWrite)
csvWriter.Flush()
有问题的数字的输出可以在以下截图和代码输出中看到。
下面是截图输出:
您也可以在代码中观察相同的输出:
[ 0 0 0.000457 0.99897 0 0 0 0.000522 0.000051 0 ]
同样,你可以看到它稍微有所变动,如下截图所示:
在代码格式中,这也是相同的:
[0 0 0 0 0 0 0 1 0 0]
练习
我们已经从第二章,什么是神经网络以及如何训练它?,扩展了我们的简单示例。此时,尝试一些内容并自行观察结果,以更好地理解您的选择可能产生的影响,将是个好主意。例如,您应该尝试以下所有内容:
-
更改损失函数
-
更改每层的单元数
-
更改层数
-
更改时期数量
-
更改批次大小
建立自编码器 - 生成 MNIST 数字
自动编码器的作用正如其名:它自动学习如何对数据进行编码。通常,自动编码器的目标是训练它自动将数据编码到更少的维度中,或者提取数据中的某些细节或其他有用的信息。它还可用于去除数据中的噪声或压缩数据。
一般来说,自动编码器有两部分:编码器和解码器。我们倾向于同时训练这两部分,目标是使解码器的输出尽可能接近我们的输入。
层
就像之前一样,我们需要考虑我们的输入和输出。我们再次使用 MNIST,因为编码数字是一个有用的特征。因此,我们知道我们的输入是 784 像素,我们也知道我们的输出也必须有 784 像素。
由于我们已经有了将输入和输出解码为张量的辅助函数,我们可以直接转向我们的神经网络。我们的网络如下:
我们可以重复使用上一个示例中的大部分代码,只需更改我们的层次结构:
func newNN(g *gorgonia.ExprGraph) *nn {
// Create node for w/weight
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 128), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 64), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w2 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(64, 128), gorgonia.WithName("w2"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w3 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 784), gorgonia.WithName("w3"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
return &nn{
g: g,
w0: w0,
w1: w1,
w2: w2,
w3: w3,
}
}
但是,这次我们不会使用 ReLU 激活函数,因为我们知道我们的输出必须是 0 和 1。我们使用Sigmoid
激活函数,因为这给了我们一个方便的输出。正如您在以下代码块中看到的,虽然我们在每一层都使用它,但你也可以在除了最后一层以外的每个地方都使用 ReLU 激活函数,因为理想情况下,输出层应该被限制在0
到1
之间:
func (m *nn) fwd(x *gorgonia.Node) (err error) {
var l0, l1, l2, l3, l4 *gorgonia.Node
var l0dot, l1dot, l2dot, l3dot *gorgonia.Node
// Set first layer to be copy of input
l0 = x
// Dot product of l0 and w0, use as input for Sigmoid
if l0dot, err = gorgonia.Mul(l0, m.w0); err != nil {
return errors.Wrap(err, "Unable to multiple l0 and w0")
}
l1 = gorgonia.Must(gorgonia.Sigmoid(l0dot))
if l1dot, err = gorgonia.Mul(l1, m.w1); err != nil {
return errors.Wrap(err, "Unable to multiple l1 and w1")
}
l2 = gorgonia.Must(gorgonia.Sigmoid(l1dot))
if l2dot, err = gorgonia.Mul(l2, m.w2); err != nil {
return errors.Wrap(err, "Unable to multiple l2 and w2")
}
l3 = gorgonia.Must(gorgonia.Sigmoid(l2dot))
if l3dot, err = gorgonia.Mul(l3, m.w3); err != nil {
return errors.Wrap(err, "Unable to multiple l3 and w3")
}
l4 = gorgonia.Must(gorgonia.Sigmoid(l3dot))
// m.pred = l3dot
// gorgonia.Read(m.pred, &m.predVal)
// return nil
m.out = l4
gorgonia.Read(l4, &m.predVal)
return
}
训练
与以前一样,我们需要一个用于训练目的的损失函数。自动编码器的输入和输出也是不同的!
损失函数
这次,我们的损失函数不同。我们使用的是均方误差的平均值,其伪代码如下所示:
mse = sum( (actual_y - predicted_y) ^ 2 ) / num_of_y
在 Gorgonia 中可以轻松实现如下:
losses, err := gorgonia.Square(gorgonia.Must(gorgonia.Sub(y, m.out)))
if err != nil {
log.Fatal(err)
}
cost := gorgonia.Must(gorgonia.Mean(losses))
输入和输出
注意,这次我们的输入和输出是相同的。这意味着我们不需要为数据集获取标签,并且在运行虚拟机时,我们可以将x
和y
都设置为我们的输入数据:
gorgonia.Let(x, xVal)
gorgonia.Let(y, xVal)
时期、迭代和批次大小
这个问题要解决起来更难。您会发现,为了了解我们的输出如何改进,这里非常有价值地运行几个时期,因为我们可以在训练过程中编写我们模型的输出,如下代码:
for j := 0; j < 1; j++ {
rowT, _ := yOutput.Slice(sli{j, j + 1})
row := rowT.Data().([]float64)
img := visualizeRow(row)
f, _ := os.OpenFile(fmt.Sprintf("training/%d - %d - %d training.jpg", j, b, i), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
jpeg.Encode(f, img, &jpeg.Options{jpeg.DefaultQuality})
f.Close()
}
在我们训练模型的过程中,我们可以观察到它在每个时期的改善:
你可以看到我们从几乎纯噪声开始,然后很快得到一个模糊的形状,随着时期的推移,形状逐渐变得更清晰。
测试和验证
我们不会详细讨论测试代码,因为我们已经讲过如何从输出中获取图像,但请注意,现在y
也有784
列:
arrayOutput := m.predVal.Data().([]float64)
yOutput := tensor.New(tensor.WithShape(bs, 784), tensor.WithBacking(arrayOutput))
for j := 0; j < yOutput.Shape()[0]; j++ {
rowT, _ := yOutput.Slice(sli{j, j + 1})
row := rowT.Data().([]float64)
img := visualizeRow(row)
f, _ := os.OpenFile(fmt.Sprintf("images/%d - %d output.jpg", b, j), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
jpeg.Encode(f, img, &jpeg.Options{jpeg.DefaultQuality})
f.Close()
}
现在,这里有个有趣的部分:从我们的自动编码器中获取结果:
您会注意到结果与输入图像相比明显不太清晰。然而,它也消除了一些图像中的噪音!
构建类似 Netflix 风格的协同过滤的 RBM
现在我们将探索一种不同的无监督学习技术,例如,能够处理反映特定用户对特定内容喜好的数据。本节将介绍网络架构和概率分布的新概念,以及它们如何在实际推荐系统的实施中使用,特别是用于推荐可能对给定用户感兴趣的电影。
RBM 简介。
从教科书的定义来看,RBM 是概率图模型,这——考虑到我们已经涵盖的神经网络结构——简单地意味着一群神经元之间存在加权连接。
这些网络有两层:一个可见层和一个隐藏层。可见层是您向其中输入数据的层,隐藏层则是不直接暴露于您的数据,但必须为当前任务开发其有意义的表示的层。这些任务包括降维、协同过滤、二元分类等。受限意味着连接不是横向的(即在同一层的节点之间),而是每个隐藏单元与网络层之间的每个可见单元连接。图是无向的,这意味着数据不能固定在一个方向上流动。如下所示:
训练过程相对简单,并与我们的普通神经网络不同,我们不仅进行预测、测试预测的强度,然后反向传播错误通过网络。在我们的 RBM 的情况下,这只是故事的一半。
进一步分解训练过程,RBM 的前向传播如下:
-
可见层节点值乘以连接权重。
-
隐藏单元偏置被添加到结果值的所有节点之和中(强制激活)。
-
激活函数被应用。
-
给定隐藏节点的值(激活概率)。
如果这是一个深度网络,隐藏层的输出将作为另一层的输入传递。这种架构的一个例子是深度置信网络(DBN),这是由 Geoff Hinton 及其在多伦多大学的团队完成的另一项重要工作,它使用多个叠加的 RBM。
然而,我们的 RBM 不是一个深度网络。因此,我们将使用隐藏单元输出做一些与网络的可见单元重构的不同尝试。我们将通过使用隐藏单元作为网络训练的后向或重构阶段的输入来实现这一点。
后向传递看起来与前向传递相似,并通过以下步骤执行:
-
隐藏层激活作为输入乘以连接权重
-
可见单元偏置被添加到所有节点乘积的总和中
-
计算重构误差,或者预测输入与实际输入(我们从前向传递中了解到的)的差异
-
该错误用于更新权重以尽量减少重构误差
两个状态(隐藏层预测激活和可见层预测输入)共同形成联合概率分布。
如果您对数学有兴趣,两个传递的公式如下所示:
- 前向传递:a(隐藏节点激活)的概率给出加权输入x:
p(a|x; w)
- 后向传递:x(可见层输入)的概率给出加权激活a:
p(x|a; w)
- 因此,联合概率分布简单地由以下给出:
p(a, x)
重构因此可以从我们迄今讨论过的技术种类中以不同方式考虑。它既不是回归(为给定的输入集预测连续输出),也不是分类(为给定的输入集应用类标签)。这一点通过我们在重构阶段计算错误的方式来清楚地表现出来。我们不仅仅测量输入与预测输入之间的实数差异(输出的差异);相反,我们比较所有值的概率分布的x输入与重建输入的所有值。我们使用一种称为Kullback-Leibler 散度的方法执行此比较。本质上,这种方法测量每个概率分布曲线下不重叠的面积。然后,我们尝试通过权重调整和重新运行训练循环来减少这种散度(误差),从而使曲线更接近,如下图所示:
在训练结束时,当这种错误被最小化时,我们可以预测给定用户可能会喜欢哪些其他电影。
RBMs 用于协同过滤
如本节介绍所讨论的,RBM 可以在多种情况下,无论是监督还是非监督方式下使用。协同过滤是一种预测用户偏好的策略,其基本假设是如果用户A喜欢物品Z,并且用户B也喜欢物品Z,那么用户B可能还喜欢用户A喜欢的其他东西(例如物品Y)。
我们每次看到 Netflix 向我们推荐内容时,或每次亚马逊向我们推荐新吸尘器时(因为我们当然买了一个吸尘器,现在显然对家用电器感兴趣)都可以看到这种用例。
现在我们已经涵盖了 RBM 是什么、它们如何工作以及它们如何使用的一些理论知识,让我们开始构建一个吧!
准备我们的数据 – GroupLens 电影评分
我们正在使用 GroupLens 数据集。它包含了从 MovieLens(www.movielens.org
)收集的用户、电影和评分的集合,并由明尼苏达大学的多位学术研究人员管理。
我们需要解析 ratings.dat
文件,该文件使用冒号作为分隔符,以获取 userids
、ratings
和 movieids
。然后,我们可以将 movieids
与 movies.dat
中的电影匹配。
首先,让我们看看我们需要构建电影索引的代码:
package main
import (
// "github.com/aotimme/rbm"
"fmt"
"log"
"math"
"strconv"
"github.com/yunabe/easycsv"
g "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
var datasetfilename string = "dataset/cleanratings.csv"
var movieindexfilename string = "dataset/cleanmovies.csv"
func BuildMovieIndex(input string) map[int]string {
var entrycount int
r := easycsv.NewReaderFile(input, easycsv.Option{
Comma: ',',
})
var entry struct {
Id int `index:"0"`
Title string `index:"1"`
}
//fix hardcode
movieindex := make(map[int]string, 3952)
for r.Read(&entry) {
// fmt.Println(entry)
movieindex[entry.Id] = entry.Title
// entries = append(entries, entry)
entrycount++
}
return movieindex
}
现在,我们编写一个函数来导入原始数据并将其转换为 m x n 矩阵。在此矩阵中,行代表单个用户,列代表他们在数据集中每部电影上的(归一化)评分:
func DataImport(input string) (out [][]int, uniquemovies map[int]int) {
//
// Initial data processing
//
// import from CSV, read into entries var
r := easycsv.NewReaderFile(input, easycsv.Option{
Comma: ',',
})
var entry []int
var entries [][]int
for r.Read(&entry) {
entries = append(entries, entry)
}
// maps for if unique true/false
seenuser := make(map[int]bool)
seenmovie := make(map[int]bool)
// maps for if unique index
uniqueusers := make(map[int]int)
uniquemovies = make(map[int]int)
// counters for uniques
var uniqueuserscount int = 0
var uniquemoviescount int = 0
// distinct movie lists/indices
for _, e := range entries {
if seenmovie[e[1]] == false {
uniquemovies[uniquemoviescount] = e[1]
seenmovie[e[1]] = true
uniquemoviescount++
} else if seenmovie[e[1]] == true {
// fmt.Printf("Seen movie %v before, aborting\n", e[0])
continue
}
}
// distinct user lists/indices
for _, e := range entries {
if seenuser[e[0]] == false {
uniqueusers[uniqueuserscount] = e[0]
seenuser[e[0]] = true
uniqueuserscount++
// uniqueusers[e[0]] =
} else if seenuser[e[0]] == true {
// fmt.Printf("Seen user %v before, aborting\n", e[0])
continue
}
}
uservecs := make([][]int, len(uniqueusers))
for i := range uservecs {
uservecs[i] = make([]int, len(uniquemovies))
}
以下是我们处理 CSV 中每行数据并添加到用户主切片和电影评分子切片的主循环:
var entriesloop int
for _, e := range entries {
// hack - wtf
if entriesloop%100000 == 0 && entriesloop != 0 {
fmt.Printf("Processing rating %v of %v\n", entriesloop, len(entries))
}
if entriesloop > 999866 {
break
}
var currlike int
// normalisze ratings
if e[2] >= 4 {
currlike = 1
} else {
currlike = 0
}
// add to a user's vector of index e[1]/movie num whether current movie is +1
// fmt.Println("Now looping uniquemovies")
for i, v := range uniquemovies {
if v == e[1] {
// fmt.Println("Now setting uservec to currlike")
// uservec[i] = currlike
// fmt.Println("Now adding to uservecs")
uservecs[e[0]][i] = currlike
break
}
}
// fmt.Printf("Processing rating %v of %v\n", entriesloop, len(entries))
entriesloop++
}
// fmt.Println(uservecs)
// os.Exit(1)
// fmt.Println(entry)
if err := r.Done(); err != nil {
log.Fatalf("Failed to read a CSV file: %v", err)
}
// fmt.Printf("length uservecs %v and uservecs.movies %v", len(uservecs))
fmt.Println("Number of unique users: ", len(seenuser))
fmt.Println("Number of unique movies: ", len(seenmovie))
out = uservecs
return
}
在 Gorgonia 中构建 RBM
现在我们清理了数据,创建了训练或测试集,并编写了生成网络输入所需的代码后,我们可以开始编写 RBM 本身的代码。
首先,我们从现在开始使用我们的标准 struct
,这是我们将网络各组件附加到其中的基础架构:
const cdS = 1
type ggRBM struct {
g *ExprGraph
v *Node // visible units
vB *Node // visible unit biases - same size tensor as v
h *Node // hidden units
hB *Node // hidden unit biases - same size tensor as h
w *Node // connection weights
cdSamples int // number of samples for contrastive divergence - WHAT ABOUT MOMENTUM
}
func (m *ggRBM) learnables() Nodes {
return Nodes{m.w, m.vB, m.hB}
}
然后,我们添加附加到我们的 RBM 的辅助函数:
- 首先,我们添加用于我们的
ContrastiveDivergence
学习算法(使用 Gibbs 采样)的func
:
// Uses Gibbs Sampling
func (r *ggRBM) ContrastiveDivergence(input *Node, learnRate float64, k int) {
rows := float64(r.TrainingSize)
// CD-K
phMeans, phSamples := r.SampleHiddenFromVisible(input)
nvSamples := make([]float64, r.Inputs)
// iteration 0
_, nvSamples, nhMeans, nhSamples := r.Gibbs(phSamples, nvSamples)
for step := 1; step < k; step++ {
/*nvMeans*/ _, nvSamples, nhMeans, nhSamples = r.Gibbs(nhSamples, nvSamples)
}
// Update weights
for i := 0; i < r.Outputs; i++ {
for j := 0; j < r.Inputs; j++ {
r.Weights[i][j] += learnRate * (phMeans[i]*input[j] - nhMeans[i]*nvSamples[j]) / rows
}
r.Biases[i] += learnRate * (phSamples[i] - nhMeans[i]) / rows
}
// update hidden biases
for j := 0; j < r.Inputs; j++ {
r.VisibleBiases[j] += learnRate * (input[j] - nvSamples[j]) / rows
}
}
- 现在,我们添加了函数来采样我们的可见层或隐藏层:
func (r *ggRBM) SampleHiddenFromVisible(vInput *Node) (means []float64, samples []float64) {
means = make([]float64, r.Outputs)
samples = make([]float64, r.Outputs)
for i := 0; i < r.Outputs; i++ {
mean := r.PropagateUp(vInput, r.Weights[i], r.Biases[i])
samples[i] = float64(binomial(1, mean))
means[i] = mean
}
return means, samples
}
func (r *ggRBM) SampleVisibleFromHidden(hInput *Node) (means []float64, samples []float64) {
means = make([]float64, r.Inputs)
samples = make([]float64, r.Inputs)
for j := 0; j < r.Inputs; j++ {
mean := r.PropagateDown(hInput, j, r.VisibleBiases[j])
samples[j] = float64(binomial(1, mean))
means[j] = mean
}
return means, samples
}
- 接着,我们添加几个处理权重更新传播的函数:
func (r *ggRBM) PropagateDown(h *Node, j int, hB *Node) *Node {
retVal := 0.0
for i := 0; i < r.Outputs; i++ {
retVal += r.Weights[i][j] * h0[i]
}
retVal += bias
return sigmoid(retVal)
}
func (r *ggRBM) PropagateUp(v *Node, w *Node, vB *Node) float64 {
retVal := 0.0
for j := 0; j < r.Inputs; j++ {
retVal += weights[j] * v0[j]
}
retVal += bias
return sigmoid(retVal)
}
- 现在,我们添加了 Gibbs 采样的函数(与我们之前使用的
ContrastiveDivergence
函数相同),以及执行网络重构步骤的函数:
func (r *ggRBM) Gibbs(h, v *Node) (vMeans []float64, vSamples []float64, hMeans []float64, hSamples []float64) {
vMeans, vSamples = r.SampleVisibleFromHidden(r.h)
hMeans, hSamples = r.SampleHiddenFromVisible(r.v)
return
}
func (r *ggRBM) Reconstruct(x *Node) *Node {
hiddenLayer := make([]float64, r.Outputs)
retVal := make([]float64, r.Inputs)
for i := 0; i < r.Outputs; i++ {
hiddenLayer[i] = r.PropagateUp(x, r.Weights[i], r.Biases[i])
}
for j := 0; j < r.Inputs; j++ {
activated := 0.0
for i := 0; i < r.Outputs; i++ {
activated += r.Weights[i][j] * hiddenLayer[i]
}
activated += r.VisibleBiases[j]
retVal[j] = sigmoid(activated)
}
return retVal
}
- 接下来,我们添加实例化我们的 RBM 的函数:
func newggRBM(g *ExprGraph, cdS int) *ggRBM {
vT := tensor.New(tensor.WithBacking(tensor.Random(tensor.Int, 3952)), tensor.WithShape(3952, 1))
v := NewMatrix(g,
tensor.Int,
WithName("v"),
WithShape(3952, 1),
WithValue(vT),
)
hT := tensor.New(tensor.WithBacking(tensor.Random(tensor.Int, 200)), tensor.WithShape(200, 1))
h := NewMatrix(g,
tensor.Int,
WithName("h"),
WithShape(200, 1),
WithValue(hT),
)
wB := tensor.Random(tensor.Float64, 3952*200)
wT := tensor.New(tensor.WithBacking(wB), tensor.WithShape(3952*200, 1))
w := NewMatrix(g,
tensor.Float64,
WithName("w"),
WithShape(3952*200, 1),
WithValue(wT),
)
return &ggRBM{
g: g,
v: v,
h: h,
w: w,
// hB: hB,
// vB: vB,
cdSamples: cdS,
}
}
- 最后,我们训练模型:
func main() {
g := NewGraph()
m := newggRBM(g, cdS)
data, err := ReadDataFile(datasetfilename)
if err != nil {
log.Fatal(err)
}
fmt.Println("Data read from CSV: \n", data)
vm := NewTapeMachine(g, BindDualValues(m.learnables()...))
// solver := NewVanillaSolver(WithLearnRate(1.0))
for i := 0; i < 1; i++ {
if vm.RunAll() != nil {
log.Fatal(err)
}
}
}
在执行代码之前,我们需要对数据进行一些预处理。这是因为我们数据集中使用的分隔符是 ::
,但我们希望将其更改为 ,
。本章节的存储库包含在文件夹根目录中的 preprocess.sh
,它会为我们执行以下操作:
#!/bin/bash
export LC_CTYPE=C
export LANG=C
cat ratings.dat | sed 's/::/,/g' > cleanratings.csv
cat movies.dat | sed 's/,//g; s/::/,/g' > cleanmovies.csv
现在我们的数据格式化得很好,让我们执行 RBM 的代码并观察输出如下:
在这里,我们看到我们的数据导入函数正在处理评分和电影索引文件,并构建每个用户向量的长度为 3706
的用户评分(归一化为 0
/1
):
训练阶段完成后(这里设置为 1,000 次迭代),RBM 会为随机选择的用户生成一组推荐。
现在,您可以尝试不同的超参数,并尝试输入您自己的数据!
总结
在本章中,我们学习了如何构建一个简单的多层神经网络和自编码器。我们还探讨了概率图模型 RBM 的设计和实现,以无监督方式创建电影推荐引擎。
强烈建议您尝试将这些模型和架构应用于其他数据片段,以查看它们的表现如何。
在下一章中,我们将看一下深度学习的硬件方面,以及 CPU 和 GPU 如何确切地满足我们的计算需求。
进一步阅读
-
协同过滤的限制玻尔兹曼机,这是多伦多大学研究小组的原始论文,可以在
www.cs.toronto.edu/~rsalakhu/papers/rbmcf.pdf
查阅。 -
限制玻尔兹曼机模拟人类选择,这篇论文探讨了限制玻尔兹曼机在建模人类选择(例如我们例子中对某种类型电影的偏好)方面的有效性,并提出了在心理学等其他领域的应用,可在😕/papers.nips.cc/paper/5280-restricted-boltzmann-machines-modeling-human-choice.pdf查阅。
第四章:CUDA - GPU 加速训练
本章将探讨深度学习的硬件方面。首先,我们将看看 CPU 和 GPU 在构建深度神经网络(DNNs)时如何满足我们的计算需求,它们之间的区别以及它们的优势在哪里。GPU 提供的性能改进是深度学习成功的核心。
我们将学习如何让 Gorgonia 与我们的 GPU 配合工作,以及如何利用CUDA来加速我们的 Gorgonia 模型:这是 NVIDIA 的软件库,用于简化构建和执行 GPU 加速深度学习模型。我们还将学习如何构建一个使用 GPU 加速操作的模型,并对比这些模型与 CPU 对应物的性能来确定不同任务的最佳选择。
本章将涵盖以下主题:
-
CPU 与 GPU 对比
-
理解 Gorgonia 和 CUDA
-
使用 CUDA 在 Gorgonia 中构建模型
-
用于训练和推理的 CPU 与 GPU 模型的性能基准测试
CPU 与 GPU 对比
到目前为止,我们已经涵盖了神经网络的基本理论和实践,但我们还没有多考虑运行它们的处理器。因此,让我们暂停编码,更深入地讨论实际执行工作的这些小小硅片。
从 30,000 英尺高空看,CPU 最初是为了支持标量操作而设计的,这些操作是按顺序执行的,而 GPU 则设计用于支持向量操作,这些操作是并行执行的。神经网络在每个层内执行大量的独立计算(比如,每个神经元乘以它的权重),因此它们是适合于偏向大规模并行的芯片设计的处理工作负载。
让我们通过一个示例来具体说明一下,这种类型的操作如何利用每种性能特征。拿两个行向量 [1, 2, 3] 和 [4, 5, 6] 作为例子,如果我们对它们进行逐元素矩阵乘法,看起来会像这样:
CPU, 2ns per operation (higher per-core clock than GPU, fewer cores):
1 * 4
2 * 5
3 * 6
= [4, 10, 18]
Time taken: 6ns
GPU, 4ns per operation (lower per-core clock than CPU, more cores):
1 * 4 | 2 * 5 | 3 *6
= [4, 10, 18]
Time taken: 4ns
如你所见,CPU 是按顺序执行计算,而 GPU 是并行执行的。这导致 GPU 完成计算所需的时间比 CPU 少。这是我们在处理与 DNN 相关的工作负载时关心的两种处理器之间的基本差异。
计算工作负载和芯片设计
这种差异如何在处理器的实际设计中体现?这张图表,摘自 NVIDIA 自己的 CUDA 文档,说明了这些差异:
控制或缓存单元减少,而核心或 ALUs 数量显著增加。这导致性能提升一个数量级(或更多)。与此相关的警告是,相对于内存、计算和功耗,GPU 的效率远非完美。这就是为什么许多公司正在竞相设计一个从头开始为 DNN 工作负载优化缓存单元/ALUs 比例,并改善数据被拉入内存然后供给计算单元的方式的处理器。目前,内存在 GPU 中是一个瓶颈,如下图所示:
只有当 ALUs 有东西可以处理时,它们才能工作。如果我们用尽了芯片上的内存,我们必须去 L2 缓存,这在 GPU 中比在 CPU 中更快,但访问芯片内 L1 内存仍然比访问芯片外 L2 缓存要慢得多。我们将在后面的章节中讨论这些缺陷,以及新的和竞争性的芯片设计的背景。目前,理解的重要事情是,理想情况下,我们希望在芯片中尽可能塞入尽可能多的 ALUs 和尽可能多的芯片内缓存,以正确的比例,并且在处理器和它们的内存之间进行快速通信。对于这个过程,CPU 确实工作,但 GPU 更好得多。而且目前,它们是广泛面向消费者的最适合机器学习的硬件。
GPU 中的内存访问
现在,你可能已经清楚,当我们把深度学习的工作负载卸载到我们的处理器时,快速和本地的内存是性能的关键。然而,重要的不仅仅是内存的数量和接近程度,还有这些内存的访问方式。想象一下硬盘上的顺序访问与随机访问性能,原则是相同的。
为什么对 DNNs 这么重要?简单来说,它们是高维结构,最终需要嵌入到供给我们 ALUs 的内存的一维空间中。现代(向量)GPU,专为图形工作负载而建,假设它们将访问相邻的内存,即一个 3D 场景的一部分将存储在相关部分旁边(帧中相邻像素)。因此,它们对这种假设进行了优化。我们的网络不是 3D 场景。它们的数据布局是稀疏的,依赖于网络(及其反过来的图)结构和它们所持有的信息。
下图代表了这些不同工作负载的内存访问模式:
对于深度神经网络(DNNs),当我们编写操作时,我们希望尽可能接近跨距(Strided)内存访问模式。毕竟,在 DNNs 中,矩阵乘法是比较常见的操作之一。
实际性能
为了真实体验实际性能差异,让我们比较适合神经网络工作负载之一的 CPU,即 Intel Xeon Phi,与 2015 年的 NVIDIA Maxwell GPU。
Intel Xeon Phi CPU
这里有一些硬性能数字:
-
该芯片的计算单元每秒可达 2,400 Gflops,并从 DRAM 中提取 88 Gwords/sec,比率为 27/1
-
这意味着每次从内存中提取的字,有 27 次浮点操作
NVIDIA 的 Maxwell GPU
现在,这是参考 NVIDIA GPU 的数字。特别注意比率的变化:
-
6,100 Gflops/sec
-
84 Gwords/sec
-
比率为 72/1
因此,就每块内存中的原始操作而言,GPU 具有明显的优势。
当然,深入微处理器设计的细节超出了本书的范围,但思考处理器内存和计算单元的分布是有用的。现代芯片的设计理念可以总结为尽可能多地将浮点单位集成到芯片上,以实现最大的计算能力相对于所需的功耗/产生的热量。
思想是尽可能保持这些算术逻辑单元(ALUs)处于完整状态,从而最小化它们空闲时的时间。
了解 Gorgonia 和 CUDA
在我们深入介绍 Gorgonia 如何与 CUDA 协作之前,让我们快速介绍一下 CUDA 及其背景。
CUDA
CUDA 是 NVIDIA 的 GPU 编程语言。这意味着您的 AMD 卡不支持 CUDA。在不断发展的深度学习库、语言和工具的景观中,它是事实上的标准。C 实现是免费提供的,但当然,它仅与 NVIDIA 自家的硬件兼容。
基础线性代数子程序
正如我们迄今所建立的网络中所看到的,张量操作对机器学习至关重要。GPU 专为这些类型的向量或矩阵操作设计,但我们的软件也需要设计以利用这些优化。这就是BLAS的作用!
BLAS 提供了线性代数操作的基本组成部分,通常在图形编程和机器学习中广泛使用。BLAS 库是低级的,最初用 Fortran 编写,将其提供的功能分为三个级别,根据涵盖的操作类型定义如下:
-
Level 1:对步进数组的向量操作,点积,向量范数和广义向量加法
-
Level 2:广义矩阵-向量乘法,解决包含上三角矩阵的线性方程
-
Level 3:矩阵操作,包括广义矩阵乘法(GEMM)
Level 3 操作是我们在深度学习中真正感兴趣的。以下是 Gorgonia 中 CUDA 优化卷积操作的示例。
Gorgonia 中的 CUDA
Gorgonia 已经实现了对 NVIDIA 的 CUDA 的支持,作为其cu
包的一部分。它几乎隐藏了所有的复杂性,因此我们在构建时只需简单地指定--tags=cuda
标志,并确保我们调用的操作实际上存在于 Gorgonia 的 API 中。
当然,并非所有可能的操作都实现了。重点是那些从并行执行中获益并适合 GPU 加速的操作。正如我们将在第五章中介绍的,使用递归神经网络进行下一个词预测,许多与卷积神经网络(CNNs)相关的操作符合这一标准。
那么,有哪些可用的呢?以下列表概述了选项:
-
1D 或 2D 卷积(在 CNN 中使用)
-
2D 最大池化(也用于 CNN 中!)
-
Dropout(杀死一些神经元!)
-
ReLU(回顾第二章,什么是神经网络及其训练方式?中的激活函数)
-
批标准化
现在,我们将依次查看每个的实现。
查看 gorgonia/ops/nn/api_cuda.go
,我们可以看到以下形式的 2D 卷积函数:
func Conv2d(im, filter *G.Node, kernelShape tensor.Shape, pad, stride, dilation []int) (retVal *G.Node, err error) {
var op *convolution
if op, err = makeConvolutionOp(im, filter, kernelShape, pad, stride, dilation); err != nil {
return nil, err
}
return G.ApplyOp(op, im, filter)
}
下面的 1D 卷积函数返回一个 Conv2d()
实例,这是一种提供两种选项的简洁方法:
func Conv1d(in, filter *G.Node, kernel, pad, stride, dilation int) (*G.Node, error) {
return Conv2d(in, filter, tensor.Shape{1, kernel}, []int{0, pad}, []int{1, stride}, []int{1, dilation})
}
接下来是 MaxPool2D()
函数。在 CNN 中,最大池化层是特征提取过程的一部分。输入的维度被减少,然后传递给后续的卷积层。
在这里,我们创建了一个带有 XY
参数的 MaxPool
实例,并返回在我们的输入节点上运行 ApplyOp()
的结果,如以下代码所示:
func MaxPool2D(x *G.Node, kernel tensor.Shape, pad, stride []int) (retVal *G.Node, err error) {
var op *maxpool
if op, err = newMaxPoolOp(x, kernel, pad, stride); err != nil {
return nil, err
}
return G.ApplyOp(op, x)
}
Dropout()
是一种正则化技术,用于防止网络过拟合。我们希望尽可能学习输入数据的最一般表示,而丢失功能可以帮助我们实现这一目标。
Dropout()
的结构现在应该已经很熟悉了。它是另一种在层内可以并行化的操作,如下所示:
func Dropout(x *G.Node, prob float64) (retVal *G.Node, err error) {
var op *dropout
if op, err = newDropout(x, prob); err != nil {
return nil, err
}
// states := &scratchOp{x.Shape().Clone(), x.Dtype(), ""}
// m := G.NewUniqueNode(G.WithType(x.Type()), G.WithOp(states), G.In(x.Graph()), G.WithShape(states.shape...))
retVal, err = G.ApplyOp(op, x)
return
}
我们在第二章中介绍的标准 ReLU 函数也是可用的,如下所示:
func Rectify(x *G.Node) (retVal *G.Node, err error) {
var op *activation
if op, err = newRelu(); err != nil {
return nil, err
}
retVal, err = G.ApplyOp(op, x)
return
}
BatchNorm()
稍微复杂一些。回顾一下由 Szegedy 和 Ioffe(2015)描述批标准化的原始论文,我们看到对于给定的批次,我们通过减去批次的均值并除以标准差来对前一层的输出进行归一化。我们还可以观察到添加了两个参数,这些参数将通过 SGD 进行训练。
现在,我们可以看到 CUDA 化的 Gorgonia 实现如下。首先,让我们执行函数定义和数据类型检查:
func BatchNorm(x, scale, bias *G.Node, momentum, epsilon float64) (retVal, γ, β *G.Node, op *BatchNormOp, err error) {
dt, err := dtypeOf(x.Type())
if err != nil {
return nil, nil, nil, nil, err
}
然后,需要创建一些临时变量,以允许虚拟机分配额外的内存:
channels := x.Shape()[1]
H, W := x.Shape()[2], x.Shape()[3]
scratchShape := tensor.Shape{1, channels, H, W}
meanScratch := &gpuScratchOp{scratchOp{x.Shape().Clone(), dt, "mean"}}
varianceScratch := &gpuScratchOp{scratchOp{x.Shape().Clone(), dt, "variance"}}
cacheMeanScratch := &gpuScratchOp{scratchOp{scratchShape, dt, "cacheMean"}}
cacheVarianceScratch := &gpuScratchOp{scratchOp{scratchShape, dt, "cacheVariance"}}
然后,我们在计算图中创建等效的变量:
g := x.Graph()
dims := len(x.Shape())
mean := G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithName(x.Name()+"_mean"), G.WithOp(meanScratch))
variance := G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithName(x.Name()+"_variance"), G.WithOp(varianceScratch))
cacheMean := G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithOp(cacheMeanScratch))
cacheVariance := G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithOp(cacheVarianceScratch))
然后,在应用函数并返回结果之前,我们在图中创建了我们的比例和偏差变量:
if scale == nil {
scale = G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithName(x.Name()+"_γ"), G.WithInit(G.GlorotN(1.0)))
}
if bias == nil {
bias = G.NewTensor(g, dt, dims, G.WithShape(scratchShape.Clone()...), G.WithName(x.Name()+"_β"), G.WithInit(G.GlorotN(1.0)))
}
op = newBatchNormOp(momentum, epsilon)
retVal, err = G.ApplyOp(op, x, scale, bias, mean, variance, cacheMean, cacheVariance)
return retVal, scale, bias, op, err
接下来,让我们看看如何在 Gorgonia 中构建利用 CUDA 的模型。
在 Gorgonia 中构建支持 CUDA 的模型
在支持 CUDA 的 Gorgonia 中构建一个模型之前,我们需要先做几件事情。我们需要安装 Gorgonia 的 cu
接口到 CUDA,并且准备好一个可以训练的模型!
为 Gorgonia 安装 CUDA 支持
要使用 CUDA,您需要一台配有 NVIDIA GPU 的计算机。不幸的是,将 CUDA 设置为与 Gorgonia 配合使用是一个稍微复杂的过程,因为它涉及设置能够与 Go 配合使用的 C 编译环境,以及能够与 CUDA 配合使用的 C 编译环境。NVIDIA 已经确保其编译器与每个平台的常用工具链兼容:在 Windows 上是 Visual Studio,在 macOS 上是 Clang-LLVM,在 Linux 上是 GCC。
安装 CUDA 并确保一切正常运行需要一些工作。我们将介绍如何在 Windows 和 Linux 上完成此操作。由于截至撰写本文时,Apple 已经多年未推出配备 NVIDIA GPU 的计算机,因此我们不会介绍如何在 macOS 上执行此操作。您仍然可以通过将外部 GPU 连接到您的 macOS 上来使用 CUDA,但这是一个相当复杂的过程,并且截至撰写本文时,Apple 尚未正式支持使用 NVIDIA GPU 的设置。
Linux
正如我们讨论过的,一旦 CUDA 设置好了,只需在构建 Gorgonia 代码时添加-tags=cuda
就可以简单地在 GPU 上运行它。但是如何达到这一点呢?让我们看看。
此指南要求您安装标准的 Ubuntu 18.04。NVIDIA 提供了独立于发行版的安装说明(以及故障排除步骤):docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html
。
在高层次上,您需要安装以下软件包:
-
NVIDIA 驱动
-
CUDA
-
cuDNN
-
libcupti-dev
首先,您需要确保安装了 NVIDIA 的专有(而不是开源默认)驱动程序。快速检查是否运行了它的方法是执行nvidia-smi
。您应该看到类似以下内容的输出,指示驱动程序版本号和关于您的 GPU 的其他详细信息:
如果出现command not found
错误,则有几种选择,这取决于您所运行的 Linux 发行版。最新的 Ubuntu 发行版允许您从默认存储库安装大部分 CUDA 依赖项(包括专有的 NVIDIA 驱动程序)。可以通过执行以下命令完成此操作:
sudo apt install nvidia-390 nvidia-cuda-toolkit libcupti-dev
或者,您可以按照官方 NVIDIA 指南中的步骤手动安装各种依赖项。
安装完成并重新启动系统后,请再次运行nvidia-smi
确认驱动程序已安装。您还需要验证 CUDA C 编译器(nvidia-cuda-toolkit
包的一部分)是否已安装,方法是执行nvcc --version
。输出应该类似于以下内容:
安装了 CUDA 之后,还需要执行一些额外的步骤,以确保 Gorgonia 已经编译并准备好使用必要的 CUDA 库:
- 确保你正在构建的模块的目标目录存在。 如果不存在,请使用以下命令创建它:
mkdir $GOPATH/src/gorgonia.org/gorgonia/cuda\ modules/target
- 运行
cudagen
来按如下方式构建模块:
cd $GOPATH/src/gorgonia.org/gorgonia/cmd/cudagen
go run main.go
- 程序执行后,请验证
/target
目录是否填充了表示我们在构建网络时将使用的 CUDA 化操作的文件,如下截图所示:
- 现在初步工作已完成,让我们使用以下命令测试一切是否正常:
go install gorgonia.org/cu/cmd/cudatest cudatest
cd $GOPATH/src/gorgonia.org/cu/cmd/cudatest
go run main.go
你应该看到类似以下的输出:
现在你已经准备好利用 GPU 提供的所有计算能力了!
Windows
Windows 的设置非常类似,但你还需要提供适用于 Go 和 CUDA 的 C 编译器。 这个设置在以下步骤中详细说明:
-
安装 GCC 环境;在 Windows 上做到这一点的最简单方法是安装 MSYS2。 你可以从
www.msys2.org/
下载 MSYS2。 -
在安装 MSYS2 后,使用以下命令更新你的安装:
pacman -Syu
- 重新启动 MSYS2 并再次运行以下命令:
pacman -Su
- 安装 GCC 包如下:
pacman -S mingw-w64-x86_64-toolchain
-
安装 Visual Studio 2017 以获取与 CUDA 兼容的编译器。 在撰写本文时,你可以从
visualstudio.microsoft.com/downloads/
下载此软件。 社区版工作正常;如果你有其他版本的许可证,它们也可以使用。 -
安装 CUDA。 你可以从 NVIDIA 网站下载此软件:
developer.nvidia.com/cuda-downloads
。 根据我的经验,如果无法使网络安装程序工作,请尝试本地安装程序。 -
然后,你还应该从 NVIDIA 安装 cuDNN:
developer.nvidia.com/cudnn
。 安装过程是简单的复制粘贴操作,非常简单。 -
设置环境变量,以便 Go 和 NVIDIA CUDA 编译器驱动程序 (
nvcc
) 知道如何找到相关的编译器。 你应该根据需要替换 CUDA、MSYS2 和 Visual Studio 安装的位置。 你需要添加的内容和相关变量名如下:
C_INCLUDE_PATH
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.2\include
LIBRARY_PATH
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.2\lib\x64
PATH
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.2\bin
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.2\libnvvp
C:\msys64\mingw64\bin
C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin\x86_amd64
- 环境现在应该正确设置,以编译支持 CUDA 的 Go 二进制文件。
现在,为了 Gorgonia,你需要首先按以下步骤进行一些操作:
- 首先确保为你将要构建的模块存在以下
target
目录:
$GOPATH/src/gorgonia.org/gorgonia/cuda\ modules/target
- 然后运行
cudagen
来按如下方式构建模块:
cd $GOPATH/src/gorgonia.org/gorgonia/cmd/cudagen
go run main.go
- 现在你已经安装好
cudatest
,如下所示:
go install gorgonia.org/cu/cmd/cudatest cudatest
- 如果现在运行
cudatest
,并且一切正常,你将得到类似以下的输出:
CUDA version: 9020
CUDA devices: 1
Device 0
========
Name : "GeForce GTX 1080"
Clock Rate: 1835000 kHz
Memory : 8589934592 bytes
Compute : 6.1
为训练和推理的 CPU 对 GPU 模型的性能基准测试
现在我们已经完成了所有这些工作,让我们探索使用 GPU 进行深度学习的一些优势。首先,让我们详细了解如何使你的应用程序实际使用 CUDA,然后我们将详细介绍一些 CPU 和 GPU 的速度。
如何使用 CUDA
如果你已经完成了所有前面的步骤来使 CUDA 工作,那么使用 CUDA 是一个相当简单的事情。你只需使用以下内容编译你的应用程序:
go build -tags='cuda'
这样构建你的可执行文件就支持 CUDA,并使用 CUDA 来运行你的深度学习模型,而不是 CPU。
为了说明,让我们使用一个我们已经熟悉的例子 – 带有权重的神经网络:
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 300), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(300, 100), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
w2 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(100, 10), gorgonia.WithName("w2"), gorgonia.WithInit(gorgonia.GlorotN(1.0)))
这只是我们简单的前馈神经网络,我们构建它来在 MNIST 数据集上使用。
CPU 结果
通过运行代码,我们得到的输出告诉我们每个 epoch 开始的时间,以及上次执行时我们的成本函数值大约是多少。对于这个特定的任务,我们只运行了 10 个 epochs,结果如下所示:
2018/07/21 23:48:45 Batches 600
2018/07/21 23:49:12 Epoch 0 | cost -0.6898460176511779
2018/07/21 23:49:38 Epoch 1 | cost -0.6901109698353116
2018/07/21 23:50:05 Epoch 2 | cost -0.6901978951202982
2018/07/21 23:50:32 Epoch 3 | cost -0.6902410983814113
2018/07/21 23:50:58 Epoch 4 | cost -0.6902669350941992
2018/07/21 23:51:25 Epoch 5 | cost -0.6902841232197489
2018/07/21 23:51:52 Epoch 6 | cost -0.6902963825164774
2018/07/21 23:52:19 Epoch 7 | cost -0.6903055672849466
2018/07/21 23:52:46 Epoch 8 | cost -0.6903127053988457
2018/07/21 23:53:13 Epoch 9 | cost -0.690318412509433
2018/07/21 23:53:13 Run Tests
2018/07/21 23:53:19 Epoch Test | cost -0.6887220522190024
我们可以看到在这个 CPU 上,每个 epoch 大约需要 26–27 秒,这是一台 Intel Core i7-2700K。
GPU 结果
我们可以对可执行文件的 GPU 构建执行相同的操作。这使我们能够比较每个 epoch 训练模型所需的时间。由于我们的模型并不复杂,我们不指望看到太大的差异:
2018/07/21 23:54:31 Using CUDA build
2018/07/21 23:54:32 Batches 600
2018/07/21 23:54:56 Epoch 0 | cost -0.6914807096357707
2018/07/21 23:55:19 Epoch 1 | cost -0.6917470871356043
2018/07/21 23:55:42 Epoch 2 | cost -0.6918343739257966
2018/07/21 23:56:05 Epoch 3 | cost -0.6918777292080605
2018/07/21 23:56:29 Epoch 4 | cost -0.6919036464362168
2018/07/21 23:56:52 Epoch 5 | cost -0.69192088335746
2018/07/21 23:57:15 Epoch 6 | cost -0.6919331749749763
2018/07/21 23:57:39 Epoch 7 | cost -0.691942382545885
2018/07/21 23:58:02 Epoch 8 | cost -0.6919495375223687
2018/07/21 23:58:26 Epoch 9 | cost -0.691955257565567
2018/07/21 23:58:26 Run Tests
2018/07/21 23:58:32 Epoch Test | cost -0.6896057773382677
在这个 GPU(一台 NVIDIA Geforce GTX960)上,我们可以看到对于这个简单的任务,速度稍快一些,大约在 23–24 秒之间。
摘要
在这一章中,我们看了深度学习的硬件方面。我们还看了 CPU 和 GPU 如何满足我们的计算需求。我们还看了 CUDA 如何在 Gorgonia 中实现 GPU 加速的深度学习,最后,我们看了如何构建一个使用 CUDA Gorgonia 实现特性的模型。
在下一章中,我们将探讨基本的 RNN 和与 RNN 相关的问题。我们还将学习如何在 Gorgonia 中构建 LSTM 模型。
第二部分:实现深度神经网络架构
本节的目标是让读者了解如何实现深度神经网络架构。
本节包括以下章节:
-
第五章,使用循环神经网络进行下一个单词预测
-
第六章,使用卷积神经网络进行对象识别
-
第七章,使用深度 Q 网络解决迷宫问题
-
第八章,使用变分自编码器生成模型
第五章:使用循环神经网络进行下一个单词预测
到目前为止,我们已经涵盖了许多基本的神经网络架构及其学习算法。这些是设计能够处理更高级任务的网络的必要构建模块,例如机器翻译、语音识别、时间序列预测和图像分割。在本章中,我们将涵盖一类由于其能够模拟数据中的序列依赖性而在这些及其他任务上表现出色的算法/架构。
这些算法已被证明具有极强的能力,它们的变体在工业和消费者应用案例中得到广泛应用。这涵盖了机器翻译、文本生成、命名实体识别和传感器数据分析的方方面面。当你说“好的,谷歌!”或“嘿,Siri!”时,在幕后,一种训练有素的循环神经网络(RNN)正在进行推断。所有这些应用的共同主题是,这些序列(如时间x处的传感器数据,或语料库中位置y处的单词出现)都可以以时间作为它们的调节维度进行建模。正如我们将看到的那样,我们可以根据需要表达我们的数据并结构化我们的张量。
一个很好的例子是自然语言处理和理解这样的困难问题。如果我们有一个大量的文本,比如莎士比亚的作品集,我们能对这个文本说些什么?我们可以详细说明文本的统计属性,即有多少个单词,其中多少个单词是独特的,总字符数等等,但我们也从阅读的经验中固有地知道文本/语言的一个重要属性是顺序;即单词出现的顺序。这个顺序对语法和语言的理解有贡献,更不用说意义本身了。正是在分析这类数据时,我们迄今涵盖的网络存在不足之处。
在本章中,我们将学习以下主题:
-
什么是基本的 RNN
-
如何训练 RNN
-
改进的 RNN 架构,包括门控循环单元(GRU)/长短期记忆(LSTM)网络
-
如何在 Gorgonia 中使用 LSTM 单元实现 RNN
原始的 RNN
根据其更乌托邦的描述,RNN 能够做到迄今为止我们所涵盖的网络所不能的事情:记忆。更确切地说,在一个单隐藏层的简单网络中,网络的输出以及隐藏层的状态与训练序列中的下一个元素结合,形成新网络的输入(具有自己的可训练隐藏状态)。原始 RNN 可以如下所示:
让我们深入了解一下。在前面的图示中,两个网络是同一事物的两种不同表示。一个处于展开状态,这只是一个计算图的抽象表示,在这里无限数量的时间步骤被表示为(t)。然后,当我们提供网络数据并训练它时,我们使用展开的 RNN。
对于给定的前向传播,这个网络接受两个输入,其中X是一个训练数据片段的表示,以及前一个隐藏状态S(在t0初始化为零向量),以及一个时间步t(序列中的位置)重复操作(输入的向量连接,即Sigmoid激活)在这些输入及其可训练参数的乘积上。然后,我们应用我们的学习算法,在反向传播上稍作调整,我们将在接下来介绍,因此我们对 RNN 是什么、由什么组成以及它是如何工作的有了基本模型。
训练 RNNs
我们训练这些网络的方式是使用通过时间的反向传播(BPTT)。这是一个对您已知的东西稍作变化的名字。在第二章中,我们将详细探讨这个变化。
通过时间反向传播
对于 RNNs,我们有多个相同网络的副本,每个时间步都有一个。因此,我们需要一种方法来反向传播误差导数,并计算每个时间步的参数的权重更新。我们做法很简单。我们沿着函数的轮廓进行,这样我们可以尝试优化其形状。我们有多个可训练参数的副本,每个时间步都有一个,并且我们希望这些副本彼此一致,以便在计算给定参数的所有梯度时,我们取它们的平均值。我们用这个来更新每次学习过程的t0处的参数。
目标是计算随着时间步骤累积的误差,并展开/收拢网络并相应地更新权重。当然,这是有计算成本的;也就是说,所需的计算量随着时间步骤的增加而增加。处理这个问题的方法是截断(因此,截断 BPTT)输入/输出对的序列,这意味着我们一次只展开/收拢 20 个时间步,使问题可处理。
对于那些有兴趣探索背后数学的进一步信息,可以在本章的进一步阅读部分找到。
成本函数
我们在 RNNs 中使用的成本函数是交叉熵损失。在实现上,与简单的二分类任务相比,并没有什么特别之处。在这里,我们比较两个概率分布——一个是预测的,一个是期望的。我们计算每个时间步的误差并对它们进行求和。
RNNs 和梯度消失
RNN 本身是一个重要的架构创新,但在梯度消失方面遇到问题。当梯度值变得如此小以至于更新同样微小时,这会减慢甚至停止学习。你的数字神经元死亡,你的网络无法按照你的意愿工作。但是,有一个记忆不好的神经网络是否比没有记忆的神经网络更好呢?
让我们深入讨论当你遇到这个问题时实际发生了什么。回顾计算给定权重值的公式在反向传播时的方法:
W = W - LRG*
在这里,权重值等于权重减去(学习率乘以梯度)。
您的网络在层间和时间步骤之间传播错误导数。您的数据集越大,时间步骤和参数越多,层次也越多。在每一步中,展开的 RNN 包含一个激活函数,将网络输出压缩到 0 到 1 之间。
当梯度值接近于零时,重复这些操作意味着神经元死亡或停止激活。在我们的计算图中,神经元模型的数学表示变得脆弱。这是因为如果我们正在学习的参数变化太小,对网络输出本身没有影响,那么网络将无法学习该参数的值。
因此,在训练过程中,我们的网络在时间步骤中前进时,是否有其他方法可以使网络在选择保留的信息方面更加智能呢?答案是肯定的!让我们考虑一下对网络架构的这些变化。
使用 GRU/LSTM 单元增强你的 RNN
所以,如果你想构建一个像死去的作家一样写作的机器呢?或者理解两周前股票价格的波动可能意味着今天股票将再次波动?对于序列预测任务,在训练早期观察到关键信息,比如在t+1时刻,但在t+250时刻进行准确预测是必要的,传统的 RNN 很难处理。这就是 LSTM(以及对某些任务来说是 GRU)网络发挥作用的地方。不再是简单的单元,而是多个条件迷你神经网络,每个网络决定是否跨时间步骤传递信息。我们现在将详细讨论每种变体。
长短期记忆单元
特别感谢那些发表了一篇名为长短期记忆的论文的瑞士研究人员小组,该论文在 1997 年描述了一种用于进一步增强 RNN 的高级记忆的方法。
那么,在这个背景下,“内存”实际上指的是什么呢?LSTM 将“愚蠢的”RNN 单元与另一个神经网络相结合(由输入、操作和激活组成),后者将选择性地从一个时间步传递到另一个时间步的信息。它通过维护“单元状态”(类似于香草 RNN 单元)和新的隐藏状态来实现这一点,然后将它们都馈入下一个步骤。正如下图中所示的“门”所示,在这个模式中学习有关应该在隐藏状态中维护的信息:
在这里,我们可以看到多个门包含在 r(t),z(t) 和 h(t) 中。每个都有一个激活函数:对于 r 和 z 是 Sigmoid,而对于 h(t) 是 tanh。
门控循环单元
LSTM 单元的替代品是 GRU。这些最初由另一位深度学习历史上的重要人物 Yoshua Bengio 领导的团队首次描述。他们的最初论文《使用 RNN 编码器-解码器学习短语表示进行统计机器翻译》(2014 年)提供了一种思考我们如何增强 RNN 效果的有趣方式。
具体来说,他们将香草 RNN 中的 Tanh
激活函数与 LSTM/GRU 单元进行等效对比,并将它们描述为“激活”。它们的激活性质之间的差异是单元本身中信息是否保留、不变或更新。实际上,使用 Tanh
函数意味着您的网络对将信息从一个步骤传递到下一个步骤更加选择性。
GRU 与 LSTM 的不同之处在于它们消除了“单元状态”,从而减少了网络执行的张量运算总数。它们还使用单个重置门,而不是 LSTM 的输入和忘记门,进一步简化了网络的架构。
这里是 GRU 的逻辑表示:
这里,我们可以看到单个重置门(z(t) 和 r(t))中包含忘记和输入门的组合,单个状态 S(t) 被传递到下一个时间步。
门的偏置初始化
最近,在一个 ML 会议上,即“国际学习表示会议”,来自 Facebook AI Research 团队发布了一篇关于 RNN 进展的论文。这篇论文关注了增强了 GRU/LSTM 单元的 RNN 的有效性。虽然深入研究这篇论文超出了本书的范围,但您可以在本章末尾的“进一步阅读”部分中了解更多信息。从他们的研究中得出了一个有趣的假设:这些单元的偏置向量可以以某种方式初始化,这将增强网络学习非常长期的依赖关系的能力。他们发布了他们的结果,结果显示,训练时间有所改善,并且困惑度降低的速度也提高了:
这张图来自论文,表示网络在y轴上的损失,以及在x轴上的训练迭代次数。红色指示了chrono 初始化*。
这是非常新的研究,了解为什么基于 LSTM/GRU 的网络表现如此出色具有明确的科学价值。本文的主要实际影响,即门控单元偏置的初始化,为我们提供了另一个工具来提高模型性能并节省宝贵的 GPU 周期。目前,这些性能改进在单词级 PTB 和字符级 text8 数据集上是最显著的(尽管仍然是渐进的)。我们将在下一节中构建的网络可以很容易地适应测试此更改的相对性能改进。
在 Gorgonia 中构建一个 LSTM
现在我们已经讨论了什么是 RNN,如何训练它们以及如何修改它们以获得更好的性能,让我们来构建一个!接下来的几节将介绍如何为使用 LSTM 单元的 RNN 处理和表示数据。我们还将查看网络本身的样子,GRU 单元的代码以及一些工具,用于理解我们的网络正在做什么。
表示文本数据
虽然我们的目标是预测给定句子中的下一个单词,或者(理想情况下)预测一系列有意义并符合某种英语语法/语法度量的单词,但实际上我们将在字符级别对数据进行编码。这意味着我们需要获取我们的文本数据(在本例中是威廉·莎士比亚的作品集)并生成一系列标记。这些标记可以是整个句子、单独的单词,甚至是字符本身,这取决于我们正在训练的模型类型。
一旦我们对文本数据进行了标记化处理,我们需要将这些标记转换为适合计算的某种数值表示。正如我们所讨论的,对于我们的情况,这些表示是张量。然后将这些标记转换为一些张量,并对文本执行多种操作,以提取文本的不同属性,以下简称为我们的语料库。
这里的目标是生成一个词汇向量(长度为n的向量,其中n是语料库中唯一字符的数量)。我们将使用这个向量作为每个字符的编码模板。
导入和处理输入
让我们从在项目目录的根目录下创建一个vocab.go
文件开始。在这里,您将定义一些保留的 Unicode 字符,用于表示我们序列的开始/结束,以及用于填充我们序列的BLANK
字符。
请注意,我们在这里不包括我们的shakespeare.txt
输入文件。相反,我们构建了一个词汇表和索引,并将我们的输入corpus
分成块:
package main
import (
"fmt"
"strings"
)
const START rune = 0x02
const END rune = 0x03
const BLANK rune = 0x04
// vocab related
var sentences []string
var vocab []rune
var vocabIndex map[rune]int
var maxsent int = 30
func initVocab(ss []string, thresh int) {
s := strings.Join(ss, " ")
fmt.Println(s)
dict := make(map[rune]int)
for _, r := range s {
dict[r]++
}
vocab = append(vocab, START)
vocab = append(vocab, END)
vocab = append(vocab, BLANK)
vocabIndex = make(map[rune]int)
for ch, c := range dict {
if c >= thresh {
// then add letter to vocab
vocab = append(vocab, ch)
}
}
for i, v := range vocab {
vocabIndex[v] = i
}
fmt.Println("Vocab: ", vocab)
inputSize = len(vocab)
outputSize = len(vocab)
epochSize = len(ss)
fmt.Println("\ninputs :", inputSize)
fmt.Println("\noutputs :", outputSize)
fmt.Println("\nepochs: :", epochSize)
fmt.Println("\nmaxsent: :", maxsent)
}
func init() {
sentencesRaw := strings.Split(corpus, "\n")
for _, s := range sentencesRaw {
s2 := strings.TrimSpace(s)
if s2 != "" {
sentences = append(sentences, s2)
}
}
initVocab(sentences, 1)
}
现在我们可以创建下一部分代码,它提供了我们后续将需要的一些辅助函数。具体来说,我们将添加两个抽样函数:一个是基于温度的,其中已高概率词的概率增加,并在低概率词的情况下减少。温度越高,在任何方向上的概率增加越大。这为您的 LSTM-RNN 提供了另一个可调整的特性。
最后,我们将包括一些函数,用于处理byte
和uint
切片,使您可以轻松地进行比较/交换/评估它们:
package main
import (
"math/rand"
"gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
func sampleT(val gorgonia.Value) int {
var t tensor.Tensor
var ok bool
if t, ok = val.(tensor.Tensor); !ok {
panic("Expects a tensor")
}
return tensor.SampleIndex(t)
}
func sample(val gorgonia.Value) int {
var t tensor.Tensor
var ok bool
if t, ok = val.(tensor.Tensor); !ok {
panic("expects a tensor")
}
indT, err := tensor.Argmax(t, -1)
if err != nil {
panic(err)
}
if !indT.IsScalar() {
panic("Expected scalar index")
}
return indT.ScalarValue().(int)
}
func shuffle(a []string) {
for i := len(a) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
a[i], a[j] = a[j], a[i]
}
}
type byteslice []byte
func (s byteslice) Len() int { return len(s) }
func (s byteslice) Less(i, j int) bool { return s[i] < s[j] }
func (s byteslice) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
type uintslice []uint
func (s uintslice) Len() int { return len(s) }
func (s uintslice) Less(i, j int) bool { return s[i] < s[j] }
func (s uintslice) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
接下来,我们将创建一个lstm.go
文件,在这里我们将定义我们的 LSTM 单元。它们看起来像小型神经网络,因为正如我们之前讨论过的那样,它们就是这样。输入门、遗忘门和输出门将被定义,并附带它们的相关权重/偏置。
MakeLSTM()
函数将会向我们的图中添加这些单元。LSTM 也有许多方法;也就是说,learnables()
用于生成我们可学习的参数,而Activate()
则用于定义我们的单元在处理输入数据时执行的操作:
package main
import (
. "gorgonia.org/gorgonia"
)
type LSTM struct {
wix *Node
wih *Node
bias_i *Node
wfx *Node
wfh *Node
bias_f *Node
wox *Node
woh *Node
bias_o *Node
wcx *Node
wch *Node
bias_c *Node
}
func MakeLSTM(g *ExprGraph, hiddenSize, prevSize int) LSTM {
retVal := LSTM{}
retVal.wix = NewMatrix(g, Float, WithShape(hiddenSize, prevSize), WithInit(GlorotN(1.0)), WithName("wix_"))
retVal.wih = NewMatrix(g, Float, WithShape(hiddenSize, hiddenSize), WithInit(GlorotN(1.0)), WithName("wih_"))
retVal.bias_i = NewVector(g, Float, WithShape(hiddenSize), WithName("bias_i_"), WithInit(Zeroes()))
// output gate weights
retVal.wox = NewMatrix(g, Float, WithShape(hiddenSize, prevSize), WithInit(GlorotN(1.0)), WithName("wfx_"))
retVal.woh = NewMatrix(g, Float, WithShape(hiddenSize, hiddenSize), WithInit(GlorotN(1.0)), WithName("wfh_"))
retVal.bias_o = NewVector(g, Float, WithShape(hiddenSize), WithName("bias_f_"), WithInit(Zeroes()))
// forget gate weights
retVal.wfx = NewMatrix(g, Float, WithShape(hiddenSize, prevSize), WithInit(GlorotN(1.0)), WithName("wox_"))
retVal.wfh = NewMatrix(g, Float, WithShape(hiddenSize, hiddenSize), WithInit(GlorotN(1.0)), WithName("woh_"))
retVal.bias_f = NewVector(g, Float, WithShape(hiddenSize), WithName("bias_o_"), WithInit(Zeroes()))
// cell write
retVal.wcx = NewMatrix(g, Float, WithShape(hiddenSize, prevSize), WithInit(GlorotN(1.0)), WithName("wcx_"))
retVal.wch = NewMatrix(g, Float, WithShape(hiddenSize, hiddenSize), WithInit(GlorotN(1.0)), WithName("wch_"))
retVal.bias_c = NewVector(g, Float, WithShape(hiddenSize), WithName("bias_c_"), WithInit(Zeroes()))
return retVal
}
func (l *LSTM) learnables() Nodes {
return Nodes{
l.wix, l.wih, l.bias_i,
l.wfx, l.wfh, l.bias_f,
l.wcx, l.wch, l.bias_c,
l.wox, l.woh, l.bias_o,
}
}
func (l *LSTM) Activate(inputVector *Node, prev lstmout) (out lstmout, err error) {
// log.Printf("prev %v", prev.hidden.Shape())
prevHidden := prev.hidden
prevCell := prev.cell
var h0, h1, inputGate *Node
h0 = Must(Mul(l.wix, inputVector))
h1 = Must(Mul(l.wih, prevHidden))
inputGate = Must(Sigmoid(Must(Add(Must(Add(h0, h1)), l.bias_i))))
var h2, h3, forgetGate *Node
h2 = Must(Mul(l.wfx, inputVector))
h3 = Must(Mul(l.wfh, prevHidden))
forgetGate = Must(Sigmoid(Must(Add(Must(Add(h2, h3)), l.bias_f))))
var h4, h5, outputGate *Node
h4 = Must(Mul(l.wox, inputVector))
h5 = Must(Mul(l.woh, prevHidden))
outputGate = Must(Sigmoid(Must(Add(Must(Add(h4, h5)), l.bias_o))))
var h6, h7, cellWrite *Node
h6 = Must(Mul(l.wcx, inputVector))
h7 = Must(Mul(l.wch, prevHidden))
cellWrite = Must(Tanh(Must(Add(Must(Add(h6, h7)), l.bias_c))))
// cell activations
var retain, write *Node
retain = Must(HadamardProd(forgetGate, prevCell))
write = Must(HadamardProd(inputGate, cellWrite))
cell := Must(Add(retain, write))
hidden := Must(HadamardProd(outputGate, Must(Tanh(cell))))
out = lstmout{
hidden: hidden,
cell: cell,
}
return
}
type lstmout struct {
hidden, cell *Node
}
正如我们之前提到的,我们还将包括 GRU-RNN 的代码。这段代码是模块化的,因此您可以将您的 LSTM 替换为 GRU,从而扩展您可以进行的实验类型和您可以处理的用例范围。
创建一个名为gru.go
的文件。它将按照lstm.go
的相同结构进行,但会减少门数量:
package main
import (
"fmt"
. "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
var Float = tensor.Float32
type contextualError interface {
error
Node() *Node
Value() Value
InstructionID() int
Err() error
}
type GRU struct {
// weights for mem
u *Node
w *Node
b *Node
// update gate
uz *Node
wz *Node
bz *Node
// reset gate
ur *Node
wr *Node
br *Node
one *Node
Name string // optional name
}
func MakeGRU(name string, g *ExprGraph, inputSize, hiddenSize int, dt tensor.Dtype) GRU {
// standard weights
u := NewMatrix(g, dt, WithShape(hiddenSize, hiddenSize), WithName(fmt.Sprintf("%v.u", name)), WithInit(GlorotN(1.0)))
w := NewMatrix(g, dt, WithShape(hiddenSize, inputSize), WithName(fmt.Sprintf("%v.w", name)), WithInit(GlorotN(1.0)))
b := NewVector(g, dt, WithShape(hiddenSize), WithName(fmt.Sprintf("%v.b", name)), WithInit(Zeroes()))
// update gate
uz := NewMatrix(g, dt, WithShape(hiddenSize, hiddenSize), WithName(fmt.Sprintf("%v.uz", name)), WithInit(GlorotN(1.0)))
wz := NewMatrix(g, dt, WithShape(hiddenSize, inputSize), WithName(fmt.Sprintf("%v.wz", name)), WithInit(GlorotN(1.0)))
bz := NewVector(g, dt, WithShape(hiddenSize), WithName(fmt.Sprintf("%v.b_uz", name)), WithInit(Zeroes()))
// reset gate
ur := NewMatrix(g, dt, WithShape(hiddenSize, hiddenSize), WithName(fmt.Sprintf("%v.ur", name)), WithInit(GlorotN(1.0)))
wr := NewMatrix(g, dt, WithShape(hiddenSize, inputSize), WithName(fmt.Sprintf("%v.wr", name)), WithInit(GlorotN(1.0)))
br := NewVector(g, dt, WithShape(hiddenSize), WithName(fmt.Sprintf("%v.bz", name)), WithInit(Zeroes()))
ones := tensor.Ones(dt, hiddenSize)
one := g.Constant(ones)
gru := GRU{
u: u,
w: w,
b: b,
uz: uz,
wz: wz,
bz: bz,
ur: ur,
wr: wr,
br: br,
one: one,
}
return gru
}
func (l *GRU) Activate(x, prev *Node) (retVal *Node, err error) {
// update gate
uzh := Must(Mul(l.uz, prev))
wzx := Must(Mul(l.wz, x))
z := Must(Sigmoid(
Must(Add(
Must(Add(uzh, wzx)),
l.bz))))
// reset gate
urh := Must(Mul(l.ur, prev))
wrx := Must(Mul(l.wr, x))
r := Must(Sigmoid(
Must(Add(
Must(Add(urh, wrx)),
l.br))))
// memory for hidden
hiddenFilter := Must(Mul(l.u, Must(HadamardProd(r, prev))))
wx := Must(Mul(l.w, x))
mem := Must(Tanh(
Must(Add(
Must(Add(hiddenFilter, wx)),
l.b))))
omz := Must(Sub(l.one, z))
omzh := Must(HadamardProd(omz, prev))
upd := Must(HadamardProd(z, mem))
retVal = Must(Add(upd, omzh))
return
}
func (l *GRU) learnables() Nodes {
retVal := make(Nodes, 0, 9)
retVal = append(retVal, l.u, l.w, l.b, l.uz, l.wz, l.bz, l.ur, l.wr, l.br)
return retVal
}
当我们继续将我们网络的各个部分组合在一起时,我们需要在我们的 LSTM/GRU 代码之上添加一个最终的抽象层—即网络本身的层次。我们遵循的命名惯例是序列到序列(或s2s
)网络。在我们的例子中,我们正在预测文本的下一个字符。这个序列是任意的,可以是单词或句子,甚至是语言之间的映射。因此,我们将创建一个s2s.go
文件。
由于这实际上是一个更大的神经网络,用于包含我们之前在lstm.go
/gru.go
中定义的小型神经网络,所以结构是类似的。我们可以看到 LSTM 正在处理我们网络的输入(而不是普通的 RNN 单元),并且我们在t-0
处有dummy
节点来处理输入,以及输出节点:
package main
import (
"encoding/json"
"io"
"log"
"os"
"github.com/pkg/errors"
. "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
type seq2seq struct {
in LSTM
dummyPrev *Node // vector
dummyCell *Node // vector
embedding *Node // NxM matrix, where M is the number of dimensions of the embedding
decoder *Node
vocab []rune
inVecs []*Node
losses []*Node
preds []*Node
predvals []Value
g *ExprGraph
vm VM
}
// NewS2S creates a new Seq2Seq network. Input size is the size of the embedding. Hidden size is the size of the hidden layer
func NewS2S(hiddenSize, embSize int, vocab []rune) *seq2seq {
g := NewGraph()
// in := MakeGRU("In", g, embSize, hiddenSize, Float)s
in := MakeLSTM(g, hiddenSize, embSize)
log.Printf("%q", vocab)
dummyPrev := NewVector(g, Float, WithShape(embSize), WithName("Dummy Prev"), WithInit(Zeroes()))
dummyCell := NewVector(g, Float, WithShape(hiddenSize), WithName("Dummy Cell"), WithInit(Zeroes()))
embedding := NewMatrix(g, Float, WithShape(len(vocab), embSize), WithInit(GlorotN(1.0)), WithName("Embedding"))
decoder := NewMatrix(g, Float, WithShape(len(vocab), hiddenSize), WithInit(GlorotN(1.0)), WithName("Output Decoder"))
return &seq2seq{
in: in,
dummyPrev: dummyPrev,
dummyCell: dummyCell,
embedding: embedding,
vocab: vocab,
decoder: decoder,
g: g,
}
}
func (s *seq2seq) learnables() Nodes {
retVal := make(Nodes, 0)
retVal = append(retVal, s.in.learnables()...)
retVal = append(retVal, s.embedding)
retVal = append(retVal, s.decoder)
return retVal
}
由于我们使用的是静态图,Gorgonia 的TapeMachine
,我们将需要一个函数来在初始化时构建我们的网络。其中一些值将在运行时被替换:
func (s *seq2seq) build() (cost *Node, err error) {
// var prev *Node = s.dummyPrev
prev := lstmout{
hidden: s.dummyCell,
cell: s.dummyCell,
}
s.predvals = make([]Value, maxsent)
var prediction *Node
for i := 0; i < maxsent; i++ {
var vec *Node
if i == 0 {
vec = Must(Slice(s.embedding, S(0))) // dummy, to be replaced at runtime
} else {
vec = Must(Mul(prediction, s.embedding))
}
s.inVecs = append(s.inVecs, vec)
if prev, err = s.in.Activate(vec, prev); err != nil {
return
}
prediction = Must(SoftMax(Must(Mul(s.decoder, prev.hidden))))
s.preds = append(s.preds, prediction)
Read(prediction, &s.predvals[i])
logprob := Must(Neg(Must(Log(prediction))))
loss := Must(Slice(logprob, S(0))) // dummy, to be replaced at runtime
s.losses = append(s.losses, loss)
if cost == nil {
cost = loss
} else {
cost = Must(Add(cost, loss))
}
}
_, err = Grad(cost, s.learnables()...)
return
}
现在我们可以定义网络本身的训练循环:
func (s *seq2seq) train(in []rune) (err error) {
for i := 0; i < maxsent; i++ {
var currentRune, correctPrediction rune
switch {
case i == 0:
currentRune = START
correctPrediction = in[i]
case i-1 == len(in)-1:
currentRune = in[i-1]
correctPrediction = END
case i-1 >= len(in):
currentRune = BLANK
correctPrediction = BLANK
default:
currentRune = in[i-1]
correctPrediction = in[i]
}
targetID := vocabIndex[correctPrediction]
if i == 0 || i-1 >= len(in) {
srcID := vocabIndex[currentRune]
UnsafeLet(s.inVecs[i], S(srcID))
}
UnsafeLet(s.losses[i], S(targetID))
}
if s.vm == nil {
s.vm = NewTapeMachine(s.g, BindDualValues())
}
s.vm.Reset()
err = s.vm.RunAll()
return
}
我们还需要一个predict
函数,这样在我们的模型训练完成后,我们就可以对其进行抽样:
func (s *seq2seq) predict(in []rune) (output []rune, err error) {
g2 := s.g.SubgraphRoots(s.preds...)
vm := NewTapeMachine(g2)
if err = vm.RunAll(); err != nil {
return
}
defer vm.Close()
for _, pred := range s.predvals {
log.Printf("%v", pred.Shape())
id := sample(pred)
if id >= len(vocab) {
log.Printf("Predicted %d. Len(vocab) %v", id, len(vocab))
continue
}
r := vocab[id]
output = append(output, r)
}
return
}
在大文本语料库上进行训练可能需要很长时间,因此有一种方式来检查点我们的模型,以便可以从训练周期中的任意点保存/加载它将会很有用:
func (s *seq2seq) checkpoint() (err error) {
learnables := s.learnables()
var f io.WriteCloser
if f, err = os.OpenFile("CHECKPOINT.bin", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644); err != nil {
return
}
defer f.Close()
enc := json.NewEncoder(f)
for _, l := range learnables {
t := l.Value().(*tensor.Dense).Data() // []float32
if err = enc.Encode(t); err != nil {
return
}
}
return nil
}
func (s *seq2seq) load() (err error) {
learnables := s.learnables()
var f io.ReadCloser
if f, err = os.OpenFile("CHECKPOINT.bin", os.O_RDONLY, 0644); err != nil {
return
}
defer f.Close()
dec := json.NewDecoder(f)
for _, l := range learnables {
t := l.Value().(*tensor.Dense).Data().([]float32)
var data []float32
if err = dec.Decode(&data); err != nil {
return
}
if len(data) != len(t) {
return errors.Errorf("Unserialized length %d. Expected length %d", len(data), len(t))
}
copy(t, data)
}
return nil
}
最后,我们可以定义meta-training
循环。这是一个循环,它接受s2s
网络、一个解算器、我们的数据以及各种超参数:
func train(s *seq2seq, epochs int, solver Solver, data []string) (err error) {
cost, err := s.build()
if err != nil {
return err
}
var costVal Value
Read(cost, &costVal)
model := NodesToValueGrads(s.learnables())
for e := 0; e < epochs; e++ {
shuffle(data)
for _, sentence := range data {
asRunes := []rune(sentence)
if err = s.train(asRunes); err != nil {
return
}
if err = solver.Step(model); err != nil {
return
}
}
// if e%100 == 0 {
log.Printf("Cost for epoch %d: %1.10f\n", e, costVal)
// }
}
return nil
}
在构建和执行我们的网络之前,我们将添加一个小的可视化工具,以帮助我们进行任何需要的故障排除。通常在处理数据时,可视化是一个强大的工具,在我们的案例中,它允许我们窥探我们的神经网络内部,以便我们理解它在做什么。具体来说,我们将生成热图,用于跟踪我们网络权重在训练过程中的变化。这样,我们可以确保它们在变化(也就是说,我们的网络正在学习)。
创建一个名为heatmap.go
的文件:
package main
import (
"image/color"
"math"
"github.com/pkg/errors"
"gonum.org/v1/gonum/mat"
"gonum.org/v1/plot"
"gonum.org/v1/plot/palette/moreland"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
"gorgonia.org/tensor"
)
type heatmap struct {
x mat.Matrix
}
func (m heatmap) Dims() (c, r int) { r, c = m.x.Dims(); return c, r }
func (m heatmap) Z(c, r int) float64 { return m.x.At(r, c) }
func (m heatmap) X(c int) float64 { return float64(c) }
func (m heatmap) Y(r int) float64 { return float64(r) }
type ticks []string
func (t ticks) Ticks(min, max float64) []plot.Tick {
var retVal []plot.Tick
for i := math.Trunc(min); i <= max; i++ {
retVal = append(retVal, plot.Tick{Value: i, Label: t[int(i)]})
}
return retVal
}
func Heatmap(a *tensor.Dense) (p *plot.Plot, H, W vg.Length, err error) {
switch a.Dims() {
case 1:
original := a.Shape()
a.Reshape(original[0], 1)
defer a.Reshape(original...)
case 2:
default:
return nil, 0, 0, errors.Errorf("Can't do a tensor with shape %v", a.Shape())
}
m, err := tensor.ToMat64(a, tensor.UseUnsafe())
if err != nil {
return nil, 0, 0, err
}
pal := moreland.ExtendedBlackBody().Palette(256)
// lum, _ := moreland.NewLuminance([]color.Color{color.Gray{0}, color.Gray{255}})
// pal := lum.Palette(256)
hm := plotter.NewHeatMap(heatmap{m}, pal)
if p, err = plot.New(); err != nil {
return nil, 0, 0, err
}
hm.NaN = color.RGBA{0, 0, 0, 0} // black
p.Add(hm)
sh := a.Shape()
H = vg.Length(sh[0])*vg.Centimeter + vg.Centimeter
W = vg.Length(sh[1])*vg.Centimeter + vg.Centimeter
return p, H, W, nil
}
func Avg(a []float64) (retVal float64) {
for _, v := range a {
retVal += v
}
return retVal / float64(len(a))
}
现在我们可以把所有的部件整合到我们的main.go
文件中。在这里,我们将设置超参数,解析输入,并启动我们的主训练循环:
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"runtime/pprof"
. "gorgonia.org/gorgonia"
"gorgonia.org/tensor"
)
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
var memprofile = flag.String("memprofile", "", "write memory profile to this file")
const (
embeddingSize = 20
maxOut = 30
// gradient update stuff
l2reg = 0.000001
learnrate = 0.01
clipVal = 5.0
)
var trainiter = flag.Int("iter", 5, "How many iterations to train")
// various global variable inits
var epochSize = -1
var inputSize = -1
var outputSize = -1
var corpus string
func init() {
buf, err := ioutil.ReadFile("shakespeare.txt")
if err != nil {
panic(err)
}
corpus = string(buf)
}
var dt tensor.Dtype = tensor.Float32
func main() {
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal(err)
}
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
hiddenSize := 100
s2s := NewS2S(hiddenSize, embeddingSize, vocab)
solver := NewRMSPropSolver(WithLearnRate(learnrate), WithL2Reg(l2reg), WithClip(clipVal), WithBatchSize(float64(len(sentences))))
for k, v := range vocabIndex {
log.Printf("%q %v", k, v)
}
// p, h, w, err := Heatmap(s2s.decoder.Value().(*tensor.Dense))
// p.Save(w, h, "embn0.png")
if err := train(s2s, 300, solver, sentences); err != nil {
panic(err)
}
out, err := s2s.predict([]rune(corpus))
if err != nil {
panic(err)
}
fmt.Printf("OUT %q\n", out)
p, h, w, err = Heatmap(s2s.decoder.Value().(*tensor.Dense))
p.Save(w, h, "embn.png")
}
现在,让我们运行go run *.go
并观察输出:
2019/05/25 23:52:03 Cost for epoch 31: 250.7806701660
2019/05/25 23:52:19 Cost for epoch 32: 176.0116729736
2019/05/25 23:52:35 Cost for epoch 33: 195.0501556396
2019/05/25 23:52:50 Cost for epoch 34: 190.6829681396
2019/05/25 23:53:06 Cost for epoch 35: 181.1398162842
我们可以看到在我们网络的早期阶段,成本(衡量网络优化程度的指标)很高且波动很大。
在指定的 epoch 数之后,将进行输出预测:
OUT ['S' 'a' 'K' 'a' 'g' 'y' 'h' ',' '\x04' 'a' 'g' 'a' 't' '\x04' '\x04' ' ' 's' 'h' 'h' 'h' 'h' 'h' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ']
现在您可以尝试使用 GRU 而不是 LSTM 单元以及探索偏置初始化等超参数和调整,以优化您的网络,从而产生更好的预测。
总结
在本章中,我们介绍了什么是 RNN 以及如何训练它。我们已经看到,为了有效地建模长期依赖关系并克服训练中的挑战,需要对标准 RNN 进行改进,包括由 GRU/LSTM 单元提供的跨时间的额外信息控制机制。我们在 Gorgonia 中构建了这样一个网络。
在下一章中,我们将学习如何构建 CNN 以及如何调整一些超参数。
进一步阅读
-
训练递归神经网络,Ilya Sutskever,可查阅
www.cs.utoronto.ca/~ilya/pubs/ilya_sutskever_phd_thesis.pdf
-
长短期记忆,Hochreiter,Sepp和Jurgen Schmidhuber,可查阅
www.researchgate.net/publication/13853244_Long_Short-term_Memory
-
关于序列建模的经验评估的门控循环神经网络,Bengio 等,可查阅
arxiv.org/abs/1412.3555
第六章:使用卷积神经网络进行物体识别
现在是时候处理一些比我们之前的 MNIST 手写例子更一般的计算机视觉或图像分类问题了。许多相同的原则适用,但我们将使用一些新类型的操作来构建卷积神经 网络(CNNs)。
本章将涵盖以下主题:
-
CNN 简介
-
构建一个示例 CNN
-
评估结果和进行改进
CNN 简介
CNN 是一类深度神经网络,它们非常适合处理具有多个通道的数据,并对输入中包含的信息局部性敏感。这使得 CNN 非常适合与计算机视觉相关的任务,例如人脸识别、图像分类、场景标记等。
什么是 CNN?
CNN,也称为ConvNets,是一类被普遍认为在图像分类方面非常出色的神经网络,也就是说,它们非常擅长区分猫和狗、汽车和飞机等常见分类任务。
CNN 通常由卷积层、激活层和池化层组成。然而,它们被特别构造以利用输入通常为图像的事实,并利用图像中某些部分极有可能紧邻彼此的事实。
在实现上,它们与我们在早期章节中介绍的前馈网络非常相似。
普通前馈与 ConvNet
一般来说,神经网络接收一个单独的向量作为输入(例如我们在第三章中的 MNIST 示例,超越基本神经网络—自编码器和 RBM),然后经过几个隐藏层,在最后得到我们推断的结果。这对于图像不是很大的情况是可以的;然而,当我们的图像变得更大时,通常是大多数实际应用中的情况,我们希望确保我们不会建立极其庞大的隐藏层来正确处理它们。
当然,我们在张量理念中的一个方便特性是,事实上我们并不需要将一个向量馈送到模型中;我们可以馈送一个稍微复杂且具有更多维度的东西。基本上,我们想要用 CNN 做的是将神经元按三维排列:高度、宽度和深度——这里所说的深度是指我们彩色系统中的颜色数量,在我们的情况下是红色、绿色和蓝色。
我们不再试图将每个层中的每个神经元连接在一起,而是试图减少它,使其更易管理,减少对我们的样本大小过拟合的可能性,因为我们不会尝试训练输入的每个像素。
层
当然,CNN 使用层,我们需要更详细地讨论其中的一些层,因为我们还没有讨论它们;一般来说,CNN 中有三个主要层:卷积层、池化层和全连接层(这些您已经见过)。
卷积层
卷积层是这种神经网络的一部分,是神经网络架构中非常重要的组成部分。它可以广义地解释为在图像上进行滑动来寻找特定的特征。我们创建一个小型滤波器,然后根据我们想要的步幅在整个图像上滑动。
因此,例如,输出的第一个单元格将通过计算我们的 3 x 3 滤波器与图像的左上角的点积来得出,如下图所示:
如果步幅为一,那么将向右移动一列并继续,如下图所示:
这样就可以继续,直到获得整个输出。
池化层
池化层通常放置在卷积层之间;它们的作用是减少传递的数据量,从而减少参数数量,以及减少网络所需的计算量。在这种情况下,我们通过在给定区域内取最大值来进行池化操作。
这些层的工作方式与卷积层类似;它们在预定的网格上应用并执行池化操作。在这种情况下,它是最大化操作,因此它将在网格内取最高值。
例如,在一个 2 x 2 网格上进行最大池化操作时,第一个输出的单元格将来自左上角,如下所示:
并且使用步幅为两,第二个将来自向右移动两行的网格,如下图所示:
基本结构
现在您理解了层次,让我们来谈谈 CNN 的基本结构。一个 CNN 主要包括以下几部分:一个输入层,然后是若干层卷积层、激活层和池化层,最后以一个全连接层结束,以获得最终的结果。
基本结构看起来像下面这样:
构建一个示例 CNN
为了说明 CNN 在实践中的工作原理,我们将构建一个模型来识别照片中的物体是否是猫。我们使用的数据集比这更加复杂,但训练它以正确分类一切会花费相当长的时间。将示例扩展到分类一切都是相当简单的,但我们宁愿不花一周时间等待模型训练。
对于我们的示例,我们将使用以下结构:
CIFAR-10
这次我们的示例中使用 CIFAR-10 而不是 MNIST。因此,我们不能方便地使用已有的 MNIST 加载器。让我们快速浏览一下加载这个新数据集所需的步骤!
我们将使用 CIFAR-10 的二进制格式,你可以在这里下载:www.cs.toronto.edu/~kriz/cifar.html
。
此数据集由 Alex Krizhevsky、Vinod Nair 和 Geoffrey Hinton 组成。它包含 60,000 张 32 像素高、32 像素宽的小图像。CIFAR-10 的二进制格式如下所示:
<1 x label><3072 x pixel>
<1 x label><3072 x pixel>
<1 x label><3072 x pixel>
<1 x label><3072 x pixel>
<1 x label><3072 x pixel>
<1 x label><3072 x pixel>
...
<1 x label><3072 x pixel>
应该注意,它没有分隔符或任何其他验证文件的信息;因此,你应该确保你下载的文件的 MD5 校验和与网站上的匹配。由于结构相对简单,我们可以直接将二进制文件导入 Go 并相应地解析它。
这 3,072 个像素实际上是红、绿、蓝三层值,从 0 到 255,按行主序在 32 x 32 网格中排列,因此这为我们提供了图像数据。
标签是从 0 到 9 的数字,分别表示以下各类之一:
CIFAR-10 有六个文件,包括五个每个包含 10,000 张图像的训练集文件和一个包含 10,000 张图像的测试集文件:
case "train":
arrayFiles = []string{
"data_batch_1.bin",
"data_batch_2.bin",
"data_batch_3.bin",
"data_batch_4.bin",
"data_batch_5.bin",
}
case "test":
arrayFiles = []string{
"test_batch.bin",
}
}
在 Go 中导入这个很容易——打开文件并读取原始字节。由于每个底层值都是单字节内的 8 位整数,我们可以将其转换为任何我们想要的类型。如果你想要单个整数值,你可以将它们全部转换为无符号 8 位整数;这在你想要将数据转换为图像时非常有用。然而,正如下面的代码所示,你会发现我们在代码中做了一些稍微不同的决定:
f, err := os.Open(filepath.Join(loc, targetFile))
if err != nil {
log.Fatal(err)
}
defer f.Close()
cifar, err := ioutil.ReadAll(f)
if err != nil {
log.Fatal(err)
}
for index, element := range cifar {
if index%3073 == 0 {
labelSlice = append(labelSlice, float64(element))
} else {
imageSlice = append(imageSlice, pixelWeight(element))
}
}
由于我们有兴趣将这些数据用于我们的深度学习算法,因此最好不要偏离我们在 0
到 1
之间的中间点。我们正在重用来自 MNIST 示例的像素权重,如下所示:
func pixelWeight(px byte) float64 {
retVal := float64(px)/pixelRange*0.9 + 0.1
if retVal == 1.0 {
return 0.999
}
return retVal
}
将所有像素值从 0 到 255 转换为 0.1
到 1.0
的范围。
类似地,对于我们的标签,我们将再次使用一位有效编码,将期望的标签编码为 0.9
,其他所有内容编码为 0.1
,如下所示:
labelBacking := make([]float64, len(labelSlice)*numLabels, len(labelSlice)*numLabels)
labelBacking = labelBacking[:0]
for i := 0; i < len(labelSlice); i++ {
for j := 0; j < numLabels; j++ {
if j == int(labelSlice[i]) {
labelBacking = append(labelBacking, 0.9)
} else {
labelBacking = append(labelBacking, 0.1)
}
}
}
我们已将其打包为一个便利的 Load
函数,这样我们就可以从我们的代码中调用它。它将为我们返回两个方便形状的张量供我们使用。这为我们提供了一个可以导入训练集和测试集的函数:
func Load(typ, loc string) (inputs, targets tensor.Tensor, err error) {
...
inputs = tensor.New(tensor.WithShape(len(labelSlice), 3, 32, 32), tensor.WithBacking(imageSlice))
targets = tensor.New(tensor.WithShape(len(labelSlice), numLabels), tensor.WithBacking(labelBacking))
return
}
这允许我们通过在 main
中调用以下方式来加载数据:
if inputs, targets, err = cifar.Load("train", loc); err != nil {
log.Fatal(err)
}
Epochs 和批处理大小
我们将选择10
个 epochs 作为本例子的训练周期,这样代码可以在不到一个小时内完成训练。需要注意的是,仅仅进行 10 个 epochs 只能使我们达到约 20%的准确率,因此如果发现生成的模型看起来不准确,不必惊慌;你需要更长时间的训练,甚至可能需要大约 1,000 个 epochs。在现代计算机上,一个 epoch 大约需要三分钟来完成;为了不让这个例子需要三天的时间才能完成,我们选择了缩短训练过程,并留给你练习评估更多 epochs 的结果,如下所示:
var (
epochs = flag.Int("epochs", 10, "Number of epochs to train for")
dataset = flag.String("dataset", "train", "Which dataset to train on? Valid options are \"train\" or \"test\"")
dtype = flag.String("dtype", "float64", "Which dtype to use")
batchsize = flag.Int("batchsize", 100, "Batch size")
cpuprofile = flag.String("cpuprofile", "", "CPU profiling")
)
请注意,这个模型将消耗相当大的内存;batchsize
设为100
仍可能需要大约 4 GB 的内存。如果你没有足够的内存而不得不使用交换内存,你可能需要降低批处理大小,以便代码在你的计算机上执行得更好。
准确率
由于这个模型需要更长时间来收敛,我们还应该添加一个简单的度量来跟踪我们的准确性。为了做到这一点,我们必须首先从数据中提取我们的标签 - 我们可以像下面这样做:
// get label
yRowT, _ := yVal.Slice(sli{j, j + 1})
yRow := yRowT.Data().([]float64)
var rowLabel int
var yRowHigh float64
for k := 0; k < 10; k++ {
if k == 0 {
rowLabel = 0
yRowHigh = yRow[k]
} else if yRow[k] > yRowHigh {
rowLabel = k
yRowHigh = yRow[k]
}
}
接着,我们需要从输出数据中获取我们的预测:
yOutput2 := tensor.New(tensor.WithShape(bs, 10), tensor.WithBacking(arrayOutput2))
// get prediction
predRowT, _ := yOutput2.Slice(sli{j, j + 1})
predRow := predRowT.Data().([]float64)
var rowGuess int
var predRowHigh float64
// guess result
for k := 0; k < 10; k++ {
if k == 0 {
rowGuess = 0
predRowHigh = predRow[k]
} else if predRow[k] > predRowHigh {
rowGuess = k
predRowHigh = predRow[k]
}
}
然后,我们可以使用这个来更新我们的准确性度量。更新的量将按示例的数量进行缩放,因此我们的输出将是一个百分比数字。
if rowLabel == rowGuess {
accuracyGuess += 1.0 / float64(numExamples)
}
这给了我们一个广泛的准确性度量指标,可以用来评估我们的训练进展。
构建层
我们可以将我们的层结构考虑为有四个部分。我们将有三个卷积层和一个全连接层。我们的前两层非常相似 - 它们遵循我们之前描述的卷积-ReLU-MaxPool-dropout 结构:
// Layer 0
if c0, err = gorgonia.Conv2d(x, m.w0, tensor.Shape{5, 5}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
return errors.Wrap(err, "Layer 0 Convolution failed")
}
if a0, err = gorgonia.Rectify(c0); err != nil {
return errors.Wrap(err, "Layer 0 activation failed")
}
if p0, err = gorgonia.MaxPool2D(a0, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
return errors.Wrap(err, "Layer 0 Maxpooling failed")
}
if l0, err = gorgonia.Dropout(p0, m.d0); err != nil {
return errors.Wrap(err, "Unable to apply a dropout")
}
我们接下来的层类似 - 我们只需要将它连接到前一个输出:
// Layer 1
if c1, err = gorgonia.Conv2d(l0, m.w1, tensor.Shape{5, 5}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
return errors.Wrap(err, "Layer 1 Convolution failed")
}
if a1, err = gorgonia.Rectify(c1); err != nil {
return errors.Wrap(err, "Layer 1 activation failed")
}
if p1, err = gorgonia.MaxPool2D(a1, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
return errors.Wrap(err, "Layer 1 Maxpooling failed")
}
if l1, err = gorgonia.Dropout(p1, m.d1); err != nil {
return errors.Wrap(err, "Unable to apply a dropout to layer 1")
}
接下来的层本质上是相同的,但为了准备好连接到全连接层,有些细微的改变:
// Layer 2
if c2, err = gorgonia.Conv2d(l1, m.w2, tensor.Shape{5, 5}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
return errors.Wrap(err, "Layer 2 Convolution failed")
}
if a2, err = gorgonia.Rectify(c2); err != nil {
return errors.Wrap(err, "Layer 2 activation failed")
}
if p2, err = gorgonia.MaxPool2D(a2, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
return errors.Wrap(err, "Layer 2 Maxpooling failed")
}
var r2 *gorgonia.Node
b, c, h, w := p2.Shape()[0], p2.Shape()[1], p2.Shape()[2], p2.Shape()[3]
if r2, err = gorgonia.Reshape(p2, tensor.Shape{b, c * h * w}); err != nil {
return errors.Wrap(err, "Unable to reshape layer 2")
}
if l2, err = gorgonia.Dropout(r2, m.d2); err != nil {
return errors.Wrap(err, "Unable to apply a dropout on layer 2")
}
Layer 3
是我们已经非常熟悉的全连接层 - 在这里,我们有一个相当简单的结构。我们当然可以向这个层添加更多的层级(许多不同的架构之前也已经这样做过,成功的程度不同)。这个层的代码如下所示:
// Layer 3
log.Printf("l2 shape %v", l2.Shape())
log.Printf("w3 shape %v", m.w3.Shape())
if fc, err = gorgonia.Mul(l2, m.w3); err != nil {
return errors.Wrapf(err, "Unable to multiply l2 and w3")
}
if a3, err = gorgonia.Rectify(fc); err != nil {
return errors.Wrapf(err, "Unable to activate fc")
}
if l3, err = gorgonia.Dropout(a3, m.d3); err != nil {
return errors.Wrapf(err, "Unable to apply a dropout on layer 3")
}
损失函数和求解器
我们将在这里使用普通的交叉熵损失函数,其实现如下:
losses := gorgonia.Must(gorgonia.HadamardProd(gorgonia.Must(gorgonia.Log(m.out)), y))
cost := gorgonia.Must(gorgonia.Sum(losses))
cost = gorgonia.Must(gorgonia.Neg(cost))
if _, err = gorgonia.Grad(cost, m.learnables()...); err != nil {
log.Fatal(err)
}
除此之外,我们将使用 Gorgonia 的计算机器和 RMSprop 求解器,如下所示:
vm := gorgonia.NewTapeMachine(g, gorgonia.WithPrecompiled(prog, locMap), gorgonia.BindDualValues(m.learnables()...))
solver := gorgonia.NewRMSPropSolver(gorgonia.WithBatchSize(float64(bs)))
测试集输出
在训练结束时,我们应该将我们的模型与测试集进行比较。
首先,我们应该导入我们的测试数据如下:
if inputs, targets, err = cifar.Load("test", loc); err != nil {
log.Fatal(err)
}
然后,我们需要重新计算我们的批次,因为测试集的大小与训练集不同:
batches = inputs.Shape()[0] / bs
bar = pb.New(batches)
bar.SetRefreshRate(time.Second)
bar.SetMaxWidth(80)
然后,我们需要一种快速的方法来跟踪我们的结果,并将我们的结果输出以便稍后检查,将以下代码插入前述章节中描述的准确度度量计算代码中:
// slices to store our output
var testActual, testPred []int
// store our output into the slices within the loop
testActual = append(testActual, rowLabel)
testPred = append(testPred, rowGuess)
最后,在我们运行整个测试集的最后时刻 - 将数据写入文本文件:
printIntSlice("testActual.txt", testActual)
printIntSlice("testPred.txt", testPred)
现在让我们评估结果。
评估结果
如前所述,例子训练了 10 个 epochs 并不特别准确。您需要训练多个 epochs 才能获得更好的结果。如果您一直关注模型的成本和准确性,您会发现随着 epochs 数量的增加,成本会保持相对稳定,准确性会增加,如下图所示:
仍然有用地探索结果以查看模型的表现;我们将特别关注猫:
如我们所见,目前似乎在非常具体的位置上猫的表现要好得多。显然,我们需要找到一个训练更快的解决方案。
GPU 加速
卷积及其相关操作在 GPU 加速上表现非常出色。您之前看到我们的 GPU 加速影响很小,但对于构建 CNNs 非常有用。我们只需添加神奇的 'cuda'
构建标签,如下所示:
go build -tags='cuda'
由于 GPU 内存受限,同样的批次大小可能不适用于您的 GPU。如前所述,该模型使用约 4 GB 内存,因此如果您的 GPU 内存少于 6 GB(因为假设您正常桌面使用约 1 GB),则可能需要减少批次大小。如果您的模型运行非常缓慢或者 CUDA 版本的可执行文件执行失败,最好检查是否存在内存不足的问题。您可以使用 NVIDIA SMI 实用程序,并让其每秒检查您的内存,如下所示:
nvidia-smi -l 1
这将导致每秒生成以下报告;在代码运行时观察它将告诉您大致消耗了多少 GPU 内存:
让我们快速比较 CPU 和 GPU 版本代码的性能。CPU 版本每个 epoch 大致需要三分钟,如下所示的代码:
2018/12/30 13:23:36 Batches 500
2018/12/30 13:26:23 Epoch 0 |
2018/12/30 13:29:15 Epoch 1 |
2018/12/30 13:32:01 Epoch 2 |
2018/12/30 13:34:47 Epoch 3 |
2018/12/30 13:37:33 Epoch 4 |
2018/12/30 13:40:19 Epoch 5 |
2018/12/30 13:43:05 Epoch 6 |
2018/12/30 13:45:50 Epoch 7 |
2018/12/30 13:48:36 Epoch 8 |
2018/12/30 13:51:22 Epoch 9 |
2018/12/30 13:51:55 Epoch Test |
GPU 版本每个 epoch 大约需要两分钟三十秒,如下所示的代码:
2018/12/30 12:57:56 Batches 500
2018/12/30 13:00:24 Epoch 0
2018/12/30 13:02:49 Epoch 1
2018/12/30 13:05:15 Epoch 2
2018/12/30 13:07:40 Epoch 3
2018/12/30 13:10:04 Epoch 4
2018/12/30 13:12:29 Epoch 5
2018/12/30 13:14:55 Epoch 6
2018/12/30 13:17:21 Epoch 7
2018/12/30 13:19:45 Epoch 8
2018/12/30 13:22:10 Epoch 9
2018/12/30 13:22:40 Epoch Test
未来的 Gorgonia 版本还将包括对更好操作的支持;目前正在测试中,您可以通过导入 gorgonia.org/gorgonia/ops/nn
并将 Gorgonia 版本的 Conv2d
、Rectify
、MaxPool2D
和 Dropout
调用替换为它们的 nnops
版本来使用它。稍有不同的 Layer 0
示例如下:
if c0, err = nnops.Conv2d(x, m.w0, tensor.Shape{3, 3}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
return errors.Wrap(err, "Layer 0 Convolution failed")
}
if a0, err = nnops.Rectify(c0); err != nil {
return errors.Wrap(err, "Layer 0 activation failed")
}
if p0, err = nnops.MaxPool2D(a0, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
return errors.Wrap(err, "Layer 0 Maxpooling failed")
}
if l0, err = nnops.Dropout(p0, m.d0); err != nil {
return errors.Wrap(err, "Unable to apply a dropout")
}
作为练习,替换所有必要的操作并运行以查看它的不同之处。
CNN 的弱点
CNN 实际上有一个相当严重的弱点:它们不具备方向不变性,这意味着如果你把同一图像倒过来输入,网络很可能完全无法识别。我们可以确保这不是问题的一种方法是训练模型使用不同的旋转;然而,有更好的架构可以解决这个问题,我们稍后会在本书中讨论。
它们也不是尺度不变的。如果输入一张比较小或比较大的同一图像,模型很可能会失败。如果你回想一下为什么会这样,那是因为我们基于一个非常特定大小的过滤器在一个非常特定的像素组上构建模型。
你也已经看到,通常情况下模型训练非常缓慢,特别是在 CPU 上。我们可以通过使用 GPU 来部分解决这个问题,但总体而言,这是一个昂贵的过程,可能需要几天的时间来完成。
摘要
现在,你已经学会了如何构建 CNN 以及如何调整一些超参数(如 epoch 数量和 batch 大小),以便获得期望的结果并在不同的计算机上顺利运行。
作为练习,你应该尝试训练这个模型以识别 MNIST 数字,甚至改变卷积层的结构;尝试批量归一化,也许甚至在全连接层中加入更多的权重。
下一章将介绍强化学习和 Q 学习的基础知识,以及如何构建一个 DQN 并解决迷宫问题。
进一步阅读
-
字符级卷积网络用于文本分类 由 张翔,赵军波 和 杨立昆
-
U-Net:用于生物医学图像分割的卷积网络 由 Olaf Ronneberger,Philipp Fischer 和 Thomas Brox
-
更快的 R-CNN:基于区域建议网络实现实时目标检测 由 任少卿,何凯明,Ross Girshick 和 孙剑
-
长期递归卷积网络用于视觉识别和描述 由 Jeff Donahue,Lisa Anne Hendricks,Marcus Rohrbach,Subhashini Venugopalan,Sergio Guadarrama,Kate Saenko 和 Trevor Darrell
第七章:使用深度 Q 网络解决迷宫问题
想象一下,你的数据不是离散的文本体或者来自你组织数据仓库的精心清理的记录集合。也许你想训练一个代理去导航一个环境。你将如何开始解决这个问题?到目前为止,我们涵盖的技术都不适合这样的任务。我们需要考虑如何以一种完全不同的方式训练我们的模型,使得这个问题可解决。此外,在使用案例中,问题可以被定义为一个代理探索并从环境中获得奖励,从游戏玩法到个性化新闻推荐,深度 Q 网络 (DQNs) 是我们深度学习技术武器库中有用的工具。
强化学习 (RL) 被 Yann LeCun 描述为机器学习方法的“蛋糕上的樱桃”(他在卷积神经网络 (CNNs) 的发展中起了重要作用,并且在撰写本文时是 Facebook AI Research 的主任)。在这个类比中,无监督学习是蛋糕,监督学习是糖霜。这里我们需要理解的重点是,尽管 RL 提供了无模型学习的承诺,你只需提供一些标量奖励作为你的模型朝着指定的目标成功优化的过程中。
本章将简要介绍为什么会这样,以及 RL 如何更普遍地融入图景中。具体而言,我们将涵盖以下主题:
-
什么是 DQN?
-
学习 Q-learning 算法
-
学习如何训练一个DQN
-
构建一个用于解决迷宫的 DQN
什么是 DQN?
正如你将会学到的,一个 DQN 与我们迄今为止涵盖的标准前馈和卷积网络并没有太大的区别。事实上,所有标准的要素都存在:
-
我们数据的表示(在这个例子中,是我们迷宫的状态以及试图通过它导航的代理的状态)
-
处理迷宫表示的标准层,其中包括这些层之间的标准操作,例如
Tanh
激活函数 -
具有线性激活的输出层,这给出了预测结果
这里,我们的预测代表着可能影响输入状态的移动。在迷宫解决的情况下,我们试图预测产生最大(和累积)期望奖励的移动,最终导致迷宫的出口。这些预测是作为训练循环的一部分出现的,学习算法使用一个作为随时间衰减的变量的Gamma来平衡环境状态空间的探索和通过建立行动、状态或奖励地图获取的知识的利用。
让我们介绍一些新概念。首先,我们需要一个m x n矩阵,表示给定状态(即行)和动作(即列)的奖励R。我们还需要一个Q表。这是一个矩阵(初始化为零值),表示代理的记忆(即我们的玩家试图找到迷宫的方式)或状态历史、采取的行动及其奖励。
这两个矩阵相互关联。我们可以通过以下公式确定我们的代理的记忆Q表与已知奖励表的关系:
Q(状态, 动作) = R(状态, 动作) + Gamma * Max[Q(下一个状态, 所有动作)]
在这里,我们的时代是一个回合。我们的代理执行一个动作并从环境中获取更新或奖励,直到系统状态终止。在我们的例子中,这意味着迷宫中卡住了。
我们试图学习的东西是一个策略。这个策略是一个将状态映射到动作的函数或映射。它是一个关于我们系统中每个可能状态的最优动作的n维巨大表。
我们评估状态S的能力取决于假设它是一个马尔可夫决策过程(MDP)。正如我们之前指出的,这本书更关注实现而非理论;然而,MDP 对于真正理解 RL 至关重要,因此稍微详细地讨论它们是值得的。
我们使用大写S来表示系统的所有可能状态。在迷宫的情况下,这是迷宫边界内代理位置的所有可能位置。
我们使用小写s表示单个状态。对所有动作A和一个单独的动作a也是如此。
每对(s**, a)生成奖励分布R。它还生成P,称为转移概率,对于给定的(s, a),可能的下一个状态分布是s(t + 1)。
我们还有一个超参数,即折现因子(gamma)。一般来说,这是我们自己设置的超参数。这是为了预测奖励在给定时间步长时的相对价值。例如,假设我们希望为下一个时间步骤的预测奖励分配更大的价值,而不是三个时间步骤之后的奖励。我们可以在学习最优策略的目标的上下文中表示它;伪代码如下:
OptimalPolicy = max(sum(gamma x reward) for timestep t
进一步分解我们的 DQN 的概念组件,我们现在可以讨论价值函数。这个函数表示给定状态的累积奖励。例如,在我们的迷宫探索早期,累积预期奖励较低。这是因为我们的代理可以采取或占据的可能动作或状态数量。
Q 学习
现在,我们来到我们系统的真正核心:Q 值函数。这包括对于给定状态s和动作a1、a2的累积预期奖励。当然,我们对找到最优 Q 值函数很感兴趣。这意味着我们不仅有一个给定的(s, a),而且我们有可训练参数(权重和偏置在我们的 DQN 中的乘积的总和),我们在训练网络时修改或更新这些参数。这些参数允许我们定义一个最优策略,即适用于任何给定状态和代理可用动作的函数。这产生了一个最优 Q 值函数,理论上告诉我们的代理在任何步骤中最佳的行动是什么。一个不好的足球类比可能是 Q 值函数就像教练在新秀代理的耳边大喊指令。
因此,当以伪代码书写时,我们对最优策略的追求如下所示:
最优策略 = (状态,动作,theta)
在这里,theta指的是我们 DQN 的可训练参数。
那么,什么是 DQN?现在让我们详细检查我们网络的结构,更重要的是,它如何被使用。在这里,我们将引入我们的 Q 值函数,并使用我们的神经网络计算给定状态的预期奖励。
像我们迄今为止涵盖的网络一样,我们提前设置了许多超参数:
-
Gamma(未来奖励的折现因子,例如,0.95)
-
Epsilon(探索或利用,1.0,偏向探索)
-
Epsilon 衰减(随着时间的推移,从学习知识到利用知识的转变,例如,0.995)
-
Epsilon 衰减最小值(例如,0.01)
-
学习率(尽管使用自适应矩估计(Adam)仍然是默认设置)
-
状态大小
-
动作大小
-
批量大小(以 2 的幂为单位;从 32 开始,逐步调整)
-
节目数
我们还需要一个固定的顺序记忆来进行经验重播功能,将其大小设置为 2,000 条目。
优化和网络架构
至于我们的优化方法,我们使用 Adam。您可能还记得来自第二章的内容,什么是神经网络,我如何训练一个?,Adam 求解器属于使用动态学习率的求解器类别。在传统的 SGD 中,我们固定学习率。在这里,学习率针对每个参数进行设置,使我们在数据(向量)稀疏的情况下更具控制力。此外,我们使用根均方误差传播与先前梯度相比,理解我们优化表面形状的变化速率,并通过这样做改进我们的网络如何处理数据中的噪声。
现在,让我们谈谈我们神经网络的层次。我们的前两层是标准的前馈网络,采用整流线性单元(ReLU)激活:
输出 = 激活(点积(输入,权重) + 偏置)
第一个按状态大小进行调整(即系统中所有可能状态的向量表示)。
我们的输出层限制为可能动作的数量。这些通过将线性激活应用于我们第二隐藏维度的输出来实现。
我们的损失函数取决于任务和我们拥有的数据;通常我们会使用 MSE 或交叉熵损失。
记住,行动,然后重放!
除了我们神经网络中通常涉及的对象,我们需要为代理的记忆定义额外的函数。remember
函数接受多个输入,如下所示:
-
状态
-
行动
-
奖励
-
下一个状态
-
是否完成
它将这些值附加到内存中(即,一个按顺序排列的列表)。
现在我们定义代理如何在act
函数中采取行动。这是我们管理探索状态空间和利用学习知识之间平衡的地方。遵循以下步骤:
-
它接收一个值,即
state
。 -
从那里,应用
epsilon
;也就是说,如果介于 0 到 1 之间的随机值小于epsilon
,则采取随机动作。随着时间的推移,我们的 epsilon 会衰减,减少动作的随机性! -
然后我们将状态输入到我们的模型中,以预测应采取的行动。
-
从这个函数中,我们返回
max(a)
。
我们需要的额外函数是用于经验回放的。此函数的步骤如下:
-
创建一个随机样本(
batch_size
)从我们的 2000 单位内存中选择,这是由前面的remember
函数定义并添加的。 -
遍历
state
,action
,reward
,next_state
和isdone
输入,如下所示:-
设置
target
=reward
。 -
如果未完成,则使用以下公式:
-
估计的未来奖励 = 当前奖励 + (折现因子(gamma) 模型预测的下一个状态的预期最大奖励的调用)*
-
将未来的
reward
输入映射到模型(即从当前状态预测的未来reward
输入)。 -
最后,通过传递当前状态和单个训练时期的目标未来奖励来
重放
记忆。 -
使用
epsilon_decay
递减epsilon
。
这部分涵盖了 DQNs 和 Q-learning 的理论,现在是写一些代码的时候了。
在 Gorgonia 中使用 DQN 解决迷宫问题。
现在,是时候建立我们的迷宫求解器了!
使用 DQN 解决一个小 ASCII 迷宫有点像带着推土机去沙滩为你的孩子做沙堡:完全不必要,但你可以玩一个大机器。然而,作为学习 DQN 的工具,迷宫是无价的。这是因为游戏中的状态或动作数量有限,约束的表示也很简单(例如我们的迷宫的墙壁代表了我们的代理无法通过的障碍)。这意味着我们可以逐步执行我们的程序并轻松检查我们的网络在做什么。
我们将按照以下步骤进行:
-
为这段代码创建一个
maze.go
文件。 -
导入我们的库并设置我们的数据类型。
-
定义我们的
Maze{}
-
编写一个
NewMaze()
函数来实例化这个struct
。
我们还需要定义我们的Maze{}
辅助函数。这些包括以下内容:
-
CanMoveTo()
: 检查移动是否有效 -
Move()
: 将我们的玩家移动到迷宫中的一个坐标 -
Value()
: 返回给定动作的奖励 -
Reset()
: 将玩家设置到起始坐标
让我们来看看我们迷宫生成器代码的开头。这是一个摘录,其余的代码可以在书的 GitHub 仓库中找到:
...
type Point struct{ X, Y int }
type Vector Point
type Maze struct {
// some maze object
*mazegen.Maze
repr *tensor.Dense
iter [][]tile
values [][]float32
player, start, goal Point
// meta
r *rand.Rand
}
...
现在我们已经得到了我们需要生成和与迷宫交互的代码,我们需要定义简单的前馈全连接网络。到现在为止,这段代码应该对我们来说已经很熟悉了。让我们创建nn.go
:
...
type NN struct {
g *ExprGraph
x *Node
y *Node
l []FC
pred *Node
predVal Value
}
func NewNN(batchsize int) *NN {
g := NewGraph()
x := NewMatrix(g, of, WithShape(batchsize, 4), WithName("X"), WithInit(Zeroes()))
y := NewVector(g, of, WithShape(batchsize), WithName("Y"), WithInit(Zeroes()))
...
现在我们可以开始定义将利用这个神经网络的 DQN 了。首先,让我们创建一个memory.go
文件,其中包含捕获给定情节信息的基本struct
类型:
package main
type Memory struct {
State Point
Action Vector
Reward float32
NextState Point
NextMovables []Vector
isDone bool
}
我们将创建一个[]Memories
的记忆,并用它来存储每次游戏的 X/Y 状态坐标、移动向量、预期奖励、下一个状态/可能的移动以及迷宫是否已解决。
现在我们可以编辑我们的main.go
,把一切整合在一起。首先,我们定义跨m x n矩阵的可能移动:
package main
import (
"fmt"
"log"
"math/rand"
"time"
"gorgonia.org/gorgonia"
)
var cardinals = [4]Vector{
Vector{0, 1}, // E
Vector{1, 0}, // N
Vector{-1, 0}, // S
Vector{0, -1}, // W
}
接下来,我们需要我们的主DQN{}
结构,我们在其中附加了之前定义的神经网络、我们的 VM/Solver 以及我们 DQN 特定的超参数。我们还需要一个init()
函数来构建嵌入的前馈网络以及DQN
对象本身:
type DQN struct {
*NN
gorgonia.VM
gorgonia.Solver
Memories []Memory // The Q-Table - stores State/Action/Reward/NextState/NextMoves/IsDone - added to each train x times per episode
gamma float32
epsilon float32
epsDecayMin float32
decay float32
}
func (m *DQN) init() {
if _, err := m.NN.cons(); err != nil {
panic(err)
}
m.VM = gorgonia.NewTapeMachine(m.NN.g)
m.Solver = gorgonia.NewRMSPropSolver()
}
接下来是我们的经验replay()
函数。在这里,我们首先从记忆中创建批次,然后重新训练和更新我们的网络,逐步更新我们的 epsilon:
func (m *DQN) replay(batchsize int) error {
var N int
if batchsize < len(m.Memories) {
N = batchsize
} else {
N = len(m.Memories)
}
Xs := make([]input, 0, N)
Ys := make([]float32, 0, N)
mems := make([]Memory, N)
copy(mems, m.Memories)
rand.Shuffle(len(mems), func(i, j int) {
mems[i], mems[j] = mems[j], mems[i]
})
for b := 0; b < batchsize; b++ {
mem := mems[b]
var y float32
if mem.isDone {
y = mem.Reward
} else {
var nextRewards []float32
for _, next := range mem.NextMovables {
nextReward, err := m.predict(mem.NextState, next)
if err != nil {
return err
}
nextRewards = append(nextRewards, nextReward)
}
reward := max(nextRewards)
y = mem.Reward + m.gamma*reward
}
Xs = append(Xs, input{mem.State, mem.Action})
Ys = append(Ys, y)
if err := m.VM.RunAll(); err != nil {
return err
}
m.VM.Reset()
if err := m.Solver.Step(m.model()); err != nil {
return err
}
if m.epsilon > m.epsDecayMin {
m.epsilon *= m.decay
}
}
return nil
}
接下来是predict()
函数,在确定最佳移动(或具有最大预测奖励的移动)时调用。它接受迷宫中玩家的位置和一个单一移动,并返回我们神经网络对该移动的预期奖励:
func (m *DQN) predict(player Point, action Vector) (float32, error) {
x := input{State: player, Action: action}
m.Let1(x)
if err := m.VM.RunAll(); err != nil {
return 0, err
}
m.VM.Reset()
retVal := m.predVal.Data().([]float32)[0]
return retVal, nil
}
然后,我们为n
个情节定义我们的主训练循环,围绕迷宫移动并构建我们的 DQN 的记忆:
func (m *DQN) train(mz *Maze) (err error) {
var episodes = 20000
var times = 1000
var score float32
for e := 0; e < episodes; e++ {
for t := 0; t < times; t++ {
if e%100 == 0 && t%999 == 1 {
log.Printf("episode %d, %dst loop", e, t)
}
moves := getPossibleActions(mz)
action := m.bestAction(mz, moves)
reward, isDone := mz.Value(action)
score = score + reward
player := mz.player
mz.Move(action)
nextMoves := getPossibleActions(mz)
mem := Memory{State: player, Action: action, Reward: reward, NextState: mz.player, NextMovables: nextMoves, isDone: isDone}
m.Memories = append(m.Memories, mem)
}
}
return nil
}
我们还需要一个bestAction()
函数,根据选项切片和我们迷宫的实例选择最佳移动:
func (m *DQN) bestAction(state *Maze, moves []Vector) (bestAction Vector) {
var bestActions []Vector
var maxActValue float32 = -100
for _, a := range moves {
actionValue, err := m.predict(state.player, a)
if err != nil {
// DO SOMETHING
}
if actionValue > maxActValue {
maxActValue = actionValue
bestActions = append(bestActions, a)
} else if actionValue == maxActValue {
bestActions = append(bestActions, a)
}
}
// shuffle bestActions
rand.Shuffle(len(bestActions), func(i, j int) {
bestActions[i], bestActions[j] = bestActions[j], bestActions[i]
})
return bestActions[0]
}
最后,我们定义一个getPossibleActions()
函数来生成可能移动的切片,考虑到我们的迷宫和我们的小max()
辅助函数,用于找到float32s
切片中的最大值:
func getPossibleActions(m *Maze) (retVal []Vector) {
for i := range cardinals {
if m.CanMoveTo(m.player, cardinals[i]) {
retVal = append(retVal, cardinals[i])
}
}
return retVal
}
func max(a []float32) float32 {
var m float32 = -999999999
for i := range a {
if a[i] > m {
m = a[i]
}
}
return m
}
所有这些部分齐全后,我们可以编写我们的main()
函数完成我们的 DQN。我们从设置vars
开始,其中包括我们的 epsilon。然后,我们初始化DQN{}
并实例化Maze
:
然后我们启动我们的训练循环,一旦完成,尝试解决我们的迷宫:
func main() {
// DQN vars
// var times int = 1000
var gamma float32 = 0.95 // discount factor
var epsilon float32 = 1.0 // exploration/exploitation bias, set to 1.0/exploration by default
var epsilonDecayMin float32 = 0.01
var epsilonDecay float32 = 0.995
rand.Seed(time.Now().UTC().UnixNano())
dqn := &DQN{
NN: NewNN(32),
gamma: gamma,
epsilon: epsilon,
epsDecayMin: epsilonDecayMin,
decay: epsilonDecay,
}
dqn.init()
m := NewMaze(5, 10)
fmt.Printf("%+#v", m.repr)
fmt.Printf("%v %v\n", m.start, m.goal)
fmt.Printf("%v\n", m.CanMoveTo(m.start, Vector{0, 1}))
fmt.Printf("%v\n", m.CanMoveTo(m.start, Vector{1, 0}))
fmt.Printf("%v\n", m.CanMoveTo(m.start, Vector{0, -1}))
fmt.Printf("%v\n", m.CanMoveTo(m.start, Vector{-1, 0}))
if err := dqn.train(m); err != nil {
panic(err)
}
m.Reset()
for {
moves := getPossibleActions(m)
best := dqn.bestAction(m, moves)
reward, isDone := m.Value(best)
log.Printf("\n%#v", m.repr)
log.Printf("player at: %v best: %v", m.player, best)
log.Printf("reward %v, done %v", reward, isDone)
m.Move(best)
}
}
现在,让我们执行我们的程序并观察输出:
我们可以看到迷宫的尺寸,以及墙壁(1
)、明显路径(o
)、我们的玩家(2
)和迷宫出口(3
)的简单表示。接下来的一行,{1 0} {9 20}
,告诉我们玩家起点和迷宫出口的确切(X, Y)坐标。然后我们通过移动向量进行一次健全性检查,并开始我们的训练运行跨过n
剧集。
我们的智能体现在通过迷宫移动:
你可以尝试不同数量的剧集(和剧集长度),并生成更大更复杂的迷宫!
摘要
在本章中,我们深入了解了强化学习的背景以及什么是 DQN,包括 Q-learning 算法。我们看到了 DQN 相对于我们迄今讨论的其他架构提供了一种独特的解决问题的方法。我们没有像传统意义上的输出标签那样为 CNN 提供输出标签,例如我们在第五章中处理 CIFAR 图像数据时的情况。事实上,我们的输出标签是相对于环境状态的给定动作的累积奖励,因此你现在可以看到我们已经动态创建了输出标签。但是,这些标签不是网络的最终目标,而是帮助虚拟智能体在离散的可能性空间内做出智能决策。我们还探讨了我们可以在奖励或行动周围做出何种类型的预测。
现在你可以考虑使用 DQN(Deep Q-Network)的其他可能应用,更普遍地应用于一些问题,其中你有某种简单的奖励但没有数据的标签——典型的例子是在某种环境中的智能体。智能体和环境应该以尽可能通用的方式定义,因为你不仅仅局限于数学玩 Atari 游戏或尝试解决迷宫问题。例如,你网站的用户可以被视为一个智能体,而环境则是一个具有基于特征表示的内容空间。你可以使用这种方法来构建一个新闻推荐引擎。你可以参考进一步阅读部分的一篇论文链接,这可能是你想要作为练习实现的内容。
在下一章中,我们将探讨构建变分自编码器(VAE)以及 VAE 相对于标准自编码器的优势。
进一步阅读
-
使用深度强化学习玩 Atari 游戏,可在
www.cs.toronto.edu/~vmnih/docs/dqn.pdf
获取。 -
DRN:用于新闻推荐的深度强化学习框架,可在
www.personal.psu.edu/~gjz5038/paper/www2018_reinforceRec/www2018_reinforceRec.pdf
获取。
第八章:使用变分自编码器生成模型
在前一章中,我们已经探讨了 DQN 是什么以及我们可以在奖励或行动周围做出什么类型的预测。在本章中,我们将讨论如何构建一个 VAE 及其相对于标准自编码器的优势。我们还将探讨改变潜在空间维度对网络的影响。
让我们再来看看另一个自编码器。我们在第三章中已经介绍过自编码器,超越基础神经网络 – 自编码器和限制玻尔兹曼机,通过一个简单的例子生成了 MNIST 数字。现在我们将看看如何将其用于一个非常不同的任务——生成新的数字。
本章将涵盖以下主题:
-
变分自编码器 (VAEs) 介绍
-
在 MNIST 上构建 VAE
-
评估结果并更改潜在维度
变分自编码器介绍
VAE 在本质上与更基本的自编码器非常相似;它学习如何将其输入的数据编码为简化表示,并且能够基于该编码在另一侧重新创建它。然而,标准自编码器通常仅限于去噪等任务。对于生成任务,使用标准自编码器存在问题,因为标准自编码器中的潜在空间不适合这种目的。它们产生的编码可能不是连续的——它们可能聚集在非常具体的部分周围,并且可能难以进行插值。
然而,由于我们想构建一个更具生成性的模型,并且不想复制我们输入的相同图像,因此我们需要对输入进行变化。如果我们尝试使用标准自编码器来做这件事,那么最终结果很可能会相当荒谬,特别是如果输入与训练集有很大差异。
标准自编码器的结构看起来有点像这样:
我们已经构建了这个标准自编码器;然而,VAE 有一种稍微不同的编码方式,使其看起来更像以下的图表:
VAE 与标准自编码器不同;它通过设计具有连续的潜在空间,使我们能够进行随机采样和插值。它通过将数据编码为两个向量来实现:一个用于存储其均值估计,另一个用于存储其标准差估计。
使用这些均值和标准差,然后我们对编码进行采样,然后将其传递给解码器。解码器然后根据采样编码生成结果。因为我们在采样过程中插入了一定量的随机噪声,所以实际的编码每次都会稍微有所不同。
通过允许此变化发生,解码器不仅仅局限于特定的编码;相反,在训练过程中,它可以跨越潜在空间的更大区域进行操作,因为它不仅仅暴露于数据的变化,还暴露于编码的变化。
为了确保编码在潜在空间中彼此接近,我们在训练过程中引入了一种称为Kullback-Leibler(KL)散度的度量。KL 散度用于衡量两个概率函数之间的差异。在这种情况下,通过最小化这种散度,我们可以奖励模型使编码彼此靠近,反之亦然,当模型试图通过增加编码之间的距离来作弊时。
在 VAEs 中,我们使用标准正态分布(即均值为 0,标准差为 1 的高斯分布)来测量 KL 散度。我们可以使用以下公式计算:
klLoss = 0.5 * sum(mean² + exp(sd) - (sd + 1))
不幸的是,仅仅使用 KL 散度是不够的,因为我们所做的只是确保编码不会散布得太远;我们仍然需要确保编码是有意义的,而不仅仅是相互混合。因此,为了优化 VAE,我们还添加了另一个损失函数来比较输入和输出。这将导致相似对象的编码(或者在 MNIST 的情况下是手写数字)更接近聚类在一起。这将使解码器能够更好地重建输入,并且允许我们通过操纵输入,在连续的轴上产生不同的结果。
在 MNIST 上构建 VAE
熟悉 MNIST 数据集以及普通自编码器的结果,这是您未来工作的一个极好的起点。正如您可能记得的那样,MNIST 由许多手写数字图像组成,每个数字尺寸为 28 x 28 像素。
编码
由于这是一个自编码器,第一步是构建编码部分,看起来像这样:
首先,我们有我们的两个全连接层:
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 256), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(256, 128), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
每一层都使用 ReLU 激活函数:
// Set first layer to be copy of input
l0 = x
log.Printf("l0 shape %v", l0.Shape())
// Encoding - Part 1
if c1, err = gorgonia.Mul(l0, m.w0); err != nil {
return errors.Wrap(err, "Layer 1 Convolution failed")
}
if l1, err = gorgonia.Rectify(c1); err != nil {
return errors.Wrap(err, "Layer 1 activation failed")
}
log.Printf("l1 shape %v", l1.Shape())
if c2, err = gorgonia.Mul(l1, m.w1); err != nil {
return errors.Wrap(err, "Layer 1 Convolution failed")
}
if l2, err = gorgonia.Rectify(c2); err != nil {
return errors.Wrap(err, "Layer 1 activation failed")
}
log.Printf("l2 shape %v", l2.Shape())
然后,我们将它们连接到我们的均值和标准差层:
estMean := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 8), gorgonia.WithName("estMean"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
estSd := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 8), gorgonia.WithName("estSd"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
这些层以它们的形式使用,因此不需要特定的激活函数:
if l3, err = gorgonia.Mul(l2, m.estMean); err != nil {
return errors.Wrap(err, "Layer 3 Multiplication failed")
}
log.Printf("l3 shape %v", l3.Shape())
if l4, err = gorgonia.HadamardProd(m.floatHalf, gorgonia.Must(gorgonia.Mul(l2, m.estSd))); err != nil {
return errors.Wrap(err, "Layer 4 Multiplication failed")
}
log.Printf("l4 shape %v", l4.Shape())
抽样
现在,我们来讲一下 VAE(变分自编码器)背后的一部分魔力:通过抽样创建我们将馈送到解码器中的编码。作为参考,我们正在构建类似以下的东西:
如果您还记得本章早些时候的内容,我们需要在抽样过程中添加一些噪声,我们将其称为epsilon
。这些数据用于我们的抽样编码;在 Gorgonia 中,我们可以通过GaussianRandomNode
,输入参数为均值为0
,标准差为1
来实现这一点:
epsilon := gorgonia.GaussianRandomNode(g, dt, 0, 1, 100, 8)
然后,我们将这些信息馈送到我们的公式中以创建我们的抽样编码:
if sz, err = gorgonia.Add(l3, gorgonia.Must(gorgonia.HadamardProd(gorgonia.Must(gorgonia.Exp(l4)), m.epsilon))); err != nil {
return errors.Wrap(err, "Layer Sampling failed")
}
log.Printf("sz shape %v", sz.Shape())
上述代码可能难以阅读。更简单地说,我们正在做以下工作:
sampled = mean + exp(sd) * epsilon
这使我们使用均值和标准差向量加上噪声成分进行了采样编码。这确保了每次的结果并不完全相同。
解码
在我们获得了采样的编码之后,我们将其馈送给我们的解码器,这本质上与我们的编码器具有相同的结构,但是顺序相反。布局看起来有点像这样:
在 Gorgonia 中的实际实现看起来像下面这样:
// Decoding - Part 3
if c5, err = gorgonia.Mul(sz, m.w5); err != nil {
return errors.Wrap(err, "Layer 5 Convolution failed")
}
if l5, err = gorgonia.Rectify(c5); err != nil {
return errors.Wrap(err, "Layer 5 activation failed")
}
log.Printf("l6 shape %v", l1.Shape())
if c6, err = gorgonia.Mul(l5, m.w6); err != nil {
return errors.Wrap(err, "Layer 6 Convolution failed")
}
if l6, err = gorgonia.Rectify(c6); err != nil {
return errors.Wrap(err, "Layer 6 activation failed")
}
log.Printf("l6 shape %v", l6.Shape())
if c7, err = gorgonia.Mul(l6, m.w7); err != nil {
return errors.Wrap(err, "Layer 7 Convolution failed")
}
if l7, err = gorgonia.Sigmoid(c7); err != nil {
return errors.Wrap(err, "Layer 7 activation failed")
}
log.Printf("l7 shape %v", l7.Shape())
我们在最后一层上放置了Sigmoid
激活,因为我们希望输出比 ReLU 通常提供的更连续。
损失或成本函数
正如本章第一部分讨论的那样,我们优化了两种不同的损失源。
我们优化的第一个损失是输入图像与输出图像之间的实际差异;如果差异很小,这对我们来说是理想的。为此,我们展示输出层,然后计算到输入的差异。对于本例,我们使用输入和输出之间的平方误差之和,没有什么花哨的东西。在伪代码中,这看起来像下面这样:
valueLoss = sum(squared(input - output))
在 Gorgonia 中,我们可以按照以下方式实现它:
m.out = l7
valueLoss, err := gorgonia.Sum(gorgonia.Must(gorgonia.Square(gorgonia.Must(gorgonia.Sub(y, m.out)))))
if err != nil {
log.Fatal(err)
}
我们的另一个损失组件是 KL 散度度量,其伪代码如下所示:
klLoss = sum(mean² + exp(sd) - (sd + 1)) / 2
我们在 Gorgonia 中的实现更冗长,大量使用了Must
:
valueOne := gorgonia.NewScalar(g, dt, gorgonia.WithName("valueOne"))
valueTwo := gorgonia.NewScalar(g, dt, gorgonia.WithName("valueTwo"))
gorgonia.Let(valueOne, 1.0)
gorgonia.Let(valueTwo, 2.0)
ioutil.WriteFile("simple_graph_2.dot", []byte(g.ToDot()), 0644)
klLoss, err := gorgonia.Div(
gorgonia.Must(gorgonia.Sum(
gorgonia.Must(gorgonia.Sub(
gorgonia.Must(gorgonia.Add(
gorgonia.Must(gorgonia.Square(m.outMean)),
gorgonia.Must(gorgonia.Exp(m.outVar)))),
gorgonia.Must(gorgonia.Add(m.outVar, valueOne)))))),
valueTwo)
if err != nil {
log.Fatal(err)
}
现在,剩下的就是一些日常管理和将所有内容联系在一起。我们将使用 Adam 的solver
作为示例:
func (m *nn) learnables() gorgonia.Nodes {
return gorgonia.Nodes{m.w0, m.w1, m.w5, m.w6, m.w7, m.estMean, m.estSd}
}
vm := gorgonia.NewTapeMachine(g, gorgonia.BindDualValues(m.learnables()...))
solver := gorgonia.NewAdamSolver(gorgonia.WithBatchSize(float64(bs)), gorgonia.WithLearnRate(0.01))
现在让我们评估一下结果。
评估结果
您会注意到,我们的 VAE 模型的结果比我们的标准自编码器要模糊得多:
在某些情况下,它还可能在几个不同数字之间犹豫不决,例如在以下示例中,它似乎接近解码为 7 而不是 9:
这是因为我们明确要求分布彼此接近。如果我们试图在二维图上可视化这一点,它看起来会有点像下面的样子:
您可以从上一个示例中看到,它可以生成每个手写数字的多个不同变体,还可以在不同数字之间的某些区域中看到它似乎在几个不同数字之间变形。
更改潜在维度
在足够的 epoch 之后,MNIST 上的 VAE 通常表现相当良好,但确保这一点的最佳方法是测试这一假设并尝试几种其他尺寸。
对于本书描述的实现,这是一个相当快速的更改:
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 256), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(256, 128), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w5 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(8, 128), gorgonia.WithName("w5"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w6 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 256), gorgonia.WithName("w6"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w7 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(256, 784), gorgonia.WithName("w7"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
estMean := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 8), gorgonia.WithName("estMean"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
estSd := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 8), gorgonia.WithName("estSd"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
floatHalf := gorgonia.NewScalar(g, dt, gorgonia.WithName("floatHalf"))
gorgonia.Let(floatHalf, 0.5)
epsilon := gorgonia.GaussianRandomNode(g, dt, 0, 1, 100, 8)
这里的基本实现是使用八个维度;要使其在两个维度上工作,我们只需将所有8
的实例更改为2
,结果如下:
w0 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(784, 256), gorgonia.WithName("w0"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w1 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(256, 128), gorgonia.WithName("w1"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w5 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(2, 128), gorgonia.WithName("w5"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w6 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 256), gorgonia.WithName("w6"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
w7 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(256, 784), gorgonia.WithName("w7"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
estMean := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 2), gorgonia.WithName("estMean"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
estSd := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128, 2), gorgonia.WithName("estSd"), gorgonia.WithInit(gorgonia.GlorotU(1.0)))
floatHalf := gorgonia.NewScalar(g, dt, gorgonia.WithName("floatHalf"))
gorgonia.Let(floatHalf, 0.5)
epsilon := gorgonia.GaussianRandomNode(g, dt, 0, 1, 100, 2)
现在我们只需重新编译代码然后运行它,这使我们能够看到当我们尝试具有更多维度的潜在空间时会发生什么。
正如我们所见,很明显,2 个维度处于劣势,但随着我们逐步升级,情况并不那么明显。您可以看到,平均而言,20 个维度产生了明显更锐利的结果,但实际上,模型的 5 维版本可能已经足够满足大多数需求:
总结
您现在已经学会了如何构建 VAE 以及使用 VAE 比标准自编码器的优势。您还了解了变动潜在空间维度对网络的影响。
作为练习,您应该尝试在 CIFAR-10 数据集上训练该模型,并使用卷积层而不是简单的全连接层。
在下一章中,我们将看看数据流水线是什么,以及为什么我们使用 Pachyderm 来构建或管理它们。
进一步阅读
-
自编码变分贝叶斯, 迪德里克·P·金格玛,和 马克斯·威林
-
变分自编码器教程, 卡尔·多尔舍
-
ELBO 手术:切割变分证据下界的又一种方法,马修·D·霍夫曼 和 马修·J·约翰逊
-
潜在对齐与变分注意力,邓云天,Yoon Kim,贾斯汀·邱,郭小芬 和 亚历山大·M·拉什
第三部分:流水线、部署与未来!
本节主要讲述构建深度学习流水线、部署以及未来深度学习发展的所有内容!
本节包括以下章节:
-
第九章,构建深度学习流水线
-
第十章,扩展部署
第九章:构建深度学习管道
到目前为止,对于我们讨论过的各种深度学习架构,我们假设我们的输入数据是静态的。我们处理的是固定的电影评论集、图像或文本。
在现实世界中,无论您的组织或项目是否包括来自自动驾驶汽车、物联网传感器、安全摄像头或客户产品使用的数据,您的数据通常会随时间变化。因此,您需要一种方式来集成这些新数据,以便更新您的模型。数据的结构可能也会发生变化,在客户或观众数据的情况下,可能需要应用新的转换操作。此外,为了测试它们对预测质量的影响,可能会添加或删除维度,这些维度可能不再相关或违反隐私法规。在这些情况下,我们该怎么办?
Pachyderm 就是这样一个有用的工具。我们想知道我们拥有什么数据,我们在哪里拥有它,以及如何确保数据被输入到我们的模型中。
现在,我们将研究如何使用 Pachyderm 工具处理网络中的动态输入值。这将帮助我们准备好在现实世界中使用和部署我们的系统。
通过本章结束时,您将学到以下内容:
-
探索 Pachyderm
-
集成我们的 CNN
探索 Pachyderm
本书的重点是在 Go 中开发深度学习系统。因此,自然而然地,现在我们正在讨论如何管理输入到我们网络中的数据,让我们看看一个同样用 Go 编写的工具。
Pachyderm 是一个成熟且可扩展的工具,提供容器化数据管道。在这些管道中,你可以从数据到工具等一切需求都集中在一个地方,可以维护和管理部署,并对数据本身进行版本控制。Pachyderm 团队将他们的工具称为数据的 Git,这是一个有用的类比。理想情况下,我们希望对整个数据管道进行版本控制,以便知道用于训练的数据,以及由此给出的特定预测X。
Pachyderm 大大简化了管理这些管道的复杂性。Docker 和 Kubernetes 都在幕后运行。我们将在下一章节更详细地探讨这些工具,但现在我们只需知道它们对于实现可复制的构建以及可扩展的模型分布式训练至关重要。
安装和配置 Pachyderm
Pachyderm 有大量出色的文档可供参考,我们不会在这里重新讨论所有内容。相反,我们将带您了解基础知识,并构建一个简单数据管道的教程,以向我们在第六章中构建的 CNN 提供版本化图像数据,使用卷积神经网络进行对象识别。
首先,您需要安装 Docker Desktop 并为您的操作系统启用 Kubernetes。在本示例中,我们使用 macOS。
完整的安装说明请参阅docs.docker.com/docker-for-mac/install/
,以下是简要说明:
-
下载 Docker 的
.dmg
文件 -
安装或启动文件
-
启用 Kubernetes
要安装并运行 Pachyderm,请按照以下步骤操作:
- 要启用 Kubernetes,在启动 Docker 设置后选择适当的复选框,如下所示:
- 确保有几个绿色的圆形图标显示您的 Docker 和 Kubernetes 安装正在运行。如果是这样,我们可以通过进入终端并运行以下命令确认底层情况是否正常:
# kubectl get all
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 7m
- 在安装 Pachyderm 之前,请确保集群正在运行。我们使用 Homebrew 安装 Pachyderm,通过以下命令(请注意,您需要安装最新版本的 Xcode):
brew tap pachyderm/tap && brew install pachyderm/tap/pachctl@1.9
Updating Homebrew...
...
==> Tapping pachyderm/tap
Cloning into '/usr/local/Homebrew/Library/Taps/pachyderm/homebrew-tap'...
remote: Enumerating objects: 13, done.
remote: Counting objects: 100% (13/13), done.
remote: Compressing objects: 100% (12/12), done.
remote: Total 13 (delta 7), reused 2 (delta 0), pack-reused 0
Unpacking objects: 100% (13/13), done.
Tapped 7 formulae (47 files, 34.6KB).
==> Installing pachctl@1.9 from pachyderm/tap
...
==> Downloading https://github.com/pachyderm/pachyderm/releases/download/v1.9.0rc2/pachctl_1.9.0rc2_d
==> Downloading from https://github-production-release-asset-2e65be.s3.amazonaws.com/23653453/0d686a0
######################################################################## 100.0%
/usr/local/Cellar/pachctl@1.9/v1.9.0rc2: 3 files, 62.0MB, built in 26 seconds
- 现在您应该能够启动 Pachyderm 命令行工具了。首先,通过运行以下命令确认工具已成功安装并观察输出:
pachctl help
Access the Pachyderm API.
..
Usage:
pachctl [command]
Administration Commands:
..
- 我们几乎完成了集群设置,现在可以专注于获取和存储数据。最后一件事是使用以下命令在 Kubernetes 上部署 Pachyderm:
pachctl deploy local
no config detected at %q. Generating new config...
/Users/xxx/.pachyderm/config.json
No UserID present in config. Generating new UserID and updating config at /Users/xxx/.pachyderm/config.json
serviceaccount "pachyderm" created
clusterrole.rbac.authorization.k8s.io "pachyderm" created
clusterrolebinding.rbac.authorization.k8s.io "pachyderm" created
deployment.apps "etcd" created
service "etcd" created
service "pachd" created
deployment.apps "pachd" created
service "dash" created
deployment.apps "dash" created
secret "pachyderm-storage-secret" created
Pachyderm is launching. Check its status with "kubectl get all"
Once launched, access the dashboard by running "pachctl port-forward"
- 执行以下命令检查集群状态。如果您在部署后立即运行该命令,应该会看到容器正在创建中:
kubectl get all
NAME READY STATUS RESTARTS AGE
pod/dash-8786f7984-tb5k9 0/2 ContainerCreating 0 8s
pod/etcd-b4d789754-x675p 0/1 ContainerCreating 0 9s
pod/pachd-fbbd6855b-jcf6c 0/1 ContainerCreating 0 9s
- 然后它们会过渡到
Running
状态:
kubectl get all
NAME READY STATUS RESTARTS AGE
pod/dash-8786f7984-tb5k9 2/2 Running 0 2m
pod/etcd-b4d789754-x675p 1/1 Running 0 2m
pod/pachd-fbbd6855b-jcf6c 1/1 Running 0 2m
接下来的部分将介绍数据的准备工作。
将数据导入 Pachyderm
让我们准备我们的数据。在这种情况下,我们使用来自第六章《使用卷积神经网络进行对象识别》的 CIFAR-10 数据集。如果您需要恢复,请从多伦多大学的源头拉取数据,如下所示:
wget https://www.cs.toronto.edu/~kriz/cifar-10-binary.tar.gz
...
cifar-10-binary.tar.gz 100%[==================================>] 162.17M 833KB/s in 2m 26s
将数据提取到临时目录,并在 Pachyderm 中创建 repo
:
# pachctl create repo data
# pachctl list repo
NAME CREATED SIZE (MASTER)
data 8 seconds ago 0B
bash-3.2$
现在我们有了一个存储库,让我们用 CIFAR-10 图像数据填充它。首先,让我们创建各个目录并分解各种 CIFAR-10 文件,以便我们可以将整个文件夹(从我们的数据或训练集)直接倒入。
现在我们可以执行以下命令,然后确认数据已成功传输到 repo
:
#pachctl put file -r data@master -f data/
#pachctl list repo
NAME CREATED SIZE (MASTER)
data 2 minutes ago 202.8MiB
我们可以深入了解 repo
包含的文件的详细信息:
pachctl list file data@master
COMMIT NAME TYPE COMMITTED SIZE
b22db05d23324ede839718bec5ff219c /data dir 6 minutes ago 202.8MiB
集成我们的 CNN
现在我们将从前面章节的 CNN 示例中获取示例,并进行一些必要的更新,以使用 Pachyderm 提供的数据打包和部署网络。
创建我们的 CNN 的 Docker 镜像
Pachyderm 数据流水线依赖于预先配置的 Docker 镜像。互联网上有很多 Docker 教程,因此我们在这里保持简单,讨论利用简单部署步骤为任何 Go 应用程序带来优势的所需操作。
让我们来看看我们的 Dockerfile:
FROM golang:1.12
ADD main.go /main.go
ADD cifar/ /cifar/
RUN export GOPATH=$HOME/go && cd / && go get -d -v .
就是这样!我们只需从 Docker Hub 获取 Go 1.12 镜像并将我们的 CIFAR CNN 放入我们的构建中。我们 Dockerfile 的最后一部分是设置 GOPATH
并满足我们的依赖项(例如,安装 Gorgonia)的命令。
执行以下命令来构建 Docker 镜像并观察输出:docker build -t cifarcnn
:
Sending build context to Docker daemon 212.6MB
Step 1/4 : FROM golang:1.12
---> 9fe4cdc1f173
Step 2/4 : ADD main.go /main.go
---> Using cache
---> 5edf0df312f4
Step 3/4 : ADD cifar/ /cifar/
---> Using cache
---> 6928f37167a8
Step 4/4 : RUN export GOPATH=$HOME/go && cd / && go get -d -v .
---> Running in 7ff14ada5e7c
Fetching https://gorgonia.org/tensor?go-get=1
Parsing meta tags from https://gorgonia.org/tensor?go-get=1 (status code 200)
get "gorgonia.org/tensor": found meta tag get.metaImport{Prefix:"gorgonia.org/tensor", VCS:"git", RepoRoot:"https://github.com/gorgonia/tensor"} at https://gorgonia.org/tensor?go-get=1
...
Fetching https://gorgonia.org/dawson?go-get=1
Parsing meta tags from https://gorgonia.org/dawson?go-get=1 (status code 200)
get "gorgonia.org/dawson": found meta tag get.metaImport{Prefix:"gorgonia.org/dawson", VCS:"git", RepoRoot:"https://github.com/gorgonia/dawson"} at https://gorgonia.org/dawson?go-get=1
gorgonia.org/dawson (download)
Removing intermediate container 7ff14ada5e7c
---> 3def2cada165
Successfully built 3def2cada165
Successfully tagged cifar_cnn:latest
我们的容器现在已准备好被引用在 Pachyderm 数据管道规范中。
更新我们的 CNN 以保存模型
我们需要向我们的 CNN 示例中添加一个简单的函数,以确保生成的模型被保存,这样它就可以被 Pachyderm 作为对象管理。让我们将以下内容添加到 main.go
中:
func (m *convnet) savemodel() (err error) {
learnables := m.learnables()
var f io.WriteCloser
if f, err = os.OpenFile("model.bin", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644); err != nil {
return
}
defer f.Close()
enc := json.NewEncoder(f)
for _, l := range learnables {
t := l.Value().(*tensor.Dense).Data() // []float32
if err = enc.Encode(t); err != nil {
return
}
}
return nil
}
创建数据管道
现在我们需要在标准 JSON 中指定一个数据管道。在这里,我们将一个存储库映射到一个目录,并在训练或推断模式下执行我们的网络。
让我们来看看我们的 cifar_cnn.json
文件:
{
"pipeline": {
"name": "cifarcnn"
},
"transform": {
"image": "cifarcnn:latest",
"cmd": [
"go run main.go"
]
},
"enable_stats": true,
"parallelism_spec": {
"constant": "1"
},
"input": {
"pfs": {
"repo": "data",
"glob": "/"
}
}
}
我们在这里选择的选项很简单,您可以看到对 Docker 镜像、命令和开关的引用,以及 repo
和我们指定的挂载点。请注意 parallelism_spec
选项。将其设置为默认值 1
以上,允许我们根据需要扩展特定管道阶段;例如,在推断阶段。
现在我们可以从上述模板创建管道:
pachctl create pipeline -f cifar_cnn.json
如果没有错误,这将返回您到命令提示符。然后,您可以检查管道的状态:
pachctl list pipeline
NAME INPUT CREATED STATE / LAST JOB
cifarcnn data:/ 8 seconds ago running / running
我们可以动态调整并行度的级别,并通过更新我们的模板将配置推送到我们的集群中:
"parallelism_spec": {
"constant": "5"
},
然后,我们可以更新我们的集群并检查我们的作业和 k8s
集群的 pod 状态:
#pachctl update pipeline -f cifar_cnn.json
#pachctl list job
ID PIPELINE STARTED DURATION RESTART PROGRESS DL UL STATE
9339d8d712d945d58322a5ac649d9239 cifarcnn 7 seconds ago - 0 0 + 0 / 1 0B 0B running
#kubectl get pods
NAME READY STATUS RESTARTS AGE
dash-5c54745d97-gs4j2 2/2 Running 2 29d
etcd-b4d789754-x675p 1/1 Running 1 35d
pachd-fbbd6855b-jcf6c 1/1 Running 1 35d
pipeline-cifarcnn-v1-bwfrq 2/2 Running 0 2m
等待一段时间运行(并使用 pachctl logs
检查进度),我们可以看到我们成功的作业:
#pachctl list job
ID OUTPUT COMMIT STARTED DURATION RESTART PROGRESS DL UL STATE
9339d8d712d945d58322a5ac649d9239 cifarcnn 2 minutes ago About a minute 0 1 + 0 / 1 4.444KiB 49.86KiB success
可互换的模型
Pachyderm 管道的灵活性使您可以通过简单的更新或推送我们先前使用的 JSON 管道来轻松地将一个模型替换为另一个模型。
指定在 JSON 中指定管道的意义是什么?它是为了使其可重复!管道每次更新其数据(在我们的案例中,是为了对标签类别进行新预测)时都会重新处理数据。
在这里,我们更新 cifa_cnn.json
中的 image
标志,以引用我们容器化的 CNN 的一个版本,这个版本由于某些原因不包含 dropout:
"image": "pachyderm/cifar_cnn_train:nodropout"
然后我们可以像这样在集群上更新管道:
pachctl update pipeline -f cifar_cnn.json --reprocesses
将预测映射到模型
Pachyderm 的一个重要特性——特别是对于企业用例——是能够对模型和预测进行版本控制。比如说,你在预测客户偿还贷款的可能性时,看到了一批奇怪的预测结果。在排查模型为何做出这些决策的问题时,如果你正在对大团队进行多模型训练,那么翻阅电子邮件和提交历史记录将是一个糟糕的主意!
因此,从推断开始到模型,只需运行以下命令:
#pachctl list job
然后您可以获取相关的提交哈希并将其提供给以下命令,观察输出的详细信息:
#pachctl inspect job 9339d8d712d945d58322a5ac649d9239
...
Input:
{
"pfs": {
"name": "data",
"repo": "data",
"branch": "master",
"commit": "b22db05d23324ede839718bec5ff219c",
"glob": "/"
}
}
...
#pachctl inspect commit data@b22db05d23324ede839718bec5ff219c
Commit: data@b22db05d23324ede839718bec5ff219c
Original Branch: master
Started: 11 minutes ago
Finished: 11 minutes ago
Size: 202.8MiB
您可以看到用于生成此预测的模型的确切提交,预测的来源,以及用于训练模型的数据:
#pachctl list file data@adb293f8a4604ed7b081c1ff030c0480
COMMIT NAME TYPE COMMITTED SIZE
b22db05d23324ede839718bec5ff219c /data dir 11 minutes ago 202.8MiB
使用 Pachyderm 仪表板
从技术上讲,这是 Pachyderm 企业版的一个功能,但由于我们希望尽可能包容您的使用情况选择,无论您的用例如何,我们将简要介绍 仪表板 工具。即使您不需要一个简单的视觉概览您的管道和数据,也可以通过 14 天的试用来探索其功能集。
启动 http://localhost:30800
。您将看到一个基本的屏幕,其中包括以下内容:
-
仓库(保存我们的 CIFAR-10 数据)
-
管道
-
作业或日志
-
设置
让我们来看下面的截图:
正如你可能记得的那样,Pachyderm 希望你将你的数据仓库视为 Git 仓库。当你深入到下一个屏幕时,这一点显而易见:
仪表板为我们到目前为止一直在使用的 pachctl
工具提供了一个熟悉的 GUI 界面。
总结
在本章中,我们进行了实际操作,并了解了如何以可维护和可追踪的方式开始增强模型输入或输出组件的过程,以及可以使用哪些工具完成这些操作。从高层次来看,我们了解了数据管道的概念及其重要性,如何在 Pachyderm 中构建/部署/维护管道,以及用于可视化我们的仓库和管道的工具。
在下一章中,我们将深入探讨 Pachyderm 下面的一些技术,包括 Docker 和 Kubernetes,以及如何使用这些工具部署堆栈到云基础设施。
第十章:扩展部署
现在我们已经介绍了一个管理数据流水线的工具,现在是完全深入了解的时候了。我们的模型最终在软件的多层抽象下运行在我们在第五章中讨论过的硬件上,直到我们可以使用像go build --tags=cuda
这样的代码为止。
我们在 Pachyderm 上构建的图像识别流水线部署是本地的。我们以一种与部署到云资源相同的方式进行了部署,而不深入探讨其具体外观。现在我们将专注于这一细节。
通过本章末尾,您应能够做到以下几点:
-
识别和理解云资源,包括我们平台示例中的特定资源(AWS)。
-
知道如何将您的本地部署迁移到云上
-
理解 Docker 和 Kubernetes 以及它们的工作原理。
-
理解计算与成本之间的权衡。
在云中迷失(和找到)
拥有一台配备 GPU 和 Ubuntu 系统的强大台式机非常适合原型设计和研究,但是当你需要将模型投入生产并实际进行每日预测时,你需要高可用性和可扩展性的计算资源。这究竟意味着什么?
想象一下,你已经拿我们的卷积神经网络(CNN)例子,调整了模型并用自己的数据进行了训练,并创建了一个简单的 REST API 前端来调用模型。你想要围绕提供客户服务的一个小业务,客户支付一些费用,获得一个 API 密钥,可以提交图像到一个端点并获得一个回复,说明图像包含什么内容。作为服务的图像识别!听起来不错吧?
我们如何确保我们的服务始终可用和快速?毕竟,人们付费给你,即使是小的停机或可靠性下降也可能导致您失去客户。传统上的解决方案是购买一堆昂贵的服务器级硬件,通常是带有多个电源和网络接口的机架式服务器,以确保在硬件故障的情况下服务的连续性。您需要检查每个级别的冗余选项,从磁盘或存储到网络,甚至到互联网连接。
据说你需要两个一模一样的东西,这一切都需要巨大甚至是禁止性的成本。如果你是一家大型、有资金支持的初创公司,你有很多选择,但当然,随着资金曲线的下降,你的选择也会减少。自助托管变成了托管托管(并非总是如此,但对于大多数小型或初创用例而言是如此),这反过来又变成了存储在别人数据中心中的计算的标准化层,以至于你根本不需要关心底层硬件或基础设施。
当然,在现实中,并非总是如此。像 AWS 这样的云提供商将大部分乏味、痛苦(但必要)的工作,如硬件更换和常规维护,从中排除了。你不会丢失硬盘或陷入故障的网络电缆,如果你决定(嘿,一切都运作良好),为每天 10 万客户提供服务,那么你只需推送一个简单的基础架构规格变更。不需要给托管提供商打电话,协商停机时间,或去计算机硬件店。
这是一个非常强大的想法;你解决方案的实际执行——硅和装置的混合物,用于做出预测——几乎可以被视为一种事后的考虑,至少与几年前相比是如此。一般来说,维护云基础设施所需的技能集或方法被称为DevOps。这意味着一个人同时参与两个(或更多!)阵营。他们了解这些 AWS 资源代表什么(服务器、交换机和负载均衡器),以及如何编写必要的代码来指定和管理它们。
一个不断发展的角色是机器学习工程师。这是传统的 DevOps 技能集,但随着更多的Ops方面被自动化或抽象化,个人也可以专注于模型训练或部署,甚至扩展。让工程师参与整个堆栈是有益的。理解一个模型可并行化的程度,一个特定模型可能具有的内存需求,以及如何构建必要的分布式基础设施以进行大规模推理,所有这些都导致了一个模型服务基础设施,在这里各种设计元素不是领域专业化的产物,而是一个整体的集成。
构建部署模板
现在我们将组合所需的各种模板,以便在规模上部署和训练我们的模型。这些模板包括:
-
AWS 云形成模板:虚拟实例及相关资源
-
Kubernetes 或 KOPS 配置:K8s 集群管理
-
Docker 模板或 Makefile:创建图像以部署在我们的 K8s 集群上
我们在这里选择了一条特定的路径。AWS 有诸如弹性容器服务(ECS)和弹性 Kubernetes 服务(EKS)等服务,通过简单的 API 调用即可访问。我们在这里的目的是深入了解细节,以便您可以明智地选择如何扩展部署您自己的用例。目前,您可以更精细地控制容器选项,以及在将容器部署到纯净的 EC2 实例时如何调用您的模型。这些服务在成本和性能方面的权衡将在稍后的成本和性能折衷部分中进行讨论。
高级步骤
我们的迷你 CI/CD 流水线包括以下任务:
-
创建或推送训练或推理 Docker 镜像到 AWS ECS。
-
在 EC2 实例上创建或部署一个带有 Kubernetes 集群的 AWS 堆栈,以便我们可以执行下一步操作。
-
训练一个模型或进行一些预测!
现在我们将依次详细介绍每个步骤的细节。
创建或推送 Docker 镜像
Docker 肯定是一个引起了很多炒作的工具。除了人类时尚之外,其主要原因在于 Docker 简化了诸如依赖管理和模型集成等问题,使得可重复使用、广泛部署的构建成为可能。我们可以预先定义从操作系统获取的所需内容,并在我们知道依赖项是最新的时候将它们全部打包起来,这样我们所有的调整和故障排除工作都不会徒劳无功。
要创建我们的镜像并将其送到我们想要去的地方,我们需要两样东西:
-
Dockerfile:这定义了我们的镜像、Linux 的版本、要运行的命令以及在启动容器时运行的默认命令。
-
Makefile:这将创建镜像并将其推送到 AWS ECS。
让我们先看一下 Dockerfile:
FROM ubuntu:16.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
pkg-config \
rsync \
awscli \
wget \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN wget -nv https://storage.googleapis.com/golang/go1.12.1.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go1.12.1.linux-amd64.tar.gz
ENV GOPATH /home/ubuntu/go
ENV GOROOT /usr/local/go
ENV PATH $PATH:$GOROOT/bin
RUN /usr/local/go/bin/go version && \
echo $GOPATH && \
echo $GOROOT
RUN git clone https://github.com/PacktPublishing/Hands-On-Deep-Learning-with-Go
RUN go get -v gorgonia.org/gorgonia && \
go get -v gorgonia.org/tensor && \
go get -v gorgonia.org/dawson && \
go get -v github.com/gogo/protobuf/gogoproto && \
go get -v github.com/golang/protobuf/proto && \
go get -v github.com/google/flatbuffers/go && \
go get -v .
WORKDIR /
ADD staging/ /app
WORKDIR /app
CMD ["/bin/sh", "model_wrapper.sh"]
我们可以通过查看每行开头的大写声明来推断一般方法:
-
选择基础 OS 镜像使用
FROM
。 -
使用
ARG
设置启动。 -
使用
RUN
运行一系列命令,使我们的 Docker 镜像达到期望的状态。然后将一个staging
数据目录添加到/app
。 -
切换到新的
WORKDIR
。 -
执行
CMD
命令,我们的容器将运行。
现在我们需要一个 Makefile。这个文件包含了将构建我们刚刚在 Dockerfile 中定义的镜像并将其推送到亚马逊容器托管服务 ECS 的命令。
这是我们的 Makefile:
cpu-image:
mkdir -p staging/
cp model_wrapper.sh staging/
docker build --no-cache -t "ACCOUNTID.dkr.ecr.ap-southeast-2.amazonaws.com/$(MODEL_CONTAINER):$(VERSION_TAG)" .
rm -rf staging/
cpu-push: cpu-image
docker push "ACCOUNTID.dkr.ecr.ap-southeast-2.amazonaws.com/$(MODEL_CONTAINER):$(VERSION_TAG)"
与我们已经涵盖过的其他示例一样,我们正在使用sp-southeast-2
地区;但是,您可以自由指定您自己的地区。您还需要包含您自己的 12 位 AWS 帐户 ID。
从这个目录(时间未到时,请耐心等待!)我们现在可以创建和推送 Docker 镜像。
准备您的 AWS 账户
您将看到有关 API 访问 AWS 的通知,以便 KOPS 管理您的 EC2 和相关计算资源。与此 API 密钥相关联的帐户还需要以下 IAM 权限:
-
AmazonEC2FullAccess
-
AmazonRoute53FullAccess
-
AmazonS3FullAccess
-
AmazonVPCFullAccess
您可以通过进入 AWS 控制台并按照以下步骤操作来启用程序化或 API 访问:
-
点击 IAM
-
从左侧菜单中选择“用户”,然后选择您的用户
-
选择“安全凭证”。然后,您将看到“访问密钥”部分
-
点击“创建访问密钥”,然后按照说明操作
结果产生的密钥和密钥 ID 将被用于您的 ~/.aws/credentials
文件或作为 shell 变量导出,以供 KOPS 和相关部署和集群管理工具使用。
创建或部署 Kubernetes 集群
我们的 Docker 镜像必须运行在某个地方,为什么不是一组 Kubernetes pod 呢?这正是分布式云计算的魔力所在。使用中央数据源,例如 AWS S3,在我们的情况下,会为训练或推理启动许多微实例,最大化 AWS 资源利用率,为您节省资金,并为企业级机器学习应用提供所需的稳定性和性能。
首先,进入存放这些章节的仓库中的 /k8s/
目录。
我们将开始创建部署集群所需的模板。在我们的案例中,我们将使用 kubectl
的前端,默认的 Kubernetes 命令,它与主 API 进行交互。
Kubernetes
让我们来看看我们的 k8s_cluster.yaml
文件:
apiVersion: kops/v1alpha2
kind: Cluster
metadata:
creationTimestamp: 2018-05-01T12:11:24Z
name: $NAME
spec:
api:
loadBalancer:
type: Public
authorization:
rbac: {}
channel: stable
cloudProvider: aws
configBase: $KOPS_STATE_STORE/$NAME
etcdClusters:
- etcdMembers:
- instanceGroup: master-$ZONE
name: b
name: main
- etcdMembers:
- instanceGroup: master-$ZONE
name: b
name: events
iam:
allowContainerRegistry: true
legacy: false
kubernetesApiAccess:
- 0.0.0.0/0
kubernetesVersion: 1.9.3
masterInternalName: api.internal.$NAME
masterPublicName: api.hodlgo.$NAME
networkCIDR: 172.20.0.0/16
networking:
kubenet: {}
nonMasqueradeCIDR: 100.64.0.0/10
sshAccess:
- 0.0.0.0/0
subnets:
- cidr: 172.20.32.0/19
name: $ZONE
type: Public
zone: $ZONE
topology:
dns:
type: Public
masters: public
nodes: public
让我们来看看我们的 k8s_master.yaml
文件:
apiVersion: kops/v1alpha2
kind: InstanceGroup
metadata:
creationTimestamp: 2018-05-01T12:11:25Z
labels:
kops.k8s.io/cluster: $NAME
name: master-$ZONE
spec:
image: kope.io/k8s-1.8-debian-jessie-amd64-hvm-ebs-2018-02-08
machineType: $MASTERTYPE
maxSize: 1
minSize: 1
nodeLabels:
kops.k8s.io/instancegroup: master-$ZONE
role: Master
subnets:
- $ZONE
让我们来看看我们的 k8s_nodes.yaml
文件:
apiVersion: kops/v1alpha2
kind: InstanceGroup
metadata:
creationTimestamp: 2018-05-01T12:11:25Z
labels:
kops.k8s.io/cluster: $NAME
name: nodes-$ZONE
spec:
image: kope.io/k8s-1.8-debian-jessie-amd64-hvm-ebs-2018-02-08
machineType: $SLAVETYPE
maxSize: $SLAVES
minSize: $SLAVES
nodeLabels:
kops.k8s.io/instancegroup: nodes-$ZONE
role: Node
subnets:
- $ZONE
这些模板将被输入到 Kubernetes 中,以便启动我们的集群。我们将用来部署集群和相关 AWS 资源的工具是 KOPS。在撰写本文时,该工具的当前版本为 1.12.1,并且所有部署均已使用此版本进行测试;较早版本可能存在兼容性问题。
首先,我们需要安装 KOPS。与我们之前的所有示例一样,这些步骤也适用于 macOS。我们使用 Homebrew 工具来管理依赖项,并保持安装的局部化和合理性:
#brew install kops
==> Installing dependencies for kops: kubernetes-cli
==> Installing kops dependency: kubernetes-cli
==> Downloading https://homebrew.bintray.com/bottles/kubernetes-cli-1.14.2.mojave.bottle.tar.gz
==> Downloading from https://akamai.bintray.com/85/858eadf77396e1acd13ddcd2dd0309a5eb0b51d15da275b491
######################################################################## 100.0%
==> Pouring kubernetes-cli-1.14.2.mojave.bottle.tar.gz
==> Installing kops
==> Downloading https://homebrew.bintray.com/bottles/kops-1.12.1.mojave.bottle.tar.gz
==> Downloading from https://akamai.bintray.com/86/862c5f6648646840c75172e2f9f701cb590b04df03c38716b5
######################################################################## 100.0%
==> Pouring kops-1.12.1.mojave.bottle.tar.gz
==> Caveats
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
zsh completions have been installed to:
/usr/local/share/zsh/site-functions
==> Summary
/usr/local/Cellar/kops/1.12.1: 5 files, 139.2MB
==> Caveats
==> kubernetes-cli
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
zsh completions have been installed to:
/usr/local/share/zsh/site-functions
==> kops
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
zsh completions have been installed to:
/usr/local/share/zsh/site-functions
我们可以看到 KOPS 已经安装,连同 kubectl
一起,这是与 API 直接交互的默认 K8s 集群管理工具。请注意,Homebrew 经常会输出关于命令完成的警告类型消息,可以安全地忽略这些消息;但是,如果出现关于符号链接配置的错误,请按照说明解决与任何现有的 kubectl
本地安装冲突的问题。
集群管理脚本
我们还需要编写一些脚本,以允许我们设置环境变量并根据需要启动或关闭 Kubernetes 集群。在这里,我们将整合我们编写的模板,KOPS 或 kubectl
,以及我们在前几节中完成的 AWS 配置。
让我们来看看我们的 vars.sh
文件:
#!/bin/bash
# AWS vars
export BUCKET_NAME="hodlgo-models"
export MASTERTYPE="m3.medium"
export SLAVETYPE="t2.medium"
export SLAVES="2"
export ZONE="ap-southeast-2b"
# K8s vars
export NAME="hodlgo.k8s.local"
export KOPS_STATE_STORE="s3://hodlgo-cluster"
export PROJECT="hodlgo"
export CLUSTER_NAME=$PROJECT
# Docker vars
export VERSION_TAG="0.1"
export MODEL_CONTAINER="hodlgo-model"
我们可以看到这里的主要变量是容器名称、K8s 集群详细信息以及我们想要启动的 AWS 资源种类的一堆规格(及其所在的区域)。您需要用您自己的值替换这些值。
现在,我们可以编写相应的脚本,在完成部署或管理 K8s 集群后,清理我们的 shell 中的变量是一个重要部分。
让我们看看我们的 unsetvars.sh
文件:
#!/bin/bash
# Unset them vars
unset BUCKET_NAME
unset MASTERTYPE
unset SLAVETYPE
unset SLAVES
unset ZONE
unset NAME
unset KOPS_STATE_STORE
unset PROJECT
unset CLUSTER_NAME
unset VERSION_TAG
unset MODEL_CONTAINER
现在,启动我们的集群脚本将使用这些变量来确定集群的命名方式、节点数量以及部署位置。您将看到我们在一行中使用了一个小技巧来将环境变量传递到我们的 Kubernetes 模板或 KOPS 中;在未来的版本中,这可能不再需要,但目前这是一个可行的解决方法。
让我们看看我们的 cluster-up.sh
文件:
#!/bin/bash
## Bring up the cluster with kops
set -e
echo "Bringing up Kubernetes cluster"
echo "Using Cluster Name: ${CLUSTER_NAME}"
echo "Number of Nodes: ${SLAVES}"
echo "Using Zone: ${ZONE}"
echo "Bucket name: ${BUCKET_NAME}"
export PARALLELISM="$((4 * ${SLAVES}))"
# Includes ugly workaround because kops is unable to take stdin as input to create -f, unlike kubectl
cat k8s_cluster.yaml | envsubst > k8s_cluster-edit.yaml && kops create -f k8s_cluster-edit.yaml
cat k8s_master.yaml | envsubst > k8s_master-edit.yaml && kops create -f k8s_master-edit.yaml
cat k8s_nodes.yaml | envsubst > k8s_nodes-edit.yaml && kops create -f k8s_nodes-edit.yaml
kops create secret --name $NAME sshpublickey admin -i ~/.ssh/id_rsa.pub
kops update cluster $NAME --yes
echo ""
echo "Cluster $NAME created!"
echo ""
# Cleanup from workaround
rm k8s_cluster-edit.yaml
rm k8s_master-edit.yaml
rm k8s_nodes-edit.yaml
相应的 down
脚本将关闭我们的集群,并确保相应清理 AWS 资源。
让我们看看我们的 cluster-down.sh
文件:
#!/bin/bash
## Kill the cluster with kops
set -e
echo "Deleting cluster $NAME"
kops delete cluster $NAME --yes
构建和推送 Docker 容器
现在我们已经做好了准备,准备好了所有模板和脚本,我们可以继续实际制作 Docker 镜像,并将其推送到 ECR,以便进行完整集群部署之前使用。
首先,我们导出了本章前面生成的 AWS 凭证:
export AWS_DEFAULT_REGION=ap-southeast-2
export AWS_ACCESS_KEY_ID="<your key here>"
export AWS_SECRET_ACCESS_KEY="<your secret here>"
然后,我们获取容器存储库登录信息。这是必要的,以便我们可以推送创建的 Docker 镜像到 ECR,然后由我们的 Kubernetes 节点在模型训练或推理时拉取。请注意,此步骤假定您已安装 AWS CLI:
aws ecr get-login --no-include-email
此命令的输出应类似于以下内容:
docker login -u AWS -p xxxxx https://ACCOUNTID.dkr.ecr.ap-southeast-2.amazonaws.com
然后,我们可以执行 make cifarcnn-image
和 make cifarcnn-push
。这将构建 Dockerfile 中指定的 Docker 镜像,并将其推送到 AWS 的容器存储服务中。
在 K8s 集群上运行模型
您现在可以编辑我们之前创建的 vars.sh
文件,并使用您喜爱的命令行文本编辑器设置适当的值。您还需要创建一个存储 k8s 集群信息的存储桶。
完成这些步骤后,您可以启动您的 Kubernetes 集群:
source vars.sh
./cluster-up.sh
现在,KOPS 正通过 kubectl
与 Kubernetes 进行交互,以启动将运行您的集群的 AWS 资源,并在这些资源上配置 K8s 本身。在继续之前,您需要验证您的集群是否已成功启动:
kops validate cluster
Validating cluster hodlgo.k8s.local
INSTANCE GROUPS
NAME ROLE MACHINETYPE MIN MAX SUBNETS
master-ap-southeast-2a Master c4.large 1 1 ap-southeast-2
nodes Node t2.medium 2 2 ap-southeast-2
NODE STATUS
NAME ROLE READY
ip-172-20-35-114.ec2.internal node True
ip-172-20-49-22.ec2.internal master True
ip-172-20-64-133.ec2.internal node True
一旦所有 K8s 主节点返回 Ready
,您就可以开始在整个集群节点上部署您的模型!
执行此操作的脚本很简单,并调用 kubectl
以与我们的 cluster_up.sh
脚本相同的方式应用模板。
让我们看看我们的 deploy-model.sh
文件:
#!/bin/bash
# envsubst doesn't exist for OSX. needs to be brew-installed
# via gettext. Should probably warn the user about that.
command -v envsubst >/dev/null 2>&1 || {
echo >&2 "envsubst is required and not found. Aborting"
if [[ "$OSTYPE" == "darwin"* ]]; then
echo >&2 "------------------------------------------------"
echo >&2 "If you're on OSX, you can install with brew via:"
echo >&2 " brew install gettext"
echo >&2 " brew link --force gettext"
fi
exit 1;
}
cat ${SCRIPT_DIR}/model.yaml | envsubst | kubectl apply -f -
概要
现在,我们来详细介绍 Kubernetes、Docker 和 AWS 的底层细节,以及如何根据您的钱包能力将尽可能多的资源投入到模型中。接下来,您可以采取一些步骤,定制这些示例以适应您的用例,或者进一步提升您的知识水平:
-
将这种方法集成到您的 CI 或 CD 工具中(如 Bamboo、CircleCI、Puppet 等)
-
将 Pachyderm 集成到您的 Docker、Kubernetes 或 AWS 解决方案中
-
使用参数服务器进行实验,例如分布式梯度下降,进一步优化您的模型流水线