신경망 아키텍처 검색 및 AutoML: 네트워크 설계 자동화
신경망 아키텍처를 설계하는 것은 전통적으로 다음이 필요한 수동 프로세스입니다. 실험을 위한 수년간의 전문 지식, 직관 및 계산 자원. ResNet, EfficientNet, MobileNet - 이러한 상징적인 아키텍처 각각은 인간의 디자인 선택의 결과입니다. 깊은 정보를 얻었습니다. 그러나, 신경망 아키텍처 검색(NAS), 이 프로세스는 자동화될 수 있습니다. 계산 예산과 작업, 알고리즘이 주어지면 가능한 아키텍처의 공간을 탐색하고 최적의 아키텍처를 식별합니다.
실용적이고 놀라운 결과. EfficientNet — 아마도 가장 영향력 있는 CNN 계열일 것입니다. 최근 몇 년 동안 — NAS를 통해 발견되었습니다. NASNet, DARTS, Once-for-All 및 기타 모델 NAS는 표준화된 벤치마크에서 지속적으로 수동으로 설계된 아키텍처를 능가했습니다. 하지만 진정한 혁명은 민주화입니다. 옵투나, 레이 튠, 에나스 e timm, 오늘은 할 수 있습니다 몇 시간 만에 소비자 GPU에 NAS를 설치하여 특정 하드웨어에 맞게 아키텍처를 최적화합니다.
이 가이드에서는 GridSearch에서 DARTS까지 NAS 기술을 처음부터 살펴봅니다. 딥 러닝 아키텍처를 최적화하기 위해 Optuna를 사용하여 실용적인 AutoML 파이프라인을 구축합니다.
무엇을 배울 것인가
- NAS란 무엇이며 왜 수동 설계를 능가합니까?
- 검색 공간: 마이크로(셀 기반) 대 매크로(레이어 기반)
- 검색 전략: 무작위 검색, RL, Evolutionary, DARTS
- One-Shot NAS 및 가중치 공유: 비용을 몇 년에서 몇 시간으로 줄이는 방법
- Optuna를 사용한 NAS 구현: 하이퍼파라미터 + 아키텍처 검색
- 전체 PyTorch를 사용한 DARTS(미분 아키텍처 검색)
- 일회성 네트워크: 이기종 하드웨어를 위한 아키텍처
- 하드웨어 인식 NAS: 대기 시간, FLOP 및 매개변수 최적화
- 에지 장치용 AutoKeras 및 NAS가 포함된 AutoML
- 실제 사례 연구: Jetson Nano의 의료 분류를 위한 NAS
NAS가 수동 설계를 능가하는 이유
수동 아키텍처 설계에는 세 가지 구조적 한계가 있습니다. 첫째,전문적 지식 편견: 연구자들은 익숙한 패턴을 재사용하는 경향이 있습니다(ResBlocks, 연결 건너뛰기). 특정 작업에 최적이 아닌 경우에도 마찬가지입니다. 둘째,하드웨어 불일치: A100의 최적 아키텍처는 Cortex-A55에서는 거의 없습니다. 셋째, 조합 폭발: 가능한 아키텍처의 공간 e 천문학적으로 큰 — 8개 레이어에 걸쳐 레이어 수, 채널 및 커널 크기를 변경하는 것만으로도 10^14개 이상의 구성을 얻게 됩니다.
NAS는 설계 문제를 공식적으로 정의하여 이러한 문제를 해결합니다.
# 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
NAS 연구 공간
NAS의 핵심과 NAS의 정의 검색 공간: 모두의 집합 알고리즘이 탐색할 수 있는 가능한 아키텍처. 검색 공간의 선택 그것은 기본적입니다. 너무 좁으면 최적을 달성할 수 없고, 너무 넓으면 검색이 불가능합니다. 계산적으로 다루기가 어려워집니다.
두 가지 주요 접근 방식이 있습니다.
- 셀 기반 NAS(마이크로 검색 공간): 우리는 최적의 구조를 찾습니다. 단일 셀(cell)로 만든 후, 해당 셀을 여러 번 복제하여 네트워크를 구축합니다. 이 유연성을 유지하면서 검색 공간을 대폭 줄입니다. NASNet, DARTS, ENAS에서 사용됩니다.
- 매크로 검색 공간: 다음과 같은 전역 아키텍처 매개변수를 찾고 있습니다. 레이어 수, 채널 크기, 연결 유형. EfficientNet(NAS + 스케일링)에서 사용됩니다. MobileNet v3, 일회성.
# 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!
연구 전략
검색 전략은 알고리즘이 아키텍처 공간을 탐색하는 방법을 결정합니다. 전략의 선택은 검색 공간 자체만큼 중요합니다.
| 전략 | 접근하다 | 찬성 | 에 맞서 | 일반적인 비용 |
|---|---|---|---|---|
| 무작위 검색 | 무작위 샘플링 | 단순하고 강력한 기준선 | 무능한 | N * 훈련 완료 |
| 그리드 검색 | 철저한 그리드 | 공간이 작으면 완성 | 기하급수적인 크기 | K^D * 훈련 |
| 베이지안 옵션 | 대리모델 + 획득 | 효율적이고 안내됨 | 넓은 공간에는 비싸다 | 50-200 시험 |
| RL (NASNet) | RNN 컨트롤러 | 복잡한 아키텍처 | 원래 GPU 일수 400일 | 1000회 이상의 시도 |
| 진화론적 | 유전 알고리즘 | 즐거운 탐험 | 매우 느림 | 500회 이상의 시도 |
| 다트 | 지속적인 차별화 | 1~4 GPU일, 최적 | 메모리 집약적 | 1 훈련 주기 |
| 원샷 / ENAS | 체중분배 슈퍼넷 | 단일 GPU에서의 시간 | 순위 근사치 | 1개의 슈퍼넷 + 샘플링 |
Optuna를 갖춘 실용적인 NAS
옵투나 하이퍼파라미터 및 아키텍처 검색에 가장 많이 사용되는 라이브러리입니다. 베이지안 샘플링(TPE), 유망하지 않은 실험 정리 및 API를 결합합니다. 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: 미분 가능한 아키텍처 검색
다트 (Liu et al., 2019) 및 가장 우아한 NAS 알고리즘 중 하나 효과적이다. 핵심 아이디어: 작업 선택 연속적이고 미분 가능한, 이산 검색 대신 경사하강법을 사용하여 아키텍처를 최적화할 수 있습니다.
DARTS 셀에서 노드 사이의 각 가장자리에는 부드러운 혼합물 가능한 모든 것의
작업(전환 3x3, 전환 5x5, 건너뛰기, 풀). 혼합 가중치(아키텍처 매개변수
alpha)은 모델 가중치와 함께 경사하강법을 사용하여 최적화됩니다.
결국, 각 간선에 대해 가장 높은 가중치를 갖는 연산을 선택합니다(이산화).
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와 One-Shot NAS: 비교
| 나는 기다린다 | 다트 | 원샷(ENAS, OFA) |
|---|---|---|
| 연구비 | 1~4 GPU일 | 시간(교육 후 슈퍼넷) |
| 건축적 품질 | 매우 높음 | 높음(약간의 근사치) |
| 대상 하드웨어 | 단일 대상 | 다중 대상(OFA) |
| GPU 메모리 | 높음(이중 수준 선택) | 평균 |
| 구현 | 복잡한 | 보통의 |
Optuna 및 대기 시간 제약이 있는 하드웨어 인식 NAS
이론적 NAS는 최대의 정확성을 추구합니다. 실용적인 NAS는 하나를 최적화합니다 다중 목표 절충 정확도, 대기 시간, FLOP 및 모델 크기 사이. Optuna는 기본적으로 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}")
한번에 해결되는: 이기종 하드웨어를 위한 NAS
OFA(일회성) MIT의 근본적인 실무 문제인 교육을 해결합니다. 각 대상 및 금지 장치에 대해 별도의 네트워크. OFA는 단일 교육 슈퍼넷 수천 개의 하위 아키텍처를 지원하고 진화된 아키텍처를 사용합니다. 각 장치에 대한 최적의 하위 아키텍처를 찾기 위한 빠른 검색입니다.
OFA 교육은 다음을 사용합니다. 점진적인 수축: 슈퍼넷이 훈련됩니다. 최대 구성부터 시작하여 치수가 점진적으로 줄어듭니다. (첫 번째 커널 크기, 그 다음 깊이, 마지막으로 너비). 그러면 공유 가중치 세트가 생성됩니다. 모든 구성에서 잘 작동합니다.
# 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()):,}")
AutoKeras를 사용한 엔드투엔드 AutoML
NAS를 처음부터 구축할 시간이 없으신 분들을 위해, AutoKeras 제안 건축 검색을 자동으로 처리하는 세계적 수준의 API, 전처리 및 하이퍼파라미터 튜닝. 내부적으로는 알고리즘과 함께 Keras Tuner를 사용합니다. 베이지안 및 무작위 검색이 가능하며 배포를 위해 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")
사례 연구: Jetson Nano의 의료 분류를 위한 NAS
실제 사례를 통해 하드웨어 인식 NAS의 실질적인 가치가 명확해졌습니다. 프로젝트에서는 피부경 이미지 분류(8개 등급의 피부 병변) 엔비디아 젯슨 나노, 제약 조건은 다음과 같습니다. 이미지당 대기 시간이 100ms 미만이고, 정확도는 88% 이상, 모델은 10MB 미만입니다. 표준 아키텍처가 만족하지 못함 모든 제약 조건을 동시에 충족합니다.
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.")
NAS의 한계와 함정
- 검색 공간 과적합: 발견된 아키텍처가 잘 작동함 연구를 위해 선택된 벤치마크에 적용되지만 다른 데이터세트에는 제대로 일반화되지 않을 수 있습니다. 항상 검색 중에 사용되지 않은 독립적인 홀드아웃 세트를 평가하세요.
- 숨겨진 계산 비용: DARTS에는 1~4일의 GPU가 필요하지만 교육은 필요합니다. 발견된 아키텍처로 완성되면 더 많은 GPU 시간이 추가됩니다. 총 비용과 자주 좋은 수동 아키텍처를 교육하는 것의 2~3배입니다.
- DARTS 불안정성: 기존 DARTS는 훈련 불안정으로 어려움을 겪고 있습니다. 연결 건너뛰기로 축소되는 경향이 있습니다. 보다 안정적인 결과를 얻으려면 DARTS+ 또는 R-DARTS를 사용하세요. 알파 가중치의 엔트로피를 모니터링하여 붕괴를 제어합니다.
- 데이터 세트 간 전송: CIFAR-10의 최적 아키텍처는 ImageNet 또는 의료 데이터세트에서는 반드시 최적입니다. 최종 데이터 세트에 대한 조사를 수행합니다.
- 신뢰할 수 없는 프록시 작업: 더 간단한 프록시 작업을 사용합니다(예: CIFAR ImageNet 대신) 비용을 줄이기 위해 순위가 잘못될 수 있습니다. 항상 유효함 실제 작업에서 찾은 아키텍처입니다.
아키텍처 비교: 표준 벤치마크에 대한 NAS와 매뉴얼
| 건축학 | 방법 | ImageNet 상위 1위 | 매개변수 | FLOP | 검색 비용 |
|---|---|---|---|---|---|
| ResNet-50 | 수동 | 76.1% | 25.6M | 4.1G | 해당 없음 |
| MobileNetV3-대형 | NAS + 매뉴얼 | 75.2% | 540만 | 0.22G | ~1000 GPU-h |
| EfficientNet-B0 | NAS(엠나스넷) | 77.1% | 530만 | 0.39G | ~6000 GPU-h |
| NASNet-A 모바일 | RL-NAS | 74.0% | 530만 | 0.56G | 400 GPU-일 |
| 다트(2차) | 다트 | 73.3% | 470만 | 0.6G | 4 GPU일 |
| OFA-595M(RPi) | OFA 원샷 | 76.0% | ~450만 | 0.6G | <1 GPU-h 포스트 OFA |
프로덕션 NAS 모범 사례
NAS를 사용해야 하는 경우와 올바르게 사용하는 방법
- NAS 이전에 미세 조정을 사용하십시오. 종종 사전 훈련된 ViT-B 또는 EfficientNet-B4 처음부터 찾아낸 NAS 아키텍처를 능가합니다. 작업이 매우 구체적일 때는 NAS를 사용하세요 (고정 대상 하드웨어, ImageNet과 매우 다른 도메인, 엄격한 하드웨어 제약 조건)
- 하이퍼파라미터에 베이지안 선택: 검색 아키텍처가 없더라도 LR, 배치 크기, 확장 및 최적화를 위한 Optuna TPE이며 종종 GridSearch보다 더 효과적입니다. 3~5배 더 적은 시도가 필요합니다. 그리고 Full NAS 이전에 취해야 할 첫 번째 단계입니다.
- 처음부터 하드웨어 인식: 함수에 대기 시간/FLOP를 포함합니다. 첫 번째 재판부터 목표. 1% 더 정확하지만 2배 느린 모델은 유용하지 않습니다. 에지 장치에 실시간 배포를 위한 것입니다.
- 적극적인 조기 중단: Optuna의 MedianPruner를 사용하세요. 30~40% 제거 초기 단계에서 성공 가능성이 없는 시험을 줄여 총 비용을 2~3배 절감합니다.
- 여러 GPU에 걸쳐 병렬화: Optuna는 기본 병렬화를 지원합니다. 공유 데이터베이스(SQLite 또는 PostgreSQL)를 통해. 4개의 GPU로 시간을 3.5배 단축 코드 변경 없이.
- 아키텍처 체크포인트 저장: 검색 후 저장 뿐만 아니라 가중치뿐만 아니라 아키텍처 사양(DARTS의 유전자형, Optuna의 config dict)도 있습니다. 이를 통해 검색을 다시 실행하지 않고도 모델을 다시 작성할 수 있습니다.
결론
신경망 아키텍처 검색은 2017년부터 현재까지 크게 발전했습니다. 알고리즘에서 단일 GPU에서 몇 시간 안에 실행되는 실용적인 도구를 사용하려면 400일의 GPU가 필요합니다. 실무자가 자동 아키텍처 설계에 접근할 수 있게 되었습니다. 2026년에는 워크플로 가장 효과적인 결합: 잘 정의된 검색 공간, 공격적인 가지치기 기능을 갖춘 Optuna 최적화된 배포를 위한 하이퍼파라미터 및 하드웨어 인식 목표.
대부분의 프로젝트에서는 기존 아키텍처(ViT, Swin, EfficientNet)를 사용합니다. 미세 조정을 통해 처음부터 NAS보다 더 효율적인 상태를 유지합니다. 하지만 작업에 하드웨어 요구 사항이 있는 경우 매우 구체적임 - Raspberry Pi의 대기 시간은 5ms 미만, 마이크로 컨트롤러의 경우 1MB 미만 모델, 전문적인 의료 분류 - 하드웨어 인식 NAS가 필수 도구가 되었습니다.
엣지 컴퓨팅을 향한 추세는 NAS의 가치를 더욱 증폭시킵니다. Gartner는 2027년까지 SLM이 클라우드 LLM보다 3배 뛰어난 성능을 발휘할 것으로 예상하고 아키텍처를 최적화합니다. 특정 하드웨어는 더 이상 학문적 사치가 아니라 실질적인 필수품입니다.
다음 단계
- 다음 기사: 지식 증류: 복잡한 모델 압축
- 관련된: Vision Transformer: 아키텍처 및 애플리케이션
- 관련된: 엣지 장치의 LLM: Raspberry Pi 및 Jetson
- MLOps 시리즈: MLflow 및 Optuna를 사용한 실험 추적
- AI 엔지니어링 시리즈: 생산을 위한 모델 최적화







