← All tutorials

Neural Architecture Search Basics

Search spaces, random search, evolution and reinforcement-learning controllers

On this page

1 Search space, search strategy and evaluation 2 How the search works 3 Running a sampling-based search 4 Pitfalls of sampling-based search 5 References

Designing architectures by hand is slow and mostly trial and error: a layer made wider here, a skip connection (a path that carries a layer's input past other layers) added there.

Neural architecture search (NAS) hands this job to an automated search.

NAS runs once, before the final model is trained; the search is its own job (Section 3 has its cost), and its output architecture is then trained from scratch. During the search the strategy keeps a memory: a population for evolution, a controller network for reinforcement learning, or architecture weights for differentiable search (One-Shot NAS: Weight Sharing and DARTS).

1 Search space, search strategy and evaluation

This page builds on convolutional networks: kernels and feature maps from CNN Basics: Convolution, Stride and Pooling, and the skip connections of ResNet from Classic CNN Architectures: AlexNet to ResNet. Every NAS method has the same three pieces (Figure 1).

1. Search spacewhat may change2. Search strategyhow to choose the next candidate3. Evaluationhow to score a candidate cheaplyscore
Figure 1. The strategy picks from the space; the score steers the next pick.

Get these wrong and thousands of GPU hours rediscover ResNet, a hand-designed network.

2 How the search works

2.1 The search space

Say each of L layers picks one op out of k candidates. Take an illustrative space of eight layers and five ops, the one Listing A searches: a 3x3 convolution, a 5x5 convolution, a 3x3 separable convolution, max pooling and a skip that passes its input through. One layer gives 5 options, two layers give 25 pairs, and every further layer multiplies by 5 again, so eight layers give 58 = 390,625 architectures. The card states that count N for any k and L.

The number of architectures is the choices per layer raised to the number of layers.
N = kL
Each of the L layers chooses independently among k ops, so every added layer multiplies the count by k. Repeated multiplication is a power, which is why N grows exponentially with depth: one more layer is k times more work for brute force.

At 390,625 architectures for only eight layers, brute force is not a plan. So what matters is the search strategy, not the size of the space.

2.2 Four search strategies

  1. Random search samples architectures and keeps the best, the baseline every NAS paper should beat.
  2. Evolution keeps a population, drops the worst and adds a mutated copy of the best; AmoebaNet's aging evolution removes the oldest member instead.
  3. Reinforcement learning trains a controller that proposes architectures and learns from their scores (Section 2.3).
  4. Differentiable search turns the choice into weights that gradient descent can tune (One-Shot NAS: Weight Sharing and DARTS).

Random search and evolution both fit in a few lines on a toy space of 200 architectures with fixed accuracies; score is a lookup standing in for training, and mutate moves up to 15 places.

import random

N, BUDGET = 200, 100
acc = [random.random() for _ in range(N)]

# stands in for training
def score(a):
    return acc[a]

def mutate(a):
    step = random.randint(-15, 15)
    # stay inside the space
    return min(N - 1, max(0, a + step))

samples = random.choices(range(N), k=BUDGET)
best_random = max(samples, key=score)
  1. 1A toy space of 200 architectures; score looks up a stored accuracy instead of training.
  2. 2mutate moves to a nearby architecture, at most 15 places away (this page's choice, not a published setting). A larger step explores more but can jump off a good peak; a smaller one refines a peak but can stay stuck on a small one.
  3. 3Random search: score BUDGET = 100 random picks and keep the best. A bigger budget gets every strategy closer to the best architecture at proportionally more GPU time.

Evolution spends the same BUDGET of architectures to train and score on a population of 8, replacing the worst with a mutated copy of the best. Hill climbing is a population of one that keeps a mutation only if it scores higher.

pop = random.choices(range(N), k=8)
for _ in range(BUDGET - 8):
    # worst first
    pop.sort(key=score)
    pop[0] = mutate(pop[-1])
best_evo = max(pop, key=score)
  1. 1Starts a population of 8 random architectures, which count toward the budget. A larger population explores more peaks for the same budget but improves each one more slowly.
  2. 2Each round replaces the worst member with a mutated copy of the best.
  3. 3Keeps the best architecture found.

NAS can only find what the search space contains.

height = true accuracy#45#130 best#185architecture #0 to #199
Figure 2. The toy space; #130 is the best peak.

Figure 3. Three searches race; hill climbing can stall.

Toy simulation with a fixed seed, not real NAS results.

2.3 Reinforcement-learning controllers

Reinforcement learning is the NASNet approach. A controller, usually a small RNN (recurrent neural network, which emits its choices one at a time; taught in Recurrent Neural Networks: RNN, GRU, and LSTM), samples architectures; each is trained, and its validation accuracy (on held-out data) comes back as a reward.

Figure 1 from Zoph et al. (2018): a loop of four boxes. Top: sample architecture A with probability p. Right: train a child network with architecture A to convergence to get validation accuracy R. Bottom: scale gradient of p by R to update the controller. Left: the controller (RNN).
Figure 4. "… Scale the gradients of p by R to update the RNN controller." Paper Fig. 1, arXiv:1707.07012

The controller is a policy: for each choice it gives every option a probability, written π(a) for the option a it actually picked, and the reward R is the child's validation accuracy. The first NAS updates it with REINFORCE against a baseline (step 4 of Figure 5); NASNet uses PPO (Proximal Policy Optimization, which limits how far one update can move the policy). REINFORCE, the log-probability it raises and the baseline it subtracts are taught, with PPO, in Policy-Based and Actor-Critic Methods: REINFORCE, PPO and SAC.

Figure 5. One loop round, illustrative numbers; reward lifts each pick.

A pick whose child beats the usual reward becomes more likely; one that falls short becomes less likely.
∇J ≈ (R − b) ∇log π(a)
J is the expected reward, the average accuracy of the children it samples, and ∇J is the weight change that raises it fastest. The reward R minus the running average b says how much better than usual this child did, and ∇log π(a) points toward making the picked op a more likely. Here R is 0.74 and b is 0, so every pick goes up. The listing below runs this line as loss = -logp * reward.
∇log π(a) = ∇π(a)π(a)
The log brings this division. A common op is sampled often, and dividing its push by π(a) stops it gaining just for turning up, so on average the pushes follow ∇J.
≈ approximately; ∇ gradient; π(a) probability of op a

The listing runs that update without a baseline on a one-layer toy: logits choose among kernels 3, 5 and 7, and a lookup table perf stands in for training.

import torch
from torch.distributions import Categorical

# kernels 3, 5, 7
perf = torch.tensor([0.70, 0.90, 0.60])
logits = torch.nn.Parameter(torch.zeros(3))
opt = torch.optim.Adam([logits], lr=0.1)
for step in range(50):
    dist = Categorical(logits=logits)
    action = dist.sample()
    reward = perf[action]
    logp = dist.log_prob(action)
    loss = -logp * reward
    opt.zero_grad()
    loss.backward()
    opt.step()
  1. 1Stored rewards for kernels 3, 5 and 7, and the controller's logits over them.
  2. 2The controller samples a kernel and receives its reward.
  3. 3REINFORCE update: raise the log-probability of the sampled kernel in proportion to its reward.

The probability mass drifts toward kernel 5; without a baseline some runs wander first. With real training in place of the lookup, this is the first NAS controller's REINFORCE update in miniature, minus its baseline and its RNN. NASNet trained the same kind of RNN controller with PPO instead.

NASNet (Zoph et al. 2018) cut the cost of the first NAS by searching for small cells instead of whole networks (Figure 6).

NAS (2017), K40s 22,400 GPU-days NASNet (2018), P100s 2,000 GPU-days
Figure 6. NASNet's footnote 1 reports 800 GPUs for 28 days against 500 for 4.

NASNet searches for two cells on CIFAR-10 (60,000 tiny labeled images), a cheap stand-in for ImageNet (the large standard image-classification dataset): a normal cell keeps the feature map (a layer's output grid) the same size and a reduction cell halves it. The same cell design scales by construction.

Search on CIFAR-10, where training is cheapnormal cellsize keptreduction cellsize halvedstack and widenBuild the network for ImageNeteach reduction cell halves the size
Figure 7. Search two small cells cheaply, then stack copies into the big network.

EfficientNet-B0, the best-known result of RL search, reaches 77.1% top-1 on ImageNet (first guess right) with 5.3M weights. The controller searched the MnasNet mobile space with a reward trading accuracy against FLOPs (the count of arithmetic operations). The authors then scaled B0 up with compound scaling, one coefficient growing depth, width and resolution (the card below).

EfficientNet's own ratios (Tan and Le 2019) are 1.2 for depth, 1.1 for width and 1.15 for resolution, applied once per step of the knob φ (B0 is φ = 0); the card names them α, β and γ.

φdepthwidthresolutionFLOPs
1×1.2×1.1×1.15×1.92
2×1.44×1.21×1.32×3.7
One knob, φ, grows depth, width and resolution together, and each step of it about doubles the compute.
d = αφ,  w = βφ,  r = γφ
The multipliers d, w and r scale depth (layers), width (channels) and input resolution (pixels per side). The ratios α, β, γ come from a small grid search on B0 and stay fixed; raising them to one power φ (phi) grows all three in balance, so φ = 1 gives the first row of the table.
α · β2 · γ2 ≈ 2
Compute grows with depth and with the square of width and of resolution, so this product is the FLOPs growth per step of φ; with EfficientNet's ratios it is 1.92, the FLOPs column of the table. Capping it near 2 means each step of φ about doubles the compute, B1 through B7. Efficient CNNs: MobileNet and EfficientNet works through it in full.

The strategy is an outer loop; each child is an ordinary short training job on a proxy, and its validation score goes back to the strategy. The evolution loop of Section 2 cannot be reused: it rescores the whole population every round (744 calls to score for a budget of 100), and its mutate moves an index along a line, not a choice inside an architecture.

Listing A trains one child: an architecture is a list of L choices from the five ops of Section 2.1, and score trains it briefly on proxy_loader (a training subset) and returns accuracy on the held-out val_loader.

import torch
import torch.nn as nn
import torch.nn.functional as F

C = 16
MAKE = {
    "conv3": lambda: nn.Sequential(nn.Conv2d(C, C, 3, padding=1), nn.ReLU()),
    "conv5": lambda: nn.Sequential(nn.Conv2d(C, C, 5, padding=2), nn.ReLU()),
    "sep3": lambda: nn.Sequential(nn.Conv2d(C, C, 3, padding=1, groups=C),
                                  nn.Conv2d(C, C, 1), nn.ReLU()),
    "pool": lambda: nn.MaxPool2d(3, stride=1, padding=1),
    "skip": lambda: nn.Identity(),
}

def build(arch):
    layers = [MAKE[op]() for op in arch]
    return nn.Sequential(nn.Conv2d(3, C, 3, padding=1), *layers,
                         nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(C, 10))

def score(arch, epochs=5):
    net = build(arch)
    opt = torch.optim.SGD(net.parameters(), lr=0.05, momentum=0.9)
    net.train()
    for _ in range(epochs):
        for x, y in proxy_loader:
            loss = F.cross_entropy(net(x), y)
            opt.zero_grad()
            loss.backward()
            opt.step()
    net.eval()
    with torch.no_grad():
        hits = sum((net(x).argmax(1) == y).sum().item() for x, y in val_loader)
    return hits / len(val_loader.dataset)
  1. 1The five ops and build, which stacks one op per layer between a first convolution and a classifier. Convolution and max pooling are taught in CNN Basics: Convolution, Stride and Pooling; sep3 is a depthwise-separable convolution (groups=C), taught in Efficient CNNs: MobileNet and EfficientNet.
  2. 2Trains the child for 5 epochs, the per-child budget MnasNet used; the optimizer is SGD with momentum (taught in Optimizers from SGD to Adam), and the learning rate 0.05 and momentum 0.9 are this page's choice.
  3. 3Returns validation accuracy, the only signal the strategy sees.

Listing B is aging evolution (Algorithm 1 of Real et al. 2019, the AmoebaNet search). Members keep their scores, so each child trains once; each cycle mutates the best of S sampled members and drops the oldest.

import random, collections

OPS = ["conv3", "conv5", "sep3", "pool", "skip"]
L, P, S, BUDGET = 8, 10, 3, 100

def mutate(arch):
    child = list(arch)
    i = random.randrange(L)
    others = [op for op in OPS if op != arch[i]]
    child[i] = random.choice(others)
    return child

# each member is (arch, acc): trained once, kept
pop = collections.deque()
history = []
while len(pop) < P:
    a = [random.choice(OPS) for _ in range(L)]
    pop.append((a, score(a)))
    history.append(pop[-1])
while len(history) < BUDGET:
    sample = random.choices(list(pop), k=S)
    parent = max(sample, key=lambda m: m[1])[0]
    child = mutate(parent)
    pop.append((child, score(child)))
    history.append(pop[-1])
    # drop the oldest
    pop.popleft()
best = max(history, key=lambda m: m[1])[0]
  1. 1L = 8 layers over the five ops, a population of P = 10, samples of S = 3 and a budget of 100 children, sized for a toy run; mutate changes one layer to a different op.
  2. 2Fills the population with random architectures, each scored once.
  3. 3Each cycle mutates the best of S sampled members, scores the child and drops the oldest member, so score runs once per child, 100 times in all.

The paper used P = 100 and S = 25, 25 epochs per child and 20,000 models on 450 K40 GPUs, about 7 days; NNI's RegularizedEvolution defaults to the same P and S. A larger S picks parents more greedily (S = 1 is random search); a larger P remembers more but adapts more slowly. Repeat architectures are retrained on purpose, so only those that retrain well survive.

The per-child budget sets the bill. The first NAS (Zoph and Le 2017) trained every child for 50 epochs (Figure 6 has the totals). MnasNet trained each of about 8K models for 5 epochs on ImageNet, 4.5 days on 64 TPUv2 devices, with a reward that also charged for latency, the time one prediction takes on the target hardware.

Implementations include MnasNet's code (tensorflow/tpu) and NNI's ready-made random, evolution and RL (PolicyBasedRL) strategies.

4 Pitfalls of sampling-based search

Each method here trains every candidate separately, thousands of GPU-days per search. Can the choice itself be trained by gradient descent, inside one network? One-Shot NAS: Weight Sharing and DARTS does that.

5 References