☰
DARTS实战:从论文到代码的神经网络架构搜索避坑指南
2026/10/15 7:29:47 网站建设 项目流程

1. 为什么值得花时间啃透DARTS

如果你正在做神经网络架构搜索(NAS)方向的研究或者工程落地,大概率绕不开DARTS这篇工作。全称Differentiable Architecture Search,核心思路一句话就能说清楚:把离散的架构选择松弛成连续的、可微的权重,然后用梯度下降同时优化网络权重和架构参数。这个想法在2018年提出来的时候,直接炸了NAS这个圈子,因为它把原本需要几千GPU天的搜索过程压缩到了几个GPU天。

我自己第一次读这篇论文的时候,感觉公式推导很顺,代码应该也不难。结果真正把官方代码拉下来跑通、再自己改结构的时候,才发现坑远比想象中多。从搜索阶段的显存爆炸,到离散化之后的性能崩塌,再到评估阶段的复现差异,每一步都有大量论文里不会写的细节。这篇文章就是把我从读论文到跑代码、再到自己改结构这一整条链路上积累的东西全部摊开讲,包括核心原理的拆解、代码关键模块的逐行分析、实操中遇到的典型问题和排查思路。

适合的读者有三类:一是刚接触NAS、想通过DARTS入门的研究生或者算法工程师;二是已经跑过官方代码但结果对不上、想搞清楚哪里出了问题的同学;三是想把DARTS的思想迁移到自己业务场景里的从业者。不管你是哪一类,我都会尽量把“为什么这么做”讲透,而不是只贴一段代码让你抄。

2. DARTS的核心设计思路拆解

2.1 从离散搜索到连续松弛:这个弯是怎么拐过来的

传统的NAS方法,比如基于强化学习或者进化算法的方案,本质上是在一个离散的搜索空间里做黑盒优化。搜索空间里每个候选架构都是一个独立的点,你需要完整训练它、评估它,才能知道它好不好。这就导致搜索成本极高,因为每次评估都意味着一次完整的训练过程。

DARTS的关键洞察在于:我不需要把每个候选架构当成独立的点,我可以把搜索空间表示成一张有向无环图,每条边上的操作是一个混合操作,混合的权重就是架构参数。这样一来,架构搜索就变成了对这些连续权重的优化问题,可以直接用梯度下降来解。

具体来说,假设一个单元(cell)里有若干节点,节点之间的边上有若干候选操作(比如3x3卷积、5x5卷积、跳连、池化等)。DARTS的做法是在每条边上放一个softmax加权的混合操作:

混合操作 = sum over o of (exp(alpha_o) / sum over o' of exp(alpha_o')) * o(x)

其中alpha就是架构参数。搜索的过程就是同时优化网络权重w和架构参数alpha。训练完成后,每条边保留softmax权重最大的那个操作,得到一个离散的架构。

这个松弛的好处是显而易见的:搜索过程变成了可微的,可以用标准的反向传播来优化,效率提升了好几个数量级。但代价也很明显:松弛之后的连续空间和离散空间之间存在gap,搜索时最优的连续架构,离散化之后不一定还是最优的。这个问题在后面会详细展开。

2.2 双层优化:为什么不能简单地把alpha和w一起训

DARTS的优化目标是一个双层优化问题(bilevel optimization)。外层优化架构参数alpha,目标是让在验证集上的损失最小;内层优化网络权重w,目标是让在训练集上的损失最小。用公式表示就是:

min over alpha of L_val(w*(alpha), alpha) s.t. w*(alpha) = argmin over w of L_train(w, alpha)

为什么不能直接把alpha和w放在一起用训练集损失来优化?因为那样会导致架构参数过拟合训练集,搜索出来的架构在验证集上表现很差。双层优化的设计就是为了让架构参数在验证集上做选择,保证泛化性。

但双层优化有个麻烦:w*(alpha)本身是内层优化的结果,要对alpha求导,需要用到隐函数定理或者近似。DARTS采用的是一种一阶近似的方法,通过一步梯度下降来近似w*(alpha),然后对alpha求导。这个近似在论文里有详细的推导,代码里也有对应的实现。

