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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章: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 库:

  1. 确保你正在构建的模块的目标目录存在。 如果不存在,请使用以下命令创建它:
mkdir $GOPATH/src/gorgonia.org/gorgonia/cuda\ modules/target
  1. 运行 cudagen 来按如下方式构建模块:
cd $GOPATH/src/gorgonia.org/gorgonia/cmd/cudagen
go run main.go
  1. 程序执行后,请验证 /target 目录是否填充了表示我们在构建网络时将使用的 CUDA 化操作的文件,如下截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 现在初步工作已完成,让我们使用以下命令测试一切是否正常:
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 编译器。 这个设置在以下步骤中详细说明:

  1. 安装 GCC 环境;在 Windows 上做到这一点的最简单方法是安装 MSYS2。 你可以从 www.msys2.org/ 下载 MSYS2。

  2. 在安装 MSYS2 后,使用以下命令更新你的安装:

pacman -Syu
  1. 重新启动 MSYS2 并再次运行以下命令:
pacman -Su
  1. 安装 GCC 包如下:
pacman -S mingw-w64-x86_64-toolchain
  1. 安装 Visual Studio 2017 以获取与 CUDA 兼容的编译器。 在撰写本文时,你可以从 visualstudio.microsoft.com/downloads/ 下载此软件。 社区版工作正常;如果你有其他版本的许可证,它们也可以使用。

  2. 安装 CUDA。 你可以从 NVIDIA 网站下载此软件:developer.nvidia.com/cuda-downloads。 根据我的经验,如果无法使网络安装程序工作,请尝试本地安装程序。

  3. 然后,你还应该从 NVIDIA 安装 cuDNN:developer.nvidia.com/cudnn。 安装过程是简单的复制粘贴操作,非常简单。

  4. 设置环境变量,以便 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
  1. 环境现在应该正确设置,以编译支持 CUDA 的 Go 二进制文件。

现在,为了 Gorgonia,你需要首先按以下步骤进行一些操作:

  1. 首先确保为你将要构建的模块存在以下 target 目录:
$GOPATH/src/gorgonia.org/gorgonia/cuda\ modules/target
  1. 然后运行 cudagen 来按如下方式构建模块:
cd $GOPATH/src/gorgonia.org/gorgonia/cmd/cudagen
go run main.go
  1. 现在你已经安装好 cudatest,如下所示:
go install gorgonia.org/cu/cmd/cudatest cudatest
  1. 如果现在运行 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) 中。每个都有一个激活函数:对于 rz 是 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 提供了另一个可调整的特性。

最后,我们将包括一些函数,用于处理byteuint切片,使您可以轻松地进行比较/交换/评估它们:

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 以及如何调整一些超参数。

进一步阅读

第六章:使用卷积神经网络进行物体识别

现在是时候处理一些比我们之前的 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 网格中排列,因此这为我们提供了图像数据。

标签是从 09 的数字,分别表示以下各类之一:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

由于我们有兴趣将这些数据用于我们的深度学习算法,因此最好不要偏离我们在 01 之间的中间点。我们正在重用来自 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.11.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 版本的 Conv2dRectifyMaxPool2DDropout 调用替换为它们的 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 RonnebergerPhilipp FischerThomas Brox

  • 更快的 R-CNN:基于区域建议网络实现实时目标检测任少卿何凯明Ross Girshick孙剑

  • 长期递归卷积网络用于视觉识别和描述Jeff DonahueLisa Anne HendricksMarcus RohrbachSubhashini VenugopalanSergio GuadarramaKate SaenkoTrevor 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和动作a1a2的累积预期奖励。当然,我们对找到最优 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函数中采取行动。这是我们管理探索状态空间和利用学习知识之间平衡的地方。遵循以下步骤:

  1. 它接收一个值,即state

  2. 从那里,应用epsilon;也就是说,如果介于 0 到 1 之间的随机值小于epsilon,则采取随机动作。随着时间的推移,我们的 epsilon 会衰减,减少动作的随机性!

  3. 然后我们将状态输入到我们的模型中,以预测应采取的行动。

  4. 从这个函数中,我们返回max(a)

我们需要的额外函数是用于经验回放的。此函数的步骤如下:

  1. 创建一个随机样本(batch_size)从我们的 2000 单位内存中选择,这是由前面的remember函数定义并添加的。

  2. 遍历stateactionrewardnext_stateisdone输入,如下所示:

    1. 设置target = reward

    2. 如果未完成,则使用以下公式:

估计的未来奖励 = 当前奖励 + (折现因子(gamma) 模型预测的下一个状态的预期最大奖励的调用)*

  1. 将未来的reward输入映射到模型(即从当前状态预测的未来reward输入)。

  2. 最后,通过传递当前状态和单个训练时期的目标未来奖励来重放记忆。

  3. 使用epsilon_decay递减epsilon

这部分涵盖了 DQNs 和 Q-learning 的理论,现在是写一些代码的时候了。

在 Gorgonia 中使用 DQN 解决迷宫问题。

现在,是时候建立我们的迷宫求解器了!

使用 DQN 解决一个小 ASCII 迷宫有点像带着推土机去沙滩为你的孩子做沙堡:完全不必要,但你可以玩一个大机器。然而,作为学习 DQN 的工具,迷宫是无价的。这是因为游戏中的状态或动作数量有限,约束的表示也很简单(例如我们的迷宫的墙壁代表了我们的代理无法通过的障碍)。这意味着我们可以逐步执行我们的程序并轻松检查我们的网络在做什么。

我们将按照以下步骤进行:

  1. 为这段代码创建一个maze.go文件。

  2. 导入我们的库并设置我们的数据类型。

  3. 定义我们的Maze{}

  4. 编写一个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 相对于标准自编码器的优势。

进一步阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值