pytorch 使用 C++ 前端构建神经网络

1,398 阅读26分钟

PyTorch C++ 前端是 PyTorch 机器学习框架的纯 C++ 接口。虽然 PyTorch 的主要接口是 Python,但 Python API 位于大量底层 C++ 代码库之上,提供了基础数据结构和功能,例如张量和自动微分。C++ 前端公开了一个纯 C++11 API,使用机器学习训练和推理所需的工具扩展了这个底层 C++ 代码库。这包括用于神经网络建模的内置通用组件集合;使用自定义模块扩展此集合的 API;流行的优化算法库,例如随机梯度下降;带有 API 的并行数据加载器,用于定义和加载数据集;序列化例程等。

本教程将引导您完成使用 C++ 前端训练模型的端到端示例。具体来说,我们将训练DCGAN(一种生成模型)来生成 MNIST 数字的图像。虽然从概念上讲是一个简单的示例,但它应该足以让您快速了解 PyTorch C++ 前端,并满足您对训练更复杂模型的兴趣。将从一些激励性的话开始,说明为什么要使用 C++ 前端,然后直接深入定义和训练我们的模型。

提示

观看来自 CppCon 2018的闪电演讲,了解有关 C++ 前端的快速(和幽默)演示。

提示

本说明全面概述了 C++ 前端的组件和设计理念。

提示

PyTorch C++ 生态系统的文档可在 pytorch.org/cppdocs获得。在那里可以找到高级描述以及 API 级文档。

1. 动机

在开始激动人心的 GAN 和 MNIST 数字之旅之前,先讨论一下为什么要使用 C++ 前端而不是 Python 前端。我们(PyTorch 团队)创建了 C++ 前端,以便在 Python 无法使用或根本不适合这项工作的环境中进行研究工作。此类环境包括:

  • 低延迟系统:可能希望在具有高帧数和低延迟要求的纯 C++ 游戏引擎中进行强化学习研究。使用纯 C++ 库比 Python 库更适合这种环境。由于 Python 解释器比较慢,Python 可能根本不适合。
  • 高并发多线程环境:由于全局解释器锁 (GIL),Python 一次不能运行多个系统线程。多处理是一种替代方案,但不具备可扩展性,并且具有明显的缺点。C++ 没有这样的约束,线程易于使用和创建。需要大量并行化的模型,例如深度神经进化中使用的模型,可以从中受益。
  • 现有的 C++ 代码库:您可能是现有 C++ 应用程序的所有者,从在后端服务器中提供网页到在照片编辑软件中渲染 3D 图形,可能希望将机器学习方法集成到现有系统中。C++ 前端允许您保留在 C++ 中,省去了在 Python 和 C++ 之间来回绑定的麻烦,同时保留了传统 PyTorch (Python) 体验的大部分灵活性和直观性。

C++ 前端并不打算与 Python 前端竞争。它旨在补充。研究人员和工程师都喜欢 PyTorch 的简单性、灵活性和直观的 API。我们的目标是确保可以在所有可能的环境中利用这些核心设计原则,包括上述环境。如果其中一种场景很好地描述了您的用例,或者只是感兴趣或好奇,请跟随我们在以下文中详细探索 C++ 前端。

提示

C++ 前端尝试提供尽可能接近 Python 前端的 API。如果您对 Python 前端有经验并且曾经问自己“如何使用 C++ 前端来做 X?”,请按照在 Python 中的方式编写代码,并且通常在 C++ 中可以使用相同的函数和方法就像在 Python 中一样(只要记住用双冒号替换点)。

2. 编写一个基本应用程序

现在从头编写一个最小的 C++ 应用程序开始,以验证在设置和构建环境方面是否在同一基础上。首先,需要获取LibTorch发行版——现成的 zip 存档,它打包了使用 C++ 前端所需的所有相关头文件、库和 CMake 构建文件。LibTorch 发行版可在适用 于 Linux、MacOS 和 Windows的PyTorch 网站上下载。本教程的其余部分将假设一个基本的 Ubuntu Linux 环境,但是也可以在 MacOS 或 Windows 上。

提示

安装 PyTorch 的 C++ 发行版的注释更详细地描述了以下步骤。

提示

在 Windows 上,调试和发布版本与 ABI 不兼容。如果您打算在调试模式下构建项目,请尝试 LibTorch 的调试版本。此外,请确保在cmake --build . 下面的行中指定正确的配置。

第一步是通过从 PyTorch 网站检索到的链接下载 LibTorch 发行版。对于普通的 Ubuntu Linux 环境,这意味着运行:

# If you need e.g. CUDA 9.0 support, please replace "cpu" with "cu90" in the URL below.
wget https://download.pytorch.org/libtorch/nightly/cpu/libtorch-shared-with-deps-latest.zip
unzip libtorch-shared-with-deps-latest.zip

接下来,编写一个名为dcgan.cpp的C++ 文件,其中包含 torch/torch.h现在简单地打印出一个3x3单位矩阵:

#include <torch/torch.h>
#include <iostream>

int main() {
  torch::Tensor tensor = torch::eye(3);
  std::cout << tensor << std::endl;
}

为了构建这个小型应用程序以及稍后完整的训练脚本,使用这个CMakeLists.txt文件:

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(dcgan)

find_package(Torch REQUIRED)

add_executable(dcgan dcgan.cpp)
target_link_libraries(dcgan "${TORCH_LIBRARIES}")
set_property(TARGET dcgan PROPERTY CXX_STANDARD 14)

提示:

虽然 CMake 是 LibTorch 的推荐构建系统,但这并不是强制要求。也可以使用 Visual Studio 项目文件、QMake、普通 Makefile 或自己觉得舒服的任何其他构建环境。但是,默认不为此提供开箱即用的支持。

上述 CMake 文件中的第 4 行:find_package(Torch REQUIRED). 这指示 CMake 查找 LibTorch 库的构建配置。为了让 CMake 知道在哪里可以找到这些文件,在调用cmake时必须设置环境变量CMAKE_PREFIX_PATH . 在这样做之前,dcgan应用程序的目录结构如下所示:

dcgan/
  CMakeLists.txt
  dcgan.cpp