实际操作中,这个近似会带来一些问题。比如搜索阶段的batch size不能太小,否则梯度的方差会很大,导致alpha的优化不稳定。另外,训练集和验证集的划分也很关键,如果验证集太小或者分布和训练集差异太大,搜索出来的架构可能会偏向于某些特定的操作。

2.3 搜索空间设计:cell结构的选择与权衡

DARTS的搜索空间是基于cell的。一个cell是一个有向无环图,包含若干个节点和边。DARTS定义了两种cell:normal cell和reduction cell。Normal cell保持特征图的空间分辨率不变,reduction cell将空间分辨率减半。

每个cell有两个输入节点(分别是前两层的输出)和一个输出节点。中间的节点通过边连接,每条边上的操作从预定义的候选操作集合中选择。候选操作通常包括:3x3深度可分离卷积、5x5深度可分离卷积、3x3空洞卷积、5x5空洞卷积、3x3最大池化、3x3平均池化、identity(跳连)、zero(无连接)。

这个设计的好处是搜索空间相对紧凑,搜索出来的cell可以堆叠成任意深度的网络。但也有一些限制:比如cell的结构是固定的,只有边上的操作是可搜索的;节点的连接方式也是预定义的,不能搜索。这些限制在一定程度上约束了搜索空间的表达能力,但换来了搜索效率的大幅提升。

我在实际使用中发现,候选操作集合的选择对搜索结果影响很大。如果池化操作太多,搜索出来的架构会倾向于大量使用池化,导致模型容量不足;如果卷积操作太多,搜索又会偏向于堆叠卷积,参数量爆炸。通常的做法是保持一个相对平衡的候选集合,并根据具体任务做微调。

3. 代码关键模块逐行解析

3.1 搜索空间的构建:从代码角度看cell是怎么定义的

官方代码里,搜索空间的构建主要在model_search.py这个文件里。核心类是Cell,它接收一个steps参数(表示cell里有多少个中间节点)和一个multiplier参数(表示输出节点由多少个中间节点拼接而成)。

每个cell的构建过程大致是这样的:首先定义两个输入节点(前两层的输出),然后对于每个中间节点,从前面的所有节点中选择两个作为输入,在这两条边上分别放置混合操作。混合操作的权重就是架构参数alpha。

代码里有一个关键的数据结构self._ops,它是一个ModuleList,每个元素是一个MixedOp,对应一条边上的混合操作。MixedOp内部维护了一个ModuleList,包含所有候选操作,以及一个alpha参数,用于计算softmax权重。

class MixedOp(nn.Module): def __init__(self, C, stride): super(MixedOp, self).__init__() self._ops = nn.ModuleList() for primitive in PRIMITIVES: op = OPS[primitive](C, stride, False) self._ops.append(op) def forward(self, x, weights): return sum(w * op(x) for w, op in zip(weights, self._ops))

这段代码看起来很简单,但有几个细节值得注意。第一,weights是通过对alpha做softmax得到的,这个softmax是在forward里计算的,而不是在__init__里。第二,sum这里用的是Python内置的sum,而不是torch.sum,这意味着每个操作的输出是逐个相加的,显存占用会比较高。第三,OPS是一个字典,把操作名映射到具体的nn.Module,这个字典的定义在文件开头。

在实际使用中,我发现MixedOp的显存占用是一个大问题。因为每条边上都要计算所有候选操作的输出,然后加权求和,所以显存占用是候选操作数量的倍数。如果候选操作有8个,那显存占用就是普通网络的8倍左右。这也是为什么DARTS搜索阶段需要比较大的显存。

3.2 架构参数的初始化与更新:alpha的优化细节

架构参数alpha的初始化方式对搜索结果有一定影响。官方代码里,alpha是用torch.nn.Parameter定义的,初始化为0。这意味着初始时所有操作的softmax权重是均匀的,每条边上的混合操作是所有候选操作的等权平均。

alpha的更新是通过优化器来完成的。官方代码里用的是torch.optim.Adam,学习率设置为3e-4,权重衰减设置为1e-3。这个学习率比网络权重的学习率(通常0.025)小很多,原因是架构参数需要更精细的调整,学习率太大会导致搜索过程震荡。

