Skip to content

AI编程转录稿:20251016卷积和池化

· 40 min

开头#

这里是 AlexNet 的框图。我们在上一节的时候讲了全连接层。在这一节,我们就给大家分享卷积还有池化相关的知识。因为在这个 AlexNet 里边,它前面是卷积层、Max Pooling,还有全连接层,这些层通过堆叠形成了一个神经网络。前面咱们课程上已经涉及了激活函数,也跟大家讲过了。全连接层,我们也讲过了。这一节就跟大家讲一讲卷积、池化以及最后的这个 Softmax 损失层。

首先进入第一个模块,卷积层的实现。在讲解卷积之前,我们先来分析一下为什么要引入卷积层。

全连接层 (Fully Connected Layers)#

首先全连接层(FC)它的优势是它非常的具有表现力,指的是它把输入的每一个元素和输出都进行了连接。在这里我们展示了其中一个例子。比如我们把一张图像通过全连接层从输入引导到输出,那么输出的每一个元素和输入的每一个元素都有相互联系。

全连接层它的实现比较简单,可以用 GEMM(通用矩阵乘法)进行实现,也非常的高效。

但是它的缺点是全连接层需要大量的参数。因为它把所有输入元素都和输出进行了连接。比如我们这个图像是 200 乘 200,如果我们输出是 1000 维,那么这一个全连接层就有将近 200M 的参数。参数太多会导致计算量大,二来最重要的是它参数太大,容易过拟合。

另外全连接层也有一些缺点,就是它不具备平移的不变性(invariance)和平移的等变性(equivariance)。其实我们是希望有一定的某种形式的等变性和不变性的。比如一个人从照片的左侧移到右侧,我们如果对行人检测这样一个任务的话,我们希望提出来的特征,如果人从左侧移到右侧,那么它对应的特征也应该从左侧移到右侧。或者说我们要做分类,那么应该这个物体动了之后,图像提出来的 feature 具有不变性才行。但全连接层不是这样的,如果这个图像中的内容发生了移动,那很显然输出也会发生移动,它不是那种等变式的动,所以它不具备这种性质,就导致它在训练的过程中容易记住训练集的东西,容易造成过拟合。

卷积层 (Convolution Layer)#

也正是因为想要解决全连接层的这种缺点,科研人员引入了卷积层。卷积层我们可以当成在这个图像上有一个小小的卷积模板(Kernel)。这个卷积模板从图像的左侧滑到右侧,在每个地方做一个运算,把输出的结果映射到输出上。

如果我们把这个图像一下子就裁成 3×33 \times 3,那其实这个卷积就退化成全连接层了。所以我们可以认为卷积层是一个很小的、共享权重的全连接层。

它的那个感受野 k×kk \times k 是固定在卷积模板里的。这个 kk 和图像的尺寸没有关系。kk 比如说我们选 3,那它的感受野也就是 3×33 \times 3 这样一个小区域。但就是因为这个 kk 很小,一般取 3×33 \times 3 这样一个量级,所以它的参数量就很少。就把这个参数量极大的减少了,就让训练不容易过拟合。

而且这个卷积具有平移的等变性(Translation Equivariance)。就是这个人从左侧移到右侧,我们做一次卷积之后,得到的 feature 也是跟着人的运动从左侧移到右侧。在每一个地方做卷积的时候,这些权重都是共享的。

这里是做 filtering 的动画。给定了这样一个输入,我们有一个 kernel,kernel 是卷积的模板,我们从左侧到右侧不断的进行滑动。在每个地方滑动的时候进行一次点乘运算,把输入放到输出,直到从左到右从上到下算完为止。

在神经网络里这个卷积和 filter 不同的地方在于,对于图像处理里面,我们说做图像过滤器(filter),这个 kernel 是写死的,是人为定义的。现在在神经网络里,我们给这个 kernel 一个随机初始化,让这个 kernel 随着神经网络的训练不断的被优化。所以这个 kernel 是学出来的,从数据中学出来的 kernel,而不是人为定义的。

