
1. 从一次调参失败说起大概两年前我在做一个图片分类项目baseline用的是ResNet-50准确率卡在92%上不去。我试过加深到ResNet-101提升不到0.5个点但推理时间涨了快一倍试过加宽卷积显存直接爆了又试过把输入分辨率从224拉到320准确率确实上去了但训练时间几乎翻倍。当时我就想深度、宽度、分辨率这三个维度到底怎么组合才最划算怎么调都像在碰运气。直到我看到EfficientNet那篇论文才意识到问题出在哪。Google Brain团队的做法不是靠拍脑袋调参而是先设计了一个性能不错的baseline网络EfficientNet-B0然后通过复合缩放方法把深度、宽度、分辨率三个维度按比例同时放大用一组简单但有效的系数把它们绑在一起。这套方法让EfficientNet在同样算力预算下精度普遍超过当时主流的ResNet、DenseNet、ResNeXt等模型而且参数量和FLOPs还更小。那EfficientNet的底气从哪来核心就在它的基础模块——MBConvMobile Inverted Bottleneck Convolution。这个模块不是EfficientNet原创的但它把MobileNetV2的倒残差结构、Squeeze-and-ExcitationSE注意力机制、Swish激活函数、DropConnect正则化等一堆技巧整合在一起形成了真正高效的基础单元。理解MBConv基本就理解了EfficientNet为什么能又快又准。这篇文章我会从MBConv的每个组件拆开讲完整走一遍它的设计思路再带上可复现的PyTorch实现和训练配置。想深入理解轻量级网络设计、准备做模型压缩或部署的朋友这篇文章应该能帮你省不少时间。2. MBConv结构拆解倒残差、SE、激活和正则化2.1 倒残差块先升维再降维最后残差连接MBConv最基础的结构来自MobileNetV2提出的Inverted Residual Block中文一般叫倒残差块。传统残差块是“降维-卷积-升维”的沙漏形倒残差反其道而行采用“升维-卷积-降维”的纺锤形。名字里有“倒”就是因为这个方向跟常规残差块正好相反。具体流程是这样的输入特征图先经过1x1卷积把通道数从C扩展到tCt是扩展因子EfficientNet-B0里除了第一层MBConv的t1其余都是6这个扩展后的高维空间方便卷积提取更丰富的特征然后做depthwise卷积每个通道独立卷积不跨通道这是整个模块计算量的大头最后再用1x1卷积把通道压缩回C形成残差连接的输入。需要注意的是最后这个投影层不用激活函数叫linear bottleneck。原因是激活函数尤其是ReLU会在低维空间中破坏信息而投影层本身就是在压缩信息如果再套一个ReLU信息损失就更大了。我举个例子帮你理解。假设输入是32x32x16的特征图t6那么升维后变成32x32x96。depthwise卷积处理的是32x32x96的特征图计算量是3x3x32x32x96约27.6万次乘法同样做3x3的普通卷积计算量是3x3x96x96x32x32约2540万次乘法。差距接近100倍。单看这个数字可能不够直观放到整网里MBConv模块的FLOPs大约是同等通道数普通卷积的1/10到1/8这就是MobileNet系列模型能跑在手机上的原因。2.2 深度可分离卷积把“卷积”拆开做省下90%的算力倒残差块里最关键的省算力操作是depthwise separable convolution中文叫深度可分离卷积。标准卷积同时做两件事跨空间聚合和跨通道聚合。而深度可分离卷积把这两件事拆开先用depthwise卷积在每个通道内部做3x3空间卷积再用1x1 pointwise卷积做跨通道信息融合。用公式来表示。假设输入尺寸是HxWxC输出通道是C卷积核大小是K。标准卷积的计算量是K^2 × C × C × H × W。深度可分离卷积的计算量分两部分depthwise是K^2 × C × H × Wpointwise是C × C × H × W。两者相除标准卷积与深度可分离卷积的计算量比值是K² × C × C × H × W / (K² × C × H × W C × C × H × W) K² × C / (K² C)当C比较大时这个值约等于K²。对3x3卷积来说大约省了8到9倍计算量。这也是为什么MBConv能比同规模的传统卷积块更快哪怕它内部多了1x1卷积和SE模块。不过注意深度可分离卷积的参数量确实少了但实际部署时要看硬件对depthwise卷积的支持程度。普通GPU上depthwise卷积的加速效果没那么理想因为它的访存开销更大但换到手机NPU或专用加速芯片上优势就非常明显了。2.3 SE模块花小钱办大事的通道注意力如果只是倒残差加深度可分离卷积那MBConv跟MobileNetV2的basic block没什么区别。EfficientNet的真正差异在于把Squeeze-and-Excitation模块集成到了每个MBConv里。简单说SE模块就是一个“通道注意力”机制它通过学习每个通道的重要性权重把有价值的特征通道放大把没价值的通道压制。用公式来描述原本模块输出的某个通道是uSE给它乘一个标量权重s最终输出是s×u。这个权重s怎么来首先对特征图做全局平均池化得到每个通道的全局统计量zz的长度就是通道数C然后经过两个全连接层第一层把C压缩到C/rr是缩减比EfficientNet里固定为4第二层恢复回C中间用Swish激活最后用sigmoid把输出映射到0到1之间这就是权重s。整体流程可以写成s σ(W2 · δ(W1 · z))其中z是全局平均池化输出W1和W2是两个全连接层的权重δ表示Swish激活σ是sigmoid。SE模块加进去增加了多少计算量两个全连接层总共涉及C²/r × 2个参数对通道数C64的层来说2×64×64/42048个参数跟整个卷积层的参数比几乎可以忽略。FLOPs增加的比例大概在0.1%到1%之间。作为对比这1%的额外算力能带来约2%到4%的准确率提升。这个性价比在工程上是极其划算的。那为什么SE放在depthwise卷积之后、1x1投影之前而不是放在模块末尾这是我一开始看代码时的疑惑。后来想想其实有道理depthwise卷积后的特征图仍然处于高维空间此时做通道注意力重标定能让后续的投影层更精准地挑选重要特征如果放在模块末尾高维信息已经被投影压缩了再给通道加权效果就没那么直接。这个顺序不是随意定的而是在当时消融实验里验证过的。2.4 Swish激活函数从ReLU到平滑门控MBConv里的标准激活函数不是ReLU而是Swish公式是f(x) x · σ(x)σ是sigmoid函数。这个函数的形状很像ReLU和线性函数的折中当x很大时f(x)趋近于x当x很小时f(x)趋近于0在x0附近它不是像ReLU那样的硬转折而是一条平滑曲线。这个平滑特性有什么好处实际操作中Swish能避免ReLU在负半轴直接把信息清零导致的梯度死区问题梯度流更顺畅。另一个好处是它的“软门控”特性对于负值输入不是直接输出0而是保留一个很小的负值这个负值乘上输入本身让网络可以更细致地控制信息流动。论文里给了实验数据用Swish替换ReLU在ImageNet上能带来大约0.6到1.1个点的提升而且不需要额外调参。不过这里要提醒一句Swish在前向推理时计算exp比ReLU慢一些。实际部署时很多框架会把它替换成近似的hard-swish公式是hswish(x) x × relu6(x3) / 6计算成本更低且精度损失很小。如果你的部署工具支持Swish的算子融合那直接用Swish就好如果不支持换成hard-swish是常见的工程妥协。2.5 DropConnect比Dropout更狠的正则化EfficientNet系列在训练时使用了一种叫DropConnect的正则化方法和常见的Dropout不太一样。Dropout是随机把神经元的输出置零而DropConnect是随机把权重矩阵里的某些权重置零。在MBConv里具体表现为随机丢弃整个MBConv块的残差分支。也就是说某个MBConv块有p的概率完全不参与恒等映射只保留旁路卷积的输出或者反过来有p的概率跳过全部卷积操作直接走恒等连接这个p称为stochastic depth rate。EfficientNet-B0的初始丢弃概率是0.2随着网络规模增大B7的丢弃概率线性增长到0.8。实际操作时丢弃概率不是每个块都一样而是按块在网络中的深度线性插值。最前面几层的概率低最后面几层的概率高。这样做的原因是深层的特征更复杂过拟合风险更大所以正则化力度更强。DropConnect在训练时能有效降低过拟合但推理时根本看不到这个模块所有分支都是完整运行的。所以它是纯训练期的技巧不影响线上推理速度和内存。如果你的数据集比较小或者你的任务本身就容易过拟合我建议从p0.2起步逐步试到0.5左右超过0.5就容易出现欠拟合了。3. 复合缩放把网络调大变成一道公式题3.1 为什么单维度缩放不行在EfficientNet之前主流做法是固定网络结构通过增加深度层数、宽度通道数或输入分辨率来提升精度。问题在于这三个维度之间不是独立的。最经典的例子是你把输入分辨率从224提升到320如果网络深度不够感受野跟不上目标尺寸的变化精度的提升很快就会饱和同理如果只加宽度不加深度网络没法学到更深层次的特征抽象。我举个例子。假设你在224分辨率下训练一个20层的网络精度是75%。你把分辨率提高到320精度可能涨到76%。然后你把网络从20层加深到40层分辨率保持320精度可能又涨到77%。但如果你从224分辨率、20层开始只加宽度把每层通道数翻倍你会发现精度可能只涨0.3%而且训练显存消耗和推理延迟上涨得非常快。这是因为宽度的收益很大程度上依赖深度和分辨率的匹配。单维度放大的边际效益是递减的而且很快。3.2 复合缩放的公式推导EfficientNet的思路是把网络缩放问题形式化为一个带约束的优化问题。假设我们有一个基础网络B0深度为d宽度为w输入分辨率为r。缩放后深度d α^φ宽度w β^φ分辨率r γ^φ。约束条件是α · β² · γ² ≈ 2这个式子是怎么来的因为卷积层的计算量大致与深度d成正比与宽度w的平方成正比因为计算量跟输入输出通道数的乘积相关与分辨率r的平方成正比因为特征图的宽高都变大了。假设基础网络的计算量是FLOPS_base缩放后的计算量大约变为FLOPS ≈ FLOPS_base × α^φ × (β^φ)² × (γ^φ)² FLOPS_base × (α · β² · γ²)^φ要让总计算量翻倍即2^φ倍就需要α · β² · γ² 2。这个等式就是复合缩放的核心。其中α、β、γ是三个基本系数论文取α1.2β1.1γ1.15且约束α ≥ 1、β ≥ 1、γ ≥ 1。φ越大模型越大。B0对应φ0B1对应φ1B2对应φ2一直到B7对应φ7。这意味着B7的计算量大约是B0的2^7128倍。当然这128倍不是都在同一精度上持续有收益模型越大增益逐渐递减但相比单维度缩放已经是质的飞跃。3.3 基础网络B0的配置细节复合缩放的前提是有一个好的baseline。EfficientNet-B0不是随机搭出来的而是用NAS神经架构搜索搜出来的。它的具体结构如下表所示Stage操作分辨率通道数层数1Conv 3x3, stride 21123212MBConv1, k3x31121613MBConv6, k3x3562424MBConv6, k5x5284025MBConv6, k3x3148036MBConv6, k5x51411237MBConv6, k5x5719248MBConv6, k3x3732019Conv 1x1 Pooling FC712801这个配置里有两个细节值得注意。第一MBConv的扩展因子t不总是6较早的层stage 2用的是1也就是不升维直接从原来通道数做depthwise卷积。第二卷积核大小不统一有3x3和5x5两种这是NAS搜出来的结果说明混用卷积核在同样计算量下能覆盖不同尺度的感受野。后来很多复现实验也验证了这个结论把5x5替换成3x3精度会有小幅下降但FLOPS也下降需根据实际场景权衡。B0的参数总量是5.3MFLOPS是0.39BImageNet top-1准确率约76.3%。作为对比ResNet-50有25.6M参数和4.1B FLOPStop-1只有76.0%。B0用约1/10的FLOPs就能打平ResNet-50这个差距完全来自MBConv的高效设计。4. PyTorch复现从零手写一个MBConv模块4.1 代码实现与逐行注释理论讲了一堆直接上代码。下面是我在实际项目中验证过的PyTorch实现完整可运行。import torch import torch.nn as nn class SqueezeExcitation(nn.Module): def __init__(self, in_channels, reduced_dim): super().__init__() self.se nn.Sequential( nn.AdaptiveAvgPool2d(1), # 全局平均池化输出 1x1 nn.Conv2d(in_channels, reduced_dim, 1), # 降维 nn.SiLU(inplaceTrue), # Swish 激活 nn.Conv2d(reduced_dim, in_channels, 1), # 恢复维度 nn.Sigmoid() ) def forward(self, x): return x * self.se(x) class MBConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1, expand_ratio6, se_ratio0.25, drop_rate0.2): super().__init__() self.use_residual (stride 1 and in_channels out_channels) hidden_dim int(round(in_channels * expand_ratio)) self.use_expand (expand_ratio ! 1) # 1x1 升维 if self.use_expand: self.expand_conv nn.Sequential( nn.Conv2d(in_channels, hidden_dim, 1, biasFalse), nn.BatchNorm2d(hidden_dim), nn.SiLU(inplaceTrue) ) else: self.expand_conv nn.Identity() # 3x3 或 5x5 depthwise 卷积 self.depthwise_conv nn.Sequential( nn.Conv2d(hidden_dim, hidden_dim, kernel_size, stride, paddingkernel_size // 2, groupshidden_dim, biasFalse), nn.BatchNorm2d(hidden_dim), nn.SiLU(inplaceTrue) ) # SE 注意力缩减比 r 由 se_ratio 决定 reduced_dim max(1, int(in_channels * se_ratio)) self.se SqueezeExcitation(hidden_dim, reduced_dim) # 1x1 降维投影注意不加激活 self.project_conv nn.Sequential( nn.Conv2d(hidden_dim, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels) ) self.drop_rate drop_rate def forward(self, x): residual x x self.expand_conv(x) x self.depthwise_conv(x) x self.se(x) x self.project_conv(x) if self.use_residual: if self.drop_rate 0 and self.training: # DropConnect训练时随机丢弃残差分支 keep_prob 1.0 - self.drop_rate mask torch.empty((x.size(0), 1, 1, 1), devicex.device).bernoulli_(keep_prob) x x / keep_prob * mask x x residual return x代码里有两个地方我要特别强调。第一SE模块的reduced_dim我用的不是hidden_dim除以4而是in_channels乘以se_ratio。EfficientNet原论文里r4是相对于扩展后的通道数但PyTorch官方实现里se_ratio定义的是in_channels的比例即0.25。实际测试下来这两种定义方式精度差异在0.1%以内但后者更省参数。我建议以官方timm库的配置为准。第二DropConnect的实现中mask是per-sample的也就是说每个样本的整个残差分支要么全保留、要么全丢弃。我做实验时踩过坑如果误写成了per-element的mask效果会明显变差因为那相当于给特征图加噪声而不是“跳过整个块”的结构性正则化。4.2 组装一个简易EfficientNet有了MBConv搭一个简化的EfficientNet就很简单了。下面这个代码足够你拿去训练CIFAR-10或者在自定义数据集上做实验。class SimpleEfficientNet(nn.Module): def __init__(self, num_classes1000, width_mult1.0, depth_mult1.0): super().__init__() # 基础配置: (expand_ratio, kernel, stride, in_ch, out_ch, layers) base_cfg [ (1, 3, 1, 32, 16, 1), (6, 3, 2, 16, 24, 2), (6, 5, 2, 24, 40, 2), (6, 3, 2, 40, 80, 3), (6, 5, 1, 80, 112, 3), (6, 5, 2, 112, 192, 4), (6, 3, 1, 192, 320, 1), ] self.stem nn.Sequential( nn.Conv2d(3, 32, 3, stride2, padding1, biasFalse), nn.BatchNorm2d(32), nn.SiLU(inplaceTrue) ) layers [] in_ch 32 for idx, (expand, kernel, stride, _, out_ch, repeats) in enumerate(base_cfg): out_ch int(out_ch * width_mult) for i in range(max(1, int(repeats * depth_mult))): stride_i stride if i 0 else 1 in_ch_i in_ch if i 0 else out_ch drop_rate 0.2 * (idx / len(base_cfg)) layers.append(MBConv(in_ch_i, out_ch, kernel, stride_i, expand, drop_ratedrop_rate)) in_ch out_ch self.blocks nn.Sequential(*layers) self.head nn.Sequential( nn.Conv2d(in_ch, 1280, 1, biasFalse), nn.BatchNorm2d(1280), nn.SiLU(inplaceTrue), nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.2), nn.Linear(1280, num_classes) ) def forward(self, x): x self.stem(x) x self.blocks(x) x self.head(x) return x注意这段代码是教学用的简化版没有完全复刻EfficientNet-B0的NAS结构比如stage的划分和最后head的具体细节但MBConv的核心逻辑跟原版一致。真正想严格复现原版效果建议直接pip install timb用timm.create_model(efficientnet_b0, pretrainedTrue)加载官方结构。4.3 训练超参数参考EfficientNet的训练配置跟它的结构设计一样讲究直接拿ResNet那套超参来训会吃大亏。原论文给出的配置大致如下优化器RMSProp (decay0.9, momentum0.9, epsilon0.001)学习率初始0.256batch size为4096时每2.4个epoch衰减0.97权重衰减1e-5标签平滑0.1随机深度丢弃率B0为0.2B7为0.8数据增强AutoAugment 指数移动平均EMAdecay0.9999实际训练时很多人不太适应RMSProp尤其是之前一直用Adam或SGD的人。我自己的经验是如果任务不是那种超大规模数据集用AdamW也能训出不错的效果但学习率要调小一般从3e-4起步。用SGDmomentum的话初始学习率设在0.1左右配合cosine schedule效果也能接近论文报告值。关键是标签平滑和EMA这两个trick不能丢它们合起来能提升1到2个点。另外如果你的显存不允许大batch size原论文的0.256学习率必须做线性缩放batch size减半学习率也要减半否则很容易发散。5. MBConv为什么高效原理层面的三个关键点5.1 信息瓶颈与残差连接的配合倒残差结构的精华在于“先升维再降维”的设计。深层语义特征是高度抽象的通道数不必保持很大。1x1卷积把高维特征压缩回低维一方面减少了后续层的计算量另一方面形成了一种信息瓶颈。如果网络在学习过程中不需要那么多冗余特征瓶颈结构会强迫它保留最核心的信息某种程度上起到了正则化的效果。同时残差连接保证了梯度可以直接从高层流回低层。即便某个MBConv块的参数在训练早期没有收敛好梯度也能绕道而行避免梯度消失。这两个机制是互补的压缩让特征更精炼残差让训练更稳定。两者缺一MBConv的效率都不会这么高。5.2 空间与通道两个维度的解耦计算传统卷积在空间和通道两个维度同时做加权求和计算复杂度是两者的乘积。MBConv用深度可分离卷积把这两个维度拆开depthwise处理空间信息pointwise处理通道信息。这个解耦的本质是把K²×C×C次乘法拆成K²×C C×C次乘法计算复杂度从“乘积”变成“相加”省下的资源是不成比例的。我再用数据说话。假设CC64K3输入分辨率是32x32。标准卷积需要3²×64×64×32² ≈ 37.7M次乘法深度可分离卷积只需要3²×64×32² 64×64×32² ≈ 5.9M次乘法差了6.4倍。而且这个倍数会随着通道数增大而增加。通道数到256时差距接近15倍。所以MBConv在宽网络上的优势更大这也部分解释了为什么复合缩放时宽度因子β选得相对保守1.1因为它对计算量的影响是平方级的提速收益却不一定线性。5.3 SE模块让网络学会了“选择性关注”把SE模块加进MBConv本质上是在每一层都做了一个“特征打分”的过程。它学习的是什么样的特征更有判别力。比如识别一只猫某层可能更关注耳朵的纹理特征另一个层更关注眼睛的特征。SE通过学习全局统计信息可以让网络自己判断当前样本更依赖哪一组特征然后动态调整权重。SE模块的设计有个重要的细节中间层的通道数要缩得很小这不仅是省参数更是一种“信息瓶颈”。如果缩减比太小两个全连接层变成近似恒等映射模块就退化成一个线性变换注意力学习就没有非线性表达能力了。如果缩减比太大中间层丢掉太多信息权重预测就不准了。从实验结果看r4是一个很好的平衡点r8时精度会有0.3到0.5个点的下降r2时参数量几乎翻倍精度收益却不明显。6. 实际使用中的常见问题与避坑指南6.1 显存占用比预期高怎么办MBConv的FLOPs确实低但训练时的显存占用并不低。原因是倒残差结构需要把升维后的高维特征图保存在显存里用于反向传播。以B0为例stage 5的MBConv6会把通道数从80升到480特征图尺寸是14x14单个特征图大小约480×14×14≈9.4万个浮点数一层是这么多十几层叠加起来就很可观了。实践中的解决办法有几个。第一使用gradient checkpointing把中间特征图不保存反向传播时重新计算显存占用能减少60%左右代价是训练时间增加约20%。第二降低batch size配合梯度累积补偿。第三如果输入分辨率可以降低尽量降低显存占用跟分辨率是平方关系从256降到224显存直接少约23%。像我这种单卡16G的用户训练B4以上的模型基本都得靠混合精度梯度累积组合起来跑。6.2 在自定义数据集上微调需要注意什么EfficientNet原模型在ImageNet上预训练过直接拿来微调自己的数据集时最容易踩的坑是图像尺寸。EfficientNet不同版本对应的推荐输入分辨率不一样B0是224B4是380B7是600。如果你用B4但输入还是224相当于浪费了一大半计算量精度反而可能不如B0。建议先看你数据集的图像平均分辨率再选合适版本的EfficientNet。另外一个坑是正则化强度。预训练模型的BN层统计量是基于ImageNet数据的微调时如果数据集很小比如几千张容易出现BN统计量不稳定。我的经验是冻结前几十个block的BN层只更新后面的能非常有效地防止过拟合或者用较小的学习率比如1e-4并配合线性warmup。还有一个技巧是如果数据集的类别数和ImageNet相差很大建议把EfficientNet最前面的stem层也一起微调因为低层特征也会受到类别分布的影响。6.3 多个版本如何选择EfficientNet-B0到B7的配置其实就是把同一个baseline按φ值缩放。选择哪个版本关键看你的算力约束和精度目标。我这里给一个经验对照表是我自己在不同任务上测试的参考值模型参数量FLOPs推荐分辨率ImageNet Top-1B05.3M0.39B22476.3%B17.8M0.70B24079.1%B29.2M1.0B26080.1%B312M1.8B30081.6%B419M4.2B38082.9%B530M9.9B45683.6%B643M19B52884.0%B766M37B60084.3%如果你是做移动端部署或者实时推理我建议B0或B1就够了再配合知识蒸馏效果能接近B3但速度快好几倍。如果服务器推理B3到B5是比较实用的区间。B6和B7更多是刷榜单用实际工程里性价比不高因为推理延迟和显存开销都涨得很多。另外EfficientNetV2后来改进了训练速度和部分架构如果你的任务对吞吐量特别敏感也值得关注但那是另一个话题了。6.4 常见问题速查表问题可能原因解决办法训练不收敛学习率偏大且没有warmup降低初始学习率到3e-4AdamW或0.1SGD增加5个epoch的线性warmup精度比论文低2个点以上缺少标签平滑/EMA/AutoAugment打开label smoothing0.1训练时启用EMAdecay0.9999推理速度没有预想快硬件不支持depthwise卷积高效算子尝试转ONNX时融合Swish和BN或改用EfficientNet-Lite小数据集微调严重过拟合正则化强度不足提高DropConnect率到0.5冻结前面的BN层显存不够高分辨率输入大batch size混合精度训练AMP梯度累积gradient checkpointing模型不收敛但在验证集上震荡明显BN统计量漂移固定BN的running_mean/var或降低BN momentum到0.01部署时算力受限模型仍然太大对MBConv做通道剪枝剪掉SE分支影响不大7. 写在最后MBConv给了我们什么启示我自己在复现EfficientNet之前一直觉得轻量级网络就是单纯把网络做小参数少了精度自然会掉。但EfficientNet和MBConv改变了我的认知——真正的高效不是简单地砍参数而是改变计算结构让每一分算力都花在刀刃上。MBConv的设计哲学可以总结成三句话卷积的空间和通道维度可以解耦注意力机制可以用极低代价换稳定收益模型的三个缩放维度必须协同调整。最后再分享一个小技巧。如果你用timm库加载EfficientNet想快速适配自己的数据可以使用timm.create_model(efficientnet_b0, pretrainedTrue, num_classes你的类别数)注意把num_classes直接传进去timm会自动帮你替换分类头。如果你不想从头训可以先冻结主干只训练分类头验证集收敛后再解冻主干用低学习率微调这样在数据量不大的情况下往往能得到比直接全量微调更稳的结果。这个套路我在好几个实际项目里都验证过值得一试。