CRN 这套源码我前前后后读了四遍,才敢说自己把框架捋顺了。网上讲级联细化思想的文章不少,但真正把 CRN 源码的工程结构拆开讲清楚的很少。这篇是 CRN 源码详细解析的第一篇,先把框架讲透:项目目录怎么组织、数据怎么流动、训练和推理的入口在哪里、模块之间如何咬合。你不用已经完整读过代码,只要对 PyTorch 有基础了解,跟着这章把骨架立起来,后面再看细化模块和损失实现会轻松很多。
这套框架的任务背景是图像复原一类的像素级预测:低质量图像进,高质量图像出。CRN 的核心思路是级联细化,也就是把生成结果拆成多个阶段,从粗糙的全局结构逐步补到精细的高频细节。源码里最能体现这一点的,就是模型前半段特征提取、后半段串行堆叠细化阶段,以及每个阶段都带一个独立输出头。第一讲我不急着贴每一行代码,先把这些结构之间的组织逻辑讲清楚。
1. 整体框架设计与模块划分
1.1 项目目录结构与各模块职责
CRN 源码和很多 PyTorch 工程一样,采用了“入口脚本 + core 引擎 + models 模型 + configs 配置”的分层结构。我第一次打开它的时候先数了一级目录,基本就能猜到代码组织方式了。
crn/ ├── configs/ # yaml 配置文件 │ ├── default.yaml │ ├── crn_base.yaml │ └── crn_large.yaml ├── core/ # 训练、验证、测试的通用引擎 │ ├── trainer.py │ ├── validator.py │ ├── tester.py │ └── hooks.py ├── data/ # 数据读取、增强、采样 │ ├── datasets.py │ ├── transforms.py │ └── samplers.py ├── models/ # 网络模块定义 │ ├── crn.py # 主干模型 │ ├── backbone.py # 特征提取 │ ├── refinement.py # 细化模块 │ ├── heads.py # 输出头 │ └── losses.py # 损失函数 ├── utils/ # 日志、指标、可视化等公共工具 │ ├── logger.py │ ├── metrics.py │ └── visualizer.py ├── tools/ # 命令行入口 │ ├── train.py │ ├── test.py │ └── export.py ├── experiments/ # 每个实验的输出目录 └── requirements.txtconfigs 目录把所有超参集中管理,换数据集、换模型规模时不需要动业务代码。core 目录是框架的发动机,trainer 负责一个 epoch 里循环调度数据、模型、优化器,validator 和 tester 分别承担验证和测试流程。models 目录最纯粹,只关心网络结构,不碰优化器和数据流。tools 目录下的脚本很薄,主要做参数解析和组装工作,真正干活的是 core 里的类。experiments 目录保存每次运行的 checkpoint 和日志,所有实验产物按 run_id 隔离,方便事后对比。
这样分层的最大好处是稳定部分和易变部分被分开了:模型经常改,数据经常换,但“跑一个 epoch 然后保存模型”这件事几乎没变过。把通用的生命周期逻辑抽到 core 里,模型和数据的改动就不容易污染训练流程。
1.2 分层设计背后的取舍
如果项目只是个人实验,很多人会把所有逻辑写进一个大 train.py,几十行把数据加载、前向、反向都写完,看着直接跑得快。但 CRN 这种规模的项目如果也这么写,一旦开始加新模型、新数据集、新指标,代码很快就会膨胀成几百行的函数,改一处牵全身。
分层之后,依赖方向变得清晰:入口脚本依赖 core、models、data,core 只依赖 models 和 utils,models 不依赖 data。反向没有依赖,意味着你抽掉某个数据集不会影响模型定义,换一个优化器也不影响数据加载。源码里有大量build_model、build_optimizer、build_dataloader这类工厂函数,目的就是让对象的创建完全交给配置驱动。
它也有成本。第一次读源码的人会在这些抽象层里迷路,因为光追踪一个 dataloader 的构建可能就要跨三四个文件。我的建议是读的时候不要从上往下铺开读,而是沿着主调用链走:tools/train.py读完组装过程,进core/trainer.py看fit(),再进入_train_epoch(),最终落到model.forward()。先抓住这一条主线,其他分支(hooks、utils、可视化)都是支线,后面需要再看。
1.3 配置驱动与工厂注册模式
CRN 源码里有个很典型的设计:模型、损失、数据集都通过注册表创建。注册表本质上是一个字典,key 是配置里写的字符串,value 是类或构造函数。需要新增一种模型时,不是去改 train.py,而是在 models 包里写一个新类,然后挂上注册装饰器。
MODEL_REGISTRY = {} def register_model(name): def wrapper(cls): MODEL_REGISTRY[name] = cls return cls return wrapper @register_model("crn") class CRN(nn.Module): def __init__(self, cfg): super().__init__() ...对应的构建函数只需要从配置里取出 name,再到注册表里查找并实例化。这种模式不是 CRN 独有的,很多工程框架都在用,但理解它会大幅提高读码效率。你看到build_model(cfg)时,不用关心内部有多少条件分支,只需要知道配置里的model.name: crn决定了加载哪个类。后面你如果要加一个crn_light,唯一要做的就是新建类、注册、写配置。
2. 训练主流程与数据流解析
2.1 从命令行入口到 Trainer 初始化
训练的执行入口是python tools/train.py -c configs/crn_base.yaml。这个脚本的逻辑特别直白,几乎就是按顺序组装所有组件。伪代码可以简化成这样:
def main(): cfg = load_config(args.config) setup_seed(cfg.seed) setup_device(cfg.device) setup_logger(cfg) train_loader, val_loader = build_dataloaders(cfg) model = build_model(cfg).to(cfg.device) optimizer = build_optimizer(model, cfg) scheduler = build_scheduler(optimizer, cfg) criterion = build_loss(cfg.loss) trainer = Trainer(cfg, model, optimizer, scheduler, criterion) trainer.fit(train_loader, val_loader)这个初始化链看着平淡,但每个 setup 都有实际意义。setup_seed 是为了让实验可复现,虽然分布式场景下 dataloader 的 worker 随机性不完全受它控制,但至少保证主流程的随机种子固定。setup_device 处理 CPU/CUDA 的选择,也负责打印当前硬件信息。build_dataloaders 内部会读取配置里的批量大小、worker 数量、图像尺寸,然后构造训练集和验证集。
optimizer 和 scheduler 同样通过配置创建,典型配置里会写优化器类型、学习率、权重衰减、以及余弦退火之类的调度策略。把调度器从训练循环中剥离出来是这套框架做得好的地方:训练代码不用关心 lr 是怎么变的,每个 epoch 开始前调用一次 scheduler.step() 就行。
2.2 一个训练 Epoch 的数据流与中间监督
进入_train_epoch()后,流程就固定下来了。加载一个 batch,数据移动到 device,模型前向,损失计算,反向传播,优化器更新。逐条展开就是下面这段:
for batch in train_loader: x, y = batch x = x.to(device) y = y.to(device) outputs = model(x) # list of tensors loss = compute_loss(outputs, y, cfg.loss) optimizer.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), cfg.grad_clip) optimizer.step()这里最关键的是最后两行位置不能错:先 backward,再 clip grad,最后 step。很多人写顺手了会写成 zero_grad -> step -> backward,那梯度根本没计算,模型参数永远不变,指标自然不涨。源码里专门抽了一个compute_loss函数,因为 CRN 这类级联网络训练时并不只用一个最终输出计算损失,而是用中间监督:模型每个细化阶段都会产出一个预测,每个预测都跟标签算一次损失,最后按权重求和。
中间监督的好处是梯度可以更直接地回传到早期细化阶段。级联网络有多个阶段,如果只把损失放在最后一个输出上,前几个阶段的参数只能靠链式法则间接拿到梯度,收敛速度会被拖慢。早期阶段学到的粗糙预测本身就有语义意义,给它单独算损失相当于给每一层都配了一个“小目标”,训练起来比只盯最终结果稳定得多。
2.3 验证、测试和训练在框架里的差异
CRN 源码中验证和训练不是两套完全独立的代码,而是通过 model 的 mode 切换来实现。训练时模型处于 train 模式,dropout 生效,BatchNorm 统计信息会更新;验证时切到 eval 模式,关闭这些随机性,并且整个前向过程包在torch.no_grad()里,不计算梯度,也不保留中间变量。
model.eval() with torch.no_grad(): pred = model(x) psnr = calculate_psnr(pred[-1], y)验证时代码只取 outputs 的最后一个预测去算 PSNR、SSIM 这些指标,前面的中间输出只是为了监督训练,不参与最终指标计算。validator 每个 epoch 结束后跑一遍验证集,记录最佳指标,然后把对应 checkpoint 另存为best.pth。tester 则更进一步,通常只加载一个已经训练好的权重,跑完保存输出图像,不做任何指标相关的标签处理。导出时还会走 export 脚本,把模型固定成指定输入尺寸的可部署格式,这一步要特别留意 BatchNorm 的统计信息是否被正确固化。
| 阶段 | 是否计算梯度 | 模式 | 主要产出 |
|---|---|---|---|
| 训练 | 是 | train | loss、更新参数 |
| 验证 | 否 | eval | PSNR/SSIM、best checkpoint |
| 测试 | 否 | eval | 预测结果图 |
| 导出 | 否 | eval | 固定输入尺寸的部署模型 |
3. 核心网络模块的框架实现
3.1 主模型如何组织级联细化结构
CRN 主模型在 models/crn.py 里,结构上可以拆成三段:backbone 负责特征提取,refine_stages 负责逐阶段细化,heads 负责每个阶段输出预测图。骨架代码大致长这样:
class CRN(nn.Module): def __init__(self, cfg): super().__init__() self.backbone = build_backbone(cfg.model) self.num_stages = cfg.model.num_stages self.refine_stages = nn.ModuleList([ RefinementBlock(cfg.model) for _ in range(self.num_stages) ]) self.heads = nn.ModuleList([ OutHead(cfg.model) for _ in range(self.num_stages) ]) def forward(self, x): feats = self.backbone(x) pred = None outputs = [] for stage, head in zip(self.refine_stages, self.heads): pred = stage(feats, pred) outputs.append(head(pred)) return outputs这里有两个细节值得细看。第一,refine_stages 用的是nn.ModuleList而不是普通 Python list。如果你用普通列表,模块不会注册到模型树上,model.parameters()里根本不会出现这些层的参数,训练时 optimizer 也没法更新它们,模型输出可能完全正常但指标就是不动。这类 bug 在源码改编里非常常见,第一遍读代码时我会特别留意这点。
第二,pred 作为上一阶段的输出传入下一阶段。这就是“级联”在代码层面的体现:后一个细化阶段看到的是前面阶段的当前结果,它要做的就是在这个基础上继续修正。越往后,pred 越接近最终目标,细化幅度也会越来越小。
3.2 细化模块的设计要点
细化模块是 CRN 框架里最核心的组件。它的输入通常有两个:主干特征和上阶段的预测,输出是一个更新后的预测或特征。内部设计一般会结合残差连接、门控和注意力机制。
先看残差。细化阶段没有必要从零开始生成输出,更合理的做法是只预测一个增量。设上阶段输出为pred_prev,细化模块学习到一个残差res,新的预测就是pred_prev + res。这个设计让网络在前几个阶段已经拿到大致正确的结构后,后续阶段只需要集中精力补细节。生活化理解就像画画:先勾勒轮廓,后面几遍只是修阴影和边缘,不需要重画。
再看门控。有些区域已经足够精细,不需要继续改动,盲目修改反而会破坏结果。源码里常见做法是用 sigmoid 生成一张 0 到 1 的掩码图,控制每个像素位置被更新的程度。掩码接近 1 的位置充分更新,接近 0 的位置保持原样。这个机制对图像复原任务特别合适,因为噪声和模糊在空间上分布并不均匀。
空间注意力或者通道注意力也会出现在细化块里。空间注意力让网络动态聚焦复杂区域,通道注意力让网络知道当前应该修纹理还是改颜色。把这些机制组合进一个块里,代码上通常就是几次 conv、一个 sigmoid、一个残差相加。看起来不复杂,但排列顺序和是否有归一化层会明显影响训练稳定性。
3.3 多尺度流动与上下采样策略
级联细化网络天然涉及多尺度信息,因为早期阶段需要的是全局结构,感受野必须够大;后期阶段需要的是细节,感受野可以小一些。最简单的方式是网络整体先下采样再上采样,像 U 型结构那样。但 CRN 框架里更常见的是每条细化分支内部都做轻微下采样,拿到低分辨率特征做粗修正,再插值回原分辨率做细修正。
下采样可以用 stride=2 的卷积,也可以用最大池化。上采样主要有两种:F.interpolate和转置卷积。F.interpolate没有可学习参数,模式选bilinear时保持平滑,训练稳定,也不容易出网格状伪影;转置卷积更灵活,能学到上采样核,但如果 kernel size 和 stride 没配好,容易在输出图上留下棋盘格噪声。
CRN 源码通常在配置里留一个开关,控制是否共享细化阶段的参数。共享模式下所有 refine_stages 是同一个模块,参数量小,显存占用低,适合在端侧模型里用;独立模式下每个阶段都有自己的参数,表达能力更强,适合追求精度的场景。这个取舍本质上是容量和效率之间的平衡,改代码时只需要控制 ModuleList 里是创建同一个实例还是不同实例。
4. 配置系统、日志与运行管理
4.1 配置文件的结构与合并逻辑
配置系统是 CRN 框架的另一条主线。把所有可调项抽到配置里,好处是你不需要看懂全部代码就能跑实验,只改 yaml 就能切换数据集、网络深度、损失配比。一个典型的基础配置长这样:
model: name: crn in_channels: 3 num_stages: 4 hidden_dim: 128 shared_refine: false data: train_root: /data/train val_root: /data/val batch_size: 16 num_workers: 8 train: epochs: 300 lr: 0.0002 optimizer: adam scheduler: cosine grad_clip: 1.0 loss: - type: l1 weight: 1.0 - type: perceptual weight: 0.1源码读起来有一个让人头疼的地方:配置文件并不是单份生效,而是多层合并。default.yaml 提供所有参数的默认值,具体实验配置只写要覆盖的字段,命令行再用--train.lr这种形式做最高优先级覆盖。理解这一点后,遇到奇怪参数时不要直接搜索,先把最终的 merged cfg 完整打印出来,再确认它来自哪一层。
# 命令行覆盖示例 python tools/train.py -c configs/crn_base.yaml --train.epochs 100 --train.lr 0.0014.2 Checkpoint 保存与恢复训练
CRN 框架的 checkpoint 保存函数写得比较规整,一个 dict 里同时包含 epoch、模型参数、优化器状态、调度器状态和最佳指标。这样恢复训练时才能精确接上之前的进度。
torch.save( { "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "best_metric": best_metric, "amp": scaler.state_dict() if use_amp else None, }, ckpt_path, )为什么保存state_dict而不是直接 pickle 整个 model 对象?因为模型类代码会迭代更新,旧环境反序列化出来的对象很可能在新环境加载时报错,而 state_dict 里只是参数张量和缓冲区的名字与数值,与代码版本解耦。恢复训练时也要记得从 checkpoint 里恢复 epoch 计数,否则 cosine 调度器的学习率起点就错了。混合精度训练如果用了 GradScaler,它的状态也要保存,否则恢复训练的前几步可能因为 scale 因子突然重置出现短暂精度波动。
4.3 实验目录管理与记录规范
框架把每个实验的结果放在 experiments/run_xxx 目录下,里面再按文件类型分开。通常包括最终生效的 config.yaml、训练日志 train.log、评估结果 eval_results、checkpoints 目录。把配置文件复制到实验目录而不是直接引用原文件,是为了防止后续改了配置文件之后,旧结果无法复现。训练日志用统一 logger 同时输出到控制台和文件,指标记录兼容 TensorBoard 格式,方便批量对比不同配置下的 loss 曲线和 PSNR 曲线。
我的习惯是每个实验跑完后立刻把 run_id、大致效果、改了什么参数写进一个实验笔记里。框架只负责存储原始日志,整理成结构化认知还是得靠自己在实验管理上留一手。
5. 常见问题与排查技巧实录
5.1 读这套框架源码时的几个卡点
读 CRN 源码最容易懵的位置是第一接触 trainer。fit 方法里会穿插 hooks 调用,日志钩子、checkpoint 钩子、评估钩子,一层套一层,不熟悉回调机制的人会跟到一半迷路。我第一次读的时候直接打印了调用栈,发现主循环其实很短,大量代码都在 hooks 里,而这些 hooks 对理解核心流程没有影响,知道它们会在哪个节点被触发就够了。
还有一个卡点是工厂函数太多。build_model往注册表里查,build_dataloader也往注册表里查,不搞清楚注册机制就会觉得像变魔术。遇到这种代码,我建议先打印MODEL_REGISTRY.keys(),看到全部可用的 key,自然就知道配置里的 name 对应的是什么。还有一种情况是配置文件合并后参数值很怪,怎么都复现不出别人的结果,先检查是不是命令行残留参数在起作用。
5.2 运行框架时的高频报错速查
| 报错表现 | 可能原因 | 排查路径 |
|---|---|---|
| shape 不匹配 | 数据集输出通道与模型输入不一致 | 打印一个 batch 的 data shape,和模型 forward 输入对齐 |
| 显存 OOM | batch 太大或多尺度中间结果没释放 | 减 batch_size 复现,再把中间输出及时 del |
| 指标不涨 | 中间损失没加入总损失 | 看日志中所有 loss 分量是否都在反传图里 |
| 参数不更新 | 子模块用了普通 list 而不是 ModuleList | 检查模型定义,比较 parameters() 前后数量 |
| 恢复训练后 lr 异常 | epoch 计数没有从 checkpoint 恢复 | 打印恢复后的 scheduler 状态 |
| 验证结果比训练差很多 | BN 在 eval 模式统计量与训练不一致 | 限制 BatchNorm 更新或改用更小的 batch 差异 |
最隐蔽的是普通 list 装模块的问题,代码执行不报错,forward 也能算,但网络性能完全不对。排查时可以打印len(list(model.parameters())),新增一个 stage 前后对比参数数量,如果数量没变,基本上就是注册方式出了问题。
5.3 二次开发时推荐的改造路径
如果你打算在这套框架上改自己的网络结构,我建议按这个顺序动刀。第一步,把配置文件里的 num_stages 改成 1,hidden_dim 改小,batch_size 也不要太大,先跑通整个纯净 pipeline。第二步,打印数据流里每个关键张量的 shape,从 dataloader 到 backbone 输出,再到最后 heads 输出,确认每个阶段特征的分辨率和通道数。第三步,新增模块时先在 models 里写一个最小类,注册后接入配置,用单 batch 验证 forward 能跑,再看 loss 是否逐步下降。
改模型结构时,优先保证主干特征的分辨率变化符合预期。很多形状不匹配的问题其实都是下采样次数和上采样参数没对齐。上采样恢复尺寸时,我用F.interpolate配合align_corners=False的次数最多,因为它不会引入额外参数,也最容易排查。等结构稳定之后,再切换回独立细化阶段、加大 hidden_dim,针对性看指标变化。
我个人实际读源码时最有用的一个习惯是每跟踪完一个关键函数,就把当前张量的 shape 和变量名记在注释旁边。框架的抽象层多,纯靠脑子记调用关系容易串线,写下来之后每条数据流的形状一目了然。CRN 这篇只讲到框架层,后面细化模块的完整实现、中间监督的损失权重设置、以及如何选择共享还是独立细化阶段,都值得单开一篇接着聊。建议先把这篇里的调用链自己走一遍,再看下一部分时会更顺。