深度学习六大组成部件:从数据管道到模型部署的工程拆解
1. 把深度学习当成一台机器来拆:先看清它的六个部件
我见过太多人把深度学习理解成"堆层"——以为把网络堆深一点、参数堆多一点,模型自然就聪明了。真上手跑第一个项目就会发现不是这么回事:层堆上去了,loss 不降;loss 降了,验证集不涨;验证集涨了,上线又崩。问题往往不出在"层"上,而出在深度学习这台机器的其他部件没装好。深度学习的组成这件事,讲清楚它比讲清楚某个具体算法更难,因为它不是一个公式,而是一套互相咬合的装置:数据、网络结构、损失函数、优化器、训练循环、评估与部署,六个部件少一个都转不起来。
这篇东西适合两类人看。一类是刚学完反向传播、能背出卷积公式,但第一次自己搭完整训练流程时会发懵的同学;另一类是已经在用现成框架调模型,但总觉得"知其然不知其所以然",想把每个部件的作用边界摸清楚的人。我不打算按教科书的顺序从头推导,而是按"一台机器怎么装起来"的顺序讲,每个部件都会说清楚它负责什么、为什么非它不可、实际动手时最容易在哪翻车。
先给一个总览,让你脑子里有个地图。下面这张表是我自己复盘项目时常用的清单,六个部件对应六个问题,任何一个问题答不上来,训练大概率会出问题。
| 部件 | 它回答的问题 | 出问题的典型症状 |
|---|---|---|
| 数据管道 | 模型看到的是什么 | 训练 loss 正常但线上崩、类别严重不均衡 |
| 网络结构 | 模型有多大的表达能力 | 欠拟合、显存爆掉、深层网络梯度消失 |
| 损失函数 | 模型该往哪个方向变好 | loss 不降、指标和业务目标对不上 |
| 优化器 | 每次该迈多大一步 | 震荡不收敛、收敛到很差的山谷 |
| 训练循环 | 整个学习过程怎么组织 | 过拟合、学习率调度不当、训练不稳定 |
| 评估与部署 | 学到的本事能不能用出去 | 离线指标漂亮、线上延迟或精度不达标 |
1.1 大多数人第一遍学深度学习时卡在哪
卡点通常不在数学。反向传播的链式法则、卷积的滑动窗口,这些看两遍视频基本能懂。真正卡住人的是"部件之间的接口"——数据管道的输出形状必须和网络第一层的输入对齐,网络最后一层的输出维度必须和损失函数的期望格式对齐,损失函数的量纲又会影响优化器的学习率该设多大。这些接口对不上,报错信息往往只告诉你形状不匹配,不会告诉你设计意图错在哪。
我自己最早踩的一个坑特别典型:做一个多分类任务,网络最后输出 10 维,我顺手接了均方误差当损失。训练能跑,loss 也在降,但收敛极慢,准确率卡在及格线附近。后来换成交叉熵,同样的网络、同样的数据、同样的学习率,三轮之内就明显好于之前训二十轮的结果。原因不复杂——均方误差配 softmax 输出时,梯度里会多出一个 softmax 导数项,在预测已经很离谱的时候梯度反而被压小了,等于模型最需要用力的时候没力气。这就是"部件接口"没对齐的代价:单看每个部件都对,拼在一起就不对。
1.2 六个部件各自负责什么,缺了哪一个都跑不起来
用一条工厂流水线打比方会更直观。数据管道是原料供应,它决定了送进车间的钢材是什么规格、有没有杂质;网络结构是机床,决定了能加工出多复杂的零件;损失函数是质检标准,它定义了什么叫"合格";优化器是操作工人,它决定每次调整刀头调多少;训练循环是排班表,决定一批干多少件、干几轮、什么时候停下来复核;评估与部署是出厂检验和物流,决定这东西能不能真的交到客户手上。
这个类比里最容易被忽视的是"质检标准"和"操作工人"的耦合。质检标准定得太松(损失函数选错),工人再努力也白搭;工人步子迈太大(学习率过高),质检标准再准也会在产品之间来回横跳。所以后面讲损失函数和优化器时,我会把这两个放在一起讲,因为它们在实际调参中从来不是独立的两件事。
再补一个容易被漏掉的点:这六个部件不是线性顺序,而是带反馈的。评估结果会倒逼你回去改数据管道,训练循环里的过拟合信号会倒逼你回去改网络结构或加正则。把这台机器理解成"一环扣一环的环路",而不是"一条直线走到底",你对深度学习组成的理解就到位了一半。
2. 数据管道:占据七成工作量却最不显眼的那一环
真正做过完整项目的人都有一个共识:模型结构可以抄,数据管道抄不了。开源仓库里网络定义往往几十行就写完了,但数据加载、清洗、增强、对齐的那部分代码,换一个数据集就完全不一样。这一节讲数据管道,重点不是教你用哪个库的 Dataset 类,而是讲清楚它在整个系统里承担了什么职责,以及哪些设计决策会在后面以"玄学"的形式报复你。
2.1 张量的形状是整条管道的契约
数据管道和网络之间靠"形状"通信。以图像分类为例,一个 batch 的张量形状通常是[N, C, H, W],N 是 batch size,C 是通道数,H 和 W 是空间尺寸。这个形状必须和网络第一层期待的输入完全一致,差一个维度就会报错,方向错了(比如把 C 放到最后)不一定报错,但结果会莫名其妙地差。
我建议在写任何模型代码之前,先写三行打印:一条样本的形状、一个 batch 的形状、模型第一层的输入期望。这三个数字对上了再往下走,能省掉大量排查时间。更稳妥的做法是把形状检查写进数据管道的断言里,训练启动时自动跑一遍。
import torch def check_shapes(dataset, model, batch_size=8): sample, label = dataset[0] print("单样本:", sample.shape, "标签:", label) loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size) batch_x, batch_y = next(iter(loader)) print("一个 batch:", batch_x.shape, batch_y.shape) print("模型首层期望输入通道:", model.first_layer_in_channels) assert batch_x.shape[1] == model.first_layer_in_channels这段代码看着简陋,但在我经历过的项目里,它至少拦下过五次"训练跑了一晚上才发现通道顺序反了"的事故。
2.2 数据增强、打乱与归一化:三个动作背后的逻辑
数据增强的本质是给模型注入"这个变换不该改变答案"的先验。图像随机水平翻转,等于告诉模型"左右镜像之后还是同一类物体";随机裁剪,等于告诉模型"物体出现在画面哪个位置都算数"。这个先验如果和你的任务不符,增强就是纯伤害——比如识别数字或者文字方向敏感的任务,水平翻转会把 6 变成 9 的镜像,直接把标签搞错。我见过有人做票据识别时无脑加了翻转增强,训了两天发现准确率上不去,查了半天才反应过来。
打乱(shuffle)的作用是打断样本之间的顺序相关性。如果数据是按类别排好序的,不打乱的话一个 batch 里全是同一类,梯度方向会严重偏斜,模型会在不同类别之间来回震荡。这个坑在自建数据集上特别常见,因为很多人是分类别收集、分类别存盘的。
归一化是三个动作里最容易被敷衍过去的。把像素从[0,255]缩到[0,1]只是最基础的一步,真正影响收敛的是减均值除标准差。原因是不同通道的数值分布差异会让各层的激活值量级不一致,梯度下降在不同方向上步长感受完全不同,训练会变慢甚至不稳。均值方差必须用训练集统计,不能把验证集和测试集一起算进去,这是数据泄漏最常见的形式之一,而且它不会报错,只会让你的离线指标虚高。
| 动作 | 目的 | 常见错误 | 后果 |
|---|---|---|---|
| 数据增强 | 注入不变性先验 | 用了与任务语义冲突的变换 | 模型学到错误不变性,上限被压低 |
| 打乱 | 打断顺序相关性 | 自建数据集忘记开 shuffle | 梯度方向偏斜、收敛震荡 |
| 归一化 | 统一各通道数值量级 | 用全量数据统计均值方差 | 数据泄漏,离线指标虚高 |
| 类别均衡 | 避免多数类主导 | 直接按原始分布训练 | 少数类召回极低 |
2.3 类别不均衡与采样策略:一个必须提前决策的问题
类别不均衡这件事,如果等到训练完发现少数类召回惨不忍睹再回来处理,成本会高很多。处理思路大致分三层:数据层做重采样(对少数类过采样或对多数类欠采样)、损失层做类别加权、评估层换用更合适的指标(比如 F1、AUC 而不是准确率)。
我个人的偏好是先改评估指标,再看要不要动损失权重。原因是很多时候"准确率低"只是个观测口径问题,模型本身没那么差;如果换了指标发现确实差,再上类别加权,最后才考虑重采样,因为重采样会改变数据分布,过采样还容易导致少数类过拟合。这个顺序不是铁律,但它符合"改动越小越优先"的工程直觉。
注意:做重采样时一定要在划分训练集和验证集之后做,只在训练集上重采样。在划分之前做,验证集会被污染,指标就失去参考意义了。
3. 网络结构:层与层之间传递的不只是数值
网络结构是深度学习里最"显性"的部分,也是最多人愿意花时间研究的部分。但我想强调一个反直觉的观点:对一个具体项目来说,结构选型的重要性往往低于数据质量和损失函数设计,因为结构决定了能力上限,而数据和损失决定了模型能不能摸到这个上限。不过结构选错了,后面的努力会全部打折,所以还是得讲清楚。
3.1 从全连接到卷积,选型时真正该看的三个指标
选结构时最实用的三个判断维度是:输入的空间结构有没有意义、参数量预算、推理时的算力预算。
输入如果是表格数据、特征向量,各维之间没有明显的邻接关系,全连接层就是合理选择。输入如果是图像、频谱、时序,相邻位置之间存在强相关,卷积的局部连接和权重共享就能大幅降低参数量同时保留归纳偏置。文本这类序列数据,早期用循环结构,现在主流是注意力机制,核心原因是注意力能直接建模任意两个位置的关系,不受距离衰减影响。
参数量这块我给一个能口算的对照,方便你快速估算预算。以一个 3 通道 224×224 的输入为例:
| 结构 | 配置 | 参数量估算 |
|---|---|---|
| 全连接 | 输入 3×224×224 直接接 512 维隐层 | 约 7700 万 |
| 卷积 | 64 个 3×3 卷积核,输入 3 通道 | 1792 |
| 卷积 | 128 个 3×3 卷积核,输入 64 通道 | 73728 |
| 全连接(分类头) | 512 维接 10 类 | 5130 |
从这张表能直接看出卷积的威力:同样是把图像压到 512 维,全连接要七千多万参数,卷积只需要不到两千。这不是精度问题,这是能不能在一张普通显卡上跑起来的问题。我第一次看到这个对比时的感受是,结构设计的本质是"用先验换参数",你把"局部相关"这个先验写进结构里,就能省下大量本来要靠数据学出来的参数。
3.2 感受野与下采样:为什么深层网络能看到更大的范围
卷积核本身很小,3×3 只能看到相邻 9 个像素,但堆叠之后感受野会线性增长。两层 3×3 堆叠的感受野是 5×5,三层是 7×7。加上池化或步长卷积做下采样之后,特征图尺寸减半,同样大小的卷积核在原始图上的覆盖范围会翻倍。这就是深层网络能"看到全局"的机制:不是靠单个大卷积核,而是靠层层下采样加小卷积核堆叠。
理解这一点在实际操作中很有用。如果你发现模型对远距离目标的关联判断很差,可能是下采样不够、感受野太小;反过来,如果模型对小目标完全不敏感,可能是下采样太激进,小目标在深层特征图上已经消失成一两个像素了。这两个症状方向相反,但根因都在同一套机制上。
3.3 残差连接与归一化层:让深层网络能训得动的两个补丁
网络层数堆到一定程度之后会出一个现象:加深反而变差,而且不是过拟合,是训练集上的误差都变大了。原因是梯度在反向传播时经过层层相乘,要么衰减到接近零,要么爆炸。残差连接解决的是这个问题,它给梯度提供了一条"短路"通道,让梯度可以绕过若干层直接回传。归一化层解决的是另一面,它把每层激活值的分布拉回到相对稳定的范围,避免层与层之间的分布漂移累积。
这两个东西在现代网络里几乎是标配,但用法上有细节。残差连接要求输入输出维度一致,维度不一致时要么用 1×1 卷积做投影,要么用零填充补齐。归一化层在 batch 维度做统计对小 batch 很不友好,batch size 很小的时候统计量噪声大,这时候换成对通道或对层做归一化的变体更稳。我在小 batch 场景下踩过这个坑:batch size 只有 4 的时候用批归一化,训练 loss 剧烈震荡,换成组归一化之后立刻平稳。
4. 损失函数与优化器:学习这件事的动力学
这一节是整篇文章里我最想讲透的部分,因为损失函数和优化器的耦合关系是深度学习组成里最"物理"的一块。损失函数定义了地形,优化器定义了你怎么在这片地形上走。地形和走法不匹配,再好的模型结构也白搭。
4.1 损失函数决定了模型朝哪个方向低头
损失函数的本质是一个"惩罚函数",它给模型的每个预测打分,分越高说明越离谱。训练的目标就是让所有样本的惩罚总和尽可能小。关键在于:不同的惩罚方式对应不同的"关注重点"。
均方误差对大误差的惩罚是平方级的,一个偏差 10 的样本贡献是偏差 1 的样本的 100 倍,所以它对离群点特别敏感。如果你做回归任务而数据里有标注错误的离群点,均方误差会被这些点牢牢拽住。绝对值误差对大误差的惩罚是线性级的,对离群点更宽容,但在零点附近不可导,训练后期收敛会抖。
分类任务最常用的是交叉熵。它衡量的不是预测值和真实值差多少,而是"模型的概率分布和真实分布差多远"。交叉熵配 softmax 输出的好处是梯度形式极其简洁——梯度就是预测概率减真实标签,预测越离谱梯度越大,修正力度越强,这个动力学非常符合直觉。
还有一种容易被忽略的情况:业务目标和损失函数不一致。比如你真正关心的是"能不能排进前 10",而损失函数优化的是整体概率分布,这两者在数据长尾严重时会分叉。这时候不要指望调参解决,得从损失函数层面下手,比如引入排序损失或者对关键样本加权。我个人的经验是,凡是业务指标有明确"阈值"或"排序"含义的任务,都要认真考虑损失函数是不是该换。
4.2 优化器:SGD、动量与自适应方法的取舍
梯度下降系列方法的差别可以用"下山"来类比。朴素 SGD 每一步只看脚下最陡的方向,简单但容易在峡谷地形里来回震荡。加动量之后相当于给了一个惯性,震荡会被平均掉,方向更稳。自适应方法(如 Adam 系列)则是按每个参数的历史梯度幅度自动调整每个方向的步长,对于稀疏特征、参数量纲差异大的场景特别友好。
这三类方法怎么选,我给一个偏实用的判断:
| 优化器 | 适合场景 | 主要缺点 | 常用学习率量级 |
|---|---|---|---|
| 朴素 SGD | 数据量大、追求极致泛化 | 收敛慢、对学习率敏感 | 0.1 ~ 0.01 |
| SGD + 动量 | 计算机视觉主流配置 | 仍需手动调度学习率 | 0.1 ~ 0.01 |
| Adam | 快速验证、稀疏特征、NLP | 泛化有时略逊、权重衰减处理需注意 | 1e-3 |
| AdamW | 大模型训练主流 | 需要配合调度策略 | 1e-4 ~ 3e-4 |
这里有个细节值得单独说:Adam 里原始的权重衰减实现和 L2 正则并不等价,因为它会被自适应学习率缩放,导致大梯度参数的正则效果被削弱。AdamW 把权重衰减从梯度更新里拆出来单独做,修正了这个问题。如果你用 Adam 且发现正则几乎没效果,大概率就是这个原因,换 AdamW 就好。
4.3 学习率调度:最便宜也最有效的调参手段
如果只能调一个超参数,我会调学习率。学习率太大会在最优解附近反复横跳,loss 曲线呈锯齿状;太小则训练慢到怀疑人生,而且容易卡在次优区域。但真正有用的不是"选一个固定学习率",而是"让学习率随训练进程变化"。
常见调度策略有三类。阶梯衰减是每隔固定轮数把学习率乘一个系数,简单直接。余弦退火是让学习率沿余弦曲线平滑下降到接近零,训练后期收敛更细腻。预热是训练最开始用极小的学习率逐步升到目标值,目的是避免训练初期参数还很随机时被大梯度带偏,这个技巧在 Transformer 类模型上几乎是必需的。
我自己习惯的组合是"短预热 + 余弦退火",预热长度取总步数的 3% 到 5%。这个配置在大部分任务上不需要精调就能得到不错的结果,属于性价比很高的默认值。
import math def lr_lambda(step, total_steps, warmup_steps, base_lr, min_lr=1e-6): if step < warmup_steps: return step / max(1, warmup_steps) progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) cosine = 0.5 * (1 + math.cos(math.pi * progress)) return min_lr / base_lr + (1 - min_lr / base_lr) * cosine这段函数直接传给学习率调度器就能用,返回值是相对初始学习率的倍率。注意min_lr不要设成严格的 0,否则训练末尾参数完全不动,白白浪费轮数。
5. 训练循环里那些容易被忽略的开关
训练循环的代码往往只有十几行,但它包含了大量"开关":batch size、梯度裁剪、混合精度、梯度累积、正则化强度、早停条件。这些开关单独看都不复杂,组合起来就构成了训练稳定性的全部。这一节我按"最常被忽视但影响最大"的顺序讲。
5.1 batch size 与学习率的耦合关系
这两个参数不能独立调。batch size 变大意味着一个 batch 内的梯度估计更准、噪声更小,理论上可以用更大的学习率。业内的经验法则是学习率和 batch size 大致按平方根或线性关系同步放大,具体用哪个取决于任务和优化器。但要注意一个反直觉的现象:batch size 大到一定程度之后,泛化性能反而会下降,因为梯度噪声太小,模型容易陷进尖锐的极小值。
显存不够的时候,梯度累积是一个很实用的替代方案:用小 batch 跑若干次前向反向,把梯度累加起来再统一更新一次,效果接近大 batch。这里有个细节容易错——用批归一化时,梯度累积的等效 batch size 和真实的 batch size 并不等价,因为归一化的统计量还是按小 batch 算的。所以用梯度累积时,前文提到的归一化层选择要一起考虑。
5.2 正则化手段的取舍:dropout、权重衰减、早停
正则化的目标都是限制模型过度拟合训练数据,但作用位置不同。dropout 是在前向时随机关掉一部分神经元,逼网络学出冗余表示,相当于每次训练一个子网络再集成。权重衰减是直接惩罚参数幅度,把解往原点方向拉。早停是在验证集指标开始变差时停下来,本质是一种隐式的容量控制。
这三者的优先级我给个建议:先上数据增强和早停,这两者成本最低、副作用最小;仍有过拟合再考虑权重衰减;dropout 放在最后,因为它在现代结构里效果不如早期明显,而且和批归一化一起用时会互相干扰——dropout 改变激活分布,批归一化的统计量就失准了,训练和推理时行为不一致。如果非要在批归一化网络里用 dropout,放在归一化层之后、激活函数之前相对安全。
还有一个常被忽略的正则手段是标签平滑。它把独热标签从 1 和 0 改成比如 0.9 和 0.1/(K-1),防止模型对训练标签过度自信。这个改动只有一行代码,但在分类任务上经常能带来一两个点的提升,尤其是标注本身有噪声的时候。
5.3 过拟合与欠拟合的现场判断
判断方法其实很朴素:看训练集指标和验证集指标的差值。
欠拟合的表现是训练集指标本身就低,验证集也低,两者挨得很近。这说明模型容量不够或者训练不充分,方向是加容量、加训练轮数、调大学习率。过拟合的表现是训练集指标很高,验证集明显落后,两者拉开差距。方向是加正则、加数据、减容量。
最麻烦的是第三种情况:训练集指标低、验证集指标也低而且两者差距还很大。这通常说明数据管道有问题,比如训练集和验证集的预处理不一致,或者验证集里有训练集没有的类别。我遇到过一次,查了半天最后发现验证集的归一化用了自己算的均值方差,和训练集不一样,等于两个分布。这种问题不会报错,只能靠对比两边的指标形态来发现。
6. 从训练完成到能上线:评估、导出与推理侧的三道关
训练跑完拿到一个还不错的验证指标,很多人就认为活儿干完了。实际上从"训练完成"到"能上线"之间还有相当一段路,而且这段路上的坑和训练阶段的坑性质完全不同。这一节讲三件事:评估口径的坑、导出环节的坑、推理性能的坑。
6.1 验证集、测试集与线上分布的三重差异
验证集用来调参,测试集用来最终评估,这个划分原则大家都知道。但真正的问题是:测试集指标和线上表现之间的差距,往往比测试集和验证集之间的差距更大。原因是测试集和训练集通常来自同一批数据、同一个时间窗口、同一套采集流程,分布高度一致。而线上数据会随着时间漂移。
所以我在实际项目中会额外做两件事。一是构造一个"对抗验证集",用分类器去区分训练集样本和线上样本,如果区分得开,说明分布漂移确实存在,需要针对性处理。二是把评估拆细,不只看总体指标,还看分群指标——按类别、按场景、按时间段分别统计,找出模型真正薄弱的角落。总体指标掩盖局部严重失败是常见现象,一个 90% 的总体准确率完全可能藏着某个重要类别只有 50%。
6.2 导出与推理:精度、速度、显存的三角权衡
模型导出的第一步是把训练框架的模型图转成部署环境能加载的格式。这个转换过程经常出问题,最常见的是动态控制流(比如 python 里的 if 判断依赖张量值)在静态图里表达不了,转换时被固化成一个分支,或者直接报错。规避方法是在模型定义里尽量避免依赖张量值的分支,把逻辑提前到预处理阶段或者用可表达的算子替代。
导出之后就是推理优化。优化手段大致分三类:算子融合(把连续的卷积、归一化、激活合并成一个算子,减少访存)、量化(把 32 位浮点压成 8 位整数,速度和显存都能显著改善)、批处理(一次推理多条样本,提高吞吐)。这三个手段里,量化收益最大但也最容易掉精度,尤其是激活值分布不均匀的模型。稳妥的做法是先做算子融合,再尝试量化,量化时保留少量层用高精度算,做混合量化。
注意:部署侧的评估指标必须用和训练时完全相同的预处理复现一遍,包括归一化的均值方差、图像的缩放插值方式。插值方式不同(双线性 vs 最近邻)就能带来明显的精度差异,这个差异在离线对比时经常被误认为模型本身的问题。
7. 一份最小可运行的骨架,把六个部件串起来
讲了这么多部件,最后落到一份能直接跑的骨架代码上。这份代码刻意写得很朴素,没有用到任何高级封装,目的就是让每个部件在代码里都看得见。
7.1 完整骨架
import torch import torch.nn as nn from torch.utils.data import DataLoader class SmallNet(nn.Module): def __init__(self, in_ch=3, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_ch, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(64, num_classes) def forward(self, x): x = self.features(x) return self.classifier(x.flatten(1)) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_correct, total_num = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad(set_to_none=True) logits = model(x) loss = criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * x.size(0) total_correct += (logits.argmax(1) == y).sum().item() total_num += x.size(0) return total_loss / total_num, total_correct / total_num @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, total_correct, total_num = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss = criterion(logits, y) total_loss += loss.item() * x.size(0) total_correct += (logits.argmax(1) == y).sum().item() total_num += x.size(0) return total_loss / total_num, total_correct / total_num7.2 骨架里每一行的意图
这份代码里几个细节值得单独说。optimizer.zero_grad(set_to_none=True)比默认的置零更省内存,因为它是把梯度设成None而不是填零张量,后续反向时直接覆盖。梯度裁剪放在backward之后、step之前,这是唯一的正确位置,放错位置要么没效果要么报错。@torch.no_grad()装饰评估函数,避免无谓的计算图构建,显存能省一大截。AdaptiveAvgPool2d(1)让模型对输入尺寸不敏感,避免全连接层硬编码特征图大小。
最容易出问题的是训练和评估的model.train()/model.eval()这一对。dropout 和批归一化在两种模式下的行为不同,忘了切换会导致评估指标莫名其妙地差。这个错误我见过不止一次,而且因为不报错,排查起来特别费时间。我的习惯是把这两个调用写进对应的函数里,而不是依赖调用方记得切。
最后一个个人体会。刚接触深度学习时我总觉得这些东西是一堆需要背下来的知识点,后来做多了才慢慢意识到,这六个部件其实是在回答六个层层递进的问题:你给它看什么、它能表达什么、你怎么定义好、你怎么让它变好、你怎么组织这个过程、你怎么证明它真的好。任何一个项目出问题,顺着这六个问题往下问,基本都能定位到出问题的部件上。这个排查顺序比任何调参技巧都管用。