同样,这个卷积和 filter 一样,它有一些边界效应。指的是如果这个卷积模板落到了图像边界,那么边缘的这些地方就找不到图像元素了,怎么办呢?

  1. 一种做法是把图像周围这一圈元素给它忽略掉,忽略掉就会导致这个图像每过滤一次都会小一圈。
  2. 另外一种常用的是我们在图像周围补上一圈 0。我们认为它落到了图像周围之后,如果找不到图像的元素,就让它和零进行乘法运算。这个补零(padding)操作是在神经网络里用的比较多的。
  3. 第三种是我们假设这个图像是周期变化的(circular)。当这个元素大于图像的最大宽度的时候,我们就取一次模,让它又卷回去,返回到图像的初始部分。
  4. 最后一种选择是我们认为这个图像边界是反射的(reflect)。

其实在神经网络里,我们一般默认选择补零就行了,除非有特殊的需求。在 PyTorch 里边,我们可以用 pad 这个函数,通过不同的选择来选择这四种模式。

PyTorch 中的卷积#

卷积在 PyTorch 里边是有实现的,它在这个 torch.nn.functional.conv2d 这个函数就定义了一个卷积。

这里需要再强调一个和图像过滤不一样的点。在神经网络里面做卷积,你这个图像是多通道的。比如说这里边显示是三通道,它做每次做卷积做点乘的时候,它一下裁出一个三通道的图像块,做一个点乘运算,把它放到输出。那我们做图像 filtering 的时候,是每个通道单独做的。

但是在神经网络内部,一般来说这个通道数是 64、128、256 等等。我相当于一下子裁了二百多个通道,做一个巨大的点乘。

输入在训练的时候一般是一次处理一个小批次(batch)。假如说图像的数量为 N,输入的通道数为 CinC_{in},图像的长和宽是 H 和 W,那么我们输入就是一个四维的 tensor,形状是 [N,Cin,H,W][N, C_{in}, H, W]。输出是 [N,Cout,H,W][N, C_{out}, H, W]

因此这个 kernel(weight)是四个维度的一个数组,形状是 [Cout,Cin,KH,KW][C_{out}, C_{in}, K_{H}, K_{W}]。做完这个乘法之后,我们还有一个偏置量(bias),它的通道数是 CoutC_{out}

有的时候这个卷积还有一个步长(stride),如果步长是大于 1 的话,那么我们就相当于对图像进行了降采样。Padding 我们刚才讲了,你可以在周围再补上一圈零。

步长卷积与池化 (Strided Convolution / Pooling)#

如果当这个卷积的步长大于 1,或者说像 AlexNet 里边,我们希望通过一些降采样的操作,把一个大的 feature map 变成一个小的 feature map。我们就可以选这个步长大于一的卷积或者选择池化(Pooling)操作。

对于池化操作,我们可以认为这个图像上进行滑动的时候,每一次我们选 2×22 \times 2 这么一个小窗口。在每一次滑动的时候,我们在 2×22 \times 2 的区域里边取出它的最大元素(Max Pooling),从输入映射到输出。经过这样一个操作,那么图像的 feature 就会降一倍,分辨率就降一倍了。

这个和图像处理里边的降采样稍有不同。因为我们知道在图像处理里边,我们为了让这个降采样的时候噪音不被放大,是要先对图像做一次高斯过滤(Gaussian filter),再逐个地丢掉一些像素。现在在神经网络里一般不做高斯过滤,我们直接降采样。

(思考:我能不能在做降采样的时候也做一下高斯过滤呢?当然是可以的。而且这个想法也是有一篇学术的文章对应发表在机器学习的顶会 ICML 上。)

对于步长大于一的卷积,我们可以认为原来的这个卷积是在每个位置上做完自己之后就做自己的邻居。现在步长大于一的话,你比如说步长等于 2,我做完这个黄色元素,我就跳两步,跳到他旁边的这个邻居的邻居,那么最终输出分辨率就降了一倍了。

分组卷积与深度卷积 (Grouped / Depthwise Convolution)#

由于这个卷积它的 kernel 是四维的 tensor,[Cout,Cin,KH,KW][C_{out}, C_{in}, K_{H}, K_{W}]。当这个输入和输出的通道数都很大的时候,比如都到了 1024 维了,参数量也比较大,也会容易造成过拟合和计算缓慢。

为此我们可以借鉴图像过滤的思想,把输入和输出这个通道数分组(Grouped Convolution)。比如我把这个输入分成两组…这样的话我们就把这个裁的图像块压扁了,参数量会变少一些。

