神经架构搜索:DARTS与延迟约束

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

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

一开始就撞墙

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

光有混合操作还不够,关键是怎么同时优化网络权重和架构参数。DARTS 用的是双层优化(bilevel optimization):网络权重在训练集上更新,架构参数 alphas 在验证集上更新,两者交替进行。这样架构参数学到的才是在未见数据上泛化最好的结构,而不是过拟合训练集的结构。

# search_train.py 核心训练循环(简化版)
def search_step(model, architect, train_queue, valid_queue, w_optimizer):
    # 1. 用验证集更新架构参数 alphas
    input_search, target_search = next(iter(valid_queue))
    architect.step(input, target, input_search, target_search)  # 二阶近似时还要展开权重

    # 2. 用训练集更新网络权重
    w_optimizer.zero_grad()
    logits = model(input)
    loss = criterion(logits, target)
    loss.backward()
    w_optimizer.step()

这个权重和架构参数的分离,是 DARTS 比纯权重共享更稳的关键。但它也带来一个副作用:两类参数的学习率要分开调,架构参数的学习率通常要比权重小一些,否则容易震荡。

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

  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)

这个改动虽然让评估更稳定,但也明显增加了搜索时间。所以后来改成:前几轮搜索用快速评估(单次、子集数据),最后一轮再用稳健评估。

真实项目里的延迟和硬件约束

前面这些坑都是在"把准确率搜高"这一个目标下踩的。等真把 NAS 搬进生产项目,才发现准确率只是其中一维——延迟、硬件这些约束反而更磨人。

那次是给一个医疗影像分类任务做模型优化:约 10 万张图,单张 A100 GPU,要求推理时间不超过 50ms,准确率从 85% 提到 88%。手动调参在 ResNet、EfficientNet、MobileNetV3 之间来回折腾,卡在 86% 上不去,才转头用 NAS。

推理时间不可控

最初搜索只盯着精度,结果搜出来的架构精度不错,但在 A100 上单次推理要 80ms,远超 50ms 预算。问题出在搜索阶段完全没把延迟算进去。

解决思路是把延迟塞进奖励函数,超过目标就线性惩罚:

def compute_reward(accuracy, latency, target_latency=50):
    # 超过目标延迟的部分线性惩罚
    if latency <= target_latency:
        latency_penalty = 0.0
    else:
        latency_penalty = (latency - target_latency) / target_latency
    return accuracy - 2.0 * latency_penalty

延迟惩罚系数 2.0 是反复试出来的平衡点——太大压得精度上不去,太小又压不进预算。

搜索和部署的硬件不一致

更大的坑是:搜索在 A100 上做,部署目标是 T4。A100 的 Tensor Core 强,搜出来的架构倾向堆矩阵运算;T4 算力弱一些、更吃显存带宽。结果 A100 上跑得快的架构,换到 T4 上未必最优。

折中办法是在奖励函数里同时考虑两个设备的延迟,并给目标设备(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
    # 目标设备延迟权重更大
    return accuracy - 1.0 * latency_penalty_a100 - 3.0 * latency_penalty_t4

但这样每个候选架构都要在两块卡上测延迟,搜索成本翻倍。最后改成只在搜索初期用 A100 快速筛,最后几轮再用 T4 做二次筛选。

搜出来的架构不一定训得动

还有一个容易被忽略的坑:搜索阶段共享权重,相当于在部分预训练的模型上微调;但最终架构要从头训练。搜出来的结构如果太深、跳跃连接过多、激活函数混用,从头训时容易梯度爆炸或消失,准确率卡在 50% 左右晃悠。

办法是给最终架构加几条可训练性约束,不满足的直接丢掉:

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

加了约束后,搜出来的架构从头训练基本都能收敛。

最终结果

这次搜索花了一个多月,最终架构在 T4 上推理 48ms、准确率 87.8%。和几个手动设计的架构对比:

架构准确率推理时间参数量
ResNet-5085.4%62ms25.6M
EfficientNet-B386.2%58ms12.2M
MobileNetV3-Large84.9%45ms5.4M
NAS 搜索架构87.8%48ms8.7M

NAS 架构在精度和延迟之间找到了更优的折中点:既压进了 50ms 预算,又明显高于手动调参的上限。离 88% 的目标还差一点,但相比起点已经够交差。

实践中的权衡

折腾这么久,最后沉淀了一些实用经验:

搜索空间别贪大。一开始总想定义一个"万能"搜索空间,能覆盖所有可能结构。实际发现,太大根本搜不完,太小又限制可能性。比较好的做法是:先根据任务确定一些合理约束,比如"最多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的真正价值不在"替代人工设计",而在"探索新的可能性"。它不一定能解决所有问题,但至少让人意识到:原本以为固定的模式,可能只是众多选择之一。

自动化设计这条路还很长,不算每个人都能负担得起,但试试也没什么坏处。

本文整合了关于神经架构搜索(NAS)的多篇笔记。

版权声明: 本文首发于 指尖魔法屋-神经架构搜索:DARTS与延迟约束https://blog.thinkmoon.cn/post/173-neural-architecture-search-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!