Hledání neuronové architektury a AutoML: Automatizace návrhu sítě
Návrh architektury neuronové sítě je tradičně manuální proces, který vyžaduje let zkušeností, intuice a výpočetních zdrojů k experimentování. ResNet, EfficientNet, MobileNet – každá z těchto ikonických architektur je výsledkem lidských návrhů hluboce informován. Přesto s Neural Architecture Search (NAS), tento proces lze automatizovat: daný výpočetní rozpočet a úkol, algoritmus zkoumá prostor možných architektur a identifikuje tu optimální.
Praktický a překvapivý výsledek. EfficientNet — pravděpodobně nejvlivnější rodina CNN posledních let — byl objeven prostřednictvím NAS. NASNet, DARTS, Once-for-All a další modely NAS důsledně překonávaly ručně navržené architektury ve standardizovaných benchmarcích. Ale skutečnou revolucí je demokratizace: s nástroji jako Optuna, Ray Tune, ENAS e timm, dnes je možné udělat NAS na spotřebitelských GPU během hodin, optimalizace architektury pro váš konkrétní hardware.
V této příručce prozkoumáme techniky NAS od základů – od GridSearch po DARTS – a se společností Optuna budujeme praktický kanál AutoML pro optimalizaci architektur hlubokého učení.
Co se naučíte
- Co je NAS a proč překonává manuální design
- Vyhledávací prostory: mikro (na základě buněk) vs. makro (na základě vrstvy)
- Strategie vyhledávání: Random Search, RL, Evolutionary, DARTS
- One-Shot NAS a sdílení hmotnosti: Jak snížit náklady z let na hodiny
- Implementace NAS s Optunou: hyperparametr + hledání architektury
- Diferenciable Architecture Search (DARTS) s plným PyTorchem
- Jednou za všechny sítě: Architektury pro heterogenní hardware
- Hardware-Aware NAS: Optimalizace pro latenci, FLOP a parametry
- AutoML s AutoKeras a NAS pro okrajová zařízení
- Skutečná případová studie: NAS pro lékařskou klasifikaci na Jetson Nano
Proč NAS překonává manuální design
Manuální návrh architektury trpí třemi strukturálními omezeními. Za prvé,odbornost zaujatost: výzkumníci mají tendenci znovu používat známé vzorce (ResBlocks, přeskakování spojení) i když nejsou optimální pro konkrétní úkol. Za druhé,hardwarový nesoulad: optimální architektura na A100 je zřídka a na Cortex-A55. Za třetí, kombinatorický výbuche. prostor možných architektur astronomicky velké – dokonce jen změnou počtu vrstev, kanálů a velikostí jádra v 8 vrstvách získáte více než 10^14 konfigurací.
NAS řeší tyto problémy formálním definováním problému návrhu:
# 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
Výzkumný prostor NAS
Srdce NAS a definice vyhledávací prostor: soubor všech možné architektury, které může algoritmus prozkoumat. Volba vyhledávacího prostoru Je to zásadní – příliš úzké a optimum není dosažitelné, příliš široké a hledání se stává výpočetně neřešitelným.
Existují dva hlavní přístupy:
- Cell-based NAS (micro search space): hledáme optimální strukturu jedna buňka (buňka), pak se buňka několikrát replikuje, aby se vytvořila síť. Toto drasticky snižuje prostor pro vyhledávání při zachování flexibility. Používá NASNet, DARTS, ENAS.
- Prostor pro vyhledávání maker: hledáte parametry globální architektury jako např počet vrstev, velikost kanálů, typ připojení. Používá EfficientNet (NAS + škálování), MobileNet v3, jednou pro všechny.
# 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!
Výzkumné strategie
Strategie vyhledávání určují, jak se algoritmus pohybuje v prostoru architektury. Volba strategie je stejně důležitá jako samotný vyhledávací prostor:
| Strategie | Přístup | Pro | Proti | Typická cena |
|---|---|---|---|---|
| Náhodné vyhledávání | Náhodné vzorkování | Jednoduchá, silná základní linie | Neefektivní | N * kompletní školení |
| Vyhledávání v mřížce | Vyčerpávající mřížka | Kompletní, pokud je malý prostor | Exponenciální velikost | K^D * trénink |
| Bayesian Opt. | Náhradní model + akvizice | Efektivní, vedená | Drahé pro velké prostory | 50-200 pokusů |
| RL (NASNet) | ovladače RNN | Komplexní architektury | 400 původních GPU dní | 1000+ pokusů |
| Evoluční | Genetické algoritmy | Hodně štěstí při objevování | Velmi pomalu | 500+ pokusů |
| ŠIPKY | Průběžná diferenciace | 1-4 dny GPU, optimální | Náročné na paměť | 1 tréninkový cyklus |
| One-Shot / ENAS | Supernet pro sdílení hmotnosti | Hodiny na jednom GPU | Přiblížení pořadí | 1 supernet + odběr vzorků |
Praktický NAS s Optunou
Optuna a nejpoužívanější knihovna pro vyhledávání hyperparametrů a architektur. Kombinuje Bayesovské vzorkování (TPE), prořezávání neslibných pokusů a API elegantní, které se přirozeně integruje s 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: Diferenciable Architecture Search
ŠIPKY (Liu et al., 2019) a jeden z nejelegantnějších NAS algoritmů ed. efektivní. Klíčová myšlenka: provést výběr operace spojité a diferencovatelné, umožňuje optimalizovat architekturu s gradientním klesáním namísto diskrétního vyhledávání.
V buňce DARTS má každá hrana mezi uzly a měkká směs ze všeho možného
operace (konv 3x3, konv 5x5, přeskočit, pool). Míchací váhy (parametry architektury
alpha) jsou optimalizovány s gradientním klesáním spolu se závažími modelu.
Nakonec pro každou hranu vybereme operaci s nejvyšší hmotností (diskretizace).
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: Srovnání
| čekám | ŠIPKY | One-Shot (ENAS, OFA) |
|---|---|---|
| Náklady na výzkum | 1-4 dny GPU | Hodiny (supernet po tréninku) |
| architektonická kvalita | Velmi vysoká | Vysoká (lehká aproximace) |
| Cílový hardware | Jediný cíl | Vícecílový (OFA) |
| Paměť GPU | Vysoká (dvouúrovňová možnost) | Průměrný |
| Implementace | Komplex | Mírný |
Hardware-Aware NAS s Optuna a Latency Constraints
Teoretický NAS se snaží o maximální přesnost. Praktický NAS jeden optimalizuje multi-cílový kompromis mezi přesností, latencí, FLOPy a velikostí modelu. Optuna nativně podporuje vícecílové vyhledávání pomocí algoritmu 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}")
Jednou za vše: NAS pro heterogenní hardware
Jednou za vše (OFA) z MIT řeší zásadní praktický problém: školení samostatná síť pro každé cílové a zakázané zařízení. OFA trénuje singl supernet který podporuje tisíce dílčích architektur, pak používá evoluční rychlé vyhledávání pro nalezení optimální podarchitektury pro každé zařízení.
Školení OFA využívá progresivní zmenšování: Supernet je trénovaný počínaje maximální konfigurací se rozměry postupně zmenšují (nejprve velikosti jádra, pak hloubka, nakonec šířka). Tím se vytvoří sada sdílených vah které fungují dobře ve všech konfiguracích.
# 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()):,}")
End-to-End AutoML s AutoKeras
Pro ty, kteří nemají čas implementovat NAS od nuly, AutoKeras nabídky API světové třídy, které automaticky zpracovává architektonické vyhledávání, předzpracování a ladění hyperparametrů. Interně používá Keras Tuner s algoritmy Bayesovské a náhodné vyhledávání a je integrováno s TensorFlow pro nasazení.
# 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")
Případová studie: NAS pro lékařskou klasifikaci na Jetson Nano
Pouzdro z reálného života objasňuje praktickou hodnotu hardwarově orientovaného NAS. V projektu o klasifikace dermatoskopických obrazů (8 tříd kožních lézí) na NVIDIA Jetson Nano, omezení byla: latence pod 100 ms na snímek, přesnost nad 88 %, model pod 10 MB. Standardní architektury nevyhovovaly všechna omezení zároveň.
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.")
Omezení a úskalí NAS
- Přebytek vyhledávacího prostoru: nalezené architektury fungují dobře na srovnávacích kritériích vybraných pro výzkum, ale mohou se špatně zobecňovat na různé soubory dat. Vyhodnoťte vždy na nezávislé sadě, která se nepoužívá při vyhledávání.
- Skryté výpočetní náklady: DARTS vyžaduje 1-4 dny GPU, ale trénink kompletní s nalezenou architekturou přidává další hodiny GPU. Celkové náklady a často 2-3x více než trénink dobré manuální architektury.
- Nestabilita DARTS: Original DARTS trpí tréninkovou nestabilitou a má tendenci se hroutit směrem k přeskakování spojení. Pro stabilnější výsledky použijte DARTS+ nebo R-DARTS. Kontrolujte kolaps sledováním entropie vah alfa.
- Přenos mezi datovými sadami: optimální architektura na CIFAR-10 není nutně optimální pro ImageNet nebo lékařské datové sady. Proveďte průzkum konečného souboru dat.
- Nespolehlivé úlohy proxy: použijte jednodušší úlohu proxy (např. CIFAR místo ImageNet) ke snížení nákladů může vést k nesprávnému hodnocení. Vždy platné architektura nalezená na skutečném úkolu.
Srovnání architektury: NAS vs manuály na standardních benchmarcích
| Architektura | Metoda | ImageNet Top-1 | Parametry | FLOPy | Cena vyhledávání |
|---|---|---|---|---|---|
| ResNet-50 | Manuál | 76,1 % | 25,6 mil | 4,1G | N/A |
| MobileNetV3-Large | NAS + manuál | 75,2 % | 5,4 mil | 0,22G | ~1000 GPU-h |
| EfficientNet-B0 | NAS (MnasNet) | 77,1 % | 5,3 mil | 0,39G | ~6000 GPU-h |
| NASNet-A Mobile | RL-NAS | 74,0 % | 5,3 mil | 0,56G | 400 GPU dní |
| ŠIPKY (2. řád) | ŠIPKY | 73,3 % | 4,7 mil | 0,6G | 4 GPU dny |
| OFA-595M (RPi) | OFA One-Shot | 76,0 % | ~4,5 mil | 0,6G | <1 GPU-h po OFA |
Nejlepší postupy pro NAS ve výrobě
Kdy použít NAS a jak to udělat správně
- Použijte jemné doladění před NAS: často předem vyškolený ViT-B nebo EfficientNet-B4 překonává architekturu NAS nalezenou od nuly. Použijte NAS, když je úkol velmi specifický (pevný cílový hardware, doména velmi odlišná od ImageNet, přísná hardwarová omezení).
- Zvolte Bayesian pro hyperparametr: i bez vyhledávací architektury, Optuna TPE pro LR, velikost dávky, rozšíření a optimalizátor a často účinnější než GridSearch a vyžaduje 3-5x méně pokusů. A první krok, který je třeba udělat před plným NAS.
- S ohledem na hardware od začátku: zahrnout do funkce latenci/FLOPy cíl z prvního pokusu. Model, který je o 1 % přesnější, ale 2x pomalejší, není užitečný pro nasazení na okrajových zařízeních v reálném čase.
- Agresivní předčasné zastavení: použijte MedianPruner společnosti Optuna. Eliminovat 30-40% neperspektivních zkoušek v raných obdobích, což snižuje celkové náklady 2-3x.
- Paralelní mezi více GPU: Optuna podporuje nativní paralelizaci prostřednictvím sdílené databáze (SQLite nebo PostgreSQL). 4 GPU zkracují čas 3,5x beze změn kódu.
- Uložit kontrolní body architektury: po vyhledání nejen uložit váhy, ale také specifikace architektury (genotyp v DARTS, config dict v Optuně). To vám umožní přestavět model bez opětovného hledání.
Závěry
Neural Architecture Search od roku 2017 do současnosti výrazně dozrálo. Z algoritmů, že vyžadovalo 400 GPU dní na praktické nástroje, které běží v hodinách na jediném GPU, v terénu zpřístupnil automatický návrh architektury odborníkům. V roce 2026 pracovní postup nejúčinnější kombinace: dobře definovaný vyhledávací prostor, Optuna s agresivním prořezáváním pro hyperparametr a cíle s ohledem na hardware pro optimalizované nasazení.
U většiny projektů je použití již existujících architektur (ViT, Swin, EfficientNet) s jemným doladěním zůstává od začátku efektivnější než NAS. Ale když má úloha hardwarové požadavky velmi specifické — latence pod 5 ms na Raspberry Pi, model pod 1 MB pro mikrokontrolér, specializovaná lékařská klasifikace – hardwarově orientovaný NAS se stává nepostradatelným nástrojem.
Trend směrem k edge computingu dále umocňuje hodnotu NAS: s Gartnerem očekává, že SLM do roku 2027 3x překoná cloudové LLM, optimalizuje architektury pro specifický hardware již není akademickým luxusem, ale praktickou nutností.
Další kroky
- Další článek: Destilace znalostí: Komprese komplexních modelů
- Související: Vision Transformer: Architektura a aplikace
- Související: LLM na zařízeních Edge: Raspberry Pi a Jetson
- Řada MLOps: Sledování experimentů s MLflow a Optuna
- AI Engineering Series: Optimalizace modelu pro výrobu







