07 卷积网络

本章从“滑动窗口加权的点积”出发,依次讨论卷积的形状计算、池化与归一化、VGG/ResNet 的模块化设计、全局平均池化、迁移学习,以及处理传感器序列的 1D 卷积。下面 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}\)。

这带来两个设计上的后果:

  1. 池化或步长卷积把空间尺寸减半,后续卷积的 MACs 大约降到四分之一;所以网络可以“先宽后深”,在分辨率高的前几层用较少通道,在分辨率低的后几层增加通道。
  2. 参数少不等于计算少。一个 \(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())  # 0

nn_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 10

GAP 对每个通道在 \(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)保留空间信息的可变输入方案。

  1. 自适应池化到固定网格:在展平前加 nnf_adaptive_avg_pool2d(x, c(7, 7)),把任意大小的特征图压到 \(7\times7\)。这样保留了粗略的空间网格,但不同输入尺寸会被拉伸/压缩到同一尺度,定位精度受限于网格。
  2. 固定输入尺寸:在数据管道里统一 resize/crop 到 \(28\times28\) 或 \(224\times224\)。实现简单,但会改变目标的尺度,可能丢失细节或引入变形。
  3. 用 GAP 替代 flatten:接受任意尺寸,但没有空间信息;适合分类,不适合检测/分割。
  4. 全卷积 + 全局池化/注意力:保留特征图,用 \(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  # 3136

torch_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 1568

R 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)

两种修法:

  1. 按实际尺寸改全连接层:nn_linear(16 * 12 * 12, 10)。
  2. 保持 \(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)验证集增强与归一化泄漏。

验证集上的随机增强有两个问题:

  1. 评估不可复现:同一个验证样本每次得到不同的裁剪/翻转,指标会随增强的随机性波动,早停和模型选择变得噪声很大。
  2. 分布不一致:验证集应该模拟“真实部署时看到的确定性输入”,随机增强会让验证分布与测试分布不同,指标不能直接比较。

正确做法是训练集用随机增强,验证/测试集只用确定性的 resize/中心裁剪和归一化。

在划分之前用全量数据计算均值和标准差则是数据泄漏:测试集的像素统计量进入了训练管线的归一化参数,测试指标会偏乐观。正确顺序是:

  1. 先按组/分层划分训练、验证、测试;
  2. 只用训练集拟合归一化的均值、标准差(以及其他有监督的预处理);
  3. 训练集做随机增强,验证/测试做确定性变换;
  4. 测试集只在最终评估时使用一次。

如果使用预训练模型,它的归一化均值/标准差是 ImageNet 的固定常数,不需要在新数据上重新估计;这不算泄漏,但输入通道顺序和数值范围必须与预训练时一致。


练习中的形状计算和参数量都可以用 sapply(model$parameters, function(p) p$numel()) 复核。真正容易出错的地方往往不是公式,而是“以为张量的某一维是通道,实际却是宽”的静默错误;写完一个卷积块后,先打印形状,再训练。