关于AI 神经网络架构搜索的几点记录
项目背景其实挺简单:给一个医疗影像分类任务做模型优化,数据量约 10 万张,算力限制在单张 A100 GPU,目标是在推理时间不超过 50ms 的前提下把准确率从 85% 提升到 88%。
这种做法在过去几年里确实管用,但这次卡在 86% 左右就死活上不去了。
起因:为什么需要 NAS
项目背景其实挺简单:给一个医疗影像分类任务做模型优化,数据量约 10 万张,算力限制在单张 A100 GPU,目标是在推理时间不超过 50ms 的前提下把准确率从 85% 提升到 88%。
按照传统思路,我会先在几个主流架构里选一个,比如 ResNet、EfficientNet、MobileNetV3,然后基于这个架构做微调:调整深度、宽度、分辨率、激活函数、正则化强度。这种做法在过去几年里确实管用,但这次卡在 86% 左右就死活上不去了。
问题在于,手动调参有几个绕不开的限制:
搜索空间有限:我只会在自己熟悉的架构里选,比如 ResNet 的三种残差连接类型、EfficientNet 的复合缩放系数。这个搜索空间实际上很小,容易陷入局部最优。
经验主义陷阱:我相信残差连接能缓解梯度消失,所以倾向于在每三层之间加一个;相信深度可分离卷积能减少参数量,所以把标准卷积都替换掉。这些信念未必在当前任务上成立。
时间成本高:每次调整架构都要重新训练好几天,试错成本太高。理论上应该做更系统的网格搜索或贝叶斯优化,但实际上根本等不起。
硬件约束难量化:推理时间不超过 50ms 这个限制,手动设计架构时只能靠感觉,比如"这个模型参数量少应该就快",但实际上显存带宽、计算密集度、并行度都影响最终速度。
这些问题不是第一次遇到,但这次实在不想再靠经验瞎猜了,于是决定试试 NAS。
NAS 的基本思路
简单来说,NAS 就是把神经网络架构设计问题当成搜索问题来解。搜索空间里包含各种可能的架构设计,比如每一层用什么类型的卷积、几个通道、激活函数选什么;搜索策略在这个空间里找最优解;评估策略判断一个架构好不好听。
整个过程可以抽象成三个部分:
搜索空间定义要回答"我们允许哪些操作"。比如在卷积网络里,每一层可能是 3x3 卷积、5x5 卷积、深度可分离卷积、空洞卷积、池化层、跳跃连接,也可能是 Identity。每一层可以选的操作越多,搜索空间越大,找到更好架构的理论上可能性越高,但搜索成本也成指数增长。
搜索策略要解决"怎么在搜索空间里找"。最笨的办法是随机搜索,但效率太低;稍聪明一点用进化算法,种群里的个体是不同的架构,按精度和延迟做选择、交叉、变异;更复杂一点用强化学习,让一个 RNN 学习生成架构,用准确率做奖励。
评估策略是另一个瓶颈。最准确的方法是每个候选架构都从头训练完再评估精度,但这样太慢。后来有了权重共享技术,让所有候选架构共享一部分预训练权重,不需要每个架构都从头训练;还有基于可微分架构搜索的方案,把离散选择问题改成连续优化问题。
选择什么 NAS 方案
理论上 NAS 方案很多,但实际项目中要考虑几个现实约束:算力有限、时间有限、不能保证一定能超过手动设计的架构、工具链要足够成熟。
在查阅了大量论文和开源项目后,我选择了 EfficientNet + DARTS 的组合路线:
- EfficientNet 作为基线,因为它的复合缩放思想在资源受限场景下表现不错
- DARTS(Differentiable Architecture Search)作为搜索策略,因为它相对于强化学习和进化算法效率高很多,且实现相对成熟
- 权重共享加上可微分搜索,把搜索过程压缩到几小时级别
这个选择不是没有争议。DARTS 在某些任务上被指出存在稳定性问题,搜索出来的架构在某些情况下不如基线。但考虑到我们的算力和时间限制,这是性价比最高的选择。
实现细节
搜索空间定义
先定义一个基础的搜索空间,每个候选操作包括以下几种:
# ops.py
import torch
import torch.nn as nn
OPS = {
'none': lambda C, stride, affine: Zero(stride),
'avg_pool_3x3': lambda C, stride, affine: nn.AvgPool2d(3, stride=stride, padding=1, count_include_pad=False),
'max_pool_3x3': lambda C, stride, affine: nn.MaxPool2d(3, stride=stride, padding=1),
'skip_connect': lambda C, stride, affine: Identity() if stride == 1 else FactorizedReduce(C, C, affine=affine),
'sep_conv_3x3': lambda C, stride, affine: SepConv(C, C, 3, stride, 1, affine=affine),
'sep_conv_5x5': lambda C, stride, affine: SepConv(C, C, 5, stride, 2, affine=affine),
'dil_conv_3x3': lambda C, stride, affine: DilConv(C, C, 3, stride, 2, 2, affine=affine),
'dil_conv_5x5': lambda C, stride, affine: DilConv(C, C, 5, stride, 4, 2, affine=affine),
}
class SepConv(nn.Module):
def __init__(self, C_in, C_out, kernel_size, stride, padding, affine=True):
super(SepConv, self).__init__()
self.op = nn.Sequential(
nn.ReLU(inplace=False),
nn.Conv2d(C_in, C_in, kernel_size, stride, padding, groups=C_in, bias=False),
nn.Conv2d(C_in, C_out, 1, 1, 0, bias=False),
nn.BatchNorm2d(C_out, affine=affine),
nn.ReLU(inplace=False),
nn.Conv2d(C_out, C_out, kernel_size, 1, padding, groups=C_out, bias=False),
nn.Conv2d(C_out, C_out, 1, 1, 0, bias=False),
nn.BatchNorm2d(C_out, affine=affine),
)
def forward(self, x):
return self.op(x)
这个搜索空间包含了 8 种操作,覆盖了常见的卷积和池化类型。理论上可以再加更多,但这样搜索空间会指数级增长,我们的算力扛不住。
网络结构
定义一个包含 normal cell 和 reduction cell 的网络结构:
# model.py
import torch.nn as nn
import torch.nn.functional as F
class Network(nn.Module):
def __init__(self, C, num_classes, layers, criterion, steps=4, multiplier=4, stem_multiplier=3):
super(Network, self).__init__()
self._C = C
self._num_classes = num_classes
self._layers = layers
self._criterion = criterion
self._steps = steps
self._multiplier = multiplier
C_curr = stem_multiplier * C
self.stem = nn.Sequential(
nn.Conv2d(3, C_curr, 3, padding=1, bias=False),
nn.BatchNorm2d(C_curr)
)
C_prev_prev, C_prev, C_curr = C_curr, C_curr, C
self.cells = nn.ModuleList()
reduction_prev = False
for i in range(layers):
if i in [layers // 3, 2 * layers // 3]:
C_curr *= 2
reduction = True
else:
reduction = False
cell = Cell(steps, multiplier, C_prev_prev, C_prev, C_curr, reduction, reduction_prev)
reduction_prev = reduction
self.cells += [cell]
C_prev_prev, C_prev = C_prev, multiplier * C_curr
self.global_pooling = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Linear(C_prev, num_classes)
def forward(self, input, target):
s0 = s1 = self.stem(input)
for i, cell in enumerate(self.cells):
s0, s1 = s1, cell(s0, s1, self.drop_path_prob)
out = self.global_pooling(s1)
logits = self.classifier(out.view(out.size(0), -1))
return self._criterion(logits, target) * 0.0 # 为了兼容,实际损失在训练循环中计算
def loss(self, input, target):
logits = self(input, target)
return self._criterion(logits, target)
这个结构参考了 DARTS 的原始实现,用 normal cell 做特征提取,用 reduction cell 做下采样。
搜索过程
DARTS 的核心思想是把架构搜索问题变成连续优化问题。每个候选操作都有一个对应的权重参数,训练时通过 softmax 把权重归一化,得到每个操作被选中的概率:
# search_train.py
import torch
import torch.optim as optim
from model import Network
from ops import OPS
def train(search_loader, valid_loader, model, architect, optimizer, args):
for epoch in range(args.epochs):
lr = scheduler.get_lr()[0]
model.drop_path_prob = args.drop_path_prob * epoch / args.epochs
# 训练网络权重
train_acc, train_obj = train_epoch(search_loader, model, architect, optimizer, lr)
# 更新架构参数
valid_acc, valid_obj = infer(valid_loader, model)
print(f'Epoch {epoch}: train_acc {train_acc:.4f} train_obj {train_obj:.4f} valid_acc {valid_acc:.4f} valid_obj {valid_obj:.4f}')
# 提取最终架构
genotype = model.genotype()
print(f'Final genotype: {genotype}')
def train_epoch(train_loader, model, architect, optimizer, lr):
model.train()
architect.optimizer.zero_grad()
for step, (input, target) in enumerate(train_loader):
input = input.cuda()
target = target.cuda(non_blocking=True)
# 架构参数的梯度计算
input_search, target_search = next(iter(valid_loader))
input_search = input_search.cuda(non_blocking=True)
target_search = target_search.cuda(non_blocking=True)
architect.step(input, target, input_search, target_search, lr, optimizer, unrolled=args.unrolled)
# 网络权重的训练
optimizer.zero_grad()
logits = model(input)
loss = criterion(logits, target)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
这里的关键是分离了网络权重和架构参数的优化。网络权重用训练集优化,架构参数用验证集优化,这样避免过拟合到训练数据。
从搜索空间到具体架构
搜索完成后,从每个节点的候选操作中选择权重最大的那个,构建最终的网络:
# extract_arch.py
def extract_final_architecture(model, genotype):
final_architecture = []
# 遍历每个 cell
for cell_type in ['normal', 'reduce']:
cell_ops = genotype[cell_type]
for op_idx, (op_name, prev_nodes) in enumerate(cell_ops):
final_architecture.append({
'cell_type': cell_type,
'op_index': op_idx,
'operation': op_name,
'input_nodes': prev_nodes
})
return final_architecture
这个提取出来的架构就是 NAS 搜索的结果,可以用于后续的训练和部署。
踩过的坑
坑一:搜索不稳定
刚开始搜索的时候,发现不同次运行的结果差异很大。有时候能搜到比 ResNet 好的架构,有时候甚至不如基线。
查了一圈发现几个问题:
学习率太激进:原始 DARTS 论文建议架构参数和网络权重都用同一个学习率,但实际操作中发现架构参数的学习率应该更小一些,否则容易震荡。
权重初始化敏感:不同次运行的随机种子不同,导致初始化的权重分布不同,进而影响搜索结果。后来固定了随机种子,并用了更保守的初始化策略。
搜索空间过大:原本想尝试更复杂的搜索空间,比如加上 attention、group convolution 等操作,结果搜索过程直接崩了。最后还是缩减到基础的卷积和池化操作。
修复这些问题后,搜索结果的稳定性明显提升,多次运行的结果差异在可接受范围内。
坑二:搜索出来的架构训练不收敛
搜到一个看起来很不错的架构,但从头训练时一直不收敛,准确率在 50% 左右晃悠。
排查后发现几个原因:
深度太深:搜索过程中共享了权重,所以训练过程相当于在部分预训练的模型上做微调。但最终架构需要从头训练,太深的网络容易出现梯度消失。
跳跃连接过多:搜索出来的架构倾向于大量使用 skip_connect,这在搜索过程中有优势,但训练时容易出现梯度爆炸或消失。
激活函数选择不当:某些架构在不同层之间混用了不同激活函数,导致梯度分布不均匀。
解决方案是加了几项约束:
# 添加架构约束
def apply_architecture_constraints(genotype):
# 限制最大深度
if count_max_depth(genotype) > 50:
return None
# 限制跳跃连接比例
skip_count = count_skip_connections(genotype)
total_count = count_total_connections(genotype)
if skip_count / total_count > 0.4:
return None
# 统一激活函数
if has_mixed_activation(genotype):
return None
return genotype
加上这些约束后,搜索出来的架构更容易训练收敛。
坑三:推理时间不可控
最初搜索时只考虑了精度,没管推理时间。结果搜出来的架构虽然精度不错,但在 A100 上跑完要 80ms,远超 50ms 的限制。
这个问题有几个解决思路:
在奖励函数中加入延迟惩罚:用 FLOPs 或实际测量的推理时间作为延迟指标,和精度一起组成奖励。
限制模型大小:在搜索空间里限制每一层的最大通道数,或限制总参数量。
后期剪枝:先用 NAS 搜索出高精度架构,再用结构化剪枝或量化减少延迟。
最后采用了第一种方案,奖励函数改成:
def compute_reward(accuracy, latency, target_latency=50):
# 准确率权重
acc_weight = 1.0
# 延迟惩罚:超过目标延迟线性惩罚
if latency <= target_latency:
latency_penalty = 0.0
else:
latency_penalty = (latency - target_latency) / target_latency
# 最终奖励
reward = acc_weight * accuracy - 2.0 * latency_penalty
return reward
延迟惩罚的权重调了多次,2.0 这个值是在多次试验后找出来的平衡点。
坑四:硬件不匹配
搜索过程是在 A100 上完成的,但最终部署的目标设备是 NVIDIA T4,两者的架构差异导致性能表现不一致。
A100 的 Tensor Core 很强,所以搜索出来的架构倾向于使用大量矩阵运算;但 T4 的计算能力弱一些,反而更依赖显存带宽。结果是在 A100 上跑得快的架构,在 T4 上未必最优。
这个问题的解决方案是在搜索过程中加入硬件感知:
# 硬件感知的奖励函数
def compute_hardware_aware_reward(accuracy, latency_a100, latency_t4, target_latency=50):
# 在两个设备上的延迟都要考虑
latency_penalty_a100 = max(0, latency_a100 - target_latency) / target_latency
latency_penalty_t4 = max(0, latency_t4 - target_latency) / target_latency
# T4 的延迟权重更大,因为它是目标设备
reward = accuracy - 1.0 * latency_penalty_a100 - 3.0 * latency_penalty_t4
return reward
但这样会成倍增加搜索成本,因为需要在两个设备上测量延迟。最终折中的方案是只在搜索初期用 A100,后期用 T4 做二次筛选。
最终结果
经过这次折腾,最终得到的架构在 T4 上的推理时间是 48ms,准确率 87.8%,相比最初的 ResNet-50(精度 85.4%,延迟 62ms)有明显提升。
为了对比,还跑了几种手动设计的架构:
| 架构 | 准确率 | 推理时间 | 参数量 |
|---|---|---|---|
| ResNet-50 | 85.4% | 62ms | 25.6M |
| EfficientNet-B3 | 86.2% | 58ms | 12.2M |
| MobileNetV3-Large | 84.9% | 45ms | 5.4M |
| NAS 搜索架构 | 87.8% | 48ms | 8.7M |
用散点图把各架构的准确率与推理延迟放在同一坐标系,能一眼看出 NAS 结果是否同时满足 50ms 约束和精度目标。

NAS 搜索出来的架构在精度和延迟之间找到了更好的平衡点,既压进了 50ms 预算,又明显高于手动调参的上限。
写在后面
这次 NAS 实践花了一个多月,从最初的盲目尝试到后来慢慢摸索出适合自己的方案,中间踩过不少坑。
解决了:
- 经验主义陷阱:不再依赖直觉调参,用搜索策略探索更大的架构空间
- 硬件约束:在奖励函数中显式考虑延迟,避免搜出无法部署的架构
- 搜索效率:用权重共享和可微分搜索把搜索过程压缩到可接受范围
留下了:
- 搜索不稳定:不同次运行的结果仍有差异,需要进一步改进
- 硬件迁移成本:在不同硬件间迁移时需要重新搜索或调整
- 工具链复杂度:整个流程涉及多个组件,维护成本高
NAS 不是银弹,但在资源受限的场景下确实能带来价值。前提是你有足够的算力和时间投入,且对搜索结果有合理的预期。
后续还值得尝试的方向:混合精度搜索、神经网络编译器协同优化、跨硬件的架构迁移。但这些得看业务优先级和团队能力。
最后提醒一句:NAS 工具在不断演进,今天看起来复杂的方案,明年可能就有更成熟的框架可以直接用。保持关注,但不要盲目追新。
这次 NAS 实践没有用现成的 AutoML 平台,而是自己实现了基础的 DARTS 流程。虽然花了不少时间,但让我对架构搜索的细节有了更深的理解,也积累了一些可以复用的组件。
版权声明: 本文首发于 指尖魔法屋-关于AI 神经网络架构搜索的几点记录(https://blog.thinkmoon.cn/post/333-ai-neural-architecture-search-manual-search-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。