如果把这个事情做到极致,就完全退化成图像里边的卷积了,我相当于在每一个通道上面单独有一个 3×33 \times 3 的卷积核。做完之后把输入放到输出,这个时候这个卷积被称为 Depthwise Convolution(深度卷积)。

最早的时候普通的这种卷积是在 90 年代的 Yann LeCun,他的 LeNet 里边就是这么做的。甚至在 AlexNet 里边,我们可以认为它分成了两半。为什么大家后来开始考虑这个 Grouped Convolution 和这个 Depthwise Convolution 呢?我们可以认为是大家觉得那个参数量太大了,想点办法降掉参数量,而且这样也会减少一些计算量。尤其在移动端手机上,如果有神经网络运行的话,那它大概率有可能就会用这个 Depthwise Convolution。

卷积的实现#

下面就来给大家介绍一下这个卷积的实现。

朴素实现 (Naive Implementation)

首先我们来看一下一个普通的卷积在 CPU 上是怎么实现的。对于输入的形状是 [N,Ci,H,W][N, C_i, H, W],输出也是 [N,Co,H,W][N, C_o, H, W],卷积核是四维的 [Co,Ci,K,K][C_o, C_i, K, K]

我们为了实现一个卷积,要做一个七重循环。我们直接这样子做的话并不高效,因为并行度不高。我们的速度要比 PyTorch 要慢上几百倍甚至上千倍。

版本一:稀疏矩阵乘法 (SpMV)

卷积可以被写成矩阵乘是非常重要的结论,也是我们要进行加速的一个核心思想。因为我们讲过这个矩阵乘它是被高度优化的,被 BLAS 库高度优化的。

我们以一维的作为例子进行讲解。假如说我们对这个一维的数组进行卷积…Y2 等于 W1 乘以 X1 加 W2 乘 X2 加 W3 乘 X3。我们就可以把它写成两个矩阵的乘。

Y=W^XY = \widehat{W} X

这个矩阵(W^\widehat{W})是非常稀疏的。所以我们就可以用我们之前讲的非常高效的稀疏的矩阵乘向量(SpMV)来实现。cuBLAS 或 cuSPARSE 对它进行了加速和优化。这一方法在图神经网络里用的非常多。

我们来看一下 backward。首先要根据 LY\frac{\partial L}{\partial Y} 来算出 LX\frac{\partial L}{\partial X}。我们推完之后发现 LX=W^T×LY\frac{\partial L}{\partial X} = \widehat{W}^{T} \times \frac{\partial L}{\partial Y}。这个矩阵的转置还是个稀疏矩阵。这个 backward 也被称为 Transposed Convolution(转置卷积),Transport 就指的是把这个卷积的 kernel 矩阵进行了一次转置。

下面我们来算一下这个 W^\widehat{W} 它的偏导数。根据这一行公式 LW^=LY×XT\frac{\partial L}{\partial \widehat{W}} = \frac{\partial L}{\partial Y} \times X^{T}。虽然这个 W^\widehat{W} 矩阵是稀疏的,但是我们这样一乘,这个矩阵就不再稀疏了,而且它是一个超大的矩阵。如果你这样直接硬把它做矩阵乘算出来的话,一下子内存就爆炸。

我们再来看一下从 LW^\frac{\partial L}{\partial \widehat{W}}LW\frac{\partial L}{\partial W}。从这个 WW 构造 W^\widehat{W} 它是复制操作。把一个元素,你比如说本来这个 W1 它只有一个数,它复制了四次。我们知道复制这个操作的偏导数等于 1。现在我一个东西 W1 复制了四次,那其实 W1 的偏导数是多少呢?就是把这四个地方的偏导数累加出来。

我们可以构建一个键值,做一次排序,排完序之后你做一次 segmented reduce,那就把 W1 的累加出来了,W2 累加出来…我们就从这个一个大的稀疏矩阵算出来 WW 它的偏导。

在计算 LW^\frac{\partial L}{\partial \widehat{W}} 这一步的时候,我们就不能用矩阵成了。我们应该只算那些非零元素位置的梯度,算完之后再做累加。这种实现在图像里边用的不多,在图神经网络里用的挺多的。

