神经架构搜索:这次怎么落地的

神经架构搜索我没按教科书顺序做。

先解决眼前的阻塞,再回头补原理。

一开始就撞墙

刚上手时,我以为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]

这个结构理论上能组合出很多种网络,但实际跑起来发现两个问题:

  1. 很多操作组合效果很差,甚至无法收敛,浪费大量算力评估无意义结构
  2. 随着层数加深,组合数爆炸增长,搜索空间远超实际能覆盖的范围

后来改了思路,不再完全随机,而是预先定义一些"合法"的连接模式,比如每个节点只连接前两个节点,减少了无效搜索:

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

这个方法确实快多了,训练一个超网络就能评估很多子结构。但也发现了新问题:

  1. 过拟合倾向:超网络学到了如何优化架构参数,但训练出的子网络在验证集上泛化能力不一定好
  2. 参数耦合:不同结构共享参数,可能导致某些结构"搭便车",过度依赖其他结构的参数更新

后来做了个简单改动:每训练几个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

这个方法看起来简单直接,但实际跑起来有几个坑:

  1. 局部最优容易陷入:几轮进化后,种群结构趋同,很难跳出局部最优
  2. 评估成本还是高:即使进化比随机搜索高效,每次变异和交叉还是要评估新结构

后来加了几个改进:

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/) 转载或引用必须申明原指尖魔法屋来源及源地址!