07 卷积网络
本章从“滑动窗口加权的点积”出发,依次讨论卷积的形状计算、池化与归一化、VGG/ResNet 的模块化设计、全局平均池化、迁移学习,以及处理传感器序列的 1D 卷积。下面 8 道题按四类编排:
- 概念与推导(练习 1–3):输出形状、参数量、感受野、池化与 BatchNorm2d、VGG 块和残差连接。
- 编程实践(练习 4–6):手写二维卷积、BatchNorm2d/GAP 实测、Mini-VGG 与可变输入。
- 开放思考(练习 7):迁移学习、混合模型、1D CNN 的工程取舍。
- 纠错与陷阱(练习 8):通道顺序、展平维度、冻结骨干和验证集增强。
建议用时约 3~3.5 小时。练习 1、2、4、8 可以先做;练习 6 需要完整跑一遍模型定义,练习 7 可以结合自己的数据场景展开。答案中的数值在 R 4.6.1、torch 0.17.0 下核对过,固定种子后你的结果应当接近;凡是写“约”的地方,不必逐位对齐。
一、概念与推导
练习 1:形状、参数、感受野与全局平均池化
考虑下面这个用于 \(64 \times 64\) 彩色图像分类的小型卷积网络。输入形状为 \([N, 3, 64, 64]\),所有卷积都使用 \(3 \times 3\) 核、步长 1、填充 1,并带偏置:
| 层 | 配置 |
|---|---|
conv1 |
输入 3 通道,输出 32 通道,\(3\times3\),padding 1 |
pool1 |
\(2\times2\) 最大池化,步长 2 |
conv2 |
输入 32 通道,输出 64 通道,\(3\times3\),padding 1 |
pool2 |
\(2\times2\) 最大池化,步长 2 |
gap |
对每个通道在 \(H,W\) 上取平均 |
fc |
\(64 \to 10\) 的全连接层 |
请回答:
- (a) 逐层写出输出形状、参数量(含偏置)和每个样本的乘加次数(MACs)。总参数量是多少?哪个卷积层的 MACs 更大,为什么?
- (b) 为什么卷积层的参数量与图像大小无关,而 MACs 会随图像大小增长?这对深层网络的设计有什么实际含义?
- (c) 计算 GAP 之前一个特征点对应输入图像的感受野。把两个 \(3\times3\) 卷积换成一个 \(5\times5\) 卷积,感受野是否相同?在输入、输出通道数都为 \(C\) 时,两者的参数量谁更少?这个结论在通道数变化时还成立吗?
- (d) 卷积是“平移等变”还是“平移不变”?池化和 GAP 各自带来了什么性质?如果把 GAP 换成“展平后接 \(64\cdot16\cdot16\to128\to10\) 的全连接头”,分类头参数量会变成多少?两种设计的优缺点分别是什么?
(a)形状、参数和 MACs。
| 层 | 输出形状 | 参数量 | MACs / 样本 |
|---|---|---|---|
conv1 |
\([N,32,64,64]\) | \((3\cdot3\cdot3+1)\cdot32=896\) | \(32\cdot64\cdot64\cdot27=3{,}538{,}944\) |
pool1 |
\([N,32,32,32]\) | 0 | — |
conv2 |
\([N,64,32,32]\) | \((3\cdot3\cdot32+1)\cdot64=18{,}496\) | \(64\cdot32\cdot32\cdot288=18{,}874{,}368\) |
pool2 |
\([N,64,16,16]\) | 0 | — |
gap |
\([N,64]\) | 0 | — |
fc |
\([N,10]\) | \((64+1)\cdot10=650\) | — |
总参数量:
\[ 896+18{,}496+650=20{,}042. \]
总 MACs:
\[ 3{,}538{,}944+18{,}874{,}368=22{,}413{,}312. \]
conv2 占总 MACs 的约 \(84.2\%\)。虽然 conv2 的通道数更多、单个输出点的计算更贵(每个点要算 \(32\cdot9=288\) 次乘加),但它的空间尺寸已经从 \(64\times64\) 降到 \(32\times32\)。真正决定计算量的是“输出元素数 × 每个元素的乘加数”,不能只看卷积核大小。
(b)参数共享与计算量。
卷积核在整张图上滑动时使用同一组权重,所以参数量只由 \(C_{in},C_{out},K\) 决定,与 \(H,W\) 无关。前向计算却要对每个空间位置做一次点积,因此 MACs 正比于输出特征图的 \(H_{out}W_{out}\)。
这带来两个设计上的后果:
- 池化或步长卷积把空间尺寸减半,后续卷积的 MACs 大约降到四分之一;所以网络可以“先宽后深”,在分辨率高的前几层用较少通道,在分辨率低的后几层增加通道。
- 参数少不等于计算少。一个 \(3\times3\) 卷积的参数量可能只有几千,但在大特征图上仍可能消耗大部分算力;分析模型时要把参数量和 MACs 分开看。
(c)感受野与 \(3\times3\) 堆叠。
用 \(r\) 表示感受野、\(j\) 表示相邻输出点之间的输入步长(jump),则
\[ r_{out}=r_{in}+(K-1)j_{in},\qquad j_{out}=j_{in}S. \]
从输入开始,\(r=1,j=1\):
| 经过 | \(r\) | \(j\) |
|---|---|---|
conv1 (\(3\times3\), \(S=1\)) |
3 | 1 |
pool1 (\(2\times2\), \(S=2\)) |
4 | 2 |
conv2 (\(3\times3\), \(S=1\)) |
8 | 2 |
pool2 (\(2\times2\), \(S=2\)) |
10 | 2 |
所以 GAP 之前的一个特征点,对应输入图像上约 \(10\times10\) 的区域。
两个 \(3\times3\) 卷积(中间没有池化)的感受野是 \(3+(3-1)\cdot1=5\),与一个 \(5\times5\) 卷积相同。若输入、输出通道数都为 \(C\),忽略偏置:
\[ \text{两个 }3\times3:\quad 2\cdot9C^2=18C^2, \qquad \text{一个 }5\times5:\quad 25C^2. \]
两个 \(3\times3\) 的参数量更少,而且中间多了一次 ReLU,非线性更强。这也是 VGG 用“小核堆叠”替代大核的核心理由。
但这个“\(18<25\)”的结论有一个前提:每个卷积的输入、输出通道相同。如果通道数在变化,就要按实际通道数重算。例如第一个卷积把 \(64\) 通道升到 \(128\),第二个卷积在 \(128\) 通道上做:
\[ 9\cdot64\cdot128+9\cdot128\cdot128=221{,}184 \quad\text{vs.}\quad 25\cdot64\cdot128=204{,}800. \]
此时两个 \(3\times3\) 反而更贵。一般地,若中间通道等于输出通道 \(C_{out}\),两个 \(3\times3\) 的权重数为 \(9C_{in}C_{out}+9C_{out}^2\),一个 \(5\times5\) 为 \(25C_{in}C_{out}\);前者更少当且仅当
\[ 9C_{out}<16C_{in} \quad\Longleftrightarrow\quad \frac{C_{out}}{C_{in}}<\frac{16}{9}\approx1.78. \]
\(64\to64\) 满足,\(64\to128\) 不满足。
(d)等变、不变与两种分类头。
卷积本身是平移等变的:输入平移 \((a,b)\),特征图也平移 \((a,b)\)(在忽略边界填充影响的意义上)。它不是平移不变——输出会跟着动。池化在局部窗口内取最大值或平均值,削弱了特征的具体位置;GAP 再把整个空间平均掉,使输出对目标在画面中的位置不敏感,更接近平移不变。严格地说,带 padding 的卷积在边界处会破坏完全的等变性,数据增强也只是鼓励模型学到近似不变性。
两种分类头的参数量:
- GAP +
fc(64, 10):\((64+1)\cdot10=650\)。 - 展平 +
fc(64*16*16, 128)+fc(128, 10):
\[ (64\cdot16\cdot16+1)\cdot128+(128+1)\cdot10 =2{,}097{,}280+1{,}290 =2{,}098{,}570. \]
展平头的参数量是 GAP 头的约 \(3229\) 倍,GAP 减少了约 \(99.97\%\) 的参数。GAP 还带来两个好处:没有需要学习的空间权重,天然不易记住“特征在第几行第几列”;分类头只依赖通道数,因此可以接受不同大小的输入。代价是它把空间布局完全平均掉了,对分类通常没问题,但对检测、分割、需要定位的任务就不合适——那些任务需要保留特征图的空间结构。展平头保留位置,但参数多、容易过拟合,而且输入尺寸必须固定。
练习 2:池化与 BatchNorm2d 的手算
(a) 对下面的 \(4\times4\) 矩阵做 \(2\times2\)、步长 2 的最大池化和平均池化,写出两个 \(2\times2\) 输出:
\[ \begin{bmatrix} 1 & 3 & | & 2 & 4\\ 5 & 9 & | & 1 & 1\\ \hline 8 & 2 & | & 3 & 2\\ 4 & 6 & | & 9 & 5 \end{bmatrix} \]
再计算:输入 \(5\times5\)、核 \(3\times3\)、步长 2、填充 1 时,输出边长是多少?
(b) 下面这个张量按 NCHW 顺序构造,形状为 \([2,2,2,2]\):
x <- torch_tensor(c(
1, 2, 3, 4, # n = 1, c = 1
10, 10, 10, 10, # n = 1, c = 2
5, 6, 7, 8, # n = 2, c = 1
12, 12, 12, 12 # n = 2, c = 2
), dtype = torch_float())$view(c(2, 2, 2, 2))令 bn <- nn_batch_norm2d(2, eps = 0),在训练模式下对 x 做一次前向。请计算:
- 两个通道各自的均值 \(\mu_c\) 和有偏方差 \(\sigma_c^2\);
- 两个通道归一化后的输出(写出通道 1 的 8 个值,通道 2 可以只写两类值);
- 一次更新后的
running_mean和running_var。设初始running_mean = 0、running_var = 1,momentum = 0.1,并说明running_var用的是有偏还是无偏方差。
(c) nn_batch_norm2d(C) 有多少个可学习参数?为什么不是 \(2CHW\)?训练模式和评估模式分别用什么统计量?如果训练时 \(N=1\) 且 \(H=W=1\),会发生什么?
(d) 怎样用 nn_batch_norm1d 得到与 nn_batch_norm2d 完全相同的训练输出?请写出 reshape 的方式,并解释为什么两者等价。
(a)池化。
四个窗口分别是左上 \([1,3,5,9]\)、右上 \([2,4,1,1]\)、左下 \([8,2,4,6]\)、右下 \([3,2,9,5]\)。
最大池化:
\[ \begin{bmatrix} 9 & 4\\ 8 & 9 \end{bmatrix}. \]
平均池化:
\[ \begin{bmatrix} 4.5 & 2\\ 5 & 4.75 \end{bmatrix}. \]
最大池化保留每个窗口里最强的响应,平均池化把所有响应平滑地汇总;前者对“特征是否出现”更敏感,后者保留更多整体强度信息。
输出边长:
\[ \left\lfloor\frac{5-3+2\cdot1}{2}\right\rfloor+1 =\left\lfloor\frac{4}{2}\right\rfloor+1=3. \]
(b)BatchNorm2d 的手算。
通道 1 的 8 个数是 \(1,2,\dots,8\):
\[ \mu_1=\frac{1+2+\cdots+8}{8}=4.5, \qquad \sigma_1^2=\frac{1}{8}\sum_{i=1}^{8}(i-4.5)^2=5.25. \]
通道 2 的 8 个数是四个 10 和四个 12:
\[ \mu_2=11,\qquad \sigma_2^2=1. \]
取 \(\varepsilon=0\),通道 1 的归一化输出为
\[ \hat x=\frac{x-4.5}{\sqrt{5.25}} \approx(-1.5275,-1.0911,-0.6547,-0.2182,\;0.2182,0.6547,1.0911,1.5275). \]
通道 2 的输出更简单:10 变成 \(-1\),12 变成 \(+1\)(各重复四次)。训练模式下,两个通道输出的均值都是 0,有偏方差都是 1;nn_batch_norm2d 的默认 affine = TRUE,初始 \(\gamma=1,\beta=0\),所以输出就是上面的标准化值。
running_mean 按“旧值保留 \(1-\text{momentum}\)、新批次占 momentum”更新:
\[ \text{running\_mean}_1=0.9\cdot0+0.1\cdot4.5=0.45, \qquad \text{running\_mean}_2=0.9\cdot0+0.1\cdot11=1.1. \]
running_var 用的是无偏方差(分母 \(n-1\)),而前向归一化用的是有偏方差(分母 \(n\))。这里每个通道有 \(n=NHW=8\) 个元素:
\[ s_1^2=\frac{8}{7}\cdot5.25=6, \qquad s_2^2=\frac{8}{7}\cdot1=\frac{8}{7}. \]
初始 running_var = 1,于是
\[ \text{running\_var}_1=0.9\cdot1+0.1\cdot6=1.5, \qquad \text{running\_var}_2=0.9\cdot1+0.1\cdot\frac{8}{7}\approx1.0143. \]
注意:running_var 不会在第一批就变成 1,它只是向批次统计量靠近;要经过很多批才逐渐稳定。当前 torch 的 BatchNorm 实现使用无偏方差更新 running stats,这一点在手工核对时要特别小心。
(c)参数与模式。
nn_batch_norm2d(C) 的可学习参数是每个通道一个缩放 \(\gamma_c\) 和一个平移 \(\beta_c\),共 \(2C\) 个;running_mean、running_var 是缓冲区,不是可学习参数。无论特征图多大,归一化都是“每个通道一套统计量”,所以参数量与 \(H,W\) 无关。如果设 affine = FALSE,可学习参数为 0。
训练模式使用当前批次的统计量:
\[ \mu_c=\frac{1}{NHW}\sum_{n,h,w}x_{n,c,h,w}, \qquad \sigma_c^2=\frac{1}{NHW}\sum_{n,h,w}(x_{n,c,h,w}-\mu_c)^2. \]
评估模式使用 Running stats,因此同一个样本的输出不再依赖同批次的其他样本。这也是训练时和推理时结果可能不同的原因之一。
如果 \(N=1\) 且 \(H=W=1\),每个通道只有一个元素:有偏方差为 0,前向输出被归一化为 0(eps 保护了除法);但 running_var 的无偏方差分母 \(n-1=0\),会变成 NaN,之后评估模式也会输出 NaN。所以 BatchNorm 不适合“每通道只有一个值”的微批次;要么保证每个通道有足够多的元素,要么改用 GroupNorm/LayerNorm,或者设置 track_running_stats = FALSE。
(d)BatchNorm1d 的等价写法。
把 \([N,C,H,W]\) 看成 \([N,C,L]\),其中 \(L=H\cdot W\):
bn1 <- nn_batch_norm1d(2, eps = 0)
bn1$train()
y1 <- bn1(x$view(c(2, 2, 4)))
y1$view(c(2, 2, 2, 2)) # 与 bn(x) 相同nn_batch_norm1d 对每个通道在 \(N\) 和 \(L\) 两个轴上求统计量;因为 \(L\) 恰好是 \(H\cdot W\),所以它和 nn_batch_norm2d 在 \(N,H,W\) 上求统计量完全一致。实测两者的最大绝对误差为 0。注意评估模式下要保证两个模块的 running_mean、running_var 也一致,否则输出会不同。
练习 3:VGG 块与残差块
(a) 本章的 vgg_block(num_convs, in_channels, out_channels) 由若干个“\(3\times3\) 卷积 + ReLU”和一个 \(2\times2\) 最大池化组成。取 num_convs = 2,输入为 \([N,64,32,32]\):
- 当
out_channels = 64时,计算该块的参数量、输出形状和感受野;并与一个 \(5\times5\) 卷积(输入输出同为 64 通道)比较参数量。 - 当
out_channels = 128时,再算一遍。此时“两个 \(3\times3\) 比一个 \(5\times5\) 参数少”还成立吗?请给出一般条件。
(b) 本章的残差块使用两个 \(3\times3\) 卷积(bias = FALSE)、两个 BatchNorm2d,以及形状不匹配时的 \(1\times1\) 卷积捷径。取 in_channels = 64、out_channels = 128、stride = 2,输入 \([N,64,32,32]\):
- 主路径、捷径和整个残差块各有多少参数?
- 输出形状是什么?捷径相对于主路径增加了多少比例?
- 如果改成
in_channels = out_channels = 64、stride = 1,参数量是多少?
(c) 对残差结构 \(x_{l+1}=x_l+F(x_l,W_l)\),证明
\[ \frac{\partial \mathcal E}{\partial x_l} = \frac{\partial \mathcal E}{\partial x_L} \left( 1+\frac{\partial}{\partial x_l}\sum_{i=l}^{L-1}F(x_i,W_i) \right). \]
解释常数项“1”为什么有助于梯度传播。它是否意味着梯度绝对不会消失?
(d) 深层 plain network 出现的“退化问题”和过拟合有什么区别?如果你训练了一个 50 层网络,发现它的训练误差比 20 层网络还高,应该优先怀疑哪一类问题?如果训练误差很低但验证误差很高,又该如何判断?
(a)VGG 块。
先看 out_channels = 64。第一个卷积 \(64\to64\),第二个卷积 \(64\to64\),每个卷积的参数量为
\[ (3\cdot3\cdot64+1)\cdot64=36{,}928. \]
两个卷积共 \(73{,}856\) 个参数;池化没有参数。输入 \(32\times32\),padding 1 的 \(3\times3\) 卷积保持 \(32\times32\),池化后变成 \(16\times16\),所以输出形状为 \([N,64,16,16]\)。两个 \(3\times3\) 卷积堆叠后的感受野是 \(5\times5\);再加上末尾的 \(2\times2\) 池化,整个 VGG 块的感受野是 \(6\times6\)。
一个 \(5\times5\)、\(64\to64\) 的卷积有
\[ (5\cdot5\cdot64+1)\cdot64=102{,}464 \]
个参数。两个 \(3\times3\) 少 \(28{,}608\) 个参数(约 \(28\%\)),而且中间多了一次 ReLU。
再看 out_channels = 128。第一个卷积 \(64\to128\):
\[ (3\cdot3\cdot64+1)\cdot128=73{,}856. \]
第二个卷积 \(128\to128\):
\[ (3\cdot3\cdot128+1)\cdot128=147{,}584. \]
合计 \(221{,}440\)。一个 \(5\times5\)、\(64\to128\) 的卷积:
\[ (5\cdot5\cdot64+1)\cdot128=204{,}928. \]
此时两个 \(3\times3\) 反而多 \(16{,}512\) 个参数(约 \(8.1\%\))。原因是第二个 \(3\times3\) 已经在 128 个通道上计算,而 \(5\times5\) 只做了一次 \(64\to128\) 的升维。
忽略偏置,设中间通道等于输出通道 \(C_{out}\),则两个 \(3\times3\) 的权重数为
\[ 9C_{in}C_{out}+9C_{out}^2, \]
一个 \(5\times5\) 为 \(25C_{in}C_{out}\)。前者更少当且仅当
\[ 9C_{out}<16C_{in} \quad\Longleftrightarrow\quad \frac{C_{out}}{C_{in}}<\frac{16}{9}\approx1.78. \]
所以“两个 \(3\times3\) 更省参数”只在通道数大致相等或缓慢变化时成立;通道数翻倍时,结论会反过来。
(b)残差块参数。
主路径:
conv1:\(3\cdot3\cdot64\cdot128=73{,}728\);bn1:\(2\cdot128=256\);conv2:\(3\cdot3\cdot128\cdot128=147{,}456\);bn2:\(2\cdot128=256\)。
主路径合计 \(221{,}696\)。捷径需要把 \([N,64,32,32]\) 变成 \([N,128,16,16]\),用 \(1\times1\)、步长 2 的卷积:
\[ 1\cdot1\cdot64\cdot128=8{,}192, \]
再加一个 BN 的 \(2\cdot128=256\),捷径共 \(8{,}448\)。整个残差块:
\[ 221{,}696+8{,}448=230{,}144. \]
捷径只占主路径的约 \(3.8\%\),但它解决了形状不匹配时的相加问题。输出形状为 \([N,128,16,16]\)。
如果 in_channels = out_channels = 64、stride = 1,捷径就是恒等映射,没有参数;主路径为
\[ 2\cdot(3\cdot3\cdot64\cdot64)+2\cdot(2\cdot64) =73{,}728+256=73{,}984. \]
(c)梯度展开。
把递归式展开:
\[ x_L=x_l+\sum_{i=l}^{L-1}F(x_i,W_i). \]
对 \(x_l\) 求导,注意 \(x_l\) 同时出现在第一项和求和项里:
\[ \frac{\partial x_L}{\partial x_l} = 1+\frac{\partial}{\partial x_l}\sum_{i=l}^{L-1}F(x_i,W_i). \]
两边左乘损失对 \(x_L\) 的梯度,就得到题目中的式子。
在普通平铺网络中,\(\partial x_L/\partial x_l\) 是各层雅可比矩阵的连乘,任何一层的小梯度都会把整个乘积拉小,任何一层的大梯度都会把它放大。残差网络把它变成了“恒等项 + 残差项”的加法:即使所有 \(F\) 的导数都很小,梯度仍可以通过常数 1 直接回传。这不是说梯度“绝对不会消失”——\(\partial \mathcal E/\partial x_L\) 本身可能很小,\(F'\) 也可能是负的,BN/ReLU 仍会影响数值;但至少梯度不再被迫穿过一长串乘法。残差连接主要改善的是优化,让深层网络更容易训练,而不是给模型增加某种无限容量。
(d)退化与过拟合。
- 退化问题:更深的 plain network 在训练集上的误差反而更高。这不是过拟合(过拟合是训练误差低、验证误差高),而是优化困难:梯度在深层反向传播时消失或爆炸,或者恒等映射难以用多层非线性逼近。
- 过拟合:训练误差很低,验证误差明显更高;模型记住了训练样本的噪声。
所以,50 层网络的训练误差比 20 层还高,应优先怀疑退化/优化问题,而不是过拟合。可以检查:是否加了残差连接、BN 的位置是否正确、学习率是否合适、初始化是否合理。相反,如果训练误差很低但验证误差高,才应该用数据增强、Dropout、权重衰减、早停、冻结部分层等手段控制过拟合。残差连接解决的是前者;它并不能替代后者的正则化。
二、编程实践
练习 4:从零实现二维卷积
(a) 对下面的单通道输入 \(X\) 和 \(3\times3\) 卷积核 \(K\),不使用 nn_conv2d,用 R 的循环实现一个 naive_conv2d()(步长 1、无填充、无偏置),计算输出:
\[ X= \begin{bmatrix} 1&2&3&0&1\\ 4&5&6&1&0\\ 7&8&9&2&1\\ 0&1&2&3&4\\ 5&6&7&8&9 \end{bmatrix}, \qquad K= \begin{bmatrix} 1&0&-1\\ 1&0&-1\\ 1&0&-1 \end{bmatrix}. \]
把结果与 nn_conv2d(1, 1, 3, bias = FALSE) 在相同权重下的输出比较,最大绝对误差应该是多少?这个卷积核在检测什么方向的边缘?
(b) 把实现扩展到多通道:输入 \([1,3,5,5]\),卷积为 \(3\to4\)、\(3\times3\)、带偏置。输出形状和权重形状分别是什么?参数量是多少?每一个输出通道是如何把 3 个输入通道汇总起来的?
(c) 用同一个 \(5\times5\) 输入,计算以下配置的输出形状,并用 nn_conv2d 验证:
kernel_size = 3, stride = 2, padding = 1;kernel_size = 3, dilation = 2, stride = 1, padding = 0。
(d) 为什么 nn_conv2d 的权重形状是 [C_out, C_in, K, K],而不是 [C_in, C_out, K, K]?一个卷积层一共有多少个“卷积核”?参数共享体现在哪里?
(a)手写卷积。
下面这个实现只处理单个样本 [C,H,W],重点是逻辑清楚,不追求速度:
naive_conv2d <- function(x, weight, bias = 0, stride = 1L, padding = 0L) {
xa <- as.array(x)
wa <- as.array(weight)
C_in <- dim(xa)[1]; H <- dim(xa)[2]; W <- dim(xa)[3]
C_out <- dim(wa)[1]; K <- dim(wa)[3]
Hp <- H + 2L * padding
Wp <- W + 2L * padding
xp <- array(0, dim = c(C_in, Hp, Wp))
xp[, (padding + 1):(padding + H),
(padding + 1):(padding + W)] <- xa
H_out <- floor((Hp - K) / stride) + 1L
W_out <- floor((Wp - K) / stride) + 1L
out <- array(0, dim = c(C_out, H_out, W_out))
bias <- as.numeric(bias)
for (co in 1:C_out) {
for (i in 1:H_out) {
for (j in 1:W_out) {
h0 <- (i - 1L) * stride + 1L
w0 <- (j - 1L) * stride + 1L
patch <- xp[, h0:(h0 + K - 1L),
w0:(w0 + K - 1L), drop = FALSE]
kernel <- array(wa[co, , , , drop = FALSE],
dim = dim(wa)[-1])
b <- if (length(bias) == 1) bias else bias[co]
out[co, i, j] <- sum(patch * kernel) + b
}
}
}
torch_tensor(out, dtype = torch_float())
}用题目中的 \(X\) 和 \(K\):
X <- torch_tensor(c(
1,2,3,0,1,
4,5,6,1,0,
7,8,9,2,1,
0,1,2,3,4,
5,6,7,8,9
), dtype = torch_float())$view(c(1, 5, 5))
K <- torch_tensor(c(
1,0,-1,
1,0,-1,
1,0,-1
), dtype = torch_float())$view(c(1, 1, 3, 3))
out <- naive_conv2d(X, K)
out[1, , ]
# torch_tensor
# -6 12 16
# -6 8 12
# -6 2 4与 torch 的卷积比较:
conv <- nn_conv2d(1, 1, 3, bias = FALSE)
with_no_grad(conv$weight$copy_(K))
ref <- conv(X$unsqueeze(1))$squeeze(1)
as.numeric((out - ref)$abs()$max()) # 0最大绝对误差为 0(把权重复制到 torch 层时要用 copy_(),直接给 $data 赋值在 R torch 中不可行)。
这个核的左右两列是 \(+1\) 和 \(-1\),中间列是 0;它对竖直边缘敏感:图像在水平方向上的灰度跳变会产生强响应。若把核转置,就变成水平边缘检测器。
(b)多通道卷积。
输入 \([1,3,5,5]\),输出通道 4、核 \(3\times3\)、无填充、步长 1:
- 输出形状:\([1,4,3,3]\);
- 权重形状:\([4,3,3,3]\),即
[C_out, C_in, K, K]; - 偏置形状:\([4]\);
- 参数量:\((3\cdot3\cdot3+1)\cdot4=112\)。
对每个输出通道 \(c_o\),网络有 3 个 \(3\times3\) 核,分别对应 3 个输入通道。计算时把 3 个通道的卷积结果逐元素相加,再加一个偏置:
\[ Y_{c_o,i,j} = \sum_{c_i=1}^{3} \sum_{m=1}^{3}\sum_{n=1}^{3} X_{c_i,i+m-1,j+n-1}K_{c_o,c_i,m,n} +b_{c_o}. \]
所以“一个输出通道”并不是一个二维核,而是 \(C_{in}\) 个二维核组成的三维权重块;不同输出通道之间不共享权重。
(c)形状验证。
输出边长公式(无空洞)为
\[ H_{out}=\left\lfloor\frac{H-K+2P}{S}\right\rfloor+1. \]
- \(K=3,S=2,P=1\):\(\lfloor(5-3+2)/2\rfloor+1=3\),输出 \(3\times3\)。
- \(K=3,D=2\):有效核大小为 \(D(K-1)+1=5\),输出 \(\lfloor(5-5)/1\rfloor+1=1\),即 \(1\times1\)。
x <- torch_randn(1, 1, 5, 5)
nn_conv2d(1, 1, 3, stride = 2, padding = 1)(x)$shape # 1 1 3 3
nn_conv2d(1, 1, 3, dilation = 2)(x)$shape # 1 1 1 1(d)权重布局与参数共享。
[C_out, C_in, K, K] 是 PyTorch/torch 的约定:第一个维度选择“哪个输出通道”,第二个维度选择“哪个输入通道”,后两维是空间核。每个输出通道都有自己的一组 \(C_{in}\times K\times K\) 权重,所以一共有 \(C_{out}\) 个三维卷积核(也可以说有 \(C_{out}\cdot C_{in}\) 个二维核)。
参数共享发生在空间维度:同一个三维核在图像的所有位置上重复使用,因此参数量不随 \(H,W\) 增长。它不发生在通道维度——不同输入通道、不同输出通道的权重是各自独立的。
练习 5:BatchNorm2d 与 GAP 的实测
(a) 构造一个形状为 \([8,4,6,6]\) 的张量,让 4 个通道有不同的均值和尺度:
torch_manual_seed(7)
offset <- torch_tensor(c(0, 5, -2, 10))$view(c(1, 4, 1, 1))
scale <- torch_tensor(c(1, 2, 0.5, 3))$view(c(1, 4, 1, 1))
x <- torch_randn(8, 4, 6, 6) * scale + offset在训练模式下通过 nn_batch_norm2d(4),验证:
- 每个通道输出的均值是否接近 0、有偏方差是否接近 1;
- 一次更新后的
running_mean、running_var大致是什么; - 把同一批数据换成前 4 个样本,训练模式下第一个样本的输出是否改变?评估模式下呢?
(b) 用 nn_batch_norm1d 对 x 的 reshape 版本做同样的归一化,验证输出与 nn_batch_norm2d 一致。
(c) 实现 GAP,并与 nnf_adaptive_avg_pool2d(x, c(1, 1)) 比较。把同一个 nn_linear(C, 10) 接在 \(7\times7\) 和 \(13\times13\) 的 GAP 输出后面,形状分别是什么?为什么不需要改全连接层?
(a)训练模式与评估模式。
bn <- nn_batch_norm2d(4)
bn$train()
y <- bn(x)
round(as.numeric(y$mean(dim = c(1, 3, 4))), 6)
# 大约 0 0 0 0
round(as.numeric(y$var(dim = c(1, 3, 4), unbiased = FALSE)), 6)
# 大约 1 1 1 1
round(as.numeric(bn$running_mean), 4)
# 大约 0.007 0.496 -0.200 1.020
round(as.numeric(bn$running_var), 4)
# 大约 0.990 1.317 0.923 1.865训练模式下,BatchNorm 用当前批次的统计量把每个通道标准化,所以输出的均值/方差必然接近 0/1(在浮点误差范围内)。running_mean 是一次指数平均:初始为 0,momentum = 0.1 时大约变成 \(0.1\times\) 通道均值。running_var 的初始值是 1,所以它大约变成 \(0.9\times1+0.1\times\) 无偏批次方差;四个通道的尺度不同,更新后的值也不会相同。由于只更新了一次,这些 running stats 还远没有收敛。
训练模式下,把批次从 8 个样本换成前 4 个,第一个样本的输出会改变,因为它的归一化用到了同批次其他样本的均值/方差。实测最大差异约 \(0.185\)。评估模式下,running_mean、running_var 固定,同一个样本的输出与批次组成无关;实测最大差异为 0。
bn$eval()
y_full <- bn(x)
y_half <- bn(x[1:4, , , , drop = FALSE])
as.numeric((y_full[1, , , , drop = FALSE] -
y_half[1, , , , drop = FALSE])$abs()$max()) # 0这就是“训练时指标随 batch 波动、推理时结果稳定”的直接原因,也是评估前必须调用 $eval() 的原因。
(b)BatchNorm1d 的等价写法。
bn1 <- nn_batch_norm1d(4)
bn1$train()
y1 <- bn1(x$view(c(8, 4, 36)))
as.numeric((y1$view(c(8, 4, 6, 6)) - y)$abs()$max()) # 0nn_batch_norm1d 对 \([N,C,L]\) 的每个通道在 \(N,L\) 上求统计量;把 \(L\) 设为 \(H\cdot W\) 后,统计集合与 nn_batch_norm2d 的 \(N,H,W\) 完全相同。注意两个模块必须使用相同的 eps、affine 设置;评估模式下还要保证 running stats 一致。
(c)GAP。
gap <- function(z) z$mean(dim = c(3, 4))
z <- torch_randn(2, 3, 7, 7)
g1 <- gap(z)
g2 <- nnf_adaptive_avg_pool2d(z, c(1, 1))$view(c(2, 3))
as.numeric((g1 - g2)$abs()$max()) # 0
fc <- nn_linear(3, 10)
fc(gap(torch_randn(2, 3, 7, 7)))$shape # 2 10
fc(gap(torch_randn(2, 3, 13, 13)))$shape # 2 10GAP 对每个通道在 \(H,W\) 上取平均,输出形状永远是 \([N,C]\),与 \(H,W\) 无关。全连接层的输入维度只由通道数 \(C\) 决定,所以 \(7\times7\) 和 \(13\times13\) 的特征图都可以接同一个 fc。如果换成 nn_flatten(),\(7\times7\) 得到 \(C\cdot49\) 维,\(13\times13\) 得到 \(C\cdot169\) 维,全连接层的权重形状就对不上了。
练习 6:Mini-VGG、GAP 与可变输入
(a) 用本章的 vgg_block() 搭一个用于 \(28\times28\) 灰度图的 Mini-VGG:
- 两个 VGG 块,每块 2 个卷积;第一块 \(1\to64\),第二块 \(64\to128\);
- 分类头为
flatten -> linear(128*7*7, 256) -> ReLU -> Dropout(0.5) -> linear(256, 10)。
计算特征提取部分、分类头和整个模型的参数量,并验证输入 \([2,1,28,28]\) 时输出 \([2,10]\)。与本章的 MNIST_CNN(206,922 个参数)相比,参数主要增加在哪里?
(b) 把分类头改成“GAP -> linear(128, 256) -> ReLU -> Dropout(0.5) -> linear(256, 10)”。重新计算参数量,并验证 \(28\times28\) 和 \(56\times56\) 输入都能得到 \([2,10]\)。
(c) 保留原来的 flatten 分类头,把输入换成 \(56\times56\),会发生什么?请写出实际的特征图形状、展平后的维度和报错信息。这个网络能接受的最小输入尺寸是多少?
(d) 如果既想保留 flatten 头的空间信息,又想接受可变输入,有哪些改法?分别有什么代价?
(a)Mini-VGG 与参数量。
vgg_block <- function(num_convs, in_channels, out_channels) {
layers <- list()
for (i in 1:num_convs) {
layers[[length(layers) + 1]] <- nn_conv2d(
in_channels, out_channels, kernel_size = 3, padding = 1
)
layers[[length(layers) + 1]] <- nn_relu()
in_channels <- out_channels
}
layers[[length(layers) + 1]] <- nn_max_pool2d(2, stride = 2)
do.call(nn_sequential, layers)
}
MiniVGG <- nn_module(
"MiniVGG",
initialize = function() {
self$features <- nn_sequential(
vgg_block(2, 1, 64),
vgg_block(2, 64, 128)
)
self$classifier <- nn_sequential(
nn_flatten(),
nn_linear(128 * 7 * 7, 256),
nn_relu(),
nn_dropout(0.5),
nn_linear(256, 10)
)
},
forward = function(x) {
x %>% self$features() %>% self$classifier()
}
)各层参数量:
| 部分 | 计算 | 参数量 |
|---|---|---|
| Block 1, conv 1 | \((3\cdot3\cdot1+1)\cdot64\) | 640 |
| Block 1, conv 2 | \((3\cdot3\cdot64+1)\cdot64\) | 36,928 |
| Block 2, conv 1 | \((3\cdot3\cdot64+1)\cdot128\) | 73,856 |
| Block 2, conv 2 | \((3\cdot3\cdot128+1)\cdot128\) | 147,584 |
| 特征提取合计 | 259,008 | |
分类头 6272 -> 256 |
\((6272+1)\cdot256\) | 1,605,888 |
分类头 256 -> 10 |
\((256+1)\cdot10\) | 2,570 |
| 分类头合计 | 1,608,458 | |
| 总计 | 1,867,466 |
m <- MiniVGG()
m(torch_randn(2, 1, 28, 28))$shape # 2 10
sum(sapply(m$parameters, function(p) p$numel())) # 1867466与 MNIST_CNN 的 206,922 个参数相比,Mini-VGG 的参数主要增加在分类头:\(128\cdot7\cdot7=6272\) 维输入接 256 个隐藏单元,仅这一层就有约 160 万个参数;而 MNIST_CNN 的分类头是 \(32\cdot7\cdot7=1568\to128\),约 20 万个参数。特征提取部分虽然更深,但卷积参数仍然只占约 26 万。
(b)GAP 版本。
把分类头改为“先 GAP,再两层全连接”:
MiniVGG_GAP <- nn_module(
"MiniVGG_GAP",
initialize = function() {
self$features <- nn_sequential(
vgg_block(2, 1, 64),
vgg_block(2, 64, 128)
)
self$classifier <- nn_sequential(
nn_linear(128, 256),
nn_relu(),
nn_dropout(0.5),
nn_linear(256, 10)
)
},
forward = function(x) {
x <- self$features(x)
x <- x$mean(dim = c(3, 4))
self$classifier(x)
}
)这里 x$mean(dim = c(3, 4)) 就是 GAP:把 \([N,128,H,W]\) 压成 \([N,128]\)。参数量:
| 部分 | 参数量 |
|---|---|
| 特征提取 | 259,008 |
128 -> 256 |
\((128+1)\cdot256=33{,}024\) |
256 -> 10 |
2,570 |
| 总计 | 294,602 |
GAP 版本的总参数量约为原来的 \(15.8\%\),减少了约 \(84.2\%\)。验证:
mg <- MiniVGG_GAP()
mg(torch_randn(2, 1, 28, 28))$shape # 2 10
mg(torch_randn(2, 1, 56, 56))$shape # 2 10(c)flatten 头遇到 \(56\times56\)。
特征提取部分有两个 \(2\times2\) 池化,\(56\to28\to14\),所以特征图形状是 \([2,128,14,14]\),展平后是 \(2\times(128\cdot14\cdot14)=2\times25{,}088\)。分类头却仍然期待 \(128\cdot7\cdot7=6272\) 维:
mat1 and mat2 shapes cannot be multiplied (2x25088 and 6272x256)
最小输入尺寸:两个 \(2\times2\) 池化要求每一维至少能连续减半两次,即 \(H,W\ge4\)。\(4\to2\to1\) 可以通过,GAP 版本随后得到 \([N,128]\);\(3\times3\) 在第一次池化后变成 \(1\times1\),第二次池化就无法进行了。注意卷积本身有 padding,不会把尺寸减到 0;限制来自池化。
(d)保留空间信息的可变输入方案。
- 自适应池化到固定网格:在展平前加
nnf_adaptive_avg_pool2d(x, c(7, 7)),把任意大小的特征图压到 \(7\times7\)。这样保留了粗略的空间网格,但不同输入尺寸会被拉伸/压缩到同一尺度,定位精度受限于网格。 - 固定输入尺寸:在数据管道里统一 resize/crop 到 \(28\times28\) 或 \(224\times224\)。实现简单,但会改变目标的尺度,可能丢失细节或引入变形。
- 用 GAP 替代 flatten:接受任意尺寸,但没有空间信息;适合分类,不适合检测/分割。
- 全卷积 + 全局池化/注意力:保留特征图,用 \(1\times1\) 卷积或注意力汇总,而不是一次性展平;结构更复杂,但更适合需要位置信息的任务。
选择哪一种,取决于任务是“这张图里有没有猫”还是“猫在哪里”。前者 GAP 通常够用,后者必须保留空间结构。
三、开放思考
练习 7:迁移学习、混合模型与 1D 卷积的取舍
假设你只有 800 张标注图像,共 10 类,每类约 80 张;你有一台单卡 GPU,可以使用在 ImageNet 上预训练的 MobileNetV2。请回答:
(a) 比较三种迁移学习策略:只训练线性分类头、冻结骨干并训练一个较大的 MLP 头、解冻最后 \(k\) 个卷积块做微调。它们的训练成本、过拟合风险和最终潜力各自如何?你会按什么顺序尝试?验证集应该怎么划?
(b) 你先做了“冻结骨干 + 训练分类头”,得到训练准确率 0.55、验证准确率 0.52。这个结果更像是欠拟合还是过拟合?下一步应该先检查什么、再尝试什么?如果换成训练准确率 0.95、验证准确率 0.60,判断和措施又有什么不同?
(c) 本章提到“CNN 特征 + XGBoost”的混合模型。它在什么场景下可能优于纯神经网络?把 CNN 特征送入 XGBoost 时,有哪些数据泄漏风险?交叉验证应该怎么设计?
(d) 本章的 CowNet 用 5 秒窗口、50% 重叠的 1D CNN 识别奶牛站立/躺卧。与 RNN/Transformer 相比,1D CNN 的优势和局限是什么?重叠窗口在划分训练集/测试集时会带来什么风险?应该按什么单位切分数据?
(a)三种策略。
| 策略 | 训练成本 | 过拟合风险 | 潜力 |
|---|---|---|---|
| 冻结骨干 + 线性头 | 最低,只需一次前向提特征 | 最低 | 最低;如果 ImageNet 特征与任务匹配,往往已经很强 |
| 冻结骨干 + 较大 MLP 头 | 低到中 | 中 | 中;给分类头更多非线性 |
| 解冻最后 \(k\) 个块 + 微调 | 高;要反传到骨干 | 高,尤其小样本 | 最高;让特征适应新 domain |
合理的顺序是:先做线性头或小 MLP 头,建立一个强基线;如果训练/验证都欠拟合,再解冻最后 1~2 个块,用较小的学习率微调骨干、较大的学习率训练头(discriminative learning rates)。不要一开始就全网络微调:800 张图很容易把预训练特征破坏掉。
验证集要分层抽样,保证每类在训练/验证中的比例接近;如果同一物体/同一场景有多张近 duplicate 图片,要按组划分,避免同一组同时出现在训练和验证里。最终留出一个独立的测试集,只用一次。
(b)0.55/0.52 与 0.95/0.60。
0.55/0.52 的绝对水平低,但训练和验证只差 0.03,说明模型没有足够拟合训练集,更像欠拟合/优化不足,而不是过拟合。先检查:标签是否正确、预处理是否与预训练模型匹配(通道顺序、归一化均值/方差、输入尺寸)、学习率是否太小、训练轮数是否足够、分类头是否太窄。然后可以:解冻最后一个卷积块、增大分类头、训练更久、换更合适的学习率。不要一上来就加 dropout 或强增强,那会让欠拟合更严重。
0.95/0.60 则是明显的过拟合:训练集几乎记住,验证集泛化差。应优先加数据增强、权重衰减、Dropout、早停,或者冻结更多层、减少解冻块数;也可以收集更多数据或做半监督/伪标签。两条曲线的形状比单个数字更有信息量。
(c)CNN + XGBoost。
混合模型可能在以下场景占优:
- 样本很少,神经网络的分类头需要大量数据才能稳定,而树模型对小样本更鲁棒;
- 输入同时包含图像和表格特征(面积、邮编、传感器读数),可以把 CNN 的 1280 维 embedding 与表格特征拼接后交给 XGBoost;
- 需要较强的可解释性/特征重要性,或者团队已有成熟的树模型管线。
风险主要是泄漏:
- 如果用全部数据(包括验证/测试)来拟合预训练 backbone 的 BN、归一化或特征提取器,测试信息会渗入特征;
- 如果先对全数据做数据增强再切分,同一张原图的不同增强版本可能同时出现在训练和验证中;
- 如果交叉验证的折之间共享近重复图像/同一病人/同一场景,特征也会泄漏。
正确做法:先按组划分训练/验证/测试,再在训练折内拟合任何有监督的变换;CNN 特征提取器如果是固定的预训练模型,可以看成无监督变换,但它的归一化统计量仍应只用训练数据估计。交叉验证时,每一折都重新训练分类头/XGBoost,不能把全量特征先算好再随机切分。
(d)1D CNN 与重叠窗口。
1D CNN 的优势:
- 卷积可以在时间轴上并行计算,训练比 RNN 快;
- 局部卷积核擅长捕捉短时波形、波峰、震荡等局部模式;
- 参数量少,适合传感器这种中低维序列;
- 可以用不同 kernel size/空洞卷积扩大感受野。
局限:
- 固定窗口长度;很长的依赖需要堆很多层或加大感受野;
- 对全局顺序、长程关系的建模不如 RNN/Transformer 自然;
- 如果任务依赖 irregular sampling 或变长序列,需要 padding/mask,处理起来更麻烦。
RNN 适合顺序依赖强、长度可变的序列;Transformer 的全局注意力表达能力强,但通常需要更多数据。CowNet 的 5 秒窗口、50% 重叠意味着相邻窗口共享一半样本。如果随机按窗口划分训练/测试,测试集的窗口很可能与训练集的窗口高度重叠,准确率会被高估。应该按奶牛个体或连续时间段划分,并在训练/测试之间留出时间间隔;同一头牛的不同窗口不能跨集合。如果按时间划分,还要避免用未来预测过去(视任务而定)。
四、纠错与陷阱
练习 8:四个真实 bug
(a)通道顺序与展平维度。 下面两段代码分别会报什么错?请给出修正后的形状。
conv <- nn_conv2d(3, 16, 3)
x <- torch_randn(1, 64, 64, 3)
conv(x)
h <- torch_randn(2, 32, 7, 7)
torch_flatten(h)$shape
nn_linear(32 * 7 * 7, 10)(torch_flatten(h))(b)卷积后的尺寸。 下面这段代码在 \(28\times28\) 输入上会失败。请算出实际的中间形状,指出错误,并给出两种修法。
conv5 <- nn_conv2d(1, 16, kernel_size = 5)
pool <- nn_max_pool2d(2)
x <- torch_randn(1, 1, 28, 28)
z <- pool(conv5(x))
nn_linear(16 * 14 * 14, 10)(torch_flatten(z, start_dim = 2))(c)冻结骨干不等于冻结 BatchNorm。 下面这段迁移学习代码把骨干的所有参数都设成 requires_grad = FALSE,但训练时骨干的 running_mean、running_var 仍然会变化。请解释原因,并给出一个不影响分类头 Dropout 的修法。
for (p in self$features$parameters) {
p$requires_grad <- FALSE
}(d)验证集上的数据增强。 有人把同一套随机裁剪、水平翻转、色彩抖动同时用在训练集和验证集上,并且在划分数据集之前用全量数据计算了归一化的均值和标准差。请指出这两个做法各自会造成什么问题,并写出正确的数据管道顺序。
(a)通道顺序与展平。
第一段:
Given groups=1, weight of size [16, 3, 3, 3],
expected input[1, 64, 64, 3] to have 3 channels,
but got 64 channels instead
torch_randn(1, 64, 64, 3) 被 torch 解释成 \([N=1,C=64,H=64,W=3]\),不是 \([N,H,W,C]\)。卷积层看到 64 个输入通道,自然与 in_channels = 3 不匹配。修正:
x_chw <- x$permute(c(1, 4, 2, 3)) # [1, 3, 64, 64]
conv(x_chw)$shape # 1 16 62 62第二段:
torch_flatten(h)$shape # 3136torch_flatten() 默认从第 1 维开始展平,把 batch 也压进去了:\(2\cdot32\cdot7\cdot7=3136\)。全连接层期待的是每个样本 1568 维,所以报:
mat1 and mat2 shapes cannot be multiplied (1x3136 and 1568x10)
修正:保留 batch 维,从第 2 维开始展平:
torch_flatten(h, start_dim = 2)$shape # 2 1568R torch 的 start_dim 是 1-based 的;start_dim = 2 对应“从通道维开始”。这类错误在 CNN 里非常常见,尤其从 MLP 代码迁移过来时。
(b)卷积后的尺寸。
\(28\times28\) 经过 \(5\times5\)、无填充、步长 1 的卷积:
\[ 28-5+1=24, \]
所以 conv5(x) 是 \([1,16,24,24]\);再经过 \(2\times2\) 池化,变成 \([1,16,12,12]\)。展平后每个样本是 \(16\cdot12\cdot12=2304\) 维,而 nn_linear(16*14*14, 10) 期待 \(3136\) 维:
mat1 and mat2 shapes cannot be multiplied (1x2304 and 3136x10)
两种修法:
- 按实际尺寸改全连接层:
nn_linear(16 * 12 * 12, 10)。 - 保持 \(14\times14\) 的预期,给卷积加
padding = 2,让卷积输出仍是 \(28\times28\),池化后为 \(14\times14\):
conv5 <- nn_conv2d(1, 16, kernel_size = 5, padding = 2)不要用“把输入补到 30”之类的方式硬凑,除非你明确知道自己在改变感受野和边界行为。形状公式要写在模型定义旁边,改一层就重新算一遍。
(c)冻结骨干与 BatchNorm。
requires_grad = FALSE 只阻止反向传播计算梯度,不改变模块的 training 标志。只要骨干仍处于训练模式,BatchNorm 就会继续用当前批次更新 running_mean、running_var;Dropout 也会继续随机置零。对于“冻结骨干、只训练头”的设置,这通常不是我们想要的:骨干的归一化统计量会被新数据的小批次带偏,评估结果也会漂移。
可以这样验证:
bn <- nn_batch_norm2d(4)
bn$train()
bn(torch_randn(8, 4, 5, 5))
m0 <- as.numeric(bn$running_mean)
for (p in bn$parameters) p$requires_grad <- FALSE
bn$train()
bn(torch_randn(8, 4, 5, 5) * 3 + 2)
identical(m0, as.numeric(bn$running_mean)) # FALSE一个简单的修法是在自定义模块的 forward() 里每次把骨干切到评估模式,而分类头仍保持训练模式:
forward = function(x) {
self$features$eval()
x %>%
self$features() %>%
self$head()
}self$features$eval() 只会影响骨干及其子模块,不会关掉 self$head 里的 Dropout。实测这样调用后,骨干的 running_mean 不再变化,而分类头的 Dropout 仍然处于训练模式。更规范的做法是写一个 train() 重载,或在 luz 的 epoch 回调里设置 ctx$model$features$eval();关键是不要把整个模型都设成 eval(),否则分类头的 Dropout 也会被关掉。
(d)验证集增强与归一化泄漏。
验证集上的随机增强有两个问题:
- 评估不可复现:同一个验证样本每次得到不同的裁剪/翻转,指标会随增强的随机性波动,早停和模型选择变得噪声很大。
- 分布不一致:验证集应该模拟“真实部署时看到的确定性输入”,随机增强会让验证分布与测试分布不同,指标不能直接比较。
正确做法是训练集用随机增强,验证/测试集只用确定性的 resize/中心裁剪和归一化。
在划分之前用全量数据计算均值和标准差则是数据泄漏:测试集的像素统计量进入了训练管线的归一化参数,测试指标会偏乐观。正确顺序是:
- 先按组/分层划分训练、验证、测试;
- 只用训练集拟合归一化的均值、标准差(以及其他有监督的预处理);
- 训练集做随机增强,验证/测试做确定性变换;
- 测试集只在最终评估时使用一次。
如果使用预训练模型,它的归一化均值/标准差是 ImageNet 的固定常数,不需要在新数据上重新估计;这不算泄漏,但输入通道顺序和数值范围必须与预训练时一致。
练习中的形状计算和参数量都可以用 sapply(model$parameters, function(p) p$numel()) 复核。真正容易出错的地方往往不是公式,而是“以为张量的某一维是通道,实际却是宽”的静默错误;写完一个卷积块后,先打印形状,再训练。