版本二:im2col + GEMM

第二个版本我们换一种思路。同样我们看这样一个卷积,Y2 等于这三个元素和这三个元素的点乘。我们能不能把 X1、X2、X3 塞到一个大矩阵(X^\hat{X})里面呢?

Y=X^WY = \hat{X} W

我们是不是把输入 X 给它转成了一个很大的一个大矩阵,这个矩阵是稠密矩阵。它是一个大矩阵乘以一个小矩阵(GEMM)。我们就可以调用上节课讲那个 GEMM 来对它进行运算,就比较高效了。

col2im 稍微有点复杂,因为我们是把 X^\hat{X} 里边的偏导数累加到 XX 的同一个位置上,所以里边会有一些往同一个位置写的这些操作,要处理一下多个 thread 往同一个地方写这样一个问题。

这里是一个二维的 im2col 示意图。im2col 做的事情,就是我们裁的这么一个图像块,把裁出来这个图像块的内容从左到右拉成一个长向量。

我们总结一下第二个版本。这个方法易于实现,而且非常高效。但它也有缺点,缺点就是我们要消耗太多的内存了。因为这个 X^\hat{X} 矩阵它高度太高了。

所以一个常用的做法是我们可以做一个 for 循环,每一次对一个图像做卷积。

对于这个方法,我们也很容易实现步长为二的卷积。我们只需要在构造 im2col 的时候跳过那些元素就行了。

我们是显式地构造一个大矩阵,做 GEMM,所以我们这个版本二被称为 Explicit GEMM(显式 GEMM)。

版本三:Implicit GEMM

现在神经网络库里用的是这个第三个版本(Implicit GEMM)。之所以第二个版本它内存消耗比较多,就是因为我们要构造那个巨大矩阵。

那一个自然的想法是不是我们不要构造这个大矩阵,我们在线地(on-the-fly)把矩阵乘和 im2col 这两个算子合到一起?我要做某一行的乘法了,我直接在线的把我需要的元素从输入图像里边拷贝过来,在这个 kernel 内部做一下乘法,直接写到输出里面。我们也可以利用 Shared Memory 对它进行加速。

这个方法是更加的高效,也更加省内存。前面这个 Explicit GEMM 是在早期著名的深度学习库 Caffe 所选用的。现在 PyTorch 什么的,他们一般都选用了这个 Implicit GEMM,它的实现难度是比较高的。

也有一些好消息就是英伟达的工程师通过 CUTLASS 这样一个代码库,实现了高度模板化的 Implicit GEMM。我们在课程作业里边,我们就推荐实现这个 Explicit GEMM 就够了。

(补充:现在有 OpenAI 开发了一个很著名的编程语言是 Triton。你可以认为它在 Python 里边写一个 GPU 的 kernel…用 Triton 也能够很方便的实现 Implicit GEMM。)

CUDA 实现总结

  1. 用 CUDA 实现 im2colcol2im
  2. 实现完之后我们就可以用我们之前封装好的 GEMM 直接调用矩阵乘。
  3. Forward Pass: im2col + GEMM。
  4. Backward Pass (Weights): 在 backward 的阶段,我在线的再算一遍 im2col(用时间换空间,避免存储巨大的 X^\hat{X}),然后做 GEMM (X^T×LY\hat{X}^T \times \frac{\partial L}{\partial Y})。
  5. Backward Pass (Input): 先做 GEMM 算出 LX^\frac{\partial L}{\partial \hat{X}},再做 col2im
  6. col2im 由于是累加,我们有两种实现办法。第一种,以 X^\hat{X} 为参考启动 thread,往 XX 写的时候用一个原子操作。另外一种实现办法是我们根据 XXXX 为参考,启动 N×C×H×WN \times C \times H \times W 个 thread,每个 thread 负责从 X^\hat{X} 里边读取它需要累加的梯度,这样就避免了原子操作。
  7. 最后还有这个 bias 操作。我们也可以 reduce to GEMM,或者把这个 bias 操作当成一个单独的神经网络层单独实现出来。

深度卷积 (Depthwise Convolution)#

我们接着讲讲 Depthwise Convolution。它在实际的生活中应用很广泛。