在train_search.py里,有一个关键的步骤是计算架构参数的梯度。由于双层优化的存在,这个梯度不能直接用验证集损失对alpha求导得到,而是需要先对网络权重做一步虚拟更新,然后再计算梯度。代码里对应的逻辑是:

# 计算虚拟更新后的网络权重 unrolled_model = self._compute_unrolled_model(input, target, eta, network_optimizer) # 计算架构参数的梯度 unrolled_loss = self._loss(unrolled_model, self._steps, input_valid, target_valid) unrolled_loss.backward()

这里的_compute_unrolled_model就是做一步虚拟更新,eta是虚拟更新的学习率。这个步骤是DARTS的核心,也是计算量最大的部分,因为它需要保留计算图,显存占用会进一步增加。

我在实际使用中发现,如果显存不够,可以把unrolled设置为False,改用一阶近似。这样虽然搜索精度会有所下降,但显存占用会大幅降低。对于显存有限的场景,这是一个很实用的折中方案。

3.3 离散化与架构导出:从连续权重到最终网络

搜索完成后,需要把连续权重离散化,得到最终的架构。这个过程在model_search.py的genotype方法里实现。核心逻辑是:对于每条边,选择softmax权重最大的操作;对于每个中间节点,选择权重最大的两条边作为输入。

def genotype(self): def _parse(weights): gene = [] n = 2 start = 0 for i in range(self._steps): end = start + n W = weights[start:end].copy() edges = sorted(range(i + 2), key=lambda x: -max(W[x][k] for k in range(len(W[x])) if k != PRIMITIVES.index('none')))[:2] for j in edges: k_best = None for k in range(len(W[j])): if k != PRIMITIVES.index('none'): if k_best is None or W[j][k] > W[j][k_best]: k_best = k gene.append((PRIMITIVES[k_best], j)) start = end n += 1 return gene # ...

这段代码有几个细节需要注意。第一,none操作被排除在外,也就是说,如果某条边的none操作权重最大,这条边会被忽略。第二,每个中间节点选择两条输入边,这是DARTS的默认设置。第三,gene是一个列表,每个元素是一个(操作名, 输入节点索引)的元组。

离散化之后,通常会重新训练一个网络来评估架构的性能。这个重新训练的过程和普通网络训练类似,但有一些细节需要注意,比如学习率调度、数据增强、正则化等。官方代码里用的是标准的训练流程,但在实际使用中,这些超参数对最终性能影响很大。

4. 实操过程中的关键环节与避坑指南

4.1 环境配置与数据准备:从零开始跑通官方代码

先把环境搭起来。官方代码基于PyTorch,建议用1.0以上的版本,但不要用太新的版本,因为有些API可能不兼容。Python版本建议3.6到3.8之间。CUDA版本根据你的显卡来选,但要注意PyTorch版本和CUDA版本的对应关系。

数据准备方面,官方代码支持CIFAR-10和ImageNet。CIFAR-10可以直接用torchvision.datasets.CIFAR10下载,ImageNet需要自己准备。数据增强方面,官方代码用了标准的随机裁剪和随机翻转,另外还用了cutout。Cutout的实现很简单,就是在图像上随机挖一个16x16的方块,把它置零。这个操作对防止过拟合很有效。

