Neural Architecture Search și AutoML: Automatizarea designului rețelei
Proiectarea unei arhitecturi de rețea neuronală este în mod tradițional un proces manual care necesită ani de expertiză, intuiție și resurse de calcul pentru a experimenta. ResNet, EfficientNet, MobileNet — fiecare dintre aceste arhitecturi iconice este rezultatul alegerilor umane de design profund informat. Cu toate acestea, cu Căutare arhitectură neuronală (NAS), acest proces poate fi automatizat: având în vedere un buget de calcul și o sarcină, un algoritm explorează spațiul arhitecturilor posibile și identifică una optimă.
Rezultatul practic și surprinzător. EfficientNet — probabil cea mai influentă familie CNN din ultimii ani — a fost descoperit prin NAS. NASNet, DARTS, Once-for-All și alte modele NAS au depășit în mod constant arhitecturile proiectate manual pe criterii de referință standardizate. Dar adevărata revoluție este democratizarea: cu instrumente de genul Optuna, Ray Tune, ENAS e timm, astăzi se poate face NAS pe GPU-uri pentru consumatori în câteva ore, optimizând arhitectura pentru hardware-ul dumneavoastră specific.
În acest ghid, explorăm tehnicile NAS de la zero - de la GridSearch la DARTS - și construim o conductă practică AutoML cu Optuna pentru a optimiza arhitecturile de învățare profundă.
Ce vei învăța
- Ce este NAS și de ce depășește proiectarea manuală
- Spații de căutare: micro (pe bază de celule) vs macro (pe bază de straturi)
- Strategii de căutare: Random Search, RL, Evolutionary, DARTS
- NAS One-Shot și partajarea greutății: Cum să reduceți costurile de la ani la ore
- Implementare NAS cu Optuna: hiperparametru + căutare arhitectură
- Căutare de arhitectură diferențiabilă (DARTS) cu PyTorch complet
- Once-for-All Networks: arhitecturi pentru hardware eterogene
- NAS conștient de hardware: optimizați pentru latență, FLOP-uri și parametri
- AutoML cu AutoKeras și NAS pentru dispozitive edge
- Studiu de caz real: NAS pentru clasificarea medicală pe Jetson Nano
De ce NAS depășește proiectarea manuală
Proiectarea manuală a arhitecturii suferă de trei limitări structurale. În primul rând,expertiza părtinire: cercetătorii tind să refolosească modele familiare (ResBlocks, omite conexiuni) chiar și atunci când nu sunt optime pentru sarcina specifică. În al doilea rând,nepotrivire hardware: o arhitectură optimă pe A100 este rar și pe un Cortex-A55. În al treilea rând, explozie combinatorie: spaţiul arhitecturilor posibile e astronomic mare - chiar și doar prin modificarea numărului de straturi, canale și dimensiuni ale nucleului pe 8 straturi primești mai mult de 10^14 configurații.
NAS rezolvă aceste probleme prin definirea formală a problemei de proiectare:
# 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
Spațiul de cercetare NAS
Inima NAS și definiția spațiu de căutare: ansamblul tuturor posibilele arhitecturi pe care algoritmul le poate explora. Alegerea spațiului de căutare Este fundamental - prea îngust și optimul nu este realizabil, prea larg și de căutare devine insolubil din punct de vedere computațional.
Există două abordări principale:
- NAS bazat pe celule (micro spațiu de căutare): căutăm structura optimă a o singură celulă (celulă), apoi celula este replicată de mai multe ori pentru a construi rețeaua. Aceasta reduce drastic spațiul de căutare, menținând în același timp flexibilitatea. Folosit de NASNet, DARTS, ENAS.
- Spațiu de căutare macro: sunteți în căutarea unor parametri globali de arhitectură, cum ar fi numărul de straturi, dimensiunea canalelor, tipul conexiunilor. Folosit de EfficientNet (NAS + scalare), MobileNet v3, O dată pentru totdeauna.
# 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!
Strategii de cercetare
Strategiile de căutare determină modul în care algoritmul navighează în spațiul arhitecturii. Alegerea strategiei este la fel de importantă ca și spațiul de căutare în sine:
| Strategie | Abordare | Pro | Împotriva | Costul tipic |
|---|---|---|---|---|
| Căutare aleatorie | Eșantionare aleatorie | Linie de bază simplă, puternică | Ineficient | N * antrenament complet |
| Căutare în grilă | Grilă exhaustivă | Spațiu complet dacă este mic | Dimensiune exponențială | K^D * antrenament |
| Opţiunea Bayesiană. | Model surogat + achiziție | Eficient, ghidat | Scump pentru spații mari | 50-200 de încercări |
| RL (NASNet) | Controloare RNN | Arhitecturi complexe | 400 de zile GPU originale | Peste 1000 de încercări |
| Evolutiv | Algoritmi genetici | Explorări fericite | Foarte lent | Peste 500 de încercări |
| DARTS | Diferențierea continuă | 1-4 zile GPU, optim | Memorie intensivă | 1 ciclu de pregătire |
| One-Shot / ENAS | Supernetul de împărțire a greutății | Ore pe un singur GPU | Aproximarea clasamentului | 1 supernet + eșantionare |
NAS practic cu Optuna
Optuna și cea mai folosită bibliotecă pentru căutarea hiperparametrilor și arhitecturilor. Acesta combină eșantionarea Bayesiană (TPE), tăierea testelor nepromițătoare și un API elegant care se integrează în mod natural cu 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: Căutare de arhitectură diferențiabilă
DARTS (Liu et al., 2019) și unul dintre cei mai eleganti algoritmi NAS ed eficient. Ideea cheie: alegeți operațiunea continuu si diferentiabil, permițându-vă să optimizați arhitectura cu coborâre în gradient în loc de căutare discretă.
Într-o celulă DARTS, fiecare margine dintre noduri are a amestec moale dintre toate posibilele
operațiuni (conv 3x3, conv 5x5, skip, pool). Greutățile de amestecare (parametrii arhitecturii
alpha) sunt optimizate cu coborârea gradientului împreună cu greutățile modelului.
În final, pentru fiecare muchie alegem operația cu cea mai mare greutate (discretizare).
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: comparație
| astept | DARTS | One-Shot (ENAS, OFA) |
|---|---|---|
| Costul cercetării | 1-4 zile GPU | Ore (supernet post-antrenament) |
| calitate arhitecturală | Foarte sus | Ridicat (ușoară aproximativă) |
| Hardware țintă | O singură țintă | Multi-țintă (OFA) |
| memorie GPU | Înalt (opt pe două niveluri) | Medie |
| Implementarea | Complex | Moderat |
NAS compatibil cu hardware-ul cu Optuna și constrângeri de latență
NAS teoretic caută acuratețe maximă. NAS practic optimizează unul compromis multi-obiectiv între precizie, latență, FLOP-uri și dimensiunea modelului. Optuna acceptă căutarea multi-obiective în mod nativ cu algoritmul 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}")
O dată pentru totdeauna: NAS pentru hardware eterogene
O dată pentru totdeauna (OFA) de la MIT rezolvă o problemă practică fundamentală: formarea o rețea separată pentru fiecare dispozitiv țintă și prohibitiv. OFA antrenează un singur supernet care acceptă mii de sub-arhitecturi, apoi utilizează un evolutiv căutare rapidă pentru a găsi sub-arhitectura optimă pentru fiecare dispozitiv.
Antrenamentul OFA utilizează micsorarea progresiva: Supernetul este antrenat pornind de la configuratia maxima, apoi dimensiunile se reduc progresiv (mai întâi dimensiunile nucleului, apoi adâncimea, în final lățimea). Acest lucru creează un set de ponderi comune care funcționează bine în toate configurațiile.
# 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()):,}")
AutoML de la capăt la capăt cu AutoKeras
Pentru cei care nu au timp să implementeze NAS de la zero, AutoKeras oferte un API de clasă mondială care gestionează automat căutarea arhitecturală, preprocesare și reglare hiperparametrică. Pe plan intern, folosește Keras Tuner cu algoritmi Căutare bayesiană și aleatorie și este integrată cu TensorFlow pentru implementare.
# 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")
Studiu de caz: NAS pentru clasificarea medicală pe Jetson Nano
Un caz din viața reală clarifică valoarea practică a NAS-ului compatibil hardware. Într-un proiect de clasificarea imaginilor dermatoscopice (8 clase de leziuni cutanate) pe NVIDIA Jetson Nano, constrângerile au fost: latență sub 100 ms per imagine, precizie peste 88%, model sub 10MB. Arhitecturile standard nu au satisfăcut toate constrângerile în același timp.
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.")
Limitările și capcanele NAS
- Supraadaptarea spațiului de căutare: arhitecturile găsite funcționează bine pe criteriile de referință alese pentru cercetare, dar se pot generaliza slab la diferite seturi de date. Evaluați întotdeauna pe un set independent care nu este utilizat în timpul căutării.
- Costul de calcul ascuns: DARTS necesită 1-4 zile GPU, dar antrenament complet cu arhitectura găsită adaugă mai multe ore GPU. Costul total și adesea De 2-3 ori de a antrena o arhitectură manuală bună.
- Instabilitatea DARTS: Original DARTS suferă de instabilitate la antrenament și tinde să se prăbușească spre săriți conexiuni. Utilizați DARTS+ sau R-DARTS pentru rezultate mai stabile. Controlați colapsul prin monitorizarea entropiei greutăților alfa.
- Transfer între seturi de date: o arhitectură optimă pe CIFAR-10 nu este neapărat optim pe ImageNet sau seturi de date medicale. Faceți cercetarea setului de date final.
- Sarcini proxy nesigure: utilizați o sarcină proxy mai simplă (de exemplu, CIFAR în loc de ImageNet) pentru a reduce costul poate duce la clasamente incorecte. Mereu valabil arhitectura găsită pe sarcina reală.
Comparație arhitecturii: NAS vs manuale pe benchmark-uri standard
| Arhitectură | Metodă | ImageNet Top-1 | Parametrii | FLOP-uri | Costul de căutare |
|---|---|---|---|---|---|
| ResNet-50 | Manual | 76,1% | 25,6 milioane | 4.1G | N / A |
| MobileNetV3-Large | NAS + manual | 75,2% | 5,4 milioane | 0,22G | ~1000 GPU-h |
| EfficientNet-B0 | NAS (MnasNet) | 77,1% | 5,3 milioane | 0,39G | ~6000 GPU-h |
| NASNet-A Mobile | RL-NAS | 74,0% | 5,3 milioane | 0,56G | 400 de zile GPU |
| DARTS (ordinea a 2-a) | DARTS | 73,3% | 4,7 milioane | 0,6G | 4 GPU-zile |
| OFA-595M (RPi) | OFA One-Shot | 76,0% | ~4,5 milioane | 0,6G | <1 GPU-h post OFA |
Cele mai bune practici pentru NAS în producție
Când să utilizați NAS și cum să o faceți corect
- Utilizați reglarea fină înainte de NAS: adesea un ViT-B sau EfficientNet-B4 pre-antrenat depășește o arhitectură NAS găsită de la zero. Utilizați NAS atunci când sarcina este foarte specifică (hardware țintă fix, domeniu foarte diferit de ImageNet, constrângeri hardware stricte).
- Optează Bayesian pentru hiperparametru: chiar și fără arhitectură de căutare, Optuna TPE pentru LR, dimensiunea lotului, mărire și optimizare și adesea mai eficient decât GridSearch și necesită de 3-5 ori mai puține încercări. Și primul pas de făcut înainte de NAS complet.
- Conștient de hardware de la început: includeți latența/FLOP-urile în funcție obiectiv din prima încercare. Un model care este cu 1% mai precis, dar de două ori mai lent nu este util pentru implementare în timp real pe dispozitive de vârf.
- Oprire precoce agresivă: utilizați MedianPruner al lui Optuna. Eliminați 30-40% de încercări nepromițătoare în primele perioade, reducând costul total de 2-3x.
- Paralelizați pe mai multe GPU-uri: Optuna acceptă paralelizarea nativă prin baza de date partajată (SQLite sau PostgreSQL). 4 GPU-uri reduc timpul de 3,5 ori fără modificări de cod.
- Salvați punctele de control pentru arhitectură: după căutare, salvați nu numai ponderile dar și specificația arhitecturii (genotip în DARTS, config dict în Optuna). Acest lucru vă permite să reconstruiți modelul fără a reface căutarea.
Concluzii
Neural Architecture Search s-a maturizat semnificativ din 2017 până în prezent. Din algoritmi care au necesitat 400 de zile GPU pentru instrumente practice care rulează în ore pe un singur GPU, pe teren a făcut ca proiectarea arhitecturii automate să fie accesibilă practicienilor. În 2026, fluxul de lucru cele mai eficiente combine: spatiu de cautare bine definit, Optuna cu taiere agresiva pt hiperparametru și obiective conștiente de hardware pentru implementare optimizată.
Pentru majoritatea proiectelor, utilizarea arhitecturilor preexistente (ViT, Swin, EfficientNet) cu reglajul fin rămâne mai eficient decât NAS de la zero. Dar când sarcina are cerințe hardware foarte specific - latență sub 5 ms pe Raspberry Pi, model sub 1 MB pentru microcontroler, clasificare medicală specializată — NAS-ul hardware-aware devine instrumentul indispensabil.
Tendința către edge computing amplifică și mai mult valoarea NAS: cu Gartner asta se așteaptă ca SLM-urile să depășească de trei ori performanța LLM-urilor în cloud până în 2027, să optimizeze arhitecturile pentru hardware-ul specific nu mai este un lux academic, ci o necesitate practică.
Următorii pași
- Articolul următor: Distilarea cunoștințelor: comprimarea modelelor complexe
- Înrudit: Vision Transformer: Arhitectură și aplicații
- Înrudit: LLM pe dispozitive Edge: Raspberry Pi și Jetson
- Seria MLOps: Urmărirea experimentelor cu MLflow și Optuna
- Seria de inginerie AI: Optimizarea modelului pentru producție







