← All tutorials

One-Shot NAS: Weight Sharing and DARTS

One supernet holds every candidate; DARTS makes the choice trainable by gradient descent

On this page

1 Weight sharing in a supernet 2 Differentiable search (DARTS) 3 Search cost of each method 4 DARTS in practice 5 Pitfalls of one-shot search 6 References
supernet, a stack of cellscellcellcellcellone edge, 3 candidate ops3x3 conv5x5 convskipsample one path(Section 1)blend all opsby weights α(Section 2)
Figure 1. One network holds every candidate; search samples or blends.

Sampling-based search trains thousands of separate networks, one per candidate. One-shot NAS trains one network that contains them all, a supernet (Figure 1). The search then either samples one path through it at a time or blends every op on an edge by learned architecture weights.

The search runs once, before the final model is trained. The supernet's weights, and in DARTS the architecture weights, update on every training step, and the winning architecture is then retrained from scratch.

Every NAS method has a search space, a search strategy and an evaluation step (Search space, search strategy and evaluation in Neural Architecture Search Basics). A cell is a small graph whose nodes are feature maps and whose edges each apply one op; the network stacks copies of it (Reinforcement-learning controllers).

1 Weight sharing in a supernet

Train one network once, and every candidate inside it borrows its weights. Most modern NAS methods rely on weight sharing: one giant supernet contains every candidate as a sub-network, and one subnet is one path through the layers (Figure 2).

Layer 1conv 3x3conv 5x5skipLayer 2conv 3x3conv 5x5skipLayer 3conv 3x3conv 5x5skipgreen marks one subnet, one op per layer
Figure 2. All subnets share one set of weights, which makes the search affordable.

The supernet trains by sampling one path per step and updating only that path's weights (Single Path One-Shot). The listing is a one-layer supernet: three convolutions (taught in CNN Basics: Convolution, Stride and Pooling) share the layer, and choice picks the path.

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

class SuperNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.ops = nn.ModuleDict({
            "k3": nn.Conv2d(3, 16, 3, padding=1),
            "k5": nn.Conv2d(3, 16, 5, padding=2),
            "k7": nn.Conv2d(3, 16, 7, padding=3),
        })
        self.fc = nn.Linear(16, 10)

    def forward(self, x, choice):
        h = F.relu(self.ops[choice](x))
        # average over height and width
        pooled = h.mean(dim=(2, 3))
        return self.fc(pooled)
  1. 1One supernet holds every candidate kernel, sharing one classifier.
  2. 2The forward pass runs only the chosen path.

Training samples a fresh path on every step, with torch and F from the SuperNet listing.

import random
supernet = SuperNet()
params = supernet.parameters()
opt = torch.optim.SGD(params, lr=0.01)
for x, y in train_loader:
    # one path per batch
    choice = random.choice(["k3", "k5", "k7"])
    out = supernet(x, choice)
    loss = F.cross_entropy(out, y)
    opt.zero_grad()
    loss.backward()
    opt.step()
  1. 1Builds the supernet and one plain SGD optimizer (stochastic gradient descent, taught in Gradient Descent) for all shared weights.
  2. 2Samples one path per batch and computes its loss.
  3. 3Updates only the weights that path used, one convolution and the shared head; over many steps every op is sampled many times, so every op still gets trained.

Once the supernet is trained, score subnets with the shared weights, pick the winner, and always retrain the winner from scratch: the shared weights are good enough for ranking candidates, not for final accuracy.

ENAS (Pham et al. 2018) shares weights the same way but lets an RL controller choose the path instead of a uniform draw.

2 Differentiable search (DARTS)

Differentiable search (DARTS) makes the search itself trainable by gradient descent. It uses a continuous relaxation: every edge between nodes holds all candidate ops at once and blends them through a softmax (taught in the softmax section of Cross-Entropy Loss) of the architecture weights α, one learnable number αo per candidate op o on each edge (α here is not a learning rate).

