首页 > 其他分享 >Go-深度学习实用指南-全-

Go-深度学习实用指南-全-

时间:2024-07-23 15:00:17浏览次数:12  
标签:指南 return nil err gorgonia 实用 Go 我们 tensor

Go 深度学习实用指南(全)

原文:zh.annas-archive.org/md5/cea3750df3b2566d662a1ec564d1211d

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

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 并注册,以便将文件直接发送到您的邮箱。

按照以下步骤下载代码文件:

  1. 登录或注册 www.packt.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名并按照屏幕上的说明操作。

下载完成后,请确保使用最新版本的解压软件解压缩文件夹:

  • 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

粗体:指示一个新术语、一个重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词在文本中显示为这样。以下是一个示例:"从管理面板中选择系统信息。"

警告或重要提示显示如此。

提示和技巧显示如此。

联系我们

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

总体反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送邮件至[email protected]

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

盗版:如果您在互联网上发现我们作品的任何形式的非法复制,请向我们提供位置地址或网站名称,我们将不胜感激。请联系我们,链接为[email protected]

如果您有兴趣成为作者:如果您在某个专题上有专业知识并且有意撰写或贡献一本书籍,请访问authors.packtpub.com

评论

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

欲了解更多有关 Packt 的信息,请访问packt.com

第一部分:Go 语言中的深度学习、神经网络及其训练方法

本节介绍了深度学习DL)及其在 Go 语言中设计、实现和训练深度神经网络DNNs)所需的库。我们还涵盖了用于无监督学习的自编码器的实现,以及用于 Netflix 风格协同过滤系统的受限玻尔兹曼机RBM)的实现。

本节包含以下章节:

  • 第一章,Go 语言深度学习介绍

  • 第二章,什么是神经网络,如何训练?

  • 第三章,超越基本神经网络 - 自编码器和受限玻尔兹曼机

  • 第四章,CUDA - GPU 加速训练

第一章:在 Go 中深度学习介绍

本书将非常快速地进入在 Go 中实现Deep Neural NetworksDNNs)的实际操作方面。简单地说,本书的标题包含了它的目标。这意味着会有大量的技术细节、大量的代码以及(不是太多的)数学。当你最终关闭本书或关闭你的 Kindle 时,你将知道如何(以及为什么)实现现代可扩展的 DNN,并能够根据你所从事的任何行业或疯狂的科学项目的需求重新利用它们。

我们选择 Go 语言反映了我们的 DNNs 执行的操作类型所建立的 Go 库景观的成熟性。当然,在选择语言或库时进行的权衡存在很多争论,我们将在本章的一个部分专门讨论我们的观点,并为我们所做的选择辩护。

但是,没有上下文的代码意味着什么?为什么我们要关心这种看似混乱的线性代数、微积分、统计学和概率论的混合?为什么要使用计算机在图像中识别事物或在金融数据中识别异常模式?而且,也许最重要的是,这些任务的方法有什么共同之处?本书的初步部分将尝试提供一些这方面的背景信息。

科学探索,当被分解为代表其机构和行业专业化的学科时,是由进步思想所统治的。通过这一点,我们指的是一种动力,一种向前推进,朝着某种目标前进。例如,医学的理想目标是能够识别和治愈任何疾病或疾病。物理学家的目标是完全理解自然界的基本法则。进步趋势是朝着这个方向的。科学本身就是一种优化方法。那么Machine LearningML)的最终目标可能是什么呢?

让我们直接了当地说吧。我们认为这是创造人工通用智能AGI)的过程。这就是我们的目标:一个通用学习计算机,可以处理工作,让人们留下生活。正如我们在详细讨论Deep LearningDL)的历史时将看到的那样,顶尖人工智能实验室的创始人们一致认为 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 SystemsNIPS)在 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 3TensorFlow 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 库示例。它实现了生成词嵌入所需的两层神经网络,使用Word2vecGloVe。它是一个出色的实现,快速而干净。它非常好地实现了有限数量的特性,特定于通过Word2vecGloVe生成词嵌入的任务。

这是一个用于训练 DNNs 的核心功能示例,称为 SGD 的优化方法。这在由斯坦福团队开发的GloVe模型中使用。然而,代码特定集成于GloVe模型中,而在 DNNs 中使用的其他优化方法(如负采样和 skip-gram)对其无用。

对于 DL 任务来说,这可能是有用的,例如,用于生成文本语料库的嵌入式层或密集向量表示,这可以在长短期记忆LSTM)网络中使用,我们将在第五章中讨论,使用递归神经网络进行下一个单词预测。然而,我们需要的所有高级功能(例如梯度下降或反向传播)和模型特性(LSTM 单元本身)都不存在。

