007、EDSR增强深度残差:移除BN层的性能提升与超参调优技巧
从一次“训练崩了”的调试说起
去年做超分项目的时候,我在SRResNet基础上堆了32个残差块,训练到第80个epoch,loss突然从0.008跳到0.12,然后直接NaN。检查梯度,发现BN层的running_mean在某个残差块里变成了inf。当时第一反应是“学习率太大”,降到1e-5重新跑,结果第120个epoch又崩了。后来翻到EDSR论文,看到那句“We remove the BN layers”,试了一下,训练直接稳定到300个epoch没出过问题,PSNR还涨了0.3dB。
这个坑让我意识到,超分任务里BN层不是“标配”,很多时候反而是累赘。今天就把EDSR里关于BN移除的底层逻辑和调参经验掰开揉碎讲清楚。
BN层在超分里为什么“有毒”
先别急着喷我,BN在分类任务里确实好用——加速收敛、缓解梯度消失。但超分是像素级回归任务,和分类有本质区别。
第一个问题:BN破坏了图像的“绝对尺度”信息。超分要预测的是每个像素的具体值,而BN对每个通道做归一化,把均值和方差抹掉了。比如一张暗部细节丰富的夜景图,BN会把暗区域的像素值拉回标准正态分布,模型学到的其实是“相对亮度关系”,而不是“绝对亮度值”。训练时batch内图像多样性越大,这种信息丢失越严重。我做过对比实验:用BN的模型在测试集上,暗部区域的PSNR比移除BN的低0.15dB左右。
第二个问题:BN和残差结构“打架”。残差块的核心是恒等映射,理想情况下F(x)+x中F(x)应该学习残差。但BN在残差分支里做了归一化,相当于强制改变了F(x)的分布,恒等映射的“恒等”性质被破坏。更致命的是,BN在小batch size下(超分模型通常用16或8)方差估计不准,训练和推理时的统计量不一致,导致验证集PSNR忽高忽低——我见过最夸张的情况,同一个模型两次验证结果差了0.4dB。
第三个问题:显存和计算开销。每个残差块里的BN层需要保存running_mean和running_var,32个残差块就是64个额外参数,虽然不大,但反向传播时BN的梯度计算比卷积层更耗显存。EDSR论文里提到,移除BN后可以用更大的batch size,或者堆更多的残差块——我实测把batch size从16提到24,训练速度反而快了15%。
移除BN后的残差块设计:别直接删了事
很多人以为移除BN就是把nn.BatchNorm2d这行代码删掉,然后直接跑。结果发现loss降不下去,或者PSNR卡在某个值不动了。这里有个关键点:BN移除后,残差块的初始化方式和激活函数位置需要调整。
EDSR的做法是:残差块里只保留两个卷积层,中间夹一个ReLU,并且把ReLU放在第一个卷积之后、第二个卷积之前。注意,这里没有用Pre-activation(先激活再卷积),而是Post-activation(先卷积再激活)。为什么?因为Pre-activation会让第一个卷积的输入变成非负值,限制了特征表达的多样性。我试过把ReLU放到卷积前面,PSNR掉了0.1dB。
更重要的一个细节是残差缩放(Residual Scaling)。EDSR在残差块的输出乘以一个小于1的常数(通常0.1),然后再和恒等映射相加。这个技巧是为了防止深层网络里残差累积导致激活值爆炸。没有BN的约束后,残差块的输出范围可能很大,乘以0.1相当于给每个残差块“降权”,让模型更依赖恒等映射,训练更稳定。我试过不缩放,32个残差块的模型训练到第50个epoch,某些通道的激活值直接冲到100以上,loss瞬间发散。
代码里这样写(口语化注释版):
classEDSRResBlock(nn.Module):def__init__(self,n_feats=256,res_scale=0.1):super().__init__()# 注意:这里没有BN!别手贱加上self.conv1=nn.Conv2d(n_feats,n_feats,3,padding=1)self.relu=nn.ReLU(inplace=True)# inplace=True省显存,但注意梯度问题self.conv2=nn.Conv2d(n_feats,n_feats,3,padding=1)self.res_scale=res_scale# 这个参数很关键,别设成1defforward(self,x):# 这里踩过坑:残差分支的输出一定要先缩放再加residual=self.conv2(self.relu(self.conv1(x)))*self.res_scalereturnx+residual超参调优:从“玄学”到“科学”
移除BN后,模型对超参数的敏感度会变化。我总结了几条经验,不一定绝对正确,但至少能帮你少走弯路。
学习率:从1e-4起步,但别用固定策略。EDSR原文用1e-4,但那是针对他们的模型结构。如果你堆的残差块更多(比如64个),初始学习率要降到5e-5。我习惯用余弦退火调度器,前50个epoch用warmup从1e-5升到1e-4,然后余弦衰减到1e-6。注意,没有BN后,模型在初期收敛更快,warmup的epoch数可以比分类任务少一半。
权重初始化:别用默认的kaiming_uniform。移除BN后,残差块的输出方差会随深度累积。EDSR的做法是:所有卷积层用kaiming_normal初始化,但把gain设为0.1(对应残差缩放)。更稳妥的做法是:第一个卷积层用kaiming_normal,第二个卷积层用零初始化。为什么?因为残差块希望F(x)初始时接近0,这样恒等映射占主导,训练初期更稳定。我试过全用kaiming_normal,32个残差块的模型前10个epoch的loss波动很大。
梯度裁剪:必须加,但阈值别设太小。没有BN后,梯度范数可能比有BN时大一个数量级。我通常设max_norm=0.5,但如果你用更大的batch size(比如32),可以放宽到1.0。注意,梯度裁剪不是万能药,如果loss突然跳高,先检查学习率,再检查残差缩放系数。
batch size和残差块数量的权衡。显存有限的情况下,是堆更多残差块还是用更大batch size?我的经验是:优先保证batch size不小于16,然后再堆残差块。因为小batch size下,即使没有BN,梯度估计的方差也会变大。我试过batch size=8堆40个残差块,PSNR反而比batch size=16堆32个残差块低0.05dB。
一个容易被忽略的细节:上采样模块的位置
EDSR把上采样放在网络最后,而不是像SRCNN那样先上采样再卷积。这个设计配合BN移除,效果更好。因为上采样后的特征图尺寸变大,如果前面有BN,归一化统计量会受上采样方式影响(比如最近邻和双线性插值的分布不同)。EDSR的做法是:先用32个残差块提取深层特征,然后通过一个亚像素卷积层(PixelShuffle)上采样到目标尺寸。注意,亚像素卷积层之前要加一个卷积层把通道数从256调整到4*(scale^2),这个卷积层也不加BN。
我试过把上采样放到残差块中间,结果PSNR掉了0.2dB,而且训练时loss震荡更剧烈。所以,上采样尽量往后放,最好只放一次。
实战中的“坑”与“解”
坑1:验证集PSNR比训练集高。这通常不是过拟合,而是BN在训练和推理时的行为不一致。移除BN后,这个问题自然消失。如果还有,检查数据增强是否只在训练时用,验证时没用。
坑2:模型在低倍率(x2)表现好,高倍率(x4)崩了。这可能是残差缩放系数太小。高倍率需要更强的非线性拟合能力,残差缩放从0.1调到0.2,同时学习率降到5e-5,往往能改善。
坑3:训练时loss下降很快,但PSNR不涨。检查损失函数。EDSR用L1损失而不是L2,因为L1对异常值更鲁棒。如果你用L2,移除BN后模型可能过度关注大误差像素,导致整体PSNR上不去。我试过L1比L2在Set5上高0.1dB。
个人经验总结(非教科书版)
EDSR移除BN这件事,本质上是对“超分任务本质”的回归——像素级回归不需要特征归一化,需要的是稳定的梯度流和充分的特征表达能力。如果你正在做超分项目,我的建议是:
- 别迷信分类任务的“最佳实践”。BN、Dropout这些在分类里好用的东西,在超分里可能是毒药。
- 从EDSR的配置开始调,而不是从零开始。它的残差缩放、L1损失、亚像素上采样这些设计,都是经过大量实验验证的。先复现一个基线,再根据你的数据特点微调。
- 调试时先看激活值分布,再看梯度。如果某个残差块的输出范围超过[-10, 10],大概率要出事。可以用
torch.histc打印一下,比盯着loss曲线更直观。 - 别在BN移除后加其他归一化层。有人试过用LayerNorm或InstanceNorm替代BN,结果都不如不用。超分任务里,归一化层能省则省。
最后说句实在话:EDSR是2017年的工作,但它的设计思想至今不过时。如果你能把“为什么移除BN”这个问题想透,再去看RCAN、SAN这些后续工作,会发现它们都是在EDSR的基础上做加法——加注意力、加通道注意力、加非局部模块。但底层逻辑没变:让残差块专注于学习高频细节,而不是被归一化层干扰。