使用 PyTorch C++ 前端
PyTorch C++ 前端是 PyTorch 机器学习框架的一个纯 C++ 接口。虽然 PyTorch 的主要接口自然是 Python,但这个 Python API 建立在一个庞大的 C++ 代码库之上,该代码库提供了基础的数据结构和功能,如张量和自动微分。C++ 前端暴露了一个纯 C++11 API,扩展了这个底层的 C++ 代码库,提供了机器学习训练和推理所需的工具。这包括用于神经网络建模的内置常用组件集合;一个用于扩展此集合的自定义模块的 API;一个包含流行优化算法(如随机梯度下降)的库;一个用于定义和加载数据集的并行数据加载器;序列化例程等等。
本教程将引导您完成一个使用 C++ 前端训练模型的端到端示例。具体来说,我们将训练一个 DCGAN —— 一种生成模型 —— 来生成 MNIST 数字图像。虽然这个示例在概念上很简单,但它足以让您快速了解 PyTorch C++ 前端,并激发您训练更复杂模型的兴趣。我们将从为什么您会想要使用 C++ 前端的一些动机开始,然后直接深入定义和训练我们的模型。
观看CppCon 2018的这个闪电演讲,快速(且幽默地)了解C++前端。
这篇笔记全面概述了 C++ 前端的组件和设计理念。
PyTorch C++ 生态系统的文档可在 https://pytorch.org/cppdocs 获取。在那里,您可以找到高级描述以及 API 级别的文档。
动机
在我们开始探索 GAN 和 MNIST 数字的激动人心的旅程之前,让我们先退一步,讨论一下为什么您会想要使用 C++ 前端而不是 Python 前端。我们(PyTorch 团队)创建 C++ 前端的目的是为了在无法使用 Python,或者 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 中的相同(只需记住将点号替换为双冒号)。
编写一个基础应用程序
让我们从编写一个最小的 C++ 应用程序开始,以验证我们的设置和构建环境是一致的。首先,您需要获取一份 LibTorch 发行版——这是我们预构建的压缩包,包含了使用 C++ 前端所需的所有相关头文件、库和 CMake 构建文件。LibTorch 发行版可以在 PyTorch 网站 上下载,适用于 Linux、MacOS 和 Windows 系统。本教程的其余部分将假设使用基本的 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.
wgethttps://download.pytorch.org/libtorch/nightly/cpu/libtorch-shared-with-deps-latest.zip
unziplibtorch-shared-with-deps-latest.zip
接下来,让我们编写一个名为 dcgan.cpp
的小型 C++ 文件,该文件包含 torch/torch.h
,并暂时简单地打印出一个三乘三的单位矩阵:
#include<torch/torch.h>
#include<iostream>
intmain(){
torch::Tensortensor=torch::eye(3);
std::cout<<tensor<<std::endl;
}
为了构建这个小应用以及后续的完整训练脚本,我们将使用这个 CMakeLists.txt
文件:
cmake_minimum_required(VERSION3.0FATAL_ERROR)
project(dcgan)
find_package(TorchREQUIRED)
add_executable(dcgandcgan.cpp)
target_link_libraries(dcgan"${TORCH_LIBRARIES}")
set_property(TARGETdcganPROPERTYCXX_STANDARD14)
尽管CMake是LibTorch推荐的构建系统,但它并不是硬性要求。您也可以使用Visual Studio项目文件、QMake、简单的Makefiles或任何您感到舒适的构建环境。然而,我们并不提供对这些构建环境的开箱即用支持。
请注意上面 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#mkdirbuild
root@fa350df05ecf:/home#cdbuild
root@fa350df05ecf:/home/build#cmake-DCMAKE_PREFIX_PATH=/path/to/libtorch..
*-TheCcompileridentificationisGNU5.4.0
*-TheCXXcompileridentificationisGNU5.4.0
*-CheckforworkingCcompiler:/usr/bin/cc
*-CheckforworkingCcompiler:/usr/bin/cc--works
*-DetectingCcompilerABIinfo
*-DetectingCcompilerABIinfo-done
*-DetectingCcompilefeatures
*-DetectingCcompilefeatures-done
*-CheckforworkingCXXcompiler:/usr/bin/c++
*-CheckforworkingCXXcompiler:/usr/bin/c++--works
*-DetectingCXXcompilerABIinfo
*-DetectingCXXcompilerABIinfo-done
*-DetectingCXXcompilefeatures
*-DetectingCXXcompilefeatures-done
*-Lookingforpthread.h
*-Lookingforpthread.h-found
*-Lookingforpthread_create
*-Lookingforpthread_create-notfound
*-Lookingforpthread_createinpthreads
*-Lookingforpthread_createinpthreads-notfound
*-Lookingforpthread_createinpthread
*-Lookingforpthread_createinpthread-found
*-FoundThreads:TRUE
*-Foundtorch:/path/to/libtorch/lib/libtorch.so
*-Configuringdone
*-Generatingdone
*-Buildfileshavebeenwrittento:/home/build
root@fa350df05ecf:/home/build#cmake--build.--configRelease
Scanningdependenciesoftargetdcgan
[50%]BuildingCXXobjectCMakeFiles/dcgan.dir/dcgan.cpp.o
[100%]LinkingCXXexecutabledcgan
[100%]Builttargetdcgan
在上面,我们首先在dcgan
目录下创建了一个build
文件夹,进入该文件夹后,运行cmake
命令以生成必要的构建(Make)文件,最后通过运行cmake --build . --config Release
成功编译了项目。现在我们已经准备好执行我们的最小化二进制文件,并完成这一节关于基本项目配置的内容。
root@fa350df05ecf:/home/build#./dcgan
100
010
001
[Variable[CPUFloatType]{3,3}]
看起来像是一个单位矩阵!
定义神经网络模型
既然我们已经配置好了基本环境,现在可以深入探讨本教程中更有趣的部分了。首先,我们将讨论如何在 C++ 前端中定义模块并与模块进行交互。我们将从基础的小规模示例模块开始,然后利用 C++ 前端提供的丰富内置模块库,实现一个完整的 GAN(生成对抗网络)。
模块 API 基础
与 Python 接口一致,基于 C++ 前端的神经网络由可重用的构建块组成,这些构建块称为 模块。所有其他模块都派生自一个基础模块类。在 Python 中,这个类是 torch.nn.Module
,而在 C++ 中则是 torch::nn::Module
。除了实现模块封装的算法的 forward()
方法外,模块通常包含三种子对象中的任意一种:参数、缓冲区和子模块。
参数和缓冲区以张量的形式存储状态。参数会记录梯度,而缓冲区则不会。参数通常是神经网络中可训练的权重。缓冲区的例子包括用于批量归一化的均值和方差。为了重特定的逻辑和状态块,PyTorch API 允许模块嵌套使用。嵌套的模块称为 子模块。
参数、缓冲区和子模块必须显式注册。一旦注册,就可以使用像 parameters()
或 buffers()
这样的方法来检索整个(嵌套)模块层次结构中的所有参数的容器。同样,像 to(...)
这样的方法(例如 to(torch::kCUDA)
将所有参数和缓冲区从 CPU 移动到 CUDA 内存)也会作用于整个模块层次结构。
定义模块并注册参数
要将这些概念转化为代码,让我们来看一个用 Python 接口编写的简单模块:
importtorch
classNet(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))
defforward(self, input):
return torch.addmm(self.b, input, self.W)
在 C++ 中,它看起来会是这样:
#include<torch/torch.h>
structNet:torch::nn::Module{
Net(int64_tN,int64_tM){
W=register_parameter("W",torch::randn({N,M}));
b=register_parameter("b",torch::randn(M));
}
torch::Tensorforward(torch::Tensorinput){
returntorch::addmm(b,input,W);
}
torch::TensorW,b;
};
就像在 Python 中一样,我们定义了一个名为 Net
的类(为了简单起见,这里使用 struct
而不是 class
),并从模块基类派生它。在构造函数内部,我们使用 torch::randn
创建张量,就像在 Python 中使用 torch.randn
一样。一个有趣的差异是我们如何注册参数。在 Python 中,我们用 torch.nn.Parameter
类包装张量,而在 C++ 中,我们必须通过 register_parameter
方法传递张量。这样做的原因是 Python API 可以检测到某个属性是 torch.nn.Parameter
类型,并自动注册这些张量。而在 C++ 中,反射功能非常有限,因此提供了一种更传统(且不那么神奇)的方法。
注册子模块并遍历模块层次结构
同样地,我们可以注册参数,也可以注册子模块。在 Python 中,当子模块被分配为模块的属性时,它们会被自动检测并注册:
classNet(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))
defforward(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
这样的模块:
structNet:torch::nn::Module{
Net(int64_tN,int64_tM)
:linear(register_module("linear",torch::nn::Linear(N,M))){
another_bias=register_parameter("b",torch::randn(M));
}
torch::Tensorforward(torch::Tensorinput){
returnlinear(input)+another_bias;
}
torch::nn::Linearlinear;
torch::Tensoranother_bias;
};
您可以在
torch::nn
命名空间的文档中找到所有可用的内置模块的完整列表,例如torch::nn::Linear
、torch::nn::Dropout
或torch::nn::Conv2d
,具体请参见 这里。
上述代码的一个细微之处在于,为什么子模块是在构造函数的初始化列表中创建的,而参数则是在构造函数体内创建的。这有一个很好的理由,我们将在下面关于 C++ 前端的所有权模型部分中详细讨论。然而,最终的结果是我们可以像在 Python 中一样递归地访问模块树的参数。调用 parameters()
会返回一个 std::vector<torch::Tensor>
,我们可以对其进行遍历:
intmain(){
Netnet(4,5);
for(constauto&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.16470.28910.0527-0.0354
0.30840.20250.03430.1824
*0.4630-0.28620.2500-0.0420
0.3679-0.1482-0.04600.1967
0.2132-0.19920.42570.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()
方法,该方法像 Python 一样返回一个 OrderedDict
:
Netnet(4,5);
for(constauto&pair:net.named_parameters()){
std::cout<<pair.key()<<": "<<pair.value()<<std::endl;
}
我们可以再次执行以查看输出:
root@fa350df05ecf:/home/build#make&&./dcgan11:13:48
Scanningdependenciesoftargetdcgan
[50%]BuildingCXXobjectCMakeFiles/dcgan.dir/dcgan.cpp.o
[100%]LinkingCXXexecutabledcgan
[100%]Builttargetdcgan
b:-0.1863
*0.8611
*0.1228
1.3269
0.9858
[Variable[CPUFloatType]{5}]
linear.weight:0.03390.24840.2035-0.2103
*0.0715-0.2975-0.4350-0.1878
*0.36160.1050-0.49820.0335
*0.16050.49630.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
操作模块层次结构的完整方法列表。
在网络中运行前向模式
要在 C++ 中执行网络,我们只需调用我们自己定义的 forward()
方法:
intmain(){
Netnet(4,5);
std::cout<<net.forward(torch::ones({2,4}))<<std::endl;
}
这会打印出类似以下内容:
root@fa350df05ecf:/home/build#./dcgan
0.85591.15722.1069-0.12470.8060
0.85591.15722.1069-0.12470.8060
[Variable[CPUFloatType]{2,5}]
模块所有权
至此,我们已经了解了如何在 C++ 中定义一个模块、注册参数、注册子模块,并通过 parameters()
等方法遍历模块层次结构,最后运行模块的 forward()
方法。尽管 C++ API 中还有许多方法、类和主题需要深入研究,我将引导您参考 文档 以获取完整的菜单。在接下来的内容中,我们还将探讨一些更多的概念,特别是在实现 DCGAN 模型和端到端训练流程时。在此之前,让我简要介绍一下 C++ 前端为 torch::nn::Module
子类提供的所有权模型。
在这次讨论中,所有权模型指的是模块的存储和传递方式——这决定了谁或什么拥有特定的模块实例。在 Python 中,对象总是动态分配(在堆上)并具有引用语义。这使得使用起来非常容易且易于理解。事实上,在 Python 中,您可以很大程度上忘记对象存储在哪里以及它们如何被引用,而是专注于完成任务。
C++ 作为一种更低级别的语言,在这方面提供了更多的选择。这增加了复杂性,并极大地影响了 C++ 前端的设计和人体工程学。特别是,对于 C++ 前端中的模块,我们可以选择使用值语义或引用语义。第一种情况是最简单的,并且在前面的示例中已经展示过:模块对象在栈上分配,当传递给函数时,可以被复制、移动(使用 std::move
),或者通过引用或指针传递:
structNet:torch::nn::Module{};
voida(Netnet){}
voidb(Net&net){}
voidc(Net*net){}
intmain(){
Netnet;
a(net);
a(std::move(net));
b(net);
c(&net);
}
对于第二种情况——引用语义——我们可以使用 std::shared_ptr
。引用语义的优势在于,就像在 Python 中一样,它减少了思考模块如何传递给函数以及参数如何声明的认知负担(假设您在所有地方都使用 shared_ptr
)。
structNet:torch::nn::Module{};
voida(std::shared_ptr<Net>net){}
intmain(){
autonet=std::make_shared<Net>();
a(net);
}
根据我们的经验,来自动态语言的研究者通常更倾向于引用语义而非值语义,尽管后者在 C++ 中更为“原生”。另外需要注意的是,torch::nn::Module
的设计为了贴近 Python API 的易用性,依赖于共享所有权。例如,我们之前(此处简化)的 Net
定义:
structNet:torch::nn::Module{
Net(int64_tN,int64_tM)
:linear(register_module("linear",torch::nn::Linear(N,M)))
{}
torch::nn::Linearlinear;
};
为了使用 linear
子模块,我们希望将其直接存储在我们的类中。然而,我们也希望模块基类能够知晓并访问这个子模块。为此,基类必须存储对该子模块的引用。此时,我们已经遇到了共享所有权的需求。torch::nn::Module
类和具体的 Net
类都需要引用这个子模块。因此,基类将模块存储为 shared_ptr
,所以具体类也必须这么做。
但是等等!在上面的代码中,我并没有看到任何关于 shared_ptr
的提及!这是为什么呢?因为 std::shared_ptr<MyModule>
输入起来非常繁琐。为了让我们的研究人员保持高效,我们设计了一个复杂的方案来隐藏 shared_ptr
的提及——这种好处通常是为值语义保留的——同时保留引用语义。为了理解这是如何工作的,我们可以看一下核心库中 torch::nn::Linear
模块的简化定义(完整定义在这里):
structLinearImpl:torch::nn::Module{
LinearImpl(int64_tin,int64_tout);
Tensorforward(constTensor&input);
Tensorweight,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)
。我们将宏创建的类称为模块 holder(持有者)。与(共享)指针类似,你可以使用箭头运算符访问底层对象(如 model->forward(...)
)。最终结果是一个与 Python API 非常相似的所有权模型。引用语义成为默认行为,但无需额外的 std::shared_ptr
或 std::make_shared
类型声明。对于我们的 Net
,使用模块持有者 API 的示例如下:
structNetImpl:torch::nn::Module{};
TORCH_MODULE(Net);
voida(Netnet){}
intmain(){
Netnet;
a(net);
}
这里有一个微妙的问题值得提及。默认构造的 std::shared_ptr
是“空的”,即包含一个空指针。那么默认构造的 Linear
或 Net
是什么?这是一个棘手的选择。我们可以说它应该是一个空的(空指针)std::shared_ptr<LinearImpl>
。然而,回想一下 Linear(3, 4)
与 std::make_shared<LinearImpl>(3, 4)
是相同的。这意味着如果我们决定 Linear linear;
应该是一个空指针,那么就没有办法构造一个不接收任何构造函数参数或默认所有参数的模块。因此,在当前的 API 中,默认构造的模块持有者(如 Linear()
)会调用底层模块的默认构造函数(LinearImpl()
)。如果底层模块没有默认构造函数,你会得到一个编译错误。要改为构造空的持有者,你可以将 nullptr
传递给持有者的构造函数。
在实践中,这意味着您可以像前面展示的那样使用子模块,即在初始化列表中注册并构建模块:
structNet:torch::nn::Module{
Net(int64_tN,int64_tM)
:linear(register_module("linear",torch::nn::Linear(N,M)))
{}
torch::nn::Linearlinear;
};
或者您可以先使用空指针构造持有者,然后在构造函数中对其进行赋值(这对 Python 开发者来说更为熟悉):
structNet:torch::nn::Module{
Net(int64_tN,int64_tM){
linear=register_module("linear",torch::nn::Linear(N,M));
}
torch::nn::Linearlinear{nullptr};// construct an empty holder
};
总结:应该使用哪种所有权模型——哪种语义?C++ 前端的 API 最好地支持了模块持有者提供的所有权模型。这种机制的唯一缺点是在模块声明下方多了一行样板代码。尽管如此,最简单的模型仍然是 C++ 模块介绍中所示的值语义模型。对于小型、简单的脚本,您可能也可以使用它。但您迟早会发现,由于技术原因,它并不总是受支持。例如,序列化 API(torch::save
和 torch::load
)仅支持模块持有者(或普通的 shared_ptr
)。因此,模块持有者 API 是使用 C++ 前端定义模块的推荐方式,我们将在本教程中从此使用此 API。
定义 DCGAN 模块
我们现在已经具备了必要的背景知识和介绍,可以为本篇文章中要解决的机器学习任务定义模块。回顾一下:我们的任务是从 MNIST 数据集 中生成数字图像。我们希望通过使用 生成对抗网络 (GAN) 来完成这一任务。具体来说,我们将采用 DCGAN 架构,这是最早且最简单的架构之一,但对于这项任务来说完全足够。
您可以在该仓库中找到本教程中提供的完整源代码。
什么是 GAN?
GAN 由两个独立的神经网络模型组成:一个生成器和一个判别器。生成器接收来自噪声分布的样本,其目标是将每个噪声样本转换为类似于目标分布中的图像——在我们的案例中是 MNIST 数据集。判别器则接收来自 MNIST 数据集的真实图像或来自生成器的伪造图像。它需要输出一个概率值,判断特定图像的真实性(越接近 1
表示越真实,越接近 0
表示越伪造)。生成器根据判别器对其生成图像真实性的反馈进行训练,而判别器则根据其判断真实性的能力进行优化。理论上,生成器和判别器之间的微妙平衡使它们能够协同改进,最终生成器生成的图像与目标分布无法区分,从而让判别器(此时)出色的判断力对真实和伪造图像都输出 0.5
的概率。对我们来说,最终的结果是一台机器,它接收噪声作为输入,并生成逼真的数字图像作为输出。
生成器模块
我们首先定义生成器模块,该模块由一系列转置的二维卷积、批量归一化和ReLU激活单元组成。在我们自己定义的模块的forward()
方法中,我们会显式地(以函数式的方式)在模块之间传递输入:
structDCGANGeneratorImpl:nn::Module{
DCGANGeneratorImpl(intkNoiseSize)
: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::Tensorforward(torch::Tensorx){
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));
returnx;
}
nn::ConvTranspose2dconv1,conv2,conv3,conv4;
nn::BatchNorm2dbatch_norm1,batch_norm2,batch_norm3;
};
TORCH_MODULE(DCGANGenerator);
DCGANGeneratorgenerator(kNoiseSize);
我们现在可以在 DCGANGenerator
上调用 forward()
方法,将噪声样本映射为图像。
所选择的特定模块,如 nn::ConvTranspose2d
和 nn::BatchNorm2d
,遵循了之前概述的结构。kNoiseSize
常量决定了输入噪声向量的大小,并设置为 100
。当然,超参数是通过“研究生下降法”找到的。
在超参数的发现过程中,没有研究生受到伤害。他们定期被喂食 Soylent。
关于如何将选项传递给 C++ 前端中内置模块(如
Conv2d
)的简要说明:每个模块都有一些必需的选项,比如BatchNorm2d
的特征数量。如果只需要配置这些必需的选项,可以直接将它们传递给模块的构造函数,例如BatchNorm2d(128)
、Dropout(0.5)
或Conv2d(8, 4, 2)
(分别表示输入通道数、输出通道数和卷积核大小)。然而,如果需要修改其他通常使用默认值的选项,比如Conv2d
的bias
,则需要构造并传递一个 options 对象。C++ 前端中的每个模块都有一个相关的选项结构体,称为ModuleOptions
,其中Module
是模块的名称,例如Linear
模块的LinearOptions
。这就是我们在上面的Conv2d
模块中所做的操作。
判别器模块
判别器同样由一系列卷积、批量归一化和激活函数组成。然而,这里的卷积是常规卷积而非转置卷积,并且我们使用 alpha 值为 0.2 的 Leaky ReLU 而不是普通的 ReLU。此外,最终的激活函数变为 Sigmoid,它将值压缩到 0 到 1 之间。我们可以将这些压缩后的值解释为判别器认为图像是真实的概率。
为了构建判别器,我们将尝试一种不同的方法:使用 Sequential 模块。与 Python 类似,PyTorch 在这里提供了两种定义模型的 API:一种是函数式 API,其中输入通过一系列函数传递(例如生成器模块示例);另一种是更面向对象的 API,我们构建一个包含整个模型作为子模块的 Sequential 模块。使用 Sequential,判别器将如下所示:
nn::Sequentialdiscriminator(
// 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
模块只是简单地执行函数组合。第一个子模块的输出成为第二个子模块的输入,第三个子模块的输出成为第四个子模块的输入,依此类推。
加载数据
既然我们已经定义了生成器和判别器模型,我们需要一些数据来训练这些模型。C++ 前端与 Python 一样,配备了一个强大的并行数据加载器。这个数据加载器可以从数据集中读取批量数据(您可以自定义数据集),并提供了许多配置选项。
虽然 Python 数据加载器使用了多进程处理,但 C++ 数据加载器是真正的多线程实现,不会启动任何新进程。
数据加载器是 C++ 前端 data
API 的一部分,包含在 torch::data::
命名空间中。该 API 由几个不同的组件组成:
-
数据加载器类,
-
用于定义数据集的 API,
-
用于定义转换的 API,这些转换可以应用于数据集,
-
用于定义采样器的 API,采样器生成用于索引数据集的索引,
-
现有数据集、转换和采样器的库。
在本教程中,我们可以使用 C++ 前端自带的 MNIST
数据集。为此,我们将实例化一个 torch::data::datasets::MNIST
,并应用两个转换:首先,我们将图像归一化,使其范围从 0
到 1
变为 -1
到 +1
。其次,我们应用 Stack
整理操作,它将一批张量沿着第一个维度堆叠成一个单一的张量:
autodataset=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
(该类型取决于数据集的类型、采样器的类型以及其他一些实现细节):
autodata_loader=torch::data::make_data_loader(std::move(dataset));
数据加载器确实提供了许多选项。您可以在这里查看完整的选项集here。例如,为了加快数据加载速度,我们可以增加工作线程的数量。默认值为零,这意味着将使用主线程。如果我们将workers
设置为2
,将会生成两个线程来并发加载数据。我们还应将批次大小从默认的1
调整为更合理的值,例如64
(即kBatchSize
的值)。因此,让我们创建一个DataLoaderOptions
对象并设置适当的属性:
autodata_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_ti=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
整理方法,数据加载器仅返回一个这样的示例。如果我们没有应用整理方法,数据加载器将会返回 std::vector<torch::data::Example<>>
,其中每个元素对应批次中的一个示例。
如果您重新构建并运行此代码,您应该会看到类似如下的输出:
root@fa350df05ecf:/home/build#make
Scanningdependenciesoftargetdcgan
[50%]BuildingCXXobjectCMakeFiles/dcgan.dir/dcgan.cpp.o
[100%]LinkingCXXexecutabledcgan
[100%]Builttargetdcgan
root@fa350df05ecf:/home/build#make
[100%]Builttargetdcgan
root@fa350df05ecf:/home/build#./dcgan
Batchsize:64|Labels:5267216701623691840653304666408606924028633292014234829935800799
Batchsize:64|Labels:2247128869022936138044888926471509754354128071961653441232350162
Batchsize:64|Labels:4542148383615436225131508215324459728920674383588305808785561780
Batchsize:64|Labels:3371416103640254042819651632892387459608300648254183780089672147
Batchsize:64|Labels:3055983989595041277200548776107930632627633405889192194492462940
Batchsize:64|Labels:9675359086678219881182071416751774032906634481286920312856485862
Batchsize:64|Labels:9303651860199161774447886782604682539840993705824562825371918227
Batchsize:64|Labels:9192726086877486116857913205173161086081054938584801262427737453
Batchsize:64|Labels:8831864295802866709838716627745521795491031939885375368942012547
Batchsize:64|Labels:9270844275006205959889357547305765716287632656127700590091783294
Batchsize:64|Labels:7657752249948748945712698512367811398795085187265120974090460086
...
这意味着我们已经成功地从 MNIST 数据集中加载了数据。
编写训练循环
现在让我们完成示例的算法部分,并实现生成器和判别器之间的微妙交互。首先,我们将创建两个优化器,一个用于生成器,另一个用于判别器。我们使用的优化器实现了 Adam 算法:
torch::optim::Adamgenerator_optimizer(
generator->parameters(),torch::optim::AdamOptions(2e-4).betas(std::make_tuple(0.5,0.5)));
torch::optim::Adamdiscriminator_optimizer(
discriminator->parameters(),torch::optim::AdamOptions(5e-4).betas(std::make_tuple(0.5,0.5)));
截至目前,C++ 前端提供了实现 Adagrad、Adam、LBFGS、RMSprop 和 SGD 的优化器。最新的列表可在 文档 中查看。
接下来,我们需要更新我们的训练循环。我们将添加一个外层循环,以便在每个 epoch 中遍历数据加载器,然后编写 GAN 的训练代码:
for(int64_tepoch=1;epoch<=kNumberOfEpochs;++epoch){
int64_tbatch_index=0;
for(torch::data::Example<>&batch:*data_loader){
// Train discriminator with real images.
discriminator->zero_grad();
torch::Tensorreal_images=batch.data;
torch::Tensorreal_labels=torch::empty(batch.data.size(0)).uniform_(0.8,1.0);
torch::Tensorreal_output=discriminator->forward(real_images).reshape(real_labels.sizes());
torch::Tensord_loss_real=torch::binary_cross_entropy(real_output,real_labels);
d_loss_real.backward();
// Train discriminator with fake images.
torch::Tensornoise=torch::randn({batch.data.size(0),kNoiseSize,1,1});
torch::Tensorfake_images=generator->forward(noise);
torch::Tensorfake_labels=torch::zeros(batch.data.size(0));
torch::Tensorfake_output=discriminator->forward(fake_images.detach()).reshape(fake_labels.sizes());
torch::Tensord_loss_fake=torch::binary_cross_entropy(fake_output,fake_labels);
d_loss_fake.backward();
torch::Tensord_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).reshape(fake_labels.sizes());
torch::Tensorg_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
Scanningdependenciesoftargetdcgan
[50%]BuildingCXXobjectCMakeFiles/dcgan.dir/dcgan.cpp.o
[100%]LinkingCXXexecutabledcgan
[100%]Builttargetdcga
[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
...
迁移到 GPU
虽然我们当前的脚本在 CPU 上运行得很好,但我们都知道卷积在 GPU 上会快得多。让我们快速讨论一下如何将训练转移到 GPU 上。为此,我们需要做两件事:将 GPU 设备规范传递给我们自己分配的张量,并通过 C++ 前端中所有张量和模块都有的 to()
方法显式地将其他张量复制到 GPU 上。实现这两点的最简单方法是在训练脚本的顶层创建一个 torch::Device
实例,然后将该设备传递给像 torch::zeros
这样的张量工厂函数以及 to()
方法。我们可以从使用 CPU 设备开始:
// Place this somewhere at the top of your training script.
torch::Devicedevice(torch::kCPU);
新的张量分配操作,例如
torch::Tensorfake_labels=torch::zeros(batch.data.size(0));
应更新为将 device
作为最后一个参数:
torch::Tensorfake_labels=torch::zeros(batch.data.size(0),device);
对于那些我们无法直接控制的张量,比如来自 MNIST 数据集的张量,我们必须显式地插入 to()
调用。这意味着
torch::Tensorreal_images=batch.data;
成为
torch::Tensorreal_images=batch.data.to(device);
并且我们的模型参数应该被移动到正确的设备上:
generator->to(device);
discriminator->to(device);
如果张量已经位于提供给
to()
的设备上,则该调用不会执行任何操作。不会进行额外的复制。
此时,我们只是让之前驻留在 CPU 上的代码更加明确。不过,现在也很容易将设备更改为 CUDA 设备:
torch::Devicedevice(torch::kCUDA)
现在,所有张量都将存在于 GPU 上,所有操作都将调用快速的 CUDA 内核,而无需我们更改任何下游代码。如果我们想指定特定的设备索引,可以将其作为 Device
构造函数的第二个参数传递。如果我们希望不同的张量存在于不同的设备上,可以传递单独的设备实例(例如一个在 CUDA 设备 0 上,另一个在 CUDA 设备 1 上)。我们甚至可以动态地进行这种配置,这通常有助于使我们的训练脚本更具可移植性:
torch::Devicedevice=torch::kCPU;
if(torch::cuda::is_available()){
std::cout<<"CUDA is available! Training on GPU."<<std::endl;
device=torch::kCUDA;
}
或者甚至
torch::Devicedevice(torch::cuda::is_available()?torch::kCUDA:torch::kCPU);
检查点和恢复训练状态
我们应对训练脚本进行的最后一项增强是定期保存模型参数的状态、优化器的状态以及一些生成的图像样本。如果我们的计算机在训练过程中崩溃,前两者将允许我们恢复训练状态。对于长时间的训练会话,这一点至关重要。幸运的是,C++ 前端提供了一个 API,可以序列化和反序列化模型和优化器状态,以及单个张量。
核心API是torch::save(thing,filename)
和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::Tensorsamples=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
的整数值,表示每 100
个批次进行一次检查点保存,而 checkpoint_counter
是一个计数器,每次创建检查点时都会增加。
要恢复训练状态,您可以在所有模型和优化器创建之后,但在训练循环开始之前,添加如下代码:
torch::optim::Adamgenerator_optimizer(
generator->parameters(),torch::optim::AdamOptions(2e-4).beta1(0.5));
torch::optim::Adamdiscriminator_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_tcheckpoint_counter=0;
for(int64_tepoch=1;epoch<=kNumberOfEpochs;++epoch){
int64_tbatch_index=0;
for(torch::data::Example<>&batch:*data_loader){
检查生成的图像
我们的训练脚本现已完成。我们已准备好训练 GAN,无论是使用 CPU 还是 GPU。为了检查训练过程中的中间输出(我们已添加代码来定期将图像样本保存到 "dcgan-sample-xxx.pt"
文件中),我们可以编写一个简短的 Python 脚本来加载张量并使用 matplotlib 显示它们:
importargparse
importmatplotlib.pyplotasplt
importtorch
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 个周期:
root@3c0711f20896:/home/build#make&&./dcgan10:17:57
Scanningdependenciesoftargetdcgan
[50%]BuildingCXXobjectCMakeFiles/dcgan.dir/dcgan.cpp.o
[100%]LinkingCXXexecutabledcgan
[100%]Builttargetdcgan
CUDAisavailable!TrainingonGPU.
[1/30][200/938]D_loss:0.4953|G_loss:4.0195
*>checkpoint1
[1/30][400/938]D_loss:0.3610|G_loss:4.8148
*>checkpoint2
[1/30][600/938]D_loss:0.4072|G_loss:4.36760
*>checkpoint3
[1/30][800/938]D_loss:0.4444|G_loss:4.0250
*>checkpoint4
[2/30][200/938]D_loss:0.3761|G_loss:3.8790
*>checkpoint5
[2/30][400/938]D_loss:0.3977|G_loss:3.3315
...
*>checkpoint120
[30/30][938/938]D_loss:0.3610|G_loss:3.8084
并在图中显示图像:
root@3c0711f20896:/home/build#pythondisplay.py-idcgan-sample-100.pt
Savedout.png
结果应该类似于这样:
数字!太棒了!现在轮到你了:你能改进模型,让数字看起来更完美吗?
结语
本教程希望为您提供了一份易于理解的 PyTorch C++ 前端概览。像 PyTorch 这样的机器学习库必然拥有非常广泛且复杂的 API。因此,我们无法在这里涵盖所有概念。不过,我鼓励您尝试使用这个 API,并在遇到问题时参考我们的文档,特别是库 API部分。此外,请记住,C++ 前端在设计和语义上尽可能与 Python 前端保持一致,因此您可以利用这一点来提高学习效率。
您可以在此教程中找到完整的源代码,请访问此仓库。
一如既往,如果您遇到任何问题或有疑问,可以使用我们的论坛或GitHub issues与我们联系。