将 LibTorch 发行版解压缩到 /path/to/libtorch. 请注意,这必须是绝对路径。特别是,设置CMAKE_PREFIX_PATH为类似的东西../../libtorch 会以意想不到问题。应该用$PWD/../../libtorch得到对应的绝对路径。现在,已准备好构建应用程序:

root@fa350df05ecf:/home# mkdir build
root@fa350df05ecf:/home# cd build
root@fa350df05ecf:/home/build# cmake -DCMAKE_PREFIX_PATH=/path/to/libtorch ..
-- The C compiler identification is GNU 5.4.0
-- The CXX compiler identification is GNU 5.4.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Looking for pthread.h
-- Looking for pthread.h - found
-- Looking for pthread_create
-- Looking for pthread_create - not found
-- Looking for pthread_create in pthreads
-- Looking for pthread_create in pthreads - not found
-- Looking for pthread_create in pthread
-- Looking for pthread_create in pthread - found
-- Found Threads: TRUE
-- Found torch: /path/to/libtorch/lib/libtorch.so
-- Configuring done
-- Generating done
-- Build files have been written to: /home/build
root@fa350df05ecf:/home/build# cmake --build . --config Release
Scanning dependencies of target dcgan
[ 50%] Building CXX object CMakeFiles/dcgan.dir/dcgan.cpp.o
[100%] Linking CXX executable dcgan
[100%] Built target dcgan

上面,首先在dcgan目录中创建了一个文件夹build,然后进入这个文件夹,运行cmake命令生成必要的构建(Make)文件,最后通过运行cmake --build . --config Release准备好了可执行的二进制文件并完成了基本项目配置:

root@fa350df05ecf:/home/build# ./dcgan
1  0  0
0  1  0
0  0  1
[ Variable[CPUFloatType]{3,3} ]

运行后打印出来一个单位矩阵!

3. 定义神经网络模型

现在已经配置了基本环境,可以深入了解本教程中更有趣的部分。首先,将讨论如何在 C++ 前端定义模块并与之交互。将从基本的小型示例模块开始,然后使用 C++ 前端提供的大量内置模块库来实现一个成熟的 GAN。

3.1 模块 API 基础

与 Python 接口一致,基于 C++ 前端的神经网络由称为模块的可重用构建块组成。有一个基本模块类,所有其他模块都从该类派生。在 Python 中,这个类是 torch.nn.Module,而在 C++ 中,它是torch::nn::Module (只要记住用双冒号替换点) . 除了 forward()实现模块封装的算法的方法外,模块通常包含三种子对象中的任何一种:参数、缓冲区和子模块。

参数和缓冲区以张量的形式存储状态。参数记录梯度,而缓冲区不记录。参数通常是神经网络的可训练权重。缓冲区的示例包括用于批量标准化的均值和方差。为了重用特定的逻辑和状态块,PyTorch API 允许嵌套模块。嵌套模块称为 子模块

参数、缓冲区和子模块必须显式注册。注册后,类似parameters()buffers()方法可用于检索整个(嵌套)模块层次结构中所有参数的容器。类似地,类似的方法to(...),例如to(torch::kCUDA)将所有参数和缓冲区从 CPU 移动到 CUDA 内存,适用于整个模块层次结构。

3.1.1 定义模块并注册参数

要将这些描述放入代码中,先考虑这个用 Python 接口编写的简单模块:

import torch

class Net(torch.nn.Module):
  def __init__(self, N, M):
    super(Net, self).__init__()
    self.W = torch.nn.Parameter(torch.randn(N, M))
    self.b = torch.nn.Parameter(torch.randn(M))

  def forward(self, input):
    return torch.addmm(self.b, input, self.W)

在 C++ 中,它看起来像这样:

#include <torch/torch.h>

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M) {
    W = register_parameter("W", torch::randn({N, M}));
    b = register_parameter("b", torch::randn(M));
  }
  torch::Tensor forward(torch::Tensor input) {
    return torch::addmm(b, input, W);
  }
  torch::Tensor W, b;
};

就像在 Python 中一样,c++中定义了一个名为Net(为简单起见,这里是 a struct而不是 a class)的类,并从模块基类派生它。在构造函数内部,创建张量的方式与在 Python 中torch::randn使用的方式相同。torch.randn一个有趣的区别是如何注册参数。在 Python 中,用torch.nn.Parameter 类包装张量,而在 C++ 中,必须通过 register_parameter方法传递张量。这样做的原因是 Python API 可以检测到torch.nn.Parameter类型的属性并自动注册此类张量。在 C++ 中,反射非常有限,因此提供了一种更传统(且不那么神奇)的方法。

3.1.2 注册子模块并遍历模块层次结构

同注册参数一样,也可以注册子模块。在 Python 中,当子模块被分配为模块的属性时,它们会被自动检测和注册:

class Net(torch.nn.Module):
  def __init__(self, N, M):
      super(Net, self).__init__()
      # Registered as a submodule behind the scenes
      # 当子模块被分配为模块的属性时,它们会被自动检测和注册
      self.linear = torch.nn.Linear(N, M)
      self.another_bias = torch.nn.Parameter(torch.rand(M))

  def forward(self, input):
    return self.linear(input) + self.another_bias

例如,这允许使用该parameters()方法递归访问模块层次结构中的所有参数:

>>> net = Net(4, 5)
>>> print(list(net.parameters()))
[Parameter containing:
tensor([0.0808, 0.8613, 0.2017, 0.5206, 0.5353], requires_grad=True), Parameter containing:
tensor([[-0.3740, -0.0976, -0.4786, -0.4928],
        [-0.1434,  0.4713,  0.1735, -0.3293],
        [-0.3467, -0.3858,  0.1980,  0.1986],
        [-0.1975,  0.4278, -0.1831, -0.2709],
        [ 0.3730,  0.4307,  0.3236, -0.0629]], requires_grad=True), Parameter containing:
tensor([ 0.2038,  0.4638, -0.2023,  0.1230, -0.0516], requires_grad=True)]

要在 C++ 中注册子模块,请使用恰当命名的register_module()方法来注册模块,比如注册 torch::nn::Linear

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M)
      : linear(register_module("linear", torch::nn::Linear(N, M))) {
    another_bias = register_parameter("b", torch::randn(M));
  }
  torch::Tensor forward(torch::Tensor input) {
    return linear(input) + another_bias;
  }
  torch::nn::Linear linear;
  torch::Tensor another_bias;
};

