Wyszukiwanie architektury neuronowej i AutoML: automatyzacja projektowania sieci
Projektowanie architektury sieci neuronowej jest tradycyjnie procesem ręcznym, który wymaga lat doświadczenia, intuicji i zasobów obliczeniowych do eksperymentowania. ResNet, EfficientNet, MobileNet — każda z tych kultowych architektur jest wynikiem ludzkich wyborów projektowych głęboko poinformowany. Jednak z Wyszukiwanie architektury neuronowej (NAS), proces ten można zautomatyzować: mając budżet obliczeniowy i zadanie, algorytm eksploruje przestrzeń możliwych architektur i identyfikuje optymalną.
Praktyczny i zaskakujący wynik. EfficientNet — prawdopodobnie najbardziej wpływowa rodzina CNN ostatnich lat — została odkryta za pośrednictwem NAS. NASNet, DARTS, Once-for-All i inne modele Serwery NAS konsekwentnie pokonują ręcznie zaprojektowane architektury w standardowych testach porównawczych. Ale prawdziwą rewolucją jest demokratyzacja: za pomocą narzędzi takich jak Opcja, Ray Tune, ENAS e Timm, dzisiaj jest to możliwe NAS na konsumenckich procesorach graficznych w ciągu kilku godzin, optymalizując architekturę dla konkretnego sprzętu.
W tym przewodniku omawiamy techniki NAS od podstaw — od GridSearch po DARTS — i wspólnie z Optuną budujemy praktyczny potok AutoML w celu optymalizacji architektur głębokiego uczenia się.
Czego się nauczysz
- Co to jest NAS i dlaczego przewyższa projektowanie ręczne
- Przestrzenie wyszukiwania: mikro (oparte na komórkach) vs makro (oparte na warstwach)
- Strategie wyszukiwania: Wyszukiwanie losowe, RL, Ewolucyjne, DARTS
- One-Shot NAS i podział wagi: jak obniżyć koszty z lat do godzin
- Implementacja NAS z Optuną: wyszukiwanie hiperparametrów + architektury
- Wyszukiwanie architektury różniczkowej (DARTS) z pełnym oprogramowaniem PyTorch
- Sieci jednorazowe: architektury dla sprzętu heterogenicznego
- Serwer NAS obsługujący sprzęt: Optymalizacja pod kątem opóźnień, FLOPów i parametrów
- AutoML z AutoKeras i NAS dla urządzeń brzegowych
- Prawdziwe studium przypadku: NAS do klasyfikacji medycznej w Jetson Nano
Dlaczego NAS przewyższa projektowanie ręczne
Ręczne projektowanie architektury ma trzy ograniczenia strukturalne. Po pierwsze,ekspertyza stronniczość: badacze mają tendencję do ponownego wykorzystywania znanych wzorców (ResBlocks, pomijanie połączeń) nawet jeśli nie są one optymalne dla konkretnego zadania. Po drugie,niedopasowanie sprzętu: optymalna architektura na A100 jest rzadkością i na Cortex-A55. Po trzecie, eksplozja kombinatoryczna: przestrzeń możliwych architektur np astronomicznie duże — nawet poprzez zmianę liczby warstw, kanałów i rozmiarów jądra w 8 warstwach otrzymujesz więcej niż 10^14 konfiguracji.
NAS rozwiązuje te problemy poprzez formalne zdefiniowanie problemu projektowego:
# FORMALIZZAZIONE DEL PROBLEMA NAS
#
# Input:
# - Spazio di ricerca A: insieme di possibili architetture
# - Dataset D = (D_train, D_val)
# - Funzione di costo c(a, D): misura la bonta di a su D
# - Budget computazionale B
#
# Output:
# - a* = argmin_{a in A} c(a, D_val) s.t. cost(search) <= B
#
# Il costo tipicamente include:
# - Validation accuracy (minimizzare errore)
# - Latenza su hardware target
# - Numero di parametri
# - Consumo energetico
#
# Esempio di funzione costo multi-obiettivo:
# c(a) = (1 - val_acc) + lambda * latency_ms / latency_target
#
# Dove lambda bilancia accuracy e efficiency
import torch
import torch.nn as nn
from typing import Dict, Any, Optional
class NASObjective:
"""
Funzione obiettivo per NAS multi-obiettivo.
Combina accuracy, latenza e dimensione del modello.
"""
def __init__(
self,
accuracy_weight: float = 1.0,
latency_weight: float = 0.1,
params_weight: float = 0.01,
latency_target_ms: float = 10.0,
params_target_M: float = 5.0
):
self.w_acc = accuracy_weight
self.w_lat = latency_weight
self.w_par = params_weight
self.lat_target = latency_target_ms
self.par_target = params_target_M * 1e6
def __call__(
self,
val_accuracy: float,
latency_ms: float,
n_params: int
) -> float:
"""
Calcola il costo composito. Più basso = meglio.
val_accuracy: [0, 1] - vogliamo massimizzarla
latency_ms: millisecondi - vogliamo minimizzarla
n_params: numero parametri - vogliamo minimizzarli
"""
acc_cost = self.w_acc * (1.0 - val_accuracy)
lat_cost = self.w_lat * max(0, latency_ms / self.lat_target - 1.0)
par_cost = self.w_par * max(0, n_params / self.par_target - 1.0)
return acc_cost + lat_cost + par_cost
# Esempio di uso:
obj = NASObjective(latency_target_ms=5.0, params_target_M=2.0)
cost = obj(val_accuracy=0.92, latency_ms=4.2, n_params=1_800_000)
print(f"Costo composito: {cost:.4f}") # ~0.08
Przestrzeń badawcza NAS
Serce NAS i definicja przestrzeń wyszukiwania: zestaw wszystkich możliwe architektury, które algorytm może zbadać. Wybór przestrzeni poszukiwań Jest to kwestia fundamentalna – za wąska i optymalne nieosiągalne, za szerokie i poszukiwania staje się niewykonalne obliczeniowo.
Istnieją dwa główne podejścia:
- NAS oparty na komórkach (mikroprzestrzeń wyszukiwania): szukamy optymalnej struktury pojedynczą komórkę (komórkę), następnie komórka jest replikowana kilka razy w celu zbudowania sieci. To drastycznie zmniejsza przestrzeń poszukiwań przy zachowaniu elastyczności. Używany przez NASNet, DARTS, ENAS.
- Przestrzeń wyszukiwania makr: szukasz globalnych parametrów architektury, takich jak liczba warstw, wielkość kanałów, rodzaj połączeń. Używany przez EfficientNet (NAS + skalowanie), MobileNet v3, raz na zawsze.
# Esempio di spazio di ricerca macro per una CNN
# Ogni dimensione e una scelta discreta o continua
SEARCH_SPACE = {
# Struttura globale
"n_layers": [4, 6, 8, 10, 12], # Numero di layer
"initial_channels": [16, 32, 48, 64], # Canali iniziali
"width_multiplier": [0.5, 0.75, 1.0, 1.25, 1.5], # Moltiplicatore larghezza
# Per ogni layer:
"kernel_sizes": [3, 5, 7], # Dimensione kernel
"expansion_ratios": [1, 2, 4, 6], # Ratio espansione MBConv
"se_ratios": [0.0, 0.25, 0.5], # Squeeze-and-Excitation ratio
"skip_ops": ["identity", "conv", "pool"], # Tipo skip connection
# Configurazione attention (per reti ibride CNN+Transformer)
"use_attention": [False, True],
"attention_heads": [1, 2, 4, 8],
}
# Stima dimensione spazio di ricerca
import math
n_configs = (
len(SEARCH_SPACE["n_layers"]) *
len(SEARCH_SPACE["initial_channels"]) *
len(SEARCH_SPACE["width_multiplier"]) *
len(SEARCH_SPACE["kernel_sizes"]) ** 8 * # 8 layer
len(SEARCH_SPACE["expansion_ratios"]) ** 8
)
print(f"Configurazioni possibili: {n_configs:.2e}")
# ~10^14: impossibile esplorazione esaustiva!
# CELL-BASED SEARCH SPACE (molto più compatto)
# In NASNet/DARTS: cerca solo la struttura della cell
CELL_SEARCH_SPACE = {
"n_nodes": [3, 4, 5], # Nodi interni per cella
"ops_per_edge": [ # Operazioni candidate per edge
"sep_conv_3x3",
"sep_conv_5x5",
"dil_conv_3x3",
"dil_conv_5x5",
"avg_pool_3x3",
"max_pool_3x3",
"skip_connect",
"none"
],
"n_cells": [6, 8, 10, 12, 14], # Quante celle nella rete
"init_channels": [16, 24, 32, 36], # Canali iniziali
}
# Spazio ridotto: ~10^4 configurazioni invece di 10^14!
Strategie badawcze
Strategie wyszukiwania określają, w jaki sposób algorytm porusza się w przestrzeni architektury. Wybór strategii jest równie ważny jak sama przestrzeń poszukiwań:
| Strategia | Zbliżać się | Zawodowiec | Przeciwko | Typowy koszt |
|---|---|---|---|---|
| Losowe wyszukiwanie | Losowe pobieranie próbek | Prosta, mocna podstawa | Nieskuteczny | N * pełne szkolenie |
| Wyszukiwanie siatki | Wyczerpująca siatka | Kompletny, jeśli jest mało miejsca | Wykładniczy rozmiar | K^D * trening |
| Opcja Bayesa | Model zastępczy + przejęcie | Skuteczny, prowadzony | Drogie dla dużych przestrzeni | 50-200 prób |
| RL (NASNet) | Kontrolery RNN | Złożone architektury | 400 oryginalnych dni GPU | Ponad 1000 prób |
| Ewolucyjny | Algorytmy genetyczne | Miłego odkrywania | Bardzo powolny | Ponad 500 prób |
| RZUTKI | Ciągłe różnicowanie | Optymalnie 1-4 dni GPU | Intensywna pamięć | 1 cykl treningowy |
| Jednorazowy / ENAS | Supersieć z podziałem wagi | Godziny na jednym GPU | Przybliżenie rankingu | 1 supersieć + próbkowanie |
Praktyczny serwer NAS z Optuną
Opcja i najczęściej używana biblioteka do wyszukiwania hiperparametrów i architektur. Łączy w sobie próbkowanie Bayesa (TPE), oczyszczanie mało obiecujących prób i interfejs API elegancki, który naturalnie integruje się z PyTorch.
# pip install optuna optuna-integration[pytorch] torch torchvision
import optuna
from optuna.trial import Trial
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
# ============================================================
# SPAZIO DI RICERCA: architettura CNN flessibile
# ============================================================
class FlexibleCNN(nn.Module):
"""
CNN con architettura parametrizzata per NAS.
Supporta 2-5 convolutional blocks con kernel e canali variabili.
"""
def __init__(self, n_conv_layers: int, channels: list,
kernel_sizes: list, use_bn: bool,
dropout_rate: float, n_classes: int = 10):
super().__init__()
layers = []
in_channels = 3
for i in range(n_conv_layers):
out_channels = channels[i]
k = kernel_sizes[i]
layers.extend([
nn.Conv2d(in_channels, out_channels, k, padding=k//2),
nn.BatchNorm2d(out_channels) if use_bn else nn.Identity(),
nn.ReLU(inplace=True),
nn.MaxPool2d(2) if i < n_conv_layers - 1 else nn.AdaptiveAvgPool2d(4)
])
in_channels = out_channels
self.features = nn.Sequential(*layers)
final_size = channels[-1] * 16
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(dropout_rate),
nn.Linear(final_size, 512),
nn.ReLU(),
nn.Dropout(dropout_rate / 2),
nn.Linear(512, n_classes)
)
def forward(self, x):
return self.classifier(self.features(x))
# ============================================================
# OBJECTIVE FUNCTION per Optuna
# ============================================================
def objective(trial: Trial) -> float:
"""
Funzione obiettivo: addestra una architettura e restituisce val_accuracy.
Optuna chiamera questa funzione centinaia di volte con configurazioni diverse.
"""
# === SPAZIO DI RICERCA ===
n_conv_layers = trial.suggest_int("n_conv_layers", 2, 5)
channels = [
trial.suggest_categorical(f"channels_{i}", [32, 64, 96, 128, 192, 256])
for i in range(n_conv_layers)
]
kernel_sizes = [
trial.suggest_categorical(f"kernel_{i}", [3, 5])
for i in range(n_conv_layers)
]
use_bn = trial.suggest_categorical("use_bn", [True, False])
dropout_rate = trial.suggest_float("dropout", 0.1, 0.5)
# Iperparametri di training
lr = trial.suggest_float("lr", 1e-4, 1e-2, log=True)
batch_size = trial.suggest_categorical("batch_size", [64, 128, 256])
optimizer_name = trial.suggest_categorical("optimizer", ["Adam", "SGD", "AdamW"])
weight_decay = trial.suggest_float("weight_decay", 1e-5, 1e-2, log=True)
# === DATI ===
transform_train = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5071, 0.4867, 0.4408), (0.2675, 0.2565, 0.2761))
])
transform_val = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5071, 0.4867, 0.4408), (0.2675, 0.2565, 0.2761))
])
train_set = torchvision.datasets.CIFAR100(
root='./data', train=True, download=True, transform=transform_train
)
val_set = torchvision.datasets.CIFAR100(
root='./data', train=False, download=True, transform=transform_val
)
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=4)
val_loader = DataLoader(val_set, batch_size=256, shuffle=False, num_workers=4)
# === MODELLO ===
device = "cuda" if torch.cuda.is_available() else "cpu"
model = FlexibleCNN(n_conv_layers, channels, kernel_sizes, use_bn,
dropout_rate, n_classes=100).to(device)
# Optimizer
if optimizer_name == "Adam":
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
elif optimizer_name == "AdamW":
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
else:
optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9,
weight_decay=weight_decay)
criterion = nn.CrossEntropyLoss()
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=15)
# === TRAINING (15 epoche per trial veloce) ===
for epoch in range(15):
model.train()
for imgs, labels in train_loader:
imgs, labels = imgs.to(device), labels.to(device)
optimizer.zero_grad()
loss = criterion(model(imgs), labels)
loss.backward()
optimizer.step()
scheduler.step()
# Pruning: elimina trial non promettenti dopo le prime 5 epoche
if epoch >= 4:
model.eval()
correct = total = 0
with torch.no_grad():
for imgs, labels in val_loader:
imgs, labels = imgs.to(device), labels.to(device)
preds = model(imgs).argmax(1)
correct += (preds == labels).sum().item()
total += labels.size(0)
val_acc = correct / total
# Segnala a Optuna per pruning
trial.report(val_acc, epoch)
if trial.should_prune():
raise optuna.exceptions.TrialPruned()
# Accuracy finale
model.eval()
correct = total = 0
with torch.no_grad():
for imgs, labels in val_loader:
imgs, labels = imgs.to(device), labels.to(device)
preds = model(imgs).argmax(1)
correct += (preds == labels).sum().item()
total += labels.size(0)
return correct / total # Optuna massimizza questo valore
# ============================================================
# AVVIO STUDIO OPTUNA
# ============================================================
study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=42), # Bayesian (Tree-structured Parzen Estimator)
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=5)
)
# Esegui 100 trial (parallelizzabile con n_jobs)
study.optimize(objective, n_trials=100, timeout=3600)
# === RISULTATI ===
best_trial = study.best_trial
print(f"Miglior accuratezza: {best_trial.value:.4f}")
print(f"Miglior configurazione:")
for key, val in best_trial.params.items():
print(f" {key}: {val}")
# Visualizzazione importanza parametri
fig = optuna.visualization.plot_param_importances(study)
fig.show() # Richiede plotly
DARTS: Wyszukiwanie zróżnicowanej architektury
RZUTKI (Liu et al., 2019) i jeden z najbardziej eleganckich algorytmów NAS wyd skuteczny. Kluczowa idea: dokonać wyboru operacji ciągłe i różniczkowalne, co pozwala zoptymalizować architekturę za pomocą opadania gradientowego zamiast wyszukiwania dyskretnego.
W komórce DARTS każda krawędź pomiędzy węzłami ma a miękka mieszanka ze wszystkich możliwych
operacje (konw. 3x3, konw. 5x5, pomiń, pula). Wagi mieszające (parametry architektury
alpha) są optymalizowane przy użyciu gradientu opadania wraz z wagami modelu.
Ostatecznie dla każdej krawędzi wybieramy operację o największej wadze (dyskretyzacja).
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import List
# ============================================================
# PRIMITIVE OPERATIONS per la cella DARTS
# ============================================================
OPS = {
'none': lambda C, stride: Zero(stride),
'skip_connect': lambda C, stride: nn.Identity() if stride == 1 else FactorizedReduce(C),
'sep_conv_3x3': lambda C, stride: SepConv(C, C, 3, stride, 1),
'sep_conv_5x5': lambda C, stride: SepConv(C, C, 5, stride, 2),
'dil_conv_3x3': lambda C, stride: DilConv(C, C, 3, stride, 2, 2),
'avg_pool_3x3': lambda C, stride: nn.AvgPool2d(3, stride, 1, count_include_pad=False),
'max_pool_3x3': lambda C, stride: nn.MaxPool2d(3, stride, 1),
}
PRIMITIVES = list(OPS.keys())
class SepConv(nn.Module):
"""Depthwise separable convolution."""
def __init__(self, C_in, C_out, kernel_size, stride, padding):
super().__init__()
self.op = nn.Sequential(
nn.ReLU(),
nn.Conv2d(C_in, C_in, kernel_size, stride, padding, groups=C_in, bias=False),
nn.Conv2d(C_in, C_out, 1, bias=False),
nn.BatchNorm2d(C_out),
nn.ReLU(),
nn.Conv2d(C_out, C_out, kernel_size, 1, padding, groups=C_out, bias=False),
nn.Conv2d(C_out, C_out, 1, bias=False),
nn.BatchNorm2d(C_out)
)
def forward(self, x):
return self.op(x)
class Zero(nn.Module):
def __init__(self, stride):
super().__init__()
self.stride = stride
def forward(self, x):
if self.stride == 1:
return x.mul(0.)
return x[:, :, ::self.stride, ::self.stride].mul(0.)
# ============================================================
# MIXED OPERATION: softmax su tutte le operazioni
# ============================================================
class MixedOp(nn.Module):
"""
Soft mixture di K operazioni con pesi alpha (architecture parameters).
output = sum_k(softmax(alpha)[k] * op_k(input))
"""
def __init__(self, C: int, stride: int):
super().__init__()
self._ops = nn.ModuleList()
for prim in PRIMITIVES:
op = OPS[prim](C, stride)
# Aggiungi BN dopo pool ops (standard DARTS)
if 'pool' in prim:
op = nn.Sequential(op, nn.BatchNorm2d(C))
self._ops.append(op)
def forward(self, x: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
"""weights: softmax(alpha) per questo edge."""
return sum(w * op(x) for w, op in zip(weights, self._ops))
# ============================================================
# CELLA DARTS (Normal Cell)
# ============================================================
class DARTSCell(nn.Module):
"""
Cella DARTS con N_NODES nodi intermedi.
Ogni nodo e la somma di tutti gli input precedenti pesati da alpha.
"""
N_NODES = 4
def __init__(self, C: int, reduction: bool = False):
super().__init__()
self.reduction = reduction
stride = 2 if reduction else 1
# Input preprocessing
self.preprocess0 = nn.Sequential(
nn.ReLU(), nn.Conv2d(C, C, 1, bias=False), nn.BatchNorm2d(C)
)
self.preprocess1 = nn.Sequential(
nn.ReLU(), nn.Conv2d(C, C, 1, bias=False), nn.BatchNorm2d(C)
)
# Mixed operations per ogni edge
self._ops = nn.ModuleList()
for i in range(self.N_NODES):
for j in range(2 + i): # Ogni nodo connesso a tutti i precedenti
s = stride if j < 2 else 1
self._ops.append(MixedOp(C, s))
def forward(self, s0: torch.Tensor, s1: torch.Tensor,
weights: torch.Tensor) -> torch.Tensor:
s0 = self.preprocess0(s0)
s1 = self.preprocess1(s1)
states = [s0, s1]
offset = 0
for i in range(self.N_NODES):
s = sum(
self._ops[offset + j](h, weights[offset + j])
for j, h in enumerate(states)
)
offset += len(states)
states.append(s)
# Output = concatenazione degli N_NODES interni
return torch.cat(states[2:], dim=1)
# ============================================================
# DARTS NETWORK con alpha parameters
# ============================================================
class DARTSNetwork(nn.Module):
def __init__(self, C: int = 16, n_classes: int = 10,
n_layers: int = 8, n_nodes: int = 4):
super().__init__()
C_curr = C
self.stem = nn.Sequential(
nn.Conv2d(3, C_curr, 3, padding=1, bias=False),
nn.BatchNorm2d(C_curr)
)
self.cells = nn.ModuleList()
for i in range(n_layers):
reduction = i in [n_layers // 3, 2 * n_layers // 3]
cell = DARTSCell(C_curr, reduction)
if reduction:
C_curr *= 2
self.cells.append(cell)
n_ops = len(PRIMITIVES)
n_edges = n_nodes * (n_nodes + 1) // 2 + 2 * n_nodes
# Alpha: architecture parameters (learnable!)
self._arch_parameters = nn.ParameterList([
nn.Parameter(1e-3 * torch.randn(n_edges, n_ops)) # Normal cell
for _ in range(n_layers)
])
self.global_pool = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Linear(C_curr * 4, n_classes)
def arch_parameters(self):
return list(self._arch_parameters)
def model_parameters(self):
ids = set(id(p) for p in self.arch_parameters())
return [p for p in self.parameters() if id(p) not in ids]
def forward(self, x: torch.Tensor) -> torch.Tensor:
s0 = s1 = self.stem(x)
for i, cell in enumerate(self.cells):
weights = F.softmax(self._arch_parameters[i], dim=-1)
s0, s1 = s1, cell(s0, s1, weights)
out = self.global_pool(s1)
return self.classifier(out.view(out.size(0), -1))
def genotype(self):
"""Estrae l'architettura discreta (argmax degli alpha)."""
result = []
for alpha in self._arch_parameters:
ops = F.softmax(alpha, dim=-1).argmax(dim=-1)
result.append([PRIMITIVES[op.item()] for op in ops])
return result
# ============================================================
# TRAINING LOOP DARTS: Bi-level optimization
# ============================================================
def train_darts(model: DARTSNetwork, train_loader, val_loader,
n_epochs: int = 50, arch_lr: float = 3e-4, model_lr: float = 3e-3):
"""
DARTS usa bi-level optimization:
- Passo 1: ottimizza pesi modello su train set
- Passo 2: ottimizza alpha (architettura) su val set
"""
device = next(model.parameters()).device
# Due optimizer separati: pesi e architettura
optimizer_model = torch.optim.SGD(
model.model_parameters(), lr=model_lr,
momentum=0.9, weight_decay=3e-4
)
optimizer_arch = torch.optim.Adam(
model.arch_parameters(), lr=arch_lr,
betas=(0.5, 0.999), weight_decay=1e-3
)
criterion = nn.CrossEntropyLoss()
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer_model, T_max=n_epochs
)
val_iter = iter(val_loader)
for epoch in range(n_epochs):
model.train()
total_loss = 0.0
for imgs_train, labels_train in train_loader:
imgs_train = imgs_train.to(device)
labels_train = labels_train.to(device)
# Passo 1: Aggiorna alpha su validation
try:
imgs_val, labels_val = next(val_iter)
except StopIteration:
val_iter = iter(val_loader)
imgs_val, labels_val = next(val_iter)
imgs_val = imgs_val.to(device)
labels_val = labels_val.to(device)
optimizer_arch.zero_grad()
loss_arch = criterion(model(imgs_val), labels_val)
loss_arch.backward()
optimizer_arch.step()
# Passo 2: Aggiorna pesi modello su training
optimizer_model.zero_grad()
loss_model = criterion(model(imgs_train), labels_train)
loss_model.backward()
nn.utils.clip_grad_norm_(model.model_parameters(), 5.0)
optimizer_model.step()
total_loss += loss_model.item()
scheduler.step()
if epoch % 10 == 0:
print(f"Epoch {epoch}/{n_epochs} | Loss: {total_loss/len(train_loader):.4f}")
print(f"Genotype: {model.genotype()[:2]}...")
return model.genotype()
DARTS vs One-Shot NAS: porównanie
| Czekam | RZUTKI | Jednorazowy (ENAS, OFA) |
|---|---|---|
| Koszt badań | 1-4 dni GPU | Godziny (supersieć po treningu) |
| jakość architektoniczna | Bardzo wysoki | Wysoka (niewielkie przybliżenie) |
| Docelowy sprzęt | Pojedynczy cel | Wiele celów (OFA) |
| Pamięć GPU | Wysoki (opcja dwupoziomowa) | Przeciętny |
| Realizacja | Złożony | Umiarkowany |
Serwer NAS obsługujący sprzęt z opcją Optuna i ograniczeniami opóźnień
Teoretyczny NAS dąży do maksymalnej dokładności. Praktyczny NAS optymalizuje jeden kompromis obejmujący wiele celów pomiędzy dokładnością, opóźnieniem, FLOPami i rozmiarem modelu. Optuna natywnie obsługuje wyszukiwanie wieloobiektowe za pomocą algorytmu NSGA-II.
import optuna
from optuna.samplers import NSGAIISampler
import torch
import time
def estimate_latency_ms(model: nn.Module, input_shape=(1, 3, 224, 224),
n_runs: int = 50, device: str = "cpu") -> float:
"""Misura latenza media in millisecondi."""
model = model.to(device).eval()
x = torch.randn(*input_shape).to(device)
# Warmup
with torch.no_grad():
for _ in range(10):
model(x)
t0 = time.perf_counter()
with torch.no_grad():
for _ in range(n_runs):
model(x)
elapsed = (time.perf_counter() - t0) / n_runs * 1000
return elapsed
def count_flops(model: nn.Module, input_shape=(1, 3, 224, 224)) -> int:
"""Stima FLOPs (usa fvcore o ptflops in produzione)."""
# Versione semplificata: conta operazioni nei layer Linear e Conv2d
total_flops = 0
x = torch.randn(*input_shape)
def hook(module, input, output):
nonlocal total_flops
if isinstance(module, nn.Conv2d):
B, C_out, H_out, W_out = output.shape
kernel_ops = module.kernel_size[0] * module.kernel_size[1] * module.in_channels
total_flops += 2 * B * C_out * H_out * W_out * kernel_ops
elif isinstance(module, nn.Linear):
B = input[0].shape[0]
total_flops += 2 * B * module.in_features * module.out_features
hooks = []
for m in model.modules():
if isinstance(m, (nn.Conv2d, nn.Linear)):
hooks.append(m.register_forward_hook(hook))
with torch.no_grad():
model(x)
for h in hooks:
h.remove()
return total_flops
# Multi-obiettivo NAS: massimizza accuracy, minimizza latency
def multi_objective(trial: optuna.Trial):
# Definisci architettura
n_channels = trial.suggest_categorical("channels", [32, 64, 128])
n_layers = trial.suggest_int("layers", 2, 6)
kernel = trial.suggest_categorical("kernel", [3, 5])
use_se = trial.suggest_categorical("use_se", [True, False])
# Costruisci modello
model = FlexibleCNN(
n_conv_layers=n_layers,
channels=[n_channels] * n_layers,
kernel_sizes=[kernel] * n_layers,
use_bn=True,
dropout_rate=0.2,
n_classes=10
)
# Addestra brevemente (versione reale: training completo)
# ...
# Metriche
val_accuracy = 0.80 + 0.05 * (n_channels / 128) # Simulato
latency = estimate_latency_ms(model, input_shape=(1, 3, 32, 32))
flops = count_flops(model, input_shape=(1, 3, 32, 32))
return val_accuracy, -latency # Massimizza accuracy, massimizza -latency
# Studio multi-obiettivo con NSGA-II (algoritmo evolutivo Pareto-ottimale)
study_mo = optuna.create_study(
directions=["maximize", "maximize"],
sampler=NSGAIISampler(seed=42)
)
study_mo.optimize(multi_objective, n_trials=100)
# Pareto front: architetture ottimali sul trade-off accuracy/latency
pareto_trials = study_mo.best_trials
print(f"Architetture Pareto-ottimali: {len(pareto_trials)}")
for t in pareto_trials[:5]:
acc, neg_lat = t.values
print(f" Acc: {acc:.3f}, Latency: {-neg_lat:.1f} ms | {t.params}")
Raz na zawsze: NAS dla sprzętu heterogenicznego
Raz na zawsze (OFA) z MIT rozwiązuje podstawowy problem praktyczny: szkolenie osobna sieć dla każdego urządzenia docelowego i zaporowego. OFA szkoli jedynkę supersieć który obsługuje tysiące podarchitektur, a następnie wykorzystuje architekturę ewolucyjną szybkie wyszukiwanie w celu znalezienia optymalnej podarchitektury dla każdego urządzenia.
Szkolenie OFA wykorzystuje technologię postępujące kurczenie się: Supersieć jest trenowana zaczynając od konfiguracji maksymalnej, następnie wymiary są stopniowo zmniejszane (najpierw rozmiary jądra, potem głębokość, na końcu szerokość). Tworzy to zestaw wspólnych wag które sprawdzają się we wszystkich konfiguracjach.
# Uso di OFA tramite la libreria ufficiale
# pip install ofa
from ofa.model_zoo import ofa_net
import torch
import time
# Carica rete OFA pre-addestrata (OFA-MobileNetV3)
ofa_network = ofa_net('ofa_mbv3_d234_e346_k357_w1.0', pretrained=True)
# Ricerca architettura ottimale per un budget specifico
# (esempio: 200M FLOPs per deployment su mobile)
def evaluate_subnet(subnet_config):
"""Valuta una sotto-architettura dell'OFA network."""
ofa_network.set_active_subnet(
ks=subnet_config['ks'], # kernel sizes
e=subnet_config['e'], # expansion ratios
d=subnet_config['d'] # depths
)
subnet = ofa_network.get_active_subnet(preserve_weight=True)
return subnet
# Esempio di configurazione ottimale trovata da evolutionary search:
# per iPhone XS (7.5ms latency target)
iphone_config = {
'ks': [3, 3, 5, 3, 5, 3, 5, 5, 3, 5, 5, 3, 5, 5, 3, 5, 5, 3, 5, 5],
'e': [3, 3, 6, 3, 6, 3, 6, 6, 3, 6, 6, 3, 6, 6, 3, 6, 6, 3, 6, 6],
'd': [2, 3, 3, 3, 3]
}
iphone_subnet = evaluate_subnet(iphone_config)
print(f"Subnet per iPhone XS: {sum(p.numel() for p in iphone_subnet.parameters()):,} params")
# Configurazione per Raspberry Pi 4 (latency target 50ms)
rpi_config = {
'ks': [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
'e': [3, 3, 3, 3, 3, 3, 4, 4, 3, 4, 4, 3, 4, 4, 3, 4, 4, 3, 4, 4],
'd': [2, 2, 2, 2, 2]
}
rpi_subnet = evaluate_subnet(rpi_config)
# Benchmark latenza su CPU (simula RPi4)
rpi_subnet.eval()
x = torch.randn(1, 3, 224, 224)
with torch.no_grad():
for _ in range(10): rpi_subnet(x) # warmup
t0 = time.perf_counter()
for _ in range(50): rpi_subnet(x)
lat_ms = (time.perf_counter() - t0) / 50 * 1000
flops = sum(p.numel() for p in rpi_subnet.parameters()) * 2 / 1e6
print(f"RPi4 subnet: {lat_ms:.1f}ms, {flops:.1f}M FLOPs")
print(f"Parametri: {sum(p.numel() for p in rpi_subnet.parameters()):,}")
Kompleksowe AutoML z AutoKeras
Dla tych, którzy nie mają czasu na wdrożenie NAS od podstaw, AutoKeras oferty światowej klasy API, które automatycznie obsługuje wyszukiwanie architektoniczne, przetwarzanie wstępne i dostrajanie hiperparametrów. Wewnętrznie wykorzystuje Keras Tuner z algorytmami Wyszukiwanie bayesowskie i losowe, a na potrzeby wdrożenia jest zintegrowane z TensorFlow.
# pip install autokeras tensorflow
# NOTA: AutoKeras richiede TensorFlow 2.x
import autokeras as ak
import numpy as np
import tensorflow as tf
# ============================================================
# IMAGE CLASSIFICATION con AutoKeras
# ============================================================
# Carica dataset
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()
x_train = x_train.astype(np.float32) / 255.0
x_test = x_test.astype(np.float32) / 255.0
# Crea ricercatore con vincoli di complessità
clf = ak.ImageClassifier(
max_trials=30, # Numero massimo di architetture da provare
overwrite=True,
project_name='nas_cifar10',
seed=42,
# Limiti hardware (opzionali)
# max_model_size=1e6, # Max 1M parametri
)
# Avvia NAS (ricerca + training)
clf.fit(
x_train, y_train,
epochs=20,
validation_split=0.15,
callbacks=[
tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True)
]
)
# Valutazione
loss, acc = clf.evaluate(x_test, y_test)
print(f"Test accuracy: {acc:.4f}")
# Esporta modello Keras migliore
best_model = clf.export_model()
best_model.summary()
# Conta parametri
total_params = best_model.count_params()
print(f"Parametri totali: {total_params:,}")
# Ispezione architettura trovata
print("\nArchitettura trovata da AutoKeras:")
for i, layer in enumerate(best_model.layers):
config = layer.get_config()
params_str = f"params={layer.count_params():,}" if hasattr(layer, 'count_params') else ""
print(f" Layer {i}: {type(layer).__name__} {params_str}")
# Export per deployment
best_model.save('best_nas_model.h5')
# Conversione a TFLite per edge deployment
converter = tf.lite.TFLiteConverter.from_keras_model(best_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT] # INT8 quantization
tflite_model = converter.convert()
with open('best_nas_model_int8.tflite', 'wb') as f:
f.write(tflite_model)
print(f"Modello TFLite: {len(tflite_model)/1024:.1f} KB")
Studium przypadku: NAS do klasyfikacji medycznej w Jetson Nano
Prawdziwy przypadek wyjaśnia praktyczną wartość serwera NAS obsługującego sprzęt. W projekcie klasyfikacja obrazów dermatoskopowych (8 klas zmian skórnych) na NVIDIA Jetson Nanoograniczeniami były: opóźnienie poniżej 100 ms na obraz, dokładność powyżej 88%, model poniżej 10 MB. Standardowe architektury nie zadowalały wszystkie ograniczenia jednocześnie.
import optuna
from optuna.samplers import NSGAIISampler
import torch
import torch.nn as nn
import time
# ============================================================
# CASO STUDIO: NAS per dermoscopia su Jetson Nano
# ============================================================
class DermatologyNASModel(nn.Module):
"""
Modello flessibile per classificazione dermoscopica.
Architettura MobileNet-style ottimizzata via NAS.
"""
def __init__(
self,
n_stages: int, # 3-5 stages di downsampling
channels: list, # Canali per stage
expansion: list, # Expansion ratios MBConv
use_se: list, # SE module per stage
n_classes: int = 8
):
super().__init__()
# Stem
self.stem = nn.Sequential(
nn.Conv2d(3, channels[0], 3, stride=2, padding=1, bias=False),
nn.BatchNorm2d(channels[0]),
nn.ReLU6()
)
# Stages con MBConv
stages = []
in_ch = channels[0]
for i in range(n_stages):
out_ch = channels[i]
exp = expansion[i]
mid_ch = in_ch * exp
stage = nn.Sequential(
# Pointwise expansion
nn.Conv2d(in_ch, mid_ch, 1, bias=False),
nn.BatchNorm2d(mid_ch),
nn.ReLU6(),
# Depthwise
nn.Conv2d(mid_ch, mid_ch, 3, stride=2 if i < n_stages-1 else 1,
padding=1, groups=mid_ch, bias=False),
nn.BatchNorm2d(mid_ch),
nn.ReLU6(),
# SE module opzionale
SEModule(mid_ch, reduction=4) if use_se[i] else nn.Identity(),
# Pointwise projection
nn.Conv2d(mid_ch, out_ch, 1, bias=False),
nn.BatchNorm2d(out_ch)
)
stages.append(stage)
in_ch = out_ch
self.stages = nn.Sequential(*stages)
self.pool = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Linear(channels[-1], n_classes)
def forward(self, x):
x = self.stem(x)
x = self.stages(x)
x = self.pool(x).flatten(1)
return self.classifier(x)
class SEModule(nn.Module):
"""Squeeze-and-Excitation per channel attention."""
def __init__(self, channels, reduction=4):
super().__init__()
self.se = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(channels, channels // reduction),
nn.ReLU(),
nn.Linear(channels // reduction, channels),
nn.Sigmoid()
)
def forward(self, x):
scale = self.se(x).view(x.size(0), -1, 1, 1)
return x * scale
def jetson_nas_objective(trial: optuna.Trial):
"""Funzione obiettivo hardware-aware per Jetson Nano."""
# Spazio di ricerca compatto
n_stages = trial.suggest_int("n_stages", 3, 5)
channels = [
trial.suggest_categorical(f"ch_{i}", [16, 24, 32, 48, 64])
for i in range(n_stages)
]
expansions = [
trial.suggest_categorical(f"exp_{i}", [2, 4, 6])
for i in range(n_stages)
]
use_se = [
trial.suggest_categorical(f"se_{i}", [True, False])
for i in range(n_stages)
]
model = DermatologyNASModel(n_stages, channels, expansions, use_se, n_classes=8)
# Stima parametri
n_params = sum(p.numel() for p in model.parameters())
model_size_mb = n_params * 4 / (1024 ** 2)
# Stima latenza su CPU (proxy per Jetson Nano ARM)
x = torch.randn(1, 3, 224, 224)
model.eval()
with torch.no_grad():
for _ in range(5): model(x) # warmup
t0 = time.perf_counter()
for _ in range(20): model(x)
latency_ms = (time.perf_counter() - t0) / 20 * 1000
# Jetson Nano e ~3x più lento di CPU Intel i7
# Aggiungiamo un fattore di correzione
jetson_latency_ms = latency_ms * 3.5
# Vincoli hardware
if jetson_latency_ms > 150: # Hard constraint: max 150ms
raise optuna.exceptions.TrialPruned()
if model_size_mb > 15: # Max 15MB
raise optuna.exceptions.TrialPruned()
# Accuracy simulata (in pratica: training completo)
val_accuracy = 0.85 + 0.05 * (sum(channels) / (64 * n_stages))
val_accuracy = min(val_accuracy, 0.94) # Cap realistico
return val_accuracy, -jetson_latency_ms
# Ricerca multi-obiettivo
study = optuna.create_study(
directions=["maximize", "maximize"],
sampler=NSGAIISampler(seed=42),
pruner=optuna.pruners.MedianPruner()
)
study.optimize(jetson_nas_objective, n_trials=80, timeout=7200)
# Seleziona architettura ottimale dal Pareto front
# Criteri: accuracy > 88%, latenza Jetson < 100ms
best = [
t for t in study.best_trials
if t.values[0] > 0.88 and -t.values[1] < 100
]
best.sort(key=lambda t: t.values[0], reverse=True)
if best:
print(f"\nMiglior architettura per Jetson Nano:")
print(f" Accuracy: {best[0].values[0]:.3f}")
print(f" Latenza stimata: {-best[0].values[1]:.1f} ms")
print(f" Configurazione: {best[0].params}")
else:
print("Nessuna architettura soddisfa tutti i vincoli. Allarga lo spazio di ricerca.")
Ograniczenia i pułapki NAS
- Przeszukanie przestrzeni: znalezione architektury działają dobrze na punktach odniesienia wybranych do badania, ale mogą słabo uogólniać na różne zbiory danych. Zawsze oceniaj na niezależnym zestawie wstrzymanym, który nie jest używany podczas wyszukiwania.
- Ukryty koszt obliczeniowy: DARTS wymaga 1-4 dni GPU, ale jest to szkolenie wraz ze znalezioną architekturą dodaje więcej godzin pracy procesora graficznego. Całkowity koszt i często 2-3 razy więcej niż w przypadku szkolenia dobrej architektury ręcznej.
- Niestabilność DARTS: Oryginalne DARTS cierpi na niestabilność treningu i ma tendencję do zapadania się w kierunku pomijania połączeń. Aby uzyskać bardziej stabilne wyniki, użyj DARTS+ lub R-DARTS. Kontroluj załamanie poprzez monitorowanie entropii wag alfa.
- Transfer pomiędzy zbiorami danych: optymalna architektura CIFAR-10 nie jest koniecznie optymalne w ImageNet lub zbiorach danych medycznych. Przeprowadź badania ostatecznego zbioru danych.
- Zawodne zadania proxy: użyj prostszego zadania proxy (np. CIFAR zamiast ImageNet) w celu obniżenia kosztów może prowadzić do nieprawidłowych rankingów. Zawsze aktualne architekturę znalezioną w prawdziwym zadaniu.
Porównanie architektury: NAS i podręczniki w standardowych testach porównawczych
| Architektura | Metoda | ImageNet Top-1 | Parametry | FLOPy | Koszt wyszukiwania |
|---|---|---|---|---|---|
| ResNet-50 | Podręcznik | 76,1% | 25,6 mln | 4,1G | Nie dotyczy |
| MobileNetV3-duży | NAS + instrukcja | 75,2% | 5,4 mln | 0,22G | ~1000 GPU-h |
| EfficientNet-B0 | NAS (MnasNet) | 77,1% | 5,3 mln | 0,39G | ~6000 GPU-h |
| NASNet-A Mobile | RL-NAS | 74,0% | 5,3 mln | 0,56G | 400 dni GPU |
| Rzutki (2. zamówienie) | RZUTKI | 73,3% | 4,7 mln | 0,6G | 4 dni GPU |
| OFA-595M (RPi) | Jednorazowy strzał OFA | 76,0% | ~4,5 mln | 0,6G | <1 GPU-h po OFA |
Najlepsze praktyki dotyczące serwerów NAS w produkcji
Kiedy używać NAS i jak to zrobić dobrze
- Użyj dostrajania przed serwerem NAS: często wstępnie przeszkolony ViT-B lub EfficientNet-B4 przewyższa architekturę NAS znalezioną od podstaw. Używaj NAS, gdy zadanie jest bardzo specyficzne (stały sprzęt docelowy, domena bardzo różna od ImageNet, rygorystyczne ograniczenia sprzętowe).
- Wybierz Bayesa dla hiperparametru: nawet bez architektury wyszukiwania, Optuna TPE dla LR, wielkości partii, powiększania i optymalizatora i często bardziej skuteczna niż GridSearch i wymaga 3-5 razy mniej prób. I pierwszy krok, jaki należy wykonać przed pełnym serwerem NAS.
- Od początku obsługujący sprzęt: uwzględnij opóźnienie/FLOP w funkcji cel z pierwszej rozprawy. Model, który jest o 1% dokładniejszy, ale 2 razy wolniejszy, nie jest przydatny do wdrażania w czasie rzeczywistym na urządzeniach brzegowych.
- Agresywne wczesne zatrzymanie: użyj narzędzia MedianPruner firmy Optuna. Wyeliminuj 30-40% mało obiecujących prób we wczesnych okresach, redukując całkowity koszt 2-3x.
- Równolegle na wielu procesorach graficznych: Optuna obsługuje natywną równoległość poprzez współdzieloną bazę danych (SQLite lub PostgreSQL). 4 procesory graficzne skracają czas 3,5 razy bez zmian w kodzie.
- Zapisz punkty kontrolne architektury: po wyszukiwaniu zapisz nie tylko wagi, ale także specyfikację architektury (genotyp w DARTS, konfiguracja w Optunie). Umożliwia to odbudowanie modelu bez konieczności powtarzania wyszukiwania.
Wnioski
Wyszukiwanie architektury neuronowej znacznie się rozwinęło od 2017 r. do chwili obecnej. Z algorytmów to wymagało 400 GPU-dni na praktyczne narzędzia działające w ciągu kilku godzin na jednym GPU, czyli w terenie sprawiło, że automatyczne projektowanie architektury stało się dostępne dla praktyków. W 2026 roku przepływ pracy najskuteczniejsze kombinacje: dobrze określona przestrzeń wyszukiwania, Optuna z agresywnym przycinaniem hiperparametr i cele uwzględniające sprzęt w celu zoptymalizowanego wdrożenia.
W przypadku większości projektów wykorzystanie istniejących architektur (ViT, Swin, EfficientNet) po dostrojeniu pozostaje bardziej wydajny niż serwer NAS od zera. Ale gdy zadanie ma wymagania sprzętowe bardzo specyficzne — opóźnienie poniżej 5ms na Raspberry Pi, model poniżej 1MB dla mikrokontrolera, specjalistyczna klasyfikacja medyczna — sprzętowy serwer NAS staje się niezbędnym narzędziem.
Trend w kierunku przetwarzania brzegowego jeszcze bardziej zwiększa wartość NAS: w przypadku firmy Gartner oczekuje, że do 2027 r. rozwiązania SLM trzykrotnie przewyższą rozwiązania LLM w chmurze, zoptymalizuje architektury pod kątem określony sprzęt nie jest już akademickim luksusem, ale praktyczną koniecznością.
Następne kroki
- Następny artykuł: Destylacja wiedzy: kompresja złożonych modeli
- Powiązany: Transformator wizji: architektura i zastosowania
- Powiązany: LLM na urządzeniach brzegowych: Raspberry Pi i Jetson
- Seria MLOps: Eksperymentuj ze śledzeniem za pomocą MLflow i Optuna
- Seria inżynierii AI: Optymalizacja modelu na potrzeby produkcji







