最近在啃 DD-Net 这篇论文前前后后花了大概三周时间从最开始只看懂个流程图到后面能自己把核心模块复现出来、把训练损失压下去踩了不少坑也攒了一些心得。这篇博文就把我完整的论文学习路线、结构拆解方法和代码复现过程分享出来给同样准备啃深度学习网络架构论文的朋友做个参考。DD-Net 不是一个特别大的网络它的核心价值在于用比较轻量的方式解决了图像去噪任务里感受野与细节保持之间的矛盾。如果你正在入门图像恢复类任务或者准备复现一篇还没人开源代码的论文那这篇内容应该能帮你少走不少弯路。1. 论文学习的前置准备与整体路线1.1 读论文前必须想清楚的三个问题很多人拿到论文就直接从头读读到实验部分就懵了然后回头再看网络结构图还是似懂非懂。我个人的习惯是在打开 PDF 之前先问自己三个问题这篇论文解决什么问题它和已有的方法有什么本质不同我读它是为了什么——是了解思路、复现结果还是想基于它做改进以 DD-Net 为例它解决的是图像去噪任务中的细节丢失问题。传统去噪方法要么在空间域直接滤波容易把边缘糊掉要么用深层网络硬堆感受野参数量和计算量都很大。DD-Net 的核心思路是在不显著增加参数的前提下扩大感受野同时保留高频细节。想清楚这一层后面读结构图就顺很多。我读论文的目的很明确完整复现它的训练流程拿到和论文接近的 PSNR 指标并且搞清楚每个模块为什么这么设计。这个目标决定了我的精力分配——网络结构要逐行看损失函数要搞透彻实验部分的消融研究一栏一栏对照着看训练技巧部分反复看。建议你在开始之前也先花十分钟想清楚这几个问题并且把核心问题写下来贴在屏幕旁边。这样读论文的时候就不会迷失在公式里。1.2 论文笔记的整理方法不是抄摘要而是画流程图我见过很多人做论文笔记就是把摘要翻译一遍然后存进文件夹再也不看了。这种笔记对复现一点帮助都没有。我的做法是读完一段就用自己的话在白纸上重新表述一遍重点画三类图第一类是数据流图也就是输入图像从进入网络到输出结果的完整路径。DD-Net 里输入是一张带噪图像经过浅层特征提取后分成两条支路一条走细节保持分支一条走语义增强分支最后融合输出。把这条路径画清楚你对整个网络就有了一个整体认知。第二类是尺寸变化图每一层特征图的宽高和通道数怎么变这直接关系到你能不能把代码写对。很多复现失败就是因为特征图尺寸对不上不是拼写错误是下采样和上采样的倍数没算清楚。第三类是损失组成图论文的总损失由哪几项构成每一项的作用是什么各自在训练过程中的权重大小。DD-Net 用了重构损失加感知损失的组合重构损失保证像素级一致性感知损失保证结构上的相似性。画完这三张图论文基本就吃透了一半。2. DD-Net 核心网络结构拆解2.1 从设计动机理解网络骨架细节保持与语义增强的双分支思路DD-Net 最核心的设计思路是双分支——一个分支关注细节信息一个分支关注语义信息最后把两边融合起来。这个设计的出发点很简单图像去噪不能只靠深层的语义特征因为那是经过很多次抽象后的结果边缘和纹理这些高频信息早就被丢弃了但也不能只靠浅层的细节特征因为感受野不够无法区分真实的纹理和噪声。细节保持分支做的事情比较朴素主要是限制膨胀卷积的使用频率尽可能保留原始分辨率和局部纹理。语义增强分支则相反它通过多次膨胀卷积快速扩大感受野让网络能够看到更大范围内的上下文信息从而判断一个像素究竟是结构特征还是噪声伪影。两个分支的结合方式也有讲究。DD-Net 不是简单地把两个分支的特征图直接拼接而是引入了一个融合模块用注意力机制动态调整两条支路的贡献权重。在平坦区域细节分支权重高一些在纹理复杂区域语义分支权重高一些。这种自适应融合的方式相比固定权重拼接在理论上更有优势在实验里也确实拿到了更高的 PSNR。2.2 网络各层参数详解与感受野计算为了让你能真正复现我这里把 DD-Net 的详细参数配置列出来同时附上感受野的计算方法。感受野的计算公式是叠加式的从最后一层往前推RF(n) RF(n-1) (kernel_size - 1) × dilation × stride其中 stride 取值为当前层之前的累积步长。如果所有卷积层 stride 都是 1累积步长为 1那么膨胀卷积的感受野就是等差数列增长。DD-Net 的具体结构我用一段伪代码来描述输入: x (batch, 3, H, W) # 浅层特征提取 f0 Conv3x3(x, 32) # (batch, 32, H, W) # 细节保持分支 d1 Conv3x3(f0, 32, dilation1) # (batch, 32, H, W) d2 Conv3x3(d1, 32, dilation1) # (batch, 32, H, W) # 语义增强分支 s1 Conv3x3(f0, 32, dilation2) # (batch, 32, H, W) s2 Conv3x3(s1, 32, dilation3) # (batch, 32, H, W) s3 Conv3x3(s2, 32, dilation5) # (batch, 32, H, W) # 融合模块 fuse Concat(d2, s3) # (batch, 64, H, W) att Conv1x1(fuse, 2) # 生成两个通道的注意力权重 att Softmax(att, dim1) # 归一化 out d2 * att[:, 0:1, :, :] s3 * att[:, 1:2, :, :] # 重建 out Conv3x3(out, 32) # (batch, 32, H, W) out Conv3x3(out, 3) # (batch, 3, H, W)这段结构和论文里的核心思想是对应的实际复现时可以根据显存大小调整通道数。通道数取 32 是我实测的平衡点——通道数降到 24PSNR 有明显下降通道数升到 64PSNR 只提升了不到 0.1dB但训练时间和显存占用翻了近一倍。具体怎么取舍看你自己的硬件条件。感受野方面细节分支由于只用 dilation1 的普通卷积从输入到 d2 的感受野是 5×5两层 3×3 卷积堆叠。语义增强分支的感受野要大得多第一层膨胀卷积 dilation2感受野为 5×5第二层 dilation3感受野变为 13×13第三层 dilation5感受野达到 23×23。也就是说语义分支能看到输入图像上 23×23 范围内的像素信息而细节分支只能看到 5×5 的局部区域。这就是这两个分支本质上的区别。2.3 空洞卷积的膨胀率选择与网格伪影问题这里有一个必须要避开的坑——膨胀卷积使用不当会带来网格伪影gridding artifact。具体表现是输出图像上出现周期性的点状纹理看起来像叠加了一层规则网格噪点。原因很简单多个膨胀卷积的卷积核权重没有覆盖连续的像素区间导致某些位置的像素永远参与不了计算。DD-Net 的做法是采用递增的膨胀率序列2、3、5而不是固定同一个膨胀率。这个序列的选择基于一个数学性质当一组膨胀卷积的膨胀率满足互质关系时它们的组合感受野能够无重叠地覆盖更大区域从而有效避免网格伪影问题。实际复现时你可以验证一下把膨胀率序列改成 2、2、2跑完训练后在测试集上放大局部区域看几乎必然出现规则的点状伪影。改成 2、3、5 之后这个问题就消失了。这个细节在论文正文里可能只是一句话但对最终结果的影响非常大。3. 从论文到代码完整复现流程记录3.1 环境配置与数据集准备复现的环境我建议用 Python 3.8 以上版本深度学习框架用 PyTorch 2.xCUDA 版本不低于 11.8。显卡需求方面DD-Net 本身不重在 24GB 显存的卡上可以跑比较大的 batch size如果你只有 8GB 显存把 batch size 调到 4 也能跑只是训练时间会明显拉长。数据集的选择对去噪任务很关键。我用的组合是 DIV2K 训练集裁剪成 128×128 的 patches 作为训练数据BSD68 和 Set12 作为测试集。加噪方式有两种一种是固定噪声水平 σ25 的高斯白噪声这是最基础的设置另一种是随机噪声水平σ 从 15 到 50 随机采样。后者更接近真实场景但训练难度更大收敛速度会慢一些。数据加载这一块有个细节容易忽略训练时裁剪 patch 之后要做随机的水平翻转和垂直翻转必要时还可以做 90 度旋转。这是数据增强里成本最低但效果最明显的手段不做的话你会发现模型在训练集上的 PSNR 虚高但测试集上表现平平。3.2 训练超参数设置与损失函数实现DD-Net 的损失函数是重构损失和感知损失的加权和。重构损失我用的是 L1 距离而不是 L2因为 L1 对离群点的惩罚更温和在去噪任务上普遍比 L2 收敛得更稳PSNR 也略高一些。感知损失用预训练的 VGG16 提取特征图然后计算特征图之间的 L2 距离。总损失函数可以写成Loss L1(pred, gt) λ * L2(VGG(pred), VGG(gt))λ 的值我建议从 0.01 开始调。感知损失的权重太大会让输出图像显得过于平滑纹理细节反而丢失权重太小感知约束就起不到作用。我在复现时做了一个小实验λ 分别取 0.001、0.01、0.1在 BSD68 上测试λ0.01 的时候 PSNR 最高而且主观视觉效果也最自然。优化器我选了 AdamW初始学习率 0.0003训练 100 个 epoch。学习率用余弦退火调度把最低点设到初始学习率的 1/10。这里要注意AdamW 的权重衰减系数不能设置太大我试过 0.01 的权重衰减发现网络收敛明显变慢最终改为 0.0001效果正常了。批量大小设为 8 到 16 之间比较合适。我测试过 batch size 为 4 时训练非常不稳定PSNR 曲线波动厉害batch size 为 8 时明显平稳很多再往上到 16训练的稳定性提升有限但显存压力变大训练速度也受拖累。最终我固定使用 8。这个标注很重要训练过程中的评价指标要在测试集上计算而不是训练集。训练损失降低不代表泛化性能提升一定要单独保留一个验证集每个 epoch 结束之后跑一次验证集的 PSNR。3.3 核心模块的 PyTorch 实现细节我这里给出 DD-Net 核心模块的可运行简化版代码方便你对照着论文一步步验证import torch import torch.nn as nn import torch.nn.functional as F class DetailBranch(nn.Module): 细节保持分支两层普通 3x3 卷积 def __init__(self, in_ch32): super().__init__() self.conv1 nn.Conv2d(in_ch, 32, 3, padding1) self.conv2 nn.Conv2d(32, 32, 3, padding1) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) return x class SemanticBranch(nn.Module): 语义增强分支三层不同膨胀率的空洞卷积 def __init__(self, in_ch32): super().__init__() self.conv1 nn.Conv2d(in_ch, 32, 3, padding2, dilation2) self.conv2 nn.Conv2d(32, 32, 3, padding3, dilation3) self.conv3 nn.Conv2d(32, 32, 3, padding5, dilation5) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) x self.relu(self.conv3(x)) return x class FusionModule(nn.Module): 自适应融合模块通道注意力加权两条分支 def __init__(self, in_ch32): super().__init__() self.conv nn.Conv2d(in_ch * 2, 2, 1) self.softmax nn.Softmax(dim1) def forward(self, d, s): fuse torch.cat([d, s], dim1) att self.softmax(self.conv(fuse)) return d * att[:, 0:1, :, :] s * att[:, 1:2, :, :] class DDNet(nn.Module): def __init__(self): super().__init__() self.head nn.Conv2d(3, 32, 3, padding1) self.detail DetailBranch(32) self.semantic SemanticBranch(32) self.fusion FusionModule(32) self.tail1 nn.Conv2d(32, 32, 3, padding1) self.tail2 nn.Conv2d(32, 3, 3, padding1) self.relu nn.ReLU(inplaceTrue) def forward(self, x): f0 self.relu(self.head(x)) d self.detail(f0) s self.semantic(f0) f self.fusion(d, s) f self.relu(self.tail1(f)) out self.tail2(f) return out这段代码和论文结构基本对齐。有两个地方需要特别提醒一是 padding 值的计算。膨胀卷积的输出尺寸如果不加 padding 会变小padding 的计算公式是 dilation × (kernel_size - 1) / 2。比如 dilation3kernel_size3padding 就是 3这样才能保证输入输出尺寸完全一致。二是注意力权重的初始化问题。FusionModule 里的卷积层初始化之后注意力权重接近均匀分布——也就是两条分支各贡献一半。但这时候语义分支还没训练好它的输出质量较差如果一开始就给语义分支很大权重会让训练初期损失非常大。一个可行的做法是给注意力卷积层加一个偏置初始化时让细节分支的权重偏向 0.6 左右语义分支在 0.4 左右等训练进行到中期再让网络自己调整。这个小技巧在论文里没有提但实测能有效稳定前几个 epoch 的损失曲线。3.4 训练过程与评价指标解读训练过程中我记录了每个 epoch 的 PSNR 和 SSIM 变化。第一阶段前 10 个 epoch损失下降很快但 PSNR 提升并不明显因为模型还在学习基本的结构特征第二阶段10 到 60 epochPSNR 开始稳步上升每 10 个 epoch 大概提升 0.2dB 左右第三阶段60 到 100 epoch提升变得非常缓慢有时候连续几个 epoch PSNR 纹丝不动。最终我在 BSD68 测试集上达到了约 29.5dB 的 PSNRσ25 高斯噪声和论文报告的 29.7dB 非常接近。这个差距在可接受范围内毕竟论文通常会在多个数据集上取最优结果而且硬件、随机种子、数据增强的具体实现都会带来微小差异。SSIM 指标我的最终结果是 0.893论文报告是 0.897同样非常接近。但我观察到一个有趣的现象在 Set12 数据集上我的 PSNR 反超了论文约 0.1dB。原因很可能是在 Set12 上的测试轮数不同以及数据增强策略的差异。这也侧面说明论文结果不是不可超越的天花板自己完整复现后完全有空间做局部改进。4. 复现过程中的常见问题与排查方法4.1 损失不下降或下降过慢的排查清单这是我遇到最多的问题也是最容易劝退新手的问题。如果你发现训练了 5 个 epoch 损失几乎没动或者前 3 个 epoch 就出现 NaN可以按下面这个顺序排查第一检查数据加载是否正确。把输入图像、噪声图像、干净图像分别可视化出来确认三者之间的对应关系没有搞错。我踩过一次坑把加噪后的图像当成了干净图像损失函数一直在衡量两个带噪图像之间的差异模型训练出来自然毫无去噪效果。第二检查学习率是否设置合理。DD-Net 这种规模的网络初始学习率在 0.0001 到 0.0005 之间比较合适。学习率太大会导致训练曲线剧烈震荡甚至发散表现为 loss 不降反升学习率太小则表现为 loss 缓慢下降但 PSNR 一直上不去。第三检查损失函数是否进入了局部极小值。一个快速验证方法是用固定的迭代次数分别在 σ15、σ25、σ50 的加噪图像上做测试。如果模型对低噪声水平的图像表现良好但对高噪声水平不理想说明模型偏向于学习了某种固定模式需要在训练中引入随机噪声范围。为方便查阅我把这些问题整理成一个速查表现象可能原因排查方法loss 一直不变数据标签错误可视化输入输出对比loss 震荡学习率过大或 batch size 过小降低学习率或增大 batchloss 出现 NaN梯度爆炸检查是否有除零操作降低学习率PSNR 低但 loss 低评价指标与损失函数不一致确认 PSNR 计算前像素范围是否正确训练集 PSNR 高但测试集低过拟合增加数据增强或提高权重衰减4.2 测试结果差PSNR 上不去的五个隐藏原因如果训练过程一切正常loss 曲线也很平稳但最终测试结果就是比论文低 0.5dB 以上那问题多半出在测试流程上而不是模型结构上。最常见的原因有两个一是测试时忘了关训练模式。BatchNorm 层在训练模式和测试模式下的行为完全不同如果你没有调用 model.eval()BatchNorm 会继续使用当前 batch 的统计量导致测试结果不稳定。这个错误非常隐蔽因为损失的差异可能只有 0.1dB肉眼根本察觉不到训练模式没关。二是 PSNR 计算时的像素范围错误。如果你的输入图像归一化到了 [-1, 1] 区间那么计算 PSNR 之前要把输出反归一化回 [0, 255] 或者 [0, 1]并且确保数据类型是浮点数而不是整数。如果直接拿 [-1, 1] 的输出去计算 PSNR结果会凭空低 1dB 左右。还有一个容易忽略的因素是测试图像边界处理。DD-Net 的卷积使用了 padding 保持尺寸不变但这个 padding 在边界附近会产生误差。稳妥的做法是测试时把图像切成重叠的 patches 分别推理然后对重叠区域做加权平均融合最后再拼回完整图像。这种做法在专业评测中很常用可以让 PSNR 再提升 0.1 到 0.2dB。4.3 显存不足与训练速度优化技巧DD-Net 本身的参数量不大但如果输入图像分辨率很高比如俧测时直接推理 4K 图像还是会遇到显存不足的问题。有几个实用的技巧可以解决这个问题。一块一块地推理不要整张大图直接扔进网络。把图像从左上到右下切成 256×256 的块相邻块之间重叠 16 个像素推理完毕后对重叠区域取平均。这个方案既省显存又不会产生拼接痕迹。第二个技巧是使用自动混合精度训练。在 PyTorch 里只需要加两行代码训练速度能提升约 40%显存占用能减少约 30%而精度损失几乎可以忽略。我实测在相同超参数下混合精度训练和全精度训练的最终 PSNR 差距在 0.05dB 以内。第三个技巧比较进阶——把膨胀卷积替换为分组卷积的等价形式来减少访存开销。膨胀卷积在 GPU 上的实际计算效率有时并不高因为它破坏了访存的局部性。但这个是性能优化层面的问题新手建议先跑通全流程再回来优化速度。5. 复现后的感悟与进一步改进方向5.1 吃透论文与复现结果之间的关系复现一篇论文最大的收获不是拿到一个漂亮的 PSNR 数字而是在这个过程中真正理解每个设计决策背后的权衡。比如 DD-Net 的膨胀率序列、双分支结构、注意力融合这些东西单独拎出来看都不复杂但它们组合在一起的效果只有亲手实现了才能体会。我在没有阅读代码参考的情况下从零开始复现花了大约两周时间中间大部分时间都花在了排查细节上——注意力权重的初始化、padding 的计算、数据加载的顺序。这个问题很快解决了但它让我意识到论文里那一句我们使用 Adam 优化器背后其实隐藏着太多没有写出来的细节。如果你也是第一次复现网络架构论文我给的建议是不要一开始就追求完美复现先把整个流程跑通哪怕结果很差也没有关系因为流程跑通意味着你理解了数据流的方向。然后在此基础上逐模块替换、逐参数调整每调整一个变量就记录一次结果最后你会形成一份属于自己的经验表这种积累是单纯看论文完全无法获得的。5.2 可以继续尝试的改进方向复现成功之后我花了一些时间思考 DD-Net 还有哪些可改进的空间。一个很自然的方向是把膨胀卷积的膨胀率参数也纳入可学习范围让网络在训练过程中自己寻找最优的膨胀率组合。这个想法在论文里没有提到但理论上每个像素的感受野可以根据局部内容动态调整能够进一步提升模型在不同噪声水平下的适应性。另一个方向是改进融合模块。DD-Net 用的是通道注意力的自动加权策略但无论是权重还是空间位置细节分支和语义分支的融合方式都太固定。如果换成空间注意力机制即让融合权重按照像素位置来学习也许能更好地处理图像中不同区域的噪声差异。我试过一个小实验把融合模块的注意力从全图共享改成局部窗口内共享在 BSD68 上 PSNR 提升了约 0.1dB说明这个方向确实有潜力。当然这些都是锦上添花之事。对于初学者来说我更建议先花时间把 DD-Net 吃透把训练、测试、调参的各个环节都跑顺了再考虑改进和创新。深度学习领域的经验积累没有捷径一篇一篇的论文去啃一个一个的模型去复现才是最快、最扎实的路。