For example, illustrative architecture weights of 1, 0 and 0 for 3x3 conv, 5x5 conv and skip give the shares in the table: an architecture weight 1 higher gives its op about 2.7 times (e times) the weight of each other op, and the three shares add to 1.

oparchitecture weight αshare
3x3 conv10.58
5x5 conv00.21
skip00.21
Each DARTS edge runs every candidate op at once and blends the outputs by a softmax of the architecture weights.
ō(x) = Σo eαoΣo′ eαo′ · o(x)
The edge's output is a weighted average of every op's output o(x). The weights come from a softmax over the architecture weights: exp makes each αo positive and dividing by the sum makes the shares add to 1, with a bigger α getting an exponentially bigger share, as in the table. Because the blend is smooth in α, gradient descent can tune the architecture weights like any other weight.
final op = argmaxo αo
At the end each edge keeps only the op with the biggest architecture weight, which turns the soft blend back into one concrete architecture. Replace a hard choice with a soft average, optimize it with gradients, then harden it.
Figure 1 from Liu et al. (2019): four panels showing the same 4-node cell. Panel (a) has dashed edges marked with question marks. Panel (b) has every edge carrying parallel red, green, and blue edges. Panel (c) has some edges bold and others faded. Panel (d) has one solid edge per surviving pair.
Figure 3. "An overview of DARTS: … (d) Inducing the final architecture …" Paper Fig. 1

The listing is one DARTS edge with three candidate ops on c channels: alpha holds the architecture weights α, and forward is the blend line of the card. After the search, the kept op is block.ops[block.alpha.argmax()].

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

class DartsBlock(nn.Module):
    def __init__(self, c):
        super().__init__()
        self.ops = nn.ModuleList([
            nn.Conv2d(c, c, 3, padding=1),
            nn.Conv2d(c, c, 5, padding=2),
            nn.Identity(),
        ])
        a = torch.zeros(3)
        self.alpha = nn.Parameter(a)

    def forward(self, x):
        w = F.softmax(self.alpha, dim=0)
        outs = [op(x) for op in self.ops]
        # weighted sum of all ops
        return sum(wi * o for wi, o in zip(w, outs))
  1. 1Three candidate operations and one architecture weight α per operation.
  2. 2Softmax over α, then the weighted sum of every operation's output.

There are now two things to learn: the architecture weights α and the ordinary network weights w. DARTS nests them as a bilevel optimization, with training loss Ltrain and validation loss Lval.

Take the same edge with two settings of α, and for each one train w on the training batches (illustrative numbers). The 5x5 conv has the most weights, so it fits the training batches best, partly by memorizing them; on held-out validation batches the 3x3 conv wins.

α (3x3, 5x5, skip)LtrainLval
1, 0, 00.180.41
0, 1, 00.100.62

Judged on Ltrain, the search would pick 5x5; judged on Lval, it picks 3x3. So α is judged on validation data, but only after w has been trained for that α. The card writes these two jobs as two nested problems.

The architecture is chosen for how well its trained network does on held-out data, and training happens inside that choice.
minα Lval(w*(α), α)
The outer problem picks the architecture weights α whose trained network does best on the validation set. It uses validation data because judging an architecture on the data its weights were fit to would reward memorizing, not a good design. In the table it picks 1, 0, 0, because 0.41 is lower than 0.62.
w*(α) = argminw Ltrain(w, α)
The inner problem trains the network weights w on the training set for a fixed architecture. Its answer w*(α), the trained weights, depends on α, which is what ties the two levels together. Ltrain(w, α) is the training loss of the blended edge from the softmax card above, so changing α changes the blend, and the best w changes with it.
w′ = w − ξ ∇w Ltrain(w, α)
Training to convergence inside every outer step would cost a full training run per step, so DARTS stands in one gradient step with step size ξ (xi) and updates α as if w′ were the fully trained weights. For one weight at 0.50 with a training gradient of 0.8 and a step size ξ of 0.025, the step is 0.025 times 0.8, or 0.02, so w′ is 0.48 (illustrative). It is approximate, but it searches in days instead of years. The paper suggests setting ξ equal to the weights' learning rate; ξ = 0 drops the look-ahead, the first-order form in the listing below.
Outer loop, αminimize validation lossfix αupdate αInner loop, wminimize training lossone batch each, alternating
Figure 4. α learns on validation data while w trains with α fixed.

