神经架构搜索:这次怎么落地的
神经架构搜索我没按教科书顺序做。
先解决眼前的阻塞,再回头补原理。
一开始就撞墙
刚上手时,我以为NAS就是给随机搜索套个深度学习外壳。写了段代码,随机生成网络结构,训练一个epoch看准确率,不行就换下一个。
import torch
import torch.nn as nn
import random
def random_architecture():
"""随机生成一个简单的CNN结构"""
layers = []
for i in range(random.randint(2, 5)):
out_channels = random.choice([32, 64, 128])
kernel_size = random.choice([3, 5, 7])
layers.append(nn.Conv2d(3, out_channels, kernel_size, padding=kernel_size//2))
layers.append(nn.ReLU())
if random.random() > 0.5:
layers.append(nn.MaxPool2d(2))
layers.append(nn.Flatten())
layers.append(nn.Linear(32 * 32 * 3, 10))
return nn.Sequential(*layers)
def evaluate_architecture(architecture, data_loader):
"""评估架构性能"""
# 训练一个epoch
optimizer = torch.optim.Adam(architecture.parameters())
criterion = nn.CrossEntropyLoss()
for batch_x, batch_y in data_loader:
optimizer.zero_grad()
output = architecture(batch_x)
loss = criterion(output, batch_y)
loss.backward()
optimizer.step()
# 评估准确率
correct = 0
total = 0
with torch.no_grad():
for batch_x, batch_y in data_loader:
output = architecture(batch_x)
_, predicted = torch.max(output.data, 1)
total += batch_y.size(0)
correct += (predicted == batch_y).sum().item()
return correct / total
# 随机搜索
best_accuracy = 0
best_arch = None
for i in range(100):
arch = random_architecture()
accuracy = evaluate_architecture(arch, train_loader)
if accuracy > best_accuracy:
best_accuracy = accuracy
best_arch = arch
print(f"Trial {i}: accuracy={accuracy:.3f}")
运行了一个小时,准确率还在0.1附近徘徊,完全没调优。问题很明显:随机搜索空间太大,大部分结构一开始就学不动,而且评估一个结构要完整训练一遍,算力根本扛不住。
这时候才意识到,NAS核心不在于"随机",而在于怎么缩小搜索空间、怎么减少评估成本。
搜索空间的坑
后来试着缩小搜索范围,只改卷积层的一些参数,比如卷积核大小、步长、是否加池化层。定义了一个"操作列表",每个节点从列表里选一个操作:
# 定义可搜索的操作
operations = [
'conv_3x3',
'conv_5x5',
'conv_7x7',
'sep_conv_3x3',
'sep_conv_5x5',
'avg_pool_3x3',
'max_pool_3x3',
'identity',
'none'
]
def get_operation(op_name, in_channels, out_channels, stride=1):
"""根据名称返回对应的操作"""
if op_name == 'conv_3x3':
return nn.Conv2d(in_channels, out_channels, kernel_size=3,
stride=stride, padding=1)
elif op_name == 'conv_5x5':
return nn.Conv2d(in_channels, out_channels, kernel_size=5,
stride=stride, padding=2)
elif op_name == 'sep_conv_3x3':
return nn.Sequential(
nn.Conv2d(in_channels, in_channels, kernel_size=3,
stride=stride, padding=1, groups=in_channels),
nn.Conv2d(in_channels, out_channels, kernel_size=1)
)
# ... 其他操作定义
elif op_name == 'identity':
return nn.Identity()
elif op_name == 'none':
return nn.ZeroPool2d(stride=stride)
else:
raise ValueError(f"Unknown operation: {op_name}")
class SearchCell(nn.Module):
"""可搜索的细胞结构"""
def __init__(self, in_channels, out_channels, num_nodes=4):
super().__init__()
self.num_nodes = num_nodes
self.ops = nn.ModuleDict()
# 为每个节点的每条边采样一个操作
for i in range(num_nodes):
for j in range(i + 1):
for op in operations:
op_name = f"node_{i}_from_{j}_{op}"
self.ops[op_name] = get_operation(op, in_channels, out_channels)
def forward(self, x, sampled_ops):
"""sampled_ops 指定每条边使用的操作"""
states = [x]
for i in range(self.num_nodes):
node_output = 0
for j in range(i + 1):
op_name = sampled_ops[f"node_{i}_from_{j}"]
node_output += self.ops[f"node_{i}_from_{j}_{op_name}"](states[j])
states.append(node_output)
return states[-1]
这个结构理论上能组合出很多种网络,但实际跑起来发现两个问题:
- 很多操作组合效果很差,甚至无法收敛,浪费大量算力评估无意义结构
- 随着层数加深,组合数爆炸增长,搜索空间远超实际能覆盖的范围
后来改了思路,不再完全随机,而是预先定义一些"合法"的连接模式,比如每个节点只连接前两个节点,减少了无效搜索:
class EfficientSearchCell(nn.Module):
"""限制连接范围的搜索细胞"""
def __init__(self, in_channels, out_channels, num_nodes=4):
super().__init__()
self.num_nodes = num_nodes
self.ops = nn.ModuleList()
# 每个节点只连接前两个节点(或者前一个节点)
for i in range(num_nodes):
node_ops = nn.ModuleDict()
for op in operations:
if i == 0:
# 第一个节点连接输入
node_ops[op] = get_operation(op, in_channels, out_channels)
else:
# 其他节点连接前两个节点
node_ops[f"from_{i-1}_{op}"] = get_operation(op, out_channels, out_channels)
if i > 1:
node_ops[f"from_{i-2}_{op}"] = get_operation(op, out_channels, out_channels)
self.ops.append(node_ops)
def forward(self, x, sampled_ops):
"""sampled_ops 格式: {node_idx: {edge_key: op_name}}"""
states = [x]
for i in range(self.num_nodes):
node_output = 0
if i == 0:
op_name = sampled_ops[i]['input']
node_output = self.ops[i][op_name](states[0])
else:
# 连接前两个节点
for j in range(max(0, i-2), i):
edge_key = f"from_{j}"
if edge_key in sampled_ops[i]:
op_name = sampled_ops[i][edge_key]
node_output += self.ops[i][f"from_{j}_{op_name}"](states[j+1])
states.append(node_output)
return sum(states[1:]) # 合并所有节点输出
这个改动让搜索空间小了很多,收敛速度也明显提升。这说明NAS里最重要的一步其实不是算法,而是设计一个好的搜索空间——空间太大就搜不完,太小又限制了可能性。
权重共享的尝试
另一个大坑是评估成本。每次生成一个新网络结构都要从头训练,一台2080 Ti一天最多评估几十个结构,速度太慢。
后来学了权重共享的方法:所有候选结构共享一套参数,训练一个"超网络",然后从中采样子网络评估。这个想法在DARTS论文里叫"可微分架构搜索"。
class DARTSCell(nn.Module):
"""DARTS可微分搜索细胞"""
def __init__(self, in_channels, out_channels, num_nodes=4):
super().__init__()
self.num_nodes = num_nodes
# 每条边的混合操作(softmax加权)
self.edges = nn.ModuleList()
for i in range(num_nodes):
edge_ops = nn.ModuleDict()
for j in range(i + 1):
for op in operations:
edge_ops[op] = get_operation(op, in_channels if j == 0 else out_channels,
out_channels)
self.edges.append(edge_ops)
# 每条边的架构参数(用于softmax)
self.alphas = nn.ParameterList()
for i in range(num_nodes):
num_edges = i + 1
alpha = nn.Parameter(torch.randn(num_edges, len(operations)))
self.alphas.append(alpha)
def forward(self, x, hard=False):
states = [x]
for i in range(self.num_nodes):
node_output = 0
for j in range(i + 1):
# 获取这条边的权重
alpha = self.alphas[i][j]
weights = torch.softmax(alpha, dim=0)
if hard:
# 硬决策:选择权重最大的操作
op_idx = torch.argmax(weights)
op_name = operations[op_idx]
node_output += self.edges[i][op_name](states[j])
else:
# 软决策:加权混合所有操作
mixed_output = 0
for op_idx, op_name in enumerate(operations):
op_output = self.edges[i][op_name](states[j])
mixed_output += weights[op_idx] * op_output
node_output += mixed_output
states.append(node_output)
return sum(states[1:])
def get_architecture(self):
"""根据架构参数提取最终结构"""
arch = {}
for i in range(self.num_nodes):
node_arch = {}
for j in range(i + 1):
alpha = self.alphas[i][j]
weights = torch.softmax(alpha, dim=0)
op_idx = torch.argmax(weights)
op_name = operations[op_idx]
node_arch[f"from_{j}"] = op_name
arch[i] = node_arch
return arch
这个方法确实快多了,训练一个超网络就能评估很多子结构。但也发现了新问题:
- 过拟合倾向:超网络学到了如何优化架构参数,但训练出的子网络在验证集上泛化能力不一定好
- 参数耦合:不同结构共享参数,可能导致某些结构"搭便车",过度依赖其他结构的参数更新
后来做了个简单改动:每训练几个epoch就随机dropout一些操作,强制超网络不能过度依赖单一操作:
def forward_with_dropout(self, x, dropout_rate=0.2):
"""带dropout的前向传播"""
states = [x]
for i in range(self.num_nodes):
node_output = 0
for j in range(i + 1):
alpha = self.alphas[i][j]
weights = torch.softmax(alpha, dim=0)
# 随机dropout一些操作
mask = (torch.rand(len(operations)) > dropout_rate).float()
weights = weights * mask
if weights.sum() > 0:
weights = weights / weights.sum()
mixed_output = 0
for op_idx, op_name in enumerate(operations):
if mask[op_idx] > 0:
op_output = self.edges[i][op_name](states[j])
mixed_output += weights[op_idx] * op_output
node_output += mixed_output
states.append(node_output)
return sum(states[1:])
这个改动让最终提取的架构在验证集上表现更稳定,但收敛速度稍微慢了一点。这是典型的"宁要稳妥的慢,不要冒险的快"。
进化算法踩坑
除了可微分方法,也试过进化算法。思路很直观:生成初始种群,评估性能,选择最好的结构进行"变异"和"交叉",迭代几轮。
import copy
def mutate_architecture(architecture, mutation_rate=0.1):
"""随机变异一个架构"""
mutated = copy.deepcopy(architecture)
for node_idx in mutated:
if random.random() < mutation_rate:
# 随机选择一条边改变操作
edge_key = random.choice(list(mutated[node_idx].keys()))
current_op = mutated[node_idx][edge_key]
available_ops = [op for op in operations if op != current_op]
if available_ops:
mutated[node_idx][edge_key] = random.choice(available_ops)
return mutated
def crossover_architectures(arch1, arch2):
"""交叉两个架构"""
child = {}
nodes = list(arch1.keys())
for node_idx in nodes:
if random.random() < 0.5:
child[node_idx] = copy.deepcopy(arch1[node_idx])
else:
child[node_idx] = copy.deepcopy(arch2[node_idx])
return child
class EvolutionaryNAS:
"""进化式神经架构搜索"""
def __init__(self, population_size=20, mutation_rate=0.1):
self.population_size = population_size
self.mutation_rate = mutation_rate
self.population = []
self.history = []
def initialize_population(self):
"""初始化种群"""
for _ in range(self.population_size):
arch = {}
for i in range(num_nodes):
node_arch = {}
for j in range(i + 1):
node_arch[f"from_{j}"] = random.choice(operations)
arch[i] = node_arch
self.population.append(arch)
def evaluate_population(self, evaluate_func):
"""评估当前种群"""
scores = []
for arch in self.population:
score = evaluate_func(arch)
scores.append(score)
return scores
def select_parents(self, scores, tournament_size=3):
"""锦标赛选择"""
parents = []
for _ in range(self.population_size):
# 随机选择几个个体,选最好的
tournament_indices = random.sample(range(self.population_size), tournament_size)
tournament_scores = [scores[i] for i in tournament_indices]
winner_idx = tournament_indices[tournament_scores.index(max(tournament_scores))]
parents.append(self.population[winner_idx])
return parents
def evolve(self, evaluate_func, num_generations=50):
"""进化迭代"""
self.initialize_population()
for generation in range(num_generations):
# 评估当前种群
scores = self.evaluate_population(evaluate_func)
best_score = max(scores)
best_arch = self.population[scores.index(best_score)]
print(f"Generation {generation}: best score = {best_score:.3f}")
self.history.append(best_score)
# 选择父代
parents = self.select_parents(scores)
# 生成新一代
new_population = [best_arch] # 保留最优个体
while len(new_population) < self.population_size:
# 随机选择两个父代
parent1, parent2 = random.sample(parents, 2)
# 交叉
child = crossover_architectures(parent1, parent2)
# 变异
if random.random() < 0.8: # 80%概率变异
child = mutate_architecture(child, self.mutation_rate)
new_population.append(child)
self.population = new_population
return best_arch, self.history
这个方法看起来简单直接,但实际跑起来有几个坑:
- 局部最优容易陷入:几轮进化后,种群结构趋同,很难跳出局部最优
- 评估成本还是高:即使进化比随机搜索高效,每次变异和交叉还是要评估新结构
后来加了几个改进:
class ImprovedEvolutionaryNAS(EvolutionaryNAS):
"""改进的进化式NAS"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.age_limit = 10 # 结构最大生存代数
def eliminate_old_structures(self):
"""淘汰过老的结构"""
new_population = []
for arch, age in zip(self.population, self.ages):
if age < self.age_limit:
new_population.append(arch)
self.population = new_population
def inject_random_structures(self, num_new=5):
"""注入新的随机结构,维持多样性"""
for _ in range(num_new):
arch = {}
for i in range(num_nodes):
node_arch = {}
for j in range(i + 1):
node_arch[f"from_{j}"] = random.choice(operations)
arch[i] = node_arch
self.population.append(arch)
def evolve(self, evaluate_func, num_generations=50):
"""带多样性维护的进化"""
self.initialize_population()
self.ages = [0] * self.population_size
for generation in range(num_generations):
# 评估当前种群
scores = self.evaluate_population(evaluate_func)
best_score = max(scores)
best_arch = self.population[scores.index(best_score)]
print(f"Generation {generation}: best score = {best_score:.3f}")
self.history.append(best_score)
# 选择父代
parents = self.select_parents(scores)
# 淘汰过老的结构
self.eliminate_old_structures()
# 生成新一代
new_population = [best_arch]
while len(new_population) < self.population_size:
parent1, parent2 = random.sample(parents, 2)
child = crossover_architectures(parent1, parent2)
if random.random() < 0.8:
child = mutate_architecture(child, self.mutation_rate)
new_population.append(child)
# 注入新结构维持多样性
if len(new_population) < self.population_size:
self.inject_random_structures(self.population_size - len(new_population))
self.population = new_population
self.ages = [age + 1 for age in self.ages]
return best_arch, self.history
加了年龄限制和随机注入后,进化过程明显不容易卡在局部最优了。但这也意味着搜索时间变长,算力不足时还是得权衡。
评估策略的几个坑
NAS里最容易被忽视的是评估策略。一开始我以为只要能测准确率就行,后来发现评估方式直接影响搜索质量。
第一个坑是过拟合验证集。如果搜索算法过度依赖验证集准确率,最终结构可能在测试集上表现平平。后来做了"两阶段评估":搜索时用验证集的子集(比如50%),最终确定结构后再在全验证集上评估:
def subset_evaluation(arch, data_loader, subset_ratio=0.5):
"""在数据子集上评估"""
correct = 0
total = 0
sample_count = 0
with torch.no_grad():
for batch_x, batch_y in data_loader:
if random.random() > subset_ratio:
continue # 跳过一部分数据
output = arch(batch_x)
_, predicted = torch.max(output.data, 1)
total += batch_y.size(0)
correct += (predicted == batch_y).sum().item()
sample_count += 1
if sample_count > 100: # 只评估有限个batch
break
return correct / total if total > 0 else 0.0
第二个坑是评估不稳定。同一个结构在不同epoch训练下,准确率波动很大。后来用了"早停+多次评估":训练同一个结构3次,取平均准确率:
def robust_evaluation(arch, train_loader, val_loader, num_trials=3):
"""多次评估取平均"""
scores = []
for _ in range(num_trials):
# 训练一个新实例
model = copy.deepcopy(arch)
optimizer = torch.optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()
# 早停训练
best_val_score = 0
patience_counter = 0
for epoch in range(50): # 最多50个epoch
# 训练
model.train()
for batch_x, batch_y in train_loader:
optimizer.zero_grad()
output = model(batch_x)
loss = criterion(output, batch_y)
loss.backward()
optimizer.step()
# 验证
model.eval()
val_score = evaluate(model, val_loader)
if val_score > best_val_score:
best_val_score = val_score
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= 5:
break
scores.append(best_val_score)
return sum(scores) / len(scores)
这个改动虽然让评估更稳定,但也明显增加了搜索时间。所以后来改成:前几轮搜索用快速评估(单次、子集数据),最后一轮再用稳健评估。
实践中的权衡
折腾这么久,最后沉淀了一些实用经验:
搜索空间别贪大。一开始总想定义一个"万能"搜索空间,能覆盖所有可能结构。实际发现,太大根本搜不完,太小又限制可能性。比较好的做法是:先根据任务确定一些合理约束,比如"最多20层"、“卷积核大小不超过7”、“池化层不超过3个”,再在这个约束里定义搜索空间。
# 实用的搜索空间约束
search_space_constraints = {
'num_layers': (2, 10), # 层数范围
'max_filters': 256, # 最大通道数
'kernel_sizes': [3, 5, 7], # 可选卷积核大小
'pool_count': (0, 3), # 池化层数范围
'connection_pattern': 'prev_two' # 连接模式
}
搜索策略按场景选。小规模任务(比如CIFAR-10)、算力有限时,进化算法够用;大规模任务、有GPU集群时,可微分方法更快;但不管选哪个,都要做好过拟合准备。
# 根据场景选择搜索策略
def select_nas_strategy(task_size, compute_budget):
if task_size == 'small' and compute_budget == 'low':
return EvolutionaryNAS(population_size=20, mutation_rate=0.1)
elif task_size == 'large' and compute_budget == 'high':
return DARTS(num_nodes=4)
else:
return RandomSearch() # 降级到随机搜索
评估方式要阶段化。搜索初期用快速评估缩小范围,后期用稳健评估确认结果。别一上来就用最重的评估方式,时间成本太高。
# 阶段化评估策略
class StagedEvaluator:
def __init__(self):
self.stage = 'fast' # fast -> medium -> final
def evaluate(self, arch, train_loader, val_loader):
if self.stage == 'fast':
return subset_evaluation(arch, val_loader, subset_ratio=0.3)
elif self.stage == 'medium':
return robust_evaluation(arch, train_loader, val_loader, num_trials=2)
else:
return robust_evaluation(arch, train_loader, val_loader, num_trials=5)
def advance_stage(self):
if self.stage == 'fast':
self.stage = 'medium'
elif self.stage == 'medium':
self.stage = 'final'
最终结构要重训。NAS找到的结构是基于共享参数的,性能不一定代表真实表现。最好用完整数据集从头训练一遍,再下定论。
自动化设计的边界
做完一轮NAS后,我开始想:到底什么时候用NAS,什么时候直接调参?
NAS适合这些场景:
- 任务明确,但没有现成结构参考
- 算力充足,能承担搜索成本
- 性能提升1-2%就有实际价值(比如竞赛、产品上线)
不适合这些场景:
- 小数据集,很容易过拟合
- 时间紧迫,等不起搜索
- 需要解释性很强的结构(NAS找的结构有时候很奇怪)
# NAS适用性检查
def should_use_nas(dataset_size, time_budget, performance_requirement):
if dataset_size < 10000:
return False, "数据集太小,容易过拟合"
if time_budget < 48: # 小时
return False, "时间预算不足"
if performance_requirement <= 0.95: # 准确率要求不高
return False, "性能要求不高,直接调参够用"
return True, "适合使用NAS"
另外,NAS不是万能的。它擅长调整网络结构,但不管数据预处理、超参数调优、训练策略。实际项目里,这些环节的影响往往比结构调整更明显。
结尾
写完这一轮NAS实践后,再看原来的手动调参,反而觉得没那么糟了。
NAS确实能找到一些人想不到的结构,但代价很高。有些时候,经验和直觉比算法更直接;有些时候,算法能补全经验盲区。到底选哪个,得看任务性质和资源情况。
或许NAS的真正价值不在"替代人工设计",而在"探索新的可能性"。它不一定能解决所有问题,但至少让人意识到:原本以为固定的模式,可能只是众多选择之一。
自动化设计这条路还很长,不算每个人都能负担得起,但试试也没什么坏处。
版权声明: 本文首发于 指尖魔法屋-神经架构搜索:这次怎么落地的(https://blog.thinkmoon.cn/post/173-neural-architecture-search-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。