最近在啃 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, dilation=1) # (batch, 32, H, W) d2 = Conv3x3(d1, 32, dilation=1) # (batch, 32, H, W) # 语义增强分支 s1 = Conv3x3(f0, 32, dilation=2) # (batch, 32, H, W) s2 = Conv3x3(s1, 32, dilation=3) # (batch, 32, H, W) s3 = Conv3x3(s2, 32, dilation=5) # (batch, 32, H, W) # 融合模块 fuse = Concat(d2, s3) # (batch, 64, H, W) att = Conv1x1(fuse, 2) # 生成两个通道的注意力权重 att = Softmax(att, dim=1) # 归一化 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 是我实测的平衡点——通道数降到 24,PSNR 有明显下降;通道数升到 64,PSNR 只提升了不到 0.1dB,但训练时间和显存占用翻了近一倍。具体怎么取舍,看你自己的硬件条件。
感受野方面,细节分支由于只用 dilation=1 的普通卷积,从输入到 d2 的感受野是 5×5(两层 3×3 卷积堆叠)。语义增强分支的感受野要大得多:第一层膨胀卷积 dilation=2,感受野为 5×5;第二层 dilation=3,感受野变为 13×13;第三层 dilation=5,感受野达到 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.x,CUDA 版本不低于 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_ch=32): super().__init__() self.conv1 = nn.Conv2d(in_ch, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 32, 3, padding=1) self.relu = nn.ReLU(inplace=True) 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_ch=32): super().__init__() self.conv1 = nn.Conv2d(in_ch, 32, 3, padding=2, dilation=2) self.conv2 = nn.Conv2d(32, 32, 3, padding=3, dilation=3) self.conv3 = nn.Conv2d(32, 32, 3, padding=5, dilation=5) self.relu = nn.ReLU(inplace=True) 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_ch=32): super().__init__() self.conv = nn.Conv2d(in_ch * 2, 2, 1) self.softmax = nn.Softmax(dim=1) def forward(self, d, s): fuse = torch.cat([d, s], dim=1) 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, padding=1) self.detail = DetailBranch(32) self.semantic = SemanticBranch(32) self.fusion = FusionModule(32) self.tail1 = nn.Conv2d(32, 32, 3, padding=1) self.tail2 = nn.Conv2d(32, 3, 3, padding=1) self.relu = nn.ReLU(inplace=True) 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。比如 dilation=3,kernel_size=3,padding 就是 3,这样才能保证输入输出尺寸完全一致。
二是注意力权重的初始化问题。FusionModule 里的卷积层初始化之后,注意力权重接近均匀分布——也就是两条分支各贡献一半。但这时候语义分支还没训练好,它的输出质量较差,如果一开始就给语义分支很大权重,会让训练初期损失非常大。一个可行的做法是给注意力卷积层加一个偏置,初始化时让细节分支的权重偏向 0.6 左右,语义分支在 0.4 左右,等训练进行到中期再让网络自己调整。这个小技巧在论文里没有提,但实测能有效稳定前几个 epoch 的损失曲线。
3.4 训练过程与评价指标解读
训练过程中我记录了每个 epoch 的 PSNR 和 SSIM 变化。第一阶段(前 10 个 epoch),损失下降很快,但 PSNR 提升并不明显,因为模型还在学习基本的结构特征;第二阶段(10 到 60 epoch),PSNR 开始稳步上升,每 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 过小 | 降低学习率或增大 batch |
| loss 出现 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 吃透,把训练、测试、调参的各个环节都跑顺了,再考虑改进和创新。深度学习领域的经验积累没有捷径,一篇一篇的论文去啃,一个一个的模型去复现,才是最快、最扎实的路。