Each search step alternates two gradient steps (Figure 4 and the card below): one for the architecture weights α on a validation batch with learning rate ηα, then one for the op weights w on a training batch with learning rate ηw.

Each search step moves the architecture weights on validation data, then the op weights on training data.
α ← α − ηα ∇α Lval
The architecture weights α take one step downhill on the validation loss Lval, sized by their learning rate ηα, while the op weights stay fixed.
w ← w − ηw ∇w Ltrain
The op weights w take one step downhill on the training loss Ltrain, sized by ηw, while the architecture weights stay fixed.
← replace with; ∇ gradient, the direction of steepest change

Raising ηα lets one op pull ahead before the op weights have learned what each op can do; lowering it leaves the architecture weights close to uniform when the search ends, so the final pick rests on tiny differences (Figure 5).

At the start of a search the convolution kernels are untrained, so their outputs only add error, while skip passes its input through unchanged and needs no training. Skip is the only op that already helps, so α first moves toward it. A convolution helps only once its kernel w has been trained (a kernel is a small filter, as in Kernels as pattern detectors), and inside the blend each kernel learns only what the other ops leave unexplained. Each op's architecture weight moves in proportion to its current share, so an op that falls behind early catches up slowly even after its kernel is trained.

The toy run below takes one edge whose input is a random signal and whose target is that signal passed through a 3-tap low-pass filter, plus noise (taught in How FIR and IIR Filters Shape Magnitude and Phase). A low-pass filter has all taps positive, so a trained 3x3 kernel shows all three bars above the midline. Each kernel is kept at a fixed size, as batch norm does in DARTS, so only α sets the blend. The toy counts 20 search steps as one epoch and runs 50 epochs, the length of the official search.

Figure 5. 1Skip leads; 2a trained 3x3 wins; bigger ηα lifts 1.

In this run the 3x3 conv catches up before epoch 50, but with other data the skip lead can last to the end, which is the skip collapse in Section 5.

The listing is the simplest, first-order form, where α's gradient skips how w′ depends on α: net is a network built from DartsBlocks, its parameters split by name into the architecture weights and the network weights, each with its own optimizer.

alphas, weights = [], []
for n, p in net.named_parameters():
    if n.endswith("alpha"):
        alphas.append(p)
    else:
        weights.append(p)
w_opt = torch.optim.SGD(weights, lr=0.025)
a_opt = torch.optim.Adam(alphas, lr=3e-4)
batches = zip(train_loader, val_loader)
for (x_tr, y_tr), (x_val, y_val) in batches:
    # alpha on validation data
    a_opt.zero_grad()
    val_loss = F.cross_entropy(net(x_val), y_val)
    val_loss.backward()
    a_opt.step()
    # w on training data
    w_opt.zero_grad()
    loss = F.cross_entropy(net(x_tr), y_tr)
    loss.backward()
    w_opt.step()
  1. 1Splits parameters into architecture weights α and network weights w, each with its own optimizer.
  2. 2Updates α on a validation batch.
  3. 3Updates w on a training batch.

3 Search cost of each method

A GPU-day is one graphics processor running for one full day. Params counts the model's weights in millions, and top-1 means the first guess is right. NASNet-A came from an RL controller that trains every sampled child (Reinforcement-learning controllers), and AmoebaNet-C from aging evolution, which keeps mutating the best of a sampled group (Neural Architecture Search Basics).

