ニューラル アーキテクチャの検索と AutoML: ネットワーク設計の自動化
ニューラル ネットワーク アーキテクチャの設計は従来、手動のプロセスでした。 長年にわたる専門知識、直感、実験のための計算リソース。 ResNet、EfficientNet、 MobileNet — これらの象徴的なアーキテクチャはそれぞれ人間の設計上の選択の結果です 深く知っています。それでも、 ニューラル アーキテクチャ検索 (NAS)、 このプロセスは自動化できます。計算予算とタスク、アルゴリズムが与えられれば、 可能なアーキテクチャの空間を探索し、最適なアーキテクチャを特定します。
実用的かつ驚くべき結果。 EfficientNet — おそらく最も影響力のある CNN ファミリー 近年 — NAS 経由で発見されました。 NASNet、DARTS、ワンスフォーオールなどのモデル NAS は、標準化されたベンチマークで手動で設計されたアーキテクチャを常に上回っています。 しかし、本当の革命は民主化です。次のようなツールを使用します。 オプチュナ, レイチューン, エナス e ティム、今日ではそれが可能です コンシューマー向け GPU 上で NAS を数時間で構築し、特定のハードウェアに合わせてアーキテクチャを最適化します。
このガイドでは、GridSearch から DARTS まで、NAS 技術を基礎から探求します。 また、Optuna を使用して実用的な AutoML パイプラインを構築し、ディープ ラーニング アーキテクチャを最適化します。
何を学ぶか
- NAS とは何か、そしてなぜ手動設計を超えるのか
- サーチ スペース: ミクロ (セルベース) とマクロ (レイヤーベース)
- 検索戦略: ランダム検索、RL、進化的、DARTS
- ワンショット NAS とウェイト シェアリング: コストを数年から数時間に削減する方法
- Optuna を使用した NAS 実装: ハイパーパラメータ + アーキテクチャ検索
- 完全な PyTorch を使用した微分可能アーキテクチャ検索 (DARTS)
- Once-for-All ネットワーク: 異種ハードウェアのアーキテクチャ
- ハードウェア対応 NAS: 遅延、FLOP、パラメータを最適化
- AutoML と AutoKeras およびエッジデバイス用の NAS
- 実際のケーススタディ: Jetson Nano での医療分類のための NAS
NAS が手動設計を超える理由
手動によるアーキテクチャ設計には 3 つの構造上の制限があります。まず、専門知識 偏見: 研究者はよく知られたパターン (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 の心臓部とその定義 サーチスペース: すべてのセット アルゴリズムが探索できる可能なアーキテクチャ。検索スペースの選択 それは基本的なことです - 狭すぎると最適は達成できませんが、広すぎると検索は困難です 計算的に扱いにくくなる。
主なアプローチは 2 つあります。
- セルベースの NAS (マイクロ検索スペース): の最適な構造を探索します。 単一のセル(セル)の場合、そのセルが複数回複製されてネットワークが構築されます。これ 柔軟性を維持しながら検索スペースを大幅に削減します。 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 コントローラー | 複雑なアーキテクチャ | オリジナルの 400 GPU 日 | 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 アルゴリズムの 1 つ 効果的です。重要なアイデア: 操作を選択する 連続的かつ微分可能、 これにより、離散検索の代わりに勾配降下法を使用してアーキテクチャを最適化できます。
DARTS セルでは、ノード間の各エッジに 柔らかい混合物 可能な限りの
オペレーション (conv 3x3、conv 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 とワンショット NAS: 比較
| 待ってます | ダーツ | ワンショット (ENAS、OFA) |
|---|---|---|
| 研究費 | 1 ~ 4 GPU 日 | 時間(トレーニング後のスーパーネット) |
| 建築品質 | 非常に高い | 高 (わずかに近似) |
| ターゲットハードウェア | 単一のターゲット | マルチターゲット (OFA) |
| GPUメモリ | 高 (バイレベルオプション) | 平均 |
| 実装 | 複雑な | 適度 |
Optuna と遅延制約を備えたハードウェア対応 NAS
理論上の NAS は最大の精度を追求します。実用的なNASは1台を最適化します 多目的のトレードオフ 精度、レイテンシー、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種類) NVIDIA Jetson Nano、制約は次のとおりです: レイテンシは画像ごとに 100 ミリ秒未満、 精度は 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+ または R-DARTS を使用してください。 アルファ重みのエントロピーを監視することで崩壊を制御します。
- データセット間の転送: CIFAR-10 の最適なアーキテクチャは存在しません。 ImageNet または医療データセットには必然的に最適です。最終的なデータセットについて調査を行います。
- 信頼性の低いプロキシ タスク: より単純なプロキシ タスクを使用します (CIFAR など) ImageNet の代わりに) コストを削減しようとすると、不正確なランキングが発生する可能性があります。常に有効 実際のタスクで見つかったアーキテクチャ。
アーキテクチャの比較: 標準ベンチマークに関する NAS とマニュアル
| 建築 | 方法 | イメージネットトップ1 | パラメータ | フロップス | 検索コスト |
|---|---|---|---|---|---|
| レスネット-50 | マニュアル | 76.1% | 25.6M | 4.1G | 該当なし |
| MobileNetV3-Large | NAS+マニュアル | 75.2% | 5.4M | 0.22G | ~1000 GPU-h |
| EfficientNet-B0 | NAS(エムナスネット) | 77.1% | 5.3M | 0.39G | ~6000 GPU-h |
| NASNet-A モバイル | RL-NAS | 74.0% | 5.3M | 0.56G | 400 GPU 日 |
| ダーツ(2次オーダー) | ダーツ | 73.3% | 4.7M | 0.6G | 4 GPU 日 |
| OFA-595M(RPi) | OFA ワンショット | 76.0% | ~450万 | 0.6G | OFA 後 <1 GPU-h |
実稼働環境における NAS のベスト プラクティス
NAS を使用するタイミングと正しい使い方
- NAS の前に微調整を使用します。 多くの場合、事前トレーニングされた ViT-B または EfficientNet-B4 ゼロから見つけた NAS アーキテクチャを超えます。タスクが非常に特殊な場合は NAS を使用する (固定ターゲット ハードウェア、ImageNet とは大きく異なるドメイン、厳しいハードウェア制約)。
- ハイパーパラメータにベイジアンを選択します。 検索アーキテクチャがなくても、 Optuna TPE は LR、バッチ サイズ、拡張機能、オプティマイザーに対応しており、多くの場合 GridSearch よりも効果的です 必要な試行回数は 3 ~ 5 倍少なくなります。そして完全な NAS の前に取るべき最初のステップ。
- 最初からハードウェアを意識: 関数にレイテンシー/FLOP を含める 最初のトライアルからの目標。精度が 1% 高いが 2 倍遅いモデルは役に立ちません エッジデバイスでのリアルタイム展開用。
- 積極的な早期停止: Optuna の MedianPruner を使用します。 30~40%を排除 初期の見込みのない試験を削減し、総コストを 2 ~ 3 倍に削減します。
- 複数の GPU 間で並列化します。 Optuna はネイティブ並列化をサポートします 共有データベース (SQLite または PostgreSQL) 経由。 4 つの GPU により時間を 3.5 倍に短縮 コード変更なしで。
- アーキテクチャのチェックポイントを保存します。 検索後は保存するだけでなく、 重みだけでなく、アーキテクチャ仕様も含まれます (DARTS では遺伝子型、Optuna では config dict)。 これにより、検索をやり直すことなくモデルを再構築できます。
結論
Neural Architecture Search は、2017 年から今日までに大幅に成熟しました。アルゴリズムから 単一の GPU で数時間で実行できる実用的なツールを開発するには、400 GPU 日が必要でした。 自動アーキテクチャ設計を実務者が利用できるようにしました。 2026 年のワークフローは 最も効果的な組み合わせ: 明確に定義された検索スペース、積極的なプルーニングを備えた Optuna ハイパーパラメータと、最適化された展開のためのハードウェア認識の目標。
ほとんどのプロジェクトでは、既存のアーキテクチャ (ViT、Swin、EfficientNet) を使用します。 微調整を行っても、最初から NAS を作成するよりも効率が高くなります。ただし、タスクにハードウェア要件がある場合は、 非常に具体的 — Raspberry Pi ではレイテンシが 5ms 未満、マイクロコントローラーのモデルが 1MB 未満、 専門的な医療分類 — ハードウェア対応の NAS は不可欠なツールになります。
エッジ コンピューティングへの傾向は、NAS の価値をさらに高めます。Gartner は次のように述べています。 は、SLM が 2027 年までにクラウド LLM を 3 倍上回るパフォーマンスを発揮すると予想し、アーキテクチャを最適化します。 特定のハードウェアはもはや学術的な贅沢品ではなく、実用的な必需品です。
次のステップ
- 次の記事: 知識の蒸留: 複雑なモデルの圧縮
- 関連している: ビジョン トランスフォーマー: アーキテクチャとアプリケーション
- 関連している: エッジデバイス上の LLM: Raspberry Pi および Jetson
- MLOps シリーズ: MLflow と Optuna を使用した実験の追跡
- AIエンジニアリングシリーズ: 実稼働向けのモデルの最適化