class Cutout(object): def __init__(self, length): self.length = length def __call__(self, img): h, w = img.size(1), img.size(2) mask = np.ones((h, w), np.float32) y = np.random.randint(h) x = np.random.randint(w) y1 = np.clip(y - self.length // 2, 0, h) y2 = np.clip(y + self.length // 2, 0, h) x1 = np.clip(x - self.length // 2, 0, w) x2 = np.clip(x + self.length // 2, 0, w) mask[y1: y2, x1: x2] = 0. img = torch.from_numpy(img.numpy() * mask) return img

这段代码看起来简单,但有一个坑:img.numpy()要求img是CPU上的tensor,如果数据加载器返回的是CUDA tensor,这里会报错。所以cutout要在数据加载阶段做,而不是在训练循环里做。

4.2 搜索阶段的参数设置与显存优化

搜索阶段的参数设置很关键。官方代码里,搜索阶段训练50个epoch,batch size设置为64,网络权重的学习率是0.025,架构参数的学习率是3e-4。这些参数在CIFAR-10上表现不错,但换到其他数据集可能需要调整。

显存优化是搜索阶段的一个大问题。因为每条边上都要计算所有候选操作的输出,显存占用是普通网络的数倍。如果显存不够,可以尝试以下几个方案:

  • 减小batch size。这是最直接的方法,但会影响搜索稳定性。
  • 减少候选操作数量。比如去掉一些不常用的操作,只保留核心的几个。
  • 使用一阶近似。把unrolled设置为False,可以大幅降低显存占用。
  • 使用混合精度训练。PyTorch的amp模块可以自动做混合精度,显存占用可以减少30%到50%。

我自己的经验是,如果显存只有8GB,建议用一阶近似加混合精度,batch size设置为32左右。如果显存有16GB以上,可以用二阶近似,batch size设置为64。

4.3 评估阶段的重新训练与性能复现

搜索完成后,会得到一个离散的架构。这个架构需要重新训练来评估性能。官方代码里,评估阶段训练600个epoch,batch size设置为96,学习率用余弦退火调度,初始学习率0.025。

评估阶段有几个坑需要注意。第一,数据增强的强度要够,否则容易过拟合。除了标准的随机裁剪和翻转,还可以加cutout、mixup、cutmix等。第二,学习率调度很关键,余弦退火比阶梯下降效果更好。第三,权重衰减要设置合理,太大或太小都会影响性能。

我在复现官方结果的时候,发现评估阶段的性能波动很大。同样的架构,不同的随机种子,最终精度可能差0.5%到1%。所以如果要对比不同架构的性能,最好用多个随机种子跑,取平均值。

另外,官方代码里的评估阶段用的是CIFAR-10的完整训练集,但搜索阶段用的是训练集的50%作为训练集,另外50%作为验证集。这个划分方式在评估阶段要改回来,否则会浪费一半的训练数据。

5. 常见问题与排查技巧实录

5.1 搜索阶段loss不下降或者震荡怎么办

搜索阶段loss不下降或者震荡,通常有几个原因。第一,学习率设置不当。架构参数的学习率如果太大,会导致搜索过程震荡;如果太小,搜索会停滞。建议从3e-4开始试,如果震荡就降到1e-4,如果停滞就升到1e-3。

第二,数据划分不合理。如果验证集太小或者分布和训练集差异太大,架构参数的梯度会很不稳定。建议验证集至少占训练集的30%到50%。

第三,候选操作集合不合理。如果候选操作里有太多相似的操作,比如同时有3x3卷积和5x5卷积,搜索过程可能会在这两个操作之间反复横跳。建议候选操作集合保持多样性,但不要有太多冗余。

第四,batch size太小。搜索阶段的batch size如果太小,梯度的方差会很大,导致搜索不稳定。建议batch size至少设置为32,如果显存允许,设置为64更好。

5.2 离散化之后性能崩塌的原因分析

离散化之后性能崩塌是DARTS最被诟病的问题之一。原因主要有两个:一是连续松弛和离散选择之间的gap,二是搜索阶段的过拟合。

连续松弛的gap是指,搜索阶段最优的连续架构,离散化之后不一定还是最优的。因为连续架构是所有候选操作的加权平均,而离散架构只保留权重最大的那个操作。如果权重分布比较均匀,离散化之后的信息损失会很大。

搜索阶段的过拟合是指,架构参数在搜索过程中可能过拟合了验证集。因为搜索阶段只训练50个epoch,网络权重还没有充分收敛,架构参数可能选择了一些在短期训练中表现好、但长期训练中表现差的操作。

缓解这个问题的方法有几个。第一,增加搜索阶段的epoch数,让网络权重更充分地收敛。第二,在搜索阶段加入正则化,比如dropout或者权重衰减。第三,在离散化之后,对架构做微调,比如调整某些边的操作,看看性能是否提升。第四,使用一些改进的DARTS变体,比如PC-DARTS、P-DARTS等,这些方法在搜索稳定性和离散化性能上都有改进。

5.3 显存不足的排查与优化方案

显存不足是DARTS实操中最常见的问题。排查思路是这样的:首先看搜索阶段的batch size是不是太大,如果是,先减小batch size。然后看候选操作数量是不是太多,如果是,减少候选操作。然后看是不是用了二阶近似,如果是,改成一阶近似。最后看是不是可以用混合精度训练。

如果以上方法都不行,还可以考虑梯度累积。梯度累积的思路是,用小的batch size计算梯度,累积多次之后再更新参数。这样可以在不增加显存占用的情况下,模拟大的batch size。PyTorch里实现梯度累积很简单,就是在backward之后不立即step,而是累积几次之后再step。

for i, (input, target) in enumerate(train_loader): output = model(input) loss = criterion(output, target) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这段代码里,accumulation_steps是累积次数。需要注意的是,loss要除以accumulation_steps,否则梯度会放大。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
搜索阶段loss震荡架构参数学习率太大打印alpha的梯度范数降低学习率到1e-4
搜索阶段loss不下降学习率太小或数据划分不合理检查验证集大小和分布增大学习率或调整数据划分
离散化后性能崩塌连续松弛gap或搜索过拟合对比搜索和评估阶段的精度增加搜索epoch或使用改进变体
显存不足batch size太大或候选操作太多用nvidia-smi监控显存减小batch size或减少候选操作
评估阶段精度波动大随机种子影响用多个随机种子跑取平均值或增加训练epoch
训练速度慢数据加载瓶颈或模型太大用profiler分析增加数据加载线程或减小模型

6. 从DARTS出发的扩展与改进方向

6.1 搜索效率的进一步优化

DARTS虽然比强化学习的方法快很多,但搜索阶段仍然需要几个GPU天。后续有很多工作试图进一步优化搜索效率。比如PC-DARTS通过部分通道连接来减少显存占用和计算量,P-DARTS通过逐步增加网络深度来稳定搜索过程,Fair-DARTS通过公平比较不同操作来避免搜索偏差。

这些改进的核心思路大同小异:要么减少搜索阶段的计算量,要么提高搜索的稳定性。PC-DARTS的部分通道连接是一个很实用的技巧,它把每个操作的输入通道分成两部分,一部分用于搜索,一部分用于保留信息。这样可以在不损失性能的情况下,把显存占用降低一半左右。

6.2 搜索空间的扩展与迁移

DARTS的搜索空间最初是为图像分类设计的,后来被扩展到其他任务,比如语义分割、目标检测、语音识别等。扩展搜索空间的关键是定义合适的候选操作和cell结构。比如在语义分割里,需要加入空洞卷积和上采样操作;在目标检测里,需要加入多尺度特征融合的操作。

迁移到其他数据集时,需要注意数据集的规模和复杂度。如果数据集比较小,搜索阶段容易过拟合,需要加强正则化;如果数据集比较大,搜索阶段需要更多的epoch来充分收敛。

6.3 与其他技术的结合

DARTS可以和很多其他技术结合,比如知识蒸馏、量化、剪枝等。知识蒸馏可以用一个大模型来指导搜索过程,让搜索出来的架构更好地模仿大模型的行为。量化可以在搜索阶段就考虑量化误差,搜索出对量化更友好的架构。剪枝可以在搜索完成后,对架构做进一步的压缩。

这些结合方式的核心思想是,把DARTS作为一个架构搜索的工具,和其他技术协同使用,而不是孤立地看待。在实际业务场景里,往往需要综合考虑精度、速度、模型大小等多个指标,单一的技术很难满足所有需求。

7. 我个人在实际操作中的几点体会

跑DARTS这段时间,最大的感受是:论文里的公式很优雅,但代码里的细节很磨人。很多在论文里一笔带过的东西,在实际操作中都是坑。比如数据划分的比例、学习率的调度、候选操作的选择,这些在论文里可能只有一句话,但在代码里需要反复调试。

另外一个体会是,不要迷信官方代码的结果。官方代码在CIFAR-10上能跑到97%以上的精度,但换到其他数据集或者换个随机种子,结果可能差很多。所以复现的时候,一定要多跑几次,取平均值,不要被单次结果迷惑。

最后,DARTS的思想比具体的实现更重要。连续松弛、双层优化、cell结构,这些思想可以迁移到很多其他问题里。即使你不做NAS,了解这些思想对理解其他优化问题也有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询