Go 或 Golang 的朴素贝叶斯分类和遗传算法

这两个库构成 Go 中 ML 库的另一组特定任务的示例。两者都写得很好,提供了特定功能的原语,但这些原语并不通用。在朴素贝叶斯分类器lib中,需要手动构建矩阵,然后才能使用,而传统的通用算法方法根本不使用矩阵。已经有一些工作正在将它们整合到 GA 中;然而,这项工作尚未进入我们在此引用的 GA 库。

Go 中的 ML

一个具有更通用集合有用功能的库是 GoLearn。虽然 DL 特定的功能在其愿望清单上,但它具有实现简单神经网络、随机森林、聚类和其他 ML 方法所需的基本原语。它严重依赖于 Gonum,这是一个提供float64complex128矩阵结构以及对它们进行线性代数操作的库。

让我们从代码的角度来看一下这意味着什么,如下所示:

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.Mulc.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 中构建模型是困难的。它需要以下三个基本步骤:

  1. 创建计算图。

  2. 输入数据。

  3. 执行图。

等等,什么是计算图?计算图是一个有向图,其中每个节点都是一个操作或一个变量。变量可以输入到操作中,操作将产生一个值。然后这个值可以输入到另一个操作中。更熟悉的术语来说,图像一个接收所有变量并产生结果的函数。

变量可以是任何东西;我们可以将其设定为单个标量值、一个向量(即数组)或一个矩阵。在 DL 中,我们通常使用一个更一般化的结构称为张量;张量可以被视为类似于n维矩阵。

以下截图显示了n维张量的可视化表示:

我们将方程表示为图形,因为这样更容易优化我们模型的性能。这是通过将每个节点放入有向图中来实现的,这样我们就知道它的依赖关系。由于我们将每个节点建模为独立的代码片段,我们知道它执行所需的只是它的依赖关系(可以是其他节点或其他变量)。此外,当我们遍历图形时,我们可以知道哪些节点彼此独立,并可以并行运行它们。

例如,考虑以下图示:

因为 ABC 是独立的,我们可以轻松并行计算它们。计算 V 需要 AB 准备好。然而,W 只需要 B 准备好。这一过程一直延续到下一个级别,直到我们准备计算最终输出 Z

简单示例 - 加法。

理解这一切如何组合在一起的最简单方法是通过构建一个简单的示例。

首先,让我们实现一个简单的图形来将两个数字加在一起 - 基本上是这样的:c = a + b

  1. 首先,让我们导入一些库,最重要的是 Gorgonia,如下所示:
package main
import (
     "fmt"
     "log"
     . "gorgonia.org/gorgonia"
 )
  1. 然后,让我们开始我们的主函数,如下所示:
func main() {
  g := NewGraph()
}
  1. 接下来,让我们添加我们的标量,如下所示:
a = NewScalar(g, Float64, WithName("a"))
b = NewScalar(g, Float64, WithName("b"))
  1. 然后,非常重要的是,让我们定义我们的操作节点,如下所示:
c, err = Add(a, b)
if err != nil {
              log.Fatal(err)
              }

注意,现在c实际上没有值;我们只是定义了我们计算图的一个新节点,因此在它有值之前我们需要执行它。

  1. 要执行它,我们需要为其创建一个虚拟机对象,如下所示:
machine := NewTapeMachine(g)
  1. 然后,设置ab的初始值,并继续让机器执行我们的图,如下所示:
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),
)

您会注意到,这一次我们做了一些不同的事情,如下所列:

  1. 我们首先声明一个带有我们想要的矩阵值的数组

  2. 然后,我们从该矩阵创建一个张量,形状为 2 x 2,因为我们希望是一个 2 x 2 的矩阵

  3. 在这一切之后,我们在图中创建了一个新的节点用于矩阵,并将其命名为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]

可视化图形

在许多情况下,通过将ioioutil添加到您的导入中,并将以下行添加到您的代码中,可以非常方便地可视化图形:

ioutil.WriteFile("simple_graph.dot", []byte(g.ToDot()), 0644)

这将生成一个 DOT 文件;您可以在 GraphViz 中打开它,或者更方便地将其转换为 SVG。您可以通过安装 GraphViz 并在命令行中输入以下内容,在大多数现代浏览器中查看它:

dot -Tsvg simple_graph.dot -O

这将生成simple_graph.dot.svg;您可以在浏览器中打开它以查看图的渲染,如下所示:

你可以看到,在我们的图中,我们有两个输入,Wx,然后将其馈送到我们的运算符中,这是一个矩阵乘法和一个向量,给我们的结果同样是另一个向量。

构建更复杂的表达式