ImageNet top-1 (%)7273747576771101001k10ksearch cost, GPU-days (log scale)NASNet-A5.3M paramsAmoebaNet-C6.4M paramsDARTS4.7M paramsProxylessNAS7.1M params
Figure 6. Similar accuracy, about 500 times less search.

ProxylessNAS is latency-aware: its search objective charges for latency, the time one prediction takes on the target hardware. DARTS's ~4 GPU-days use the full look-ahead update of Section 2, and the first-order form takes 1.5; ProxylessNAS's ~8 are 200 GPU-hours.

NASNet-A (RL)~2,000AmoebaNet-C (evolution)~3,150DARTS (differentiable)~4ProxylessNAS (differentiable)~8GPU-days
Figure 7. Search cost to scale, with the two smallest bars widened to stay visible.

RL and evolution found great architectures for thousands of GPU-days; DARTS got comparable results for about 4. That cost collapse is why weight sharing won.

The best-known result of RL search, EfficientNet, is covered in Neural Architecture Search Basics.

4 DARTS in practice

A DARTS search is its own training job, run before the real model exists: the first-order loop of Section 2, one batch for w and one held-out batch for α per step. After the search, keep the strongest op on each edge and train that network from scratch with an ordinary loop.

The official search script (quark0/darts, cnn/train_search.py and cnn/architect.py) runs 50 epochs at batch size 64, and half of the CIFAR-10 training set trains w while the other half trains α. The listing is its optimizer setup and one search step, with model the search network and criterion its cross-entropy loss.

w_opt = torch.optim.SGD(model.parameters(), lr=0.025, momentum=0.9, weight_decay=3e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(w_opt, T_max=50, eta_min=0.001)
a_opt = torch.optim.Adam(model.arch_parameters(), lr=3e-4, betas=(0.5, 0.999), weight_decay=1e-3)

# one α step: a batch of 64 from the half of CIFAR-10 kept for α
a_opt.zero_grad()
criterion(model(x_val), y_val).backward()
a_opt.step()

# one w step: a batch of 64 from the half kept for w
w_opt.zero_grad()
criterion(model(x), y).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5)
w_opt.step()

sched.step()  # once per epoch, 50 epochs
  1. 1SGD with momentum for the op weights w and Adam for the architecture weights α (both taught in Optimizers from SGD to Adam), with cosine decay from 0.025 to 0.001 over 50 epochs (taught in Optimizers in Practice: AdamW, SAM, Schedules and Clipping). Adam's betas (0.5, 0.999) are β1 and β2, how fast its two running averages (of the gradient and of its square) forget: about 2 and 1,000 steps of memory.
  2. 2One α step on a validation batch.
  3. 3One w step on a training batch, with the gradient norm clipped at 5.
  4. 4The learning-rate schedule steps once per epoch.

5 Pitfalls of one-shot search

FairDARTS replaces the competing softmax shares with independent sigmoid weights, which takes away the skip connection's head start.
po = eαoΣo′ eαo′
DARTS weighs each op o by a share po, a softmax over the architecture weights α: the same share that weights each op in the Section 2 blend. The shares must add to 1, so the ops compete: a skip connection that trains fast grabs share from every other op.
po = σ(αo) = 11 + e−αo
FairDARTS uses the sigmoid σ instead, which squashes each architecture weight into a value between 0 and 1 on its own. The Section 2 architecture weights 1, 0, 0 give 0.73, 0.50 and 0.50 here: the leader no longer lowers the others, so a strong skip takes nothing from the other ops.
softmax sharessum to 1startendsigmoid weightseach on its ownstartend3x3 conv5x5 convskip
Figure 8. Toy skip collapse; softmax shares compete, sigmoid weights do not.

NAS finds the architecture. Knowledge Distillation then teaches that small model to behave like a big one: two halves of the same goal, big-model accuracy on a small-model budget. Pruning and quantization (Post-Training Quantization and Quantization-Aware Training) shrink whatever comes out further.

6 References