普通的卷积…它的维度是四维的。对于 Depthwise Convolution,相当于在每一个通道上单独的做一个一通道的卷积。它在什么地方有用呢?最早是被谷歌提出来的 MobileNet…后来中国的企业旷视科技,他们也提出了 ShuffleNet,基本上也是 Depthwise Convolution。

自然一个问题是:能不能同样用 GEMM 做 Depthwise Convolution?

如果对于这个问题,你还 reduce to GEMM,速度会慢得多。因为每一次做点乘,相当于一次只取 K×KK \times K(比如 9 个)元素做一个点乘。计算密度比较低…内存拷贝就会成为瓶颈。所以对于这个问题的话,我们一般就要选别的办法了,不如我们直接启动一些 kernel 来解决。

对于一个输入的形状 [N,C,H,W][N, C, H, W]…原来的卷积…kernel 的形状是四维的。现在对于 Depthwise Convolution,这个卷积核里边的参数是 [C,K,K][C, K, K]。参数量一下子降了 C 倍,计算量也少了很多。

我们首先来看一下这个 Depthwise Convolution…用伪代码实现是什么样的。由于这个图像它是 [N,C,H,W][N, C, H, W]…这个 CinC_{in}CoutC_{out} 都等于 CC,所以对于这个我们相当于是循环从七重循环变成六重循环。

对 GPU 上的加速我们就不要用 GEMM 了,我们可以直接启动一个 kernel。这个 kernel 把外面这个 N,H,W,CN, H, W, C 这 4 重循环展开,我相当于是启动一个 kernel 里边有 N×H×W×CN \times H \times W \times C 这么多个 thread,每个 thread 负责自己的小领域的一个运算(K×KK \times K 的乘法)。

怎么把这个四重循环并行化呢?我们很显然写一个 kernel。我们通过除法和取余,就能够把当前 thread 所负责的那个 batch 的 ID、长、宽以及它的通道数给算出来。算出来之后下面就是两重循环(遍历 K×KK \times K)。

这样一个简单的实现其实在 GPU 上已经比较高效了。它的前向是比较容易实现的。但是它的 backward 稍微麻烦一点点,尤其是对于 kernel 的 backward。

为什么呢?每一个 weight 元素…在一个图像的一个通道…要在图像从左到右,从上到下逐个滑动…H 乘 W 这么多的位置,每个位置的梯度都要累加到同样一个元素上。由于你好多元素往同一个地方累加,这个优化难度就有了。当然我们可以使用一些 Shared Memory 以及 reduce 操作。

Depthwise Convolution 不要求我们课程上实现,作为补充学习。


池化层 (Pooling Layer)#

下面我们就进入第二个模块,就是 Max Pooling。Max Pooling 的主要作用是对图像进行空间的降采样。而且这个 Max Pooling 和这个 Depthwise Convolution 挺像的,它是在每个通道上独立的进行 Max Pooling。

比如说我们这个输入…我们取其中一个切片…我们在这个切片里边每一次选一个 2×22 \times 2 的小窗口,把这个小窗口里边的最大元素给算出来…就把它的分辨率一下子降了一倍。

其中我们选的这个小模板…常用的就是这个 kernel 是 2,步长也是 2。

我们作业或者我们课程上就要求大家实现一个 kernel size 为 2×22 \times 2,stride 为 2 的 Max Pooling。

在 PyTorch 里边,这个 Max Pooling 也在 torch.nn.functional 里边有定义和实现。

Max Pooling Forward Pass

对 Max Pooling…我们可以根据图像的长和宽启动 kernel,每个 thread 负责输出的一个元素的运算。这一个 thread 从一个小局部窗口里面把它的元素都给取出来,算一下 max 就放到输出就结束了。

尤其值得一提的是这个 Max Pooling,你不单单要算 max,你还要把它的 mask 也给算出来。Mask 就是你比如说这四个元素,谁最大,你把它的 ID 给记下来。

Max Pooling Backward Pass

为什么我们要一个 mask 呢?我们就来考虑一下这个 max 函数的梯度是什么?

你看比如说这里边一共有 KK 个元素,假设第二个元素是它里边最大值。我们是不是应该把那个梯度给拷贝给第二个元素,其他的元素梯度都是零。

