C---深度学习-三-
C++ 深度学习(三)
译者:飞龙
VectorXd z1 = Wih * x + bh;
VectorXd a1 = sigmoid(z1);
VectorXd z2 = Who * a1 + bo;
VectorXd y = sigmoid(z2);
// ----- Deltas (backprop) -----
VectorXd delta_out = sigmoid_deriv_from_output(y).cwiseProduct(t - y); // [O]
VectorXd delta_hid = (Who.transpose() * delta_out).cwiseProduct(sigmoid_deriv_from_output(a1)); // [H]
// ----- Gradients (outer products) -----
MatrixXd dWho = delta_out * a1.transpose();
VectorXd dbo = delta_out;
MatrixXd dWih = delta_hid * x.transpose();
VectorXd dbh = delta_hid;
// ----- SGD update (+= because delta uses t - y) -----
Who += lr * dWho; bo += lr * dbo;
Wih += lr * dWih; bh += lr * dbh;
return 0.5 * (y - t).squaredNorm();
}
如*图 4.5*所示,不同的梯度下降变体在同一个损失曲面上会走出截然不同的路径。
<https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_155_0.png>
*图 4.5:批量梯度下降与小批量梯度下降及随机梯度下降的对比*
椭圆代表损失曲面的等高线。批量梯度下降(Batch GD)每一步都需要计算整个数据集的梯度,因此轨迹平滑且稳定,但每次更新的代价很高。随机梯度下降(SGD)每次只根据一个样本进行更新,产生一条噪声大、抖动明显的路径,移动虽快但方差很高。小批量梯度下降(Mini-batch GD)在一个小批量(如 32–256)上对梯度求平均,得到分段平滑的更新步,在稳定性、速度和硬件效率之间取得平衡——因此成为训练神经网络的默认选择。
在**批量梯度下降**中,每次更新前都要在整个**训练集**上计算梯度:
W ← W − lr × (1/N) Σ_{i=1}^{N} ∇_W L(x_i, t_i),
b ← b − lr × (1/N) Σ_{i=1}^{N} ∇_b L(x_i, t_i).
它能给出平滑、稳定的更新步,但每次更新的代价都很高(必须遍历全部 N 个样本),而且可能非常消耗内存。
在**SGD**中,你每次使用**一个样本**进行更新:
W ← W − lr × ∇_W L(x_i, t_i),
b ← b − lr × ∇_b L(x_i, t_i),
以随机顺序遍历数据。更新既廉价又频繁,固有的噪声有助于逃离浅的局部最小值,但损失曲线会抖动,更新步也可能不稳定。
**小批量SGD**在一个由 m 个样本组成的小批量(如 32–256)上对梯度求平均,然后更新一次:
W ← W − lr × (1/m) Σ_{j=1}^{m} ∇_W L(x_{i_j}, t_{i_j}),
这兼顾了稳定性与速度。小批量在 GPU 上能很好地向量化,充分利用内存带宽,也是深度学习的默认选择。
### 何时使用哪种方法(实用要点)
- **批量梯度下降**:适用于很小的数据集,此时完整遍历一次代价不高;否则每次更新太慢。
- **SGD(单样本)**:适用于流式数据,或需要非常快速、带噪声的探索;往往需要更小的学习率。
- **小批量(推荐)**:对大多数工作负载而言是最佳折衷;选择能放进内存的最大批量(常见范围:32–1024)。每个 epoch 都做 shuffle 以保证批次无偏。
### 可在训练循环中配合使用的技巧
- 将**epoch**定义为对数据集的一次完整遍历;**iteration/step**则是一次参数更新(处理一个批次)。
- 随着批量增大,通常可以调大 `lr`(例如采用线性缩放规则,并配合短暂 warmup)。
- 更大的批量能提升吞吐量,但若过度增大可能损害泛化能力;请监控验证集指标。
- 分批次前一定要**shuffle**以减少样本间的相关性;在更深的网络上使用小批量 + momentum/Adam 可加快收敛。
简而言之:批量梯度下降稳定但缓慢;纯 SGD 快速但有噪声;**小批量SGD**则兼得两者之长,也是本章训练网络时所采用的方法。
### 本章小结
在本章中,你从第一性原理出发,逐步构建起了用 C++ 训练小型神经网络的整套机制。你从线性回归和逻辑回归入手,为损失函数和梯度的数学打下了基础;随后将神经元建模为“仿射变换 + 非线性激活”。你用 Eigen 实现了一个微型 MLP,编写了**前向传播**(`z = Wx + b, a = σ(z)`),并通过链式法则推导了**反向传播**,得到逐层的 δ 与外积梯度。你使用**梯度下降**的各变体(批量、随机、小批量)训练模型,在留出集上监控准确率 / MSE,并学会了实用的训练习惯:每个 epoch 进行 shuffle、选择与任务匹配的输出激活函数和损失函数、把学习率理解为更新步长。在这个过程中,你看到一旦数据被映射为向量,*相同*的“前向 | 反向 | 更新”循环就能跨领域工作。这为你将这些逻辑直接扩展到 LibTorch,以及后续通过 CUDA 实现 GPU 加速做好了准备。
在下一章中,我们将从*从零开始*编写的 Eigen 代码转向 PyTorch C++ API(LibTorch),引入模块、优化器以及 dataset/dataloader 抽象。你将构建更深的网络,在可用时用 GPU 训练,并学习如何保存和加载模型,以及如何为更大的项目组织训练循环。
### 思考题
在继续之前,为了帮助你检查自己的理解,请思考以下问题:
1. 神经元的权重和偏置各有什么作用?
2. 为什么需要非线性激活?
3. 解释批量梯度下降、SGD 和小批量SGD——各自在什么时候更适用?
4. 学习率控制什么?如果太大或太小会怎样?
### 延伸阅读
如果你想加深理解,以下资源提供了清晰的解释和有用的背景:
- *深度学习——Goodfellow、Bengio、Courville(第 6–8 章:MLP、优化、正则化)*
- *模式识别与机器学习——Bishop* *(第 4–5 章:线性/逻辑模型、神经网络)*
- *斯坦福 CS231n 笔记*:反向传播与神经网络优化
- *PyTorch(LibTorch)C++ API 教程*:模型、优化器与张量基础
- *反向传播通俗入门*:帮助建立直觉的博客文章(链式法则、计算图)
### 参考答案
当你准备好后,可将自己的回答与下面的简要答案进行对照,以确认理解是否正确:
1. 权重对输入进行缩放以形成特征;偏置对激活进行平移,使神经元不必强制经过原点。
2. 没有非线性的话,堆叠的仿射层会坍缩成一个仿射映射,从而无法建模弯曲的决策边界。
3. 批量梯度下降:使用全量数据集的梯度——稳定但每步较慢。SGD:每步一个样本——快速但有噪声。小批量:在一个小批量(如 32–256)上求平均——最佳折衷,也是深度学习的默认选择。
4. 它决定更新步长。太大:发散/震荡;太小:收敛缓慢或陷入停滞。
### 获取本书 PDF 版及更多内容
扫描二维码(或访问 `packtpub.com/unlock`)。按书名搜索本书,确认版本,然后按照页面上的步骤操作。
<https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_158_0.png><https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_158_1.png>
*注意:请保管好发票。从 Packt 直接购买则无需发票。*
## 5 C++ 中的多层感知机
**多层感知机**(**MLPs**)代表了神经网络架构中的关键进步,将简单的感知机转变为强大的学习系统,能够解决复杂的非线性可分问题。通过堆叠多层相互连接的神经元——每一层都应用加权变换和非线性激活函数——MLPs 创建了分层表示,能够捕获数据中的复杂模式。本章探讨了 MLPs 的理论基础和 C++ 中的实践实现,探索三种不同的方法:使用 Eigen 库的基础 CPU 实现、使用 CUDA 的高性能 GPU 加速解决方案,以及使用 LibTorch(PyTorch 的 C++ API)的生产级实现。
我们首先通过 XOR 问题的视角剖析 MLP 架构,说明为什么单层网络无法成功而多层架构可以。随后,本章全面探索激活函数——从经典的 sigmoid 和 tanh 函数到现代变体,如 ReLU、Leaky ReLU、Swish 和 Mish——解释每种函数如何引入学习复杂映射所必需的非线性。最后,我们研究驱动学习过程的优化算法,包括梯度下降、动量、RMSprop、**自适应矩估计**(**Adam**)、Adagrad 和 Adadelta,并为每种算法提供数学直觉和完整的 C++ 实现。
学完本章后,你将掌握在 C++ 中实现、训练和优化多层神经网络的理论理解和实践技能,并能够根据你的具体需求选择最合适的实现策略——无论是用于嵌入式系统的原生 C++、追求最大性能的 CUDA,还是用于生产部署的 LibTorch。
本章将涵盖以下主题:
- 理解 MLP 架构
- 选择你的 MLP 实现方法:策略指南
- 探索深度学习训练概念
### 技术要求
要跟随本章的实现,你需要以下内容:
- 软件要求:
- 支持 C++14 或更高版本的 C++ 编译器
- 用于矩阵运算的 Eigen 库(版本 3.3 或更高)
- 用于 GPU 实现的 CUDA 工具包(版本 11.0 或更高)
- 用于 PyTorch 实现的 LibTorch(PyTorch C++ API)
- 硬件要求:
- 支持 CUDA 的 NVIDIA GPU(用于 GPU 实现)
- 至少 8GB 内存
- 建议使用多核 CPU 进行并行处理
- 开发环境:
- 支持 C++ 的 IDE(Visual Studio、PyCharm 或类似工具)
- 用于访问代码仓库的 Git
- 用于构建和运行示例的命令行界面
- 先备知识:
- 对 C++ 编程的基本理解
- 熟悉神经网络概念
- 理解基础线性代数
- 具有构建系统和包管理经验
### 理解 MLP 架构
MLP 的架构是一种复杂的层次化结构,每个组件在网络功能中都扮演着关键角色。MLP 架构的核心遵循层级化设计,起始于输入层,它充当网络的感知系统,接受各种维度的原始数据特征。这些输入随后进入一个或多个隐藏层。每个隐藏层包含大量神经元,它们通过带权重的突触相互连接,形成复杂的计算路径网络。每个神经元通过激活函数处理其输入,引入非线性,使网络能够学习模式和关系。层之间的加权连接充当知识库,在训练过程中存储学到的模式。最后,架构在输出层达到顶点,其结构由手头的特定任务决定——可能是用于二分类的单个神经元,也可能是用于更复杂预测的多个神经元。这种受生物神经网络启发的架构设计,使 MLP 能够通过连续的抽象层转换输入数据,有效学习输入空间的层次化表示。我们将通过一个 C++ 程序来探索基本的 MLP。首先,我们将查看整个程序,然后在后续章节中剖析上述每个组件。
```text
Illustrative example of Multilayer perceptron, a Feedforward neural network
Input layer
Hidden layer
Output layer
h1
h2
yk
x1
1
x2
w1k
w2k
w11
w21
w31
w12
w22
w32
input
input
To model bias
| 变量 | 含义 |
|---|---|
| x1, x2 | 输入数据特征 |
| wij | 网络权重 |
| h1, h2 | 隐藏层中的节点 |
| yk | 输出变量 |
该示例由以下部分组成:(a) 一个包含两个输入节点和一个偏置节点的输入层,(b) 一个包含两个神经元的隐藏层,(c) 一个包含一个神经元的输出层
图 5.1:多层感知机。图片由 AIML.com 提供
现在我们对 MLP 架构有了概念性的理解,接下来让我们深入实践实现,探索这些组件如何在代码中协同工作。
在 C++ 中实现 MLP
在 C++ 中实现 MLP 需要仔细考虑各种组件及其交互。在本节中,我们将创建一个完整的 MLP 实现,用于解决异或(XOR)问题,演示如何使用类来构建网络,并利用 Eigen 库进行高效的矩阵运算。
在深入研究代码之前,让我们先看看 XOR 问题,以及为什么单层感知机不足以解决 XOR 问题。XOR 是一个逻辑门问题,它接受两个二进制输入并产生一个二进制输出。当输入值不同时,XOR 操作的输出为 1。与 AND、OR 或 NAND 等其他具有线性可分边界的逻辑门不同,如图 5.2所示,XOR 数据不是线性可分的。感兴趣的读者可以参考延伸阅读部分,以更深入地理解该问题。
图 5.2:逻辑门 AND、OR、NAND 和 XOR。除 XOR 之外的所有逻辑门都具有线性可分边界
我们的实现将包含两个主要类:一个处理单个层操作的 Layer 类,以及一个管理整个网络架构的 MultilayerPerceptron 类。通过这个实现,你将学习如何使用现代 C++ 实践执行前向传播、反向传播和神经网络训练。在本节中,我们将只强调 C++ 代码的重要方面。完整代码可以从相关的 GitHub 页面下载(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter5/eigen_mlp_xor.cpp)。让我们从 Layer 类开始,它是我们网络的基本构建块。
Layer 类
Layer 类是神经网络实现中的基本构建块,表示单个神经元层。在下面的代码中,你会看到一个层类的实例化。有一些关键变量,如 weights、biases、activation、input 和 output。weights 是一个矩阵,其维度由输入层和输出层的节点数决定。因此,如果输入层和输出层分别有 2 个和 3 个节点,则权重矩阵的大小为 (2, 3)。biases 是每层的默认激活值。activation 变量存储中间计算,如 activation = f(weights * input + bias)。其中 f 是激活函数。我们将在本章后面讨论各种激活函数。
class Layer {
private:
Eigen::MatrixXd weights;
Eigen::VectorXd biases;
Eigen::MatrixXd activation;
Eigen::MatrixXd input;
Eigen::MatrixXd output;
public:
Layer(int input_size, int output_size) {
weights = Eigen::MatrixXd::Random(output_size, input_size) * 0.1;
biases = Eigen::VectorXd::Zero(output_size);
}
Layer 类有两个主要操作:
- 前向传播:这就像将信息向前传递穿过层。它接收输入数据,乘以权重,加上偏置,并应用激活函数(此处为 ReLU)来产生输出。
Eigen::MatrixXd forward(const Eigen::MatrixXd& x)
{
input = x;
activation = (weights * input + biases.replicate(1, input.cols()));
output = relu(activation);
return output;
}
b. 反向传播:这是一个学习阶段,层会根据自身产生的误差调整权重和偏置。就像学生在看到正确答案后改正自己的错误一样。
Eigen::MatrixXd backward(const Eigen::MatrixXd& grad_output, double learning_rate) {
Eigen::MatrixXd grad_activation = grad_output.array() * relu_derivative(activation).array();
// Calculate gradients
Eigen::MatrixXd grad_weights = grad_activation * input.transpose();
Eigen::VectorXd grad_biases = grad_activation.rowwise().sum();
Eigen::MatrixXd grad_input = weights.transpose() * grad_activation;
// Update weights and biases
weights -= learning_rate * grad_weights;
biases -= learning_rate * grad_biases;
return grad_input;
}
现在,我们已经定义了 Layer 类,可以利用它来构建 MLP 类,该类将多个层编排成一个完整的神经网络架构。
MultilayerPerceptron 类
在 Layer 类奠定的基础之上,我们可以构建 MultilayerPerceptron 类——这是一个完整的神经网络,将多个层编排成一个内聚的架构。该类作为训练模型的神经网络,管理从初始化到预测的整个学习过程。
MLP 网络通过指定层大小来创建——层大小是一个向量,定义了网络中每一层的节点数量。构造函数使用 for 循环遍历该向量,通过连接相邻的层大小依次创建每一层。
网络结构建立后,MultilayerPerceptron 类实现了一个 forward 函数,该函数建立在 Layer 类中定义的 forward 函数之上。这个函数编排数据在整个网络中的流动,将每一层的输出作为下一层的输入,从而有效地将各层操作串联起来,形成一个能够解决 XOR 分类任务等复杂问题的深度学习架构。
class MultilayerPerceptron {
private:
std::vector<Layer> layers;
double learning_rate;
public:
MultilayerPerceptron(const std::vector<int>& layer_sizes, double lr = 0.01)
: learning_rate(lr) {
for (size_t i = 0; i < layer_sizes.size() - 1; ++i) {
layers.emplace_back(layer_sizes[i], layer_sizes[i + 1]);
}
}
Eigen::MatrixXd forward(const Eigen::MatrixXd& input) {
Eigen::MatrixXd current_input = input;
for (auto& layer : layers) {
current_input = layer.forward(current_input);
}
return current_input;
}
最后,我们可以定义训练循环,对 MLP 网络进行训练。这个 train 函数会按给定的 epochs(轮数)进行迭代。在每个 epoch 中,它计算损失函数均方误差(MSE)。每一步都会执行前向传播和反向传播函数,同时尽量降低损失值。
void train(const Eigen::MatrixXd& X, const Eigen::MatrixXd& y, int epochs) {
for (int epoch = 0; epoch < epochs; ++epoch) {
// Forward pass
auto prediction = forward(X);
// Calculate loss (MSE)
double loss = (prediction - y).array().square().mean();
if (epoch % 100 == 0) {
std::cout << "Epoch " << epoch << ", Loss: " << loss << std::endl;
}
// Backpropagation
Eigen::MatrixXd grad = 2.0 * (prediction - y) / y.cols(); // MSE derivative
// Backward pass through all layers in reverse
for (int i = layers.size() - 1; i >= 0; --i) {
grad = layers[i].backward(grad, learning_rate);
}
}
}
// Method to make predictions after training
Eigen::MatrixXd predict(const Eigen::MatrixXd& X) {
return forward(X);
}
};
在这个程序中,我们使用了一些术语,如 ReLU(激活函数)、epochs(轮数)和学习率(learning rate)。我们将在探索深度学习训练概念一节中讨论这些术语。
与此同时,让我们看看编写此程序时使用的一些 C++ 库。它们包括:
- Eigen:Eigen 是一个高性能的 C++ 模板库,用于线性代数、矩阵、向量及相关算法。在 MLP 代码中,它用于:
MatrixXd:动态大小矩阵运算VectorXd:动态大小向量运算- 高效的矩阵乘法和数学运算
- 管理神经网络层中的权重和偏置
- 向量库(std::vector):C++ 标准模板库(STL)中的 vector 在代码中用于:
- 存储和管理
Layer对象的集合 - 为神经网络架构提供动态数组功能
- 管理网络配置中的层大小
- 存储和管理
- 数学库(cmath):C++ 数学库提供基本的数学运算。在神经网络实现的上下文中,它用于:
- 数学函数和运算
- 激活函数中的计算
- 计算损失和梯度
以上说明都是针对上述 C++ 代码的。如需详细了解,请参阅相关参考资料。这里给出的 MLP 代码没有使用图形处理器(GPU)提供的任何并行处理。现在,我们将看看使用 GPU 并行化的同一段 C++ 代码。
使用 CUDA 实现 MLP
GPU 并行化是现代深度学习实现的基石,它提供了训练和部署复杂神经网络所需的大规模计算加速能力。虽然 PyTorch 和 TensorFlow 等框架抽象掉了大部分 GPU 处理细节,但理解 C++ 中 GPU 加速的底层机制对于优化性能和构建自定义解决方案至关重要。本节探讨如何在 C++ 中直接利用 GPU 能力,重点关注那些与金融、自动驾驶系统和医疗保健等高风险应用中尤为相关的实际实现,这些领域对计算效率和低延迟的要求极高。与之前一样,完整代码将在本章关联的 GitHub(https://github.com/PacktPublishing/Deep-Learning-with-C-/tree/main/chapter5)中提供。这里,我们将拆解代码中最重要的部分。
以下是为 CUDA 核函数定义的头部,它们处理基本的 CUDA 操作并定义 GPU 专用函数:
#include <cuda_runtime.h>
#include <curand.h>
#include <cublas_v2.h>
// CUDA kernels for ReLU activation and its derivative
__global__ void reluKernel(float* data, int size)
__global__ void reluDerivativeKernel(float* data, float* output, int size)
接下来,我们定义 CudaLayer 类。这与上一节中定义的 Layer 类类似。
class CudaLayer {
private:
int input_size, output_size;
float *d_weights, *d_biases;
float *d_input, *d_output;
float *d_activation;
cublasHandle_t handle;
public:
CudaLayer(int in_size, int out_size) : input_size(in_size), output_size(out_size) {
cublasCreate(&handle);
// Allocate memory on GPU
cudaMalloc(&d_weights, output_size * input_size * sizeof(float));
cudaMalloc(&d_biases, output_size * sizeof(float));
cudaMalloc(&d_input, input_size * sizeof(float));
cudaMalloc(&d_output, output_size * sizeof(float));
cudaMalloc(&d_activation, output_size * sizeof(float));
// Initialize weights randomly
curandGenerator_t gen;
curandCreateGenerator(&gen, CURAND_RNG_PSEUDO_DEFAULT);
curandGenerateUniform(gen, d_weights, output_size * input_size);
curandDestroyGenerator(gen);
// Initialize biases to zero
cudaMemset(d_biases, 0, output_size * sizeof(float));
}
该函数展示了变量的初始化过程。它使用 cudaMalloc 在 GPU 上分配内存。这与标准 C++ 中的 malloc 函数类似,但专门用于 GPU 内存分配。我们对每个变量都使用了 malloc 函数(这里指 cudaMalloc)。接下来,我们希望使用随机数生成器 curandGenerator 来随机初始化权重。curandGenerateUniform 利用该随机数生成器从均匀分布中采样随机数。最后,我们使用 cudaMemset 将偏置初始化为零。
初始化之后,让我们来看看前向传播函数。
void forward(float* input, int batch_size) {
const float alpha = 1.0f;
const float beta = 0.0f;
// Matrix multiplication: activation = weights * input + biases
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N,
output_size, batch_size, input_size,
&alpha, d_weights, output_size,
input, input_size, &beta,
d_activation, output_size);
// Add biases
for (int i = 0; i < batch_size; ++i) {
cublasSaxpy(handle, output_size, &alpha,
d_biases, 1,
d_activation + i * output_size, 1);
}
}
第5章:C++ 中的多层感知机
// Apply ReLU
int block_size = 256;
int num_blocks = (output_size * batch_size + block_size - 1) / block_size;
reluKernel<<<num_blocks, block_size>>>(d_activation, output_size * batch_size);
}
cublasSgemm 执行权重与输入之间的矩阵乘法。该函数对于 GPU 上神经网络计算中的高效矩阵运算至关重要。我们建议查看 进一步阅读 部分以及 cublas 库以了解各个参数的含义。然后我们使用 cublasSaxpy 将偏置加到之前乘法的结果上。接着我们对结果应用 ReLU 操作。这就完成了前向传播。
现在让我们来看反向传播。下面的代码展示了如何通过 ReLU 计算梯度。
void backward(float* grad_output, float learning_rate, int batch_size) {
const float alpha = learning_rate;
const float beta = 1.0f;
// Compute gradient through ReLU
int block_size = 256;
int num_blocks = (output_size * batch_size + block_size - 1) / block_size;
float* d_grad_activation;
cudaMalloc(&d_grad_activation, output_size * batch_size * sizeof(float));
reluDerivativeKernel<<<num_blocks, block_size>>>(d_activation, d_grad_activation, output_size * batch_size);
// Update weights
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_T,
output_size, input_size, batch_size,
&alpha, d_grad_activation, output_size,
d_input, input_size, &beta, d_weights, output_size);
// Update biases
cublasSgemv(handle, CUBLAS_OP_N,
output_size, batch_size,
&alpha,
d_grad_activation, output_size,
d_input, 1,
&beta,
d_biases, 1);
cudaFree(d_grad_activation);
}
然后它使用相同的 cublasSgemm 来更新模型权重,并使用 cublasSgemv 来更新偏置。与执行矩阵-矩阵乘法的 cublasSgemm 不同,cublasSgemv 执行的是矩阵-向量乘法。
最后,我们可以使用 CudaLayer 类来构建多层感知机,如下所示。
class CudaMLP {
private:
std::vector<CudaLayer> layers;
float learning_rate;
public:
CudaMLP(const std::vector<int>& layer_sizes, float lr = 0.01)
: learning_rate(lr) {
for (size_t i = 0; i < layer_sizes.size() - 1; ++i) {
layers.emplace_back(layer_sizes[i], layer_sizes[i + 1]);
}
}
在这个函数中,layers 是一个存储 CudaLayer 对象的向量。layer_sizes 是一个存储每层大小的向量。构造函数遍历 layer_sizes 来创建网络结构。它使用 emplace_back 操作来创建层。C++ 中常见的操作是 push_back,它会创建一个临时变量,然后将其存储到向量中;而 emplace_back 则通过在向量中直接构造 CudaLayer 对象,提供了一种更高效的操作。例如,如果 layer_sizes = {2, 4, 3, 1},该函数将创建一个 MLP,其中第一层有 2 个输入和 4 个输出,第二层有 4 个输入和 3 个输出,最后第三层有 3 个输入和 1 个输出。
请参阅 chapter_5/cuda_mlp.cpp 获取完整代码。要执行此程序,首先使用以下命令编译:
nvcc -o cuda_mlp cuda_mlp.cpp -lcublas -lcurand
然后按如下方式执行代码:
./cuda_mlp
到目前为止,我们已经看到了使用 C++ 解决相同的 XOR 问题,然后使用 CUDA 库进行高效加速计算。这些是底层库,可能不适用于构建复杂的神经网络架构。因此,我们现在将使用 LibTorch(PyTorch 的 C++ 实现)来查看相同的程序。
使用 LibTorch 实现 MLP
LibTorch 是 PyTorch 的 C++ 发行版,为 PyTorch 的机器学习能力提供了原生的 C++ 接口。它允许开发者用 C++ 构建和训练神经网络。它还通过 CUDA 库提供 GPU 加速。LibTorch 还通过 torch::nn 接口提供了预构建的神经网络组件。它还自动处理内存管理,大大减少了开发者的操作负担。
让我们来看一下 C++ 中的 LibTorch 接口:
#include <torch/torch.h>
#include <iostream>
// Define the MLP structure
struct MLP : torch::nn::Module {
torch::nn::Linear layer1{nullptr}, layer2{nullptr}, layer3{nullptr};
MLP() {
// Same architecture as before: 2-4-3-1
layer1 = register_module("layer1", torch::nn::Linear(2, 4));
layer2 = register_module("layer2", torch::nn::Linear(4, 3));
layer3 = register_module("layer3", torch::nn::Linear(3, 1));
}
torch::Tensor forward(torch::Tensor x) {
x = torch::relu(layer1->forward(x));
x = torch::relu(layer2->forward(x));
x = layer3->forward(x);
return x;
}
};
MLP 类初始化了三个层,节点大小分别为 2、4、3,最后输出。它使用 register_module 将层添加到模型中。还应注意,这里只实现了前向传播。在 LibTorch 中,不需要显式实现反向传播,因为它使用自动微分(autograd),并通过在损失对象上调用 backward() 方法来计算,如这个训练循环所示。
for (size_t epoch = 0; epoch < 1000; ++epoch) {
// Forward pass
auto output = model->forward(X);
auto loss = torch::mse_loss(output, y);
// Backward pass and optimize
optimizer.zero_grad();
loss.backward();
optimizer.step();
if (epoch % 100 == 0) {
std::cout << "Epoch " << epoch << ", Loss: "
<< loss.item<float>() << std::endl;
}
}
上述函数是使用 LibTorch 的 C++ API 编写的典型训练/优化循环。我们使用 MSE 损失函数,损失函数上的 backward() 函数在每一步计算反向传播。优化步骤包括三个步骤:
zero_grad():清除旧的梯度或重置其跟踪的所有参数的梯度,确保当前步骤的梯度不会与之前步骤的梯度累积。loss.backward():该步骤启动反向传播过程并计算新的梯度。optimizer.step():根据所使用的优化算法,应用计算出的梯度来调整模型的可训练参数。在我们的例子中,我们使用了随机梯度下降(SGD)。
到目前为止,我们探讨了实现 MLP 解决 XOR 问题的三种不同方法:
- 使用 C++ 和 Eigen 库的传统 CPU 实现
- 使用 CUDA 的 GPU 加速方案
- 使用 PyTorch 的 C++ API(LibTorch)的现代方法
选择你的 MLP 实现方案:一份策略指南
在完成了同一个 XOR 问题的三种完整实现——先用 Eigen,再用 CUDA,最后用 LibTorch——之后,你现在可以为自己的项目做出明智的选择了。每种方法都反映了不同的权衡,只有当你实际看到它们时才会变得清晰。接下来,让我们逐一看看这些方法。
使用 Eigen 的纯 C++
Eigen 实现要求我们手动编写每个组件:权重初始化、前向传播、反向传播循环和梯度更新步骤。这并非限制——而正是重点所在。当你的部署环境禁止外部框架依赖时,当你的目标是内存预算严格的嵌入式或物联网硬件时,或者当你在一个要求每行代码都可审计的受监管领域(医疗设备、航空航天)工作时,这种透明度是一种要求,而非偏好。
成本与控制力成正比。正如你在 Layer 类中所看到的,即使是一个简单的网络也需要手动实现梯度计算和权重更新。将其扩展到更深的架构或更复杂的损失函数,会显著增加这一负担。这种方法最适合拥有强大 C++ 专业能力,并且面临明确、具体约束的团队,因为这些约束使这种努力是值得的。
CUDA:以高昂成本换取最大性能
CUDA 实现展示了 GPU 加速在代码层面实际上涉及什么:为每个张量显式调用 cudaMalloc 和 cudaFree、仔细管理 CPU 到 GPU 的数据传输,以及直接调用 cuBLAS 例程(如 cublasSgemm 和 cublasSgemv)进行矩阵运算。性能上限很高——对于大规模工作负载,GPU 并行化相比 CPU 实现可以实现 10–100 倍的加速——但维护负担同样巨大。
在这个层面编写的代码可移植性较差,更难调试,并且需要持续的 GPU 专业知识来维护。当训练时间是主要瓶颈时,当你在为现有框架不支持的运算构建自定义 GPU 内核时,或者当你的应用程序需要大规模实时推理时,它是一个正确的选择。然而,对于大多数项目来说,除非这些条件得到明确满足,否则很难证明这种复杂性的合理性。
LibTorch:生产系统的实用默认选择
相同 XOR 网络的 LibTorch 实现只需要一次前向传播。反向传播通过调用 loss.backward() 自动处理,三步优化循环 —— zero_grad()、backward()、optimizer.step() —— 取代了前两种实现中手动编写的梯度累积和权重更新逻辑。跨平台部署、自动内存管理,以及对完整 PyTorch 生态系统的访问都无需额外代码。
这些权衡是真实存在的,但很少具有决定性:更大的二进制文件、框架依赖,以及对底层操作的可见性稍低。对于从原型转向生产的团队,或需要长期维护和扩展的系统而言,这些成本通常会被实现复杂度的降低所抵消。
混合方法
许多生产系统并不会只采用某一种方法。一种常见模式是使用 LibTorch 作为主要框架,同时针对构成可衡量性能瓶颈的特定操作编写自定义 CUDA 内核,并在资源受限的环境中使用原生 C++ 处理预处理或后处理步骤。这样既保留了 LibTorch 的大部分生产力优势,又为确实需要的地方保留了针对性优化的空间。
建议:对于大多数用例 —— 包括本章探讨的各类面向生产的架构 —— LibTorch 都是合适的起点。自动微分、内置优化器和跨平台支持满足了最常见的实际需求,而无需承担 CUDA 所需的基础设施投入,也无须承受原生 Eigen 的手动实现开销。只有当框架无法满足某个具体且可衡量的需求时,才应偏离这一默认选择。最有效的实现是能够交付、维护和迭代的实现,而不是理论上最优的实现。
下表总结了本章涉及的三种实现 —— 原生 C++/Eigen、CUDA 和 LibTorch —— 在内存管理、抽象级别、性能和实际用例等维度上的主要差异。
| 特性 | CPU 实现 | CUDA 实现 | LibTorch 实现 |
|---|---|---|---|
| 使用的库 | Eigen | cuBLAS、cuRAND | PyTorch C++ API |
| 内存管理 | 自动(C++) | 显式(cudaMalloc、cudaFree) | 自动 |
| 处理单元 | CPU | GPU | CPU/GPU |
| 抽象级别 | 中层 | 低层 | 高层 |
| 矩阵运算 | Eigen::MatrixXd |
cublasSgemm、cublasSgemv |
torch::Tensor |
| 实现复杂度 | 中等 | 复杂 | 简单 |
| 要求 | - C++ 编译器 - Eigen 库 - 8 GB 内存 |
- CUDA 工具包 - cuBLAS 库 - NVIDIA GPU |
- LibTorch - CMake - C++14 或更高版本 |
| 性能 | 大型计算较慢 | 大型计算最快 | 良好的平衡 |
| 内存访问 | 直接 | 需要 CPU-GPU 传输 | 抽象化 |
| 用例 | 原型开发、小型数据集 | 高性能计算 | 生产部署 |
| 梯度计算 | 手动实现 | 手动实现 | 自动微分 |
表 5.1:MLP 实现方法对比
在接下来的章节中,我们将使用 PyTorch 的 C++ API 重点介绍深度学习在 C++ 中的实际应用,提供更多动手实践示例和真实场景的实现。
探索深度学习训练概念
在本章中,我们使用了激活函数和优化技术等概念,但并未明确定义它们。在本节中,我们将稍微偏离主题,正式定义这两个概念。学习这些概念并建立直观理解,将在你的深度学习之旅中至关重要。
激活函数
在本章中,我们在 MLP 实现中一直使用 ReLU 函数。然而,激活函数的选择远非随意——它会显著影响网络学习和泛化的能力。激活函数是将非线性引入神经网络的数学门户,它将输入的加权和转换为有意义的输出,使网络能够学习数据中复杂的模式和关系。
如果没有激活函数,任何多层网络无论多深都会退化为一个简单的线性模型,从而丧失学习实际应用中存在的复杂非线性模式的能力。我们之前探讨的 XOR 问题完美地说明了这一局限性——线性模型无法解决它,但添加非线性激活函数后,网络就能学会解决方案。
几十年来,激活函数的格局经历了相当大的演变。早期神经网络严重依赖 sigmoid 和双曲正切(tanh)函数,它们提供平滑且有界的输出,适合某些应用。然而,随着网络越来越深,研究人员发现这些函数存在梯度消失问题,即反向传播过程中梯度变得越来越小,实际上使更深层的学习陷入停滞。这一局限性推动了 ReLU 函数族的发展,它通过支持训练更深的网络而彻底改变了深度学习。概括地说,激活函数可以分为两个主要家族:Sigmoid 家族和 ReLU 家族。
在本节中,我们将探索这两个家族的激活函数,考察它们的数学公式和关键特征。理解这些函数及其权衡,将使你在设计神经网络架构时能够做出明智的决策,最终带来更好的模型性能和训练稳定性。
第一个家族以平滑、有界的输出为特征,尽管后来暴露出诸多挑战,它仍然主导了早期神经网络架构。
Sigmoid 家族
Sigmoid 家族损失函数是机器学习中具有独特 S 形曲线的基础数学工具。这些函数巧妙地将任意实数值转换为 0 到 1 之间的概率值,使其在二分类问题中极具价值。其中最著名的成员——逻辑损失函数——是逻辑回归的支柱,也是神经网络的重要组成部分。它们提供平滑的梯度和自然的概率解释,使其成为机器学习工具箱中不可或缺的部分。现在,我们来看一些属于 Sigmoid 家族的常见损失函数:
a. 逻辑 Sigmoid(标准 Sigmoid):这是一种数学函数,将任意实数映射到 0 到 1 之间的值,其定义公式如下:
主要特征:
- 输出范围限定在 0 到 1 之间
- S 形曲线(sigmoid 形状)
- 平滑、连续且可微
- 可用于输出概率预测
b. 双曲正切(tanh):该函数与 sigmoid 类似,但将输入值映射到 -1 到 1 之间的输出。其定义公式如下:
主要特征:
- 零中心化(不同于以 0.5 为中心的 sigmoid)
- S 形曲线(与 sigmoid 类似但更陡峭)
- 平滑、连续且可微
- 与 sigmoid 相比梯度更强
- 适用于需要围绕 0 对称的数据
c. Softplus:它是 ReLU 函数的平滑、可微近似,可确保整个过程中梯度连续。其定义公式如下:
主要特点:
-
连续可微
-
输出范围为 [0, ∞)
-
比 ReLU 更平滑
-
计算成本更高
-
实践中较少使用
-
软符号(Softsign):它是一种与 tanh 类似但渐近行为不同的激活函数。其定义如下:
softsign(x) = x / (1 + |x|)主要特点:
- 输出范围在 -1 和 1 之间
- 与 tanh 一样以零为中心
- 与 tanh 相比,更平滑地趋近渐近线
- 采用多项式饱和而非指数饱和
- 计算上比 tanh 更简单
虽然 Sigmoid 系列凭借其平滑、有界的输出提供了理论基础,但更深网络中梯度消失的挑战促使了新型激活函数的开发。ReLU 系列作为一种革命性的替代方案出现,提供了计算简单性以及更好的梯度流,从而能够训练深度显著增加的神经网络。
ReLU 系列
ReLU 系列激活函数以其简单而有效的神经网络计算方式彻底改变了深度学习。它们的核心是允许正值不变地通过,同时将负值设为零(f(x) = max(0, x)),解决了早期基于 sigmoid 的网络中一直存在的梯度消失问题。该系列包括重要的变体,如允许少量负值通过的 Leaky ReLU,以及为负输入提供可学习斜率的 Parametric ReLU。它们的高计算效率、促进稀疏性以及生物学合理性,使 ReLU 系列函数在深度学习架构中无处不在,从而能够训练更深的网络。
-
修正线性单元(ReLU):它是现代神经网络中最常用的激活函数之一。其定义如下:
ReLU(x) = max(0, x)主要特点:
- 输出范围为 [0, ∞)
- 计算简单(仅取 0 和输入之间的最大值),从而加快训练速度
- 对正值非饱和
- 有助于解决梯度消失问题
- 在网络中引入稀疏性
-
带泄漏的修正线性单元(Leaky ReLU):与标准 ReLU 不同,这一变体为负值维持一个小的非零输出,解决了神经元完全停止学习的问题。其定义如下:
LeakyReLU(x) = max(αx, x)其中 α 是一个小的常数(通常为 0.01)。
主要特点:
- 输出范围为 (-∞, ∞)
- 与 ReLU 类似,但允许小的负值
- 通过为负输入维持小梯度,防止 ReLU 死亡问题
- α 参数决定负值的斜率
- 保持 ReLU 的大部分计算效率
Leaky ReLU 在 ReLU 的优点与保留部分负输入信息的需求之间提供了折衷,使其成为许多深度学习架构中的流行选择。
-
参数化修正线性单元(Parametric ReLU):它是 ReLU 的一种自适应变体,其中负斜率是在训练期间学习得到的,而不是预先定义的。其定义如下:
f(x) = max(αx, x)其中 α 是一个可学习参数。
主要特点:
- 自适应的负斜率参数
- 输出范围为 (-∞, ∞)
- 如果 α 变为 0,则退化为 ReLU
- 比 Leaky ReLU 更灵活
- 需要额外的参数训练
-
指数线性单元(ELU):它是一种平滑的激活函数,允许负值,并有助于减少 ReLU 网络所经历的偏差偏移。其定义如下:
f(x) = x, x ≥ 0
f(x) = α(e^x − 1), x < 0其中 α 是一个可调参数。
主要特点:
- 包含负值的平滑函数
- 可以产生负输出
- 有助于减少偏差偏移
- 自正则化
- 计算成本比 ReLU 更高
-
缩放指数线性单元(SELU):它是 ELU 的一种自归一化变体,可自动保持网络中激活的归一化。其定义如下:
f(x) = λx, x ≥ 0
f(x) = λα(e^x − 1), x < 0其中 λ ≈ 1.0507 且 α ≈ 1.6733。
主要特点:
- 自动归一化网络激活
- 在层间保持均值和方差
- 有助于深层网络训练
- 需要特定的初始化
- 最好用于全连接层
-
高斯误差线性单元(GELU):它根据高斯分布下的累积分布函数对输入进行加权,使网络能够学习更细致的表示。随着输入值减小,其被抑制的概率会逐渐增加而非突然增加,从而产生平滑的概率门控效果。这使得它对于上下文和微妙模式很重要的自然语言处理任务特别有效。其定义如下:
f(x) = 0.5 * x * (1 + tanh(√(2/π) * (x + 0.044715 * x^3)))主要特点:
- 比 ReLU 更平滑
- 用于现代 Transformer
- 概率式激活方法
- 计算成本更高
- 在某些语言模型和 Transformer 架构中表现更好
-
随机化修正线性单元(RReLU):它是 ReLU 的一种变体,在训练期间使用随机的负斜率以获得更好的正则化。与在反向传播中学习斜率的 PReLU 不同,RReLU 中的斜率在训练期间从随机分布中采样。其定义如下:
f(x) = max(αx, x)其中
α ~ U(l, u), l < u且l, u ∈ [0, 1)。主要特点:
- 训练期间使用随机斜率参数
- 测试期间使用固定斜率
- 起到正则化作用
- 有助于防止过拟合
- 实现比标准 ReLU 更复杂
Sigmoid 系列和 ReLU 系列代表了在神经网络中引入非线性的两种基本方法,各自具有不同的特点,因此适用于不同的场景。
从 sigmoid 到基于 ReLU 的函数的演变,反映了该领域向更高效率和可扩展架构的迈进。虽然 sigmoid 函数为神经网络奠定了理论基础,但 ReLU 及其变体已成为现代深度学习隐藏层的默认选择,使得训练更深层的网络成为可能,这些网络支撑着当今最先进的模型。在这两个系列之间做出选择最终取决于你的具体需求:当你需要有界输出和概率解释时,请使用 sigmoid 系列函数;当构建需要计算效率、更快收敛和更好梯度流的深层网络时,请选择 ReLU 系列函数。
其他高级函数
虽然 Sigmoid 系列和 ReLU 系列构成了神经网络中激活函数的基础,但深度学习的发展催生了几种超越这些传统类别的高级激活函数。这些函数代表了神经网络设计的下一代,它们结合了多种方法的优点,或引入了全新的机制,以应对现代架构中的特定挑战。
这些函数中的每一个都是为了解决特定问题而开发的——无论是改善极深网络中的梯度流、降低边缘设备的计算开销,还是提升 Transformer 等前沿架构的性能。
对于研究最先进模型的研究人员来说,理解这些高级激活函数至关重要,因为它们通常能提供在竞争性应用中所需要的性能优势。在本节中,我们将探讨这些函数的工作原理、数学公式和主要特点。
-
Swish:它是一种自门控激活函数,将输入乘以该输入的 sigmoid。它是 sigmoid 函数的一种变换。其定义如下:
f(x) = x * sigmoid(βx)其中 β 可以是常数(通常为 1),也可以是可训练的。
主要特点:
- 光滑且非单调
- 上方无界,下方有界
-
自门控激活机制
-
计算成本比 ReLU 更高
-
提供更好的梯度流动
-
在深度神经网络中提供比 ReLU 更好的性能
-
Mish:它是一种与 Swish 相关的自正则化非单调激活函数。与 Swish 类似,Mish 对双曲正切函数进行变换。它通过以下公式定义:
f(x) = x * tanh(ln(1 + e^x))主要特点:
- 平滑且非单调
- 几乎始终为正
- 上方无界,下方有界
- 比 ReLU 具有更好的梯度流动
- 自正则化特性
- 提供更好的训练稳定性
-
Hard Swish:它是 Swish 激活函数的一种更快、更高效的近似,通过以下公式定义:
f(x) = 1/6 * x * min(max(x + 3, 0), 6)主要特点:
- Swish 的分段线性近似
- 计算效率更高
- 与 Swish 具有相似的特性
- 更适合移动设备
- 计算有界
-
Hard Tanh:它是双曲正切函数的分段线性近似。通过以下公式定义:
f(x) = max(-1, min(1, x))主要特点:
- 输出有界于 [-1, 1]
- 计算高效
- 中心区域线性
- 分段线性函数
- 比 tanh 更简单
理解这些激活函数及其特性,能够帮助你在架构设计上做出明智的决策。不要将激活函数的选择视为一成不变的决定,而应将其视为网络设计过程中不可分割的一部分——需要与其他超参数一起反复审视和优化。最适合你特定应用的激活函数,往往是通过系统性的实验得出的,这些实验依据本节所述的原理和权衡进行。
在深度学习之旅中不断前进时,请记住,激活函数不仅仅是数学形式,更是强大的工具,从根本上塑造网络学习和泛化的方式。理解它们行为所获得的洞见,将在你应对日益复杂的问题和架构时证明其价值非凡。
到目前为止,我们已经了解了如何在 C++ 中使用不同方法实现多层感知机,并探索了各种能将非线性引入神经网络的激活函数。然而,构建网络架构和选择激活函数只是问题的一部分。为了有效训练这些网络并实现最佳性能,我们需要了解优化技术,这将在下一节中讨论。我们将研究关键的优化算法,如梯度下降、Adam、RMSprop、动量、Adagrad 和 AdaDelta。
优化技术
深度学习本质上是一个围绕成本函数(也称为损失函数)的优化问题。理解优化技术对于有效训练神经网络至关重要,因为训练过程涉及寻找可微损失函数的最小(或最大)值。损失函数必须是平滑且可微的,以便进行梯度计算。在之前探讨了各种损失函数之后,我们现在将研究几种流行的优化技术,从最基础的方法开始。在下图中,我绘制了一个损失函数 f(x)。你可以看到它有多个(此处为两个)局部最小值,但只有一个全局最小值。
图 5.3:损失函数
在优化过程中,我们的主要目标是找到损失函数的最小点。这个最小值可以是局部的(在邻近区域内最优)或全局的(在整个函数空间内最优)。然而,在复杂的神经网络架构中,判断所发现的最小值究竟是真正的全局最小值还是仅仅局部最小值,是一个重大的技术挑战。这在深度学习中尤其重要,因为损失函数的形态通常是高度非凸的,存在许多局部最小值。
在接下来的部分,我们将了解当今使用的一些常见优化技术。这些算法是相互构建的,因此请注意后续技术是如何在彼此基础上发展的。
梯度下降
梯度下降是一种用于训练神经网络的优化技术,通过连续参数调整来降低损失函数的值。在每次迭代中,算法计算损失函数相对于每个参数的导数。然后,它沿着梯度的反方向调整这些参数——向更低的损失值下降。每次调整的幅度取决于学习率,这是一个关键的超参数,需要在训练速度与稳定性之间取得平衡。如果学习率设置过高,可能会越过最优值;设置过低,则会导致不必要的长时间训练。
下面的 gradient_descent C++ 实现演示了这一优化过程。通过基于梯度信息的迭代参数更新,该函数沿着最陡下降路径系统性地降低损失。代码执行以下步骤:
- 初始化:接收张量
x(要优化的参数),并通过requires_grad_(true)启用梯度跟踪 - 迭代优化循环:最多执行 1,000 次迭代(或直到收敛):
- 使用提供的
loss_fn计算损失 - 通过
loss.backward()计算梯度 - 更新参数:
x = x - learning_rate * x.grad() - 将梯度归零,为下一次迭代做准备
- 使用提供的
- 收敛检查:如果梯度幅度低于 1e-6,则提前停止,表示已达到局部最小值
- 返回:优化后的参数(从计算图中分离)
NoGradGuard 块在参数更新期间临时禁用梯度跟踪,以防止构建不必要的计算图,从而提高内存效率。
该实现代表了构建 Adam 和 RMSprop 等更复杂优化器的基础,正如你文档后续部分所讨论的那样。
torch::Tensor gradient_descent(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float learning_rate = 0.01,
int max_iterations = 1000
) {
x = x.detach().requires_grad_(true);
for(int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
x = x - learning_rate * x.grad();
x.grad().zero_();
}
if(x.grad().abs().max().item<float>() < 1e-6) {
break;
}
}
return x.detach();
}
虽然梯度下降为优化提供了很好的基础,但它的固定学习率在处理不同尺度的参数以及不同方向曲率不同的损失景观时可能会遇到困难。这一局限性促使了自适应优化器 RMSprop 的发展,我们将在下一节中研究它。
RMS Prop
均方根传播(RMSprop)跟踪每个参数平方梯度的移动平均值,并使用该值对当前梯度进行缩放。这种归一化有助于防止在处理不同量级梯度时学习率变得过小。
这种自适应行为使算法能够更有效地穿越损失景观中的陡峭和平缓区域,与标准梯度下降相比,收敛速度更快。下面是一个用于 rmsprop 的 C++ 函数:
torch::Tensor rmsprop_optimize_gpu(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float learning_rate = 0.01,
float decay_rate = 0.99,
float epsilon = 1e-8,
int max_iterations = 1000
) {
// Move tensors to GPU
x = x.detach().cuda().requires_grad_(true);
auto cache = torch::zeros_like(x).cuda();
for(int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
// Update moving average of squared gradients
cache = decay_rate * cache + (1 - decay_rate) * x.grad().pow(2);
// Update parameters
x = x - learning_rate * x.grad() / (cache.sqrt() + epsilon);
x.grad().zero_();
}
if(x.grad().abs().max().item<float>() < 1e-6) {
break;
第5章:C++中的多层感知机
}
}
return x.detach().cpu(); // Return result back to CPU
}
承载算法逻辑的代码如下:
cache = decay_rate * cache + (1 - decay_rate) * x.grad().pow(2);
该缓存(cache)追踪近期梯度的幅值,并赋予近期历史更大的权重(由 decay_rate 控制,取值为 0.99)。
参数更新步骤 x = x - learning_rate * x.grad() / (cache.sqrt() + epsilon) 正是 RMSprop 自适应行为大放异彩之处:它将梯度除以缓存的平方根,从而对每个参数独立地归一化学习率。接收较大且一致梯度的参数会累积较大的缓存值,因此有效步长较小;梯度较小的参数则获得按比例更大的步长。epsilon 项可在训练早期缓存接近零时防止除零错误。更新参数后,梯度通过 .zero_() 重置为零,为下一次迭代做准备。
与之前的实现相比,一个结构上的差异:张量通过 .cuda() 移动到 GPU,并通过 .detach().cpu() 返回 CPU。RMSprop 利用梯度历史来归一化更新幅度,而一种新技术——动量(momentum)则关注方向一致性——通过累积过去梯度的运行平均值来平滑振荡,而不是对每个梯度独立做出反应。
动量
动量是一种优化技术,通过引入物理学中的速度概念来增强梯度下降。它累积过去梯度的移动平均值,并利用这些信息决定当前更新的方向和幅度。通过将先前更新的一部分加到当前更新上,动量帮助优化过程在持续梯度下降的方向上保持一致的移动,同时在梯度变化不一致的方向上抑制振荡。这种方法在处理高曲率、微小但一致的梯度或含噪梯度时尤为有效。动量项帮助优化器穿越损失曲面中纯梯度下降可能难以处理的区域,例如狭窄的谷地或平坦区域,从而加快收敛,并更好地逃离局部最小值。以下是动量优化的 C++ 示例代码:
torch::Tensor momentum_optimize(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float learning_rate = 0.01,
float momentum = 0.9,
int max_iterations = 1000
) {
x = x.detach().requires_grad_(true);
auto velocity = torch::zeros_like(x);
for(int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
// Update velocity using momentum
velocity = momentum * velocity + learning_rate * x.grad();
// Update parameters using velocity
x = x - velocity;
x.grad().zero_();
}
if(x.grad().abs().max().item<float>() < 1e-6) {
break;
}
}
return x.detach();
}
速度更新 velocity = momentum * velocity + learning_rate * x.grad(); 保留了先前速度的 90%(momentum = 0.9),并加上当前缩放后的梯度。在梯度持续指向相同方向的方向上,速度不断累积,有效步长随之增大。在梯度符号交替的方向上——即振荡情形——正负贡献相互抵消,从而抑制不稳定性。随后,参数更新直接应用累积的速度:x = x - velocity,而不是像标准梯度下降那样直接使用梯度。
与 RMSprop 不同,动量不会针对每个参数自适应步长——所有参数共享相同的 learning_rate 和 momentum 系数。其收益体现在方向上:一致的下降方向会加速;不一致的方向则被抑制。这使得动量在梯度下降停滞的平台区域和浅层区域特别有效,但对于各参数梯度幅度相差数量级的问题则不太适用——而这正是 RMSprop 所针对的场景。
该算法包含一个提前停止条件:当最大绝对梯度低于 1e-6 时终止,表示已收敛。最后,优化后的参数从计算图中分离并返回,可用于预测或进一步处理。
下一个优化器 Adam 结合了我们刚刚看到的两种方法——像动量那样使用速度估计来保持方向一致性,像 RMSprop 那样使用梯度幅度估计来做自适应缩放。
ADAM 优化器
Adam 扩展了前几节介绍的两种方法。动量通过运行中的速度估计来追踪梯度方向,RMSprop 通过平方梯度的运行平均值来追踪梯度幅度,而 Adam 同时维护两者——根据每个参数自身的梯度历史,独立地调整其有效学习率。由于两个估计都初始化为零,Adam 引入了一个其他优化器不需要的偏差校正步骤,确保矩估计从第一次迭代起就是准确的,而不仅仅是在预热期之后。
下面的 C++ 函数使用两个矩累加器,并在每次迭代中应用显式的偏差校正步骤来实现这一点:
torch::Tensor adam_optimize(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float learning_rate = 0.001,
float beta1 = 0.9,
float beta2 = 0.999,
float epsilon = 1e-8,
int max_iterations = 1000
) {
x = x.detach().requires_grad_(true);
auto m = torch::zeros_like(x);
auto v = torch::zeros_like(x);
for(int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
// Update biased first moment estimate
m = beta1 * m + (1 - beta1) * x.grad();
// Update biased second raw moment estimate
v = beta2 * v + (1 - beta2) * x.grad().pow(2);
// Bias correction
auto m_hat = m / (1 - std::pow(beta1, i + 1));
auto v_hat = v / (1 - std::pow(beta2, i + 1));
// Update parameters
x = x - learning_rate * m_hat / (v_hat.sqrt() + epsilon);
x.grad().zero_();
}
if(x.grad().abs().max().item<float>() < 1e-6) {
break;
}
}
return x.detach();
}
该函数接受多个参数:参数张量 x、损失函数、学习率(默认 0.001)、beta1(默认 0.9)、beta2(默认 0.999)、用于数值稳定性的 epsilon(默认 1e-8)以及最大迭代次数(1000)。
优化开始时,先将参数张量分离(detach),并通过 .requires_grad_(true) 启用梯度追踪。两个矩向量被初始化为零:
- m:追踪梯度的指数加权运行均值(一阶矩)
- v:追踪平方梯度的指数加权运行均值(二阶矩)
在每次迭代中,计算损失,并通过 .backward() 将梯度沿计算图反向传播。
一阶矩累加器的更新方式如下:
m = beta1 * m + (1 - beta1) * x.grad();
beta1 系数(0.9)决定了运行均值相对于当前梯度方向保留先前梯度方向的强度。二阶矩累加器的更新方式如下:
v = beta2 * v + (1 - beta2) * x.grad().pow(2);
beta2 系数(0.999)控制平方梯度平均值的衰减速率,使估计对梯度幅度具有较长的记忆。
由于两个累加器都初始化为零,其早期估计会被系统性地低估。偏差校正项可以抵消这一影响:
auto m_hat = m / (1 - std::pow(beta1, i + 1));
auto v_hat = v / (1 - std::pow(beta2, i + 1));
随着迭代次数 i 增大,分母趋近于 1,校正变得可忽略不计。随后,校正后的估计用于计算参数更新:
x = x - learning_rate * m_hat / (v_hat.sqrt() + epsilon);
epsilon 项在 v_hat 接近零时防止除零错误。每次更新后,通过 .zero_() 清除梯度,以防止跨迭代累积。
Adam 优化器具有若干关键优势,使其成为神经网络训练中的稳健选择。每个参数都能基于梯度历史获得自己的有效学习率,这使得 Adam 在不同参数尺度下都表现稳健。一阶矩估计融入了动量,有助于加速收敛并穿越鞍点;偏差校正则确保训练早期迭代中的矩估计准确。由于几乎不需要调整超参数,默认值在众多应用中都能良好工作,不过学习率可能仍需要调整以获得最佳性能。
对于剩余的优化器函数,请尝试逐行理解代码。代码结构与前几个函数类似,因此我们认为你很容易跟上。
AdaGrad
AdaGrad 通过维护平方梯度的运行总和,为每个参数调整学习率。累积的历史会反比地缩放学习率——累积梯度较大的参数获得逐渐变小的更新,而累积梯度较小的参数获得较大的更新。这种逐参数自适应使 AdaGrad 在稀疏数据上很有效,其中不常出现的特征受益于更大的学习率。然而,平方梯度的单调累积会导致有效学习率不断缩小,往往在收敛之前就变得可以忽略不计。
与 RMSprop 的关键区别在于累积策略:RMSprop 使用指数衰减平均值,会遗忘较早的历史,而 Adagrad 则不加衰减地累加所有过去梯度的平方,因此在长时间训练中表现出其特有的学习率不断减小的行为。
下面的 C++ 实现演示了 Adagrad 的核心机制——累加梯度的平方,从而为每个参数自适应地缩放学习率:
torch::Tensor adagrad_optimize(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float learning_rate = 0.01,
float epsilon = 1e-8,
int max_iterations = 1000
) {
x = x.detach().requires_grad_(true);
auto cache = torch::zeros_like(x);
for (int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
// Accumulate squared gradients
cache = cache + x.grad().pow(2);
// Update parameters
x = x - learning_rate * x.grad() / (cache.sqrt() + epsilon);
x.grad().zero_();
}
if (x.grad().abs().max().item<float>() < 1e-6) {
break;
}
}
return x.detach();
}
该实现的核心是优化循环中的两个关键操作:
cache = cache + x.grad().pow(2);
这一行持续将梯度的平方累加到缓存中——与 RMSprop 的指数衰减不同,这是纯粹的累积。每个参数都会构建一个不断增长的梯度幅度历史。
自适应参数更新:
x = x - learning_rate * x.grad() / (cache.sqrt() + epsilon);
该更新将梯度除以累积缓存的平方根。随着缓存每次迭代不断增大,分母也随之增大,从而逐步缩小有效步长。在训练过程中经历过较大梯度的参数会累积更大的缓存值,因此得到的更新会越来越小。epsilon 项用于防止缓存接近零时出现数值不稳定。
RMSprop 的缓存会随时间衰减(如果最近的梯度较小,学习率可以再次增大),而 Adagrad 的缓存只会不断增长。这种单调累积意味着有效学习率只能减小,无法恢复——这解释了为什么在长时间训练中它可能会过早停止学习,尽管在稀疏数据场景中它非常有效,因为不频繁出现的特征需要持续的大幅更新。
AdaDelta
Adadelta 通过用指数衰减窗口替代无界累积来解决 Adagrad 学习率不断减小的问题。它不是对所有历史梯度平方求和,而是维护一个移动平均值,该平均值强调最近的梯度,同时逐渐遗忘较早的梯度。这种窗口化方法防止分母无限增长,使得有效学习率能够在整个训练过程中动态调整。
该算法通过由两个累积量(近期梯度的均方根和近期参数更新的均方根)的比值构造更新,从而消除了手动指定学习率的需要。这种基于比值的机制会自动适当地缩放更新,并确保分子与分母之间的量纲一致性——这是一项理论上的改进,使 Adadelta 有别于那些简单地将梯度除以其历史幅度的算法。
下面是为 AdaDelta 优化器提供的 C++ 代码。请逐行阅读以理解其功能。
torch::Tensor adadelta_optimize(
torch::Tensor x,
std::function<torch::Tensor(torch::Tensor)> loss_fn,
float rho = 0.95,
float epsilon = 1e-8,
int max_iterations = 1000
) {
x = x.detach().requires_grad_(true);
auto grad_square_avg = torch::zeros_like(x);
auto delta_square_avg = torch::zeros_like(x);
for (int i = 0; i < max_iterations; i++) {
auto loss = loss_fn(x);
loss.backward();
{
torch::NoGradGuard no_grad;
// Update running average of gradient squares
grad_square_avg = rho * grad_square_avg + (1 - rho) * x.grad().pow(2);
// Compute update
auto delta = torch::sqrt(delta_square_avg + epsilon) /
torch::sqrt(grad_square_avg + epsilon) *
x.grad();
// Update running average of parameter updates
delta_square_avg = rho * delta_square_avg + (1 - rho) * delta.pow(2);
// Apply update
x = x - delta;
x.grad().zero_();
}
if (x.grad().abs().max().item<float>() < 1e-6) {
break;
}
}
return x.detach();
}
Adadelta 实现维护了两个不同的指数加权平均值,它们共同构造无参数更新。第一个是梯度历史累积:
grad_square_avg = rho * grad_square_avg + (1 - rho) * x.grad().pow(2);
这使用衰减因子 rho(0.95)跟踪梯度平方的移动平均值,类似于 RMSprop 的缓存机制,但在更新计算中用途不同。
接下来我们计算更新幅度:
auto delta = torch::sqrt(delta_square_avg + epsilon) /
torch::sqrt(grad_square_avg + epsilon) *
x.grad();
该更新被构造为一个比值:近期参数变化的均方根(分子)除以近期梯度的均方根(分母),再乘以当前梯度。这个比值取代了固定学习率——当近期更新的幅度相对于近期梯度较大时,有效步长会增大;当它们较小时,有效步长会减小。
计算完更新后,其平方幅度被累加到 delta_square_avg 中。这第二个累加器会反馈到下一次迭代的更新计算中,形成一种自我调节机制,即过去更新的规模直接影响未来的更新。
delta_square_avg = rho * delta_square_avg + (1 - rho) * delta.pow(2);
我们已经探讨了几种关键的优化算法,每种算法都有其独特的特点和优势。梯度下降简单且基础,为参数更新提供了基本框架。更复杂的方法如 Adam 结合了动量和自适应学习率的优点,而 RMSprop 则能高效处理不同幅度的梯度。动量通过累积过去的梯度来加速收敛,Adagrad 为每个参数自适应地调整学习率,而 Adadelta 则进一步解决了学习率不断减小的问题。
每种优化器都有不同的权衡:梯度下降实现简单但可能收敛缓慢,Adam 在许多应用中表现稳健但复杂度更高,RMSprop 擅长处理非平稳目标,Adadelta 则消除了手动设置学习率的需要。选择哪种优化器取决于具体需求,例如收敛速度、内存限制以及待优化问题的性质。
现代实现利用 PyTorch 的 C++ API 通过 GPU 加速,能够高效执行这些优化算法,使其适用于大规模深度学习应用。
在探索了多种优化技术之后,有必要理解神经网络优化中固有的更广泛挑战。根本挑战在于在高维参数空间中导航,以找到能够最小化损失函数的配置。
同时保持模型对未见过数据的泛化能力。要全面理解优化算法,你应该熟悉几个影响训练效果的关键概念和技术。它们是:
- a. 学习率策略:这些技术控制模型学习的速度。它们包括在训练过程中自动调整学习率的方法,以实现更好的收敛并避免局部最小值。
- b. 正则化技术:这些方法通过在训练过程中增加约束来帮助防止过拟合。如dropout和批量归一化等技术有助于模型更好地泛化到未见数据。
- c. 参数初始化:这些技术决定了如何设置神经网络中的初始权重和偏置。正确的初始化对于成功的训练和收敛至关重要。
- d. 批量优化:这些方法决定了训练期间数据的处理方式,包括批次大小选择和处理的策略。
- e. 架构优化:这些技术侧重于改进网络结构本身,包括使网络更高效和更有效的方法。
- f. 损失函数设计:这些方法涉及设计和修改损失函数,以更好地适应特定的训练目标和问题。
总结
在本章中,我们探讨了多层感知器(MLPs)——深度学习中的一种基础架构,它通过多层相互连接的神经元处理信息。我们考察了三种不同的C++实现方法:使用Eigen库进行矩阵运算的基础实现,展示了面向对象的设计原则;基于CUDA的实现,利用GPU加速通过并行处理和优化内存管理获得显著的性能提升;以及使用LibTorch(PyTorch的C++ API)的高级实现,它在保持计算效率的同时抽象了复杂操作。
本章全面介绍了激活函数,探讨了sigmoid和双曲正切等传统方法,以及ReLU、Leaky ReLU、Swish和Mish等现代变体。每种激活函数都提供了适合不同架构需求和训练场景的独特特性。我们还研究了基本的优化技术——包括梯度下降、RMSprop、动量、Adam、Adagrad和Adadelta——这些构成了有效神经网络训练的支柱。这些优化算法与适当的学习率策略和正则化方法相结合,为在C++中开发高效且稳健的神经网络提供了一套完整的工具。
在下一章中,我们将探讨卷积神经网络(CNNs)——一类专门用于处理视觉数据的神经网络。CNN已成为计算机视觉任务的基石架构,包括图像分类、目标检测和语义分割,在这些任务中,它们擅长自动从图像中学习分层模式和空间特征。
进一步阅读
- Rumelhart, D. E., Hinton, G. E., & Williams, R. J. (1986). 通过反向传播误差学习表示. Nature, 323(6088), 533-536. 介绍反向传播的开创性论文。
- Glorot, X., & Bengio, Y. (2010). 理解训练深层前馈神经网络的难度. Proceedings of AISTATS. 关于初始化策略和激活函数选择的重要读物。
- Kingma, D. P., & Ba, J. (2014). Adam:一种随机优化方法. arXiv:1412.6980. Adam优化器的原始论文,包含理论基础。
- Tieleman, T., & Hinton, G. (2012). “第6.5讲-rmsprop:将梯度除以其最近幅度的运行平均值。” COURSERA:机器学习神经网络。
- Zeiler, M. D. (2012). ADADELTA:一种自适应学习率方法. arXiv:1212.5701. 对Adadelta算法的全面论述。
- Ramachandran, P., Zoph, B., & Le, Q. V. (2017). 搜索激活函数. arXiv:1710.05941. 介绍了Swish,并讨论了系统化的激活函数发现。
- Misra, D. (2019). Mish:一种自正则化的非单调激活函数. arXiv:1908.08681. 详细介绍了Mish激活函数及其性质。
- Clevert, D. A., Unterthiner, T., & Hochreiter, S. (2015). 通过指数线性单元(ELUs)实现快速准确的深度网络学习. arXiv:1511.07289.
- Eigen文档:
https://eigen.tuxfamily.org/— 本章使用的Eigen线性代数库的综合指南。 - LibTorch文档:
https://pytorch.org/cppdocs/— 带有教程和示例的官方PyTorch C++ API文档。 - CUDA编程指南:
https://docs.nvidia.com/cuda/cuda-c-programming-guide/:NVIDIA官方的GPU编程指南。 - cuBLAS库文档:
https://docs.nvidia.com/cuda/cublas/— GPU加速线性代数运算的参考。 - Goodfellow, I., Bengio, Y., & Courville, A. (2016). 深度学习. MIT Press. 第6-8章全面涵盖了前馈网络、优化和正则化。
- Nielsen, M. A. (2015). 神经网络与深度学习. Determination Press. 可免费在线阅读,配有出色的反向传播和梯度下降可视化。
- 代码示例:
https://github.com/PacktPublishing/Deep-Learning-with-C-/tree/main/chapter5 - CS231n:用于视觉识别的卷积神经网络(斯坦福大学):
http://cs231n.stanford.edu/— 关于神经网络基础和优化的优秀讲义。 - XOR问题详解:
https://towardsdatascience.com/how-neural-networks-solve-the-xor-problem-59763136bdd7— 直观解释为什么非线性可分问题需要MLP。
获取本书的PDF版本及更多信息
扫描二维码(或访问 packtpub.com/unlock)。按名称搜索本书,确认版本,然后按照页面上的步骤操作。
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_199_0.png
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_199_1.png
注意:请随身准备好您的发票。直接从Packt购买不需要发票。
6 C++中的卷积神经网络
卷积神经网络(CNNs)是一种深度学习架构,主要设计用于处理图像和视频等网格状数据。这些神经网络使用称为卷积的数学运算,从原始输入数据中自动学习层级特征表示,这使它们与传统神经网络有着根本的不同。CNN受到动物视觉皮层组织的启发,凭借保持空间关系的能力以及通过共享权重和平移不变性保持计算效率,彻底改变了计算机视觉任务。
本章全面探讨了使用现代C++实现CNN的方法。你将学习卷积运算的数学基础,并理解它们与传统的多层感知器(MLPs)有何不同。本章涵盖了使用C++集成PyTorch的实际实现技术,包括通过CUDA进行GPU加速以增强性能。我们将通过动手项目探索实际应用,包括在MNIST数据集上实现用于图像分类的VGG-16,以及用于图像分割任务的U-Net架构。此外,你还将了解基本的图像处理技术,如数据增强方法(旋转、缩放、裁剪和翻转)。
在本章结束时,你将获得使用C++从零实现CNN架构的实践技能,理解卷积运算背后的数学原理,并应用现代优化技术来训练深度神经网络。这种渐进式设计是有意为之:而不是直接给你一个现成的框架,我们会带你逐步了解每一层抽象,这样当你接触到PyTorch C++ API时,你不仅理解
C++ 中的卷积神经网络
如何使用它,以及它为什么以这种方式工作。在此过程中,你将构建高效的图像分类和分割系统,集成 GPU 加速以提升性能,并实现全面的图像增强流水线,以增强模型的鲁棒性和泛化能力。
在本章中,我们将介绍以下主题:
- CNN 的历史
- 卷积的数学基础
- C++ 实现
- 构建基于 C++ 的图像分类神经网络
- 使用 U-Net 进行图像分割
- 用于深度学习的图像处理
- 与 CNN 相关的术语
继续阅读本章时,你可能会遇到一些不易立刻理解的术语。我们鼓励你继续读下去,因为这些术语会在本章后面用适当的术语加以定义。
CNN 的历史
CNN 的发展代表着人工智能和计算机视觉领域的一个重要里程碑。CNN 的基础可以追溯到 20 世纪 60 年代,休伯尔和维塞尔对猫视觉皮层进行了开创性研究,揭示了大脑神经元如何对特定模式的视觉刺激作出反应。1980 年,福岛邦彦提出了新认知机(Neocognitron),它被认为是 CNN 架构的第一个实现。然而,我们今天所熟知的现代 CNN 架构,是由杨立昆(Yann LeCun)及其同事于 1989 年在 LeNet 系列神经网络上的工作开创的。这项工作最终形成了 LeNet-5,它在改良版美国国家标准与技术研究院(MNIST)数据集上取得了非凡的 98.5% 准确率。该数据集包含由美国邮政管理局提供的 16x16 灰度手写数字,数字范围从 0 到 9。
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
图 6.1:MNIST 数据库中的图像示例
2012 年,亚历克斯·克里热夫斯基、伊利亚·苏茨克弗和杰弗里·辛顿开发了 AlexNet,并以显著优势赢得了 ImageNet 大规模视觉识别挑战赛(ILSVRC),这是一个重大突破。这一成功重新激发了人们对 CNN 以及整个深度学习的兴趣。此后,涌现出众多架构,包括 VGGNet、GoogLeNet、ResNet 等,每一种都在不断拓展计算机视觉任务的可能性边界。如今,CNN 构成了许多现代计算机视觉应用的支柱,从人脸识别系统到自动驾驶汽车——从识别简单的手写数字,到以人类水平或超人类水平对数千种复杂物体类别进行分类。如今,CNN 构成了许多现代计算机视觉应用的支柱,从人脸识别系统到自动驾驶汽车。
卷积的数学基础
CNN 的核心是一种称为卷积的重要数学运算。到目前为止,我们一直在讨论 CNN,却没有定义卷积。现在,是时候正式定义它了。卷积运算在数学上定义如下:
(f * g)(x) = ∫ f(τ) g(x - τ) dτ
让我们分解一下:
- f 和 g 是函数
- τ(tau)是积分变量
- ∗ 表示卷积运算
- x 是自变量
- ∫ 表示对所有可能的 τ 值进行积分
卷积是 CNN 中的主要数学运算。在处理图像和执行计算机视觉任务时,这种运算遵循一种结构化的方式:一个核(或滤波器),也就是一个小矩阵,按照定义好的模式在输入数据上移动。在移动过程中,核与其覆盖的输入值进行逐元素乘法。然后将这些乘积相加,生成一个值,这个值位于所谓的特征图中。这一结构化过程实现了层级化特征学习:早期层检测边缘或颜色变化等基本特征,而更深层则将这些特征组合成越来越复杂的模式——纹理、形状,最终形成完整的物体。在整个过程中保持空间关系,使得卷积成为图像处理和模式识别中一种特别有效的方法。
在处理 RGB 图像时,卷积运算扩展到多个颜色通道。核的深度必须与输入通道的数量匹配,运算会同时处理所有通道。这些通道的结果组合起来,形成特征图中的各个值。两个关键参数控制这一过程:stride 定义了核在每一步移动的距离,padding 由在输入数据周围添加的零组成,以管理输出尺寸。
二维数组
| 1 | 2 | 3 | 4 |
|---|---|---|---|
| 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 |
滤波器
| -1 | -1 | -1 |
|---|---|---|
| -1 | 4 | -1 |
| -1 | -1 | -1 |
输出
| -24 | -28 |
|---|---|
| -40 | -44 |
1-1 + 2-1 + 3-1 + 5-1 + 64 + 7-1 + 9-1 + 10-1 + 11*-1 = -24
6-1 + 7-1 + 8-1 + 10-1 + 114 + 12-1 + 14-1 + 15-1 + 16*-1 = -44
图 6.2:二维卷积示例
你可以看到颜色匹配框下方那个框的计算过程。
C++ 实现
在本节中,我们介绍卷积层的一个基本实现。卷积层类包含用于存储 stride、padding 和 filters 参数的私有成员,并实现了用于卷积运算的前向方法。前向方法接受一个输入矩阵(表示为向量的向量)。它使用指定的步幅在输入上滑动每个滤波器。在每个位置,它计算逐元素乘法和求和,然后进行 ReLU 激活。这将为每个滤波器创建特征图。本章的完整代码可在 GitHub 上获取。在这里,我们将拆解整个代码的重要部分,以解释神经网络的结构。ConvolutionalLayer 类封装了卷积层的三个基本要素(filters、stride 和 padding),如下所示:
vector<vector<float>> filters; // Convolution filters/kernels
int stride; // Filter stride
int padding; // Padding size
filters 成员存储多个可学习的核,这些核用于检测输入数据中的特定模式。为了提高计算效率,每个滤波器都表示为一个扁平化的向量。stride 参数控制滤波器在输入上移动时的步长大小,而 padding 决定边界处理方式(尽管在这个基础版本中并未完全实现)。
构造函数建立层的配置并初始化可学习参数:
ConvolutionalLayer(int numFilters, int filterSize, int strideSize, int paddingSize)
这种设计允许灵活的层配置,支持不同的滤波器数量、大小和步长模式。构造函数将滤波器初始化委托给一个私有方法,从而保持了关注点的清晰分离。initializeFilters 方法采用高斯随机初始化:
normal_distribution<float> dist(0.0f, 0.1f);
这种方法使用以零为中心的小随机值初始化权重,既能防止对称性破坏问题,又能在训练期间保持梯度流动。0.1 的标准差为学习提供了一个均衡的起点。
在层正确初始化且权重配置为最佳梯度流动之后,我们现在可以检查数据在前向传播过程中如何流经卷积层——这是这些学习到的滤波器检测输入数据模式的核心操作。
前向传播实现
forward 方法通过四层嵌套循环实现了完整的卷积操作,系统地将每个滤波器应用于输入:
for (auto& filter : filters) {
// Slide filter across input
vector<float> featureMap;
for (int i = 0; i < input.size() - filter.size() + 1; i += stride) {
for (int j = 0; j < input.size() - filter.size() + 1; j += stride) {
// Calculate convolution at current position
float sum = 0;
for (int k = 0; k < filter.size(); k++) {
for (int l = 0; l < filter.size(); l++) {
sum += input[i + k][j + l] * filter[k][l];
}
}
// Apply ReLU activation
featureMap.push_back(max(0.0f, sum));
}
}
featureMaps.push_back(featureMap);
}
最外层的 for(auto& filter: filters) 循环独立地处理每个学习到的滤波器。在具有多个滤波器的层中,每个滤波器检测不同的模式。接下来的两个 for 循环控制滤波器在输入上滑动时的位置。input.size() - filter.size() + 1 边界条件确保滤波器不会超出输入边界,防止无效的内存访问。stride 参数控制移动步长:stride 1 每次移动一个像素,而 stride 2 跳过每隔一个位置,从而减小输出尺寸。对于 5×5 的输入、3×3 的滤波器和 stride 1,有效位置范围为 (0,0) 到 (2,2),生成 3×3 的输出特征图。
最内层的循环执行实际的卷积运算——计算滤波器与当前输入区域之间的点积。每次迭代将对应元素相乘并累加结果。这种数学运算衡量了滤波器模式与局部输入区域的匹配程度。以下方程以编程方式表达了卷积运算:
Output[i,j] = ReLU(Σ Σ Input[i+m,j+n] × Filter[m,n])
卷积运算后,结果被存储在当前的特征图中,构建出该滤波器的完整响应模式。扫描完整个输入后,每个滤波器的完整特征图被添加到输出集合中。最终结果包含每个滤波器的一个特征图。featureMap 被推入 featureMaps 向量,如下所示:
featureMaps.push_back(featureMap);
让我们来看一下该实现的时间复杂度和内存模式:
- 时间复杂度:O(F × H × W × K²),其中 F 是滤波器数量,H × W 是输出尺寸,K × K 是滤波器尺寸
- 内存模式:该算法以缓存友好的方式处理数据,按顺序访问空间局部的输入区域
当前实现存在某些局限性,具体如下:
- 仅支持二维输入:它假设输入矩阵是方阵
- 无偏置项:它缺少现代实现中常见的偏置参数
- 简化的填充:
padding参数已声明但未实现 - 内存效率:它可以针对更大的输入进行优化
与更大网络的集成
在实践中,多个 ConvolutionalLayer 实例会与池化层、归一化层和全连接层组合,以构建完整的神经网络。该类作为一个基础组件,可以通过以下附加功能进行扩展:
- 偏置项以提高表达能力
- ReLU 之外的不同激活函数
- 用于边界处理的适当填充实现
- 支持 RGB 图像的多通道输入
- 针对生产使用优化的内存管理
该实现的简洁性使其成为理解驱动现代计算机视觉系统的基本操作的理想起点。
基于 Eigen 的矩阵实现
前述实现可以通过利用广受认可的高性能 Eigen 库提供的矩阵运算进一步增强。Eigen 是一个专门为线性代数设计的 C++ 模板库。如前所述,完整代码可在 GitHub 上获取。在本节中,我们将重点关注此增强实现的主要方面。首先要查看的重要函数是 im2col。这是一种常见的数据转换技术,用于在 CNN 中实现更快的矩阵计算。它从输入中提取所有滤波器大小的图像块,并将它们排列为矩阵列,如下图所示:
(0,3) (0,2) (0,1) (0,0) (1,3) (2,3) (3,3) (0,3) (0,2) (0,1) (0,0) (1,3) (2,3) (3,3) (0,3) (0,2) (0,1) (0,0) (1,3) (1,2) (1,1) (1,0) (2,3) (2,2) (2,1) (2,0) (3,3) (3,2) (3,1) (3,0)
36 35 34 33 40 44 48 20 19 18 17 24 28 32 4 3 2 1 8 7 6 5 12 11 10 9 16 15 14 13 5 3 2 1 6 4 3 2 9 7 6 5 10 8 7 6 10 9 7 6 11 10 8 7 14 13 11 10 15 14 12 11 11 12 15 16 21 19 18 17 22 20 19 18 25 23 22 21 26 24 23 22 26 25 23 22 27 26 24 23 30 29 27 26 31 30 28 27 27 28 31 32 37 35 34 33 38 36 35 34 41 39 38 37 42 40 39 38 42 41 39 38 43 42 40 39 46 45 43 42 47 46 44 43 43 44 47 48 9 (possible sliding window position) 12 indices (i, j)
图 6.3:(im2col) 一个 2×2 图像块被存储为一列。后续通道(RGB)在同一列中拼接
这种转换支持单指令多数据(SIMD)操作,并利用了诸如 BLAS 之类的高效线性代数库。它还使得在多个核心和 GPU 之间进行分布式计算变得容易,从而加快执行速度。下面是 im2col 函数:
Matrix im2col(const Matrix& input, int kSize, int stride) {
int inputRows = input.rows();
int inputCols = input.cols();
int outputRows = (inputRows - kSize) / stride + 1;
int outputCols = (inputCols - kSize) / stride + 1;
Matrix result(kSize * kSize, outputRows * outputCols);
int colIdx = 0;
for(int i = 0; i <= inputRows - kSize; i += stride) {
for(int j = 0; j <= inputCols - kSize; j += stride) {
int rowIdx = 0;
for(int ki = 0; ki < kSize; ki++) {
for(int kj = 0; kj < kSize; kj++) {
result(rowIdx++, colIdx) = input(i + ki, j + kj);
}
}
colIdx++;
}
}
return result;
}
在这段代码中,首先,我们定义了一个 result 矩阵,通过从输入中提取相关图像块来填充该矩阵的相关部分。外部两个 for 循环在输入上滑动滤波器,内部循环提取指定大小的图像块并将其展平为列格式。
这是基本的预处理步骤之一,它极大地简化了卷积运算,我们将在下面的函数中看到这一点:
OptimizedConvolutionalLayer(int inChannels, int outChannels, int kSize, int strideSize) {
inputChannels = inChannels;
outputChannels = outChannels;
kernelSize = kSize;
stride = strideSize;
// Initialize filters as matrix
filters = Matrix::Random(outChannels, inChannels * kernelSize * kernelSize);
}
Matrix forward(const Matrix& input) {
// Convert input to matrix format for efficient computation
Matrix inputMatrix = im2col(input, kernelSize, stride);
// Perform matrix multiplication instead of explicit convolution
Matrix output = filters * inputMatrix;
// Apply ReLU activation
applyReLU(output);
return output;
}
该实现通过利用矩阵运算显著降低了代码复杂度:im2col 转换使整个前向传播过程可以浓缩为一行:filters * inputMatrix。这种从显式嵌套循环到矩阵乘法的转变不仅简化了代码,还通过高效的线性代数库实现了在多个 CPU 核心和 GPU 线程上的同时并行化。虽然总体时间复杂度与嵌套循环方法相当,并且转换需要更大的内存分配,但这种权衡带来了 5–6 倍性能提升——使得基于矩阵的方法特别适合现代 GPU 架构。
基于 CUDA 的 GPU 加速实现
到目前为止,我们已经探讨了基于 CPU 的实现,它们展示了卷积运算的基本原理。然而,ConvolutionalLayer 类可以通过集成 CUDA 功能来利用 GPU 加速能力,从而得到显著增强。与之前的实现一样,本章相关的 GitHub 仓库中提供了一个完整的基于 CUDA 的版本(https://github.com/PacktPublishing/Deep-Learning-with-C-/tree/main/chapter6)。
我们强烈建议您检查并比较所有三种实现方法——基本的嵌套循环版本、使用 Eigen 的基于矩阵的实现,以及这种 GPU 加速变体。根据我们的经验,这种并排比较是本章中最具启发性的练习之一:观察一个概念上简单的想法如何系统地转变为越来越复杂且高性能的解决方案,能让每一步优化的价值都立刻变得具体可感。您应该亲自运行每种实现,以直接体验 GPU 加速带来的计算效率的显著提升。在这里,我们将指出代码的一些特性,以帮助您理解所提出的实现。要全面理解 CUDA 库函数,我们强烈建议您查阅官方指南:https://docs.nvidia.com/cuda/cuda-c-programming-guide/。
由于使用 CUDA 进行 GPU 编程涉及几个新概念,我们将先列举关键的 CUDA 函数并解释各自的用途,然后再讨论实现细节。在这些函数中,你会多次看到 BLAS 一词,它代表 基础线性代数子程序(Basic Linear Algebra Subprograms)。接下来让我们了解这些函数:
- cublasHandle_t:这是一个不透明指针,代表 cuBLAS 库的上下文句柄。它存储 cuBLAS 操作所需的状态和配置,包括使用哪个 GPU 设备、工作内存和执行流。
- cublasCreate:这是一个初始化函数,用于创建并设置新的 cuBLAS 库上下文句柄。它与
cublasDestroy()配对用于上下文清理,并且应在 cuBLAS 操作之前调用。这是执行任何 GPU 线性代数操作之前必须完成的第一步。 - cudaMalloc:这是一个 CUDA 函数,用于在 GPU 设备上分配内存,类似于 CPU 内存分配中的
malloc()。 - curandGenerator_t:这是 CUDA 随机数生成器(cuRAND)的句柄类型,用于在 GPU 设备上生成随机数。
- cublasSscal:这是一个 cuBLAS 函数,用于将向量按标量值缩放,例如
x = α × x。 - cublasSaxpy:与前面的函数类似,它执行操作
y = α × x + y。 - cublasSgemm:这是一个 cuBLAS 函数,用于 GPU 加速的矩阵乘法,即
C = α × A × B + β × C。 - curandDestroyGenerator:这是一个用于清理并释放与随机数生成器相关资源的函数。
以下是一个基于 CUDA 的卷积层实现:
class CudaConvolutionalLayer {
float *d_filters, *d_input, *d_im2col_output, *d_conv_output;
int inputChannels, outputChannels, kernelSize, stride;
cublasHandle_t cublasHandle;
public:
CudaConvolutionalLayer(int inChannels, int outChannels, int kSize, int strideSize)
: inputChannels(inChannels), outputChannels(outChannels),
kernelSize(kSize), stride(strideSize) {
cublasCreate(&cublasHandle);
int filterSize = outChannels * inChannels * kernelSize * kernelSize;
cudaMalloc(&d_filters, filterSize * sizeof(float));
curandGenerator_t gen;
curandCreateGenerator(&gen, CURAND_RNG_PSEUDO_DEFAULT);
curandSetPseudoRandomGeneratorSeed(gen, 1234ULL);
curandGenerateUniform(gen, d_filters, filterSize);
float alpha = 0.2f, beta = -0.1f;
cublasSscal(cublasHandle, filterSize, &alpha, d_filters, 1);
cublasSaxpy(cublasHandle, filterSize, &beta, d_filters, 1, d_filters, 1);
curandDestroyGenerator(gen);
}
~CudaConvolutionalLayer() {
cudaFree(d_filters); cudaFree(d_input); cudaFree(d_im2col_output); cudaFree(d_conv_output);
cublasDestroy(cublasHandle);
}
};
掌握了这些 CUDA 库的信息后,你可以逐行理解上述代码。这里,初始化设置了层参数,创建了 cuBLAS 上下文(cublasCreate),并根据层维度为权重分配了 GPU 内存(cudaMalloc)。接着,使用 cuRAND 通过三个步骤生成随机权重:curandCreateGenerator、curandSetPseudoRandomGeneratorSeed 和 curandGenerateUniform。然后,使用 cublasSscal 对权重进行缩放(乘以 0.2),并使用 cublasSaxpy 进行平移,以获得状态良好的初始值,从而保证稳定的训练。最后,销毁资源以释放内存。
现在,我们来看一下 forward 函数:
vector<float> forward(const vector<float>& input, int inputRows, int inputCols) {
int outputRows = (inputRows - kernelSize) / stride + 1;
int outputCols = (inputCols - kernelSize) / stride + 1;
int totalOutputs = outputRows * outputCols;
int outputSize = outputChannels * totalOutputs;
cudaMalloc(&d_input, input.size() * sizeof(float));
cudaMalloc(&d_im2col_output, kernelSize * kernelSize * totalOutputs * sizeof(float));
cudaMalloc(&d_conv_output, outputSize * sizeof(float));
cudaMemcpy(d_input, input.data(), input.size() * sizeof(float), cudaMemcpyHostToDevice);
int blockSize = 256, gridSize = (totalOutputs + blockSize - 1) / blockSize;
im2col_relu_kernel<<<gridSize, blockSize>>>(d_input, d_im2col_output, inputRows, inputCols, kernelSize, stride, outputRows, outputCols);
const float alpha = 1.0f, beta = 0.0f;
cublasSgemm(cublasHandle, CUBLAS_OP_N, CUBLAS_OP_N, totalOutputs, outputChannels, kernelSize * kernelSize,
&alpha, d_im2col_output, totalOutputs, d_filters, kernelSize * kernelSize, &beta, d_conv_output, totalOutputs);
gridSize = (outputSize + blockSize - 1) / blockSize;
relu_kernel<<<gridSize, blockSize>>>(d_conv_output, outputSize);
vector<float> result(outputSize);
cudaMemcpy(result.data(), d_conv_output, outputSize * sizeof(float), cudaMemcpyDeviceToHost);
return result;
}
forward 方法首先根据卷积参数计算输出维度,然后为输入数据、中间 im2col 变换结果和最终卷积结果动态分配 GPU 内存。输入张量通过 cudaMemcpy 从主机内存传输到设备内存,为后续的 GPU 操作奠定基础。
核心计算序列包含在 GPU 上执行的三个关键操作。首先,一个自定义 CUDA 内核执行 im2col 变换(如上一节所示),以便进行高效的矩阵操作。
卷积计算本身利用 cuBLAS 矩阵乘法(cublasSgemm)在变换后的输入数据和已学习的滤波器权重之间进行。这种方法充分发挥了现代 GPU 架构固有的并行处理能力,数千个核心可以同时在矩阵的不同元素上执行算术运算。卷积操作之后,一个 ReLU 激活内核通过逐元素应用修正线性单元函数来引入非线性。方法最后将处理后的结果传回主机内存,使计算得到的特征图可供后续网络层或最终输出处理使用。
这个 CUDA 实现是我们探索卷积层优化的自然演进。通过对比三种方法的基准测试,你将获得关于算法选择和硬件利用策略如何显著影响深度学习应用性能的实用见解。从基础 CPU 操作到优化的矩阵计算,再到最终的 GPU 并行执行,这一演进过程展示了现代神经网络开发中典型的迭代优化流程。
在探索了从基础 CPU 实现到复杂的 GPU 加速解决方案的演进之后,我们现在将目光转向 PyTorch 的 C++ API。它提供了一个高层抽象层,封装了我们先前手动实现的计算复杂性。这个综合性框架是我们优化之旅的必然总结,它提供了预优化的卷积操作实现、内存管理例程和 GPU 加速框架,让开发者能够专注于架构设计和算法创新,而不是低层实现细节。
使用 PyTorch C++ API 的卷积层
以下部分将展示 PyTorch API 如何简化开发流程,使从业者能够以显著降低的代码复杂度来实现 CNN 架构,同时保留我们通过手动实现所达到的计算效率。以下是一个类似的单卷积层实现:
struct ConvNetImpl : torch::nn::Module {
ConvNetImpl(int64_t input_channels, int64_t output_channels, int64_t kernel_size)
: conv1(torch::nn::Conv2dOptions(input_channels, output_channels, kernel_size).padding(0)),
relu() {
register_module("conv1", conv1);
register_module("relu", relu);
}
torch::Tensor forward(torch::Tensor x) {
x = conv1(x);
x = relu(x);
return x;
}
torch::nn::Conv2d conv1{nullptr};
torch::nn::ReLU relu{nullptr};
};
要在 GPU(如果可用)上执行这些计算,请使用以下代码片段:
torch::Device device(torch::kCPU);
if (torch::cuda::is_available()) {
std::cout << "CUDA available! Training on GPU." << std::endl;
device = torch::Device(torch::kCUDA);
}
第6章
在后续章节中,我们将利用这些PyTorch实现来展示先进的CNN架构和训练方法,这些方法构建在我们通过手动实现所建立的基础概念之上,并受益于框架强大的优化能力。
后续章节将演示这些基础概念如何汇聚于两个关键的计算机视觉领域:图像分类和图像分割。这些应用代表了现代深度学习中的基本范式,每个都应对不同的计算挑战,同时利用我们已经建立的核心CNN原理。
构建基于C++的神经网络用于图像分类
图像分类是一项计算机视觉任务,我们训练神经网络(或机器学习模型)将图像归类到预定义类别中。在本节中,我们将实现著名的VGG-16神经网络用于图像分类。我们使用的数据集是MNIST数据集。MNIST数据集包含0到9数字的灰度图像,通常用于训练和测试机器学习模型。该数据集是用于实现和训练CNN进行图像分类任务的基础资源。使用MNIST数据时,输入图像以单通道(灰度)格式处理,非常适合初期的CNN实现和教学目的。数据集的结构允许有效训练各种神经网络架构,包括像VGG-16这样复杂的模型,可以针对MNIST数字所呈现的10类分类问题进行专门调整。
VGG架构是深度神经网络的一个重要里程碑,具有参数量大、需要大量计算资源的特点。虽然计算密集且速度慢,但VGG16不同层的特征激活值特别丰富且通用。该架构于2014年开发,并因其有效性而继续被广泛使用。网络堆叠了五个卷积块——每个块都加深特征表示——然后将输出传递给一系列全连接层进行最终分类。其优势在于能够通过深层架构提取丰富特征,这一点已通过在多个基准数据集上取得的最先进结果得到证明。该架构可以通过修改灰度图像的输入通道并调整输出层以匹配目标类别数量,来适应不同任务,例如处理MNIST数据集。为了获得最佳性能,现代VGG-16实现利用GPU加速和基于矩阵的计算,而非传统的基于向量的方法。
该模型包含ReLU激活函数、最大池化操作和dropout层,以防止训练过程中的过拟合。
224 x 224 x64
112 x 112 x128
56 x 56 x 256
fc6
fc7
fc8
28 x 28 x 512
14 x 14 x 512
7 x 7 x 512
1 x 1 x 4096
1 x 1 x 1000
conv5
conv4
conv3
conv2
conv1
卷积+ReLU
全连接+ReLU
最大池化
图 6.4:VGG-16 架构
完整的VGG16实现可在本书配套的GitHub页面(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter6/VGG16.cpp)上获得。这里,在本节中,我们将查看代码的一些重要部分,以便更好地理解。该网络被划分为若干个块。以下是一个示例块:
torch::nn::Conv2d(torch::nn::Conv2dOptions(3, 64, 3).padding(1)),
torch::nn::ReLU(true),
torch::nn::Conv2d(torch::nn::Conv2dOptions(64, 64, 3).padding(1)),
torch::nn::ReLU(true),
torch::nn::MaxPool2d(torch::nn::MaxPool2dOptions(2).stride(2)),
该块将原始的红、绿、蓝(RGB)通道转换为64个特征图。两个连续的带填充3x3卷积在保持空间维度的同时,通过ReLU激活引入非线性。此外,最大池化操作将空间分辨率降低一半,创建层次化表示。在VGG-16中,添加了五个这样的卷积层块,但通道深度逐渐增加:分别为64、128、256、512和512。这种设计使网络能够捕获越来越复杂的视觉模式——从早期层的边缘和纹理到更深层的物体部分和语义概念。
此外,还添加了一个分类器模块,通常称为分类头,如下所示:
classifier = register_module("classifier", torch::nn::Sequential(
torch::nn::Linear(512 * 7 * 7, 4096),
torch::nn::ReLU(true),
torch::nn::Dropout(0.5),
torch::nn::Linear(4096, 4096),
torch::nn::ReLU(true),
torch::nn::Dropout(0.5),
torch::nn::Linear(4096, num_classes)
));
}
在网络架构中逐步应用最大池化操作,系统地减小特征图的空间维度,最终将表示压缩到7×7的空间分辨率。在这个最后的卷积阶段,网络保持512个通道的特征深度,总共产生25,088个特征值(512×7×7)。随后,这种高维特征表示通过由两个全连接层组成的架构进行变换,每个全连接层包含4,096个神经元。
该架构设计在这些全连接层之间加入ReLU激活函数,以引入非线性并增强网络的表示能力。为了减轻训练过程中的过拟合,采用概率为0.5的dropout正则化机制,在每次训练迭代中有效停用一半的神经元。这种随机正则化技术可防止模型对特定神经元路径产生过度依赖,从而提高对未见数据的泛化性能。
网络架构以最终分类层收尾,其维度与分类任务中目标类别的数量精确对应。该最终层生成所有可能类别标签上的概率分布,使网络能够基于先前各层学到的层次化特征表示做出明智的预测。
以下是VGG-16神经网络前向传播的实现:
torch::Tensor forward(torch::Tensor x) {
x = features->forward(x);
x = x.view({x.size(0), -1}); // Flatten
x = classifier->forward(x);
return x;
}
在前面的实现中,features模块代表了构成特征提取路径的所有五个卷积块的整体架构。网络接受格式为[batch, 3, 224, 224]的RGB输入张量,其中批次维度同时容纳多个图像,空间维度符合标准ImageNet预处理要求。卷积层前向传播完成后,features模块生成维度为[batch, 512, 7, 7]的输出张量,表示512个不同的特征图,每个特征图的空间分辨率为7×7像素。
随后的维度变换通过张量重塑操作完成:
x = x.view({x.size(0), -1});
该操作在深度学习术语中通常称为展平,通过x.size(0)保留批次维度,同时使用-1参数自动计算剩余维度。自动计算产生25,088个元素(512×7×7=25,088),从而将多维特征图转换为适合全连接层处理的一维特征向量。
展平后的特征表示随后传入分类器模块,该模块应用前一节讨论的全连接层变换和正则化技术。网络的最终输出由对应每个目标类别的类别logits组成——在用于ImageNet分类的标准VGG-16实现中为1,000个类别——为多类别分类决策提供所需的概率分布。
这种架构设计体现了基于CNN的分类系统的基本原理:通过分层特征学习和降维,从空间特征表示系统性地转变为抽象语义分类。
第 6 章
使用 U-Net 进行图像分割
图像分割是一项涉及为像素分配标签并识别图像中感兴趣对象的任务。该过程通过从边界周围的图像像素构建初始背景模型来工作,其中背景和前景模型表示为多个 RGB 聚类的直方图。然后,程序动态地将对象边界向内收缩,同时更新和评估新的图像强度和梯度模型,直到获得具有强边缘屏障的稳定前景和背景模型。
图像分割技术已在多个领域得到广泛应用。基于深度学习的分割在自动驾驶、精准农业和生物医学应用中取得了显著成功。在文档处理系统中,分割有助于提取和分析特定区域(如驾照上的条形码),从而提高数据提取的精确率和召回率。
CNN 也可用于图像分割算法。在本节中,我们将讨论用于图像分割的 U-Net 算法。U-Net 架构是分割任务中一种轻量且有效的方法,采用全 CNN 设计。该架构由一个编码步骤和随后的转置卷积层组成,特别适合处理非序列数据。当使用适当的训练参数(如 Adam 优化器和二元交叉熵损失)实现时,U-Net 在二分类任务中可以取得很高的准确率。该架构已被证明在生物医学图像分割应用中特别有效,能够在保持计算效率的同时处理复杂的分割任务。还有其他复杂的算法,如 Mask R-CNN。为完整起见,你还应熟悉语义、实例和全景分割等术语。下图对此进行了说明。
图 6.5:语义、实例和全景分割
不同类型的分割在图像分析中服务于不同目的。在语义分割中,同一类别的所有对象都被标记为相同的标签,这在自动驾驶汽车和机器人技术中尤其有用,因为这些场景中的环境上下文至关重要。例如,在语义分割中,所有汽车都会被标记为相同。
全景分割结合了实例(对象)分割和语义(背景)分割两种方法。它要求标注者围绕所有对象绘制紧贴的边界,直到所有像素都被覆盖,并通过不同的颜色/标签将相似对象标记为不同实例。该技术在卫星图像等应用中尤其有价值,可用于检测保护区中的变化。
目前用于分割的典型神经网络之一称为 U-Net。U-Net 最初由德国弗莱堡大学的 Olaf Ronneberger、Philipp Fischer 和 Thomas Brox 于 2015 年提出。该网络因其独特的 U 形架构而得名,该架构由两条主要路径组成:
- 编码器路径(收缩路径):编码器路径构成 U 的向下部分。该路径在增加特征深度的同时逐步减小空间维度。它从输入图像中捕获上下文和特征信息。
- 解码器路径(扩展路径):解码器构成 U 的向上部分。与编码器路径不同,它在减小特征深度的同时逐步增加空间维度。它还重建空间信息以实现精确定位。
U-Net 的一个关键特征是跳跃连接,它将编码器和解码器路径中相应的层级连接起来。这些连接在每一分辨率级别上桥接收缩路径和扩展路径,使解码器能够通过直接引入来自编码器的高分辨率特征图,恢复在下采样过程中丢失的空间精度——这正是赋予 U-Net 特有的分割准确率的机制。
U-Net 已被证明在生物医学图像分割应用中特别有效,能够在保持计算效率的同时处理复杂的分割任务。该架构被认为是轻量且有效的,采用全 CNN 设计,特别适合处理非序列数据。自其问世以来,已出现了其他 U-Net 变体,如 Attention U-Net、U-Net++、ResUNet 和 3D U-Net 等。
| 块 | 操作 | 尺寸 | 通道数 |
|---|---|---|---|
| 输入 | 输入图像块 | 572 x 572 | 1 |
| 编码器 1A | 卷积 3x3, ReLU | 570 x 570 | 64 |
| 编码器 1B | 卷积 3x3, ReLU | 568 x 568 | 64 |
| 池化 1 | 最大池化 2x2 | 284 x 284 | 64 |
| 编码器 2A | 卷积 3x3, ReLU | 282 x 282 | 128 |
| 编码器 2B | 卷积 3x3, ReLU | 280 x 280 | 128 |
| 池化 2 | 最大池化 2x2 | 140 x 140 | 128 |
| 编码器 3A | 卷积 3x3, ReLU | 138 x 138 | 256 |
| 编码器 3B | 卷积 3x3, ReLU | 136 x 136 | 256 |
| 池化 3 | 最大池化 2x2 | 68 x 68 | 256 |
| 编码器 4A | 卷积 3x3, ReLU | 66 x 66 | 512 |
| 编码器 4B | 卷积 3x3, ReLU | 64 x 64 | 512 |
| 池化 4 | 最大池化 2x2 | 32 x 32 | 512 |
| 瓶颈 A | 卷积 3x3, ReLU | 30 x 30 | 1024 |
| 瓶颈 B | 卷积 3x3, ReLU | 28 x 28 | 1024 |
| 上卷积 1 | 上卷积 2x2 + 复制和裁剪 | 56 x 56 | 512 |
| 解码器 1A | 卷积 3x3, ReLU | 54 x 54 | 512 |
| 解码器 1B | 卷积 3x3, ReLU | 52 x 52 | 512 |
| 上卷积 2 | 上卷积 2x2 + 复制和裁剪 | 104 x 104 | 256 |
| 解码器 2A | 卷积 3x3, ReLU | 102 x 102 | 256 |
| 解码器 2B | 卷积 3x3, ReLU | 100 x 100 | 256 |
| 上卷积 3 | 上卷积 2x2 + 复制和裁剪 | 200 x 200 | 128 |
| 解码器 3A | 卷积 3x3, ReLU | 198 x 198 | 128 |
| 解码器 3B | 卷积 3x3, ReLU | 196 x 196 | 128 |
| 上卷积 4 | 上卷积 2x2 + 复制和裁剪 | 392 x 392 | 64 |
| 解码器 4A | 卷积 3x3, ReLU | 390 x 390 | 64 |
| 解码器 4B | 卷积 3x3, ReLU | 388 x 388 | 64 |
| 输出 | 卷积 1x1 | 388 x 388 | 2 |
图 6.6:原始 U-Net 的架构
有关 UNet 的完整实现,请参阅 GitHub 上的相关代码(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter6/UNet.cpp)。让我们来看一下编码器和解码器的实现。
以下是编码器的代码:
encoder1 = register_module("encoder1", torch::nn::Sequential(
torch::nn::Conv2d(torch::nn::Conv2dOptions(in_channels, 64, 3).padding(1)),
torch::nn::BatchNorm2d(64),
torch::nn::ReLU(),
torch::nn::Conv2d(torch::nn::Conv2dOptions(64, 64, 3).padding(1)),
torch::nn::BatchNorm2d(64),
torch::nn::ReLU()
));
在上述代码中,register_module 函数是 PyTorch C++ API 中的一个方法,负责将神经网络模块注册为当前模块的子模块。在此上下文中,当前模块是一个名为 UNetImpl 的 C++ 类,它继承自 PyTorch 的基础神经网络模块类,具体来说是 torch::nn::Module。
UNetImpl 类接受带有 in_channels 参数的输入(对于灰度图像默认为 1)。它生成 64 个特征图,同时通过填充保持空间维度。应用 BatchNorm2d 对特征图进行归一化,然后是 ReLU() 激活。随后,使用第二个卷积层。
理解几个关键且重要的 PyTorch 函数和方法至关重要。torch::nn::Sequential 类负责编排层的串联,确保数据按顺序流经这些层。register_module 函数自动管理已注册模块的参数,有助于梯度在整个注册模块中的传播。此外,它还处理训练模式和评估模式之间的切换、管理设备迁移,以及保存和加载完整的模型状态。
现在,让我们来看一下解码器子模块:
decoder4 = register_module("decoder4", torch::nn::Sequential(
torch::nn::Conv2d(torch::nn::Conv2dOptions(512, 256, 3).padding(1)),
torch::nn::BatchNorm2d(256),
torch::nn::ReLU(),
torch::nn::Conv2d(torch::nn::Conv2dOptions(256, 256, 3).padding(1)),
torch::nn::BatchNorm2d(256),
torch::nn::ReLU()
));
与编码器的情况一样,解码器实现使用 PyTorch 的 C++ API 遵循类似的模式。解码器接收编码后的特征,在减小特征深度的同时逐步重建空间维度。
让我们也来看一下这个神经网络的前向传播:
torch::Tensor forward(torch::Tensor x) {
// Encoder path
auto e1 = encoder1->forward(x);
auto e2 = encoder2->forward(maxpool(e1));
auto e3 = encoder3->forward(maxpool(e2));
auto e4 = encoder4->forward(maxpool(e3));
// Decoder path with skip connections
auto d4 = decoder4->forward(upconv4(e4));
auto d3 = decoder3->forward(upconv3(d4));
auto d2 = decoder2->forward(upconv2(d3));
return torch::sigmoid(final_layer(d2));
}
上述代码表示前向传播方法,定义了数据在 U-Net 中的流动方式。在前向传播的每一步中,先进行卷积,然后进行最大池化。在 U-Net 的编码器部分,空间尺寸减半,通道数加倍。它在多个尺度上捕获特征。解码器部分使用 upconv4 对 e4 进行上采样,upconv4 是一个转置卷积层(也称为反卷积)。转置卷积在输入像素之间插入零,并应用卷积,从而在训练过程中学习上采样权重的同时有效提高空间分辨率。最后,对 U-Net 的最后一个输出 d2 执行 sigmoid 操作。最终输出是与输入尺寸相同的分割概率图。
面向深度学习的图像处理
在本章中,我们探讨了 CNN 的两个关键应用:使用 VGG-16 进行图像分类和使用 U-Net 架构进行图像分割。虽然我们已经实现了这些神经网络的基本版本,但要获得最佳模型性能还需要其他技术。一种同时提高模型准确性和泛化能力的常用方法是图像增强(或更一般地说,数据增强)。图像增强包含一系列工具和技术,通过创建多样化的训练数据变体来提升模型性能。图像增强的目标是训练一个对输入数据的微小变化具有不变性的模型。在本节中,我们将探讨一些计算机视觉中常用的图像增强技术,并提供相应的代码片段。
旋转
旋转是最常见的图像增强技术之一,它使图像围绕其中心点旋转不同角度。这种变换有助于 CNN 学习识别图像中任意方向的物体。在 C++ 中实现旋转时,你可以指定随机旋转和固定角度旋转,通常范围从 -180 度到 +180 度。然而,需要注意的是,旋转可能会在图像角落产生空白区域,如下图所示。这些空白区域需要通过填充、裁剪或重采样等技术来处理,以保持 CNN 所需的输入尺寸。
在日常实践中,最好利用 LibTorch 的 C++ 实现所提供的内置实现:
#include <torch/torch.h>
#include <torchvision/transforms/functional.h>
torch::Tensor image_tensor = torch::rand({1, 256, 256}).cuda(); // Example: a 256x256 grayscale image
double angle = 45.0; // Rotate by 45 degrees
torch::Tensor rotated_image =
torchvision::transforms::functional::rotate(image_tensor, angle);
平移
平移是另一种图像增强技术,它通过指定像素数沿 x 轴和/或 y 轴移动图像。这种变换通过让 CNN 学习识别图像中任意位置的物体,帮助其发展位置不变性。在 C++ 中实现平移时,你可以使用固定的像素值进行确定性平移,也可以在定义范围内进行随机平移。该变换需要仔细处理边界区域,可以通过各种填充策略来管理。对于目标检测和图像分类等任务,平移增强对于提高模型鲁棒性尤其有价值,因为在这些任务中,物体可能出现在输入图像的不同位置。
在图 6.8中,图像沿水平轴平移。由于这种平移,图像的部分区域为空白,需要用某个常数值进行填充。
裁剪
裁剪通过选择特定的感兴趣区域(ROI)来提取原始图像的一部分。这种变换通过迫使 CNN 从图像的不同部分学习,使其更加鲁棒。在 C++ 中实现裁剪时,你可以执行固定尺寸的中心裁剪,也可以在指定边界内改变裁剪区域的位置和大小进行随机裁剪。随机裁剪尤其有效,因为它通过关注图像的不同部分来创建多样的训练样本,帮助模型学习识别即使部分可见的物体。当目标物体不一定出现在画面中心或可能被部分遮挡时,这种技术尤其有价值。
以下是用于裁剪图像的 Torch 示例代码。在这段代码中,我们使用一个 100 像素的随机 torch 数组作为占位符来裁剪 RGB 图像:

浙公网安备 33010602011771号