当然,我们已经大部分讲解了如何构建简单的方程;但是,如果你的方程稍微复杂一些,例如以下情况会发生:

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]

计算图现在看起来像以下的屏幕截图:

你可以看到,Wx都输入到第一个操作中(我们的乘法操作),稍后又输入到我们的加法操作中以生成我们的结果。

这是使用 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
}

我们将网络的关键组件嵌入名为nnstruct中。这不仅使我们的代码易读,而且在我们希望对深度(多层)网络的每一层的多个权重执行优化过程(SGD/反向传播)时,它也能很好地扩展。正如你所见,除了每层的权重外,我们还有一个表示网络预测的节点,以及*ExprGraph本身。

我们的网络有两层。这些是在网络的前向传递过程中计算的。前向传递代表我们在计算图中希望对值节点执行的所有数值转换。

具体来说,我们有以下内容:

  • l0:输入矩阵,我们的X

  • w0:可训练参数,我们网络的权重,将通过 SGD 算法进行优化

  • l1:对l0w0的点积应用 Sigmoid 函数的值

  • pred:表示网络预测的节点,反馈到nn struct的适当字段

那么,我们在这里的目标是什么?

我们希望建立一个系统,该系统学习一个最能够模拟列序列0, 0, 1, 1的函数。现在,让我们深入研究一下!

你的第一个神经网络

让我们从基本的包命名和导入我们需要的包开始。这个过程分为以下步骤进行:

  1. 在这个示例中,我们将使用与 Gorgonia 相同开发者提供的tensor库。我们将用它来支持与计算图中的各自节点关联的张量:
package main

import (
    "fmt"
    "io/ioutil"
    "log"

    . "gorgonia.org/gorgonia"
    "gorgonia.org/tensor"
)
  1. 创建一个变量,将使用以下代码捕获错误:
var err error

现在我们可以定义用于嵌入神经网络图、权重和预测(输出)的主struct。在更深的网络中,我们会有w0w1w2w3等,一直到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层网络的权重梯度。扩展这一点意味着添加w1w2w3wn,如下面的代码所示:

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),
    )

让我们看看我们的图现在加入了Xy之后的样子:

我们可以看到个别节点wXy。就像我们查看w时所做的那样,请注意每个节点的类型、ShapeValue

现在,我们调用我们的nnfwd方法,并真正构建我们的图,包括Xyw之间的计算关系,如下面的代码所示:

// 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函数非常简单;它接受一个值然后返回01

这是一个非常简单的函数,对深度学习来说并不特别有用。这是因为这个函数的梯度是一个恒定的零,这意味着当我们进行反向传播时,它将不断产生零,这在我们执行反向传播时几乎没有(如果有的话)任何改进。

线性函数

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
    }
}

前述函数的图表如下所示:

请注意,此图表已经进行了强调修改,因此yx的斜率实际上是0.1,而不是通常认为的0.01,这是被视为 Leaky ReLU 的特征之一。

由于它总是产生一个小的梯度,这应该有助于防止神经元永久性地死亡,同时仍然给我们带来 ReLU 的许多好处。

Sigmoid 函数

Sigmoid 或逻辑函数也相对流行,如下所示:

func sigmoid(x){
    return 1 / (1 + Exp(-x))
}

输出如下:

Sigmoid 还有一个有用的特性:它可以将任何实数映射回在01之间的范围内。这对于生成偏好于在01之间输出的模型非常有用(例如,用于预测某事物的概率模型)。

它也具有我们正在寻找的大多数属性,如下所列:

  • 它是非线性的。因此,堆叠多层这样的函数不一定会导致与单层相同的结果。

  • 它是可微分的。因此,它适用于反向传播。

  • 它是单调递增的。

然而,其中一个缺点是与 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。适用的求解器选项包括WithL1RegWithL2RegWithBatchSizeWithClipWithLearnRate

让我们使用本章开头的代码示例,但不使用普通的 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 个值。例如,我们按照以下图表构建我们的层:

这种结构通常被描述为具有两个隐藏层,每个层分别有300100个单元。这可以用以下代码在 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 激活函数,因为理想情况下,输出层应该被限制在01之间:

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))

输入和输出

注意,这次我们的输入和输出是相同的。这意味着我们不需要为数据集获取标签,并且在运行虚拟机时,我们可以将xy都设置为我们的输入数据:

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 不是一个深度网络。因此,我们将使用隐藏单元输出做一些与网络的可见单元重构的不同尝试。我们将通过使用隐藏单元作为网络训练的后向或重构阶段的输入来实现这一点。