因此我们记录了这个 mask,我们有了 backward 的梯度。假如说[输出]梯度是 1.3、0.5、0.4、0.1,那按照我们刚才这个讲解,我们就应该把 1.3 复制到[输入中]那个最大值的位置,0.5 复制到它对应的最大值位置…

如果我们不记录这个 mask,我们就应该在 backward 的时候重新再算一遍这个最大值来自于哪里,这就比较麻烦。

这个 Pooling 的 backward 也被称为 Unpooling。

Unpooling 与 Stencil 模式#

Unpooling 在神经网络的 Segmentation(图像分割)里边也被广泛使用。因为我们知道给了一个图像之后,他不断的做 convolution、pooling,最终那个图像就变得很小。

如果我们想做图像的分割,我们岂不是要有一个大的图?我们完全可以把这个降采样卷积这个过程给它逆过来。本来做卷积,我们现在就用 Transposed Convolution。本来是 Pooling,我这里边来一个 Unpooling,按照这个 mask 这个标记,把对应的梯度复制到对应的位置上,就能把这个图进行上采样。

这里我们总结一下并行计算的模式:

  1. Gather (多对一) : Convolution 和 Pooling 的 forward 运算,是一种特殊的 Gather。
  2. Scatter (一对多) : Convolution 和 Pooling 的 backward 被称为 Scatter。
  3. Stencil: 指的是我们做 Gather 或 Scatter 的时候,它那个邻域模式是固定的。

对于实现来说…我们完全可以启动一个 kernel,让 thread 来对它进行并行。具体而言…我们就以输出为参考元素。

对于 Scatter(比如 col2im 或 Unpooling),一个最直接的想法是我以输入为参考启动 thread…但更多的 thread 它往同一个内存位置写,就有冲突,就要有原子操作。

我们还不如以输出元素为参考,启动少量的 thread(例如 col2im 时启动 N×C×H×WN \times C \times H \times W 个 thread)。由于他邻居比较少(比如 K×KK \times K 个),这 thread 的计算量也可以接受,也避免了原子操作。


Softmax 与损失函数#

最后再花一点点时间给大家讲解一下这个 Softmax 损失函数的计算。

The Softmax Function

Softmax 是神经网络的最后一层…我们给定了一个神经网络的预测…按照这个公式进行运算之后,把它给转成概率。

这个 Softmax 可以被当成一个可微(differentiable)版本的 one-hot function。

数值稳定性 (Numerical Stability)

这个 Softmax 貌似很简单,但是如果我们课上不讲解,同学们实现的过程中就容易出错。因为 Softmax 有数值稳定性问题。

为什么有数值问题呢?因为有 exp(指数函数),exp 就容易溢出。大家想想,当这个 X 等于 1000…就得到 NaN(Not a Number)。…神经网络就崩溃了。

我们来看一个示例,如果这个元素是 1000、2000、3000。…exp(3000) 一下子超出它的表达范围了,我们就返回 NaN。

对于这个数值稳定性的处理也有个很简单的解决办法:

Sj=eaj+Dk=1Neak+DS _ {j} = \frac {e ^ {a _ {j} + D}}{\sum_ {k = 1} ^ {N} e ^ {a _ {k} + D}}

我们可以取这个数组中的最大数 D=max(a1,...,aN)D = - \max(a_1, ..., a_N),把每个元素都减去它的最大值,减完之后…全是负数了,最大等于 0。exp 一个负数…就是一个 0 到 1 之间的数,做归一化,那就很稳定了。

回到我们刚才那个例子,输入是 1000、2000、3000,把它全部都减 3000,就变成了-2000、-1000、0。再做 exp,…所以那个概率就是 0, 0, 1,算的就又对又好。

Softmax CUDA 实现

Softmax 在 CUDA 上怎么实现的?输入是 [N,C][N, C]…我们要在 C 这个维度上做一次 Softmax。

当这个 C 比较小的时候(比如 10),我们完全可以启动一个 kernel,启动 N 个 thread,在每个 thread 就负责这一行(C 个元素)的 Softmax 运算。

当这个 C 比较大的时候…我们讲解的是一个更加一般的一个方法:

  1. Compute max: 用 reduce 操作,在每一行都算一个最大值。
  2. Subtract: 把每一行都减去这个最大值(Map 操作)。
  3. Compute exponent: 对每一个元素都算一下 exp(Map 操作)。
  4. Sum: 算出这些 exp 的总的和(Reduce 操作)。
  5. Normalize: 把这个归一化的这个数除到每个元素上(Map 操作)。

