文章目录
-
- 一、从全连接到卷积
-
- [1.1 当你试图用MLP来处理图像](#1.1 当你试图用MLP来处理图像)
- [1.2 两大直觉](#1.2 两大直觉)
- [1.3 数学推导](#1.3 数学推导)
-
- [1.3.1 回顾最简单的 1D 全连接层 (MLP)](#1.3.1 回顾最简单的 1D 全连接层 (MLP))
- [1.3.2 把 1D 公式升级为 2D(处理图像)](#1.3.2 把 1D 公式升级为 2D(处理图像))
- [1.3.3 从"绝对位置"到"相对偏移"](#1.3.3 从“绝对位置”到“相对偏移”)
- [1.3.4 两大直觉的优化](#1.3.4 两大直觉的优化)
- [1.3.5 通道 (Channel)](#1.3.5 通道 (Channel))
- [1.4 卷积层代码实现](#1.4 卷积层代码实现)
- [1.5 填充和步幅](#1.5 填充和步幅)
-
- [1.5.1 动机](#1.5.1 动机)
- [1.5.2 填充](#1.5.2 填充)
- [1.5.3 步幅](#1.5.3 步幅)
- [1.5.4 形状计算公式](#1.5.4 形状计算公式)
- [1.5.5 代码实现](#1.5.5 代码实现)
- [1.6 多个输入和输出通道](#1.6 多个输入和输出通道)
-
- [1.6.1 为什么需要多通道](#1.6.1 为什么需要多通道)
- [1.6.2 多输入通道](#1.6.2 多输入通道)
- [1.6.3 多输出通道](#1.6.3 多输出通道)
- [1.6.4 1×1卷积层](#1.6.4 1×1卷积层)
- [1.7 池化层](#1.7 池化层)
-
- [1.7.1 为什么需要池化层?](#1.7.1 为什么需要池化层?)
- [1.7.2 最大池化 vs 平均池化](#1.7.2 最大池化 vs 平均池化)
- [1.7.3 填充、步幅与多通道](#1.7.3 填充、步幅与多通道)
- [1.8 LeNet](#1.8 LeNet)
-
- [1.8.1 为什么我们需要卷积神经网络?](#1.8.1 为什么我们需要卷积神经网络?)
- [1.8.2 LeNet 的网络结构解析](#1.8.2 LeNet 的网络结构解析)
- [1.8.3 代码实现(pytorch)](#1.8.3 代码实现(pytorch))
一、从全连接到卷积
1.1 当你试图用MLP来处理图像
假设我们有一张 1000 x 1000 像素的高清猫咪照片,它是彩色的(RGB 3个通道),所以输入特征有 1000 × 1000 × 3 = 300 万 1000 \times 1000 \times 3 = 300万 1000×1000×3=300万 个维度。
如果我们用全连接层(多层感知机 MLP)来处理:
哪怕隐藏层只设置 1000 个神经元,这一层 的权重参数量就会高达:
3,000,000 (输入) × 1000 (输出) = 30亿 个参数!
后果:
- 内存不够:几张哪怕最顶级的显卡也存不下这么大的模型。
- 极其容易过拟合:模型太复杂了,它宁愿把你家猫的每一根毛死死记住,也学不会什么是"猫的通用特征"。
1.2 两大直觉
《寻找沃尔多》游戏画面
为了压缩参数,科学家从人类玩"找茬"或"寻找沃尔多"的游戏中获得了两个伟大的直觉(先验假设/归纳偏置):
1. 平移不变性 (Translation Invariance)
- 猫就是猫,不管它在图片的左上角还是右下角,它看起来都一样。
- 启发:我们不需要为"左上角的猫"和"右下角的猫"分别学习两套参数。我们可以打造一个**"通用的猫耳探测器"**,让它在整张图片上滑动扫描。
2. 局部性 (Locality)
- 为了认出这是一只猫的耳朵,我根本不需要看远在天边的像素(比如背景里的树叶)。我只需要看猫耳附近的那一小块区域就够了。
- 启发 :神经元不需要连接全图的所有像素,它只需要连接一个很小的局部窗口(比如 3x3 像素)。
1.3 数学推导
1.3.1 回顾最简单的 1D 全连接层 (MLP)
假设我们处理的不是图片,而是一维的数据(比如房价预测的 5 个特征)。
- 输入是一个一维向量: x = [ x 1 , x 2 , x 3 , x 4 , x 5 ] \mathbf{x} = [x_1, x_2, x_3, x_4, x_5] x=[x1,x2,x3,x4,x5]
- 隐藏层的输出也是一个一维向量: h = [ h 1 , h 2 , h 3 ] \mathbf{h} = [h_1, h_2, h_3] h=[h1,h2,h3]
计算隐藏层的第 i i i 个神经元 h i h_i hi 的公式非常简单:
h i = b i + ∑ k W i , k ⋅ x k h_i = b_i + \sum_k W_{i, k} \cdot x_k hi=bi+k∑Wi,k⋅xk
- x k x_k xk 是输入的第 k k k 个数据点。
- W i , k W_{i, k} Wi,k 是连接"输入 k k k"和"输出 i i i"的权重。
- 把所有的输入 x k x_k xk 乘以它们各自的权重 W i , k W_{i, k} Wi,k,加起来,再加上一个偏置 b i b_i bi,就得到了 h i h_i hi。
1.3.2 把 1D 公式升级为 2D(处理图像)
现在,我们要处理的是图片 。图片不是一维的线条,而是二维的矩阵。
- 输入图片是一个矩阵 X \mathbf{X} X。它的像素位置用行和列 ( k , l ) (k, l) (k,l) 来表示,即 [ X ] k , l [\mathbf{X}]_{k, l} [X]k,l。
- 隐藏层输出也是一个矩阵 H \mathbf{H} H(为了方便理解,假设输出矩阵和输入图片一样大)。它的像素位置用 ( i , j ) (i, j) (i,j) 来表示,即 [ H ] i , j [\mathbf{H}]_{i, j} [H]i,j。
所以,权重 W W W 不能是二维矩阵了,它必须升级成一个四维的超级张量(Tensor) ,我们叫它 W i , j , k , l \mathsf{W}_{i, j, k, l} Wi,j,k,l。
- W i , j , k , l \mathsf{W}_{i, j, k, l} Wi,j,k,l 的意思是:连接"输入图片位置 ( k , l ) (k, l) (k,l)"和"输出图片位置 ( i , j ) (i, j) (i,j)"的权重。
按照前面的逻辑,我们写出二维图片的 MLP 公式:
H \] i , j = \[ U \] i , j + ∑ k ∑ l W i , j , k , l ⋅ \[ X \] k , l \[\\mathbf{H}\]_{i, j} = \[\\mathbf{U}\]_{i, j} + \\sum_k \\sum_l \\mathsf{W}_{i, j, k, l} \\cdot \[\\mathbf{X}\]_{k, l} \[H\]i,j=\[U\]i,j+k∑l∑Wi,j,k,l⋅\[X\]k,l *(注: \[ U \] i , j \[\\mathbf{U}\]_{i, j} \[U\]i,j 就是偏置项,对应上面的 b i b_i bi。)* ##### 1.3.3 从"绝对位置"到"相对偏移" 在看图片时,我们更关心的是**相对坐标(偏移量)** 。 对于输出位置 ( i , j ) (i, j) (i,j),我们其实关心的是"它正上方的一个像素"、"它右下角的一个像素",而不是"全图第 5 行第 10 列的像素"。 我们定义两个新变量: * a a a:行偏移量(比如 a = − 1 a=-1 a=−1 代表上一行) * b b b:列偏移量(比如 b = 1 b=1 b=1 代表右一列) 那么,输入图片的位置 ( k , l ) (k, l) (k,l),就可以改写为输出位置 ( i , j ) (i, j) (i,j) 加上偏移量: * k = i + a k = i + a k=i+a * l = j + b l = j + b l=j+b 原先的权重 W i , j , k , l \\mathsf{W}_{i, j, k, l} Wi,j,k,l 可以写做 V i , j , a , b \\mathsf{V}_{i, j, a, b} Vi,j,a,b。 公式变为: \[ H \] i , j = \[ U \] i , j + ∑ a ∑ b V i , j , a , b ⋅ \[ X \] i + a , j + b \[\\mathbf{H}\]_{i, j} = \[\\mathbf{U}\]_{i, j} + \\sum_a \\sum_b \\mathsf{V}_{i, j, a, b} \\cdot \[\\mathbf{X}\]_{i+a, j+b} \[H\]i,j=\[U\]i,j+a∑b∑Vi,j,a,b⋅\[X\]i+a,j+b **注意:到这一步为止,数学上没有任何删减,它依然是一个拥有几十亿参数、庞大且笨重的全连接层(MLP)。只是写法变了。** ##### 1.3.4 两大直觉的优化 **平移不变性 (Translation Invariance)** * **物理意义** :不论你站在图片的哪个位置 ( i , j ) (i, j) (i,j) 去找猫耳,你往偏移方向 ( a , b ) (a, b) (a,b) 看去的"判定标准(权重)"应该是一模一样的。 * **数学操作** :权重 V \\mathsf{V} V 根本不需要关心当前的绝对位置 ( i , j ) (i, j) (i,j),所以下标i,j可以删去。 * V i , j , a , b \\mathsf{V}_{i, j, a, b} Vi,j,a,b ,变成了只有偏移量的二维矩阵 V a , b \\mathbf{V}_{a, b} Va,b。偏置 \[ U \] i , j \[\\mathbf{U}\]_{i, j} \[U\]i,j 也变成了一个常数 u u u。 公式变成了: \[ H \] i , j = u + ∑ a ∑ b V a , b ⋅ \[ X \] i + a , j + b \[\\mathbf{H}\]_{i, j} = u + \\sum_a \\sum_b \\mathbf{V}_{a, b} \\cdot \[\\mathbf{X}\]_{i+a, j+b} \[H\]i,j=u+a∑b∑Va,b⋅\[X\]i+a,j+b *(即全图卷积,参数量从几十亿降到了几百万)* **局部性 (Locality)** * **物理意义** :为了判断某个点是不是猫耳,我只需要看它附近的一小圈就行了,不需要看偏移量 a , b a,b a,b 非常大的像素。 * **数学操作** :限制偏移量 a a a 和 b b b 的范围,它们不能无限大,只能在一个很小的窗口 − Δ -\\Delta −Δ 到 Δ \\Delta Δ 之间取值(比如 Δ = 1 \\Delta=1 Δ=1 时,就是一个 3 × 3 3 \\times 3 3×3 的九宫格)。(即远处的权重强制规定为 0,直接不看了) 最终公式: \[ H \] i , j = u + ∑ a = − Δ Δ ∑ b = − Δ Δ V a , b ⋅ \[ X \] i + a , j + b = u + V ∗ X , ∗ 是二维交叉运算操作子 \[\\mathbf{H}\]_{i, j} = u + \\sum_{a=-\\Delta}\^{\\Delta} \\sum_{b=-\\Delta}\^{\\Delta} \\mathbf{V}_{a, b} \\cdot \[\\mathbf{X}\]_{i+a, j+b} = u + V \* X,\*是二维交叉运算操作子 \[H\]i,j=u+a=−Δ∑Δb=−Δ∑ΔVa,b⋅\[X\]i+a,j+b=u+V∗X,∗是二维交叉运算操作子 * 参数V,u 均可以梯度下降 这就是\*\*卷积神经网络(CNN)\*\*的原理。 ##### 1.3.5 通道 (Channel) 真实的图片是 RGB 三彩的,而且我们不可能只找"猫耳",我们还要找"猫眼"、"猫胡须"、"垂直边缘"、"水平边缘"。所以必须引入\*\*通道(Channel)\*\*的概念。 1. **输入通道(Input Channels)** :RGB 图片有 3 个通道。所以我们的卷积核不能是薄薄的一片 2D 矩阵,必须是厚厚的 3D 积木(例如 3 × 3 × 3 3 \\times 3 \\times 3 3×3×3),和输入通道**一样厚**,一下把红绿蓝三个通道的信息都混合起来。 2. **输出通道(Output Channels)**:如果我们想要提取 64 种不同的特征(64种探测器),我们就准备 64 个这种 3D 积木。算完之后,输出的隐藏层就会有 64 层厚(称为特征图 Feature Maps)。 最终卷积核变成了 4D 张量:**\[核高, 核宽, 输入通道数, 输出通道数\]**。 > 《DIVE INTO DEEP LEARNING》中的一些问题: > > **1. 假设局部区域 Δ = 0 \\Delta = 0 Δ=0,证明卷积层为每组通道独立地实现一个全连接层。** > > * 当 Δ = 0 \\Delta = 0 Δ=0 时,卷积核的大小是 1 × 1 1 \\times 1 1×1。它不再看周围的像素,只盯着当前坐标 ( i , j ) (i,j) (i,j) 这一个点看。它做的事情,就是把这个点在不同通道(红绿蓝等)上的数值,乘以权重加起来。这在数学上完全等价于:**把每个像素点看作一个独立的样本,对其通道维度做了一次普通的全连接层计算(多层感知机)** 。这就是著名的 **1x1卷积**,常用来做通道数的降维或升维。 > > **2. 为什么平移不变性可能也不是好主意呢?** > > * 因为有些任务**极其依赖绝对位置信息** 。 > 比如**人脸识别** :眼睛永远在上面,嘴巴永远在下面。如果你的网络彻底平移不变,它可能会认为"嘴巴长在额头上"的人也是正常人。 > 比如**医学图像** :肺部上半部分出现的阴影和下半部分的阴影,医学诊断可能完全不同。 > (所以后来的 Vision Transformer (ViT) 会强行加入"位置编码"来弥补这个缺陷)。 > > **3. 当从图像边界像素获取隐藏表示时,我们需要思考哪些问题?** > > * 当 3x3 的卷积核滑到图像最边缘(比如左上角点)时,它的左边和上边已经没有像素了! > 这会导致:1. 边缘信息丢失;2. 输出的图像尺寸会变小。 > **解决方案** :我们需要在图像边缘人为地补一圈"假像素"(通常补 0),这个操作叫做 **Padding(填充)**。 > > **4. 描述一个类似的音频卷积层的架构。** > > * 图像是 2D 的(有高和宽),所以用 2D 卷积。而音频是一维的时间序列(一段声波)。所以音频卷积层应该是 **1D 卷积(一维卷积)**。它的卷积核是一个短线条,只沿着时间轴从左向右单向滑动,去捕捉局部的音频模式(比如某个音节)。 > > **5. 卷积层也适合于文本数据吗?为什么?** > > * **非常适合!** 文本和音频一样,也是一维的词序列。 > * **满足局部性**:几个相邻的词往往构成一个短语(N-gram,如"深度/学习"),理解短语只需要看这几个词,不需要看隔了 100 个词的句尾。 > * **满足平移不变性** :"太棒了"这个词汇,无论出现在句首还是句尾,表达的情感特征是相似的。 > 所以 1D-CNN 早期在文本分类(如情感分析)任务上大放异彩。 > > **6. 证明 f ∗ g = g ∗ f f \* g = g \* f f∗g=g∗f。** > > * 数学推导: > ( f ∗ g ) ( i ) = ∑ a f ( a ) g ( i − a ) (f \* g)(i) = \\sum_a f(a) g(i-a) (f∗g)(i)=∑af(a)g(i−a) > 设一个新的变量 k = i − a k = i - a k=i−a,那么 a = i − k a = i - k a=i−k。 > 由于求和域是无限的(或完整的),遍历所有的 a a a 就等同于遍历所有的 k k k。 > 代入得: ∑ k f ( i − k ) g ( k ) = ∑ k g ( k ) f ( i − k ) \\sum_k f(i-k) g(k) = \\sum_k g(k) f(i-k) ∑kf(i−k)g(k)=∑kg(k)f(i−k) > 这正是 ( g ∗ f ) ( i ) (g \* f)(i) (g∗f)(i) 的定义,证明完毕。(这也说明在数学上,信号和滤波器是谁卷谁都一样)。 #### 1.4 卷积层代码实现 ```python import torch from torch import nn from d2l import torch as d2l ``` **二维交叉运算** * 值得注意的是,pytorch中,\* 做的是 阿达玛乘积 ```python def corr2d(X, K): '''二维互相关运算''' h, w = K.shape Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1)) for i in range(Y.shape[0]): for j in range(Y.shape[1]): Y[i, j] = (X[i: i + h, j: j + w] * K).sum() return Y ``` ```python class Conv2D(nn.Module): def __init__(self, kernal_size): super().__init__() self.weight = nn.Parameter(torch.rand(kernal_size)) self.bias = nn.Parameter(torch.zeros(1)) def forward(self, x): return corr2d(x, self.weight) + self.bias ``` **手造一个小数据来检测物体边缘** ```python X = torch.ones((6, 8)) X[:, 2: 6] = 0 X ``` **输出** tensor([[1., 1., 0., 0., 0., 0., 1., 1.], [1., 1., 0., 0., 0., 0., 1., 1.], [1., 1., 0., 0., 0., 0., 1., 1.], [1., 1., 0., 0., 0., 0., 1., 1.], [1., 1., 0., 0., 0., 0., 1., 1.], [1., 1., 0., 0., 0., 0., 1., 1.]]) **只能检测竖直边缘线的卷积核** ```python K = torch.tensor([[1., -1.]]) K ``` **输出:** tensor([[ 1., -1.]]) **学习卷积核** ```python conv2d = nn.Conv2d(1, 1, kernel_size = (1, 2), bias = False) X = X.reshape((1, 1, 6, 8)) Y = Y.reshape((1, 1, 6, 7)) for i in range(10): Y_hat = conv2d(X) l = (Y_hat - Y)**2 conv2d.zero_grad() l.sum().backward() conv2d.weight.data[:] -= 3e-2 * conv2d.weight.grad if (i + 1) % 2 == 0: print(f'batch {i+1}, loss = {l.sum():.3f}') ``` **输出:** batch 2, loss = 5.405 batch 4, loss = 1.545 batch 6, loss = 0.521 batch 8, loss = 0.194 batch 10, loss = 0.076 ```python conv2d.weight.data.reshape((1, 2)) ``` **输出:** tensor([[ 0.9632, -1.0195]]) #### 1.5 填充和步幅 ##### 1.5.1 动机 标准的卷积运算中,存在两个致命的痛点: 1. **图像越卷越小** :假设输入是 3 × 3 3\\times3 3×3,卷积核是 2 × 2 2\\times2 2×2,输出就变成了 2 × 2 2\\times2 2×2。如果图像是 240 × 240 240\\times240 240×240,经过 10 层 5 × 5 5\\times5 5×5 的卷积后,就缩水成 200 × 200 200\\times200 200×200 了。 2. **边缘信息丢失** :图像边缘的像素(比如左上角的点),卷积核只扫到了一次;而图像中间的像素,卷积核滑过时会被反复计算。**这意味着标准卷积会"忽视"图像边缘的有用信息。** 3. **计算量太大** :有时候输入图像非常大(比如 4 K 4K 4K 高清图),像素非常冗余,如果一格一格滑,计算量会爆炸。 **为了解决前两个问题,我们引入了"填充(Padding)";为了解决第三个问题,我们引入了"步幅(Stride)"。** > 填充和步幅均为卷积层的超参数 ##### 1.5.2 填充 **即** ,在输入图像的四周,人为地补上一圈(或多圈)数字,通常补的是 `0`。 **效果**:  看文档里的 原来 3 × 3 3\\times3 3×3 的图像,四周各补一圈 0,变成了 5 × 5 5\\times5 5×5。这时再用 2 × 2 2\\times2 2×2 的卷积核去滑,输出就变成了 4 × 4 4\\times4 4×4。不仅没变小,反而变大了。 在实际写代码时,我们通常希望**输入图像和输出图像的尺寸保持完全一致** (比如输入 32 × 32 32\\times32 32×32,输出还是 32 × 32 32\\times32 32×32),这样方便网络层数的叠加。 为了达到这个目的,有一个固定套路: 1. **卷积核大小(Kernel Size)通常选奇数** :比如 3 × 3 3\\times3 3×3、 5 × 5 5\\times5 5×5、 7 × 7 7\\times7 7×7。 * k h ∗ k w k_h \* k_w kh∗kw 2. 行填充大小和列填充大小通常分别设置为卷积核的高 - 1、宽 - 1 * p h = k h − 1 , p w = k w − 1 p_{h} = k_{h} - 1,p_{w} = k_{w} - 1 ph=kh−1,pw=kw−1 * 如果 k h k_h kh 为奇数,在上下填充 $\\left \\lfloor p_h/2 \\right \\rfloor $ * 如果 k h k_h kh 为偶数,在下侧填充 $\\left \\lfloor p_h/2 \\right \\rfloor $,在上侧填充 $\\left \\lceil p_h/2 \\right \\rceil $ ##### 1.5.3 步幅 **做法** :卷积核原来是每次向右/向下移动 1 格。现在我们让它每次移动 2 2 2 格、 3 3 3 格甚至更多。这个每次移动的格数,就是**步幅(stride)**。 **极大地降低输出图像的尺寸(降维/下采样)**,从而成倍地减少计算量。 通常我们将步幅设为 `2`。 如果步幅为 `2`,图像的宽和高都会变成原来的一半,整体特征图的面积就变成了原来的 **四分之一**。这在处理大图像时非常高效。 ##### 1.5.4 形状计算公式 给定高度 s h s_h sh和宽度 s w s_w sw的步幅,输出形状是 ⌊ ( n h − k h + p h + s h ) / s h ⌋ × ⌊ ( n w − k w + p w + s w ) / s w ⌋ \\left \\lfloor (n_h - k_h + p_h + s_h) / s_h \\right \\rfloor \\times \\left \\lfloor (n_w - k_w + p_w + s_w) / s_w \\right \\rfloor ⌊(nh−kh+ph+sh)/sh⌋×⌊(nw−kw+pw+sw)/sw⌋ > 推导非常简单,算一下边界下标就行了 如果 p h = k h − 1 , p w = k w − 1 p_h = k_h - 1,p_w = k_w - 1 ph=kh−1,pw=kw−1 ⌊ ( n h + s h − 1 ) / s h ⌋ × ⌊ ( n w + s w − 1 ) / s w ⌋ \\left \\lfloor (n_h + s_h - 1) / s_h \\right \\rfloor \\times \\left \\lfloor (n_w + s_w - 1) / s_w \\right \\rfloor ⌊(nh+sh−1)/sh⌋×⌊(nw+sw−1)/sw⌋ 如果输入高度和宽度都可以被步幅整除 ( n h / s h ) × ( n w / s w ) (n_h / s_h) \\times (n_w / s_w) (nh/sh)×(nw/sw) ##### 1.5.5 代码实现 **在所有侧边填充1个像素** ```python import torch from torch import nn def comp_conv2d(conv2d, X): X = X.reshape((1, 1) + X.shape) Y = conv2d(X) return Y.reshape(Y.shape[2:]) conv2d = nn.Conv2d(1, 1, kernel_size = 3, padding = 1) X = torch.rand(size = (8, 8)) comp_conv2d(conv2d, X).shape ``` * 卷积核大小为3\*3,上下左右各填充一排0 输出: torch.Size([8, 8]) **填充不同的高度和宽度** ```python conv2d = nn.Conv2d(1, 1, kernel_size = (5, 3), padding = (2, 1)) comp_conv2d(conv2d, X).shape ``` * 卷积核大小5\*3,左右各填充2排0,上下各填充1排0 输出: torch.Size([8, 8]) **将高度和宽度的步幅设置为2** ```python conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2) comp_conv2d(conv2d, X).shape ``` 输出: torch.Size([4, 4]) **稍微复杂一点的例子** ```python conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4)) comp_conv2d(conv2d, X).shape ``` 输出: torch.Size([2, 2]) #### 1.6 多个输入和输出通道 > 输出通道数是卷积层的另一个超参数 ##### 1.6.1 为什么需要多通道 在之前的学习中,我们处理的都是**二维张量** (单通道),比如黑白图像。 但现实中,数据要丰富得多: * **彩色图像**:包含红(R)、绿(G)、蓝(B)三个通道。 * **网络深层特征**:随着网络加深,图像会被提取出边缘、纹理、形状等各种特征,这些特征在网络中也就是以"通道"的形式存在的。 因此,我们的输入不再是一个二维矩阵,而是变成了**三维张量** :**通道数 × \\times × 高度 × \\times × 宽度** ( c × h × w c \\times h \\times w c×h×w)。 ##### 1.6.2 多输入通道 当输入数据有多个通道(比如 c i = 3 c_i = 3 ci=3 的RGB图像)时,我们的卷积核也要随之变化。 **1. 卷积核形状的变化** * **规则** :卷积核的"输入通道数"必须和输入数据的"通道数"**严格相等**。 * **形状** :卷积核从原来的二维矩阵 k h × k w k_h \\times k_w kh×kw,变成了三维张量 c i × k h × k w c_i \\times k_h \\times k_w ci×kh×kw。 **2. 运算过程** * **按通道配对**:输入数据的第1个通道,只和卷积核的第1个通道做二维互相关运算;第2个配第2个......以此类推。 * **按通道相加** :各个通道算完后,会得到 c i c_i ci 个二维矩阵。把这 c i c_i ci 个矩阵**按元素加在一起** ,最终只输出**一个二维矩阵**。 **简单实现:** ```python from d2l import torch as d2l import torch def corr2d_multi_in(X, K): # 先遍历"X"和"K"的第0个维度(通道维度),再把它们加在一起 return sum(d2l.corr2d(x, k) for x, k in zip(X, K)) X = d2l.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]]) K = d2l.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]]) corr2d_multi_in(X, K) ``` 输出: tensor([[ 56., 72.], [104., 120.]]) ##### 1.6.3 多输出通道 很多时候,我们需要提取图像的**多种特征** (比如有的提取水平边缘,有的提取颜色渐变)。这就需要**多输出通道**。 **1. 如何实现多输出通道?** * **规则** :想要几个输出通道(设为 c o c_o co),就准备几个刚才那种"多输入通道的卷积核"。 * **终极卷积核形状** :变成了**四维张量** : c o × c i × k h × k w c_o \\times c_i \\times k_h \\times k_w co×ci×kh×kw (输出通道数 × \\times × 输入通道数 × \\times × 核高 × \\times × 核宽)。 **2. 运算过程** * 每个输出通道的计算,都是将**整个输入数据** 与**属于该输出通道的那组卷积核**进行运算。 * 最后把算出来的 c o c_o co 个二维结果叠在一起,输出形状变成了 c o × h ′ × w ′ c_o \\times h' \\times w' co×h′×w′。 **代码实现:** ```python def corr2d_multi_in_out(X, K): # 迭代"K"的第0个维度,每次都对输入"X"执行互相关运算。 # 最后将所有结果都叠加在一起 return d2l.stack([corr2d_multi_in(X, k) for k in K], 0) K = d2l.stack((K, K + 1, K + 2), 0) K.shape corr2d_multi_in_out(X, K) ``` tensor([[[ 56., 72.], [104., 120.]], [[ 76., 100.], [148., 172.]], [[ 96., 128.], [192., 224.]]]) ##### 1.6.4 1×1卷积层 1. **它不跨像素,只跨通道** : 1 × 1 1 \\times 1 1×1 卷积的本质是**对同一位置的所有通道像素进行一次线性组合**。 2. **等价于全连接层** :如果你把每个像素位置(包含 c i c_i ci 个通道的值)看作一个特征向量,那么 1 × 1 1 \\times 1 1×1 卷积就是一个输入节点数为 c i c_i ci,输出节点数为 c o c_o co 的全连接层! 3. **核心作用:降维与升维** :它可以兵不血刃地改变通道数量。比如输入是 256 通道,用 64 个 1 × 1 1 \\times 1 1×1 的卷积核,就能把通道数降到 64,大幅减少后续计算量。这在 ResNet、Inception 等经典网络中是标准操作。 ```python def corr2d_multi_in_out_1x1(X, K): c_i, h, w = X.shape c_o = K.shape[0] X = d2l.reshape(X, (c_i, h * w)) K = d2l.reshape(K, (c_o, c_i)) # 全连接层中的矩阵乘法 Y = d2l.matmul(K, X) return d2l.reshape(Y, (c_o, h, w)) X = d2l.normal(0, 1, (3, 3, 3)) K = d2l.normal(0, 1, (2, 3, 1, 1)) Y1 = corr2d_multi_in_out_1x1(X, K) Y2 = corr2d_multi_in_out(X, K) assert float(d2l.reduce_sum(d2l.abs(Y1 - Y2))) < 1e-6 ``` > 一些课后题 > > **1. 两个连续卷积核 k 1 , k 2 k_1, k_2 k1,k2(无非线性激活)等价于一个单次卷积吗?** > > * **是的**。因为卷积运算本质是线性组合。两个线性操作的嵌套仍然是线性操作。 > * **维数** :假设 k 1 k_1 k1 大小是 h 1 × w 1 h_1 \\times w_1 h1×w1, k 2 k_2 k2 是 h 2 × w 2 h_2 \\times w_2 h2×w2,等效卷积核的大小通常是 ( h 1 + h 2 − 1 ) × ( w 1 + w 2 − 1 ) (h_1+h_2-1) \\times (w_1+w_2-1) (h1+h2−1)×(w1+w2−1)。 > * **反之亦然吗?** 不一定。一个大的卷积核不一定能分解为两个小的卷积核连乘(相当于矩阵分解不一定存在实数解)。 > > **2. 计算成本与内存占用(假设输出特征图大小为 h o u t × w o u t h_{out} \\times w_{out} hout×wout)** > > * **计算成本 (FLOPs)** :一次输出元素的计算需要 c i × k h × k w c_i \\times k_h \\times k_w ci×kh×kw 次乘加。总输出有 c o × h o u t × w o u t c_o \\times h_{out} \\times w_{out} co×hout×wout 个元素。因此总计算量约为: O ( c o × c i × k h × k w × h o u t × w o u t ) O(c_o \\times c_i \\times k_h \\times k_w \\times h_{out} \\times w_{out}) O(co×ci×kh×kw×hout×wout)。 > * **内存占用** :包含参数量( c o × c i × k h × k w c_o \\times c_i \\times k_h \\times k_w co×ci×kh×kw)+ 输出特征图激活值( c o × h o u t × w o u t c_o \\times h_{out} \\times w_{out} co×hout×wout)+ 优化器状态等。 > > **3. 通道数翻倍,计算量如何变化?** > > * 看上一题的公式,计算量里包含了 c o × c i c_o \\times c_i co×ci。如果两者都翻倍,计算量会变成原来的 2 × 2 = 4 2 \\times 2 = \\mathbf{4} 2×2=4 **倍** !这也是为什么设计网络时通道数不能随便乱加的原因。填充翻倍对输出大小有影响,计算量增加取决于 h o u t × w o u t h_{out} \\times w_{out} hout×wout 增加的比例。 > > **4. 1 × 1 1 \\times 1 1×1 卷积的计算复杂度?** > > * 代入上面的公式,令 k h = k w = 1 k_h=k_w=1 kh=kw=1,复杂度骤降为: O ( c o × c i × h × w ) O(c_o \\times c_i \\times h \\times w) O(co×ci×h×w)。这也是为什么常用它来降维减小计算量的原因。 > > **5. 非 1 × 1 1 \\times 1 1×1 卷积如何用矩阵乘法实现?** > > * 使用一种叫 **`im2col` (Image to Column)** 的技术。 > > * 把输入图像中**每个**卷积窗口滑动覆盖到的区域,强行"拉平"成一个一维向量,组合成一个大矩阵。 > > * 虽然极大的浪费了内存,但是现代飞快的矩阵乘法省下的时间还是很划算的 > * 而且,现在有隐式矩阵乘法,也不一定真的要拉显存把所有滑窗存下来 > * 把多输出通道的卷积核也拉平。 > > * 然后做一次大规模的矩阵乘法,最后再把结果 `col2im` 变回图像形状。这样可以极大地利用 GPU 的矩阵运算加速能力(如 cuBLAS 库)。 #### 1.7 池化层 ##### 1.7.1 为什么需要池化层? 在用卷积层提取特征后,面临两个现实痛点: 1. **"只见树木,不见森林"** 当我们判断一张图"是不是猫"时,底层的卷积核只能看到"一根胡须"或"一块毛皮"。要做出全局判断,高层神经元必须能"看"到整张图片。如果全靠卷积层硬算,计算量会爆炸。我们需要一种方法,**把图像缩小,浓缩信息**,让后续的层能一眼看到更大的区域。 2. **对位置过于敏感(缺乏平移不变性)** 假设你在拍一只猫,手稍微抖了一下,猫的边缘在照片上向右移动了 1 个像素。对于死板的卷积核来说,这可能完全变成了另一种输入,导致识别失败。我们需要网络有一定的\*\*"容错率"\*\*,只要特征在附近,就能被识别出来。 **解决方案:汇聚层(池化层)** 它故意丢失一些精确的细节(到底在哪个具体像素),来换取对全局特征的把握和对位置移动的容忍度。 ##### 1.7.2 最大池化 vs 平均池化 池化层的操作和卷积层非常像,也有一个"滑动窗口",但**它没有权重参数(不需要学习)**,它是一个死板的、确定性的操作。 **1. 最大汇聚层 (Max Pooling)** * **规则** :窗口滑到一个区域,挑出这个区域里的**最大值**作为代表,其他值丢弃。 * **意义** :最大值代表了该区域内**最强烈的特征信号**(比如最明显的边缘、最亮的斑点)。 * **效果** :只要这个特征在这个 2 × 2 2 \\times 2 2×2 的小窗口里出现了,不管它在左上角还是右下角,输出的都是那个最大值。**这就完美解决了"手抖1个像素"的问题(平移不变性)** **2. 平均汇聚层 (Average Pooling)** * **规则** :窗口滑到一个区域,计算这个区域所有值的**平均值**。 * **意义** :保留了该区域的**整体背景信息或平均响应**。 * **效果**:它比较温和,通常用在网络的最后端(如 Global Average Pooling),把一整张特征图压缩成一个数字,代表这张图的整体特征。 ##### 1.7.3 填充、步幅与多通道 池化层也可以像卷积层一样设置**窗口大小 (pool_size)** 、**步幅 (stride)** 和 **填充 (padding)**,但有几个关键的区别: **1. 步幅的默认潜规则** 在卷积层中,默认步幅通常是 1(慢慢滑,一点点看)。 但在池化层中,深度学习框架(PyTorch/TensorFlow)的**默认步幅往往等于窗口大小** * 为什么?因为池化的主要目的就是**降维(缩小图片)** 。如果窗口是 2 × 2 2 \\times 2 2×2,步幅也是 2,那么窗口滑动时刚好**不重叠** ,长和宽都会直接减半,图像面积缩小为原来的 1 / 4 1/4 1/4。 **2. 多通道独立运算** 这是池化层和"多输入通道卷积层"**最大的不同**: * **卷积层** :面对 RGB 3 个通道,卷积核会把 3 个通道的结果**相加**,融合成 1 个通道(跨通道融合)。 * **池化层**:它在 R 通道做一次池化,在 G 通道做一次,在 B 通道做一次。 * **结论** :**池化层的输入有多少个通道,输出就一定有多少个通道!它绝不改变通道数,只改变特征图的高和宽。** > 一些课后题 > > **1. 尝试将平均汇聚层作为卷积层的特殊情况实现。** > > * **可以实现** > * **做法** :假设平均池化窗口是 3 × 3 3 \\times 3 3×3。我们只需要构造一个 3 × 3 3 \\times 3 3×3 的卷积核,把里面的 9 个权重参数全部固定死,设为 1 / 9 1/9 1/9(且不参与梯度更新)。这样卷积滑过的时候,刚好就是求这 9 个像素的平均值。 > > **2. 尝试将最大汇聚层作为卷积层的特殊情况实现。** > > * **不可能** > * **原因** :卷积运算的本质是**线性组合** (乘法和加法)。而"求最大值 `max()`"是一个纯粹的**非线性操作** 。你无法用任何线性矩阵乘法来等价替换 `max()` 操作。这也是为什么最大池化能为网络引入一定的非线性能力的原因之一。 > > **3. 池化层的计算成本是多少?** > > * 假设输入是 c × h × w c \\times h \\times w c×h×w,窗口 p h × p w p_h \\times p_w ph×pw,输出大小是 h o u t × w o u t h_{out} \\times w_{out} hout×wout。 > * **计算成本极低** :不需要做乘法!如果是最大池化,每个输出像素只需要做 ( p h × p w − 1 ) (p_h \\times p_w - 1) (ph×pw−1) 次比较操作。 > * 总成本约为: O ( c × h o u t × w o u t × p h × p w ) O(c \\times h_{out} \\times w_{out} \\times p_h \\times p_w) O(c×hout×wout×ph×pw) 次**比较** 或**加法**。相比于卷积层的海量乘法,池化层的计算量几乎可以忽略不计。 > > **4. 为什么最大池化和平均池化工作方式不同?(本质区别)** > > * **最大池化** 提取的是**纹理、边缘**等响应最强烈的"高频信号"。哪怕背景全黑,只有一个白点,最大池化也能敏锐捕捉到。 > * **平均池化** 提取的是**背景、色彩**等平滑的"低频信号"。它把所有信号抹匀了看。现代 CNN 中,特征提取阶段多用最大池化,最后分类前的汇总阶段多用平均池化。 > > **5. 我们需要最小汇聚层吗?可以用已知函数替换它吗?** > > * 理论上存在,可以用 `MinPool(X) = -MaxPool(-X)` 来实现(取相反数,求最大,再取反)。 > * **为什么不需要?** 因为在神经网络中(特别是 ReLU 激活后),重要的特征信号往往表现为**正的大数值**,而没有特征的背景往往是 0。我们关心的是"哪里有特征",而不是"哪里特征最弱",所以极少使用最小池化。 > > **6. 除平均和最大池化外,还有其他函数吗(回想 softmax)?为什么不流行?** > > * **有。** 比如 **LogSumExp 池化** (也叫 Softmax 池化)或 **L2 范数池化**。 > * LogSumExp 介于最大和平均之间: p o o l i n g = log ( ∑ exp ( x i ) ) pooling = \\log(\\sum \\exp(x_i)) pooling=log(∑exp(xi))。它既能突出最大值,又保留了一点其他较小值的信息。 > * **为什么不流行?** 第一,计算太复杂了,算指数 exp \\exp exp 和对数 log \\log log 在硬件上非常耗时;第二,实践证明,简单粗暴的 Max Pooling 在分类任务上的效果已经足够好了,没必要杀鸡用牛刀。 **简单的代码练习** ```python from d2l import torch as d2l import torch from torch import nn ``` **前向传播函数** ```python def pool2d(X, pool_size, mode='max'): p_h, p_w = pool_size Y = d2l.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1)) for i in range(Y.shape[0]): for j in range(Y.shape[1]): if mode == 'max': Y[i, j] = X[i: i + p_h, j: j + p_w].max() elif mode == 'avg': Y[i, j] = X[i: i + p_h, j: j + p_w].mean() return Y ``` ```python X = d2l.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]) pool2d(X, (2, 2)) ``` 输出: ```python tensor([[4., 5.], [7., 8.]]) ``` pool2d(X, (2, 2), 'avg') 输出: ```python tensor([[2., 3.], [5., 6.]]) ``` **填充和步幅** ```python X = d2l.reshape(d2l.arange(16, dtype=d2l.float32), (1, 1, 4, 4)) X ``` 输出: tensor([[[[ 0., 1., 2., 3.], [ 4., 5., 6., 7.], [ 8., 9., 10., 11.], [12., 13., 14., 15.]]]]) ```python pool2d = nn.MaxPool2d(3) # 3*3 窗口 pool2d(X) ``` 输出:**(输出一个10,因为stride 默认和池化窗口大小一致)** tensor([[[[10.]]]]) 也可以手动指定 padding 和 stride ```python pool2d = nn.MaxPool2d(3, padding=1, stride=2) pool2d(X) ``` 输出: tensor([[[[ 5., 7.], [13., 15.]]]]) 指定池化核大小为(2, 3): ```python pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1)) pool2d(X) ``` 输出: tensor([[[[ 5., 7.], [13., 15.]]]]) #### 1.8 LeNet ##### 1.8.1 为什么我们需要卷积神经网络? 在前面的章节中,我们要识别图像(比如 28 × 28 28 \\times 28 28×28 的衣服图片),做法是**把图像展平(Flatten)** ,变成一个长度为 784 的一维向量,然后丢给全连接层。 **这样做的致命缺点是:丢失了图像的空间结构。** 比如,原本在图片上相邻的两个像素,展平后可能相隔很远,网络很难学习到"边缘"、"轮廓"这样的二维空间特征。 **卷积层(Convolutional Layer)的引入解决了两个问题:** 1. **保留空间结构:** 卷积核在二维图像上滑动,能够捕捉局部的空间模式(比如水平边缘、垂直边缘)。 2. **参数更少:** 因为"权重共享"(同一个卷积核扫遍全图),参数量大大减少,模型更轻量且不易过拟合。 **LeNet 的历史地位:** 1989年由 Yann LeCun 提出,是最早的卷积神经网络之一。它首次证明了用**反向传播**训练的卷积神经网络在实际任务(识别支票上的手写数字)中是非常有效的。在当时,它的性能可以和主流的 SVM(支持向量机)一较高下。 ##### 1.8.2 LeNet 的网络结构解析 LeNet 的设计奠定了现代 CNN 的基本范式,即:**卷积层提取特征 + 全连接层进行分类**。  它可以分为两大块: 1. **卷积编码器(特征提取):** 由两个 `卷积层 + 激活函数 + 汇聚层(池化层)` 组成。 2. **全连接层密集块(分类输出):** 展平后,由三个 `全连接层` 组成。 **详细层级拆解**: * **输入:** 1 × 28 × 28 1 \\times 28 \\times 28 1×28×28 的单通道图像(灰度图)。 * **第一层(Conv1):** 使用 5 × 5 5 \\times 5 5×5 的卷积核,`padding=2`。为了提取更多特征,输出了 6 个通道。激活函数用了 Sigmoid。 * **第一层池化(Pool1):** 2 × 2 2 \\times 2 2×2 的平均池化层(AvgPool),步幅为 2。这会让图像的长宽缩小一半(降采样),目的是减小计算量,并让特征具有平移不变性。 * **第二层(Conv2):** 使用 5 × 5 5 \\times 5 5×5 的卷积核,不加 padding。通道数增加到 16 个。激活函数是 Sigmoid。 * **第二层池化(Pool2):** 同样是 2 × 2 2 \\times 2 2×2 平均池化。 * **展平(Flatten):** 将前面输出的三维特征图,拉平变成一维向量,方便喂给全连接层。 * **全连接层(Dense/Linear):** 依次经过 120个神经元 → \\rightarrow → 84个神经元 → \\rightarrow → 10个神经元(因为是10分类任务)。 *(注:90年代还没有流行 ReLU 和 MaxPool,所以 LeNet 使用了 Sigmoid 和 AvgPool。在现代网络中,通常会被 ReLU 和 MaxPool 替代。)* *** ** * ** *** ##### 1.8.3 代码实现(pytorch) ```python import torch from torch import nn from d2l import torch as d2l class Reshape(nn.Module): def forward(self, x): return x.view(-1, 1, 28, 28) net = nn.Sequential( Reshape(), # first conv nn.Conv2d(1, 6, kernel_size=5, padding=2), # ci=1, co=6, kernel=5*5, padding=2 nn.Sigmoid(), # first pool nn.AvgPool2d(kernel_size=2, stride=2), # 2*2 avgpool # second conv nn.Conv2d(6, 16, kernel_size=5), # ci=6, co=16, kernel=5*5, no padding nn.Sigmoid(), # second pool nn.AvgPool2d(kernel_size=2, stride=2), # 2*2 avgpool nn.Flatten(), # flatten for the next Linear nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(), nn.Linear(120, 84), nn.Sigmoid(), nn.Linear(84, 10), ) ``` ```python X = torch.rand(size=(1, 1, 28, 28), dtype=torch.float32) for layer in net: X = layer(X) print(layer.__class__.__name__, 'output shape: \t', X.shape) ``` 输出: Reshape output shape: torch.Size([1, 1, 28, 28]) Conv2d output shape: torch.Size([1, 6, 28, 28]) Sigmoid output shape: torch.Size([1, 6, 28, 28]) AvgPool2d output shape: torch.Size([1, 6, 14, 14]) Conv2d output shape: torch.Size([1, 16, 10, 10]) Sigmoid output shape: torch.Size([1, 16, 10, 10]) AvgPool2d output shape: torch.Size([1, 16, 5, 5]) Flatten output shape: torch.Size([1, 400]) Linear output shape: torch.Size([1, 120]) Sigmoid output shape: torch.Size([1, 120]) Linear output shape: torch.Size([1, 84]) Sigmoid output shape: torch.Size([1, 84]) Linear output shape: torch.Size([1, 10]) ```python batch_size = 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size) ``` **评价函数** ```python def evaluate_accuracy_gpu(net, data_iter, device=None): ''' use Gpu to test accuracy of model''' if isinstance(net, torch.nn.Module): net.eval() if not device: device = next(iter(net.parameters())).device metric = d2l.Accumulator(2) for X, y in data_iter: if isinstance(X, list): X = [x.to(device) for x in X] else: X = X.to(device) y = y.to(device) metric.add(d2l.accuracy(net(X), y), y.numel()) return metric[0] / metric[1] # 正确数目 / 总数 ``` ```python import time def train_ch6(net, train_iter, test_iter, num_epochs, lr, device): """用GPU训练模型""" st_tim = time.time() # 1. 权重初始化:使用 Xavier 初始化,防止梯度消失/爆炸 def init_weights(m): if type(m) == nn.Linear or type(m) == nn.Conv2d: nn.init.xavier_uniform_(m.weight) net.apply(init_weights) # 2. 将模型搬到 GPU 上 print('training on', device) net.to(device) # 3. 定义优化器 (SGD) 和 损失函数 (交叉熵) optimizer = torch.optim.SGD(net.parameters(), lr=lr) loss = nn.CrossEntropyLoss() # 动画绘制工具 (D2L 提供的包,用于画 Loss 曲线) animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], legend=['train loss', 'train acc', 'test acc']) timer, num_batches = d2l.Timer(), len(train_iter) # 4. 训练循环 for epoch in range(num_epochs): metric = d2l.Accumulator(3) # 记录: [训练损失之和, 训练准确率之和, 样本数] net.train() # 设置为训练模式 for i, (X, y) in enumerate(train_iter): timer.start() optimizer.zero_grad() # 梯度清零 # 数据搬运到 GPU X, y = X.to(device), y.to(device) # 前向传播 -> 计算损失 -> 反向传播 -> 更新参数 y_hat = net(X) l = loss(y_hat, y) l.backward() optimizer.step() with torch.no_grad(): # 记录指标时不计算梯度 metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0]) timer.stop() train_l = metric[0] / metric[2] train_acc = metric[1] / metric[2] # 每经过 1/5 的批次,或者到了最后一个批次,更新一下图表 if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1: animator.add(epoch + (i + 1) / num_batches, (train_l, train_acc, None)) # 每一个 epoch 结束后,在测试集上评估一次 test_acc = evaluate_accuracy_gpu(net, test_iter) animator.add(epoch + 1, (None, None, test_acc)) ed_tim = time.time() all_tim = ed_tim - st_tim # 打印最终结果和训练速度 print(f'it takes {all_tim // 60}minutes {all_tim % 60}s') print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, test acc {test_acc:.3f}') print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec on {str(device)}') ``` **训练参数** ```python lr, num_epochs = 0.5, 20 train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu()) ``` 输出: it takes 4.0minutes 9.768802165985107s loss 0.423, train acc 0.843, test acc 0.829 57758.7 examples/sec on cuda:0  设置了学习率 `lr=0.9` 和 `num_epochs=10`。之所以学习率设这么大,是因为模型使用了 Sigmoid 激活函数,且没有批归一化(BatchNorm),容易发生梯度消失,需要较大的学习率来推动参数更新。) 如果我们尝试加入BatchNorm,并且更换AvgPool 为 MaxPool,更换 Sigmoid 为 ReLU > 值得注意的是,net.eval() 后,会关闭BatchNorm,net.train() 下,会打开 BatchNorm ```python net = nn.Sequential( Reshape(), # first conv nn.Conv2d(1, 6, kernel_size=5, padding=2), # ci=1, co=6, kernel=5*5, padding=2 nn.BatchNorm2d(6), nn.ReLU(), # first pool nn.MaxPool2d(kernel_size=2, stride=2), # 2*2 avgpool # second conv nn.Conv2d(6, 16, kernel_size=5), # ci=6, co=16, kernel=5*5, no padding nn.BatchNorm2d(16), nn.ReLU(), # second pool nn.MaxPool2d(kernel_size=2, stride=2), # 2*2 avgpool nn.Flatten(), # flatten for the next Linear nn.Linear(16 * 5 * 5, 120), nn.BatchNorm1d(120), nn.ReLU(), nn.Linear(120, 84), nn.BatchNorm1d(84), nn.ReLU(), nn.Linear(84, 10), ) ``` **同样的训练参数:** ```python lr, num_epochs = 0.5, 20 train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu()) ``` 输出: it takes 4.0minutes 4.190480470657349s loss 0.097, train acc 0.964, test acc 0.900 49093.5 examples/sec on cuda:0  得到了巨大的提升。
