
006、DRRN深度递归残差记忆单元与全局残差学习的代码解构上周帮师弟调一个超分模型他用了DRRN做基线训练到第50个epoch时PSNR死活卡在32.1dB上不去。我扫了一眼代码发现他把残差块的递归次数设成了1——等于把DRRN退化成了普通的ResNet。这种坑我太熟了当年自己复现时也栽在同样的地方。DRRNDeep Recursive Residual Network的核心思想其实就两句话用递归结构共享参数用全局残差学习简化映射。但代码实现时这两句话背后藏着不少细节。递归结构参数共享的陷阱先看递归模块的实现。DRRN的递归单元叫Residual UnitRU每个RU内部是两个卷积层加ReLU。关键点在于整个网络只维护一组RU参数通过多次前向传播实现深度。classResidualUnit(nn.Module):def__init__(self,n_feats128):super().__init__()# 注意这里只有一组卷积没有为每个递归步单独创建self.conv1nn.Conv2d(n_feats,n_feats,3,padding1)self.conv2nn.Conv2d(n_feats,n_feats,3,padding1)self.relunn.ReLU(inplaceTrue)# 这里踩过坑inplaceTrue省显存但梯度回传时要注意defforward(self,x):outself.relu(self.conv1(x))outself.conv2(out)returnxout# 局部残差连接递归调用时很多人会写成循环defforward(self,x):# 别这样写每次循环都重新实例化RUforiinrange(self.num_recursions):ruResidualUnit()# 错误每次都是新参数xru(x)returnx正确做法是只实例化一次RU循环调用同一个对象classDRRN(nn.Module):def__init__(self,num_recursions25,n_feats128):super().__init__()self.num_recursionsnum_recursions# 输入卷积把RGB三通道映射到特征空间self.input_convnn.Conv2d(3,n_feats,3,padding1)# 只实例化一个RU后面递归调用self.ruResidualUnit(n_feats)# 输出卷积特征空间映射回RGBself.output_convnn.Conv2d(n_feats,3,3,padding1)defforward(self,x):# 全局残差保存输入用于最后的跳跃连接identityx# 输入映射xself.input_conv(x)# 递归调用同一个RU共享参数for_inrange(self.num_recursions):xself.ru(x)# 输出映射 全局残差xself.output_conv(x)returnxidentity# 全局残差连接这里有个容易被忽视的点递归次数num_recursions决定了网络的有效深度。原论文用25次递归相当于25个共享参数的残差块堆叠。参数总量只有一组RU但感受野和表达能力随递归次数增加。记忆单元梯度流动的隐形通道DRRN论文里提到记忆单元Memory Block听起来高大上实际实现就是把中间特征图保存下来。为什么要这么做因为递归结构虽然参数少但梯度经过多次反向传播容易消失或爆炸。看这个改进版本classDRRNWithMemory(nn.Module):def__init__(self,num_recursions25,n_feats128):super().__init__()self.num_recursionsnum_recursions self.input_convnn.Conv2d(3,n_feats,3,padding1)self.ruResidualUnit(n_feats)# 记忆单元一个1x1卷积用于融合中间特征self.memory_convnn.Conv2d(n_feats,n_feats,1)self.output_convnn.Conv2d(n_feats,3,3,padding1)defforward(self,x):identityx xself.input_conv(x)# 初始化记忆状态memoryxforiinrange(self.num_recursions):xself.ru(x)# 每隔几步更新一次记忆ifi%50:memoryself.memory_conv(x)memory# 残差式记忆更新# 最终输出融合记忆xxmemory xself.output_conv(x)returnxidentity这个记忆机制的实际效果让梯度多了一条直达路径。反向传播时梯度可以通过记忆连接直接流回早期层缓解了递归结构带来的梯度衰减。我在训练时发现加了记忆单元后学习率可以设得更大从1e-4提到3e-4收敛速度明显加快。全局残差学习为什么必须做DRRN的全局残差连接Global Residual Learning是另一个关键设计。直观理解网络只需要学习高频细节低频信息直接从输入绕过去。看这个对比实验# 错误写法没有全局残差classDRRNNoGlobalResidual(nn.Module):defforward(self,x):xself.input_conv(x)for_inrange(self.num_recursions):xself.ru(x)xself.output_conv(x)returnx# 直接输出没有加输入# 正确写法全局残差classDRRN(nn.Module):defforward(self,x):identityx# 保存原始输入xself.input_conv(x)for_inrange(self.num_recursions):xself.ru(x)xself.output_conv(x)returnxidentity# 加上原始输入没有全局残差时网络需要同时学习低频背景和高频细节。加了之后网络只需要学习残差即高频部分训练难度大幅降低。我实测过不加全局残差PSNR低0.8-1.2dB而且训练曲线震荡严重。训练技巧那些论文没写的事初始化策略DRRN的递归结构对初始化敏感。推荐用Kaiming初始化但要把卷积层的bias初始化为0。我试过用xavier前10个epoch loss降不下去。梯度裁剪递归25次意味着梯度要回传25层即使有残差连接梯度范数也可能爆炸。设个max_norm0.5的梯度裁剪训练稳定很多。学习率调度不要用StepLR。DRRN适合用CosineAnnealingLR周期设成50个epoch初始学习率1e-3。我试过ReduceLROnPlateau效果不如余弦退火。数据增强随机旋转90度、水平翻转就够了。别用色彩抖动超分任务对颜色保真度要求高色彩抖动反而会降低PSNR。踩坑实录最让我头疼的是递归次数与显存的平衡。DRRN参数少约0.3M但递归25次意味着中间特征图要保存25份用于反向传播。训练时batch size设成16显存直接爆掉。解决方案用checkpointing技术在递归循环中只保存部分中间结果。fromtorch.utils.checkpointimportcheckpointdefforward(self,x):identityx xself.input_conv(x)foriinrange(self.num_recursions):# 每5步保存一次梯度其余步不保存中间结果ifi%50:xself.ru(x)else:xcheckpoint(self.ru,x)# 不保存中间激活xself.output_conv(x)returnxidentity这样显存占用减少约60%训练速度只慢10%左右。代价是梯度计算时多了一次前向传播但相比显存溢出这个trade-off值得。个人经验DRRN这个模型放在2024年看确实老了。但它的设计哲学——用递归换深度用残差换稳定——至今仍有借鉴意义。如果你要做轻量级超分模型DRRN的参数效率0.3M参数达到32.7dB PSNR on Set5依然能打。我的建议别把DRRN当最终方案把它当基线。在这个框架上改把普通卷积换成可变形卷积把ReLU换成LeakyReLU把递归次数从25改成自适应。我最近在做的项目就是把DRRN的递归结构嵌入到Transformer里效果出乎意料地好。最后说一句复现经典模型时别信论文里的超参数。原论文用Set5测试集batch size设成128学习率1e-4。实际复现时batch size设成16学习率调到3e-4效果反而更好。原因很简单小batch size带来的噪声有助于正则化对递归结构这种参数共享的模型特别友好。