提示

您可以在此处找到可用内置模块的完整列表 torch::nn::Lineartorch::nn::Dropouttorch::nn::Conv2dtorch::nn 名称空间的文档中。

上面代码的一个微妙之处是为什么子模块是在构造函数的初始化列表中创建的,而参数是在构造函数体中创建的。这是有充分理由的,这将在下面关于 C++ 前端所有权模型的部分中对此进行讨论。然而,最终结果是我们可以像在 Python 中一样递归地访问模块树的参数。调用parameters()返回 一个 std::vector<torch::Tensor>,对其进行迭代:

int main() {
  Net net(4, 5);
  for (const auto& p : net.parameters()) {
    std::cout << p << std::endl;
  }
}

打印:

root@fa350df05ecf:/home/build# ./dcgan
0.0345
1.4456
-0.6313
-0.3585
-0.4008
[ Variable[CPUFloatType]{5} ]
-0.1647  0.2891  0.0527 -0.0354
0.3084  0.2025  0.0343  0.1824
-0.4630 -0.2862  0.2500 -0.0420
0.3679 -0.1482 -0.0460  0.1967
0.2132 -0.1992  0.4257  0.0739
[ Variable[CPUFloatType]{5,4} ]
0.01 *
3.6861
-10.1166
-45.0333
7.9983
-20.0705
[ Variable[CPUFloatType]{5} ]

就像在 Python 中一样,带有三个参数。为了查看这些参数的名称,C++ API 提供了一个named_parameters()方法,该方法返回一个OrderedDict就像在 Python 中一样:

Net net(4, 5);
for (const auto& pair : net.named_parameters()) {
  std::cout << pair.key() << ": " << pair.value() << std::endl;
}

我们可以再次执行以查看输出:

root@fa350df05ecf:/home/build# make && ./dcgan                                                                                                                                            11:13:48
Scanning dependencies of target dcgan
[ 50%] Building CXX object CMakeFiles/dcgan.dir/dcgan.cpp.o
[100%] Linking CXX executable dcgan
[100%] Built target dcgan
b: -0.1863
-0.8611
-0.1228
1.3269
0.9858
[ Variable[CPUFloatType]{5} ]
linear.weight:  0.0339  0.2484  0.2035 -0.2103
-0.0715 -0.2975 -0.4350 -0.1878
-0.3616  0.1050 -0.4982  0.0335
-0.1605  0.4963  0.4099 -0.2883
0.1818 -0.3447 -0.1501 -0.0215
[ Variable[CPUFloatType]{5,4} ]
linear.bias: -0.0250
0.0408
0.3756
-0.2149
-0.3636
[ Variable[CPUFloatType]{5} ]

提示

文档 包含torch::nn::Module在模块层次结构上操作的方法的完整列表。

3.1.3 运行网络Forward

要在 C++ 中执行网络,只需调用forward()方法:

int main() {
  Net net(4, 5);
  std::cout << net.forward(torch::ones({2, 4})) << std::endl;
}

打印如下内容:

root@fa350df05ecf:/home/build# ./dcgan
0.8559  1.1572  2.1069 -0.1247  0.8060
0.8559  1.1572  2.1069 -0.1247  0.8060
[ Variable[CPUFloatType]{2,5} ]

3.1.4 模块所有权

至此,知道了如何在C++中定义一个模块,注册参数,注册子模块,通过parameters()方法遍历模块层次结构 ,最后运行模块的forward()方法。虽然 C++ API 中有更多的方法、类和主题可供使用,但我将向您推荐完整的文档。当我们在一秒钟内实现 DCGAN 模型和端到端训练管道时,还将涉及更多概念。在这样做之前,需要简要介绍一下torch::nn::Module.

在本次讨论中,所有权模型是指模块的存储和传递方式——它决定了谁或什么拥有一个特定的模块实例。在 Python 中,对象总是动态分配(在堆上)并具有引用语义。这很容易使用并且易于理解。事实上,在 Python 中,可以在很大程度上忘记对象所在的位置以及它们如何被引用,而专注于完成工作。

C++ 作为一种低级语言,在这个领域提供了更多的选择。这增加了复杂性并严重影响了 C++ 前端的设计和工程学。特别是,对于 C++ 前端中的模块,可以选择使用值语义引用语义。第一种情况是最简单的,到目前为止的示例中已显示:模块对象在堆栈上分配,当传递给函数时,可以复制、移动(使用 std::move)或通过引用或指针获取:

struct Net : torch::nn::Module { };

void a(Net net) { }
void b(Net& net) { }
void c(Net* net) { }

int main() {
  Net net;
  a(net);
  a(std::move(net));
  b(net);
  c(&net);
}

对于第二种情况——引用语义——可以使用std::shared_ptr. 引用语义的优点是,就像在 Python 中一样,它减少了思考模块必须如何传递给函数以及必须如何声明参数(假设在任何地方都使用shared_ptr)的认知开销。

struct Net : torch::nn::Module {};

void a(std::shared_ptr<Net> net) { }

int main() {
  auto net = std::make_shared<Net>();
  a(net);
}

根据经验,来自动态语言的研究人员更喜欢引用语义而不是值语义,尽管后者对 C++ 更“原生”。同样重要的是要注意,torch::nn::Module为了接近 Python API 的人体工程学设计,它的设计依赖于共享所有权。例如,以之前的(这里缩短的)定义 Net

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M)
    : linear(register_module("linear", torch::nn::Linear(N, M)))
  { }
  torch::nn::Linear linear;
};

为了使用linear子模块,希望将它直接存储在我们的类中。但是,也希望模块基类知道并可以访问这个子模块。为此,它必须存储对此子模块的引用。至此,已经达到了共享所有权的需求。torch::nn::Module类和具体类都 Net需要对子模块的引用。因此,基类将模块存储为 shared_ptr,因此具体类也必须如此。