简简单单的 Softmax,我们要分五步来实现。

The Gradient of Softmax

Backward 怎么办呢?

…经过推导:

我们需要进行前向运算之后,把这个概率 pp 就存下来,在 backward 的时候把这些一组合,按照这个公式就算出了它的那个导数。

Cross-Entropy Loss

光有 Softmax 它只是出了个概率。我们真要训练神经网络的话,我们是不是还有个损失函数?这个损失函数一般是用 Cross-Entropy。

H(y,p)=iyilog(pi)H (y, p) = - \sum_ {i} y _ {i} \log \left(p _ {i}\right)

Cross-Entropy 可以当成是预测出来的概率和 ground truth 概率之间的某一种概率的距离。

Derivative of Cross-Entropy Loss with Softmax

这个 Softmax with Cross-Entropy 在 PyTorch 里边,它专门有一个类。他是把两者的 backward 合为一体的。

我们来直接算一下这个 LL 对于神经网络的输出 oio_i 的偏导数。

…最终经过整理整理出来这样一个结果:

Loi=piyi\frac{\partial L}{\partial o_i} = p_i - y_i

它貌似是神经网络的两层,但是我们把两层合成一层,他算导数的时候那更简单了。

本来我是要给了 oo 做一次 Softmax,得到了 pp(概率),再经过一次 Cross-Entropy 得到了 LL(损失)。我们其实神经网络在实现的过程中,是直接一步到位…你推出来这个 LLoo 的偏导数更简洁,所以有代码在实现的时候就应该这样实现。

以及这个 loss 层它的偏导数来自于哪里呢?…我们可以…把这个 loss 层和普通层都统一对待。我们认为 loss 层它前面没有接任何东西的时候,它就是最后一层。…它来自上层的偏导数等于 1。因此他在做 back-propagation 的时候,LL=1\frac{\partial L}{\partial L} = 1

如果我们不是训练阶段,在测试阶段…直接就按照前面这个 Softmax 的运算就算出概率就行了。在训练阶段我们推荐用 Cross-Entropy with Softmax 这两个合在一起的这样一个损失函数。


总结与转置卷积 (Transposed Convolution)#

讲到这里我们整理一下。我们…讲了全连接层,…讲了卷积层。全连接层就推荐大家用 GEMM。卷积层你也 reduce to 用 im2col,reduce to GEMM。然后 max pooling 就启动 kernel。最后…也讲了讲这个 Softmax 的数值稳定问题,以及在算 loss 的过程中,我们可以把 Cross-Entropy 和 Softmax 合并起来。

最后还有一点点时间…给大家讲讲这个 Transposed Convolution(转置卷积)。

我们前面讲的是这个降采样的时候有两种手段。一种是做 Max Pooling…然后如果用这个步长大于一的卷积…也能够做这个降采样。

那怎么上采样呢?

  1. Pooling 的 backward 可以被称为 Unpooling,它可以用来做上采样。
  2. 同理,这个 stride 等于 2 的卷积的 backward 也能用于上采样。

backward 是把一个小梯度、小分辨率变成大分辨率了。如果我们把这个 backward 当 forward 来运算来使用…它就是做了一个上采样。

所以反卷积(Deconvolution),或者说被称为 Transposed Convolution,也可以用来用于这个神经网络的上采样。

为什么大家不太喜欢把它称为 Deconvolution?因为 Deconvolution(反卷积)在那个信号处理里边有专门的一种做法…容易和信号处理的混淆,所以大家把它称为 Transposed Convolution。

好,讲完了。

附录:提示词和模型#

根据以上转录稿以及PPT的内容,帮我整理一份文字清晰、术语准确、与原文对应的转录稿。原文本中有非常多识别错误,需要你结合上下文进行猜测。首先确定文本主题,然后想想都有哪些关键词,最后想一想原文中错误的字和哪个关键词相似,然后替换。整理过程中,你还需要去除重复的话和语气词。

将转录文本和经过 MinerU 处理的课件放入提示词中。

模型:Gemini 2.5 Pro.