后向传递看起来与前向传递相似,并通过以下步骤执行:

  1. 隐藏层激活作为输入乘以连接权重

  2. 可见单元偏置被添加到所有节点乘积的总和中

  3. 计算重构误差,或者预测输入与实际输入(我们从前向传递中了解到的)的差异

  4. 该错误用于更新权重以尽量减少重构误差

两个状态(隐藏层预测激活和可见层预测输入)共同形成联合概率分布。

如果您对数学有兴趣,两个传递的公式如下所示:

  • 前向传递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 文件,该文件使用冒号作为分隔符,以获取 useridsratingsmovieids。然后,我们可以将 movieidsmovies.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 的辅助函数:

  1. 首先,我们添加用于我们的 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
   }
}
  1. 现在,我们添加了函数来采样我们的可见层或隐藏层:
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
}
  1. 接着,我们添加几个处理权重更新传播的函数:
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)
}
  1. 现在,我们添加了 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
}
  1. 接下来,我们添加实例化我们的 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,
   }
}
  1. 最后,我们训练模型:
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查阅。

  • 限制玻尔兹曼机模拟人类选择,这篇论文探讨了限制玻尔兹曼机在建模人类选择(例如我们例子中对某种类型电影的偏好)方面的有效性,并提出了在心理学等其他领域的应用,可在

    标签:指南,return,nil,err,gorgonia,实用,Go,我们,tensor
    From: https://www.cnblogs.com/apachecn/p/18318407

相关文章

  • PyTorch-1-x-模型训练加速指南-全-
    PyTorch1.x模型训练加速指南(全)原文:zh.annas-archive.org/md5/787ca80dbbc0168b14234d14375188ba译者:飞龙协议:CCBY-NC-SA4.0前言你好!我是一名专注于高性能计算(HPC)的系统分析师和学术教授。是的,你没看错!我不是数据科学家。那么,你可能会想知道我为什么决定写一本关于机器......
  • golang 构建Web服务器
    main.gopackagemainimport("fmt" "log" "net/http")funcloggingMiddleware(nexthttp.Handler)http.Handler{ returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){ log.Printf("%s%s\n"......
  • Django视图与URLs路由详解
            在DjangoWeb框架中,视图(Views)和URLs路由(URLrouting)是Web应用开发的核心概念。它们共同负责将用户的请求映射到相应的Python函数,并返回适当的响应。本篇博客将深入探讨Django的视图和URLs路由系统,提供实际的代码示例和操作指导,确保读者能够具体而实际地了解如......
  • Golang异步编程方式和技巧
    Golang异步编程方式和技巧原创 腾讯程序员 腾讯技术工程  2024年04月23日18:00 广东 12人听过Golang基于多线程、协程实现,与生俱来适合异步编程,当我们遇到那种需要批量处理且耗时的操作时,传统的线性执行就显得吃力,这时就会想到异步并行处理。下面介绍一些异步......
  • 自动 Wi-Fi 设置网页:实施指南
    我想创建一个网页,我有自己的WiFi,当我给用户一个二维码时,直接转到用户填写手机号码和OTP验证的网页,验证后Wifi自动连接30分钟解决问题自动连接wifi结果就是这样解决了很多人的问题自动Wi-Fi设置网页:实施指南创建自动连接Wi-Fi的网页需要结合前端网页设计和后端逻......
  • django学习入门系列之第四点《案例 走马灯(让字幕滚动)》
    文章目录往期回顾<!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><title>Title</title></head><body><spanid="txt">欢迎中国联通领导过来指导</span><scri......
  • gofiber sse
    packagemainimport( "bufio" "fmt" "log" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/valyala/fasthttp")varindex=[]byte......
  • Python 协议和 Django 模型
    假设我有一个简单的协议A和一个未能实现该协议的类B:fromtypingimportProtocolclassA(Protocol):deffoo(self)->str:...classB:pass当下面的代码进行类型检查时,Mypy将正确地抱怨x:A=B()mypy.error:Incompatibletypes......
  • 关于使用阿里云ECS搭建114cha.com网站的避坑指南
    阿里云ECS(ElasticComputeService)作为弹性计算服务,提供了灵活的云服务器资源,适合各类网站和应用的部署。然而,对于初次使用ECS搭建网站的用户来说,可能会遇到一些挑战。本文旨在帮助用户顺利搭建网站,并避免一些常见的坑。一、准备工作1.购买ECS实例在阿里云官网购买ECS实例......
  • MySQL server has gone away
    环境:Os:Centos7DB:mysql5.7.39 导入大量数据的时候报错误:[root@localhost~]#mysql-hlocalhost-uroot-pmysql--default-character-set=utf8-Ddev_test</tmp/db_test_20240723mysql:[Warning]Usingapasswordonthecommandlineinterfacecanbeinsecure.......