可是!在上面的代码中没有看到任何提及shared_ptr!这是为什么?好吧,因为std::shared_ptr<MyModule>要输入很多东西。为了让研究人员保持高效,我们提出了一个精心设计的方案来隐藏提及(shared_ptr通常为值语义保留的好处),同时保留引用语义。为了理解它是如何工作的,我们可以看一下torch::nn::Linear核心库中模块的简化定义(完整定义在这里):

struct LinearImpl : torch::nn::Module {
  LinearImpl(int64_t in, int64_t out);

  Tensor forward(const Tensor& input);

  Tensor weight, bias;
};

TORCH_MODULE(Linear);

简而言之:模块不是被调用的Linear,而是LinearImpl。一个宏, TORCH_MODULE定义实际的Linear类。这个“生成”的类实际上是一个std::shared_ptr<LinearImpl>. 它是一个包装器而不是简单的 typedef,因此,除其他外,构造函数仍然可以按预期工作,即您仍然可以编写torch::nn::Linear(3, 4) 而不是std::make_shared<LinearImpl>(3, 4). 将宏创建的类称为模块持有者。与(共享)指针一样,可以使用箭头运算符(如model->forward(...))访问底层对象。最终结果是一个非常类似于 Python API 的所有权模型。引用语义成为默认值,但没有额外的 std::shared_ptrstd::make_shared类型。对于我们的Net,使用模块持有者 API 如下所示:

struct NetImpl : torch::nn::Module {};
TORCH_MODULE(Net);

void a(Net net) { }

int main() {
  Net net;
  a(net);
}

这里有一个微妙的问题值得一提。默认构造 std::shared_ptr为“空”,即包含一个空指针。什么是默认构造的Linearor Net?哎,这是一个棘手的选择。可以说它应该是一个空的 (null) std::shared_ptr<LinearImpl>。但是,回想一下Linear(3, 4), 这与std::make_shared<LinearImpl>(3, 4) 一样. 这意味着如果我们决定Linear linear;它应该是一个空指针,那么就没有办法构造一个不接受任何构造函数参数或默认所有参数的模块。因此,在当前 API 中,默认构造的模块持有者(如Linear())调用底层模块的默认构造函数(LinearImpl())。如果底层模块没有默认构造函数,则会出现编译器错误。要改为构造时空持有者,您可以传递nullptr给持有者的构造函数。

在实践中,这意味着可以像前面显示的那样使用子模块,其中模块在初始化列表中注册和构造:

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M)
    : linear(register_module("linear", torch::nn::Linear(N, M)))
  { }
  torch::nn::Linear linear;
};

或者可以先用空指针构造持有者,然后在构造函数中分配给它(Pythonistas 更熟悉):

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M) {
    linear = register_module("linear", torch::nn::Linear(N, M));
  }
  torch::nn::Linear linear{nullptr}; // construct an empty holder
};

总结:你应该使用哪种所有权模型——哪种语义?C++ 前端的 API 最好地支持模块持有者提供的所有权模型。这种机制的唯一缺点是在模块声明下面多出了一行样板文件。也就是说,最简单的模型仍然是 C++ 模块介绍中显示的值语义模型。对于小而简单的脚本,你也可以侥幸逃脱。但是你迟早会发现,由于技术原因,它并不总是得到支持。例如,序列化 API ( torch::saveand torch::load) 仅支持模块持有者 (或 plain shared_ptr)。因此,模块持有者 API 是使用 C++ 前端定义模块的推荐方式,我们将在本教程中使用此 API。 【上段英文原文如下: In conclusion: Which ownership model – which semantics – should you use? The C++ frontend’s API best supports the ownership model provided by module holders. The only disadvantage of this mechanism is one extra line of boilerplate below the module declaration. That said, the simplest model is still the value semantics model shown in the introduction to C++ modules. For small, simple scripts, you may get away with it too. But you’ll find sooner or later that, for technical reasons, it is not always supported. For example, the serialization API (torch::save and torch::load) only supports module holders (or plain shared_ptr). As such, the module holder API is the recommended way of defining modules with the C++ frontend, and we will use this API in this tutorial henceforth. 】

3.2 定义 DCGAN 模块

现在有足够的背景和介绍来定义想要在这篇文章中解决的机器学习任务的模块。回顾一下:任务是从MNIST 数据集生成数字图像。我们想使用生成对抗网络(GAN)来解决这个任务。特别是,将使用DCGAN 架构——这是同类中第一个也是最简单的架构之一,但对于这项任务来说完全足够了。

提示

您可以在此存储库中找到本教程中提供的完整源代码。

3.2.1 GAN 是什么?

GAN 由两个不同的神经网络模型组成:生成器和 鉴别器。生成器从噪声分布中接收样本,其目的是将每个噪声样本转换为类似于目标分布的图像——在我们的例子中是 MNIST 数据集。鉴别器依次接收来自 MNIST 数据集的真实图像或来自生成器的假图像。它被要求产出一个概率来判断一个特定的图像是 真实(更接近 1)或假(更接近0) 。来自鉴别器的关于生成器生成的图像的真实程度的反馈用于训练生成器。关于鉴别器对真实性的观察有多好的反馈用于优化鉴别器。从理论上讲,生成器和判别器之间的微妙平衡使它们协同改进,导致生成器生成与目标分布无法区分的图像,从而欺骗判别器(届时)出色的眼睛,使其发出0.5真假图像的概率。对我们来说,最终结果是一台机器,它接收噪声作为输入并生成逼真的数字图像作为其输出。

image.png

3.2.2 生成器模块

首先定义生成器模块,它由一系列转置的 2D 卷积、批量归一化和 ReLU 激活单元组成。 在定义的网络模块中forward()方法中传递输入 :

struct DCGANGeneratorImpl : nn::Module {
  DCGANGeneratorImpl(int kNoiseSize)
      : conv1(nn::ConvTranspose2dOptions(kNoiseSize, 256, 4)
                  .bias(false)),
        batch_norm1(256),
        conv2(nn::ConvTranspose2dOptions(256, 128, 3)
                  .stride(2)
                  .padding(1)
                  .bias(false)),
        batch_norm2(128),
        conv3(nn::ConvTranspose2dOptions(128, 64, 4)
                  .stride(2)
                  .padding(1)
                  .bias(false)),
        batch_norm3(64),
        conv4(nn::ConvTranspose2dOptions(64, 1, 4)
                  .stride(2)
                  .padding(1)
                  .bias(false))
 {
   // register_module() is needed if we want to use the parameters() method later on
   register_module("conv1", conv1);
   register_module("conv2", conv2);
   register_module("conv3", conv3);
   register_module("conv4", conv4);
   register_module("batch_norm1", batch_norm1);
   register_module("batch_norm2", batch_norm2);
   register_module("batch_norm3", batch_norm3);
 }

 torch::Tensor forward(torch::Tensor x) {
   x = torch::relu(batch_norm1(conv1(x)));
   x = torch::relu(batch_norm2(conv2(x)));
   x = torch::relu(batch_norm3(conv3(x)));
   x = torch::tanh(conv4(x));
   return x;
 }

 nn::ConvTranspose2d conv1, conv2, conv3, conv4;
 nn::BatchNorm2d batch_norm1, batch_norm2, batch_norm3;
};
TORCH_MODULE(DCGANGenerator);

DCGANGenerator generator(kNoiseSize);

现在可以调用DCGANGeneratorforward()将噪声样本映射到图像。

选择的特定模块,如nn::ConvTranspose2dnn::BatchNorm2d,遵循前面概述的结构。该kNoiseSize常数确定输入噪声向量的大小并设置为100

笔记

关于将选项传递给 C++ 前端Conv2d等内置模块的方式的简短说明 :每个模块都有一些必需的选项,例如BatchNorm2d. 如果只需要配置所需的选项,可以直接将它们传递给模块的构造函数,比如 BatchNorm2d(128)或 Dropout(0.5)Conv2d(8, 4, 2) (用于输入通道数、输出通道数和内核大小)。但是,如果需要修改通常默认的其他选项,例如Conv2d的 bias ,则需要构造并传递一个选项对象。C++ 前端中的每个模块都有一个关联的选项结构,称为ModuleOptions 其中 Module是模块的名称,例如 LinearLinearOptions 。这就是我们为上面的Conv2d模块所做的。

3.2.1 鉴别器模块

鉴别器类似地是一系列卷积、批量归一化和激活。但是,卷积现在是常规卷积而不是转置卷积,我们使用 alpha 值为 0.2 的leaky ReLU 而不是普通 ReLU。此外,最终的激活变为 Sigmoid,它将值压缩到 0 到 1 之间的范围内。然后可以将这些压缩值解释为鉴别器分配给真实图像的概率。

为了构建鉴别器,将尝试一些不同的东西:一个顺序模块。与 Python 一样,这里的 PyTorch 提供了两个用于模型定义的 API:一个是功能性的,其中输入通过连续的函数传递(例如生成器模块示例),另一个更面向对象,我们构建一个包含整个模型作为子模块的Sequential模块. 使用Sequential,鉴别器看起来像:

nn::Sequential discriminator(
  // Layer 1
  nn::Conv2d(
      nn::Conv2dOptions(1, 64, 4).stride(2).padding(1).bias(false)),
  nn::LeakyReLU(nn::LeakyReLUOptions().negative_slope(0.2)),
  // Layer 2
  nn::Conv2d(
      nn::Conv2dOptions(64, 128, 4).stride(2).padding(1).bias(false)),
  nn::BatchNorm2d(128),
  nn::LeakyReLU(nn::LeakyReLUOptions().negative_slope(0.2)),
  // Layer 3
  nn::Conv2d(
      nn::Conv2dOptions(128, 256, 4).stride(2).padding(1).bias(false)),
  nn::BatchNorm2d(256),
  nn::LeakyReLU(nn::LeakyReLUOptions().negative_slope(0.2)),
  // Layer 4
  nn::Conv2d(
      nn::Conv2dOptions(256, 1, 3).stride(1).padding(0).bias(false)),
  nn::Sigmoid());

提示:

一个Sequential模块只是简单地执行功能组合。第一个子模块的输出成为第二个子模块的输入,第三个子模块的输出成为第四个子模块的输入,依此类推。

4. 加载数据

现在已经定义了生成器和鉴别器模型,需要一些数据来训练这些模型。C++ 前端,就像 Python 前端一样,带有一个强大的并行数据加载器。该数据加载器可以从数据集中读取批量数据(可以自己定义)并提供许多配置选项。

笔记

虽然 Python 数据加载器使用多处理,但 C++ 数据加载器是真正的多线程并且不会启动任何新进程。

数据加载器是 C++ 前端dataAPI 的一部分,包含在 torch::data::命名空间中。该 API 由几个不同的组件组成:

  • 数据加载器类,
  • 用于定义数据集的 API,
  • 用于定义转换的 API ,可应用于数据集,
  • 用于定义采样器的 API ,它生成索引数据集的索引,
  • 现有数据集、转换和采样器的库。

对于本教程,可以使用C++ 前端附带的数据集MNIST。让我们为此实例化 torch::data::datasets::MNIST,并应用两个转换:首先,对图像进行归一化,使它们在 -1到1 的范围内(原始范围从 0到 1)。其次,应用Stack堆叠,它采用一批张量并将它们沿第一维堆叠成单个张量:

auto dataset = torch::data::datasets::MNIST("./mnist")
    .map(torch::data::transforms::Normalize<>(0.5, 0.5))
    .map(torch::data::transforms::Stack<>());

请注意,MNIST 数据集应位于与./mnist执行训练二进制文件的位置相关的目录中。您可以使用此脚本 下载 MNIST 数据集。

接下来,创建一个数据加载器并将这个数据集传递给它。为了创建一个新的数据加载器,使用torch::data::make_data_loader,它返回 std::unique_ptr对应类型 (这取决于数据集的类型、采样器的类型和其他一些实现细节):

auto data_loader = torch::data::make_data_loader(std::move(dataset));

数据加载器确实带有很多选项。你可以在这里查看全套 。例如,为了加快数据加载,可以增加workers的数量。默认数字为零,这意味着将使用主线程。如果设置workers2,将产生两个同时加载数据的线程。还应该将批量大小从其默认值1 增加到更合理的值,例如64(的值kBatchSize)。可以创建一个DataLoaderOptions对象并设置适当的属性:

auto data_loader = torch::data::make_data_loader(
    std::move(dataset),
    torch::data::DataLoaderOptions().batch_size(kBatchSize).workers(2));

现在可以编写一个循环来加载批量数据,现在只打印到控制台:

for (torch::data::Example<>& batch : *data_loader) {
  std::cout << "Batch size: " << batch.data.size(0) << " | Labels: ";
  for (int64_t i = 0; i < batch.data.size(0); ++i) {
    std::cout << batch.target[i].item<int64_t>() << " ";
  }
  std::cout << std::endl;
}

在这种情况下,数据加载器返回的类型是torch::data::Example. 这种类型是一个简单的结构体,其中包含一个data数据字段和一个target 标签字段。因为之前我们应用了Stack堆叠,所以数据加载器只返回一个这样的示例。如果没有应用Stack堆叠,数据加载器将生成std::vector<torch::data::Example<>> ,批处理中的每个示例都有一个元素。

如果您重新构建并运行此代码,您应该会看到如下内容:

root@fa350df05ecf:/home/build# make
Scanning dependencies of target dcgan
[ 50%] Building CXX object CMakeFiles/dcgan.dir/dcgan.cpp.o
[100%] Linking CXX executable dcgan
[100%] Built target dcgan
root@fa350df05ecf:/home/build# make
[100%] Built target dcgan
root@fa350df05ecf:/home/build# ./dcgan
Batch size: 64 | Labels: 5 2 6 7 2 1 6 7 0 1 6 2 3 6 9 1 8 4 0 6 5 3 3 0 4 6 6 6 4 0 8 6 0 6 9 2 4 0 2 8 6 3 3 2 9 2 0 1 4 2 3 4 8 2 9 9 3 5 8 0 0 7 9 9
Batch size: 64 | Labels: 2 2 4 7 1 2 8 8 6 9 0 2 2 9 3 6 1 3 8 0 4 4 8 8 8 9 2 6 4 7 1 5 0 9 7 5 4 3 5 4 1 2 8 0 7 1 9 6 1 6 5 3 4 4 1 2 3 2 3 5 0 1 6 2
Batch size: 64 | Labels: 4 5 4 2 1 4 8 3 8 3 6 1 5 4 3 6 2 2 5 1 3 1 5 0 8 2 1 5 3 2 4 4 5 9 7 2 8 9 2 0 6 7 4 3 8 3 5 8 8 3 0 5 8 0 8 7 8 5 5 6 1 7 8 0
Batch size: 64 | Labels: 3 3 7 1 4 1 6 1 0 3 6 4 0 2 5 4 0 4 2 8 1 9 6 5 1 6 3 2 8 9 2 3 8 7 4 5 9 6 0 8 3 0 0 6 4 8 2 5 4 1 8 3 7 8 0 0 8 9 6 7 2 1 4 7
Batch size: 64 | Labels: 3 0 5 5 9 8 3 9 8 9 5 9 5 0 4 1 2 7 7 2 0 0 5 4 8 7 7 6 1 0 7 9 3 0 6 3 2 6 2 7 6 3 3 4 0 5 8 8 9 1 9 2 1 9 4 4 9 2 4 6 2 9 4 0
Batch size: 64 | Labels: 9 6 7 5 3 5 9 0 8 6 6 7 8 2 1 9 8 8 1 1 8 2 0 7 1 4 1 6 7 5 1 7 7 4 0 3 2 9 0 6 6 3 4 4 8 1 2 8 6 9 2 0 3 1 2 8 5 6 4 8 5 8 6 2
Batch size: 64 | Labels: 9 3 0 3 6 5 1 8 6 0 1 9 9 1 6 1 7 7 4 4 4 7 8 8 6 7 8 2 6 0 4 6 8 2 5 3 9 8 4 0 9 9 3 7 0 5 8 2 4 5 6 2 8 2 5 3 7 1 9 1 8 2 2 7
Batch size: 64 | Labels: 9 1 9 2 7 2 6 0 8 6 8 7 7 4 8 6 1 1 6 8 5 7 9 1 3 2 0 5 1 7 3 1 6 1 0 8 6 0 8 1 0 5 4 9 3 8 5 8 4 8 0 1 2 6 2 4 2 7 7 3 7 4 5 3
Batch size: 64 | Labels: 8 8 3 1 8 6 4 2 9 5 8 0 2 8 6 6 7 0 9 8 3 8 7 1 6 6 2 7 7 4 5 5 2 1 7 9 5 4 9 1 0 3 1 9 3 9 8 8 5 3 7 5 3 6 8 9 4 2 0 1 2 5 4 7
Batch size: 64 | Labels: 9 2 7 0 8 4 4 2 7 5 0 0 6 2 0 5 9 5 9 8 8 9 3 5 7 5 4 7 3 0 5 7 6 5 7 1 6 2 8 7 6 3 2 6 5 6 1 2 7 7 0 0 5 9 0 0 9 1 7 8 3 2 9 4
Batch size: 64 | Labels: 7 6 5 7 7 5 2 2 4 9 9 4 8 7 4 8 9 4 5 7 1 2 6 9 8 5 1 2 3 6 7 8 1 1 3 9 8 7 9 5 0 8 5 1 8 7 2 6 5 1 2 0 9 7 4 0 9 0 4 6 0 0 8 6
...

这意味着能够成功地从 MNIST 数据集中加载数据。

5. 编写训练循环

现在完成示例的算法部分并实现生成器和判别器之间的微妙舞蹈。首先,将创建两个优化器,一个用于生成器,一个用于鉴别器。使用的优化器实现了Adam算法:

torch::optim::Adam generator_optimizer(
    generator->parameters(), torch::optim::AdamOptions(2e-4).beta1(0.5));
torch::optim::Adam discriminator_optimizer(
    discriminator->parameters(), torch::optim::AdamOptions(5e-4).beta1(0.5));

笔记

在撰写本文时,C++ 前端提供了实现 Adagrad、Adam、LBFGS、RMSprop 和 SGD 的优化器。文档有最新的列表。

接下来,需要更新训练循环。添加一个外部循环以在每个 epoch 耗尽数据加载器,然后编写 GAN 训练代码:

for (int64_t epoch = 1; epoch <= kNumberOfEpochs; ++epoch) {
  int64_t batch_index = 0;
  for (torch::data::Example<>& batch : *data_loader) {
    // Train discriminator with real images.
    discriminator->zero_grad();
    torch::Tensor real_images = batch.data;
    torch::Tensor real_labels = torch::empty(batch.data.size(0)).uniform_(0.8, 1.0);
    torch::Tensor real_output = discriminator->forward(real_images);
    torch::Tensor d_loss_real = torch::binary_cross_entropy(real_output, real_labels);
    d_loss_real.backward();

    // Train discriminator with fake images.
    torch::Tensor noise = torch::randn({batch.data.size(0), kNoiseSize, 1, 1});
    torch::Tensor fake_images = generator->forward(noise);
    torch::Tensor fake_labels = torch::zeros(batch.data.size(0));
    torch::Tensor fake_output = discriminator->forward(fake_images.detach());
    torch::Tensor d_loss_fake = torch::binary_cross_entropy(fake_output, fake_labels);
    d_loss_fake.backward();

    torch::Tensor d_loss = d_loss_real + d_loss_fake;
    discriminator_optimizer.step();

    // Train generator.
    generator->zero_grad();
    fake_labels.fill_(1);
    fake_output = discriminator->forward(fake_images);
    torch::Tensor g_loss = torch::binary_cross_entropy(fake_output, fake_labels);
    g_loss.backward();
    generator_optimizer.step();

    std::printf(
        "\r[%2ld/%2ld][%3ld/%3ld] D_loss: %.4f | G_loss: %.4f",
        epoch,
        kNumberOfEpochs,
        ++batch_index,
        batches_per_epoch,
        d_loss.item<float>(),
        g_loss.item<float>());
  }
}

上面,首先在真实图像上评估鉴别器,它应该分配一个高概率。为此,我们将 torch::empty(batch.data.size(0)).uniform_(0.8, 1.0)其用作目标概率。

笔记

选择均匀分布在 0.8 和 1.0 之间而不是 1.0 之间的随机值,以使鉴别器训练更加稳健。这个技巧称为标签平滑

在评估鉴别器之前,将其参数的梯度归零。在计算损失之后,通过调用d_loss.backward()计算新的梯度通过网络反向传播它。对假图像重复这个过程。不使用数据集中的图像,而是让生成器通过为其提供一批随机噪声来为此创建假图像。然后将这些假图像转发给鉴别器。这一次,希望判别器发出低概率,最好是全零。一旦计算了一批真实图像和一批假图像的鉴别器损失,可以将鉴别器的优化器前进一步以更新其参数。

为了训练生成器,首先再次将其梯度归零,然后重新评估假图像上的鉴别器。然而,这一次希望判别器分配非常接近 1 的概率,这表明生成器可以生成图像,让判别器误以为它们实际上是真实的(来自数据集)。为此,fake_labels 用全1填充张量。最后一步一步更新生成器的优化器来更新它的参数。

现在应该准备好在 CPU 上训练的模型了。还没有任何代码来捕获状态或示例输出,但稍后会添加它。现在,观察一下模型正在做某事——稍后将根据生成的图像验证这件事是否有意义。重新构建和运行应打印如下内容:

root@3c0711f20896:/home/build# make && ./dcgan
Scanning dependencies of target dcgan
[ 50%] Building CXX object CMakeFiles/dcgan.dir/dcgan.cpp.o
[100%] Linking CXX executable dcgan
[100%] Built target dcga
[ 1/10][100/938] D_loss: 0.6876 | G_loss: 4.1304
[ 1/10][200/938] D_loss: 0.3776 | G_loss: 4.3101
[ 1/10][300/938] D_loss: 0.3652 | G_loss: 4.6626
[ 1/10][400/938] D_loss: 0.8057 | G_loss: 2.2795
[ 1/10][500/938] D_loss: 0.3531 | G_loss: 4.4452
[ 1/10][600/938] D_loss: 0.3501 | G_loss: 5.0811
[ 1/10][700/938] D_loss: 0.3581 | G_loss: 4.5623
[ 1/10][800/938] D_loss: 0.6423 | G_loss: 1.7385
[ 1/10][900/938] D_loss: 0.3592 | G_loss: 4.7333
[ 2/10][100/938] D_loss: 0.4660 | G_loss: 2.5242
[ 2/10][200/938] D_loss: 0.6364 | G_loss: 2.0886
[ 2/10][300/938] D_loss: 0.3717 | G_loss: 3.8103
[ 2/10][400/938] D_loss: 1.0201 | G_loss: 1.3544
[ 2/10][500/938] D_loss: 0.4522 | G_loss: 2.6545
...

6. 移动到 GPU

虽然当前的脚本可以在 CPU 上正常运行,但卷积在 GPU 上要快得多。让我们快速讨论如何将训练转移到 GPU 上。为此,需要做两件事:将 GPU 设备规范传递给分配的张量,并通过to()方法 将C++ 前端中所有张量和模块显式复制到 GPU 上。实现这两者的最简单方法是在训练脚本的顶层创建一个torch::Device 实例,然后将该设备传递给张量工厂函数比如:torch::zeros。现在从使用 CPU 设备开始:

// Place this somewhere at the top of your training script.
torch::Device device(torch::kCPU);

新的张量分配,如

torch::Tensor fake_labels = torch::zeros(batch.data.size(0));

应更新以将 thedevice作为最后一个参数:

torch::Tensor fake_labels = torch::zeros(batch.data.size(0), device);

对于不在我们手中创建的张量,例如来自 MNIST 数据集的张量,必须显式调用to()。这表示

torch::Tensor real_images = batch.data;

变成

torch::Tensor real_images = batch.data.to(device);

模型参数也应该移动到正确的设备上:

generator->to(device);
discriminator->to(device);

笔记

如果张量已经存在于提供给 的设备上,则to()调用是无操作的。没有额外的拷贝。

现在将设备更改为 CUDA 设备也很容易:

torch::Device device(torch::kCUDA)

现在所有张量都将存在于 GPU 上,为所有操作调用快速的 CUDA 内核,而无需更改任何下游代码。如果想指定一个特定的设备索引,它可以作为第二个参数传递给Device构造函数。如果希望不同的张量存在于不同的设备上,可以传递单独的设备实例(例如,一个在 CUDA 设备 0 上,另一个在 CUDA 设备 1 上)。甚至可以动态地进行这种配置,这通常有助于使训练脚本更便携:

torch::Device device = torch::kCPU;
if (torch::cuda::is_available()) {
  std::cout << "CUDA is available! Training on GPU." << std::endl;
  device = torch::kCUDA;
}

甚至

torch::Device device(torch::cuda::is_available() ? torch::kCUDA : torch::kCPU);

7. 检查点和恢复训练状态

应该对训练脚本进行定期保存模型参数的状态、优化器的状态以及一些生成的图像样本。如果计算机在训练过程中崩溃,前两个将允许恢复训练状态。对于持久的训练过程,这是绝对必要的。幸运的是,C++ 前端提供了一个 API 来序列化和反序列化模型和优化器状态,以及单个张量。

核心 API 是torch::save(thing,filename)and torch::load(thing,filename),其中thing可以是 torch::nn::Module子类或优化器实例,例如在训练脚本中的Adam对象。让我们更新训练循环以在某个时间间隔检查模型和优化器状态:

if (batch_index % kCheckpointEvery == 0) {
  // Checkpoint the model and optimizer state.
  torch::save(generator, "generator-checkpoint.pt");
  torch::save(generator_optimizer, "generator-optimizer-checkpoint.pt");
  torch::save(discriminator, "discriminator-checkpoint.pt");
  torch::save(discriminator_optimizer, "discriminator-optimizer-checkpoint.pt");
  // Sample the generator and save the images.
  torch::Tensor samples = generator->forward(torch::randn({8, kNoiseSize, 1, 1}, device));
  torch::save((samples + 1.0) / 2.0, torch::str("dcgan-sample-", checkpoint_counter, ".pt"));
  std::cout << "\n-> checkpoint " << ++checkpoint_counter << '\n';
}

其中kCheckpointEvery是一个整数,表示每100批设置一个检查点。

要恢复训练状态,可以在创建所有模型和优化器之后,但在训练循环之前添加如下行:

torch::optim::Adam generator_optimizer(
    generator->parameters(), torch::optim::AdamOptions(2e-4).beta1(0.5));
torch::optim::Adam discriminator_optimizer(
    discriminator->parameters(), torch::optim::AdamOptions(2e-4).beta1(0.5));

if (kRestoreFromCheckpoint) {
  torch::load(generator, "generator-checkpoint.pt");
  torch::load(generator_optimizer, "generator-optimizer-checkpoint.pt");
  torch::load(discriminator, "discriminator-checkpoint.pt");
  torch::load(
      discriminator_optimizer, "discriminator-optimizer-checkpoint.pt");
}

int64_t checkpoint_counter = 0;
for (int64_t epoch = 1; epoch <= kNumberOfEpochs; ++epoch) {
  int64_t batch_index = 0;
  for (torch::data::Example<>& batch : *data_loader) {

8. 检查生成的图像

训练脚本现已完成。已经准备好在 CPU 或 GPU 上训练我们的 GAN。为了检查训练过程的中间输出,添加了代码来定期将图像样本保存到 "dcgan-sample-xxx.pt"文件中,可以编写一个小的 Python 脚本来加载张量并用 matplotlib 显示它们:

from __future__ import print_function
from __future__ import unicode_literals

import argparse

import matplotlib.pyplot as plt
import torch


parser = argparse.ArgumentParser()
parser.add_argument("-i", "--sample-file", required=True)
parser.add_argument("-o", "--out-file", default="out.png")
parser.add_argument("-d", "--dimension", type=int, default=3)
options = parser.parse_args()

module = torch.jit.load(options.sample_file)
images = list(module.parameters())[0]

for index in range(options.dimension * options.dimension):
  image = images[index].detach().cpu().reshape(28, 28).mul(255).to(torch.uint8)
  array = image.numpy()
  axis = plt.subplot(options.dimension, options.dimension, 1 + index)
  plt.imshow(array, cmap="gray")
  axis.get_xaxis().set_visible(False)
  axis.get_yaxis().set_visible(False)

plt.savefig(options.out_file)
print("Saved ", options.out_file)

现在训练我们的模型大约 30 个 epoch:

root@3c0711f20896:/home/build# make && ./dcgan                                                                                                                                10:17:57
Scanning dependencies of target dcgan
[ 50%] Building CXX object CMakeFiles/dcgan.dir/dcgan.cpp.o
[100%] Linking CXX executable dcgan
[100%] Built target dcgan
CUDA is available! Training on GPU.
[ 1/30][200/938] D_loss: 0.4953 | G_loss: 4.0195
-> checkpoint 1
[ 1/30][400/938] D_loss: 0.3610 | G_loss: 4.8148
-> checkpoint 2
[ 1/30][600/938] D_loss: 0.4072 | G_loss: 4.36760
-> checkpoint 3
[ 1/30][800/938] D_loss: 0.4444 | G_loss: 4.0250
-> checkpoint 4
[ 2/30][200/938] D_loss: 0.3761 | G_loss: 3.8790
-> checkpoint 5
[ 2/30][400/938] D_loss: 0.3977 | G_loss: 3.3315
...
-> checkpoint 120
[30/30][938/938] D_loss: 0.3610 | G_loss: 3.8084

并在图中显示图像:

root@3c0711f20896:/home/build# python display.py -i dcgan-sample-100.pt
Saved out.png

应该是这样的:

数字

ok,看起来还可以:你能改进模型以使数字看起来更好吗?

9. 结论

本教程有望为您提供 PyTorch C++ 前端的摘要。像 PyTorch 这样的机器学习库必然具有非常广泛的 API。因此,有许多概念我们没有时间或空间在这里讨论。但是,鼓励您尝试 API,并在遇到困难时查阅 我们的文档,尤其是 Library API部分。另外,请记住,只要我们能够做到这一点,您就可以期望 C++ 前端遵循 Python 前端的设计和语义,因此您可以利用这一事实来提高学习率。

提示

您可以在此存储库中找到本教程中提供的完整源代码。

原文连接